diff --git a/.eslintignore b/.eslintignore index c4f7298047..e453170087 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,4 @@ src/component-index.js test/end-to-end-tests/node_modules/ -test/end-to-end-tests/riot/ +test/end-to-end-tests/element/ test/end-to-end-tests/synapse/ diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles deleted file mode 100644 index db90d26ba7..0000000000 --- a/.eslintignore.errorfiles +++ /dev/null @@ -1,16 +0,0 @@ -# autogenerated file: run scripts/generate-eslint-error-ignore-file to update. - -src/Markdown.js -src/Velociraptor.js -src/components/structures/RoomDirectory.js -src/components/views/rooms/MemberList.js -src/ratelimitedfunc.js -src/utils/DMRoomMap.js -src/utils/MultiInviter.js -test/components/structures/MessagePanel-test.js -test/components/views/dialogs/InteractiveAuthDialog-test.js -test/mock-clock.js -src/component-index.js -test/end-to-end-tests/node_modules/ -test/end-to-end-tests/riot/ -test/end-to-end-tests/synapse/ diff --git a/.eslintrc.js b/.eslintrc.js index bc2a142c2d..827b373949 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,7 +1,9 @@ module.exports = { - extends: ["matrix-org", "matrix-org/react-legacy"], - parser: "babel-eslint", - + plugins: ["matrix-org"], + extends: [ + "plugin:matrix-org/babel", + "plugin:matrix-org/react", + ], env: { browser: true, node: true, @@ -15,20 +17,64 @@ module.exports = { "prefer-promise-reject-errors": "off", "no-async-promise-executor": "off", "quotes": "off", - "indent": "off", - }, + "no-extra-boolean-cast": "off", + // Bind or arrow functions in props causes performance issues (but we + // currently use them in some places). + // It's disabled here, but we should using it sparingly. + "react/jsx-no-bind": "off", + "react/jsx-key": ["error"], + + "no-restricted-properties": [ + "error", + ...buildRestrictedPropertiesOptions( + ["window.innerHeight", "window.innerWidth", "window.visualViewport"], + "Use UIStore to access window dimensions instead.", + ), + ...buildRestrictedPropertiesOptions( + ["*.mxcUrlToHttp", "*.getHttpUriForMxc"], + "Use Media helper instead to centralise access for customisation.", + ), + ], + }, overrides: [{ - "files": ["src/**/*.{ts,tsx}"], - "extends": ["matrix-org/ts"], - "rules": { + files: [ + "src/**/*.{ts,tsx}", + "test/**/*.{ts,tsx}", + ], + extends: [ + "plugin:matrix-org/typescript", + "plugin:matrix-org/react", + ], + rules: { + // Things we do that break the ideal style + "prefer-promise-reject-errors": "off", + "quotes": "off", + "no-extra-boolean-cast": "off", + + // Remove Babel things manually due to override limitations + "@babel/no-invalid-this": ["off"], + + // We're okay being explicit at the moment + "@typescript-eslint/no-empty-interface": "off", // We disable this while we're transitioning "@typescript-eslint/no-explicit-any": "off", // We'd rather not do this but we do "@typescript-eslint/ban-ts-comment": "off", - - "quotes": "off", - "no-extra-boolean-cast": "off", }, }], }; + +function buildRestrictedPropertiesOptions(properties, message) { + return properties.map(prop => { + let [object, property] = prop.split("."); + if (object === "*") { + object = undefined; + } + return { + object, + property, + message, + }; + }); +} diff --git a/.flowconfig b/.flowconfig deleted file mode 100644 index 81770c6585..0000000000 --- a/.flowconfig +++ /dev/null @@ -1,6 +0,0 @@ -[include] -src/**/*.js -test/**/*.js - -[ignore] -node_modules/ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..e9ede862d2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ + + + + + diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml new file mode 100644 index 0000000000..0ae59da09a --- /dev/null +++ b/.github/workflows/develop.yml @@ -0,0 +1,44 @@ +name: Develop +on: + # These tests won't work for non-develop branches at the moment as they + # won't pull in the right versions of other repos, so they're only enabled + # on develop. + push: + branches: [develop] + pull_request: + branches: [develop] +jobs: + end-to-end: + runs-on: ubuntu-latest + container: vectorim/element-web-ci-e2etests-env:latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Prepare End-to-End tests + run: ./scripts/ci/prepare-end-to-end-tests.sh + - name: Run End-to-End tests + run: ./scripts/ci/run-end-to-end-tests.sh + - name: Archive logs + uses: actions/upload-artifact@v2 + if: ${{ always() }} + with: + path: | + test/end-to-end-tests/logs/**/* + test/end-to-end-tests/synapse/installations/consent/homeserver.log + retention-days: 14 + - name: Download previous benchmark data + uses: actions/cache@v1 + with: + path: ./cache + key: ${{ runner.os }}-benchmark + - name: Store benchmark result + uses: matrix-org/github-action-benchmark@jsperfentry-1 + with: + tool: 'jsperformanceentry' + output-file-path: test/end-to-end-tests/performance-entries.json + fail-on-alert: false + comment-on-alert: false + # Only temporary to monitor where failures occur + alert-comment-cc-users: '@gsouquet' + github-token: ${{ secrets.DEPLOY_GH_PAGES }} + auto-push: ${{ github.ref == 'refs/heads/develop' }} diff --git a/.gitignore b/.gitignore index 33e8bfc7ac..102f4b5ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /*.log package-lock.json +/coverage /node_modules /lib @@ -13,3 +14,7 @@ package-lock.json /src/component-index.js .DS_Store +*.tmp + +.vscode +.vscode/ diff --git a/.stylelintrc.js b/.stylelintrc.js index 313102ea83..0e6de7000f 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -4,6 +4,7 @@ module.exports = { "stylelint-scss", ], "rules": { + "color-hex-case": null, "indentation": 4, "comment-empty-line-before": null, "declaration-empty-line-before": null, diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5372ae5d..22b35b7c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,1860 @@ +Changes in [3.25.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0) (2021-07-05) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0-rc.1...v3.25.0) + + * Remove reminescent references to the tinter + [\#6316](https://github.com/matrix-org/matrix-react-sdk/pull/6316) + * Update to released version of js-sdk + +Changes in [3.25.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0-rc.1) (2021-06-29) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0...v3.25.0-rc.1) + + * Update to js-sdk v12.0.1-rc.1 + * Translations update from Weblate + [\#6286](https://github.com/matrix-org/matrix-react-sdk/pull/6286) + * Fix back button on user info card after clicking a permalink + [\#6277](https://github.com/matrix-org/matrix-react-sdk/pull/6277) + * Group ACLs with MELS + [\#6280](https://github.com/matrix-org/matrix-react-sdk/pull/6280) + * Fix editState not getting passed through + [\#6282](https://github.com/matrix-org/matrix-react-sdk/pull/6282) + * Migrate message context menu to IconizedContextMenu + [\#5671](https://github.com/matrix-org/matrix-react-sdk/pull/5671) + * Improve audio recording performance + [\#6240](https://github.com/matrix-org/matrix-react-sdk/pull/6240) + * Fix multiple timeline panels handling composer and edit events + [\#6278](https://github.com/matrix-org/matrix-react-sdk/pull/6278) + * Let m.notice messages mark a room as unread + [\#6281](https://github.com/matrix-org/matrix-react-sdk/pull/6281) + * Removes the override on the Bubble Container + [\#5953](https://github.com/matrix-org/matrix-react-sdk/pull/5953) + * Fix IRC layout regressions + [\#6193](https://github.com/matrix-org/matrix-react-sdk/pull/6193) + * Fix trashcan.svg by exporting it with its viewbox + [\#6248](https://github.com/matrix-org/matrix-react-sdk/pull/6248) + * Fix tiny scrollbar dot on chrome/electron in Forward Dialog + [\#6276](https://github.com/matrix-org/matrix-react-sdk/pull/6276) + * Upgrade puppeteer to use newer version of Chrome + [\#6268](https://github.com/matrix-org/matrix-react-sdk/pull/6268) + * Make toast dismiss button less prominent + [\#6275](https://github.com/matrix-org/matrix-react-sdk/pull/6275) + * Encrypt the voice message file if needed + [\#6269](https://github.com/matrix-org/matrix-react-sdk/pull/6269) + * Fix hyper-precise presence + [\#6270](https://github.com/matrix-org/matrix-react-sdk/pull/6270) + * Fix issues around private spaces, including previewable + [\#6265](https://github.com/matrix-org/matrix-react-sdk/pull/6265) + * Make _pinned messages_ in `m.room.pinned_events` event clickable + [\#6257](https://github.com/matrix-org/matrix-react-sdk/pull/6257) + * Fix space avatar management layout being broken + [\#6266](https://github.com/matrix-org/matrix-react-sdk/pull/6266) + * Convert EntityTile, MemberTile and PresenceLabel to TS + [\#6251](https://github.com/matrix-org/matrix-react-sdk/pull/6251) + * Fix UserInfo not working when rendered without a room + [\#6260](https://github.com/matrix-org/matrix-react-sdk/pull/6260) + * Update membership reason handling, including leave reason displaying + [\#6253](https://github.com/matrix-org/matrix-react-sdk/pull/6253) + * Consolidate types with js-sdk changes + [\#6220](https://github.com/matrix-org/matrix-react-sdk/pull/6220) + * Fix edit history modal + [\#6258](https://github.com/matrix-org/matrix-react-sdk/pull/6258) + * Convert MemberList to TS + [\#6249](https://github.com/matrix-org/matrix-react-sdk/pull/6249) + * Fix two PRs duplicating the css attribute + [\#6259](https://github.com/matrix-org/matrix-react-sdk/pull/6259) + * Improve invite error messages in InviteDialog for room invites + [\#6201](https://github.com/matrix-org/matrix-react-sdk/pull/6201) + * Fix invite dialog being cut off when it has limited results + [\#6256](https://github.com/matrix-org/matrix-react-sdk/pull/6256) + * Fix pinning event in a room which hasn't had events pinned in before + [\#6255](https://github.com/matrix-org/matrix-react-sdk/pull/6255) + * Allow modal widget buttons to be disabled when the modal opens + [\#6178](https://github.com/matrix-org/matrix-react-sdk/pull/6178) + * Decrease e2e shield fill mask size so that it doesn't overlap + [\#6250](https://github.com/matrix-org/matrix-react-sdk/pull/6250) + * Dial Pad UI bug fixes + [\#5786](https://github.com/matrix-org/matrix-react-sdk/pull/5786) + * Simple handling of mid-call output changes + [\#6247](https://github.com/matrix-org/matrix-react-sdk/pull/6247) + * Improve ForwardDialog performance by using TruncatedList + [\#6228](https://github.com/matrix-org/matrix-react-sdk/pull/6228) + * Fix dependency and lockfile mismatch + [\#6246](https://github.com/matrix-org/matrix-react-sdk/pull/6246) + * Improve room directory click behaviour + [\#6234](https://github.com/matrix-org/matrix-react-sdk/pull/6234) + * Fix keyboard accessibility of the space panel + [\#6239](https://github.com/matrix-org/matrix-react-sdk/pull/6239) + * Add ways to manage addresses for Spaces + [\#6151](https://github.com/matrix-org/matrix-react-sdk/pull/6151) + * Hide communities invites and the community autocompleter when Spaces on + [\#6244](https://github.com/matrix-org/matrix-react-sdk/pull/6244) + * Convert bunch of files to TS + [\#6241](https://github.com/matrix-org/matrix-react-sdk/pull/6241) + * Open local addresses section by default when there are no existing local + addresses + [\#6179](https://github.com/matrix-org/matrix-react-sdk/pull/6179) + * Allow reordering of the space panel via Drag and Drop + [\#6137](https://github.com/matrix-org/matrix-react-sdk/pull/6137) + * Replace drag and drop mechanism in communities with something simpler + [\#6134](https://github.com/matrix-org/matrix-react-sdk/pull/6134) + * EventTilePreview fixes + [\#6000](https://github.com/matrix-org/matrix-react-sdk/pull/6000) + * Upgrade @types/react and @types/react-dom + [\#6233](https://github.com/matrix-org/matrix-react-sdk/pull/6233) + * Fix type error in the SpaceStore + [\#6242](https://github.com/matrix-org/matrix-react-sdk/pull/6242) + * Add experimental options to the Spaces beta + [\#6199](https://github.com/matrix-org/matrix-react-sdk/pull/6199) + * Consolidate types with js-sdk changes + [\#6215](https://github.com/matrix-org/matrix-react-sdk/pull/6215) + * Fix branch matching for Buildkite + [\#6236](https://github.com/matrix-org/matrix-react-sdk/pull/6236) + * Migrate SearchBar to TypeScript + [\#6230](https://github.com/matrix-org/matrix-react-sdk/pull/6230) + * Add support to keyboard shortcuts dialog for [digits] + [\#6088](https://github.com/matrix-org/matrix-react-sdk/pull/6088) + * Fix modal opening race condition + [\#6238](https://github.com/matrix-org/matrix-react-sdk/pull/6238) + * Deprecate FormButton in favour of AccessibleButton + [\#6229](https://github.com/matrix-org/matrix-react-sdk/pull/6229) + * Add PR template + [\#6216](https://github.com/matrix-org/matrix-react-sdk/pull/6216) + * Prefer canonical aliases while autocompleting rooms + [\#6222](https://github.com/matrix-org/matrix-react-sdk/pull/6222) + * Fix quote button + [\#6232](https://github.com/matrix-org/matrix-react-sdk/pull/6232) + * Restore branch matching support for GitHub Actions e2e tests + [\#6224](https://github.com/matrix-org/matrix-react-sdk/pull/6224) + * Fix View Source accessing renamed private field on MatrixEvent + [\#6225](https://github.com/matrix-org/matrix-react-sdk/pull/6225) + * Fix ConfirmUserActionDialog returning an input field rather than text + [\#6219](https://github.com/matrix-org/matrix-react-sdk/pull/6219) + * Revert "Partially restore immutable event objects at the rendering layer" + [\#6221](https://github.com/matrix-org/matrix-react-sdk/pull/6221) + * Add jq to e2e tests Dockerfile + [\#6218](https://github.com/matrix-org/matrix-react-sdk/pull/6218) + * Partially restore immutable event objects at the rendering layer + [\#6196](https://github.com/matrix-org/matrix-react-sdk/pull/6196) + * Update MSC number references for voice messages + [\#6197](https://github.com/matrix-org/matrix-react-sdk/pull/6197) + * Fix phase enum usage in JS modules as well + [\#6214](https://github.com/matrix-org/matrix-react-sdk/pull/6214) + * Migrate some dialogs to TypeScript + [\#6185](https://github.com/matrix-org/matrix-react-sdk/pull/6185) + * Typescript fixes due to MatrixEvent being TSified + [\#6208](https://github.com/matrix-org/matrix-react-sdk/pull/6208) + * Allow click-to-ping, quote & emoji picker for edit composer too + [\#5858](https://github.com/matrix-org/matrix-react-sdk/pull/5858) + * Add call silencing + [\#6082](https://github.com/matrix-org/matrix-react-sdk/pull/6082) + * Fix types in SlashCommands + [\#6207](https://github.com/matrix-org/matrix-react-sdk/pull/6207) + * Benchmark multiple common user scenario + [\#6190](https://github.com/matrix-org/matrix-react-sdk/pull/6190) + * Fix forward dialog message preview display names + [\#6204](https://github.com/matrix-org/matrix-react-sdk/pull/6204) + * Remove stray bullet point in reply preview + [\#6206](https://github.com/matrix-org/matrix-react-sdk/pull/6206) + * Stop requesting null next replies from the server + [\#6203](https://github.com/matrix-org/matrix-react-sdk/pull/6203) + * Fix soft crash caused by a broken shouldComponentUpdate + [\#6202](https://github.com/matrix-org/matrix-react-sdk/pull/6202) + * Keep composer reply when scrolling away from a highlighted event + [\#6200](https://github.com/matrix-org/matrix-react-sdk/pull/6200) + * Cache virtual/native room mappings when they're created + [\#6194](https://github.com/matrix-org/matrix-react-sdk/pull/6194) + * Disable comment-on-alert + [\#6191](https://github.com/matrix-org/matrix-react-sdk/pull/6191) + * Bump postcss from 7.0.35 to 7.0.36 + [\#6195](https://github.com/matrix-org/matrix-react-sdk/pull/6195) + +Changes in [3.24.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.24.0) (2021-06-21) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0-rc.1...v3.24.0) + + * Upgrade to JS SDK 12.0.0 + * [Release] Keep composer reply when scrolling away from a highlighted event + [\#6211](https://github.com/matrix-org/matrix-react-sdk/pull/6211) + * [Release] Remove stray bullet point in reply preview + [\#6210](https://github.com/matrix-org/matrix-react-sdk/pull/6210) + * [Release] Stop requesting null next replies from the server + [\#6209](https://github.com/matrix-org/matrix-react-sdk/pull/6209) + +Changes in [3.24.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.24.0-rc.1) (2021-06-15) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.23.0...v3.24.0-rc.1) + + * Upgrade to JS SDK 12.0.0-rc.1 + * Translations update from Weblate + [\#6192](https://github.com/matrix-org/matrix-react-sdk/pull/6192) + * Disable comment-on-alert for PR coming from a fork + [\#6189](https://github.com/matrix-org/matrix-react-sdk/pull/6189) + * Add JS benchmark tracking in CI + [\#6177](https://github.com/matrix-org/matrix-react-sdk/pull/6177) + * Upgrade matrix-react-test-utils for React 17 peer deps + [\#6187](https://github.com/matrix-org/matrix-react-sdk/pull/6187) + * Fix display name overlaps on the IRC layout + [\#6186](https://github.com/matrix-org/matrix-react-sdk/pull/6186) + * Small fixes to the spaces experience + [\#6184](https://github.com/matrix-org/matrix-react-sdk/pull/6184) + * Add footer and privacy note to the start dm dialog + [\#6111](https://github.com/matrix-org/matrix-react-sdk/pull/6111) + * Format mxids when disambiguation needed + [\#5880](https://github.com/matrix-org/matrix-react-sdk/pull/5880) + * Move various createRoom types to the js-sdk + [\#6183](https://github.com/matrix-org/matrix-react-sdk/pull/6183) + * Fix HTML tag for Event Tile when not rendered in a list + [\#6175](https://github.com/matrix-org/matrix-react-sdk/pull/6175) + * Remove legacy polyfills and unused dependencies + [\#6176](https://github.com/matrix-org/matrix-react-sdk/pull/6176) + * Fix buggy hovering/selecting of event tiles + [\#6173](https://github.com/matrix-org/matrix-react-sdk/pull/6173) + * Add room intro warning when e2ee is not enabled + [\#5929](https://github.com/matrix-org/matrix-react-sdk/pull/5929) + * Migrate end to end tests to GitHub actions + [\#6156](https://github.com/matrix-org/matrix-react-sdk/pull/6156) + * Fix expanding last collapsed sticky session when zoomed in + [\#6171](https://github.com/matrix-org/matrix-react-sdk/pull/6171) + * ⚛️ Upgrade to React@17 + [\#6165](https://github.com/matrix-org/matrix-react-sdk/pull/6165) + * Revert refreshStickyHeaders optimisations + [\#6168](https://github.com/matrix-org/matrix-react-sdk/pull/6168) + * Add logging for which rooms calls are in + [\#6170](https://github.com/matrix-org/matrix-react-sdk/pull/6170) + * Restore read receipt animation from event to event + [\#6169](https://github.com/matrix-org/matrix-react-sdk/pull/6169) + * Restore copy button icon when sharing permalink + [\#6166](https://github.com/matrix-org/matrix-react-sdk/pull/6166) + * Restore Page Up/Down key bindings when focusing the composer + [\#6167](https://github.com/matrix-org/matrix-react-sdk/pull/6167) + * Timeline rendering optimizations + [\#6143](https://github.com/matrix-org/matrix-react-sdk/pull/6143) + * Bump css-what from 5.0.0 to 5.0.1 + [\#6164](https://github.com/matrix-org/matrix-react-sdk/pull/6164) + * Bump ws from 6.2.1 to 6.2.2 in /test/end-to-end-tests + [\#6145](https://github.com/matrix-org/matrix-react-sdk/pull/6145) + * Bump trim-newlines from 3.0.0 to 3.0.1 + [\#6163](https://github.com/matrix-org/matrix-react-sdk/pull/6163) + * Fix upgrade to element home button in top left menu + [\#6162](https://github.com/matrix-org/matrix-react-sdk/pull/6162) + * Fix unpinning of pinned messages and panel empty state + [\#6140](https://github.com/matrix-org/matrix-react-sdk/pull/6140) + * Better handling for widgets that fail to load + [\#6161](https://github.com/matrix-org/matrix-react-sdk/pull/6161) + * Improved forwarding UI + [\#5999](https://github.com/matrix-org/matrix-react-sdk/pull/5999) + * Fixes for sharing room links + [\#6118](https://github.com/matrix-org/matrix-react-sdk/pull/6118) + * Fix setting watchers + [\#6160](https://github.com/matrix-org/matrix-react-sdk/pull/6160) + * Fix Stickerpicker context menu + [\#6152](https://github.com/matrix-org/matrix-react-sdk/pull/6152) + * Add warning to private space creation flow + [\#6155](https://github.com/matrix-org/matrix-react-sdk/pull/6155) + * Add prop to alwaysShowTimestamps on TimelinePanel + [\#6159](https://github.com/matrix-org/matrix-react-sdk/pull/6159) + * Fix notif panel timestamp padding + [\#6157](https://github.com/matrix-org/matrix-react-sdk/pull/6157) + * Fixes and refactoring for the ImageView + [\#6149](https://github.com/matrix-org/matrix-react-sdk/pull/6149) + * Fix timestamps + [\#6148](https://github.com/matrix-org/matrix-react-sdk/pull/6148) + * Make it easier to pan images in the lightbox + [\#6147](https://github.com/matrix-org/matrix-react-sdk/pull/6147) + * Fix scroll token for EventTile and EventListSummary node type + [\#6154](https://github.com/matrix-org/matrix-react-sdk/pull/6154) + * Convert bunch of things to Typescript + [\#6153](https://github.com/matrix-org/matrix-react-sdk/pull/6153) + * Lint the typescript tests + [\#6142](https://github.com/matrix-org/matrix-react-sdk/pull/6142) + * Fix jumping to bottom without a highlighted event + [\#6146](https://github.com/matrix-org/matrix-react-sdk/pull/6146) + * Repair event status position in timeline + [\#6141](https://github.com/matrix-org/matrix-react-sdk/pull/6141) + * Adapt for js-sdk MatrixClient conversion to TS + [\#6132](https://github.com/matrix-org/matrix-react-sdk/pull/6132) + * Improve pinned messages in Labs + [\#6096](https://github.com/matrix-org/matrix-react-sdk/pull/6096) + * Map phone number lookup results to their native rooms + [\#6136](https://github.com/matrix-org/matrix-react-sdk/pull/6136) + * Fix mx_Event containment rules and empty read avatar row + [\#6138](https://github.com/matrix-org/matrix-react-sdk/pull/6138) + * Improve switch room rendering + [\#6079](https://github.com/matrix-org/matrix-react-sdk/pull/6079) + * Add CSS containment rules for shorter reflow operations + [\#6127](https://github.com/matrix-org/matrix-react-sdk/pull/6127) + * ignore hash/fragment when de-duplicating links for url previews + [\#6135](https://github.com/matrix-org/matrix-react-sdk/pull/6135) + * Clicking jump to bottom resets room hash + [\#5823](https://github.com/matrix-org/matrix-react-sdk/pull/5823) + * Use passive option for scroll handlers + [\#6113](https://github.com/matrix-org/matrix-react-sdk/pull/6113) + * Optimise memberSort performance for large list + [\#6130](https://github.com/matrix-org/matrix-react-sdk/pull/6130) + * Tweak event border radius to match action bar + [\#6133](https://github.com/matrix-org/matrix-react-sdk/pull/6133) + * Log when we ignore a second call in a room + [\#6131](https://github.com/matrix-org/matrix-react-sdk/pull/6131) + * Performance monitoring measurements + [\#6041](https://github.com/matrix-org/matrix-react-sdk/pull/6041) + +Changes in [3.23.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.23.0) (2021-06-07) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.23.0-rc.1...v3.23.0) + + * Upgrade to JS SDK 11.2.0 + * [Release] Fix notif panel timestamp padding + [\#6158](https://github.com/matrix-org/matrix-react-sdk/pull/6158) + +Changes in [3.23.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.23.0-rc.1) (2021-06-01) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.22.0...v3.23.0-rc.1) + + * Upgrade to JS SDK 11.2.0-rc.1 + * Translations update from Weblate + [\#6128](https://github.com/matrix-org/matrix-react-sdk/pull/6128) + * Fix all DMs wrongly appearing in room list when `m.direct` is changed + [\#6122](https://github.com/matrix-org/matrix-react-sdk/pull/6122) + * Update way of checking for registration disabled + [\#6123](https://github.com/matrix-org/matrix-react-sdk/pull/6123) + * Fix the ability to remove avatar from a space via settings + [\#6126](https://github.com/matrix-org/matrix-react-sdk/pull/6126) + * Switch to stable endpoint/fields for MSC2858 + [\#6125](https://github.com/matrix-org/matrix-react-sdk/pull/6125) + * Clear stored editor state when canceling editing using a shortcut + [\#6117](https://github.com/matrix-org/matrix-react-sdk/pull/6117) + * Respect newlines in space topics + [\#6124](https://github.com/matrix-org/matrix-react-sdk/pull/6124) + * Add url param `defaultUsername` to prefill the login username field + [\#5674](https://github.com/matrix-org/matrix-react-sdk/pull/5674) + * Bump ws from 7.4.2 to 7.4.6 + [\#6115](https://github.com/matrix-org/matrix-react-sdk/pull/6115) + * Sticky headers repositioning without layout trashing + [\#6110](https://github.com/matrix-org/matrix-react-sdk/pull/6110) + * Handle user_busy in voip calls + [\#6112](https://github.com/matrix-org/matrix-react-sdk/pull/6112) + * Avoid showing warning modals from the invite dialog after it unmounts + [\#6105](https://github.com/matrix-org/matrix-react-sdk/pull/6105) + * Fix misleading child counts in spaces + [\#6109](https://github.com/matrix-org/matrix-react-sdk/pull/6109) + * Close creation menu when expanding space panel via expand hierarchy + [\#6090](https://github.com/matrix-org/matrix-react-sdk/pull/6090) + * Prevent having duplicates in pending room state + [\#6108](https://github.com/matrix-org/matrix-react-sdk/pull/6108) + * Update reactions row on event decryption + [\#6106](https://github.com/matrix-org/matrix-react-sdk/pull/6106) + * Destroy playback instance on voice message unmount + [\#6101](https://github.com/matrix-org/matrix-react-sdk/pull/6101) + * Fix message preview not up to date + [\#6102](https://github.com/matrix-org/matrix-react-sdk/pull/6102) + * Convert some Flow typed files to TS (round 2) + [\#6076](https://github.com/matrix-org/matrix-react-sdk/pull/6076) + * Remove unused middlePanelResized event listener + [\#6086](https://github.com/matrix-org/matrix-react-sdk/pull/6086) + * Fix accessing currentState on an invalid joinedRoom + [\#6100](https://github.com/matrix-org/matrix-react-sdk/pull/6100) + * Remove Promise allSettled polyfill as js-sdk uses it directly + [\#6097](https://github.com/matrix-org/matrix-react-sdk/pull/6097) + * Prevent DecoratedRoomAvatar to update its state for the same value + [\#6099](https://github.com/matrix-org/matrix-react-sdk/pull/6099) + * Skip generatePreview if event is not part of the live timeline + [\#6098](https://github.com/matrix-org/matrix-react-sdk/pull/6098) + * fix sticky headers when results num get displayed + [\#6095](https://github.com/matrix-org/matrix-react-sdk/pull/6095) + * Improve addEventsToTimeline performance scoping WhoIsTypingTile::setState + [\#6094](https://github.com/matrix-org/matrix-react-sdk/pull/6094) + * Safeguards to prevent layout trashing for window dimensions + [\#6092](https://github.com/matrix-org/matrix-react-sdk/pull/6092) + * Use local room state to render space hierarchy if the room is known + [\#6089](https://github.com/matrix-org/matrix-react-sdk/pull/6089) + * Add spinner in UserMenu to list pending long running actions + [\#6085](https://github.com/matrix-org/matrix-react-sdk/pull/6085) + * Stop overscroll in Firefox Nightly for macOS + [\#6093](https://github.com/matrix-org/matrix-react-sdk/pull/6093) + * Move SettingsStore watchers/monitors over to ES6 maps for performance + [\#6063](https://github.com/matrix-org/matrix-react-sdk/pull/6063) + * Bump libolm version. + [\#6080](https://github.com/matrix-org/matrix-react-sdk/pull/6080) + * Improve styling of the message action bar + [\#6066](https://github.com/matrix-org/matrix-react-sdk/pull/6066) + * Improve explore rooms when no results are found + [\#6070](https://github.com/matrix-org/matrix-react-sdk/pull/6070) + * Remove logo spinner + [\#6078](https://github.com/matrix-org/matrix-react-sdk/pull/6078) + * Fix add reaction prompt showing even when user is not joined to room + [\#6073](https://github.com/matrix-org/matrix-react-sdk/pull/6073) + * Vectorize spinners + [\#5680](https://github.com/matrix-org/matrix-react-sdk/pull/5680) + * Fix handling of via servers for suggested rooms + [\#6077](https://github.com/matrix-org/matrix-react-sdk/pull/6077) + * Upgrade showChatEffects to room-level setting exposure + [\#6075](https://github.com/matrix-org/matrix-react-sdk/pull/6075) + * Delete RoomView dead code + [\#6071](https://github.com/matrix-org/matrix-react-sdk/pull/6071) + * Reduce noise in tests + [\#6074](https://github.com/matrix-org/matrix-react-sdk/pull/6074) + * Fix room name issues in right panel summary card + [\#6069](https://github.com/matrix-org/matrix-react-sdk/pull/6069) + * Cache normalized room name + [\#6072](https://github.com/matrix-org/matrix-react-sdk/pull/6072) + * Update MemberList to reflect changes for invite permission change + [\#6061](https://github.com/matrix-org/matrix-react-sdk/pull/6061) + * Delete RoomView dead code + [\#6065](https://github.com/matrix-org/matrix-react-sdk/pull/6065) + * Show subspace rooms count even if it is 0 for consistency + [\#6067](https://github.com/matrix-org/matrix-react-sdk/pull/6067) + +Changes in [3.22.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.22.0) (2021-05-24) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.22.0-rc.1...v3.22.0) + + * Upgrade to JS SDK 11.1.0 + * [Release] Bump libolm version + [\#6087](https://github.com/matrix-org/matrix-react-sdk/pull/6087) + +Changes in [3.22.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.22.0-rc.1) (2021-05-19) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.21.0...v3.22.0-rc.1) + + * Upgrade to JS SDK 11.1.0-rc.1 + * Translations update from Weblate + [\#6068](https://github.com/matrix-org/matrix-react-sdk/pull/6068) + * Show DMs in space for invited members too, to match Android impl + [\#6062](https://github.com/matrix-org/matrix-react-sdk/pull/6062) + * Support filtering by alias in add existing to space dialog + [\#6057](https://github.com/matrix-org/matrix-react-sdk/pull/6057) + * Fix issue when a room without a name or alias is marked as suggested + [\#6064](https://github.com/matrix-org/matrix-react-sdk/pull/6064) + * Fix space room hierarchy not updating when removing a room + [\#6055](https://github.com/matrix-org/matrix-react-sdk/pull/6055) + * Revert "Try putting room list handling behind a lock" + [\#6060](https://github.com/matrix-org/matrix-react-sdk/pull/6060) + * Stop assuming encrypted messages are decrypted ahead of time + [\#6052](https://github.com/matrix-org/matrix-react-sdk/pull/6052) + * Add error detail when languges fail to load + [\#6059](https://github.com/matrix-org/matrix-react-sdk/pull/6059) + * Add space invaders chat effect + [\#6053](https://github.com/matrix-org/matrix-react-sdk/pull/6053) + * Create SpaceProvider and hide Spaces from the RoomProvider autocompleter + [\#6051](https://github.com/matrix-org/matrix-react-sdk/pull/6051) + * Don't mark a room as unread when redacted event is present + [\#6049](https://github.com/matrix-org/matrix-react-sdk/pull/6049) + * Add support for MSC2873: Client information for Widgets + [\#6023](https://github.com/matrix-org/matrix-react-sdk/pull/6023) + * Support UI for MSC2762: Widgets reading events from rooms + [\#5960](https://github.com/matrix-org/matrix-react-sdk/pull/5960) + * Fix crash on opening notification panel + [\#6047](https://github.com/matrix-org/matrix-react-sdk/pull/6047) + * Remove custom LoggedInView::shouldComponentUpdate logic + [\#6046](https://github.com/matrix-org/matrix-react-sdk/pull/6046) + * Fix edge cases with the new add reactions prompt button + [\#6045](https://github.com/matrix-org/matrix-react-sdk/pull/6045) + * Add ids to homeserver and passphrase fields + [\#6043](https://github.com/matrix-org/matrix-react-sdk/pull/6043) + * Update space order field validity requirements to match msc update + [\#6042](https://github.com/matrix-org/matrix-react-sdk/pull/6042) + * Try putting room list handling behind a lock + [\#6024](https://github.com/matrix-org/matrix-react-sdk/pull/6024) + * Improve progress bar progression for smaller voice messages + [\#6035](https://github.com/matrix-org/matrix-react-sdk/pull/6035) + * Fix share space edge case where space is public but not invitable + [\#6039](https://github.com/matrix-org/matrix-react-sdk/pull/6039) + * Add missing 'rel' to image view download button + [\#6033](https://github.com/matrix-org/matrix-react-sdk/pull/6033) + * Improve visible waveform for voice messages + [\#6034](https://github.com/matrix-org/matrix-react-sdk/pull/6034) + * Fix roving tab index intercepting home/end in space create menu + [\#6040](https://github.com/matrix-org/matrix-react-sdk/pull/6040) + * Decorate room avatars with publicity in add existing to space flow + [\#6030](https://github.com/matrix-org/matrix-react-sdk/pull/6030) + * Improve Spaces "Just Me" wizard + [\#6025](https://github.com/matrix-org/matrix-react-sdk/pull/6025) + * Increase hover feedback on room sub list buttons + [\#6037](https://github.com/matrix-org/matrix-react-sdk/pull/6037) + * Show alternative button during space creation wizard if no rooms + [\#6029](https://github.com/matrix-org/matrix-react-sdk/pull/6029) + * Swap rotation buttons in the image viewer + [\#6032](https://github.com/matrix-org/matrix-react-sdk/pull/6032) + * Typo: initilisation -> initialisation + [\#5915](https://github.com/matrix-org/matrix-react-sdk/pull/5915) + * Save edited state of a message when switching rooms + [\#6001](https://github.com/matrix-org/matrix-react-sdk/pull/6001) + * Fix shield icon in Untrusted Device Dialog + [\#6022](https://github.com/matrix-org/matrix-react-sdk/pull/6022) + * Do not eagerly decrypt breadcrumb rooms + [\#6028](https://github.com/matrix-org/matrix-react-sdk/pull/6028) + * Update spaces.png + [\#6031](https://github.com/matrix-org/matrix-react-sdk/pull/6031) + * Encourage more diverse reactions to content + [\#6027](https://github.com/matrix-org/matrix-react-sdk/pull/6027) + * Wrap decodeURIComponent in try-catch to protect against malformed URIs + [\#6026](https://github.com/matrix-org/matrix-react-sdk/pull/6026) + * Iterate beta feedback dialog + [\#6021](https://github.com/matrix-org/matrix-react-sdk/pull/6021) + * Disable space fields whilst their form is busy + [\#6020](https://github.com/matrix-org/matrix-react-sdk/pull/6020) + * Add missing space on beta feedback dialog + [\#6018](https://github.com/matrix-org/matrix-react-sdk/pull/6018) + * Fix colours used for the back button in space create menu + [\#6017](https://github.com/matrix-org/matrix-react-sdk/pull/6017) + * Prioritise and reduce the amount of events decrypted on application startup + [\#5980](https://github.com/matrix-org/matrix-react-sdk/pull/5980) + * Linkify topics in space room directory results + [\#6015](https://github.com/matrix-org/matrix-react-sdk/pull/6015) + * Persistent space collapsed states + [\#5972](https://github.com/matrix-org/matrix-react-sdk/pull/5972) + * Catch another instance of unlabeled avatars. + [\#6010](https://github.com/matrix-org/matrix-react-sdk/pull/6010) + * Rescale and smooth voice message playback waveform to better match + expectation + [\#5996](https://github.com/matrix-org/matrix-react-sdk/pull/5996) + * Scale voice message clock with user's font size + [\#5993](https://github.com/matrix-org/matrix-react-sdk/pull/5993) + * Remove "in development" flag from voice messages + [\#5995](https://github.com/matrix-org/matrix-react-sdk/pull/5995) + * Support voice messages on Safari + [\#5989](https://github.com/matrix-org/matrix-react-sdk/pull/5989) + * Translations update from Weblate + [\#6011](https://github.com/matrix-org/matrix-react-sdk/pull/6011) + +Changes in [3.21.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.21.0) (2021-05-17) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.21.0-rc.1...v3.21.0) + +## Security notice + +matrix-react-sdk 3.21.0 fixes a low severity issue (GHSA-8796-gc9j-63rv) +related to file upload. When uploading a file, the local file preview can lead +to execution of scripts embedded in the uploaded file, but only after several +user interactions to open the preview in a separate tab. This only impacts the +local user while in the process of uploading. It cannot be exploited remotely +or by other users. Thanks to [Muhammad Zaid Ghifari](https://github.com/MR-ZHEEV) +for responsibly disclosing this via Matrix's Security Disclosure Policy. + +## All changes + + * Upgrade to JS SDK 11.0.0 + * [Release] Add missing space on beta feedback dialog + [\#6019](https://github.com/matrix-org/matrix-react-sdk/pull/6019) + * [Release] Add feedback mechanism for beta features, namely Spaces + [\#6013](https://github.com/matrix-org/matrix-react-sdk/pull/6013) + * Add feedback mechanism for beta features, namely Spaces + [\#6012](https://github.com/matrix-org/matrix-react-sdk/pull/6012) + +Changes in [3.21.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.21.0-rc.1) (2021-05-11) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.20.0...v3.21.0-rc.1) + + * Upgrade to JS SDK 11.0.0-rc.1 + * Add disclaimer about subspaces being experimental in add existing dialog + [\#5978](https://github.com/matrix-org/matrix-react-sdk/pull/5978) + * Spaces Beta release + [\#5933](https://github.com/matrix-org/matrix-react-sdk/pull/5933) + * Improve permissions error when adding new server to room directory + [\#6009](https://github.com/matrix-org/matrix-react-sdk/pull/6009) + * Allow user to progress through space creation & setup using Enter + [\#6006](https://github.com/matrix-org/matrix-react-sdk/pull/6006) + * Upgrade sanitize types + [\#6008](https://github.com/matrix-org/matrix-react-sdk/pull/6008) + * Upgrade `cheerio` and resolve type errors + [\#6007](https://github.com/matrix-org/matrix-react-sdk/pull/6007) + * Add slash commands support to edit message composer + [\#5865](https://github.com/matrix-org/matrix-react-sdk/pull/5865) + * Fix the two todays problem + [\#5940](https://github.com/matrix-org/matrix-react-sdk/pull/5940) + * Switch the Home Space out for an All rooms space + [\#5969](https://github.com/matrix-org/matrix-react-sdk/pull/5969) + * Show device ID in UserInfo when there is no device name + [\#5985](https://github.com/matrix-org/matrix-react-sdk/pull/5985) + * Switch back to release version of `sanitize-html` + [\#6005](https://github.com/matrix-org/matrix-react-sdk/pull/6005) + * Bump hosted-git-info from 2.8.8 to 2.8.9 + [\#5998](https://github.com/matrix-org/matrix-react-sdk/pull/5998) + * Don't use the event's metadata to calc the scale of an image + [\#5982](https://github.com/matrix-org/matrix-react-sdk/pull/5982) + * Adjust MIME type of upload confirmation if needed + [\#5981](https://github.com/matrix-org/matrix-react-sdk/pull/5981) + * Forbid redaction of encryption events + [\#5991](https://github.com/matrix-org/matrix-react-sdk/pull/5991) + * Fix voice message playback being squished up against send button + [\#5988](https://github.com/matrix-org/matrix-react-sdk/pull/5988) + * Improve style of notification badges on the space panel + [\#5983](https://github.com/matrix-org/matrix-react-sdk/pull/5983) + * Add dev dependency for parse5 typings + [\#5990](https://github.com/matrix-org/matrix-react-sdk/pull/5990) + * Iterate Spaces admin UX around room management + [\#5977](https://github.com/matrix-org/matrix-react-sdk/pull/5977) + * Guard all isSpaceRoom calls behind the labs flag + [\#5979](https://github.com/matrix-org/matrix-react-sdk/pull/5979) + * Bump lodash from 4.17.20 to 4.17.21 + [\#5986](https://github.com/matrix-org/matrix-react-sdk/pull/5986) + * Bump lodash from 4.17.19 to 4.17.21 in /test/end-to-end-tests + [\#5987](https://github.com/matrix-org/matrix-react-sdk/pull/5987) + * Bump ua-parser-js from 0.7.23 to 0.7.28 + [\#5984](https://github.com/matrix-org/matrix-react-sdk/pull/5984) + * Update visual style of plain files in the timeline + [\#5971](https://github.com/matrix-org/matrix-react-sdk/pull/5971) + * Support for multiple streams (not MSC3077) + [\#5833](https://github.com/matrix-org/matrix-react-sdk/pull/5833) + * Update space ordering behaviour to match updates in MSC + [\#5963](https://github.com/matrix-org/matrix-react-sdk/pull/5963) + * Improve performance of search all spaces and space switching + [\#5976](https://github.com/matrix-org/matrix-react-sdk/pull/5976) + * Update colours and sizing for voice messages + [\#5970](https://github.com/matrix-org/matrix-react-sdk/pull/5970) + * Update link to Android SDK + [\#5973](https://github.com/matrix-org/matrix-react-sdk/pull/5973) + * Add cleanup functions for image view + [\#5962](https://github.com/matrix-org/matrix-react-sdk/pull/5962) + * Add a note about sharing your IP in P2P calls + [\#5961](https://github.com/matrix-org/matrix-react-sdk/pull/5961) + * Only aggregate DM notifications on the Space Panel in the Home Space + [\#5968](https://github.com/matrix-org/matrix-react-sdk/pull/5968) + * Add retry mechanism and progress bar to add existing to space dialog + [\#5975](https://github.com/matrix-org/matrix-react-sdk/pull/5975) + * Warn on access token reveal + [\#5755](https://github.com/matrix-org/matrix-react-sdk/pull/5755) + * Fix newly joined room appearing under the wrong space + [\#5945](https://github.com/matrix-org/matrix-react-sdk/pull/5945) + * Early rendering for voice messages in the timeline + [\#5955](https://github.com/matrix-org/matrix-react-sdk/pull/5955) + * Calculate the real waveform in the Playback class for voice messages + [\#5956](https://github.com/matrix-org/matrix-react-sdk/pull/5956) + * Don't recurse on arrayFastResample + [\#5957](https://github.com/matrix-org/matrix-react-sdk/pull/5957) + * Support a dark theme for voice messages + [\#5958](https://github.com/matrix-org/matrix-react-sdk/pull/5958) + * Handle no/blocked microphones in voice messages + [\#5959](https://github.com/matrix-org/matrix-react-sdk/pull/5959) + +Changes in [3.20.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0) (2021-05-10) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.20.0-rc.1...v3.20.0) + + * Upgrade to JS SDK 10.1.0 + * [Release] Don't use the event's metadata to calc the scale of an image + [\#6004](https://github.com/matrix-org/matrix-react-sdk/pull/6004) + +Changes in [3.20.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0-rc.1) (2021-05-04) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0...v3.20.0-rc.1) + + * Upgrade to JS SDK 10.1.0-rc.1 + * Translations update from Weblate + [\#5966](https://github.com/matrix-org/matrix-react-sdk/pull/5966) + * Fix more space panel layout and hover behaviour issues + [\#5965](https://github.com/matrix-org/matrix-react-sdk/pull/5965) + * Fix edge case with space panel alignment with subspaces on ff + [\#5964](https://github.com/matrix-org/matrix-react-sdk/pull/5964) + * Fix saving room pill part to history + [\#5951](https://github.com/matrix-org/matrix-react-sdk/pull/5951) + * Generate room preview even when minimized + [\#5948](https://github.com/matrix-org/matrix-react-sdk/pull/5948) + * Another change from recovery passphrase to Security Phrase + [\#5934](https://github.com/matrix-org/matrix-react-sdk/pull/5934) + * Sort rooms in the add existing to space dialog based on recency + [\#5943](https://github.com/matrix-org/matrix-react-sdk/pull/5943) + * Inhibit sending RR when context switching to a room + [\#5944](https://github.com/matrix-org/matrix-react-sdk/pull/5944) + * Prevent room list keyboard handling from landing focus on hidden nodes + [\#5950](https://github.com/matrix-org/matrix-react-sdk/pull/5950) + * Make the text filter search all spaces instead of just the selected one + [\#5942](https://github.com/matrix-org/matrix-react-sdk/pull/5942) + * Enable indent rule and fix indent + [\#5931](https://github.com/matrix-org/matrix-react-sdk/pull/5931) + * Prevent peeking members from reacting + [\#5946](https://github.com/matrix-org/matrix-react-sdk/pull/5946) + * Disallow inline display maths + [\#5939](https://github.com/matrix-org/matrix-react-sdk/pull/5939) + * Space creation prompt user to add existing rooms for "Just Me" spaces + [\#5923](https://github.com/matrix-org/matrix-react-sdk/pull/5923) + * Add test coverage collection script + [\#5937](https://github.com/matrix-org/matrix-react-sdk/pull/5937) + * Fix joining room using via servers regression + [\#5936](https://github.com/matrix-org/matrix-react-sdk/pull/5936) + * Revert "Fixes the two Todays problem in Redaction" + [\#5938](https://github.com/matrix-org/matrix-react-sdk/pull/5938) + * Handle encoded matrix URLs + [\#5903](https://github.com/matrix-org/matrix-react-sdk/pull/5903) + * Render ignored users setting regardless of if there are any + [\#5860](https://github.com/matrix-org/matrix-react-sdk/pull/5860) + * Fix inserting trailing colon after mention/pill + [\#5830](https://github.com/matrix-org/matrix-react-sdk/pull/5830) + * Fixes the two Todays problem in Redaction + [\#5917](https://github.com/matrix-org/matrix-react-sdk/pull/5917) + * Fix page up/down scrolling only half a page + [\#5920](https://github.com/matrix-org/matrix-react-sdk/pull/5920) + * Voice messages: Composer controls + [\#5935](https://github.com/matrix-org/matrix-react-sdk/pull/5935) + * Support MSC3086 asserted identity + [\#5886](https://github.com/matrix-org/matrix-react-sdk/pull/5886) + * Handle possible edge case with getting stuck in "unsent messages" bar + [\#5930](https://github.com/matrix-org/matrix-react-sdk/pull/5930) + * Fix suggested rooms not showing up regression from room list optimisation + [\#5932](https://github.com/matrix-org/matrix-react-sdk/pull/5932) + * Broadcast language change to ElectronPlatform + [\#5913](https://github.com/matrix-org/matrix-react-sdk/pull/5913) + * Fix VoIP PIP frame color + [\#5701](https://github.com/matrix-org/matrix-react-sdk/pull/5701) + * Convert some Flow-typed files to TypeScript + [\#5912](https://github.com/matrix-org/matrix-react-sdk/pull/5912) + * Initial SpaceStore tests work + [\#5906](https://github.com/matrix-org/matrix-react-sdk/pull/5906) + * Fix issues with space hierarchy in layout and with incompatible servers + [\#5926](https://github.com/matrix-org/matrix-react-sdk/pull/5926) + * Scale all mxc thumbs using device pixel ratio for hidpi + [\#5928](https://github.com/matrix-org/matrix-react-sdk/pull/5928) + * Fix add existing to space dialog no longer showing rooms for public spaces + [\#5918](https://github.com/matrix-org/matrix-react-sdk/pull/5918) + * Disable spaces context switching for when exploring a space + [\#5924](https://github.com/matrix-org/matrix-react-sdk/pull/5924) + * Autofocus search box in the add existing to space dialog + [\#5921](https://github.com/matrix-org/matrix-react-sdk/pull/5921) + * Use label element in add existing to space dialog for easier hit target + [\#5922](https://github.com/matrix-org/matrix-react-sdk/pull/5922) + * Dynamic max and min zoom in the new ImageView + [\#5916](https://github.com/matrix-org/matrix-react-sdk/pull/5916) + * Improve message error states + [\#5897](https://github.com/matrix-org/matrix-react-sdk/pull/5897) + * Check for null room in `VisibilityProvider` + [\#5914](https://github.com/matrix-org/matrix-react-sdk/pull/5914) + * Add unit tests for various collection-based utility functions + [\#5910](https://github.com/matrix-org/matrix-react-sdk/pull/5910) + * Spaces visual fixes + [\#5909](https://github.com/matrix-org/matrix-react-sdk/pull/5909) + * Remove reliance on DOM API to generated message preview + [\#5908](https://github.com/matrix-org/matrix-react-sdk/pull/5908) + * Expand upon voice message event & include overall waveform + [\#5888](https://github.com/matrix-org/matrix-react-sdk/pull/5888) + * Use floats for image background opacity + [\#5905](https://github.com/matrix-org/matrix-react-sdk/pull/5905) + * Show invites to spaces at the top of the space panel + [\#5902](https://github.com/matrix-org/matrix-react-sdk/pull/5902) + * Improve edge cases with spaces context switching + [\#5899](https://github.com/matrix-org/matrix-react-sdk/pull/5899) + * Fix spaces notification dots wrongly including upgraded (hidden) rooms + [\#5900](https://github.com/matrix-org/matrix-react-sdk/pull/5900) + * Iterate the spaces face pile design + [\#5898](https://github.com/matrix-org/matrix-react-sdk/pull/5898) + * Fix alignment issue with nested spaces being cut off wrong + [\#5890](https://github.com/matrix-org/matrix-react-sdk/pull/5890) + +Changes in [3.19.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0) (2021-04-26) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0-rc.1...v3.19.0) + + * Upgrade to JS SDK 10.0.0 + * [Release] Dynamic max and min zoom in the new ImageView + [\#5927](https://github.com/matrix-org/matrix-react-sdk/pull/5927) + * [Release] Add a WheelEvent normalization function + [\#5911](https://github.com/matrix-org/matrix-react-sdk/pull/5911) + * Add a WheelEvent normalization function + [\#5904](https://github.com/matrix-org/matrix-react-sdk/pull/5904) + * [Release] Use floats for image background opacity + [\#5907](https://github.com/matrix-org/matrix-react-sdk/pull/5907) + +Changes in [3.19.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0-rc.1) (2021-04-21) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0...v3.19.0-rc.1) + + * Upgrade to JS SDK 10.0.0-rc.1 + * Translations update from Weblate + [\#5896](https://github.com/matrix-org/matrix-react-sdk/pull/5896) + * Fix sticky tags header in room list + [\#5895](https://github.com/matrix-org/matrix-react-sdk/pull/5895) + * Fix spaces filtering sometimes lagging behind or behaving oddly + [\#5893](https://github.com/matrix-org/matrix-react-sdk/pull/5893) + * Fix issue with spaces context switching looping and breaking + [\#5894](https://github.com/matrix-org/matrix-react-sdk/pull/5894) + * Improve RoomList render time when filtering + [\#5874](https://github.com/matrix-org/matrix-react-sdk/pull/5874) + * Avoid being stuck in a space + [\#5891](https://github.com/matrix-org/matrix-react-sdk/pull/5891) + * [Spaces] Context switching + [\#5795](https://github.com/matrix-org/matrix-react-sdk/pull/5795) + * Warn when you attempt to leave room that you are the only member of + [\#5415](https://github.com/matrix-org/matrix-react-sdk/pull/5415) + * Ensure PersistedElement are unmounted on application logout + [\#5884](https://github.com/matrix-org/matrix-react-sdk/pull/5884) + * Add missing space in seshat dialog and the corresponding string + [\#5866](https://github.com/matrix-org/matrix-react-sdk/pull/5866) + * A tiny change to make the Add existing rooms dialog a little nicer + [\#5885](https://github.com/matrix-org/matrix-react-sdk/pull/5885) + * Remove weird margin from the file panel + [\#5889](https://github.com/matrix-org/matrix-react-sdk/pull/5889) + * Trigger lazy loading when filtering using spaces + [\#5882](https://github.com/matrix-org/matrix-react-sdk/pull/5882) + * Fix typo in method call in add existing to space dialog + [\#5883](https://github.com/matrix-org/matrix-react-sdk/pull/5883) + * New Image View fixes/improvements + [\#5872](https://github.com/matrix-org/matrix-react-sdk/pull/5872) + * Limit voice recording length + [\#5871](https://github.com/matrix-org/matrix-react-sdk/pull/5871) + * Clean up add existing to space dialog and include DMs in it too + [\#5881](https://github.com/matrix-org/matrix-react-sdk/pull/5881) + * Fix unknown slash command error exploding + [\#5853](https://github.com/matrix-org/matrix-react-sdk/pull/5853) + * Switch to a spec conforming email validation Regexp + [\#5852](https://github.com/matrix-org/matrix-react-sdk/pull/5852) + * Cleanup unused state in MessageComposer + [\#5877](https://github.com/matrix-org/matrix-react-sdk/pull/5877) + * Pulse animation for voice messages recording state + [\#5869](https://github.com/matrix-org/matrix-react-sdk/pull/5869) + * Don't include invisible rooms in notify summary + [\#5875](https://github.com/matrix-org/matrix-react-sdk/pull/5875) + * Properly disable composer access when recording a voice message + [\#5870](https://github.com/matrix-org/matrix-react-sdk/pull/5870) + * Stabilise starting a DM with multiple people flow + [\#5862](https://github.com/matrix-org/matrix-react-sdk/pull/5862) + * Render msgOption only if showReadReceipts is enabled + [\#5864](https://github.com/matrix-org/matrix-react-sdk/pull/5864) + * Labs: Add quick/cheap "do not disturb" flag + [\#5873](https://github.com/matrix-org/matrix-react-sdk/pull/5873) + * Fix ReadReceipts animations + [\#5836](https://github.com/matrix-org/matrix-react-sdk/pull/5836) + * Add tooltips to message previews + [\#5859](https://github.com/matrix-org/matrix-react-sdk/pull/5859) + * IRC Layout fix layout spacing in replies + [\#5855](https://github.com/matrix-org/matrix-react-sdk/pull/5855) + * Move user to welcome_page if continuing with previous session + [\#5849](https://github.com/matrix-org/matrix-react-sdk/pull/5849) + * Improve image view + [\#5521](https://github.com/matrix-org/matrix-react-sdk/pull/5521) + * Add a button to reset personal encryption state during login + [\#5819](https://github.com/matrix-org/matrix-react-sdk/pull/5819) + * Fix js-sdk import in SlashCommands + [\#5850](https://github.com/matrix-org/matrix-react-sdk/pull/5850) + * Fix useRoomPowerLevels hook + [\#5854](https://github.com/matrix-org/matrix-react-sdk/pull/5854) + * Prevent state events being rendered with invalid state keys + [\#5851](https://github.com/matrix-org/matrix-react-sdk/pull/5851) + * Give server ACLs a name in 'roles & permissions' tab + [\#5838](https://github.com/matrix-org/matrix-react-sdk/pull/5838) + * Don't hide notification badge on the home space button as it has no menu + [\#5845](https://github.com/matrix-org/matrix-react-sdk/pull/5845) + * User Info hide disambiguation as we always show MXID anyway + [\#5843](https://github.com/matrix-org/matrix-react-sdk/pull/5843) + * Improve kick state to not show if the target was not joined to begin with + [\#5846](https://github.com/matrix-org/matrix-react-sdk/pull/5846) + * Fix space store wrongly switching to a non-space filter + [\#5844](https://github.com/matrix-org/matrix-react-sdk/pull/5844) + * Tweak appearance of invite reason + [\#5847](https://github.com/matrix-org/matrix-react-sdk/pull/5847) + * Update Inter font to v3.18 + [\#5840](https://github.com/matrix-org/matrix-react-sdk/pull/5840) + * Enable sharing historical keys on invite + [\#5839](https://github.com/matrix-org/matrix-react-sdk/pull/5839) + * Add ability to hide post-login encryption setup with customisation point + [\#5834](https://github.com/matrix-org/matrix-react-sdk/pull/5834) + * Use LaTeX and TeX delimiters by default + [\#5515](https://github.com/matrix-org/matrix-react-sdk/pull/5515) + * Clone author's deps fork for Netlify previews + [\#5837](https://github.com/matrix-org/matrix-react-sdk/pull/5837) + * Show drop file UI only if dragging a file + [\#5827](https://github.com/matrix-org/matrix-react-sdk/pull/5827) + * Ignore punctuation when filtering rooms + [\#5824](https://github.com/matrix-org/matrix-react-sdk/pull/5824) + * Resizable CallView + [\#5710](https://github.com/matrix-org/matrix-react-sdk/pull/5710) + +Changes in [3.18.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.18.0) (2021-04-12) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0-rc.1...v3.18.0) + + * Upgrade to JS SDK 9.11.0 + * [Release] Tweak appearance of invite reason + [\#5848](https://github.com/matrix-org/matrix-react-sdk/pull/5848) + +Changes in [3.18.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.18.0-rc.1) (2021-04-07) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.17.0...v3.18.0-rc.1) + + * Upgrade to JS SDK 9.11.0-rc.1 + * Translations update from Weblate + [\#5832](https://github.com/matrix-org/matrix-react-sdk/pull/5832) + * Add fake fallback thumbnail URL for encrypted videos + [\#5826](https://github.com/matrix-org/matrix-react-sdk/pull/5826) + * Fix broken "Go to Home View" shortcut on macOS + [\#5818](https://github.com/matrix-org/matrix-react-sdk/pull/5818) + * Remove status area UI defects when in-call + [\#5828](https://github.com/matrix-org/matrix-react-sdk/pull/5828) + * Fix viewing invitations when the inviter has no avatar set + [\#5829](https://github.com/matrix-org/matrix-react-sdk/pull/5829) + * Restabilize room list ordering with prefiltering on spaces/communities + [\#5825](https://github.com/matrix-org/matrix-react-sdk/pull/5825) + * Show invite reasons + [\#5694](https://github.com/matrix-org/matrix-react-sdk/pull/5694) + * Require strong password in forgot password form + [\#5744](https://github.com/matrix-org/matrix-react-sdk/pull/5744) + * Attended transfer + [\#5798](https://github.com/matrix-org/matrix-react-sdk/pull/5798) + * Make user autocomplete query search beyond prefix + [\#5822](https://github.com/matrix-org/matrix-react-sdk/pull/5822) + * Add reset option for corrupted event index store + [\#5806](https://github.com/matrix-org/matrix-react-sdk/pull/5806) + * Prevent Re-request encryption keys from appearing under redacted messages + [\#5816](https://github.com/matrix-org/matrix-react-sdk/pull/5816) + * Keybindings follow up + [\#5815](https://github.com/matrix-org/matrix-react-sdk/pull/5815) + * Increase default visible tiles for room sublists + [\#5821](https://github.com/matrix-org/matrix-react-sdk/pull/5821) + * Change copy to point to native node modules docs in element desktop + [\#5817](https://github.com/matrix-org/matrix-react-sdk/pull/5817) + * Show waveform and timer in voice messages + [\#5801](https://github.com/matrix-org/matrix-react-sdk/pull/5801) + * Label unlabeled avatar button in event panel + [\#5585](https://github.com/matrix-org/matrix-react-sdk/pull/5585) + * Fix the theme engine breaking with some web theming extensions + [\#5810](https://github.com/matrix-org/matrix-react-sdk/pull/5810) + * Add /spoiler command + [\#5696](https://github.com/matrix-org/matrix-react-sdk/pull/5696) + * Don't specify sample rates for voice messages + [\#5802](https://github.com/matrix-org/matrix-react-sdk/pull/5802) + * Tweak security key error handling + [\#5812](https://github.com/matrix-org/matrix-react-sdk/pull/5812) + * Add user settings for warn before exit + [\#5793](https://github.com/matrix-org/matrix-react-sdk/pull/5793) + * Decouple key bindings from event handling + [\#5720](https://github.com/matrix-org/matrix-react-sdk/pull/5720) + * Fixing spaces papercuts + [\#5792](https://github.com/matrix-org/matrix-react-sdk/pull/5792) + * Share keys for historical messages when inviting users to encrypted rooms + [\#5763](https://github.com/matrix-org/matrix-react-sdk/pull/5763) + * Fix upload bar not populating when starting uploads + [\#5804](https://github.com/matrix-org/matrix-react-sdk/pull/5804) + * Fix crash on login when using social login + [\#5803](https://github.com/matrix-org/matrix-react-sdk/pull/5803) + * Convert AccessSecretStorageDialog to TypeScript + [\#5805](https://github.com/matrix-org/matrix-react-sdk/pull/5805) + * Tweak cross-signing copy + [\#5807](https://github.com/matrix-org/matrix-react-sdk/pull/5807) + * Fix password change popup message + [\#5791](https://github.com/matrix-org/matrix-react-sdk/pull/5791) + * View Source: make Event ID go below Event ID + [\#5790](https://github.com/matrix-org/matrix-react-sdk/pull/5790) + * Fix line numbers when missing trailing newline + [\#5800](https://github.com/matrix-org/matrix-react-sdk/pull/5800) + * Remember reply when switching rooms + [\#5796](https://github.com/matrix-org/matrix-react-sdk/pull/5796) + * Fix edge case with redaction grouper messing up continuations + [\#5797](https://github.com/matrix-org/matrix-react-sdk/pull/5797) + * Only show the ask anyway modal for explicit user lookup failures + [\#5785](https://github.com/matrix-org/matrix-react-sdk/pull/5785) + * Improve error reporting when EventIndex fails on a supported environment + [\#5787](https://github.com/matrix-org/matrix-react-sdk/pull/5787) + * Tweak and fix some space features + [\#5789](https://github.com/matrix-org/matrix-react-sdk/pull/5789) + * Support replying with a message command + [\#5686](https://github.com/matrix-org/matrix-react-sdk/pull/5686) + * Labs feature: Early implementation of voice messages + [\#5769](https://github.com/matrix-org/matrix-react-sdk/pull/5769) + +Changes in [3.17.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.17.0) (2021-03-29) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.17.0-rc.1...v3.17.0) + + * Upgrade to JS SDK 9.10.0 + * [Release] Tweak cross-signing copy + [\#5808](https://github.com/matrix-org/matrix-react-sdk/pull/5808) + * [Release] Fix crash on login when using social login + [\#5809](https://github.com/matrix-org/matrix-react-sdk/pull/5809) + * [Release] Fix edge case with redaction grouper messing up continuations + [\#5799](https://github.com/matrix-org/matrix-react-sdk/pull/5799) + +Changes in [3.17.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.17.0-rc.1) (2021-03-25) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.16.0...v3.17.0-rc.1) + + * Upgrade to JS SDK 9.10.0-rc.1 + * Translations update from Weblate + [\#5788](https://github.com/matrix-org/matrix-react-sdk/pull/5788) + * Track next event [tile] over group boundaries + [\#5784](https://github.com/matrix-org/matrix-react-sdk/pull/5784) + * Fixing the minor UI issues in the email discovery + [\#5780](https://github.com/matrix-org/matrix-react-sdk/pull/5780) + * Don't overwrite callback with undefined if no customization provided + [\#5783](https://github.com/matrix-org/matrix-react-sdk/pull/5783) + * Fix redaction event list summaries breaking sender profiles + [\#5781](https://github.com/matrix-org/matrix-react-sdk/pull/5781) + * Fix CIDER formatting buttons on Safari + [\#5782](https://github.com/matrix-org/matrix-react-sdk/pull/5782) + * Improve discovery of rooms in a space + [\#5776](https://github.com/matrix-org/matrix-react-sdk/pull/5776) + * Spaces improve creation journeys + [\#5777](https://github.com/matrix-org/matrix-react-sdk/pull/5777) + * Make buttons in verify dialog respect the system font + [\#5778](https://github.com/matrix-org/matrix-react-sdk/pull/5778) + * Collapse redactions into an event list summary + [\#5728](https://github.com/matrix-org/matrix-react-sdk/pull/5728) + * Added invite option to room's context menu + [\#5648](https://github.com/matrix-org/matrix-react-sdk/pull/5648) + * Add an optional config option to make the welcome page the login page + [\#5658](https://github.com/matrix-org/matrix-react-sdk/pull/5658) + * Fix username showing instead of display name in Jitsi widgets + [\#5770](https://github.com/matrix-org/matrix-react-sdk/pull/5770) + * Convert a bunch more js-sdk imports to absolute paths + [\#5774](https://github.com/matrix-org/matrix-react-sdk/pull/5774) + * Remove forgotten rooms from the room list once forgotten + [\#5775](https://github.com/matrix-org/matrix-react-sdk/pull/5775) + * Log error when failing to list usermedia devices + [\#5771](https://github.com/matrix-org/matrix-react-sdk/pull/5771) + * Fix weird timeline jumps + [\#5772](https://github.com/matrix-org/matrix-react-sdk/pull/5772) + * Replace type declaration in Registration.tsx + [\#5773](https://github.com/matrix-org/matrix-react-sdk/pull/5773) + * Add possibility to delay rageshake persistence in app startup + [\#5767](https://github.com/matrix-org/matrix-react-sdk/pull/5767) + * Fix left panel resizing and lower min-width improving flexibility + [\#5764](https://github.com/matrix-org/matrix-react-sdk/pull/5764) + * Work around more cases where a rageshake server might not be present + [\#5766](https://github.com/matrix-org/matrix-react-sdk/pull/5766) + * Iterate space panel visually and functionally + [\#5761](https://github.com/matrix-org/matrix-react-sdk/pull/5761) + * Make some dispatches async + [\#5765](https://github.com/matrix-org/matrix-react-sdk/pull/5765) + * fix: make room directory correct when using a homeserver with explicit port + [\#5762](https://github.com/matrix-org/matrix-react-sdk/pull/5762) + * Hangup all calls on logout + [\#5756](https://github.com/matrix-org/matrix-react-sdk/pull/5756) + * Remove now-unused assets and CSS from CompleteSecurity step + [\#5757](https://github.com/matrix-org/matrix-react-sdk/pull/5757) + * Add details and summary to allowed HTML tags + [\#5760](https://github.com/matrix-org/matrix-react-sdk/pull/5760) + * Support a media handling customisation endpoint + [\#5714](https://github.com/matrix-org/matrix-react-sdk/pull/5714) + * Edit button on View Source dialog that takes you to devtools -> + SendCustomEvent + [\#5718](https://github.com/matrix-org/matrix-react-sdk/pull/5718) + * Show room alias in plain/formatted body + [\#5748](https://github.com/matrix-org/matrix-react-sdk/pull/5748) + * Allow pills on the beginning of a part string + [\#5754](https://github.com/matrix-org/matrix-react-sdk/pull/5754) + * [SK-3] Decorate easy components with replaceableComponent + [\#5734](https://github.com/matrix-org/matrix-react-sdk/pull/5734) + * Use fsync in reskindex to ensure file is written to disk + [\#5753](https://github.com/matrix-org/matrix-react-sdk/pull/5753) + * Remove unused common CSS classes + [\#5752](https://github.com/matrix-org/matrix-react-sdk/pull/5752) + * Rebuild space previews with new designs + [\#5751](https://github.com/matrix-org/matrix-react-sdk/pull/5751) + * Rework cross-signing login flow + [\#5727](https://github.com/matrix-org/matrix-react-sdk/pull/5727) + * Change read receipt drift to be non-fractional + [\#5745](https://github.com/matrix-org/matrix-react-sdk/pull/5745) + +Changes in [3.16.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0) (2021-03-15) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.16.0-rc.2...v3.16.0) + + * Upgrade to JS SDK 9.9.0 + * [Release] Change read receipt drift to be non-fractional + [\#5746](https://github.com/matrix-org/matrix-react-sdk/pull/5746) + * [Release] Properly gate SpaceRoomView behind labs + [\#5750](https://github.com/matrix-org/matrix-react-sdk/pull/5750) + +Changes in [3.16.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0-rc.2) (2021-03-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.16.0-rc.1...v3.16.0-rc.2) + + * Fixed incorrect build output in rc.1 + +Changes in [3.16.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0-rc.1) (2021-03-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.15.0...v3.16.0-rc.1) + + * Upgrade to JS SDK 9.9.0-rc.1 + * Translations update from Weblate + [\#5743](https://github.com/matrix-org/matrix-react-sdk/pull/5743) + * Document behaviour of showReadReceipts=false for sent receipts + [\#5739](https://github.com/matrix-org/matrix-react-sdk/pull/5739) + * Tweak sent marker code style + [\#5741](https://github.com/matrix-org/matrix-react-sdk/pull/5741) + * Fix sent markers disappearing for edits/reactions + [\#5737](https://github.com/matrix-org/matrix-react-sdk/pull/5737) + * Ignore to-device decryption in the room list store + [\#5740](https://github.com/matrix-org/matrix-react-sdk/pull/5740) + * Spaces suggested rooms support + [\#5736](https://github.com/matrix-org/matrix-react-sdk/pull/5736) + * Add tooltips to sent/sending receipts + [\#5738](https://github.com/matrix-org/matrix-react-sdk/pull/5738) + * Remove a bunch of useless 'use strict' definitions + [\#5735](https://github.com/matrix-org/matrix-react-sdk/pull/5735) + * [SK-1] Fix types for replaceableComponent + [\#5732](https://github.com/matrix-org/matrix-react-sdk/pull/5732) + * [SK-2] Make debugging skinning problems easier + [\#5733](https://github.com/matrix-org/matrix-react-sdk/pull/5733) + * Support sending invite reasons with /invite command + [\#5695](https://github.com/matrix-org/matrix-react-sdk/pull/5695) + * Fix clicking on the avatar for opening member info requires pixel-perfect + accuracy + [\#5717](https://github.com/matrix-org/matrix-react-sdk/pull/5717) + * Display decrypted and encrypted event source on the same dialog + [\#5713](https://github.com/matrix-org/matrix-react-sdk/pull/5713) + * Fix units of TURN server expiry time + [\#5730](https://github.com/matrix-org/matrix-react-sdk/pull/5730) + * Display room name in pills instead of address + [\#5624](https://github.com/matrix-org/matrix-react-sdk/pull/5624) + * Refresh UI for file uploads + [\#5723](https://github.com/matrix-org/matrix-react-sdk/pull/5723) + * UI refresh for uploaded files + [\#5719](https://github.com/matrix-org/matrix-react-sdk/pull/5719) + * Improve message sending states to match new designs + [\#5699](https://github.com/matrix-org/matrix-react-sdk/pull/5699) + * Add clipboard write permission for widgets + [\#5725](https://github.com/matrix-org/matrix-react-sdk/pull/5725) + * Fix widget resizing + [\#5722](https://github.com/matrix-org/matrix-react-sdk/pull/5722) + * Option for audio streaming + [\#5707](https://github.com/matrix-org/matrix-react-sdk/pull/5707) + * Show a specific error for hs_disabled + [\#5576](https://github.com/matrix-org/matrix-react-sdk/pull/5576) + * Add Edge to the targets list + [\#5721](https://github.com/matrix-org/matrix-react-sdk/pull/5721) + * File drop UI fixes and improvements + [\#5505](https://github.com/matrix-org/matrix-react-sdk/pull/5505) + * Fix Bottom border of state counters is white on the dark theme + [\#5715](https://github.com/matrix-org/matrix-react-sdk/pull/5715) + * Trim spurious whitespace of nicknames + [\#5332](https://github.com/matrix-org/matrix-react-sdk/pull/5332) + * Ensure HostSignupDialog border colour matches light theme + [\#5716](https://github.com/matrix-org/matrix-react-sdk/pull/5716) + * Don't place another call if there's already one ongoing + [\#5712](https://github.com/matrix-org/matrix-react-sdk/pull/5712) + * Space room hierarchies + [\#5706](https://github.com/matrix-org/matrix-react-sdk/pull/5706) + * Iterate Space view and right panel + [\#5705](https://github.com/matrix-org/matrix-react-sdk/pull/5705) + * Add a scroll to bottom on message sent setting + [\#5692](https://github.com/matrix-org/matrix-react-sdk/pull/5692) + * Add .tmp files to gitignore + [\#5708](https://github.com/matrix-org/matrix-react-sdk/pull/5708) + * Initial Space Room View and Creation UX + [\#5704](https://github.com/matrix-org/matrix-react-sdk/pull/5704) + * Add multi language spell check + [\#5452](https://github.com/matrix-org/matrix-react-sdk/pull/5452) + * Fix tetris effect (holes) in read receipts + [\#5697](https://github.com/matrix-org/matrix-react-sdk/pull/5697) + * Fixed edit for markdown images + [\#5703](https://github.com/matrix-org/matrix-react-sdk/pull/5703) + * Iterate Space Panel + [\#5702](https://github.com/matrix-org/matrix-react-sdk/pull/5702) + * Fix read receipts for compact layout + [\#5700](https://github.com/matrix-org/matrix-react-sdk/pull/5700) + * Space Store and Space Panel for Room List filtering + [\#5689](https://github.com/matrix-org/matrix-react-sdk/pull/5689) + * Log when turn creds expire + [\#5691](https://github.com/matrix-org/matrix-react-sdk/pull/5691) + * Null check for maxHeight in call view + [\#5690](https://github.com/matrix-org/matrix-react-sdk/pull/5690) + * Autocomplete invited users + [\#5687](https://github.com/matrix-org/matrix-react-sdk/pull/5687) + * Add send message button + [\#5535](https://github.com/matrix-org/matrix-react-sdk/pull/5535) + * Move call buttons to the room header + [\#5693](https://github.com/matrix-org/matrix-react-sdk/pull/5693) + * Use the default SSSS key if the default is set + [\#5638](https://github.com/matrix-org/matrix-react-sdk/pull/5638) + * Initial Spaces feature flag + [\#5668](https://github.com/matrix-org/matrix-react-sdk/pull/5668) + * Clean up code edge cases and add helpers + [\#5667](https://github.com/matrix-org/matrix-react-sdk/pull/5667) + * Clean up widgets when leaving the room + [\#5684](https://github.com/matrix-org/matrix-react-sdk/pull/5684) + * Fix read receipts? + [\#5567](https://github.com/matrix-org/matrix-react-sdk/pull/5567) + * Fix MAU usage alerts + [\#5678](https://github.com/matrix-org/matrix-react-sdk/pull/5678) + +Changes in [3.15.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.15.0) (2021-03-01) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.15.0-rc.1...v3.15.0) + +## Security notice + +matrix-react-sdk 3.15.0 fixes a moderate severity issue (CVE-2021-21320) where +the user content sandbox can be abused to trick users into opening unexpected +documents after several user interactions. The content can be opened with a +`blob` origin from the Matrix client, so it is possible for a malicious document +to access user messages and secrets. Thanks to @keerok for responsibly +disclosing this via Matrix's Security Disclosure Policy. + +## All changes + + * Upgrade to JS SDK 9.8.0 + +Changes in [3.15.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.15.0-rc.1) (2021-02-24) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.14.0...v3.15.0-rc.1) + + * Upgrade to JS SDK 9.8.0-rc.1 + * Translations update from Weblate + [\#5683](https://github.com/matrix-org/matrix-react-sdk/pull/5683) + * Fix object diffing when objects have different keys + [\#5681](https://github.com/matrix-org/matrix-react-sdk/pull/5681) + * Add if it's missing + [\#5673](https://github.com/matrix-org/matrix-react-sdk/pull/5673) + * Add email only if the verification is complete + [\#5629](https://github.com/matrix-org/matrix-react-sdk/pull/5629) + * Fix portrait videocalls + [\#5676](https://github.com/matrix-org/matrix-react-sdk/pull/5676) + * Tweak code block icon positions + [\#5643](https://github.com/matrix-org/matrix-react-sdk/pull/5643) + * Revert "Improve URL preview formatting and image upload thumbnail size" + [\#5677](https://github.com/matrix-org/matrix-react-sdk/pull/5677) + * Fix context menu leaving visible area + [\#5644](https://github.com/matrix-org/matrix-react-sdk/pull/5644) + * Jitsi conferences names, take 3 + [\#5675](https://github.com/matrix-org/matrix-react-sdk/pull/5675) + * Update isUserOnDarkTheme to take use_system_theme in account + [\#5670](https://github.com/matrix-org/matrix-react-sdk/pull/5670) + * Discard some dead code + [\#5665](https://github.com/matrix-org/matrix-react-sdk/pull/5665) + * Add developer tool to explore and edit settings + [\#5664](https://github.com/matrix-org/matrix-react-sdk/pull/5664) + * Use and create new room helpers + [\#5663](https://github.com/matrix-org/matrix-react-sdk/pull/5663) + * Clear message previews when the maximum limit is reached for history + [\#5661](https://github.com/matrix-org/matrix-react-sdk/pull/5661) + * VoIP virtual rooms, mk II + [\#5639](https://github.com/matrix-org/matrix-react-sdk/pull/5639) + * Disable chat effects when reduced motion preferred + [\#5660](https://github.com/matrix-org/matrix-react-sdk/pull/5660) + * Improve URL preview formatting and image upload thumbnail size + [\#5637](https://github.com/matrix-org/matrix-react-sdk/pull/5637) + * Fix border radius when the panel is collapsed + [\#5641](https://github.com/matrix-org/matrix-react-sdk/pull/5641) + * Use a more generic layout setting - useIRCLayout → layout + [\#5571](https://github.com/matrix-org/matrix-react-sdk/pull/5571) + * Remove redundant lockOrigin parameter from usercontent + [\#5657](https://github.com/matrix-org/matrix-react-sdk/pull/5657) + * Set ICE candidate pool size option + [\#5655](https://github.com/matrix-org/matrix-react-sdk/pull/5655) + * Prepare to encrypt when a call arrives + [\#5654](https://github.com/matrix-org/matrix-react-sdk/pull/5654) + * Use config for host signup branding + [\#5650](https://github.com/matrix-org/matrix-react-sdk/pull/5650) + * Use randomly generated conference names for Jitsi + [\#5649](https://github.com/matrix-org/matrix-react-sdk/pull/5649) + * Modified regex to account for an immediate new line after slash commands + [\#5647](https://github.com/matrix-org/matrix-react-sdk/pull/5647) + * Fix codeblock scrollbar color for non-Firefox + [\#5642](https://github.com/matrix-org/matrix-react-sdk/pull/5642) + * Fix codeblock scrollbar colors + [\#5630](https://github.com/matrix-org/matrix-react-sdk/pull/5630) + * Added loading and disabled the button while searching for server + [\#5634](https://github.com/matrix-org/matrix-react-sdk/pull/5634) + +Changes in [3.14.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.14.0) (2021-02-16) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.14.0-rc.1...v3.14.0) + + * Upgrade to JS SDK 9.7.0 + * [Release] Use config for host signup branding + [\#5651](https://github.com/matrix-org/matrix-react-sdk/pull/5651) + +Changes in [3.14.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.14.0-rc.1) (2021-02-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.13.1...v3.14.0-rc.1) + + * Upgrade to JS SDK 9.7.0-rc.1 + * Translations update from Weblate + [\#5636](https://github.com/matrix-org/matrix-react-sdk/pull/5636) + * Add host signup modal with iframe + [\#5450](https://github.com/matrix-org/matrix-react-sdk/pull/5450) + * Fix duplication of codeblock elements + [\#5633](https://github.com/matrix-org/matrix-react-sdk/pull/5633) + * Handle undefined call stats + [\#5632](https://github.com/matrix-org/matrix-react-sdk/pull/5632) + * Avoid delayed displaying of sources in source picker + [\#5631](https://github.com/matrix-org/matrix-react-sdk/pull/5631) + * Give breadcrumbs toolbar an accessibility label. + [\#5628](https://github.com/matrix-org/matrix-react-sdk/pull/5628) + * Fix the %s in logs + [\#5627](https://github.com/matrix-org/matrix-react-sdk/pull/5627) + * Fix jumpy notifications settings UI + [\#5625](https://github.com/matrix-org/matrix-react-sdk/pull/5625) + * Improve displaying of code blocks + [\#5559](https://github.com/matrix-org/matrix-react-sdk/pull/5559) + * Fix desktop Matrix screen sharing and add a screen/window picker + [\#5525](https://github.com/matrix-org/matrix-react-sdk/pull/5525) + * Call "MatrixClientPeg.get()" only once in method "findOverrideMuteRule" + [\#5498](https://github.com/matrix-org/matrix-react-sdk/pull/5498) + * Close current modal when session is logged out + [\#5616](https://github.com/matrix-org/matrix-react-sdk/pull/5616) + * Switch room explorer list to CSS grid + [\#5551](https://github.com/matrix-org/matrix-react-sdk/pull/5551) + * Improve SSO login start screen and 3pid invite handling somewhat + [\#5622](https://github.com/matrix-org/matrix-react-sdk/pull/5622) + * Don't jump to bottom on reaction + [\#5621](https://github.com/matrix-org/matrix-react-sdk/pull/5621) + * Fix several profile settings oddities + [\#5620](https://github.com/matrix-org/matrix-react-sdk/pull/5620) + * Add option to hide the stickers button in the composer + [\#5530](https://github.com/matrix-org/matrix-react-sdk/pull/5530) + * Fix confusing right panel button behaviour + [\#5598](https://github.com/matrix-org/matrix-react-sdk/pull/5598) + * Fix jumping timestamp if hovering a message with e2e indicator bar + [\#5601](https://github.com/matrix-org/matrix-react-sdk/pull/5601) + * Fix avatar and trash alignment + [\#5614](https://github.com/matrix-org/matrix-react-sdk/pull/5614) + * Fix z-index of stickerpicker + [\#5617](https://github.com/matrix-org/matrix-react-sdk/pull/5617) + * Fix permalink via parsing for rooms + [\#5615](https://github.com/matrix-org/matrix-react-sdk/pull/5615) + * Fix "Terms and Conditions" checkbox alignment + [\#5613](https://github.com/matrix-org/matrix-react-sdk/pull/5613) + * Fix flair height after accent changes + [\#5611](https://github.com/matrix-org/matrix-react-sdk/pull/5611) + * Iterate Social Logins work around edge cases and branding + [\#5609](https://github.com/matrix-org/matrix-react-sdk/pull/5609) + * Lock widget room ID when added + [\#5607](https://github.com/matrix-org/matrix-react-sdk/pull/5607) + * Better errors for SSO failures + [\#5605](https://github.com/matrix-org/matrix-react-sdk/pull/5605) + * Increase language search bar width + [\#5549](https://github.com/matrix-org/matrix-react-sdk/pull/5549) + * Scroll to bottom on message_sent + [\#5565](https://github.com/matrix-org/matrix-react-sdk/pull/5565) + * Fix new rooms being titled 'Empty Room' + [\#5587](https://github.com/matrix-org/matrix-react-sdk/pull/5587) + * Fix saving the collapsed state of the left panel + [\#5593](https://github.com/matrix-org/matrix-react-sdk/pull/5593) + * Fix app-url hint in the e2e-test run script output + [\#5600](https://github.com/matrix-org/matrix-react-sdk/pull/5600) + * Fix RoomView re-mounting breaking peeking + [\#5602](https://github.com/matrix-org/matrix-react-sdk/pull/5602) + * Tweak a few room ID checks + [\#5592](https://github.com/matrix-org/matrix-react-sdk/pull/5592) + * Remove pills from event permalinks with text + [\#5575](https://github.com/matrix-org/matrix-react-sdk/pull/5575) + +Changes in [3.13.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.13.1) (2021-02-04) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.13.0...v3.13.1) + + * [Release] Fix z-index of stickerpicker + [\#5618](https://github.com/matrix-org/matrix-react-sdk/pull/5618) + +Changes in [3.13.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.13.0) (2021-02-03) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.13.0-rc.1...v3.13.0) + + * Upgrade to JS SDK 9.6.0 + * [Release] Fix flair height after accent changes + [\#5612](https://github.com/matrix-org/matrix-react-sdk/pull/5612) + * [Release] Iterate Social Logins work around edge cases and branding + [\#5610](https://github.com/matrix-org/matrix-react-sdk/pull/5610) + * [Release] Lock widget room ID when added + [\#5608](https://github.com/matrix-org/matrix-react-sdk/pull/5608) + * [Release] Better errors for SSO failures + [\#5606](https://github.com/matrix-org/matrix-react-sdk/pull/5606) + * [Release] Fix RoomView re-mounting breaking peeking + [\#5603](https://github.com/matrix-org/matrix-react-sdk/pull/5603) + +Changes in [3.13.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.13.0-rc.1) (2021-01-29) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.12.1...v3.13.0-rc.1) + + * Upgrade to JS SDK 9.6.0-rc.1 + * Translations update from Weblate + [\#5597](https://github.com/matrix-org/matrix-react-sdk/pull/5597) + * Support managed hybrid widgets from config + [\#5596](https://github.com/matrix-org/matrix-react-sdk/pull/5596) + * Add managed hybrid call widgets when supported + [\#5594](https://github.com/matrix-org/matrix-react-sdk/pull/5594) + * Tweak mobile guide toast copy + [\#5595](https://github.com/matrix-org/matrix-react-sdk/pull/5595) + * Improve SSO auth flow + [\#5578](https://github.com/matrix-org/matrix-react-sdk/pull/5578) + * Add optional mobile guide toast + [\#5586](https://github.com/matrix-org/matrix-react-sdk/pull/5586) + * Fix invisible text after logging out in the dark theme + [\#5588](https://github.com/matrix-org/matrix-react-sdk/pull/5588) + * Fix escape for cancelling replies + [\#5591](https://github.com/matrix-org/matrix-react-sdk/pull/5591) + * Update widget-api to beta.12 + [\#5589](https://github.com/matrix-org/matrix-react-sdk/pull/5589) + * Add commands for DM conversion + [\#5540](https://github.com/matrix-org/matrix-react-sdk/pull/5540) + * Run a UI refresh over the OIDC Exchange confirmation dialog + [\#5580](https://github.com/matrix-org/matrix-react-sdk/pull/5580) + * Allow stickerpickers the legacy "visibility" capability + [\#5581](https://github.com/matrix-org/matrix-react-sdk/pull/5581) + * Hide local video if it is muted + [\#5529](https://github.com/matrix-org/matrix-react-sdk/pull/5529) + * Don't use name width in reply thread for IRC layout + [\#5518](https://github.com/matrix-org/matrix-react-sdk/pull/5518) + * Update code_style.md + [\#5554](https://github.com/matrix-org/matrix-react-sdk/pull/5554) + * Fix Czech capital letters like ŠČŘ... + [\#5569](https://github.com/matrix-org/matrix-react-sdk/pull/5569) + * Add optional search shortcut + [\#5548](https://github.com/matrix-org/matrix-react-sdk/pull/5548) + * Fix Sudden 'find a room' UI shows up when the only room moves to favourites + [\#5584](https://github.com/matrix-org/matrix-react-sdk/pull/5584) + * Increase PersistedElement's z-index + [\#5568](https://github.com/matrix-org/matrix-react-sdk/pull/5568) + * Remove check that prevents Jitsi widgets from being unpinned + [\#5582](https://github.com/matrix-org/matrix-react-sdk/pull/5582) + * Fix Jitsi widgets causing localized tile crashes + [\#5583](https://github.com/matrix-org/matrix-react-sdk/pull/5583) + * Log candidates for calls + [\#5573](https://github.com/matrix-org/matrix-react-sdk/pull/5573) + * Upgrade deps 2021-01 + [\#5579](https://github.com/matrix-org/matrix-react-sdk/pull/5579) + * Fix "Continuing without email" dialog bug + [\#5566](https://github.com/matrix-org/matrix-react-sdk/pull/5566) + * Require registration for verification actions + [\#5574](https://github.com/matrix-org/matrix-react-sdk/pull/5574) + * Don't play the hangup sound when the call is answered from elsewhere + [\#5572](https://github.com/matrix-org/matrix-react-sdk/pull/5572) + * Move to newer base image for end-to-end tests + [\#5570](https://github.com/matrix-org/matrix-react-sdk/pull/5570) + * Update widgets in the room upon join + [\#5564](https://github.com/matrix-org/matrix-react-sdk/pull/5564) + * Update AuxPanel and related buttons when widgets change or on reload + [\#5563](https://github.com/matrix-org/matrix-react-sdk/pull/5563) + * Add VoIP user mapper + [\#5560](https://github.com/matrix-org/matrix-react-sdk/pull/5560) + * Improve styling of SSO Buttons for multiple IdPs + [\#5558](https://github.com/matrix-org/matrix-react-sdk/pull/5558) + * Fixes for the general tab in the room dialog + [\#5522](https://github.com/matrix-org/matrix-react-sdk/pull/5522) + * fix issue 16226 to allow switching back to default HS. + [\#5561](https://github.com/matrix-org/matrix-react-sdk/pull/5561) + * Support room-defined widget layouts + [\#5553](https://github.com/matrix-org/matrix-react-sdk/pull/5553) + * Change a bunch of strings from Recovery Key/Phrase to Security Key/Phrase + [\#5533](https://github.com/matrix-org/matrix-react-sdk/pull/5533) + * Give a bigger target area to AppsDrawer vertical resizer + [\#5557](https://github.com/matrix-org/matrix-react-sdk/pull/5557) + * Fix minimized left panel avatar alignment + [\#5493](https://github.com/matrix-org/matrix-react-sdk/pull/5493) + * Ensure component index has been written before renaming + [\#5556](https://github.com/matrix-org/matrix-react-sdk/pull/5556) + * Fixed continue button while selecting home-server + [\#5552](https://github.com/matrix-org/matrix-react-sdk/pull/5552) + * Wire up MSC2931 widget navigation + [\#5527](https://github.com/matrix-org/matrix-react-sdk/pull/5527) + * Various fixes for Bridge Info page (MSC2346) + [\#5454](https://github.com/matrix-org/matrix-react-sdk/pull/5454) + * Use room-specific listeners for message preview and community prototype + [\#5547](https://github.com/matrix-org/matrix-react-sdk/pull/5547) + * Fix some misc. React warnings when viewing timeline + [\#5546](https://github.com/matrix-org/matrix-react-sdk/pull/5546) + * Use device storage for allowed widgets if account data not supported + [\#5544](https://github.com/matrix-org/matrix-react-sdk/pull/5544) + * Fix incoming call box on dark theme + [\#5542](https://github.com/matrix-org/matrix-react-sdk/pull/5542) + * Convert DMRoomMap to typescript + [\#5541](https://github.com/matrix-org/matrix-react-sdk/pull/5541) + * Add in-call dialpad for DTMF sending + [\#5532](https://github.com/matrix-org/matrix-react-sdk/pull/5532) + +Changes in [3.12.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.12.1) (2021-01-26) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.12.0...v3.12.1) + + * Upgrade to JS SDK 9.5.1 + +Changes in [3.12.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.12.0) (2021-01-18) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.12.0-rc.1...v3.12.0) + + * Upgrade to JS SDK 9.5.0 + * Fix incoming call box on dark theme + [\#5543](https://github.com/matrix-org/matrix-react-sdk/pull/5543) + +Changes in [3.12.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.12.0-rc.1) (2021-01-13) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.11.1...v3.12.0-rc.1) + + * Upgrade to JS SDK 9.5.0-rc.1 + * Fix soft crash on soft logout page + [\#5539](https://github.com/matrix-org/matrix-react-sdk/pull/5539) + * Translations update from Weblate + [\#5538](https://github.com/matrix-org/matrix-react-sdk/pull/5538) + * Run TypeScript tests + [\#5537](https://github.com/matrix-org/matrix-react-sdk/pull/5537) + * Add a basic widget explorer to devtools (per-room) + [\#5528](https://github.com/matrix-org/matrix-react-sdk/pull/5528) + * Add to security key field + [\#5534](https://github.com/matrix-org/matrix-react-sdk/pull/5534) + * Fix avatar upload prompt/tooltip floating wrong and permissions + [\#5526](https://github.com/matrix-org/matrix-react-sdk/pull/5526) + * Add a dialpad UI for PSTN lookup + [\#5523](https://github.com/matrix-org/matrix-react-sdk/pull/5523) + * Basic call transfer initiation support + [\#5494](https://github.com/matrix-org/matrix-react-sdk/pull/5494) + * Fix #15988 + [\#5524](https://github.com/matrix-org/matrix-react-sdk/pull/5524) + * Bump node-notifier from 8.0.0 to 8.0.1 + [\#5520](https://github.com/matrix-org/matrix-react-sdk/pull/5520) + * Use TypeScript source for development, swap to build during release + [\#5503](https://github.com/matrix-org/matrix-react-sdk/pull/5503) + * Look for emoji in the body that will be displayed + [\#5517](https://github.com/matrix-org/matrix-react-sdk/pull/5517) + * Bump ini from 1.3.5 to 1.3.7 + [\#5486](https://github.com/matrix-org/matrix-react-sdk/pull/5486) + * Recognise `*.element.io` links as Element permalinks + [\#5514](https://github.com/matrix-org/matrix-react-sdk/pull/5514) + * Fixes for call UI + [\#5509](https://github.com/matrix-org/matrix-react-sdk/pull/5509) + * Add a snowfall chat effect (with /snowfall command) + [\#5511](https://github.com/matrix-org/matrix-react-sdk/pull/5511) + * fireworks effect + [\#5507](https://github.com/matrix-org/matrix-react-sdk/pull/5507) + * Don't play call end sound for calls that never started + [\#5506](https://github.com/matrix-org/matrix-react-sdk/pull/5506) + * Add /tableflip slash command + [\#5485](https://github.com/matrix-org/matrix-react-sdk/pull/5485) + * Import from src in IncomingCallBox.tsx + [\#5504](https://github.com/matrix-org/matrix-react-sdk/pull/5504) + * Social Login support both https and mxc icons + [\#5499](https://github.com/matrix-org/matrix-react-sdk/pull/5499) + * Fix padding in confirmation email registration prompt + [\#5501](https://github.com/matrix-org/matrix-react-sdk/pull/5501) + * Fix room list help prompt alignment + [\#5500](https://github.com/matrix-org/matrix-react-sdk/pull/5500) + +Changes in [3.11.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.11.1) (2020-12-21) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.11.0...v3.11.1) + + * Upgrade JS SDK to 9.4.1 + +Changes in [3.11.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.11.0) (2020-12-21) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.11.0-rc.2...v3.11.0) + + * Upgrade JS SDK to 9.4.0 + * [Release] Look for emoji in the body that will be displayed + [\#5519](https://github.com/matrix-org/matrix-react-sdk/pull/5519) + * [Release] Recognise `*.element.io` links as Element permalinks + [\#5516](https://github.com/matrix-org/matrix-react-sdk/pull/5516) + * [Release] Fixes for call UI + [\#5513](https://github.com/matrix-org/matrix-react-sdk/pull/5513) + * [RELEASE] Add a snowfall chat effect (with /snowfall command) + [\#5512](https://github.com/matrix-org/matrix-react-sdk/pull/5512) + * [Release] Fix padding in confirmation email registration prompt + [\#5502](https://github.com/matrix-org/matrix-react-sdk/pull/5502) + +Changes in [3.11.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.11.0-rc.2) (2020-12-16) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.11.0-rc.1...v3.11.0-rc.2) + + * Upgrade JS SDK to 9.4.0-rc.2 + +Changes in [3.11.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.11.0-rc.1) (2020-12-16) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.10.0...v3.11.0-rc.1) + + * Upgrade JS SDK to 9.4.0-rc.1 + * Translations update from Weblate + [\#5497](https://github.com/matrix-org/matrix-react-sdk/pull/5497) + * Unregister from the dispatcher in CallHandler + [\#5495](https://github.com/matrix-org/matrix-react-sdk/pull/5495) + * Better adhere to MSC process + [\#5496](https://github.com/matrix-org/matrix-react-sdk/pull/5496) + * Use random pickle key on all platforms + [\#5483](https://github.com/matrix-org/matrix-react-sdk/pull/5483) + * Fix mx_MemberList icons + [\#5492](https://github.com/matrix-org/matrix-react-sdk/pull/5492) + * Convert InviteDialog to TypeScript + [\#5491](https://github.com/matrix-org/matrix-react-sdk/pull/5491) + * Add keyboard shortcut for emoji reactions + [\#5425](https://github.com/matrix-org/matrix-react-sdk/pull/5425) + * Run chat effects on events sent by widgets too + [\#5488](https://github.com/matrix-org/matrix-react-sdk/pull/5488) + * Fix being unable to pin widgets + [\#5487](https://github.com/matrix-org/matrix-react-sdk/pull/5487) + * Line 1 / 2 Support + [\#5468](https://github.com/matrix-org/matrix-react-sdk/pull/5468) + * Remove impossible labs feature: sending hidden read receipts + [\#5484](https://github.com/matrix-org/matrix-react-sdk/pull/5484) + * Fix height of Remote Video in call + [\#5456](https://github.com/matrix-org/matrix-react-sdk/pull/5456) + * Add UI for hold functionality + [\#5446](https://github.com/matrix-org/matrix-react-sdk/pull/5446) + * Allow SearchBox to expand to fill width + [\#5411](https://github.com/matrix-org/matrix-react-sdk/pull/5411) + * Use room alias in generated permalink for rooms + [\#5451](https://github.com/matrix-org/matrix-react-sdk/pull/5451) + * Only show confetti if the current room is receiving an appropriate event + [\#5482](https://github.com/matrix-org/matrix-react-sdk/pull/5482) + * Throttle RoomState.members handler to improve performance + [\#5481](https://github.com/matrix-org/matrix-react-sdk/pull/5481) + * Handle manual hs urls better for the server picker + [\#5477](https://github.com/matrix-org/matrix-react-sdk/pull/5477) + * Add Olm as a dev dependency for types + [\#5479](https://github.com/matrix-org/matrix-react-sdk/pull/5479) + * Hide Invite to this room CTA if no permission + [\#5476](https://github.com/matrix-org/matrix-react-sdk/pull/5476) + * Fix width of underline in server picker dialog + [\#5478](https://github.com/matrix-org/matrix-react-sdk/pull/5478) + * Fix confetti room unread state check + [\#5475](https://github.com/matrix-org/matrix-react-sdk/pull/5475) + * Show confetti in a chat room on command or emoji + [\#5140](https://github.com/matrix-org/matrix-react-sdk/pull/5140) + * Fix inverted settings default value + [\#5391](https://github.com/matrix-org/matrix-react-sdk/pull/5391) + * Improve usability of the Server Picker Dialog + [\#5474](https://github.com/matrix-org/matrix-react-sdk/pull/5474) + * Fix typos in some strings + [\#5473](https://github.com/matrix-org/matrix-react-sdk/pull/5473) + * Bump highlight.js from 10.1.2 to 10.4.1 + [\#5472](https://github.com/matrix-org/matrix-react-sdk/pull/5472) + * Remove old app test script path + [\#5471](https://github.com/matrix-org/matrix-react-sdk/pull/5471) + * add support for giving reason when redacting + [\#5260](https://github.com/matrix-org/matrix-react-sdk/pull/5260) + * Add support for Netlify to fetchdep script + [\#5469](https://github.com/matrix-org/matrix-react-sdk/pull/5469) + * Nest other layers inside on automation + [\#5467](https://github.com/matrix-org/matrix-react-sdk/pull/5467) + * Rebrand various CI scripts and modules + [\#5466](https://github.com/matrix-org/matrix-react-sdk/pull/5466) + * Add more widget sanity checking + [\#5462](https://github.com/matrix-org/matrix-react-sdk/pull/5462) + * Fix React complaining about unknown DOM props + [\#5465](https://github.com/matrix-org/matrix-react-sdk/pull/5465) + * Jump to home page when leaving a room + [\#5464](https://github.com/matrix-org/matrix-react-sdk/pull/5464) + * Fix SSO buttons for Social Logins + [\#5463](https://github.com/matrix-org/matrix-react-sdk/pull/5463) + * Social Login and login delight tweaks + [\#5426](https://github.com/matrix-org/matrix-react-sdk/pull/5426) + +Changes in [3.10.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.10.0) (2020-12-07) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.10.0-rc.1...v3.10.0) + + * Upgrade to JS SDK 9.3.0 + +Changes in [3.10.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.10.0-rc.1) (2020-12-02) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.9.0...v3.10.0-rc.1) + + * Upgrade to JS SDK 9.3.0-rc.1 + * Translations update from Weblate + [\#5461](https://github.com/matrix-org/matrix-react-sdk/pull/5461) + * Fix VoIP call plinth on dark theme + [\#5460](https://github.com/matrix-org/matrix-react-sdk/pull/5460) + * Add sanity checking around widget pinning + [\#5459](https://github.com/matrix-org/matrix-react-sdk/pull/5459) + * Update i18n for Appearance User Settings + [\#5457](https://github.com/matrix-org/matrix-react-sdk/pull/5457) + * Only show 'answered elsewhere' if we tried to answer too + [\#5455](https://github.com/matrix-org/matrix-react-sdk/pull/5455) + * Fixed Avatar for 3PID invites + [\#5442](https://github.com/matrix-org/matrix-react-sdk/pull/5442) + * Slightly better error if we can't capture user media + [\#5449](https://github.com/matrix-org/matrix-react-sdk/pull/5449) + * Make it possible in-code to hide rooms from the room list + [\#5445](https://github.com/matrix-org/matrix-react-sdk/pull/5445) + * Fix the stickerpicker + [\#5447](https://github.com/matrix-org/matrix-react-sdk/pull/5447) + * Add live password validation to change password dialog + [\#5436](https://github.com/matrix-org/matrix-react-sdk/pull/5436) + * LaTeX rendering in element-web using KaTeX + [\#5244](https://github.com/matrix-org/matrix-react-sdk/pull/5244) + * Add lifecycle customisation point after logout + [\#5448](https://github.com/matrix-org/matrix-react-sdk/pull/5448) + * Simplify UserMenu for Guests as they can't use most of the options + [\#5421](https://github.com/matrix-org/matrix-react-sdk/pull/5421) + * Fix known issues with modal widgets + [\#5444](https://github.com/matrix-org/matrix-react-sdk/pull/5444) + * Fix existing widgets not having approved capabilities for their function + [\#5443](https://github.com/matrix-org/matrix-react-sdk/pull/5443) + * Use the WidgetDriver to run OIDC requests + [\#5440](https://github.com/matrix-org/matrix-react-sdk/pull/5440) + * Add a customisation point for widget permissions and fix amnesia issues + [\#5439](https://github.com/matrix-org/matrix-react-sdk/pull/5439) + * Fix Widget event notification text including spurious space + [\#5441](https://github.com/matrix-org/matrix-react-sdk/pull/5441) + * Move call listener out of MatrixChat + [\#5438](https://github.com/matrix-org/matrix-react-sdk/pull/5438) + * New Look in-Call View + [\#5432](https://github.com/matrix-org/matrix-react-sdk/pull/5432) + * Support arbitrary widgets sticking to the screen + sending stickers + [\#5435](https://github.com/matrix-org/matrix-react-sdk/pull/5435) + * Auth typescripting and validation tweaks + [\#5433](https://github.com/matrix-org/matrix-react-sdk/pull/5433) + * Add new widget API actions for changing rooms and sending/receiving events + [\#5385](https://github.com/matrix-org/matrix-react-sdk/pull/5385) + * Revert room header click behaviour to opening room settings + [\#5434](https://github.com/matrix-org/matrix-react-sdk/pull/5434) + * Add option to send/edit a message with Ctrl + Enter / Command + Enter + [\#5160](https://github.com/matrix-org/matrix-react-sdk/pull/5160) + * Add Analytics instrumentation to the Homepage + [\#5409](https://github.com/matrix-org/matrix-react-sdk/pull/5409) + * Fix encrypted video playback in Chrome-based browsers + [\#5430](https://github.com/matrix-org/matrix-react-sdk/pull/5430) + * Add border-radius for video + [\#5333](https://github.com/matrix-org/matrix-react-sdk/pull/5333) + * Push name to the end, near text, in IRC layout + [\#5166](https://github.com/matrix-org/matrix-react-sdk/pull/5166) + * Disable notifications for the room you have recently been active in + [\#5325](https://github.com/matrix-org/matrix-react-sdk/pull/5325) + * Search through the list of unfiltered rooms rather than the rooms in the + state which are already filtered by the search text + [\#5331](https://github.com/matrix-org/matrix-react-sdk/pull/5331) + * Lighten blockquote colour in dark mode + [\#5353](https://github.com/matrix-org/matrix-react-sdk/pull/5353) + * Specify community description img must be mxc urls + [\#5364](https://github.com/matrix-org/matrix-react-sdk/pull/5364) + * Add keyboard shortcut to close the current conversation + [\#5253](https://github.com/matrix-org/matrix-react-sdk/pull/5253) + * Redirect user home from auth screens if they are already logged in + [\#5423](https://github.com/matrix-org/matrix-react-sdk/pull/5423) + +Changes in [3.9.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.9.0) (2020-11-23) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.9.0-rc.1...v3.9.0) + + * Upgrade JS SDK to 9.2.0 + * [Release] Fix encrypted video playback in Chrome-based browsers + [\#5431](https://github.com/matrix-org/matrix-react-sdk/pull/5431) + +Changes in [3.9.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.9.0-rc.1) (2020-11-18) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.8.0...v3.9.0-rc.1) + + * Upgrade JS SDK to 9.2.0-rc.1 + * Translations update from Weblate + [\#5429](https://github.com/matrix-org/matrix-react-sdk/pull/5429) + * Fix message search summary text + [\#5428](https://github.com/matrix-org/matrix-react-sdk/pull/5428) + * Shrink new room intro top margin to half for encryption bubble tile + [\#5427](https://github.com/matrix-org/matrix-react-sdk/pull/5427) + * Small delight tweaks to improve rough corners in the app + [\#5418](https://github.com/matrix-org/matrix-react-sdk/pull/5418) + * Fix DM logic to always pick a more reliable DM room + [\#5424](https://github.com/matrix-org/matrix-react-sdk/pull/5424) + * Update styling of the Analytics toast + [\#5408](https://github.com/matrix-org/matrix-react-sdk/pull/5408) + * Fix vertical centering of the Homepage and button layout + [\#5420](https://github.com/matrix-org/matrix-react-sdk/pull/5420) + * Fix BaseAvatar sometimes messing up and duplicating the url + [\#5422](https://github.com/matrix-org/matrix-react-sdk/pull/5422) + * Disable buttons when required by MSC2790 + [\#5412](https://github.com/matrix-org/matrix-react-sdk/pull/5412) + * Fix drag drop file to upload for Safari + [\#5414](https://github.com/matrix-org/matrix-react-sdk/pull/5414) + * Fix poorly i18n'd string + [\#5416](https://github.com/matrix-org/matrix-react-sdk/pull/5416) + * Fix the feedback not closing without feedback/countly + [\#5417](https://github.com/matrix-org/matrix-react-sdk/pull/5417) + * Fix New Room Intro invite to this room button + [\#5419](https://github.com/matrix-org/matrix-react-sdk/pull/5419) + * Change how we expose Role in User Info and hide in DMs + [\#5413](https://github.com/matrix-org/matrix-react-sdk/pull/5413) + * Disallow sending of empty messages + [\#5390](https://github.com/matrix-org/matrix-react-sdk/pull/5390) + * hide some validation tooltips if fields are valid. + [\#5403](https://github.com/matrix-org/matrix-react-sdk/pull/5403) + * Improvements around new room empty space interactions + [\#5398](https://github.com/matrix-org/matrix-react-sdk/pull/5398) + * Implement call hold + [\#5366](https://github.com/matrix-org/matrix-react-sdk/pull/5366) + * Fix Skeleton UI showing up when not intended. + [\#5407](https://github.com/matrix-org/matrix-react-sdk/pull/5407) + * Close context menu when user clicks the Home button + [\#5406](https://github.com/matrix-org/matrix-react-sdk/pull/5406) + * Skip e2ee warn logout prompt if user has no megolm sessions to lose + [\#5410](https://github.com/matrix-org/matrix-react-sdk/pull/5410) + * Allow country names to be translated + [\#5405](https://github.com/matrix-org/matrix-react-sdk/pull/5405) + * Support thirdparty lookup for phone numbers + [\#5396](https://github.com/matrix-org/matrix-react-sdk/pull/5396) + * Change "Password" to "New Password" + [\#5371](https://github.com/matrix-org/matrix-react-sdk/pull/5371) + * Add customisation point for dehydration key + [\#5397](https://github.com/matrix-org/matrix-react-sdk/pull/5397) + * Rebrand Riot -> Element in the permalink classes + [\#5386](https://github.com/matrix-org/matrix-react-sdk/pull/5386) + * Invite / Create DM UX tweaks + [\#5387](https://github.com/matrix-org/matrix-react-sdk/pull/5387) + * Tweaks to toasts and post-registration landing + [\#5383](https://github.com/matrix-org/matrix-react-sdk/pull/5383) + +Changes in [3.8.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.8.0) (2020-11-09) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.8.0-rc.1...v3.8.0) + + * Upgrade JS SDK to 9.1.0 + +Changes in [3.8.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.8.0-rc.1) (2020-11-04) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.1...v3.8.0-rc.1) + + * Upgrade JS SDK to 9.1.0-rc.1 + * Log when saving profile + [\#5394](https://github.com/matrix-org/matrix-react-sdk/pull/5394) + * Translations update from Weblate + [\#5395](https://github.com/matrix-org/matrix-react-sdk/pull/5395) + * Hide prompt to add email for notifications if 3pid ui feature is off + [\#5392](https://github.com/matrix-org/matrix-react-sdk/pull/5392) + * Fix room list message preview copy for hangup events + [\#5388](https://github.com/matrix-org/matrix-react-sdk/pull/5388) + * Track UISIs as Countly Events + [\#5382](https://github.com/matrix-org/matrix-react-sdk/pull/5382) + * Don't let users accidentally redact ACL events + [\#5384](https://github.com/matrix-org/matrix-react-sdk/pull/5384) + * Two more easy files to remove from eslintignore + [\#5378](https://github.com/matrix-org/matrix-react-sdk/pull/5378) + * Fix Widget OpenID Permissions for realsies + [\#5381](https://github.com/matrix-org/matrix-react-sdk/pull/5381) + * Fix regression with OpenID permissions on widgets + [\#5380](https://github.com/matrix-org/matrix-react-sdk/pull/5380) + * Fix room directory events happening in the wrong order for Funnels + [\#5379](https://github.com/matrix-org/matrix-react-sdk/pull/5379) + * Remove a couple more files from eslintignore + [\#5377](https://github.com/matrix-org/matrix-react-sdk/pull/5377) + * Fix countly method bindings and errors + [\#5376](https://github.com/matrix-org/matrix-react-sdk/pull/5376) + * Fix a bunch of silly lint errors + [\#5375](https://github.com/matrix-org/matrix-react-sdk/pull/5375) + * Typescript: ImageUtils + [\#5374](https://github.com/matrix-org/matrix-react-sdk/pull/5374) + * Convert AuxPanel to TypeScript + [\#5373](https://github.com/matrix-org/matrix-react-sdk/pull/5373) + * Only pass metrics if they exist otherwise Countly will be unhappy! + [\#5372](https://github.com/matrix-org/matrix-react-sdk/pull/5372) + * Fix CountlyAnalytics NPE on MatrixClientPeg + [\#5370](https://github.com/matrix-org/matrix-react-sdk/pull/5370) + * fix CountlyAnalytics canEnable on wrong target + [\#5369](https://github.com/matrix-org/matrix-react-sdk/pull/5369) + * Initial Countly work + [\#5365](https://github.com/matrix-org/matrix-react-sdk/pull/5365) + * Fix videos not playing in non-encrypted rooms + [\#5368](https://github.com/matrix-org/matrix-react-sdk/pull/5368) + * Fix custom tag layout which regressed in #5309 + [\#5367](https://github.com/matrix-org/matrix-react-sdk/pull/5367) + * Watch replyToEvent at RoomView to prevent races + [\#5360](https://github.com/matrix-org/matrix-react-sdk/pull/5360) + * Add a UI Feature flag for room history settings + [\#5362](https://github.com/matrix-org/matrix-react-sdk/pull/5362) + * Hide inline images when preference disabled + [\#5361](https://github.com/matrix-org/matrix-react-sdk/pull/5361) + * Fix React warning by moving handler to each button + [\#5359](https://github.com/matrix-org/matrix-react-sdk/pull/5359) + * Do not preload encrypted videos|images unless autoplay or thumbnailing is on + [\#5352](https://github.com/matrix-org/matrix-react-sdk/pull/5352) + * Fix theme variable passed to Jitsi + [\#5357](https://github.com/matrix-org/matrix-react-sdk/pull/5357) + * docs: added comment explanation + [\#5349](https://github.com/matrix-org/matrix-react-sdk/pull/5349) + * Modal Widgets - MSC2790 + [\#5252](https://github.com/matrix-org/matrix-react-sdk/pull/5252) + * Widgets fixes + [\#5350](https://github.com/matrix-org/matrix-react-sdk/pull/5350) + * Fix User Menu avatar colouring being based on wrong string + [\#5348](https://github.com/matrix-org/matrix-react-sdk/pull/5348) + * Support 'answered elsewhere' + [\#5345](https://github.com/matrix-org/matrix-react-sdk/pull/5345) + Changes in [3.7.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.1) (2020-10-28) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.0...v3.7.1) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.rst rename to CONTRIBUTING.md diff --git a/README.md b/README.md index 73afe34df0..b3e96ef001 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Platform Targets: * WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox. * Mobile Web is not currently a target platform - instead please use the native iOS (https://github.com/matrix-org/matrix-ios-kit) and Android - (https://github.com/matrix-org/matrix-android-sdk) SDKs. + (https://github.com/matrix-org/matrix-android-sdk2) SDKs. All code lands on the `develop` branch - `master` is only used for stable releases. **Please file PRs against `develop`!!** diff --git a/__mocks__/FontManager.js b/__mocks__/FontManager.js new file mode 100644 index 0000000000..41eab4bf94 --- /dev/null +++ b/__mocks__/FontManager.js @@ -0,0 +1,6 @@ +// Stub out FontManager for tests as it doesn't validate anything we don't already know given +// our fixed test environment and it requires the installation of node-canvas. + +module.exports = { + fixupColorFonts: () => Promise.resolve(), +}; diff --git a/__mocks__/empty.js b/__mocks__/empty.js new file mode 100644 index 0000000000..51fb4fe937 --- /dev/null +++ b/__mocks__/empty.js @@ -0,0 +1,2 @@ +// Yes, this is empty. +module.exports = {}; diff --git a/__mocks__/workerMock.js b/__mocks__/workerMock.js new file mode 100644 index 0000000000..6ee585673e --- /dev/null +++ b/__mocks__/workerMock.js @@ -0,0 +1 @@ +module.exports = jest.fn(); diff --git a/babel.config.js b/babel.config.js index d5a97d56ce..f00e83652c 100644 --- a/babel.config.js +++ b/babel.config.js @@ -3,12 +3,14 @@ module.exports = { "presets": [ ["@babel/preset-env", { "targets": [ - "last 2 Chrome versions", "last 2 Firefox versions", "last 2 Safari versions" + "last 2 Chrome versions", + "last 2 Firefox versions", + "last 2 Safari versions", + "last 2 Edge versions", ], }], "@babel/preset-typescript", - "@babel/preset-flow", - "@babel/preset-react" + "@babel/preset-react", ], "plugins": [ ["@babel/plugin-proposal-decorators", {legacy: true}], @@ -16,8 +18,7 @@ module.exports = { "@babel/plugin-proposal-numeric-separator", "@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-object-rest-spread", - "@babel/plugin-transform-flow-comments", "@babel/plugin-syntax-dynamic-import", - "@babel/plugin-transform-runtime" - ] + "@babel/plugin-transform-runtime", + ], }; diff --git a/code_style.md b/code_style.md index fe04d2cc3d..5747540a76 100644 --- a/code_style.md +++ b/code_style.md @@ -35,12 +35,6 @@ General Style - lowerCamelCase for functions and variables. - Single line ternary operators are fine. - UPPER_SNAKE_CASE for constants -- Single quotes for strings by default, for consistency with most JavaScript styles: - - ```javascript - "bad" // Bad - 'good' // Good - ``` - Use parentheses or `` ` `` instead of `\` for line continuation where ever possible - Open braces on the same line (consistent with Node): @@ -162,7 +156,14 @@ ECMAScript - Be careful mixing arrow functions and regular functions, eg. if one function in a promise chain is an arrow function, they probably all should be. - Apart from that, newer ES features should be used whenever the author deems them to be appropriate. -- Flow annotations are welcome and encouraged. + +TypeScript +---------- +- TypeScript is preferred over the use of JavaScript +- It's desirable to convert existing JavaScript files to TypeScript. TypeScript conversions should be done in small + chunks without functional changes to ease the review process. +- Use full type definitions for function parameters and return values. +- Avoid `any` types and `any` casts React ----- @@ -201,6 +202,8 @@ React this.state = { counter: 0 }; } ``` +- Prefer class components over function components and hooks (not a strict rule though) + - Think about whether your component really needs state: are you duplicating information in component state that could be derived from the model? diff --git a/docs/ciderEditor.md b/docs/ciderEditor.md index f522dc2fc4..379b6f5b51 100644 --- a/docs/ciderEditor.md +++ b/docs/ciderEditor.md @@ -21,14 +21,14 @@ caret nodes (more on that later). For these reasons it doesn't use `innerText`, `textContent` or anything similar. The model addresses any content in the editor within as an offset within this string. The caret position is thus also converted from a position in the DOM tree -to an offset in the content string. This happens in `getCaretOffsetAndText` in `dom.js`. +to an offset in the content string. This happens in `getCaretOffsetAndText` in `dom.ts`. Once the content string and caret offset is calculated, it is passed to the `update()` method of the model. The model first calculates the same content string of its current parts, basically just concatenating their text. It then looks for differences between the current and the new content string. The diffing algorithm is very basic, and assumes there is only one change around the caret offset, -so this should be very inexpensive. See `diff.js` for details. +so this should be very inexpensive. See `diff.ts` for details. The result of the diffing is the strings that were added and/or removed from the current content. These differences are then applied to the parts, @@ -51,7 +51,7 @@ which relate poorly to text input or changes, and don't need the `beforeinput` e which isn't broadly supported yet. Once the parts of the model are updated, the DOM of the editor is then reconciled -with the new model state, see `renderModel` in `render.js` for this. +with the new model state, see `renderModel` in `render.ts` for this. If the model didn't reject the input and didn't make any additional changes, this won't make any changes to the DOM at all, and should thus be fairly efficient. diff --git a/docs/media-handling.md b/docs/media-handling.md new file mode 100644 index 0000000000..a4307fb7d4 --- /dev/null +++ b/docs/media-handling.md @@ -0,0 +1,19 @@ +# Media handling + +Surely media should be as easy as just putting a URL into an `img` and calling it good, right? +Not quite. Matrix uses something called a Matrix Content URI (better known as MXC URI) to identify +content, which is then converted to a regular HTTPS URL on the homeserver. However, sometimes that +URL can change depending on deployment considerations. + +The react-sdk features a [customisation endpoint](https://github.com/vector-im/element-web/blob/develop/docs/customisations.md) +for media handling where all conversions from MXC URI to HTTPS URL happen. This is to ensure that +those obscure deployments can route all their media to the right place. + +For development, there are currently two functions available: `mediaFromMxc` and `mediaFromContent`. +The `mediaFromMxc` function should be self-explanatory. `mediaFromContent` takes an event content as +a parameter and will automatically parse out the source media and thumbnail. Both functions return +a `Media` object with a number of options on it, such as getting various common HTTPS URLs for the +media. + +**It is extremely important that all media calls are put through this customisation endpoint.** So +much so it's a lint rule to avoid accidental use of the wrong functions. diff --git a/docs/room-list-store.md b/docs/room-list-store.md index fa849e2505..6fc5f71124 100644 --- a/docs/room-list-store.md +++ b/docs/room-list-store.md @@ -6,7 +6,7 @@ It's so complicated it needs its own README. Legend: * Orange = External event. -* Purple = Deterministic flow. +* Purple = Deterministic flow. * Green = Algorithm definition. * Red = Exit condition/point. * Blue = Process definition. @@ -24,8 +24,8 @@ algorithm to call, instead of having all the logic in the room list store itself Tag sorting is effectively the comparator supplied to the list algorithm. This gives the list algorithm -the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm, -later described in this document, heavily uses the list ordering behaviour to break the tag into categories. +the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm, +later described in this document, heavily uses the list ordering behaviour to break the tag into categories. Each category then gets sorted by the appropriate tag sorting algorithm. ### Tag sorting algorithm: Alphabetical @@ -36,7 +36,7 @@ useful. ### Tag sorting algorithm: Manual -Manual sorting makes use of the `order` property present on all tags for a room, per the +Manual sorting makes use of the `order` property present on all tags for a room, per the [Matrix specification](https://matrix.org/docs/spec/client_server/r0.6.0#room-tagging). Smaller values of `order` cause rooms to appear closer to the top of the list. @@ -74,7 +74,7 @@ relative (perceived) importance to the user: set to 'All Messages'. * **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without a badge/notification count (or 'Mentions Only'/'Muted'). -* **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user +* **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user last read it. Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey @@ -82,7 +82,7 @@ above bold, etc. Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm gets applied to each category in a sub-list fashion. This should result in the red rooms (for example) -being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but +being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but collectively the tag will be sorted into categories with red being at the top. ## Sticky rooms @@ -103,48 +103,62 @@ receive another notification which causes the room to move into the topmost posi above the sticky room will move underneath to allow for the new room to take the top slot, maintaining the sticky room's position. -Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries -and thus the user can see a shift in what kinds of rooms move around their selection. An example would -be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having -the rooms above it read on another device. This would result in 1 red room and 1 other kind of room +Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries +and thus the user can see a shift in what kinds of rooms move around their selection. An example would +be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having +the rooms above it read on another device. This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain 2 rooms above the sticky room. An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain the N required rooms above the sticky room. In this case, the sticky room will simply decrease N as needed. -The N value will never increase while selection remains unchanged: adding a bunch of rooms after having +The N value will never increase while selection remains unchanged: adding a bunch of rooms after having put the sticky room in a position where it's had to decrease N will not increase N. ## Responsibilities of the store -The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets -an object containing the tags it needs to worry about and the rooms within. The room list component will -decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with +The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets +an object containing the tags it needs to worry about and the rooms within. The room list component will +decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with all kinds of filtering. ## Filtering -Filters are provided to the store as condition classes, which are then passed along to the algorithm -implementations. The implementations then get to decide how to actually filter the rooms, however in -practice the base `Algorithm` class deals with the filtering in a more optimized/generic way. +Filters are provided to the store as condition classes and have two major kinds: Prefilters and Runtime. -The results of filters get cached to avoid needlessly iterating over potentially thousands of rooms, -as the old room list store does. When a filter condition changes, it emits an update which (in this -case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a +Prefilters flush out rooms which shouldn't appear to the algorithm implementations. Typically this is +due to some higher order room list filtering (such as spaces or tags) deliberately exposing a subset of +rooms to the user. The algorithm implementations will not see a room being prefiltered out. + +Runtime filters are used for more dynamic filtering, such as the user filtering by room name. These +filters are passed along to the algorithm implementations where those implementations decide how and +when to apply the filter. In practice, the base `Algorithm` class ends up doing the heavy lifting for +optimization reasons. + +The results of runtime filters get cached to avoid needlessly iterating over potentially thousands of +rooms, as the old room list store does. When a filter condition changes, it emits an update which (in this +case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a minor subset where possible to avoid over-iterating rooms. All filter conditions are considered "stable" by the consumers, meaning that the consumer does not expect a change in the condition unless the condition says it has changed. This is intentional to maintain the caching behaviour described above. +One might ask why we don't just use prefilter conditions for everything, and the answer is one of slight +subtlety: in the cases of prefilters we are knowingly exposing the user to a workspace-style UX where +room notifications are self-contained within that workspace. Runtime filters tend to not want to affect +visible notification counts (as it doesn't want the room header to suddenly be confusing to the user as +they type), and occasionally UX like "found 2/12 rooms" is desirable. If prefiltering were used instead, +the notification counts would vary while the user was typing and "found 2/12" UX would not be possible. + ## Class breakdowns -The `RoomListStore` is the major coordinator of various algorithm implementations, which take care -of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible -for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get -defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the -user). Various list-specific utilities are also included, though they are expected to move somewhere -more general when needed. For example, the `membership` utilities could easily be moved elsewhere +The `RoomListStore` is the major coordinator of various algorithm implementations, which take care +of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible +for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get +defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the +user). Various list-specific utilities are also included, though they are expected to move somewhere +more general when needed. For example, the `membership` utilities could easily be moved elsewhere as needed. The various bits throughout the room list store should also have jsdoc of some kind to help describe diff --git a/docs/widget-layouts.md b/docs/widget-layouts.md new file mode 100644 index 0000000000..e7f72e2001 --- /dev/null +++ b/docs/widget-layouts.md @@ -0,0 +1,60 @@ +# Widget layout support + +Rooms can have a default widget layout to auto-pin certain widgets, make the container different +sizes, etc. These are defined through the `io.element.widgets.layout` state event (empty state key). + +Full example content: +```json5 +{ + "widgets": { + "first-widget-id": { + "container": "top", + "index": 0, + "width": 60, + "height": 40 + }, + "second-widget-id": { + "container": "right" + } + } +} +``` + +As shown, there are two containers possible for widgets. These containers have different behaviour +and interpret the other options differently. + +## `top` container + +This is the "App Drawer" or any pinned widgets in a room. This is by far the most versatile container +though does introduce potential usability issues upon members of the room (widgets take up space and +therefore fewer messages can be shown). + +The `index` for a widget determines which order the widgets show up in from left to right. Widgets +without an `index` will show up as the rightmost widgets. Tiebreaks (same `index` or multiple defined +without an `index`) are resolved by comparing widget IDs. A maximum of 3 widgets can be in the top +container - any which exceed this will be ignored (placed into the `right` container). Smaller numbers +represent leftmost widgets. + +The `width` is relative width within the container in percentage points. This will be clamped to a +range of 0-100 (inclusive). The widgets will attempt to scale to relative proportions when more than +100% space is allocated. For example, if 3 widgets are defined at 40% width each then the client will +attempt to show them at 33% width each. + +Note that the client may impose minimum widths on the widgets, such as a 10% minimum to avoid pinning +hidden widgets. In general, widgets defined in the 30-70% range each will be free of these restrictions. + +The `height` is not in fact applied per-widget but is recorded per-widget for potential future +capabilities in future containers. The top container will take the tallest `height` and use that for +the height of the whole container, and thus all widgets in that container. The `height` is relative +to the container, like with `width`, meaning that 100% will consume as much space as the client is +willing to sacrifice to the widget container. Like with `width`, the client may impose minimums to avoid +the container being uselessly small. Heights in the 30-100% range are generally acceptable. The height +is also clamped to be within 0-100, inclusive. + +## `right` container + +This is the default container and has no special configuration. Widgets which overflow from the top +container will be put in this container instead. Putting a widget in the right container does not +automatically show it - it only mentions that widgets should not be in another container. + +The behaviour of this container may change in the future. diff --git a/package.json b/package.json index 9689892e24..e80ed8dd5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.7.1", + "version": "3.25.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -23,18 +23,17 @@ "package.json" ], "bin": { - "reskindex": "scripts/reskindex.js", - "matrix-gen-i18n": "scripts/gen-i18n.js", - "matrix-prune-i18n": "scripts/prune-i18n.js" + "reskindex": "scripts/reskindex.js" }, - "main": "./lib/index.js", - "typings": "./lib/index.d.ts", + "main": "./src/index.js", "matrix_src_main": "./src/index.js", + "matrix_lib_main": "./lib/index.js", + "matrix_lib_typings": "./lib/index.d.ts", "scripts": { - "prepare": "yarn build", + "prepublishOnly": "yarn build", "i18n": "matrix-gen-i18n", "prunei18n": "matrix-prune-i18n", - "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", + "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && matrix-gen-i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "reskindex": "node scripts/reskindex.js -h header", "reskindex:watch": "node scripts/reskindex.js -h header -w", "rethemendex": "res/css/rethemendex.sh", @@ -46,129 +45,136 @@ "start:all": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n build,reskindex \"yarn start:build\" \"yarn reskindex:watch\"", "start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"", "lint": "yarn lint:types && yarn lint:js && yarn lint:style", - "lint:js": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", + "lint:js": "eslint --max-warnings 0 src test", "lint:types": "tsc --noEmit --jsx react", "lint:style": "stylelint 'res/css/**/*.scss'", "test": "jest", - "test:e2e": "./test/end-to-end-tests/run.sh --riot-url http://localhost:8080" + "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080", + "coverage": "yarn test --coverage" }, "dependencies": { - "@babel/runtime": "^7.10.5", - "await-lock": "^2.0.1", - "blueimp-canvas-to-blob": "^3.27.0", + "@babel/runtime": "^7.12.5", + "await-lock": "^2.1.0", + "blurhash": "^1.1.3", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", + "cheerio": "^1.0.0-rc.9", "classnames": "^2.2.6", - "commonmark": "^0.29.1", + "commonmark": "^0.29.3", "counterpart": "^0.18.6", - "diff-dom": "^4.1.6", + "diff-dom": "^4.2.2", "diff-match-patch": "^1.0.5", - "emojibase-data": "^5.0.1", - "emojibase-regex": "^4.0.1", + "emojibase-data": "^5.1.1", + "emojibase-regex": "^4.1.1", "escape-html": "^1.0.3", - "file-saver": "^1.3.8", - "filesize": "3.6.1", + "file-saver": "^2.0.5", + "filesize": "6.1.0", "flux": "2.1.1", - "focus-visible": "^5.1.0", - "fuse.js": "^2.7.4", + "focus-visible": "^5.2.0", "gfm.css": "^1.1.2", "glob-to-regexp": "^0.4.1", - "highlight.js": "^10.1.2", - "html-entities": "^1.3.1", - "is-ip": "^2.0.0", + "highlight.js": "^10.5.0", + "html-entities": "^1.4.0", + "is-ip": "^3.1.0", + "katex": "^0.12.0", "linkifyjs": "^2.1.9", - "lodash": "^4.17.19", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "^0.1.0-beta.5", + "lodash": "^4.17.20", + "matrix-js-sdk": "12.0.1", + "matrix-widget-api": "^0.1.0-beta.15", "minimist": "^1.2.5", - "pako": "^1.0.11", - "parse5": "^5.1.1", + "opus-recorder": "^8.0.3", + "pako": "^2.0.3", + "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", - "project-name-generator": "^2.1.7", "prop-types": "^15.7.2", "qrcode": "^1.4.4", - "qs": "^6.9.4", - "re-resizable": "^6.5.4", - "react": "^16.13.1", - "react-beautiful-dnd": "^4.0.1", - "react-dom": "^16.13.1", - "react-focus-lock": "^2.4.1", + "re-resizable": "^6.9.0", + "react": "^17.0.2", + "react-beautiful-dnd": "^13.1.0", + "react-blurhash": "^0.1.3", + "react-dom": "^17.0.2", + "react-focus-lock": "^2.5.0", "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.1", "rfc4648": "^1.4.0", - "sanitize-html": "github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db", + "sanitize-html": "^2.3.2", "tar-js": "^0.3.0", - "text-encoding-utf-8": "^1.0.2", "url": "^0.11.0", - "velocity-animate": "^1.5.2", "what-input": "^5.2.10", "zxcvbn": "^4.4.2" }, "devDependencies": { - "@babel/cli": "^7.10.5", - "@babel/core": "^7.10.5", - "@babel/parser": "^7.11.0", - "@babel/plugin-proposal-class-properties": "^7.10.4", - "@babel/plugin-proposal-decorators": "^7.10.5", - "@babel/plugin-proposal-export-default-from": "^7.10.4", - "@babel/plugin-proposal-numeric-separator": "^7.10.4", - "@babel/plugin-proposal-object-rest-spread": "^7.10.4", - "@babel/plugin-transform-flow-comments": "^7.10.4", - "@babel/plugin-transform-runtime": "^7.10.5", - "@babel/preset-env": "^7.10.4", - "@babel/preset-flow": "^7.10.4", - "@babel/preset-react": "^7.10.4", - "@babel/preset-typescript": "^7.10.4", - "@babel/register": "^7.10.5", - "@babel/traverse": "^7.11.0", - "@peculiar/webcrypto": "^1.1.3", - "@types/classnames": "^2.2.10", + "@babel/cli": "^7.12.10", + "@babel/core": "^7.12.10", + "@babel/eslint-parser": "^7.12.10", + "@babel/eslint-plugin": "^7.12.10", + "@babel/parser": "^7.12.11", + "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-decorators": "^7.12.12", + "@babel/plugin-proposal-export-default-from": "^7.12.1", + "@babel/plugin-proposal-numeric-separator": "^7.12.7", + "@babel/plugin-proposal-object-rest-spread": "^7.12.1", + "@babel/plugin-transform-runtime": "^7.12.10", + "@babel/preset-env": "^7.12.11", + "@babel/preset-react": "^7.12.10", + "@babel/preset-typescript": "^7.12.7", + "@babel/register": "^7.12.10", + "@babel/traverse": "^7.12.12", + "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", + "@peculiar/webcrypto": "^1.1.4", + "@sinonjs/fake-timers": "^7.0.2", + "@types/classnames": "^2.2.11", + "@types/commonmark": "^0.27.4", "@types/counterpart": "^0.18.1", + "@types/css-font-loading-module": "^0.0.6", + "@types/diff-match-patch": "^1.0.32", "@types/flux": "^3.1.9", + "@types/jest": "^26.0.20", "@types/linkifyjs": "^2.1.3", - "@types/lodash": "^4.14.158", + "@types/lodash": "^4.14.168", "@types/modernizr": "^3.5.3", - "@types/node": "^12.12.51", + "@types/node": "^14.14.22", "@types/pako": "^1.0.1", - "@types/qrcode": "^1.3.4", - "@types/react": "^16.9", - "@types/react-dom": "^16.9.8", + "@types/parse5": "^6.0.0", + "@types/qrcode": "^1.3.5", + "@types/react": "^17.0.2", + "@types/react-beautiful-dnd": "^13.0.0", + "@types/react-dom": "^17.0.2", "@types/react-transition-group": "^4.4.0", - "@types/sanitize-html": "^1.23.3", + "@types/sanitize-html": "^2.3.1", "@types/zxcvbn": "^4.4.0", - "@typescript-eslint/eslint-plugin": "^3.7.0", - "@typescript-eslint/parser": "^3.7.0", - "babel-eslint": "^10.1.0", - "babel-jest": "^24.9.0", - "chokidar": "^3.4.1", - "concurrently": "^4.1.2", + "@typescript-eslint/eslint-plugin": "^4.17.0", + "@typescript-eslint/parser": "^4.17.0", + "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", + "babel-jest": "^26.6.3", + "chokidar": "^3.5.1", + "concurrently": "^5.3.0", "enzyme": "^3.11.0", - "enzyme-adapter-react-16": "^1.15.2", - "eslint": "7.5.0", - "eslint-config-matrix-org": "^0.1.2", - "eslint-plugin-babel": "^5.3.1", - "eslint-plugin-flowtype": "^2.50.3", - "eslint-plugin-react": "^7.20.3", - "eslint-plugin-react-hooks": "^2.5.1", - "glob": "^5.0.15", - "jest": "^26.5.2", + "eslint": "7.18.0", + "eslint-config-google": "^0.14.0", + "eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#main", + "eslint-plugin-react": "^7.22.0", + "eslint-plugin-react-hooks": "^4.2.0", + "glob": "^7.1.6", + "jest": "^26.6.3", "jest-canvas-mock": "^2.3.0", "jest-environment-jsdom-sixteen": "^1.0.3", - "lolex": "^5.1.2", + "jest-fetch-mock": "^3.0.3", "matrix-mock-request": "^1.2.3", - "matrix-react-test-utils": "^0.2.2", - "react-test-renderer": "^16.13.1", - "rimraf": "^2.7.1", - "stylelint": "^9.10.1", - "stylelint-config-standard": "^18.3.0", + "matrix-react-test-utils": "^0.2.3", + "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", + "react-test-renderer": "^17.0.2", + "rimraf": "^3.0.2", + "stylelint": "^13.9.0", + "stylelint-config-standard": "^20.0.0", "stylelint-scss": "^3.18.0", - "typescript": "^3.9.7", + "typescript": "^4.1.3", "walk": "^2.3.14" }, "jest": { "testEnvironment": "./__test-utils__/environment.js", "testMatch": [ - "/test/**/*-test.js" + "/test/**/*-test.[jt]s?(x)" ], "setupFiles": [ "jest-canvas-mock" @@ -178,10 +184,20 @@ ], "moduleNameMapper": { "\\.(gif|png|svg|ttf|woff2)$": "/__mocks__/imageMock.js", - "\\$webapp/i18n/languages.json": "/__mocks__/languages.json" + "\\$webapp/i18n/languages.json": "/__mocks__/languages.json", + "decoderWorker\\.min\\.js": "/__mocks__/empty.js", + "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", + "waveWorker\\.min\\.js": "/__mocks__/empty.js", + "workers/(.+)\\.worker\\.ts": "/__mocks__/workerMock.js" }, "transformIgnorePatterns": [ "/node_modules/(?!matrix-js-sdk).+$" + ], + "collectCoverageFrom": [ + "/src/**/*.{js,ts,tsx}" + ], + "coverageReporters": [ + "text" ] } } diff --git a/res/css/_common.scss b/res/css/_common.scss index 666129af34..b128a82442 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -17,11 +17,27 @@ limitations under the License. */ @import "./_font-sizes.scss"; +@import "./_font-weights.scss"; $hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic +$EventTile_e2e_state_indicator_width: 4px; + +$MessageTimestamp_width: 46px; /* 8 + 30 (avatar) + 8 */ +$MessageTimestamp_width_hover: calc($MessageTimestamp_width - 2 * $EventTile_e2e_state_indicator_width); + :root { font-size: 10px; + + --transition-short: .1s; + --transition-standard: .3s; +} + +@media (prefers-reduced-motion) { + :root { + --transition-short: 0; + --transition-standard: 0; + } } html { @@ -29,6 +45,8 @@ html { N.B. Breaks things when we have legitimate horizontal overscroll */ height: 100%; overflow: hidden; + // Stop similar overscroll bounce in Firefox Nightly for macOS + overscroll-behavior: none; } body { @@ -59,6 +77,10 @@ pre, code { color: $accent-color; } +.text-muted { + color: $muted-fg-color; +} + b { // On Firefox, the default weight for `` is `bolder` which results in no bold // effect since we only have specific weights of our fonts available. @@ -165,7 +187,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { border: 1px solid rgba($primary-fg-color, .1); // these things should probably not be defined globally margin: 9px; - flex: 0 0 auto; } .mx_textinput { @@ -270,6 +291,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_staticWrapper .mx_Dialog { z-index: 4010; + contain: content; } .mx_Dialog_background { @@ -294,7 +316,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { } .mx_Dialog_lightbox .mx_Dialog_background { - opacity: 0.85; + opacity: $lightbox-background-bg-opacity; background-color: $lightbox-background-bg-color; } @@ -306,6 +328,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { max-width: 100%; max-height: 100%; pointer-events: none; + padding: 0; } .mx_Dialog_header { @@ -323,6 +346,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_title { font-size: $font-22px; + font-weight: $font-semi-bold; line-height: $font-36px; color: $dialog-title-fg-color; } @@ -348,8 +372,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { background-color: $dialog-close-fg-color; cursor: pointer; position: absolute; - top: 4px; - right: 0px; + top: 10px; + right: 0; } .mx_Dialog_content { @@ -362,6 +386,11 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_buttons { margin-top: 20px; text-align: right; + + .mx_Dialog_buttons_additive { + // The consumer is responsible for positioning their elements. + float: left; + } } /* XXX: Our button style are a mess: buttons that happen to appear in dialogs get special styles applied @@ -380,6 +409,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { border: 1px solid $accent-color; color: $accent-color; background-color: $button-secondary-bg-color; + font-family: inherit; } .mx_Dialog button:last-child { @@ -474,54 +504,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { margin-top: 69px; } -.mx_Beta { - color: red; - margin-right: 10px; - position: relative; - top: -3px; - background-color: white; - padding: 0 4px; - border-radius: 3px; - border: 1px solid darkred; - cursor: help; - transition-duration: 200ms; - font-size: smaller; - filter: opacity(0.5); -} - -.mx_Beta:hover { - color: white; - border: 1px solid gray; - background-color: darkred; -} - -.mx_TintableSvgButton { - position: relative; - display: flex; - flex-direction: row; - justify-content: center; - align-content: center; -} - -.mx_TintableSvgButton object { - margin: 0; - padding: 0; - width: 100%; - height: 100%; - max-width: 100%; - max-height: 100%; -} - -.mx_TintableSvgButton span { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - opacity: 0; - cursor: pointer; -} - // username colors // used by SenderProfile & RoomPreviewBar .mx_Username_color1 { @@ -591,6 +573,13 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { } } +@define-mixin ProgressBarBgColour $colour { + background-color: $colour; + &::-webkit-progress-bar { + background-color: $colour; + } +} + @define-mixin ProgressBarBorderRadius $radius { border-radius: $radius; &::-moz-progress-bar { diff --git a/res/css/_components.scss b/res/css/_components.scss index 37d0e0d286..4efc3f2316 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -27,6 +27,9 @@ @import "./structures/_RoomView.scss"; @import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; +@import "./structures/_SpacePanel.scss"; +@import "./structures/_SpaceRoomDirectory.scss"; +@import "./structures/_SpaceRoomView.scss"; @import "./structures/_TabbedView.scss"; @import "./structures/_ToastContainer.scss"; @import "./structures/_UploadBar.scss"; @@ -34,6 +37,11 @@ @import "./structures/_ViewSource.scss"; @import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; +@import "./views/audio_messages/_AudioPlayer.scss"; +@import "./views/audio_messages/_PlayPauseButton.scss"; +@import "./views/audio_messages/_PlaybackContainer.scss"; +@import "./views/audio_messages/_SeekBar.scss"; +@import "./views/audio_messages/_Waveform.scss"; @import "./views/auth/_AuthBody.scss"; @import "./views/auth/_AuthButtons.scss"; @import "./views/auth/_AuthFooter.scss"; @@ -45,20 +53,21 @@ @import "./views/auth/_InteractiveAuthEntryComponents.scss"; @import "./views/auth/_LanguageSelector.scss"; @import "./views/auth/_PassphraseField.scss"; -@import "./views/auth/_ServerConfig.scss"; -@import "./views/auth/_ServerTypeSelector.scss"; @import "./views/auth/_Welcome.scss"; @import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss"; -@import "./views/avatars/_PulsedAvatar.scss"; @import "./views/avatars/_WidgetAvatar.scss"; +@import "./views/beta/_BetaCard.scss"; +@import "./views/context_menus/_CallContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; +@import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; +@import "./views/dialogs/_BetaFeedbackDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @@ -71,26 +80,33 @@ @import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; @import "./views/dialogs/_FeedbackDialog.scss"; +@import "./views/dialogs/_ForwardDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; +@import "./views/dialogs/_HostSignupDialog.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_KeyboardShortcutsDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_ModalWidgetDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss"; +@import "./views/dialogs/_RegistrationEmailPromptDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialogBridges.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; @import "./views/dialogs/_RoomUpgradeWarningDialog.scss"; @import "./views/dialogs/_ServerOfflineDialog.scss"; +@import "./views/dialogs/_ServerPickerDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss"; @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss"; +@import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; +@import "./views/dialogs/_UntrustedDeviceDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; +@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.scss"; @import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.scss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.scss"; @@ -103,18 +119,21 @@ @import "./views/elements/_AddressSelector.scss"; @import "./views/elements/_AddressTile.scss"; @import "./views/elements/_DesktopBuildsNotice.scss"; +@import "./views/elements/_DesktopCapturerSourcePicker.scss"; +@import "./views/elements/_DialPadBackspaceButton.scss"; @import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_Dropdown.scss"; @import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_EventListSummary.scss"; +@import "./views/elements/_FacePile.scss"; @import "./views/elements/_Field.scss"; -@import "./views/elements/_FormButton.scss"; -@import "./views/elements/_IconButton.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InlineSpinner.scss"; +@import "./views/elements/_InviteReason.scss"; @import "./views/elements/_ManageIntegsButton.scss"; +@import "./views/elements/_MiniAvatarUploader.scss"; @import "./views/elements/_PowerSelector.scss"; @import "./views/elements/_ProgressBar.scss"; @import "./views/elements/_QRCode.scss"; @@ -123,6 +142,8 @@ @import "./views/elements/_RichText.scss"; @import "./views/elements/_RoleButton.scss"; @import "./views/elements/_RoomAliasField.scss"; +@import "./views/elements/_SSOButtons.scss"; +@import "./views/elements/_ServerPicker.scss"; @import "./views/elements/_Slider.scss"; @import "./views/elements/_Spinner.scss"; @import "./views/elements/_StyledCheckbox.scss"; @@ -139,14 +160,18 @@ @import "./views/groups/_GroupUserSettings.scss"; @import "./views/messages/_CreateEvent.scss"; @import "./views/messages/_DateSeparator.scss"; +@import "./views/messages/_EventTileBubble.scss"; @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; +@import "./views/messages/_MImageReplyBody.scss"; @import "./views/messages/_MJitsiWidgetEvent.scss"; @import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MVideoBody.scss"; +@import "./views/messages/_MVoiceMessageBody.scss"; +@import "./views/messages/_MediaBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MjolnirBody.scss"; @@ -161,6 +186,7 @@ @import "./views/messages/_common_CryptoEvent.scss"; @import "./views/right_panel/_BaseCard.scss"; @import "./views/right_panel/_EncryptionInfo.scss"; +@import "./views/right_panel/_PinnedMessagesCard.scss"; @import "./views/right_panel/_RoomSummaryCard.scss"; @import "./views/right_panel/_UserInfo.scss"; @import "./views/right_panel/_VerificationPanel.scss"; @@ -177,16 +203,18 @@ @import "./views/rooms/_GroupLayout.scss"; @import "./views/rooms/_IRCLayout.scss"; @import "./views/rooms/_JumpToBottomButton.scss"; +@import "./views/rooms/_LinkPreviewGroup.scss"; @import "./views/rooms/_LinkPreviewWidget.scss"; @import "./views/rooms/_MemberInfo.scss"; @import "./views/rooms/_MemberList.scss"; @import "./views/rooms/_MessageComposer.scss"; @import "./views/rooms/_MessageComposerFormatBar.scss"; +@import "./views/rooms/_NewRoomIntro.scss"; @import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_PinnedEventTile.scss"; -@import "./views/rooms/_PinnedEventsPanel.scss"; @import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_ReplyPreview.scss"; +@import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomList.scss"; @@ -198,6 +226,7 @@ @import "./views/rooms/_SendMessageComposer.scss"; @import "./views/rooms/_Stickers.scss"; @import "./views/rooms/_TopUnreadMessagesBar.scss"; +@import "./views/rooms/_VoiceRecordComposerTile.scss"; @import "./views/rooms/_WhoIsTypingTile.scss"; @import "./views/settings/_AvatarSetting.scss"; @import "./views/settings/_CrossSigningPanel.scss"; @@ -211,6 +240,7 @@ @import "./views/settings/_SecureBackupPanel.scss"; @import "./views/settings/_SetIdServer.scss"; @import "./views/settings/_SetIntegrationManager.scss"; +@import "./views/settings/_SpellCheckLanguages.scss"; @import "./views/settings/_UpdateCheckButton.scss"; @import "./views/settings/tabs/_SettingsTab.scss"; @import "./views/settings/tabs/room/_GeneralRoomSettingsTab.scss"; @@ -219,14 +249,24 @@ @import "./views/settings/tabs/user/_AppearanceUserSettingsTab.scss"; @import "./views/settings/tabs/user/_GeneralUserSettingsTab.scss"; @import "./views/settings/tabs/user/_HelpUserSettingsTab.scss"; +@import "./views/settings/tabs/user/_LabsUserSettingsTab.scss"; @import "./views/settings/tabs/user/_MjolnirUserSettingsTab.scss"; @import "./views/settings/tabs/user/_NotificationUserSettingsTab.scss"; @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; +@import "./views/spaces/_SpaceBasicSettings.scss"; +@import "./views/spaces/_SpaceCreateMenu.scss"; +@import "./views/spaces/_SpacePublicShare.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; +@import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; -@import "./views/voip/_VideoView.scss"; +@import "./views/voip/_CallViewForRoom.scss"; +@import "./views/voip/_CallPreview.scss"; +@import "./views/voip/_DialPad.scss"; +@import "./views/voip/_DialPadContextMenu.scss"; +@import "./views/voip/_DialPadModal.scss"; +@import "./views/voip/_VideoFeed.scss"; diff --git a/res/css/structures/_ContextualMenu.scss b/res/css/structures/_ContextualMenu.scss index 658033339a..d7f2cb76e8 100644 --- a/res/css/structures/_ContextualMenu.scss +++ b/res/css/structures/_ContextualMenu.scss @@ -38,6 +38,7 @@ limitations under the License. position: absolute; font-size: $font-14px; z-index: 5001; + contain: content; } .mx_ContextualMenu_right { @@ -115,8 +116,3 @@ limitations under the License. border-top: 8px solid $menu-bg-color; border-right: 8px solid transparent; } - -.mx_ContextualMenu_spinner { - display: block; - margin: 0 auto; -} diff --git a/res/css/structures/_FilePanel.scss b/res/css/structures/_FilePanel.scss index 2aa068b674..7b975110e1 100644 --- a/res/css/structures/_FilePanel.scss +++ b/res/css/structures/_FilePanel.scss @@ -22,7 +22,6 @@ limitations under the License. } .mx_FilePanel .mx_RoomView_messageListWrapper { - margin-right: 20px; flex-direction: row; align-items: center; justify-content: center; diff --git a/res/css/structures/_GroupFilterPanel.scss b/res/css/structures/_GroupFilterPanel.scss index e5a8ef6df2..444435dd57 100644 --- a/res/css/structures/_GroupFilterPanel.scss +++ b/res/css/structures/_GroupFilterPanel.scss @@ -56,6 +56,12 @@ limitations under the License. .mx_GroupFilterPanel .mx_TagTile { // opacity: 0.5; position: relative; + + .mx_BetaDot { + position: absolute; + right: -13px; + top: -11px; + } } .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype { diff --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss index 2350d9f28a..60f9ebdd08 100644 --- a/res/css/structures/_GroupView.scss +++ b/res/css/structures/_GroupView.scss @@ -323,7 +323,7 @@ limitations under the License. } .mx_GroupView_featuredThing .mx_BaseAvatar { - /* To prevent misalignment with mx_TintableSvg (in addButton) */ + /* To prevent misalignment with img (in addButton) */ vertical-align: initial; } diff --git a/res/css/structures/_HomePage.scss b/res/css/structures/_HomePage.scss index 04527bff48..9f72213d1a 100644 --- a/res/css/structures/_HomePage.scss +++ b/res/css/structures/_HomePage.scss @@ -26,9 +26,10 @@ limitations under the License. .mx_HomePage_default { text-align: center; + display: flex; .mx_HomePage_default_wrapper { - padding: 25vh 0 12px; + margin: auto; } img { @@ -50,56 +51,54 @@ limitations under the License. color: $muted-fg-color; } + .mx_MiniAvatarUploader { + margin: 0 auto; + } + .mx_HomePage_default_buttons { - margin: 80px auto 0; + margin: 60px auto 0; width: fit-content; .mx_AccessibleButton { padding: 73px 8px 15px; // top: 20px top padding + 40px icon + 13px margin - width: 104px; // 120px - 2* 8px - margin: 0 39px; // 55px - 2* 8px + width: 160px; + height: 132px; + margin: 20px; position: relative; display: inline-block; border-radius: 8px; vertical-align: top; word-break: break-word; + box-sizing: border-box; font-weight: 600; font-size: $font-15px; line-height: $font-20px; - color: $muted-fg-color; - - &:hover { - color: $accent-color; - background: rgba($accent-color, 0.06); - - &::before { - background-color: $accent-color; - } - } + color: #fff; // on all themes + background-color: $accent-color; &::before { top: 20px; - left: 40px; // (120px-40px)/2 + left: 60px; // (160px-40px)/2 width: 40px; height: 40px; content: ''; position: absolute; - background-color: $muted-fg-color; + background-color: #fff; // on all themes mask-repeat: no-repeat; mask-size: contain; } &.mx_HomePage_button_sendDm::before { - mask-image: url('$(res)/img/feather-customised/message-circle.svg'); + mask-image: url('$(res)/img/element-icons/feedback.svg'); } &.mx_HomePage_button_explore::before { - mask-image: url('$(res)/img/feather-customised/explore.svg'); + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); } &.mx_HomePage_button_createGroup::before { - mask-image: url('$(res)/img/feather-customised/group.svg'); + mask-image: url('$(res)/img/element-icons/community-members.svg'); } } } diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 1424d9cda0..f254ca3226 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -15,14 +15,17 @@ limitations under the License. */ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculations +$roomListCollapsedWidth: 68px; .mx_LeftPanel { background-color: $roomlist-bg-color; - min-width: 260px; + // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel + min-width: 206px; max-width: 50%; // Create a row-based flexbox for the GroupFilterPanel and the room list display: flex; + contain: content; .mx_LeftPanel_GroupFilterPanelContainer { flex-grow: 0; @@ -37,18 +40,12 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation // GroupFilterPanel handles its own CSS } - &:not(.mx_LeftPanel_hasGroupFilterPanel) { - .mx_LeftPanel_roomListContainer { - width: 100%; - } - } - // Note: The 'room list' in this context is actually everything that isn't the tag // panel, such as the menu options, breadcrumbs, filtering, etc .mx_LeftPanel_roomListContainer { - width: calc(100% - $groupFilterPanelWidth); background-color: $roomlist-bg-color; - + flex: 1 0 0; + min-width: 0; // Create another flexbox (this time a column) for the room list components display: flex; flex-direction: column; @@ -74,6 +71,7 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation // aligned correctly. This is also a row-based flexbox. display: flex; align-items: center; + contain: content; &.mx_IndicatorScrollbar_leftOverflow { mask-image: linear-gradient(90deg, transparent, black 5%); @@ -113,6 +111,29 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation } } + .mx_LeftPanel_dialPadButton { + width: 32px; + height: 32px; + border-radius: 8px; + background-color: $roomlist-button-bg-color; + position: relative; + margin-left: 8px; + + &::before { + content: ''; + position: absolute; + top: 8px; + left: 8px; + width: 16px; + height: 16px; + mask-image: url('$(res)/img/element-icons/call/dialpad.svg'); + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $secondary-fg-color; + } + } + .mx_LeftPanel_exploreButton { width: 32px; height: 32px; @@ -134,6 +155,10 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation mask-repeat: no-repeat; background: $secondary-fg-color; } + + &.mx_LeftPanel_exploreButton_space::before { + mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); + } } } @@ -168,23 +193,27 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation // These styles override the defaults for the minimized (66px) layout &.mx_LeftPanel_minimized { min-width: unset; - - // We have to forcefully set the width to override the resizer's style attribute. - &.mx_LeftPanel_hasGroupFilterPanel { - width: calc(68px + $groupFilterPanelWidth) !important; - } - &:not(.mx_LeftPanel_hasGroupFilterPanel) { - width: 68px !important; - } + width: unset !important; .mx_LeftPanel_roomListContainer { - width: 68px; + width: $roomListCollapsedWidth; + + .mx_LeftPanel_userHeader { + flex-direction: row; + justify-content: center; + } .mx_LeftPanel_filterContainer { // Organize the flexbox into a centered column layout flex-direction: column; justify-content: center; + .mx_LeftPanel_dialPadButton { + margin-left: 0; + margin-top: 8px; + background-color: transparent; + } + .mx_LeftPanel_exploreButton { margin-left: 0; margin-top: 8px; diff --git a/res/css/structures/_LeftPanelWidget.scss b/res/css/structures/_LeftPanelWidget.scss index 4df651d7b6..6e2d99bb37 100644 --- a/res/css/structures/_LeftPanelWidget.scss +++ b/res/css/structures/_LeftPanelWidget.scss @@ -134,7 +134,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); + mask-image: url('$(res)/img/feather-customised/maximise.svg'); background: $muted-fg-color; } } diff --git a/res/css/structures/_MainSplit.scss b/res/css/structures/_MainSplit.scss index ad1656efbb..8199121420 100644 --- a/res/css/structures/_MainSplit.scss +++ b/res/css/structures/_MainSplit.scss @@ -18,6 +18,7 @@ limitations under the License. display: flex; flex-direction: row; min-width: 0; + min-height: 0; height: 100%; } diff --git a/res/css/structures/_MatrixChat.scss b/res/css/structures/_MatrixChat.scss index 812a7f8472..a220c5d505 100644 --- a/res/css/structures/_MatrixChat.scss +++ b/res/css/structures/_MatrixChat.scss @@ -66,7 +66,7 @@ limitations under the License. } /* not the left panel, and not the resize handle, so the roomview/groupview/... */ -.mx_MatrixChat > :not(.mx_LeftPanel):not(.mx_ResizeHandle) { +.mx_MatrixChat > :not(.mx_LeftPanel):not(.mx_SpacePanel):not(.mx_ResizeHandle) { background-color: $primary-bg-color; flex: 1 1 0; diff --git a/res/css/structures/_MyGroups.scss b/res/css/structures/_MyGroups.scss index 73f1332cd0..9c0062b72d 100644 --- a/res/css/structures/_MyGroups.scss +++ b/res/css/structures/_MyGroups.scss @@ -17,6 +17,11 @@ limitations under the License. .mx_MyGroups { display: flex; flex-direction: column; + + .mx_BetaCard { + margin: 0 72px; + max-width: 760px; + } } .mx_MyGroups .mx_RoomHeader_simpleHeader { @@ -30,7 +35,7 @@ limitations under the License. flex-wrap: wrap; } -.mx_MyGroups > :not(.mx_RoomHeader) { +.mx_MyGroups > :not(.mx_RoomHeader):not(.mx_BetaCard) { max-width: 960px; margin: 40px; } diff --git a/res/css/structures/_NotificationPanel.scss b/res/css/structures/_NotificationPanel.scss index 1258ace069..e54feca175 100644 --- a/res/css/structures/_NotificationPanel.scss +++ b/res/css/structures/_NotificationPanel.scss @@ -82,7 +82,6 @@ limitations under the License. color: $primary-fg-color; font-size: $font-12px; display: inline; - padding-left: 0px; } .mx_NotificationPanel .mx_EventTile_senderDetails { @@ -103,6 +102,7 @@ limitations under the License. visibility: visible; position: initial; display: inline; + padding-left: 5px; } .mx_NotificationPanel .mx_EventTile_line { diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index 5bf0d953f3..3222fe936c 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -25,6 +25,7 @@ limitations under the License. padding: 4px 0; box-sizing: border-box; height: 100%; + contain: strict; .mx_RoomView_MessageList { padding: 14px 18px; // top and bottom is 4px smaller to balance with the padding set above @@ -98,6 +99,76 @@ limitations under the License. mask-position: center; } +$dot-size: 8px; +$pulse-color: $pinned-unread-color; + +.mx_RightPanel_pinnedMessagesButton { + &::before { + mask-image: url('$(res)/img/element-icons/room/pin.svg'); + mask-position: center; + } + + .mx_RightPanel_pinnedMessagesButton_unreadIndicator { + position: absolute; + right: 0; + top: 0; + margin: 4px; + width: $dot-size; + height: $dot-size; + border-radius: 50%; + transform: scale(1); + background: rgba($pulse-color, 1); + box-shadow: 0 0 0 0 rgba($pulse-color, 1); + animation: mx_RightPanel_indicator_pulse 2s infinite; + animation-iteration-count: 1; + + &::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + top: 0; + left: 0; + transform: scale(1); + transform-origin: center center; + animation-name: mx_RightPanel_indicator_pulse_shadow; + animation-duration: inherit; + animation-iteration-count: inherit; + border-radius: 50%; + background: rgba($pulse-color, 1); + } + } +} + +@keyframes mx_RightPanel_indicator_pulse { + 0% { + transform: scale(0.95); + } + + 70% { + transform: scale(1); + } + + 100% { + transform: scale(0.95); + } +} + +@keyframes mx_RightPanel_indicator_pulse_shadow { + 0% { + opacity: 0.7; + } + + 70% { + transform: scale(2.2); + opacity: 0; + } + + 100% { + opacity: 0; + } +} + .mx_RightPanel_headerButton_highlight { &::before { background-color: $accent-color !important; @@ -160,3 +231,20 @@ limitations under the License. mask-position: center; } } + +.mx_RightPanel_scopeHeader { + margin: 24px; + text-align: center; + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + + .mx_BaseAvatar { + margin-right: 8px; + vertical-align: middle; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + } +} diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index 29e6fecd34..ec07500af5 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -61,31 +61,59 @@ limitations under the License. .mx_RoomDirectory_tableWrapper { overflow-y: auto; flex: 1 1 0; + + .mx_RoomDirectory_footer { + margin-top: 24px; + text-align: center; + + > h5 { + margin: 0; + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $primary-fg-color; + } + + > p { + margin: 40px auto 60px; + font-size: $font-14px; + line-height: $font-20px; + color: $secondary-fg-color; + max-width: 464px; // easier reading + } + + > hr { + margin: 0; + border: none; + height: 1px; + background-color: $header-panel-bg-color; + } + + .mx_RoomDirectory_newRoom { + margin: 24px auto 0; + width: max-content; + } + } } .mx_RoomDirectory_table { - font-size: $font-12px; color: $primary-fg-color; - width: 100%; + display: grid; + font-size: $font-12px; + grid-template-columns: max-content auto max-content max-content max-content; + row-gap: 24px; text-align: left; - table-layout: fixed; + width: 100%; } .mx_RoomDirectory_roomAvatar { - width: 32px; - padding-right: 14px; - vertical-align: top; -} - -.mx_RoomDirectory_roomDescription { - padding-bottom: 16px; + padding: 2px 14px 0 0; } .mx_RoomDirectory_roomMemberCount { + align-self: center; color: $light-fg-color; - width: 60px; - padding: 0 10px; - text-align: center; + padding: 3px 10px 0; &::before { background-color: $light-fg-color; @@ -105,8 +133,7 @@ limitations under the License. } .mx_RoomDirectory_join, .mx_RoomDirectory_preview { - width: 80px; - text-align: center; + align-self: center; white-space: nowrap; } @@ -144,11 +171,6 @@ limitations under the License. color: $settings-grey-fg-color; } -.mx_RoomDirectory_table tr { - padding-bottom: 10px; - cursor: pointer; -} - .mx_RoomDirectory .mx_RoomView_MessageList { padding: 0; } diff --git a/res/css/structures/_RoomSearch.scss b/res/css/structures/_RoomSearch.scss index c33a3c0ff9..7fdafab5a6 100644 --- a/res/css/structures/_RoomSearch.scss +++ b/res/css/structures/_RoomSearch.scss @@ -22,7 +22,7 @@ limitations under the License. // keep border thickness consistent to prevent movement border: 1px solid transparent; height: 28px; - padding: 2px; + padding: 1px; // Create a flexbox for the icons (easier to manage) display: flex; diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index cd4390ee5c..de9e049165 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -14,62 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RoomStatusBar { +.mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { margin-left: 65px; min-height: 50px; } -/* position the indicator in the same place horizontally as .mx_EventTile_avatar. */ -.mx_RoomStatusBar_indicator { - padding-left: 17px; - padding-right: 12px; - margin-left: -73px; - margin-top: 15px; - float: left; - width: 24px; - text-align: center; -} - -.mx_RoomStatusBar_callBar { - height: 50px; - line-height: $font-50px; -} - -.mx_RoomStatusBar_placeholderIndicator span { - color: $primary-fg-color; - opacity: 0.5; - position: relative; - top: -4px; - /* - animation-duration: 1s; - animation-name: bounce; - animation-direction: alternate; - animation-iteration-count: infinite; - */ -} - -.mx_RoomStatusBar_placeholderIndicator span:nth-child(1) { - animation-delay: 0.3s; -} -.mx_RoomStatusBar_placeholderIndicator span:nth-child(2) { - animation-delay: 0.6s; -} -.mx_RoomStatusBar_placeholderIndicator span:nth-child(3) { - animation-delay: 0.9s; -} - -@keyframes bounce { - from { - opacity: 0.5; - top: 0; - } - - to { - opacity: 0.2; - top: -3px; - } -} - .mx_RoomStatusBar_typingIndicatorAvatars { width: 52px; margin-top: -1px; @@ -119,6 +68,97 @@ limitations under the License. min-height: 58px; } +.mx_RoomStatusBar_unsentMessages { + > div[role="alert"] { + // cheat some basic alignment + display: flex; + align-items: center; + min-height: 70px; + margin: 12px; + padding-left: 16px; + background-color: $header-panel-bg-color; + border-radius: 4px; + } + + .mx_RoomStatusBar_unsentBadge { + margin-right: 12px; + + .mx_NotificationBadge { + // Override sizing from the default badge + width: 24px !important; + height: 24px !important; + border-radius: 24px !important; + + .mx_NotificationBadge_count { + font-size: $font-16px !important; // override default + } + } + } + + .mx_RoomStatusBar_unsentTitle { + color: $warning-color; + font-size: $font-15px; + } + + .mx_RoomStatusBar_unsentDescription { + font-size: $font-12px; + } + + .mx_RoomStatusBar_unsentButtonBar { + flex-grow: 1; + text-align: right; + margin-right: 22px; + color: $muted-fg-color; + + .mx_AccessibleButton { + padding: 5px 10px; + padding-left: 30px; // 18px for the icon, 2px margin to text, 10px regular padding + display: inline-block; + position: relative; + + &:nth-child(2) { + border-left: 1px solid $resend-button-divider-color; + } + + &::before { + content: ''; + position: absolute; + left: 10px; // inset for regular button padding + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + width: 18px; + height: 18px; + top: 50%; // text sizes are dynamic + transform: translateY(-50%); + } + + &.mx_RoomStatusBar_unsentCancelAllBtn::before { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); + } + + &.mx_RoomStatusBar_unsentResendAllBtn { + padding-left: 34px; // 28px from above, but +6px to account for the wider icon + + &::before { + mask-image: url('$(res)/img/element-icons/retry.svg'); + } + } + } + + .mx_InlineSpinner { + vertical-align: middle; + margin-right: 5px; + top: 1px; // just to help the vertical alignment be slightly better + + & + span { + margin-right: 10px; // same margin/padding as the rightmost button + } + } + } +} + .mx_RoomStatusBar_connectionLostBar img { padding-left: 10px; padding-right: 10px; @@ -153,18 +193,8 @@ limitations under the License. display: block; } -.mx_RoomStatusBar_isAlone { - height: 50px; - line-height: $font-50px; - - color: $primary-fg-color; - opacity: 0.5; - overflow-y: hidden; - display: block; -} - .mx_MatrixChat_useCompactLayout { - .mx_RoomStatusBar { + .mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { min-height: 40px; } @@ -172,11 +202,6 @@ limitations under the License. margin-top: 10px; } - .mx_RoomStatusBar_callBar { - height: 40px; - line-height: $font-40px; - } - .mx_RoomStatusBar_typingBar { height: 40px; line-height: $font-40px; diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 572c7166d2..831f186ed4 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -20,35 +20,55 @@ limitations under the License. flex-direction: column; } + +@keyframes mx_RoomView_fileDropTarget_animation { + from { + opacity: 0; + } + to { + opacity: 0.95; + } +} + .mx_RoomView_fileDropTarget { min-width: 0px; width: 100%; + height: 100%; + font-size: $font-18px; text-align: center; pointer-events: none; - padding-left: 12px; - padding-right: 12px; - margin-left: -12px; + background-color: $primary-bg-color; + opacity: 0.95; - border-top-left-radius: 10px; - border-top-right-radius: 10px; - - background-color: $droptarget-bg-color; - border: 2px #e1dddd solid; - border-bottom: none; position: absolute; - top: 52px; - bottom: 0px; z-index: 3000; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + animation: mx_RoomView_fileDropTarget_animation; + animation-duration: 0.5s; } -.mx_RoomView_fileDropTargetLabel { - top: 50%; - width: 100%; - margin-top: -50px; - position: absolute; +@keyframes mx_RoomView_fileDropTarget_image_animation { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +.mx_RoomView_fileDropTarget_image { + width: 32px; + animation: mx_RoomView_fileDropTarget_image_animation; + animation-duration: 0.5s; + margin-bottom: 16px; } .mx_RoomView_auxPanel { @@ -117,7 +137,6 @@ limitations under the License. } .mx_RoomView_body { - position: relative; //for .mx_RoomView_auxPanel_fullHeight display: flex; flex-direction: column; flex: 1; @@ -134,6 +153,7 @@ limitations under the License. flex: 1; display: flex; flex-direction: column; + contain: content; } .mx_RoomView_statusArea { @@ -219,7 +239,8 @@ hr.mx_RoomView_myReadMarker { position: relative; top: -1px; z-index: 1; - transition: width 400ms easeInSine 1s, opacity 400ms easeInSine 1s; + will-change: width; + transition: width 400ms easeinsine 1s, opacity 400ms easeinsine 1s; width: 99%; opacity: 1; } @@ -244,12 +265,6 @@ hr.mx_RoomView_myReadMarker { padding-top: 1px; } -.mx_RoomView_inCall .mx_RoomView_statusAreaBox { - background-color: $accent-color; - color: $accent-fg-color; - position: relative; -} - .mx_RoomView_voipChevron { position: absolute; bottom: -11px; diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss index 699224949b..7b75c69e86 100644 --- a/res/css/structures/_ScrollPanel.scss +++ b/res/css/structures/_ScrollPanel.scss @@ -21,6 +21,8 @@ limitations under the License. display: flex; flex-direction: column; justify-content: flex-end; - overflow-y: hidden; + + content-visibility: auto; + contain-intrinsic-size: 50px; } } diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss new file mode 100644 index 0000000000..e64057d16c --- /dev/null +++ b/res/css/structures/_SpacePanel.scss @@ -0,0 +1,376 @@ +/* +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. +*/ + +$topLevelHeight: 32px; +$nestedHeight: 24px; +$gutterSize: 16px; +$activeBorderTransparentGap: 1px; + +$activeBackgroundColor: $roomtile-selected-bg-color; +$activeBorderColor: $secondary-fg-color; + +.mx_SpacePanel { + flex: 0 0 auto; + background-color: $groupFilterPanel-bg-color; + padding: 0; + margin: 0; + + // Create another flexbox so the Panel fills the container + display: flex; + flex-direction: column; + + .mx_SpacePanel_spaceTreeWrapper { + flex: 1; + padding: 8px 8px 16px 0; + } + + .mx_SpacePanel_toggleCollapse { + flex: 0 0 auto; + width: 40px; + height: 40px; + mask-position: center; + mask-size: 32px; + mask-repeat: no-repeat; + margin-left: $gutterSize; + margin-bottom: 12px; + background-color: $roomlist-header-color; + mask-image: url('$(res)/img/element-icons/expand-space-panel.svg'); + + &.expanded { + transform: scaleX(-1); + } + } + + ul { + margin: 0; + list-style: none; + padding: 0; + + > .mx_SpaceItem { + padding-left: 16px; + } + } + + .mx_SpaceButton_toggleCollapse { + cursor: pointer; + } + + .mx_SpaceItem_dragging { + .mx_SpaceButton_toggleCollapse { + visibility: hidden; + } + } + + .mx_SpaceTreeLevel { + display: flex; + flex-direction: column; + max-width: 250px; + flex-grow: 1; + } + + .mx_SpaceItem { + display: inline-flex; + flex-flow: wrap; + + &.mx_SpaceItem_narrow { + align-self: baseline; + } + } + + .mx_SpaceItem.collapsed { + & > .mx_SpaceButton > .mx_SpaceButton_toggleCollapse { + transform: rotate(-90deg); + } + + & > .mx_SpaceTreeLevel { + display: none; + } + } + + .mx_SpaceItem:not(.hasSubSpaces) > .mx_SpaceButton { + margin-left: $gutterSize; + min-width: 40px; + } + + .mx_SpaceButton { + border-radius: 8px; + display: flex; + align-items: center; + padding: 4px 4px 4px 0; + width: 100%; + + &.mx_SpaceButton_active { + &:not(.mx_SpaceButton_narrow) .mx_SpaceButton_selectionWrapper { + background-color: $activeBackgroundColor; + } + + &.mx_SpaceButton_narrow .mx_SpaceButton_selectionWrapper { + padding: $activeBorderTransparentGap; + border: 3px $activeBorderColor solid; + } + } + + .mx_SpaceButton_selectionWrapper { + position: relative; + display: flex; + flex: 1; + align-items: center; + border-radius: 12px; + padding: 4px; + } + + &:not(.mx_SpaceButton_narrow) { + .mx_SpaceButton_selectionWrapper { + width: 100%; + padding-right: 16px; + overflow: hidden; + } + } + + .mx_SpaceButton_name { + flex: 1; + margin-left: 8px; + white-space: nowrap; + display: block; + text-overflow: ellipsis; + overflow: hidden; + padding-right: 8px; + font-size: $font-14px; + line-height: $font-18px; + } + + .mx_SpaceButton_toggleCollapse { + width: $gutterSize; + height: 20px; + mask-position: center; + mask-size: 20px; + mask-repeat: no-repeat; + background-color: $roomlist-header-color; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + + .mx_SpaceButton_icon { + width: $topLevelHeight; + min-width: $topLevelHeight; + height: $topLevelHeight; + border-radius: 8px; + position: relative; + + &::before { + position: absolute; + content: ''; + width: $topLevelHeight; + height: $topLevelHeight; + top: 0; + left: 0; + mask-position: center; + mask-repeat: no-repeat; + mask-size: 18px; + } + } + + &.mx_SpaceButton_home .mx_SpaceButton_icon { + background-color: #ffffff; + + &::before { + background-color: #3f3d3d; + mask-image: url('$(res)/img/element-icons/home.svg'); + } + } + + &.mx_SpaceButton_new .mx_SpaceButton_icon { + background-color: $accent-color; + transition: all .1s ease-in-out; // TODO transition + + &::before { + background-color: #ffffff; + mask-image: url('$(res)/img/element-icons/plus.svg'); + transition: all .2s ease-in-out; // TODO transition + } + } + + &.mx_SpaceButton_newCancel .mx_SpaceButton_icon { + background-color: $icon-button-color; + + &::before { + transform: rotate(45deg); + } + } + + .mx_BaseAvatar_image { + border-radius: 8px; + } + + .mx_SpaceButton_menuButton { + width: 20px; + min-width: 20px; // yay flex + height: 20px; + margin-top: auto; + margin-bottom: auto; + display: none; + position: absolute; + right: 4px; + + &::before { + top: 2px; + left: 2px; + content: ''; + width: 16px; + height: 16px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/context-menu.svg'); + background: $primary-fg-color; + } + } + } + + .mx_SpacePanel_badgeContainer { + position: absolute; + + // Create a flexbox to make aligning dot badges easier + display: flex; + align-items: center; + + .mx_NotificationBadge { + margin: 0 2px; // centering + } + + .mx_NotificationBadge_dot { + // make the smaller dot occupy the same width for centering + margin: 0 7px; + } + } + + &.collapsed { + .mx_SpaceButton { + .mx_SpacePanel_badgeContainer { + 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; + } + } + } + + &:not(.collapsed) { + .mx_SpacePanel_badgeContainer { + position: absolute; + right: 4px; + } + + .mx_SpaceButton:hover, + .mx_SpaceButton:focus-within, + .mx_SpaceButton_hasMenuOpen { + &:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) { + // Hide the badge container on hover because it'll be a menu button + .mx_SpacePanel_badgeContainer { + width: 0; + height: 0; + display: none; + } + + .mx_SpaceButton_menuButton { + display: block; + } + } + } + } + + /* root space buttons are bigger and not indented */ + & > .mx_AutoHideScrollbar { + & > .mx_SpaceButton { + height: $topLevelHeight; + + &.mx_SpaceButton_active::before { + height: $topLevelHeight; + } + } + + & > ul { + padding-left: 0; + } + } +} + +.mx_SpacePanel_contextMenu { + .mx_SpacePanel_contextMenu_header { + margin: 12px 16px 12px; + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + } + + .mx_IconizedContextMenu_optionList .mx_AccessibleButton.mx_SpacePanel_contextMenu_inviteButton { + color: $accent-color; + + .mx_SpacePanel_iconInvite::before { + background-color: $accent-color; + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } + + .mx_SpacePanel_iconSettings::before { + mask-image: url('$(res)/img/element-icons/settings.svg'); + } + + .mx_SpacePanel_iconLeave::before { + mask-image: url('$(res)/img/element-icons/leave.svg'); + } + + .mx_SpacePanel_iconMembers::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + .mx_SpacePanel_iconPlus::before { + mask-image: url('$(res)/img/element-icons/roomlist/plus-circle.svg'); + } + + .mx_SpacePanel_iconHash::before { + mask-image: url('$(res)/img/element-icons/roomlist/hash-circle.svg'); + } + + .mx_SpacePanel_iconExplore::before { + mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); + } +} + + +.mx_SpacePanel_sharePublicSpace { + margin: 0; +} diff --git a/res/css/structures/_SpaceRoomDirectory.scss b/res/css/structures/_SpaceRoomDirectory.scss new file mode 100644 index 0000000000..7925686bf1 --- /dev/null +++ b/res/css/structures/_SpaceRoomDirectory.scss @@ -0,0 +1,315 @@ +/* +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_SpaceRoomDirectory_dialogWrapper > .mx_Dialog { + max-width: 960px; + height: 100%; +} + +.mx_SpaceRoomDirectory { + height: 100%; + margin-bottom: 12px; + color: $primary-fg-color; + word-break: break-word; + display: flex; + flex-direction: column; +} + +.mx_SpaceRoomDirectory, +.mx_SpaceRoomView_landing { + .mx_Dialog_title { + display: flex; + + .mx_BaseAvatar { + margin-right: 12px; + align-self: center; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + } + + > div { + > h1 { + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + margin: 0; + } + + > div { + font-weight: 400; + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + } + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + + .mx_SearchBox { + margin: 24px 0 16px; + } + + .mx_SpaceRoomDirectory_noResults { + text-align: center; + + > div { + font-size: $font-15px; + line-height: $font-24px; + color: $secondary-fg-color; + } + } + + .mx_SpaceRoomDirectory_listHeader { + display: flex; + min-height: 32px; + align-items: center; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + + .mx_AccessibleButton { + padding: 4px 12px; + font-weight: normal; + + & + .mx_AccessibleButton { + margin-left: 16px; + } + } + + .mx_AccessibleButton_kind_danger_outline, + .mx_AccessibleButton_kind_primary_outline { + padding: 3px 12px; // to account for the 1px border + } + + > span { + margin-left: auto; + } + } + + .mx_SpaceRoomDirectory_error { + position: relative; + font-weight: $font-semi-bold; + color: $notice-primary-color; + font-size: $font-15px; + line-height: $font-18px; + margin: 20px auto 12px; + padding-left: 24px; + width: max-content; + + &::before { + content: ""; + position: absolute; + height: 16px; + width: 16px; + left: 0; + background-image: url("$(res)/img/element-icons/warning-badge.svg"); + } + } +} + +.mx_SpaceRoomDirectory_list { + margin-top: 16px; + padding-bottom: 40px; + + .mx_SpaceRoomDirectory_roomCount { + > h3 { + display: inline; + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + color: $primary-fg-color; + } + + > span { + margin-left: 8px; + font-size: $font-15px; + line-height: $font-24px; + color: $secondary-fg-color; + } + } + + .mx_SpaceRoomDirectory_subspace { + .mx_BaseAvatar_image { + border-radius: 8px; + } + } + + .mx_SpaceRoomDirectory_subspace_toggle { + position: absolute; + left: -1px; + top: 10px; + height: 16px; + width: 16px; + border-radius: 4px; + background-color: $primary-bg-color; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 16px; + width: 16px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $tertiary-fg-color; + mask-size: 16px; + transform: rotate(270deg); + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + + &.mx_SpaceRoomDirectory_subspace_toggle_shown::before { + transform: rotate(0deg); + } + } + + .mx_SpaceRoomDirectory_subspace_children { + position: relative; + padding-left: 12px; + } + + .mx_SpaceRoomDirectory_roomTile { + position: relative; + padding: 8px 16px; + border-radius: 8px; + min-height: 56px; + box-sizing: border-box; + + display: grid; + grid-template-columns: 20px auto max-content; + grid-column-gap: 8px; + grid-row-gap: 6px; + align-items: center; + + .mx_BaseAvatar { + grid-row: 1; + grid-column: 1; + } + + .mx_SpaceRoomDirectory_roomTile_name { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + grid-row: 1; + grid-column: 2; + + .mx_InfoTooltip { + display: inline; + margin-left: 12px; + color: $tertiary-fg-color; + font-size: $font-12px; + line-height: $font-15px; + + .mx_InfoTooltip_icon { + margin-right: 4px; + position: relative; + vertical-align: text-top; + + &::before { + position: absolute; + top: 0; + left: 0; + } + } + } + } + + .mx_SpaceRoomDirectory_roomTile_info { + font-size: $font-14px; + line-height: $font-18px; + color: $secondary-fg-color; + grid-row: 2; + grid-column: 1/3; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + } + + .mx_SpaceRoomDirectory_actions { + text-align: right; + margin-left: 20px; + grid-column: 3; + grid-row: 1/3; + + .mx_AccessibleButton { + line-height: $font-24px; + padding: 4px 16px; + display: inline-block; + visibility: hidden; + } + + .mx_AccessibleButton_kind_danger_outline, + .mx_AccessibleButton_kind_primary_outline { + padding: 3px 16px; // to account for the 1px border + } + + .mx_Checkbox { + display: inline-flex; + vertical-align: middle; + margin-left: 12px; + } + } + + &:hover { + background-color: $groupFilterPanel-bg-color; + + .mx_AccessibleButton { + visibility: visible; + } + } + } + + .mx_SpaceRoomDirectory_roomTile, + .mx_SpaceRoomDirectory_subspace_children { + &::before { + content: ""; + position: absolute; + background-color: $groupFilterPanel-bg-color; + width: 1px; + height: 100%; + left: 6px; + top: 0; + } + } + + .mx_SpaceRoomDirectory_actions { + .mx_SpaceRoomDirectory_actionsText { + font-weight: normal; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + } + } + + > hr { + border: none; + height: 1px; + background-color: rgba(141, 151, 165, 0.2); + margin: 20px 0; + } + + .mx_SpaceRoomDirectory_createRoom { + display: block; + margin: 16px auto 0; + width: max-content; + } +} diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss new file mode 100644 index 0000000000..48b565be7f --- /dev/null +++ b/res/css/structures/_SpaceRoomView.scss @@ -0,0 +1,569 @@ +/* +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. +*/ + +$SpaceRoomViewInnerWidth: 428px; + +@define-mixin SpacePillButton { + position: relative; + padding: 16px 32px 16px 72px; + width: 432px; + box-sizing: border-box; + border-radius: 8px; + border: 1px solid $input-border-color; + font-size: $font-15px; + margin: 20px 0; + + > h3 { + font-weight: $font-semi-bold; + margin: 0 0 4px; + } + + > span { + color: $secondary-fg-color; + } + + &::before { + position: absolute; + content: ''; + width: 32px; + height: 32px; + top: 24px; + left: 20px; + mask-position: center; + mask-repeat: no-repeat; + mask-size: 24px; + background-color: $tertiary-fg-color; + } + + &:hover { + border-color: $accent-color; + + &::before { + background-color: $accent-color; + } + + > span { + color: $primary-fg-color; + } + } +} + +.mx_SpaceRoomView { + .mx_MainSplit > div:first-child { + padding: 80px 60px; + flex-grow: 1; + max-height: 100%; + overflow-y: auto; + + h1 { + margin: 0; + font-size: $font-24px; + font-weight: $font-semi-bold; + color: $primary-fg-color; + width: max-content; + } + + .mx_SpaceRoomView_description { + font-size: $font-15px; + color: $secondary-fg-color; + margin-top: 12px; + margin-bottom: 24px; + max-width: $SpaceRoomViewInnerWidth; + } + + .mx_AddExistingToSpace { + max-width: $SpaceRoomViewInnerWidth; + + .mx_AddExistingToSpace_content { + height: calc(100vh - 360px); + max-height: 400px; + } + } + + &:not(.mx_SpaceRoomView_landing) .mx_SpaceFeedbackPrompt { + width: $SpaceRoomViewInnerWidth; + } + + .mx_SpaceRoomView_buttons { + display: block; + margin-top: 44px; + width: $SpaceRoomViewInnerWidth; + text-align: right; // button alignment right + + .mx_AccessibleButton_hasKind { + padding: 8px 22px; + margin-left: 16px; + } + + input.mx_AccessibleButton { + border: none; // override default styles + } + } + + .mx_Field { + max-width: $SpaceRoomViewInnerWidth; + + & + .mx_Field { + margin-top: 28px; + } + } + + .mx_SpaceRoomView_errorText { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + color: $notice-primary-color; + margin-bottom: 28px; + } + + .mx_AccessibleButton_disabled { + cursor: not-allowed; + } + } + + .mx_SpaceRoomView_preview { + padding: 32px 24px !important; // override default padding from above + margin: auto; + max-width: 480px; + box-sizing: border-box; + box-shadow: 2px 15px 30px $dialog-shadow-color; + border-radius: 8px; + position: relative; + + // XXX remove this when spaces leaves Beta + .mx_BetaCard_betaPill { + position: absolute; + right: 24px; + top: 32px; + } + // XXX remove this when spaces leaves Beta + .mx_SpaceRoomView_preview_spaceBetaPrompt { + font-weight: $font-semi-bold; + font-size: $font-14px; + line-height: $font-24px; + color: $primary-fg-color; + margin-top: 24px; + position: relative; + padding-left: 24px; + + .mx_AccessibleButton_kind_link { + display: inline; + padding: 0; + font-size: inherit; + line-height: inherit; + } + + &::before { + content: ""; + position: absolute; + height: $font-24px; + width: 20px; + left: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + background-color: $secondary-fg-color; + } + } + + .mx_SpaceRoomView_preview_inviter { + display: flex; + align-items: center; + margin-bottom: 20px; + font-size: $font-15px; + + > div { + margin-left: 8px; + + .mx_SpaceRoomView_preview_inviter_name { + line-height: $font-18px; + } + + .mx_SpaceRoomView_preview_inviter_mxid { + line-height: $font-24px; + color: $secondary-fg-color; + } + } + } + + > .mx_BaseAvatar_image, + > .mx_BaseAvatar > .mx_BaseAvatar_image { + border-radius: 12px; + } + + h1.mx_SpaceRoomView_preview_name { + margin: 20px 0 !important; // override default margin from above + } + + .mx_SpaceRoomView_preview_topic { + font-size: $font-14px; + line-height: $font-22px; + color: $secondary-fg-color; + margin: 20px 0; + max-height: 160px; + overflow-y: auto; + } + + .mx_SpaceRoomView_preview_joinButtons { + margin-top: 20px; + + .mx_AccessibleButton { + width: 200px; + box-sizing: border-box; + padding: 14px 0; + + & + .mx_AccessibleButton { + margin-left: 20px; + } + } + } + } + + .mx_SpaceRoomView_landing { + > .mx_BaseAvatar_image, + > .mx_BaseAvatar > .mx_BaseAvatar_image { + border-radius: 12px; + } + + .mx_SpaceRoomView_landing_name { + margin: 24px 0 16px; + font-size: $font-15px; + color: $secondary-fg-color; + + > span { + display: inline-block; + } + + .mx_SpaceRoomView_landing_nameRow { + margin-top: 12px; + + > h1 { + display: inline-block; + } + } + + .mx_SpaceRoomView_landing_inviter { + .mx_BaseAvatar { + margin-right: 4px; + vertical-align: middle; + } + } + } + + .mx_SpaceRoomView_landing_info { + display: flex; + align-items: center; + + .mx_SpaceRoomView_info { + display: inline-block; + margin: 0 auto 0 0; + } + + .mx_FacePile { + display: inline-block; + margin-right: 12px; + + .mx_FacePile_faces { + cursor: pointer; + } + } + + .mx_SpaceRoomView_landing_inviteButton { + position: relative; + padding: 4px 18px 4px 40px; + line-height: $font-24px; + height: min-content; + + &::before { + position: absolute; + content: ""; + left: 8px; + height: 16px; + width: 16px; + background: #ffffff; // white icon fill + mask-position: center; + mask-size: 16px; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } + + .mx_SpaceRoomView_landing_settingsButton { + position: relative; + margin-left: 16px; + width: 24px; + height: 24px; + + &::before { + position: absolute; + content: ""; + left: 0; + top: 0; + height: 24px; + width: 24px; + background: $tertiary-fg-color; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/settings.svg'); + } + } + } + + .mx_SpaceRoomView_landing_topic { + font-size: $font-15px; + margin-top: 12px; + margin-bottom: 16px; + white-space: pre-wrap; + word-wrap: break-word; + } + + > hr { + border: none; + height: 1px; + background-color: $groupFilterPanel-bg-color; + } + + .mx_SearchBox { + margin: 0 0 20px; + } + + .mx_SpaceFeedbackPrompt { + margin-bottom: 16px; + + // hide the HR as we have our own + & + hr { + display: none; + } + } + } + + .mx_SpaceRoomView_privateScope { + > .mx_AccessibleButton { + @mixin SpacePillButton; + } + + .mx_SpaceRoomView_privateScope_justMeButton::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + .mx_SpaceRoomView_privateScope_meAndMyTeammatesButton::before { + mask-image: url('$(res)/img/element-icons/community-members.svg'); + } + } + + .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-fg-color; + 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-fg-color; + } + } + + .mx_SpaceRoomView_inviteTeammates { + // XXX remove this when spaces leaves Beta + .mx_SpaceRoomView_inviteTeammates_betaDisclaimer { + padding: 58px 16px 16px; + position: relative; + border-radius: 8px; + background-color: $header-panel-bg-color; + max-width: $SpaceRoomViewInnerWidth; + margin: 20px 0 30px; + box-sizing: border-box; + + .mx_BetaCard_betaPill { + position: absolute; + left: 16px; + top: 16px; + } + } + + .mx_SpaceRoomView_inviteTeammates_buttons { + color: $secondary-fg-color; + margin-top: 28px; + + .mx_AccessibleButton { + position: relative; + display: inline-block; + padding-left: 32px; + line-height: 24px; // to center icons + + &::before { + content: ""; + position: absolute; + height: 24px; + width: 24px; + top: 0; + left: 0; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + + & + .mx_AccessibleButton { + margin-left: 32px; + } + } + + .mx_SpaceRoomView_inviteTeammates_inviteDialogButton::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } + } +} + +.mx_SpaceRoomView_info { + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + margin: 20px 0; + + .mx_SpaceRoomView_info_public, + .mx_SpaceRoomView_info_private { + padding-left: 20px; + position: relative; + + &::before { + position: absolute; + content: ""; + width: 20px; + height: 20px; + top: 0; + left: -2px; + mask-position: center; + mask-repeat: no-repeat; + background-color: $tertiary-fg-color; + } + } + + .mx_SpaceRoomView_info_public::before { + mask-size: 12px; + mask-image: url("$(res)/img/globe.svg"); + } + + .mx_SpaceRoomView_info_private::before { + mask-size: 14px; + mask-image: url("$(res)/img/element-icons/lock.svg"); + } + + .mx_AccessibleButton_kind_link { + color: inherit; + position: relative; + padding-left: 16px; + + &::before { + content: "·"; // visual separator + position: absolute; + left: 6px; + } + } +} + +.mx_SpaceFeedbackPrompt { + margin-top: 18px; + margin-bottom: 12px; + + > hr { + border: none; + border-top: 1px solid $input-border-color; + margin-bottom: 12px; + } + + > div { + display: flex; + flex-direction: row; + font-size: $font-15px; + line-height: $font-24px; + + > span { + color: $secondary-fg-color; + position: relative; + padding-left: 32px; + font-size: inherit; + line-height: inherit; + margin-right: auto; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 2px; + height: 20px; + width: 20px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + } + } + + .mx_AccessibleButton_kind_link { + color: $accent-color; + position: relative; + padding: 0 0 0 24px; + margin-left: 8px; + font-size: inherit; + line-height: inherit; + + &::before { + content: ''; + position: absolute; + left: 0; + height: 16px; + width: 16px; + background-color: $accent-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/chat-bubbles.svg'); + mask-position: center; + } + } + } +} diff --git a/res/css/structures/_TabbedView.scss b/res/css/structures/_TabbedView.scss index 39a8ebed32..833450a25b 100644 --- a/res/css/structures/_TabbedView.scss +++ b/res/css/structures/_TabbedView.scss @@ -1,6 +1,7 @@ /* Copyright 2017 Travis Ralston Copyright 2019 New Vector Ltd +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. @@ -20,7 +21,6 @@ limitations under the License. padding: 0 0 0 16px; display: flex; flex-direction: column; - position: absolute; top: 0; bottom: 0; left: 0; @@ -28,11 +28,93 @@ limitations under the License. margin-top: 8px; } +.mx_TabbedView_tabsOnLeft { + flex-direction: column; + position: absolute; + + .mx_TabbedView_tabLabels { + width: 170px; + max-width: 170px; + position: fixed; + } + + .mx_TabbedView_tabPanel { + margin-left: 240px; // 170px sidebar + 70px padding + flex-direction: column; + } + + .mx_TabbedView_tabLabel_active { + background-color: $tab-label-active-bg-color; + color: $tab-label-active-fg-color; + } + + .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { + background-color: $tab-label-active-icon-bg-color; + } + + .mx_TabbedView_maskedIcon { + width: 16px; + height: 16px; + margin-left: 8px; + margin-right: 16px; + } + + .mx_TabbedView_maskedIcon::before { + mask-size: 16px; + width: 16px; + height: 16px; + } +} + +.mx_TabbedView_tabsOnTop { + flex-direction: column; + + .mx_TabbedView_tabLabels { + display: flex; + margin-bottom: 8px; + } + + .mx_TabbedView_tabLabel { + padding-left: 0px; + padding-right: 52px; + + .mx_TabbedView_tabLabel_text { + font-size: 15px; + color: $tertiary-fg-color; + } + } + + .mx_TabbedView_tabPanel { + flex-direction: row; + } + + .mx_TabbedView_tabLabel_active { + color: $accent-color; + .mx_TabbedView_tabLabel_text { + color: $accent-color; + } + } + + .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { + background-color: $accent-color; + } + + .mx_TabbedView_maskedIcon { + width: 22px; + height: 22px; + margin-left: 0px; + margin-right: 8px; + } + + .mx_TabbedView_maskedIcon::before { + mask-size: 22px; + width: inherit; + height: inherit; + } +} + .mx_TabbedView_tabLabels { - width: 170px; - max-width: 170px; color: $tab-label-fg-color; - position: fixed; } .mx_TabbedView_tabLabel { @@ -46,43 +128,25 @@ limitations under the License. position: relative; } -.mx_TabbedView_tabLabel_active { - background-color: $tab-label-active-bg-color; - color: $tab-label-active-fg-color; -} - .mx_TabbedView_maskedIcon { - margin-left: 8px; - margin-right: 16px; - width: 16px; - height: 16px; display: inline-block; } .mx_TabbedView_maskedIcon::before { display: inline-block; - background-color: $tab-label-icon-bg-color; + background-color: $icon-button-color; mask-repeat: no-repeat; - mask-size: 16px; - width: 16px; - height: 16px; mask-position: center; content: ''; } -.mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { - background-color: $tab-label-active-icon-bg-color; -} - .mx_TabbedView_tabLabel_text { vertical-align: middle; } .mx_TabbedView_tabPanel { - margin-left: 240px; // 170px sidebar + 70px padding flex-grow: 1; display: flex; - flex-direction: column; min-height: 0; // firefox } diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index c381668a6a..d248568740 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-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. @@ -71,7 +71,7 @@ limitations under the License. &::before { background-color: #ffffff; mask-image: url('$(res)/img/e2e/normal.svg'); - mask-size: 90%; + mask-size: 80%; } &::after { @@ -135,10 +135,14 @@ limitations under the License. float: right; display: flex; - .mx_FormButton { + .mx_AccessibleButton { min-width: 96px; box-sizing: border-box; } + + .mx_AccessibleButton + .mx_AccessibleButton { + margin-left: 5px; + } } .mx_Toast_description { @@ -158,6 +162,10 @@ limitations under the License. } } + .mx_Toast_detail { + color: $secondary-fg-color; + } + .mx_Toast_deviceID { font-size: $font-10px; } diff --git a/res/css/structures/_UploadBar.scss b/res/css/structures/_UploadBar.scss index d76c81668c..7c62516b47 100644 --- a/res/css/structures/_UploadBar.scss +++ b/res/css/structures/_UploadBar.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015, 2016, 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. @@ -15,47 +15,45 @@ limitations under the License. */ .mx_UploadBar { + padding-left: 65px; // line up with the shield area in the composer position: relative; + + .mx_ProgressBar { + width: calc(100% - 40px); // cheating at a right margin + } } -.mx_UploadBar_uploadProgressOuter { - height: 5px; - margin-left: 63px; - margin-top: -1px; - padding-bottom: 5px; -} - -.mx_UploadBar_uploadProgressInner { - background-color: $accent-color; - height: 5px; -} - -.mx_UploadBar_uploadFilename { +.mx_UploadBar_filename { margin-top: 5px; - margin-left: 65px; - opacity: 0.5; - color: $primary-fg-color; -} - -.mx_UploadBar_uploadIcon { - float: left; - margin-top: 5px; - margin-left: 14px; -} - -.mx_UploadBar_uploadCancel { - float: right; - margin-top: 5px; - margin-right: 10px; + color: $muted-fg-color; position: relative; - opacity: 0.6; - cursor: pointer; - z-index: 1; + padding-left: 22px; // 18px for icon, 4px for padding + font-size: $font-15px; + vertical-align: middle; + + &::before { + content: ""; + height: 18px; + width: 18px; + position: absolute; + top: 0; + left: 0; + mask-repeat: no-repeat; + mask-position: center; + background-color: $muted-fg-color; + mask-image: url('$(res)/img/element-icons/upload.svg'); + } } -.mx_UploadBar_uploadBytes { - float: right; - margin-top: 5px; - margin-right: 30px; - color: $accent-color; +.mx_UploadBar_cancel { + position: absolute; + top: 0; + right: 0; + height: 16px; + width: 16px; + margin-right: 16px; // align over rightmost button in composer + mask-repeat: no-repeat; + mask-position: center; + background-color: $muted-fg-color; + mask-image: url('$(res)/img/icons-close.svg'); } diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 6a352d46a3..17e6ad75df 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -72,6 +72,7 @@ limitations under the License. position: relative; // to make default avatars work margin-right: 8px; height: 32px; // to remove the unknown 4px gap the browser puts below it + padding: 3px 0; // to align with and without using doubleName .mx_UserMenu_userAvatar { border-radius: 32px; // should match avatar size @@ -116,23 +117,45 @@ limitations under the License. .mx_UserMenu_headerButtons { // No special styles: the rest of the layout happens to make it work. } + + .mx_UserMenu_dnd { + width: 24px; + height: 24px; + margin-right: 8px; + position: relative; + + &::before { + content: ''; + position: absolute; + width: 24px; + height: 24px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $muted-fg-color; + } + + &.mx_UserMenu_dnd_noisy::before { + mask-image: url('$(res)/img/element-icons/notifications.svg'); + } + + &.mx_UserMenu_dnd_muted::before { + mask-image: url('$(res)/img/element-icons/roomlist/notifications-off.svg'); + } + } } &.mx_UserMenu_minimized { - .mx_UserMenu_userHeader { - .mx_UserMenu_row { - justify-content: center; - } + padding-right: 0px; - .mx_UserMenu_userAvatarContainer { - margin-right: 0; - } + .mx_UserMenu_userAvatarContainer { + margin-right: 0px; } } } .mx_UserMenu_contextMenu { - width: 247px; + width: 258px; // These override the styles already present on the user menu rather than try to // define a new menu. They are specifically for the stacked menu when a community @@ -231,9 +254,29 @@ limitations under the License. justify-content: center; } + &.mx_UserMenu_contextMenu_guestPrompts, &.mx_UserMenu_contextMenu_hostingLink { padding-top: 0; } + + &.mx_UserMenu_contextMenu_guestPrompts { + display: inline-block; + + > span { + font-weight: 600; + display: block; + + & + span { + margin-top: 8px; + } + } + + .mx_AccessibleButton_kind_link { + font-weight: normal; + font-size: inherit; + padding: 0; + } + } } .mx_IconizedContextMenu_icon { @@ -256,6 +299,9 @@ limitations under the License. .mx_UserMenu_iconHome::before { mask-image: url('$(res)/img/element-icons/roomlist/home.svg'); } + .mx_UserMenu_iconHosting::before { + mask-image: url('$(res)/img/element-icons/brands/element.svg'); + } .mx_UserMenu_iconBell::before { mask-image: url('$(res)/img/element-icons/notifications.svg'); diff --git a/res/css/structures/_ViewSource.scss b/res/css/structures/_ViewSource.scss index 421d1f03cd..248eab5d88 100644 --- a/res/css/structures/_ViewSource.scss +++ b/res/css/structures/_ViewSource.scss @@ -14,17 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ViewSource_label_left { - float: left; -} - -.mx_ViewSource_label_right { - float: right; -} - -.mx_ViewSource_label_bottom { +.mx_ViewSource_separator { clear: both; border-bottom: 1px solid #e5e5e5; + padding-top: 0.7em; + padding-bottom: 0.7em; +} + +.mx_ViewSource_heading { + font-size: $font-17px; + font-weight: 400; + color: $primary-fg-color; + margin-top: 0.7em; } .mx_ViewSource pre { @@ -34,3 +35,7 @@ limitations under the License. word-wrap: break-word; white-space: pre-wrap; } + +.mx_ViewSource_details { + margin-top: 0.8em; +} diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss index f742be70e4..80e7aaada0 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -26,50 +26,6 @@ limitations under the License. position: relative; } -.mx_CompleteSecurity_clients { - width: max-content; - margin: 36px auto 0; - - .mx_CompleteSecurity_clients_desktop, .mx_CompleteSecurity_clients_mobile { - position: relative; - width: 160px; - text-align: center; - padding-top: 64px; - display: inline-block; - - &::before { - content: ''; - position: absolute; - height: 48px; - width: 48px; - left: 56px; - top: 0; - background-color: $muted-fg-color; - mask-repeat: no-repeat; - mask-size: contain; - } - } - - .mx_CompleteSecurity_clients_desktop { - margin-right: 56px; - } - - .mx_CompleteSecurity_clients_desktop::before { - mask-image: url('$(res)/img/feather-customised/monitor.svg'); - } - - .mx_CompleteSecurity_clients_mobile::before { - mask-image: url('$(res)/img/feather-customised/smartphone.svg'); - } - - p { - margin-top: 16px; - font-size: $font-12px; - color: $muted-fg-color; - text-align: center; - } -} - .mx_CompleteSecurity_heroIcon { width: 128px; height: 128px; diff --git a/res/css/structures/auth/_Login.scss b/res/css/structures/auth/_Login.scss index 02436833a2..9c98ca3a1c 100644 --- a/res/css/structures/auth/_Login.scss +++ b/res/css/structures/auth/_Login.scss @@ -18,7 +18,7 @@ limitations under the License. .mx_Login_submit { @mixin mx_DialogButton; width: 100%; - margin-top: 35px; + margin-top: 24px; margin-bottom: 24px; box-sizing: border-box; text-align: center; @@ -33,12 +33,6 @@ limitations under the License. cursor: default; } -.mx_AuthBody a.mx_Login_sso_link:link, -.mx_AuthBody a.mx_Login_sso_link:hover, -.mx_AuthBody a.mx_Login_sso_link:visited { - color: $button-primary-fg-color; -} - .mx_Login_loader { display: inline; position: relative; @@ -87,10 +81,13 @@ limitations under the License. } .mx_Login_underlinedServerName { + width: max-content; border-bottom: 1px dashed $accent-color; } div.mx_AccessibleButton_kind_link.mx_Login_forgot { + display: block; + margin: 0 auto; // style it as a link font-size: inherit; padding: 0; diff --git a/res/css/views/audio_messages/_AudioPlayer.scss b/res/css/views/audio_messages/_AudioPlayer.scss new file mode 100644 index 0000000000..9a65ad008f --- /dev/null +++ b/res/css/views/audio_messages/_AudioPlayer.scss @@ -0,0 +1,68 @@ +/* +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_AudioPlayer_container { + padding: 16px 12px 12px 12px; + max-width: 267px; // use max to make the control fit in the files/pinned panels + + .mx_AudioPlayer_primaryContainer { + display: flex; + + .mx_PlayPauseButton { + margin-right: 8px; + } + + .mx_AudioPlayer_mediaInfo { + flex: 1; + overflow: hidden; // makes the ellipsis on the file name work + + & > * { + display: block; + } + + .mx_AudioPlayer_mediaName { + color: $primary-fg-color; + font-size: $font-15px; + line-height: $font-15px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + padding-bottom: 4px; // mimics the line-height differences in the Figma + } + + .mx_AudioPlayer_byline { + font-size: $font-12px; + line-height: $font-12px; + } + } + } + + .mx_AudioPlayer_seek { + display: flex; + align-items: center; + + .mx_SeekBar { + flex: 1; + } + + .mx_Clock { + width: $font-42px; // we're not using a monospace font, so fake it + min-width: $font-42px; // for flexbox + padding-left: 4px; // isolate from seek bar + text-align: right; + } + } +} diff --git a/res/css/views/audio_messages/_PlayPauseButton.scss b/res/css/views/audio_messages/_PlayPauseButton.scss new file mode 100644 index 0000000000..714da3e605 --- /dev/null +++ b/res/css/views/audio_messages/_PlayPauseButton.scss @@ -0,0 +1,53 @@ +/* +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_PlayPauseButton { + position: relative; + width: 32px; + height: 32px; + min-width: 32px; // for when the button is used in a flexbox + min-height: 32px; // for when the button is used in a flexbox + border-radius: 32px; + background-color: $voice-playback-button-bg-color; + + &::before { + content: ''; + position: absolute; // sizing varies by icon + background-color: $voice-playback-button-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + } + + &.mx_PlayPauseButton_disabled::before { + opacity: 0.5; + } + + &.mx_PlayPauseButton_play::before { + width: 13px; + height: 16px; + top: 8px; // center + left: 12px; // center + mask-image: url('$(res)/img/element-icons/play.svg'); + } + + &.mx_PlayPauseButton_pause::before { + width: 10px; + height: 12px; + top: 10px; // center + left: 11px; // center + mask-image: url('$(res)/img/element-icons/pause.svg'); + } +} diff --git a/res/css/views/audio_messages/_PlaybackContainer.scss b/res/css/views/audio_messages/_PlaybackContainer.scss new file mode 100644 index 0000000000..5548f6198e --- /dev/null +++ b/res/css/views/audio_messages/_PlaybackContainer.scss @@ -0,0 +1,56 @@ +/* +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: there's no actual component called . These classes +// are shared amongst multiple voice message components. + +// Container for live recording and playback controls +.mx_VoiceMessagePrimaryContainer { + // 7px top and bottom for visual design. 12px left & right, but the waveform (right) + // has a 1px padding on it that we want to account for. + padding: 7px 12px 7px 11px; + + // Cheat at alignment a bit + display: flex; + align-items: center; + + contain: content; + + .mx_Waveform { + .mx_Waveform_bar { + background-color: $voice-record-waveform-incomplete-fg-color; + height: 100%; + /* Variable set by a JS component */ + transform: scaleY(max(0.05, var(--barHeight))); + + &.mx_Waveform_bar_100pct { + // Small animation to remove the mechanical feel of progress + transition: background-color 250ms ease; + background-color: $message-body-panel-fg-color; + } + } + } + + .mx_Clock { + width: $font-42px; // we're not using a monospace font, so fake it + padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended. + padding-left: 8px; // isolate from recording circle / play control + } + + &.mx_VoiceMessagePrimaryContainer_noWaveform { + max-width: 162px; // with all the padding this results in 185px wide + } +} diff --git a/res/css/views/audio_messages/_SeekBar.scss b/res/css/views/audio_messages/_SeekBar.scss new file mode 100644 index 0000000000..d13fe4ac6a --- /dev/null +++ b/res/css/views/audio_messages/_SeekBar.scss @@ -0,0 +1,103 @@ +/* +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. +*/ + +// CSS inspiration from: +// * https://www.w3schools.com/howto/howto_js_rangeslider.asp +// * https://stackoverflow.com/a/28283806 +// * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ + +.mx_SeekBar { + // Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't + // need to support IE. + + appearance: none; // default style override + + width: 100%; + height: 1px; + background: $quaternary-fg-color; + outline: none; // remove blue selection border + position: relative; // for before+after pseudo elements later on + + cursor: pointer; + + &::-webkit-slider-thumb { + appearance: none; // default style override + + // Dev note: This needs to be duplicated with the -moz-range-thumb selector + // because otherwise Edge (webkit) will fail to see the styles and just refuse + // to apply them. + width: 8px; + height: 8px; + border-radius: 8px; + background-color: $tertiary-fg-color; + cursor: pointer; + } + + &::-moz-range-thumb { + width: 8px; + height: 8px; + border-radius: 8px; + background-color: $tertiary-fg-color; + cursor: pointer; + + // Firefox adds a border on the thumb + border: none; + } + + // This is for webkit support, but we can't limit the functionality of it to just webkit + // browsers. Firefox responds to webkit-prefixed values now, which means we can't use media + // or support queries to selectively apply the rule. An upside is that this CSS doesn't work + // in firefox, so it's just wasted CPU/GPU time. + &::before { // ::before to ensure it ends up under the thumb + content: ''; + background-color: $tertiary-fg-color; + + // Absolute positioning to ensure it overlaps with the existing bar + position: absolute; + top: 0; + left: 0; + + // Sizing to match the bar + width: 100%; + height: 1px; + + // And finally dynamic width without overly hurting the rendering engine. + transform-origin: 0 100%; + transform: scaleX(var(--fillTo)); + } + + // This is firefox's built-in support for the above, with 100% less hacks. + &::-moz-range-progress { + background-color: $tertiary-fg-color; + height: 1px; + } + + &:disabled { + opacity: 0.5; + } + + // Increase clickable area for the slider (approximately same size as browser default) + // We do it this way to keep the same padding and margins of the element, avoiding margin math. + // Source: https://front-back.com/expand-clickable-areas-for-a-better-touch-experience/ + &::after { + content: ''; + position: absolute; + top: -6px; + bottom: -6px; + left: 0; + right: 0; + } +} diff --git a/res/css/views/audio_messages/_Waveform.scss b/res/css/views/audio_messages/_Waveform.scss new file mode 100644 index 0000000000..cf03c84601 --- /dev/null +++ b/res/css/views/audio_messages/_Waveform.scss @@ -0,0 +1,40 @@ +/* +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_Waveform { + position: relative; + height: 30px; // tallest bar can only be 30px + top: 1px; // because of our border trick (see below), we're off by 1px of aligntment + + display: flex; + align-items: center; // so the bars grow from the middle + + overflow: hidden; // this is cheaper than a `max-height: calc(100% - 4px)` in the bar's CSS. + + // A bar is meant to be a 2x2 circle when at zero height, and otherwise a 2px wide line + // with rounded caps. + .mx_Waveform_bar { + width: 0; // 0px width means we'll end up using the border as our width + border: 1px solid transparent; // transparent means we'll use the background colour + border-radius: 2px; // rounded end caps, based on the border + min-height: 0; // like the width, we'll rely on the border to give us height + max-height: 100%; // this makes the `height: 42%` work on the element + margin-left: 1px; // we want 2px between each bar, so 1px on either side for balance + margin-right: 1px; + + // background color is handled by the parent components + } +} diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 0ba0d10e06..90dca32e48 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -34,7 +34,11 @@ limitations under the License. h3 { font-size: $font-14px; font-weight: 600; - color: $authpage-primary-color; + color: $authpage-secondary-color; + } + + h3.mx_AuthBody_centered { + text-align: center; } a:link, @@ -96,12 +100,6 @@ limitations under the License. } } -.mx_AuthBody_editServerDetails { - padding-left: 1em; - font-size: $font-12px; - font-weight: normal; -} - .mx_AuthBody_fieldRow { display: flex; margin-bottom: 10px; @@ -146,6 +144,14 @@ limitations under the License. display: block; text-align: center; width: 100%; + + > a { + font-weight: $font-semi-bold; + } +} + +.mx_SSOButtons + .mx_AuthBody_changeFlow { + margin-top: 24px; } .mx_AuthBody_spinner { diff --git a/res/css/views/auth/_AuthHeader.scss b/res/css/views/auth/_AuthHeader.scss index b1372affee..13d5195160 100644 --- a/res/css/views/auth/_AuthHeader.scss +++ b/res/css/views/auth/_AuthHeader.scss @@ -18,7 +18,7 @@ limitations under the License. display: flex; flex-direction: column; width: 206px; - padding: 25px 40px; + padding: 25px 25px; box-sizing: border-box; } diff --git a/res/css/views/auth/_AuthHeaderLogo.scss b/res/css/views/auth/_AuthHeaderLogo.scss index 917dcabf67..86f0313b68 100644 --- a/res/css/views/auth/_AuthHeaderLogo.scss +++ b/res/css/views/auth/_AuthHeaderLogo.scss @@ -17,7 +17,7 @@ limitations under the License. .mx_AuthHeaderLogo { margin-top: 15px; flex: 1; - padding: 0 10px; + padding: 0 25px; } .mx_AuthHeaderLogo img { diff --git a/res/css/views/auth/_InteractiveAuthEntryComponents.scss b/res/css/views/auth/_InteractiveAuthEntryComponents.scss index 05cddf2c48..ffaad3cd7a 100644 --- a/res/css/views/auth/_InteractiveAuthEntryComponents.scss +++ b/res/css/views/auth/_InteractiveAuthEntryComponents.scss @@ -14,6 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_InteractiveAuthEntryComponents_emailWrapper { + padding-right: 100px; + position: relative; + margin-top: 32px; + margin-bottom: 32px; + + &::before, &::after { + position: absolute; + width: 116px; + height: 116px; + content: ""; + right: -10px; + } + + &::before { + background-color: rgba(244, 246, 250, 0.91); + border-radius: 50%; + top: -20px; + } + + &::after { + background-image: url('$(res)/img/element-icons/email-prompt.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + top: -25px; + } +} + .mx_InteractiveAuthEntryComponents_msisdnWrapper { text-align: center; } @@ -54,7 +83,10 @@ limitations under the License. } .mx_InteractiveAuthEntryComponents_termsPolicy { - display: block; + display: flex; + flex-direction: row; + justify-content: start; + align-items: center; } .mx_InteractiveAuthEntryComponents_passwordSection { diff --git a/res/css/views/auth/_LanguageSelector.scss b/res/css/views/auth/_LanguageSelector.scss index 781561f876..885ee7f30d 100644 --- a/res/css/views/auth/_LanguageSelector.scss +++ b/res/css/views/auth/_LanguageSelector.scss @@ -23,6 +23,7 @@ limitations under the License. font-size: $font-14px; font-weight: 600; color: $authpage-lang-color; + width: auto; } .mx_AuthBody_language .mx_Dropdown_arrow { diff --git a/res/css/views/auth/_ServerTypeSelector.scss b/res/css/views/auth/_ServerTypeSelector.scss deleted file mode 100644 index fbd3d2655d..0000000000 --- a/res/css/views/auth/_ServerTypeSelector.scss +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright 2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_ServerTypeSelector { - display: flex; - margin-bottom: 28px; -} - -.mx_ServerTypeSelector_type { - margin: 0 5px; -} - -.mx_ServerTypeSelector_type:first-child { - margin-left: 0; -} - -.mx_ServerTypeSelector_type:last-child { - margin-right: 0; -} - -.mx_ServerTypeSelector_label { - text-align: center; - font-weight: 600; - color: $authpage-primary-color; - margin: 8px 0; -} - -.mx_ServerTypeSelector_type .mx_AccessibleButton { - padding: 10px; - border: 1px solid $input-border-color; - border-radius: 4px; -} - -.mx_ServerTypeSelector_type.mx_ServerTypeSelector_type_selected .mx_AccessibleButton { - border-color: $input-valid-border-color; -} - -.mx_ServerTypeSelector_logo { - display: flex; - justify-content: center; - height: 18px; - margin-bottom: 12px; - font-weight: 600; - color: $authpage-primary-color; -} - -.mx_ServerTypeSelector_logo > div { - display: flex; - width: 70%; - align-items: center; - justify-content: space-evenly; -} - -.mx_ServerTypeSelector_description { - font-size: $font-10px; -} diff --git a/res/css/views/auth/_Welcome.scss b/res/css/views/auth/_Welcome.scss index f0e2b3de33..894174d6e2 100644 --- a/res/css/views/auth/_Welcome.scss +++ b/res/css/views/auth/_Welcome.scss @@ -18,7 +18,6 @@ limitations under the License. display: flex; flex-direction: column; align-items: center; - &.mx_WelcomePage_registrationDisabled { .mx_ButtonCreateAccount { display: none; @@ -27,6 +26,6 @@ limitations under the License. } .mx_Welcome .mx_AuthBody_language { - width: 120px; + width: 160px; margin-bottom: 10px; } diff --git a/res/css/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index 1a1e14e7ac..cbddd97e18 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -41,7 +41,7 @@ limitations under the License. .mx_BaseAvatar_image { object-fit: cover; - border-radius: 40px; + border-radius: 125px; vertical-align: top; background-color: $avatar-bg-color; } diff --git a/res/css/views/avatars/_DecoratedRoomAvatar.scss b/res/css/views/avatars/_DecoratedRoomAvatar.scss index e0afd9de66..257b512579 100644 --- a/res/css/views/avatars/_DecoratedRoomAvatar.scss +++ b/res/css/views/avatars/_DecoratedRoomAvatar.scss @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -// XXX: We shouldn't be using TemporaryTile anywhere - delete it. -.mx_DecoratedRoomAvatar, .mx_TemporaryTile { +.mx_DecoratedRoomAvatar, .mx_ExtraTile { position: relative; + contain: content; &.mx_DecoratedRoomAvatar_cutout .mx_BaseAvatar { mask-image: url('$(res)/img/element-icons/roomlist/decorated-avatar-mask.svg'); diff --git a/res/css/views/beta/_BetaCard.scss b/res/css/views/beta/_BetaCard.scss new file mode 100644 index 0000000000..2af4e79ecd --- /dev/null +++ b/res/css/views/beta/_BetaCard.scss @@ -0,0 +1,161 @@ +/* +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_BetaCard { + margin-bottom: 20px; + padding: 24px; + background-color: $settings-profile-placeholder-bg-color; + border-radius: 8px; + box-sizing: border-box; + + .mx_BetaCard_columns { + display: flex; + + > div { + .mx_BetaCard_title { + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + color: $primary-fg-color; + margin: 4px 0 14px; + + .mx_BetaCard_betaPill { + margin-left: 12px; + } + } + + .mx_BetaCard_caption { + font-size: $font-15px; + line-height: $font-20px; + color: $secondary-fg-color; + margin-bottom: 20px; + } + + .mx_BetaCard_buttons .mx_AccessibleButton { + display: block; + margin: 12px 0; + padding: 7px 40px; + width: auto; + } + + .mx_BetaCard_disclaimer { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + margin-top: 20px; + } + } + + > img { + margin: auto 0 auto 20px; + width: 300px; + object-fit: contain; + height: 100%; + } + } + + .mx_BetaCard_relatedSettings { + .mx_SettingsFlag { + margin: 16px 0 0; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + + .mx_SettingsFlag_microcopy { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + } + } + } +} + +.mx_BetaCard_betaPill { + background-color: $accent-color-alt; + padding: 4px 10px; + border-radius: 8px; + text-transform: uppercase; + font-size: 12px; + line-height: 15px; + color: #FFFFFF; + display: inline-block; + vertical-align: text-bottom; + + &.mx_BetaCard_betaPill_clickable { + cursor: pointer; + } +} + +$pulse-color: $accent-color-alt; +$dot-size: 12px; + +.mx_BetaDot { + border-radius: 50%; + margin: 10px; + height: $dot-size; + width: $dot-size; + transform: scale(1); + background: rgba($pulse-color, 1); + animation: mx_Beta_bluePulse 2s infinite; + animation-iteration-count: 20; + position: relative; + + &::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + top: 0; + left: 0; + transform: scale(1); + transform-origin: center center; + animation-name: mx_Beta_bluePulse_shadow; + animation-duration: inherit; + animation-iteration-count: inherit; + border-radius: 50%; + background: rgba($pulse-color, 1); + } +} + +@keyframes mx_Beta_bluePulse { + 0% { + transform: scale(0.95); + } + + 70% { + transform: scale(1); + } + + 100% { + transform: scale(0.95); + } +} + +@keyframes mx_Beta_bluePulse_shadow { + 0% { + opacity: 0.7; + } + + 70% { + transform: scale(2.2); + opacity: 0; + } + + 100% { + opacity: 0; + } +} diff --git a/res/css/views/context_menus/_CallContextMenu.scss b/res/css/views/context_menus/_CallContextMenu.scss new file mode 100644 index 0000000000..55b73b0344 --- /dev/null +++ b/res/css/views/context_menus/_CallContextMenu.scss @@ -0,0 +1,23 @@ +/* +Copyright 2020 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CallContextMenu_item { + width: 205px; + height: 40px; + padding-left: 16px; + line-height: 40px; + vertical-align: center; +} diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index d911ac6dfe..204435995f 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -75,6 +75,11 @@ limitations under the License. background-color: $menu-selected-color; } + &.mx_AccessibleButton_disabled { + opacity: 0.5; + cursor: not-allowed; + } + img, .mx_IconizedContextMenu_icon { // icons width: 16px; min-width: 16px; diff --git a/res/css/views/context_menus/_MessageContextMenu.scss b/res/css/views/context_menus/_MessageContextMenu.scss index 2ecb93e734..338841cce4 100644 --- a/res/css/views/context_menus/_MessageContextMenu.scss +++ b/res/css/views/context_menus/_MessageContextMenu.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2021 Michael Weimann Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,16 +16,69 @@ limitations under the License. */ .mx_MessageContextMenu { - padding: 6px; -} -.mx_MessageContextMenu_field { - display: block; - padding: 3px 6px 3px 6px; - cursor: pointer; - white-space: nowrap; -} + .mx_IconizedContextMenu_icon { + width: 16px; + height: 16px; + display: block; -.mx_MessageContextMenu_field.mx_MessageContextMenu_fieldSet { - font-weight: bold; + &::before { + content: ''; + width: 16px; + height: 16px; + display: block; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-fg-color; + } + } + + .mx_MessageContextMenu_iconCollapse::before { + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); + } + + .mx_MessageContextMenu_iconReport::before { + mask-image: url('$(res)/img/element-icons/warning-badge.svg'); + } + + .mx_MessageContextMenu_iconLink::before { + mask-image: url('$(res)/img/element-icons/link.svg'); + } + + .mx_MessageContextMenu_iconPermalink::before { + mask-image: url('$(res)/img/element-icons/room/share.svg'); + } + + .mx_MessageContextMenu_iconUnhidePreview::before { + mask-image: url('$(res)/img/element-icons/settings/appearance.svg'); + } + + .mx_MessageContextMenu_iconForward::before { + mask-image: url('$(res)/img/element-icons/message/fwd.svg'); + } + + .mx_MessageContextMenu_iconRedact::before { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); + } + + .mx_MessageContextMenu_iconResend::before { + mask-image: url('$(res)/img/element-icons/retry.svg'); + } + + .mx_MessageContextMenu_iconSource::before { + mask-image: url('$(res)/img/element-icons/room/format-bar/code.svg'); + } + + .mx_MessageContextMenu_iconQuote::before { + mask-image: url('$(res)/img/element-icons/room/format-bar/quote.svg'); + } + + .mx_MessageContextMenu_iconPin::before { + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + } + + .mx_MessageContextMenu_iconUnpin::before { + mask-image: url('$(res)/img/element-icons/room/pin.svg'); + } } diff --git a/res/css/views/context_menus/_TagTileContextMenu.scss b/res/css/views/context_menus/_TagTileContextMenu.scss index 8929c8906e..d707f4ce7c 100644 --- a/res/css/views/context_menus/_TagTileContextMenu.scss +++ b/res/css/views/context_menus/_TagTileContextMenu.scss @@ -38,6 +38,15 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/view-community.svg'); } +.mx_TagTileContextMenu_moveUp::before { + transform: rotate(180deg); + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); +} + +.mx_TagTileContextMenu_moveDown::before { + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); +} + .mx_TagTileContextMenu_hideCommunity::before { mask-image: url('$(res)/img/element-icons/hide.svg'); } diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss new file mode 100644 index 0000000000..2776c477fc --- /dev/null +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -0,0 +1,281 @@ +/* +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_AddExistingToSpaceDialog_wrapper { + .mx_Dialog { + display: flex; + flex-direction: column; + } +} + +.mx_AddExistingToSpace { + .mx_SearchBox { + // To match the space around the title + margin: 0 0 15px 0; + flex-grow: 0; + } + + .mx_AddExistingToSpace_content { + flex-grow: 1; + } + + .mx_AddExistingToSpace_noResults { + display: block; + margin-top: 24px; + } + + .mx_AddExistingToSpace_section { + &:not(:first-child) { + margin-top: 24px; + } + + > h3 { + margin: 0; + color: $secondary-fg-color; + font-size: $font-12px; + font-weight: $font-semi-bold; + line-height: $font-15px; + } + + .mx_AddExistingToSpace_entry { + display: flex; + margin-top: 12px; + + // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling + .mx_DecoratedRoomAvatar { + margin-right: 12px; + } + + .mx_AddExistingToSpace_entry_name { + font-size: $font-15px; + line-height: 30px; + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 12px; + } + + .mx_Checkbox { + align-items: center; + } + } + } + + .mx_AddExistingToSpace_section_spaces { + .mx_BaseAvatar { + margin-right: 12px; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + } + } + + .mx_AddExistingToSpace_section_experimental { + position: relative; + border-radius: 8px; + margin: 12px 0; + padding: 8px 8px 8px 42px; + background-color: $header-panel-bg-color; + + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + &::before { + content: ''; + position: absolute; + left: 10px; + top: calc(50% - 8px); // vertical centering + height: 16px; + width: 16px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + } + } + + .mx_AddExistingToSpace_footer { + display: flex; + margin-top: 20px; + + > span { + flex-grow: 1; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + .mx_ProgressBar { + height: 8px; + width: 100%; + + @mixin ProgressBarBorderRadius 8px; + } + + .mx_AddExistingToSpace_progressText { + margin-top: 8px; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + } + + > * { + vertical-align: middle; + } + } + + .mx_AddExistingToSpace_error { + padding-left: 12px; + + > img { + align-self: center; + } + + .mx_AddExistingToSpace_errorHeading { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $notice-primary-color; + } + + .mx_AddExistingToSpace_errorCaption { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $primary-fg-color; + } + } + + .mx_AccessibleButton { + display: inline-block; + align-self: center; + } + + .mx_AccessibleButton_kind_primary { + padding: 8px 36px; + } + + .mx_AddExistingToSpace_retryButton { + margin-left: 12px; + padding-left: 24px; + position: relative; + + &::before { + content: ''; + position: absolute; + background-color: $primary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + left: 0; + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + } +} + +.mx_AddExistingToSpaceDialog { + width: 480px; + color: $primary-fg-color; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + min-height: 0; + height: 80vh; + + .mx_Dialog_title { + display: flex; + + .mx_BaseAvatar_image { + border-radius: 8px; + margin: 0; + vertical-align: unset; + } + + .mx_BaseAvatar { + display: inline-flex; + margin: auto 16px auto 5px; + vertical-align: middle; + } + + > div { + > h1 { + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + margin: 0; + } + + .mx_AddExistingToSpaceDialog_onlySpace { + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + } + } + + .mx_Dropdown_input { + border: none; + + > .mx_Dropdown_option { + padding-left: 0; + flex: unset; + height: unset; + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + + .mx_BaseAvatar { + display: none; + } + } + + .mx_Dropdown_menu { + .mx_AddExistingToSpaceDialog_dropdownOptionActive { + color: $accent-color; + padding-right: 32px; + position: relative; + + &::before { + content: ''; + width: 20px; + height: 20px; + top: 8px; + right: 0; + 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_AddExistingToSpace { + display: contents; + } +} diff --git a/res/css/views/dialogs/_BetaFeedbackDialog.scss b/res/css/views/dialogs/_BetaFeedbackDialog.scss new file mode 100644 index 0000000000..9f5f6b512e --- /dev/null +++ b/res/css/views/dialogs/_BetaFeedbackDialog.scss @@ -0,0 +1,30 @@ +/* +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_BetaFeedbackDialog { + .mx_BetaFeedbackDialog_subheading { + color: $primary-fg-color; + font-size: $font-14px; + line-height: $font-20px; + margin-bottom: 24px; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + font-size: inherit; + line-height: inherit; + } +} diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss index 35cb6bc7ab..8fee740016 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.scss +++ b/res/css/views/dialogs/_DevtoolsDialog.scss @@ -223,3 +223,54 @@ limitations under the License. content: ":"; } } + +.mx_DevTools_SettingsExplorer { + table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + + th { + // Colour choice: first one autocomplete gave me. + border-bottom: 1px solid $accent-color; + text-align: left; + } + + td, th { + width: 360px; // "feels right" number + + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + td + td, th + th { + width: auto; + } + + tr:hover { + // Colour choice: first one autocomplete gave me. + background-color: $accent-color-50pct; + } + } + + .mx_DevTools_SettingsExplorer_mutable { + background-color: $accent-color; + } + + .mx_DevTools_SettingsExplorer_immutable { + background-color: $warning-color; + } + + .mx_DevTools_SettingsExplorer_edit { + float: right; + margin-right: 16px; + } + + .mx_DevTools_SettingsExplorer_warning { + border: 2px solid $warning-color; + border-radius: 4px; + padding: 4px; + margin-bottom: 8px; + } +} diff --git a/res/css/views/dialogs/_ForwardDialog.scss b/res/css/views/dialogs/_ForwardDialog.scss new file mode 100644 index 0000000000..95d7ce74c4 --- /dev/null +++ b/res/css/views/dialogs/_ForwardDialog.scss @@ -0,0 +1,159 @@ +/* +Copyright 2021 Robin Townsend + +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_ForwardDialog { + width: 520px; + color: $primary-fg-color; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + min-height: 0; + height: 80vh; + + > h3 { + margin: 0 0 6px; + color: $secondary-fg-color; + font-size: $font-12px; + font-weight: $font-semi-bold; + line-height: $font-15px; + } + + > .mx_ForwardDialog_preview { + max-height: 30%; + flex-shrink: 0; + overflow-y: auto; + + div { + pointer-events: none; + } + + .mx_EventTile_msgOption { + display: none; + } + + // When forwarding messages from encrypted rooms, EventTile will complain + // that our preview is unencrypted, which doesn't actually matter + .mx_EventTile_e2eIcon_unencrypted { + display: none; + } + + // We also hide download links to not encourage users to try interacting + .mx_MFileBody_download { + display: none; + } + } + + > hr { + width: 100%; + border: none; + border-top: 1px solid $input-border-color; + margin: 12px 0; + } + + > .mx_ForwardList { + display: contents; + + .mx_SearchBox { + // To match the space around the title + margin: 0 0 15px 0; + flex-grow: 0; + } + + .mx_ForwardList_content { + flex-grow: 1; + } + + .mx_ForwardList_noResults { + display: block; + margin-top: 24px; + } + + .mx_ForwardList_results { + &:not(:first-child) { + margin-top: 24px; + } + + .mx_ForwardList_entry { + display: flex; + justify-content: space-between; + height: 32px; + padding: 6px; + border-radius: 8px; + + &:hover { + background-color: $groupFilterPanel-bg-color; + } + + .mx_ForwardList_roomButton { + display: flex; + margin-right: 12px; + min-width: 0; + + .mx_DecoratedRoomAvatar { + margin-right: 12px; + } + + .mx_ForwardList_entry_name { + font-size: $font-15px; + line-height: 30px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 12px; + } + } + + .mx_ForwardList_sendButton { + position: relative; + + &:not(.mx_ForwardList_canSend) .mx_ForwardList_sendLabel { + // Hide the "Send" label while preserving button size + visibility: hidden; + } + + .mx_ForwardList_sendIcon, .mx_NotificationBadge { + position: absolute; + } + + .mx_NotificationBadge { + // Match the failed to send indicator's color with the disabled button + background-color: $button-danger-disabled-fg-color; + } + + &.mx_ForwardList_sending .mx_ForwardList_sendIcon { + background-color: $button-primary-bg-color; + mask-image: url('$(res)/img/element-icons/circle-sending.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 14px; + width: 14px; + height: 14px; + } + + &.mx_ForwardList_sent .mx_ForwardList_sendIcon { + background-color: $button-primary-bg-color; + mask-image: url('$(res)/img/element-icons/circle-sent.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 14px; + width: 14px; + height: 14px; + } + } + } + } + } +} diff --git a/res/css/views/dialogs/_HostSignupDialog.scss b/res/css/views/dialogs/_HostSignupDialog.scss new file mode 100644 index 0000000000..ac4bc41951 --- /dev/null +++ b/res/css/views/dialogs/_HostSignupDialog.scss @@ -0,0 +1,143 @@ +/* +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_HostSignupDialog { + width: 90vw; + max-width: 580px; + height: 80vh; + max-height: 600px; + // Ensure dialog borders are always white as the HostSignupDialog + // does not yet support dark mode or theming in general. + // In the future we might want to pass the theme to the called + // iframe, should some hosting provider have that need. + background-color: #ffffff; + + .mx_HostSignupDialog_info { + text-align: center; + + .mx_HostSignupDialog_content_top { + margin-bottom: 24px; + } + + .mx_HostSignupDialog_paragraphs { + text-align: left; + padding-left: 25%; + padding-right: 25%; + } + + .mx_HostSignupDialog_buttons { + margin-bottom: 24px; + display: flex; + justify-content: center; + + button { + padding: 12px; + margin: 0 16px; + } + } + + .mx_HostSignupDialog_footer { + display: flex; + justify-content: center; + align-items: baseline; + + img { + padding-right: 5px; + } + } + } + + iframe { + width: 100%; + height: 100%; + border: none; + background-color: #fff; + min-height: 540px; + } +} + +.mx_HostSignupDialog_text_dark { + color: $primary-fg-color; +} + +.mx_HostSignupDialog_text_light { + color: $secondary-fg-color; +} + +.mx_HostSignup_maximize_button { + mask: url('$(res)/img/feather-customised/maximise.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + width: 14px; + height: 14px; + background-color: $dialog-close-fg-color; + cursor: pointer; + position: absolute; + top: 10px; + right: 10px; +} + +.mx_HostSignup_minimize_button { + mask: url('$(res)/img/feather-customised/minimise.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + width: 14px; + height: 14px; + background-color: $dialog-close-fg-color; + cursor: pointer; + position: absolute; + top: 10px; + right: 25px; +} + +.mx_HostSignup_persisted { + width: 90vw; + max-width: 580px; + height: 80vh; + max-height: 600px; + top: 0; + left: 0; + position: fixed; + display: none; +} + +.mx_HostSignupDialog_minimized { + position: fixed; + bottom: 80px; + right: 26px; + width: 314px; + height: 217px; + overflow: hidden; + + &.mx_Dialog { + padding: 12px; + } + + .mx_Dialog_title { + text-align: left !important; + padding-left: 20px; + font-size: $font-15px; + } + + iframe { + width: 100%; + height: 100%; + border: none; + background-color: #fff; + } +} diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss index b9063f46b9..9fc4b7a15c 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -14,9 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_InviteDialog_transferWrapper .mx_Dialog { + padding-bottom: 16px; +} + .mx_InviteDialog_addressBar { display: flex; flex-direction: row; + // Right margin for the design. We could apply this to the whole dialog, but then the scrollbar + // for the user section gets weird. + margin: 8px 45px 0 0; .mx_InviteDialog_editor { flex: 1; @@ -27,37 +34,29 @@ limitations under the License. padding-left: 8px; overflow-x: hidden; overflow-y: auto; + display: flex; + flex-wrap: wrap; .mx_InviteDialog_userTile { + margin: 6px 6px 0 0; display: inline-block; - float: left; - position: relative; - top: 7px; + min-width: max-content; // prevent manipulation by flexbox } - // Using a textarea for this element, to circumvent autofill - // Mostly copied from AddressPickerDialog - textarea, - textarea:focus { - height: 34px; - line-height: $font-34px; + // Mostly copied from AddressPickerDialog; overrides bunch of our default text input styles + > input[type="text"] { + margin: 6px 0 !important; + height: 24px; + line-height: $font-24px; font-size: $font-14px; padding-left: 12px; - margin: 0 !important; border: 0 !important; outline: 0 !important; resize: none; - overflow: hidden; box-sizing: border-box; - word-wrap: nowrap; - - // Roughly fill about 2/5ths of the available space. This is to try and 'fill' the - // remaining space after a bunch of pills, but is a bit hacky. Ideally we'd have - // support for "fill remaining width", but traditional tricks don't work with what - // we're pushing into this "field". Flexbox just makes things worse. The theory is - // that users won't need more than about 2/5ths of the input to find the person - // they're looking for. - width: 40%; + min-width: 40%; + flex: 1 !important; + color: $primary-fg-color !important; } } @@ -81,7 +80,7 @@ limitations under the License. } .mx_InviteDialog_section { - padding-bottom: 10px; + padding-bottom: 4px; h3 { font-size: $font-12px; @@ -90,6 +89,14 @@ limitations under the License. text-transform: uppercase; } + > p { + margin: 0; + } + + > span { + color: $primary-fg-color; + } + .mx_InviteDialog_subname { margin-bottom: 10px; margin-top: -10px; // HACK: Positioning with margins is bad @@ -98,6 +105,63 @@ limitations under the License. } } +.mx_InviteDialog_section_hidden_suggestions_disclaimer { + padding: 8px 0 16px 0; + font-size: $font-14px; + + > span { + color: $primary-fg-color; + font-weight: 600; + } + + > p { + margin: 0; + } +} + +.mx_InviteDialog_footer { + border-top: 1px solid $input-border-color; + + > h3 { + margin: 12px 0; + font-size: $font-12px; + color: $muted-fg-color; + font-weight: bold; + text-transform: uppercase; + } + + .mx_InviteDialog_footer_link { + display: flex; + justify-content: space-between; + border-radius: 4px; + border: solid 1px $light-fg-color; + padding: 8px; + + > a { + text-decoration: none; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .mx_InviteDialog_footer_link_copy { + flex-shrink: 0; + cursor: pointer; + margin-left: 20px; + display: inherit; + + > div { + mask-image: url($copy-button-url); + background-color: $message-action-bar-fg-color; + margin-left: 5px; + width: 20px; + height: 20px; + background-repeat: no-repeat; + } + } +} + .mx_InviteDialog_roomTile { cursor: pointer; padding: 5px 10px; @@ -148,6 +212,11 @@ limitations under the License. } } + .mx_InviteDialog_roomTile_nameStack { + display: inline-block; + overflow: hidden; + } + .mx_InviteDialog_roomTile_name { font-weight: 600; font-size: $font-14px; @@ -161,6 +230,13 @@ limitations under the License. margin-left: 7px; } + .mx_InviteDialog_roomTile_name, + .mx_InviteDialog_roomTile_userId { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mx_InviteDialog_roomTile_time { text-align: right; font-size: $font-12px; @@ -214,26 +290,165 @@ limitations under the License. } } -.mx_InviteDialog { +.mx_InviteDialog_other { // Prevent the dialog from jumping around randomly when elements change. - height: 590px; + height: 600px; padding-left: 20px; // the design wants some padding on the left + + .mx_InviteDialog_userSections { + height: calc(100% - 115px); // mx_InviteDialog's height minus some for the upper and lower elements + } +} + +.mx_InviteDialog_content { + height: calc(100% - 36px); // full height minus the size of the header + overflow: hidden; +} + +.mx_InviteDialog_transfer { + width: 496px; + height: 466px; + flex-direction: column; + + .mx_InviteDialog_content { + flex-direction: column; + + .mx_TabbedView { + height: calc(100% - 60px); + } + overflow: visible; + } + + .mx_InviteDialog_addressBar { + margin-top: 8px; + } + + input[type="checkbox"] { + margin-right: 8px; + } } .mx_InviteDialog_userSections { - margin-top: 10px; + margin-top: 4px; overflow-y: auto; - padding-right: 45px; - height: 455px; // mx_InviteDialog's height minus some for the upper elements + padding: 0 45px 4px 0; } -// Right margin for the design. We could apply this to the whole dialog, but then the scrollbar -// for the user section gets weird. -.mx_InviteDialog_helpText, -.mx_InviteDialog_addressBar { - margin-right: 45px; +.mx_InviteDialog_hasFooter .mx_InviteDialog_userSections { + height: calc(100% - 175px); +} + +.mx_InviteDialog_helpText { + margin: 0; } .mx_InviteDialog_helpText .mx_AccessibleButton_kind_link { padding: 0; } + +.mx_InviteDialog_dialPad .mx_InviteDialog_dialPadField { + border-top: 0; + border-left: 0; + border-right: 0; + border-radius: 0; + margin-top: 0; + border-color: $quaternary-fg-color; + + input { + font-size: 18px; + font-weight: 600; + padding-top: 0; + } +} + +.mx_InviteDialog_dialPad .mx_InviteDialog_dialPadField:focus-within { + border-color: $accent-color; +} + +.mx_InviteDialog_dialPadField .mx_Field_postfix { + /* Remove border separator between postfix and field content */ + border-left: none; +} + +.mx_InviteDialog_dialPad { + width: 224px; + margin-top: 16px; + margin-left: auto; + margin-right: auto; +} + +.mx_InviteDialog_dialPad .mx_DialPad { + row-gap: 16px; + column-gap: 48px; + + margin-left: auto; + margin-right: auto; +} + +.mx_InviteDialog_transferConsultConnect { + padding-top: 16px; + /* This wants a drop shadow the full width of the dialog, so relative-position it + * and make it wider, then compensate with padding + */ + position: relative; + width: 496px; + left: -24px; + padding-left: 24px; + padding-right: 24px; + border-top: 1px solid $message-body-panel-bg-color; + + display: flex; + flex-direction: row; + align-items: center; +} + +.mx_InviteDialog_transferConsultConnect_pushRight { + margin-left: auto; +} + +.mx_InviteDialog_userDirectoryIcon::before { + mask-image: url('$(res)/img/voip/tab-userdirectory.svg'); +} + +.mx_InviteDialog_dialPadIcon::before { + mask-image: url('$(res)/img/voip/tab-dialpad.svg'); +} + +.mx_InviteDialog_multiInviterError { + > h4 { + font-size: $font-15px; + line-height: $font-24px; + color: $secondary-fg-color; + font-weight: normal; + } + + > div { + .mx_InviteDialog_multiInviterError_entry { + margin-bottom: 24px; + + .mx_InviteDialog_multiInviterError_entry_userProfile { + .mx_InviteDialog_multiInviterError_entry_name { + margin-left: 6px; + font-size: $font-15px; + line-height: $font-24px; + font-weight: $font-semi-bold; + color: $primary-fg-color; + } + + .mx_InviteDialog_multiInviterError_entry_userId { + margin-left: 6px; + font-size: $font-12px; + line-height: $font-15px; + color: $tertiary-fg-color; + } + } + + .mx_InviteDialog_multiInviterError_entry_error { + margin-left: 32px; + font-size: $font-15px; + line-height: $font-24px; + color: $notice-primary-color; + } + } + } +} diff --git a/src/components/views/avatars/PulsedAvatar.tsx b/res/css/views/dialogs/_RegistrationEmailPromptDialog.scss similarity index 73% rename from src/components/views/avatars/PulsedAvatar.tsx rename to res/css/views/dialogs/_RegistrationEmailPromptDialog.scss index b4e876b9f6..31fc6d7a04 100644 --- a/src/components/views/avatars/PulsedAvatar.tsx +++ b/res/css/views/dialogs/_RegistrationEmailPromptDialog.scss @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +.mx_RegistrationEmailPromptDialog { + width: 417px; -interface IProps { + .mx_Dialog_content { + margin-bottom: 24px; + color: $tertiary-fg-color; + } + + .mx_Dialog_primary { + width: 100%; + } } - -const PulsedAvatar: React.FC = (props) => { - return
- {props.children} -
; -}; - -export default PulsedAvatar; diff --git a/res/css/views/dialogs/_RoomSettingsDialogBridges.scss b/res/css/views/dialogs/_RoomSettingsDialogBridges.scss index a1793cc75e..c97a3b69b7 100644 --- a/res/css/views/dialogs/_RoomSettingsDialogBridges.scss +++ b/res/css/views/dialogs/_RoomSettingsDialogBridges.scss @@ -89,24 +89,18 @@ limitations under the License. } } - .mx_showMore { - display: block; - text-align: left; - margin-top: 10px; - } - .metadata { color: $muted-fg-color; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; margin-bottom: 0; - } - - .metadata.visible { overflow-y: visible; text-overflow: ellipsis; white-space: normal; + padding: 0; + + > li { + padding: 0; + border: 0; + } } } } diff --git a/res/css/views/dialogs/_ServerPickerDialog.scss b/res/css/views/dialogs/_ServerPickerDialog.scss new file mode 100644 index 0000000000..b01b49d7af --- /dev/null +++ b/res/css/views/dialogs/_ServerPickerDialog.scss @@ -0,0 +1,78 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ServerPickerDialog { + width: 468px; + box-sizing: border-box; + + .mx_Dialog_content { + margin-bottom: 0; + + > p { + color: $secondary-fg-color; + font-size: $font-14px; + margin: 16px 0; + + &:first-of-type { + margin-bottom: 40px; + } + + &:last-of-type { + margin: 0 24px 24px; + } + } + + > h4 { + font-size: $font-15px; + font-weight: $font-semi-bold; + color: $secondary-fg-color; + margin-left: 8px; + } + + > a { + color: $accent-color; + margin-left: 8px; + } + } + + .mx_ServerPickerDialog_otherHomeserverRadio { + input[type="radio"] + div { + margin-top: auto; + margin-bottom: auto; + } + } + + .mx_ServerPickerDialog_otherHomeserver { + border-top: none; + border-left: none; + border-right: none; + border-radius: unset; + + > input { + padding-left: 0; + } + + > label { + margin-left: 0; + } + } + + .mx_AccessibleButton_kind_primary { + width: calc(100% - 64px); + margin: 0 8px; + padding: 15px 18px; + } +} diff --git a/res/css/views/dialogs/_SettingsDialog.scss b/res/css/views/dialogs/_SettingsDialog.scss index 6c4ed35c5a..b3b6802c3d 100644 --- a/res/css/views/dialogs/_SettingsDialog.scss +++ b/res/css/views/dialogs/_SettingsDialog.scss @@ -15,7 +15,7 @@ limitations under the License. */ // Not actually a component but things shared by settings components -.mx_UserSettingsDialog, .mx_RoomSettingsDialog { +.mx_UserSettingsDialog, .mx_RoomSettingsDialog, .mx_SpaceSettingsDialog { width: 90vw; max-width: 1000px; // set the height too since tabbed view scrolls itself. diff --git a/res/css/views/dialogs/_ShareDialog.scss b/res/css/views/dialogs/_ShareDialog.scss index ce3fdd021f..4d5e1409db 100644 --- a/res/css/views/dialogs/_ShareDialog.scss +++ b/res/css/views/dialogs/_ShareDialog.scss @@ -50,7 +50,8 @@ limitations under the License. margin-left: 20px; display: inherit; } -.mx_ShareDialog_matrixto_copy > div { +.mx_ShareDialog_matrixto_copy::after { + content: ""; mask-image: url($copy-button-url); background-color: $message-action-bar-fg-color; margin-left: 5px; diff --git a/res/css/views/dialogs/_SpaceSettingsDialog.scss b/res/css/views/dialogs/_SpaceSettingsDialog.scss new file mode 100644 index 0000000000..fa074fdbe8 --- /dev/null +++ b/res/css/views/dialogs/_SpaceSettingsDialog.scss @@ -0,0 +1,100 @@ +/* +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_SpaceSettingsDialog { + color: $primary-fg-color; + + .mx_SpaceSettings_errorText { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + color: $notice-primary-color; + margin-bottom: 28px; + } + + .mx_ToggleSwitch { + display: inline-block; + vertical-align: middle; + margin-left: 16px; + } + + .mx_SettingsTab_section { + .mx_SettingsTab_section_caption { + margin-top: 12px; + margin-bottom: 20px; + } + + & + .mx_SettingsTab_subheading { + border-top: 1px solid $message-body-panel-bg-color; + margin-top: 0; + padding-top: 24px; + } + + .mx_RadioButton { + margin-top: 8px; + margin-bottom: 4px; + + .mx_RadioButton_content { + font-weight: $font-semi-bold; + line-height: $font-18px; + color: $primary-fg-color; + } + + & + span { + font-size: $font-15px; + line-height: $font-18px; + color: $secondary-fg-color; + margin-left: 26px; + } + } + + .mx_SettingsTab_showAdvanced { + margin: 16px 0; + padding: 0; + } + + .mx_SettingsFlag { + margin-top: 24px; + } + } + + .mx_SpaceSettingsDialog_buttons { + display: flex; + margin-top: 64px; + + .mx_AccessibleButton { + display: inline-block; + } + + .mx_AccessibleButton_kind_link { + margin-left: auto; + } + } + + .mx_AccessibleButton_hasKind { + padding: 8px 22px; + } + + .mx_TabbedView_tabLabel { + .mx_SpaceSettingsDialog_generalIcon::before { + mask-image: url('$(res)/img/element-icons/settings.svg'); + } + + .mx_SpaceSettingsDialog_visibilityIcon::before { + mask-image: url('$(res)/img/element-icons/eye.svg'); + } + } +} diff --git a/res/css/views/dialogs/_UntrustedDeviceDialog.scss b/res/css/views/dialogs/_UntrustedDeviceDialog.scss new file mode 100644 index 0000000000..0ecd9d4f71 --- /dev/null +++ b/res/css/views/dialogs/_UntrustedDeviceDialog.scss @@ -0,0 +1,26 @@ +/* +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_UntrustedDeviceDialog { + .mx_Dialog_title { + display: flex; + align-items: center; + + .mx_E2EIcon { + margin-left: 0; + } + } +} diff --git a/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss new file mode 100644 index 0000000000..176919b84c --- /dev/null +++ b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss @@ -0,0 +1,75 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + + +.mx_WidgetCapabilitiesPromptDialog { + .text-muted { + font-size: $font-12px; + } + + .mx_Dialog_content { + margin-bottom: 16px; + } + + .mx_WidgetCapabilitiesPromptDialog_cap { + margin-top: 20px; + font-size: $font-15px; + line-height: $font-15px; + + .mx_WidgetCapabilitiesPromptDialog_byline { + color: $muted-fg-color; + margin-left: 26px; + font-size: $font-12px; + line-height: $font-12px; + } + } + + .mx_Dialog_buttons { + margin-top: 40px; // double normal + } + + .mx_SettingsFlag { + line-height: calc($font-14px + 7px + 7px); // 7px top & bottom padding + color: $muted-fg-color; + font-size: $font-12px; + + .mx_ToggleSwitch { + display: inline-block; + vertical-align: middle; + margin-right: 8px; + + // downsize the switch + ball + width: $font-32px; + height: $font-15px; + + + &.mx_ToggleSwitch_on > .mx_ToggleSwitch_ball { + left: calc(100% - $font-15px); + } + + .mx_ToggleSwitch_ball { + width: $font-15px; + height: $font-15px; + border-radius: $font-15px; + } + } + + .mx_SettingsFlag_label { + display: inline-block; + vertical-align: middle; + } + } +} diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss index 63d0ca555d..ec3bea0ef7 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss @@ -1,6 +1,5 @@ /* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2018, 2019, 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. @@ -15,6 +14,28 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_AccessSecretStorageDialog_reset { + position: relative; + padding-left: 24px; // 16px icon + 8px padding + margin-top: 7px; // vertical alignment to buttons + + &::before { + content: ""; + display: inline-block; + position: absolute; + height: 16px; + width: 16px; + left: 0; + top: 2px; // alignment + background-image: url("$(res)/img/element-icons/warning-badge.svg"); + background-size: contain; + } + + .mx_AccessSecretStorageDialog_reset_link { + color: $warning-color; + } +} + .mx_AccessSecretStorageDialog_titleWithIcon::before { content: ''; display: inline-block; @@ -26,6 +47,13 @@ limitations under the License. background-color: $primary-fg-color; } +.mx_AccessSecretStorageDialog_resetBadge::before { + // The image isn't capable of masking, so we use a background instead. + background-image: url("$(res)/img/element-icons/warning-badge.svg"); + background-size: 24px; + background-color: transparent; +} + .mx_AccessSecretStorageDialog_secureBackupTitle::before { mask-image: url('$(res)/img/feather-customised/secure-backup.svg'); } diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index 9c26f8f120..7bc47a3c98 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -26,7 +26,9 @@ limitations under the License. padding: 7px 18px; text-align: center; border-radius: 8px; - display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; font-size: $font-14px; } @@ -70,16 +72,20 @@ limitations under the License. .mx_AccessibleButton_kind_danger_outline { color: $button-danger-bg-color; - background-color: $button-secondary-bg-color; + background-color: transparent; border: 1px solid $button-danger-bg-color; } -.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled, -.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled { +.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled { color: $button-danger-disabled-fg-color; background-color: $button-danger-disabled-bg-color; } +.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled { + color: $button-danger-disabled-bg-color; + border-color: $button-danger-disabled-bg-color; +} + .mx_AccessibleButton_hasKind.mx_AccessibleButton_kind_danger_sm { padding: 5px 12px; color: $button-danger-fg-color; diff --git a/res/css/views/elements/_DesktopCapturerSourcePicker.scss b/res/css/views/elements/_DesktopCapturerSourcePicker.scss new file mode 100644 index 0000000000..69dde5925e --- /dev/null +++ b/res/css/views/elements/_DesktopCapturerSourcePicker.scss @@ -0,0 +1,72 @@ +/* +Copyright 2021 Šimon Brandner + +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_desktopCapturerSourcePicker { + overflow: hidden; +} + +.mx_desktopCapturerSourcePicker_tabLabels { + display: flex; + padding: 0 0 8px 0; +} + +.mx_desktopCapturerSourcePicker_tabLabel, +.mx_desktopCapturerSourcePicker_tabLabel_selected { + width: 100%; + text-align: center; + border-radius: 8px; + padding: 8px 0; + font-size: $font-13px; +} + +.mx_desktopCapturerSourcePicker_tabLabel_selected { + background-color: $tab-label-active-bg-color; + color: $tab-label-active-fg-color; +} + +.mx_desktopCapturerSourcePicker_panel { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + height: 500px; + overflow: overlay; +} + +.mx_desktopCapturerSourcePicker_stream_button { + display: flex; + flex-direction: column; + margin: 8px; + border-radius: 4px; +} + +.mx_desktopCapturerSourcePicker_stream_button:hover, +.mx_desktopCapturerSourcePicker_stream_button:focus { + background: $roomtile-selected-bg-color; +} + +.mx_desktopCapturerSourcePicker_stream_thumbnail { + margin: 4px; + width: 312px; +} + +.mx_desktopCapturerSourcePicker_stream_name { + margin: 0 4px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 312px; +} diff --git a/res/css/views/elements/_DialPadBackspaceButton.scss b/res/css/views/elements/_DialPadBackspaceButton.scss new file mode 100644 index 0000000000..40e4af7025 --- /dev/null +++ b/res/css/views/elements/_DialPadBackspaceButton.scss @@ -0,0 +1,40 @@ +/* +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_DialPadBackspaceButton { + position: relative; + height: 28px; + width: 28px; + + &::before { + /* force this element to appear on the DOM */ + content: ""; + + background-color: #8D97A5; + width: inherit; + height: inherit; + top: 0px; + left: 0px; + position: absolute; + display: inline-block; + vertical-align: middle; + + mask-image: url('$(res)/img/element-icons/call/delete.svg'); + mask-position: 8px; + mask-size: 20px; + mask-repeat: no-repeat; + } +} diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss new file mode 100644 index 0000000000..c691baffb5 --- /dev/null +++ b/res/css/views/elements/_FacePile.scss @@ -0,0 +1,65 @@ +/* +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_FacePile { + .mx_FacePile_faces { + display: inline-flex; + flex-direction: row-reverse; + vertical-align: middle; + + > .mx_FacePile_face + .mx_FacePile_face { + margin-right: -8px; + } + + .mx_BaseAvatar_image { + border: 1px solid $primary-bg-color; + } + + .mx_BaseAvatar_initial { + margin: 1px; // to offset the border on the image + } + + .mx_FacePile_more { + position: relative; + border-radius: 100%; + width: 30px; + height: 30px; + background-color: $groupFilterPanel-bg-color; + + &::before { + content: ""; + z-index: 1; + position: absolute; + top: 0; + left: 0; + height: inherit; + width: inherit; + background: $tertiary-fg-color; + mask-position: center; + mask-size: 20px; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + } + } + } + + .mx_FacePile_summary { + margin-left: 12px; + font-size: $font-14px; + line-height: $font-24px; + color: $tertiary-fg-color; + } +} diff --git a/res/css/views/elements/_IconButton.scss b/res/css/views/elements/_IconButton.scss deleted file mode 100644 index d8ebbeb65e..0000000000 --- a/res/css/views/elements/_IconButton.scss +++ /dev/null @@ -1,55 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_IconButton { - width: 32px; - height: 32px; - border-radius: 100%; - background-color: $accent-bg-color; - // don't shrink or grow if in a flex container - flex: 0 0 auto; - - &.mx_AccessibleButton_disabled { - background-color: none; - - &::before { - background-color: lightgrey; - } - } - - &:hover { - opacity: 90%; - } - - &::before { - content: ""; - display: block; - width: 100%; - height: 100%; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 55%; - background-color: $accent-color; - } - - &.mx_IconButton_icon_check::before { - mask-image: url('$(res)/img/feather-customised/check.svg'); - } - - &.mx_IconButton_icon_edit::before { - mask-image: url('$(res)/img/feather-customised/edit.svg'); - } -} diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index 0a4ed2a194..cf92ffec64 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -14,139 +14,113 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* This has got to be the most fragile piece of CSS ever written. - But empirically it works on Chrome/FF/Safari - */ +$button-size: 32px; +$icon-size: 22px; +$button-gap: 24px; .mx_ImageView { display: flex; width: 100%; height: 100%; - align-items: center; -} - -.mx_ImageView_lhs { - order: 1; - flex: 1 1 10%; - min-width: 60px; - // background-color: #080; - // height: 20px; -} - -.mx_ImageView_content { - order: 2; - /* min-width hack needed for FF */ - min-width: 0px; - height: 90%; - flex: 15 15 0; - display: flex; - align-items: center; - justify-content: center; -} - -.mx_ImageView_content img { - max-width: 100%; - /* XXX: max-height interacts badly with flex on Chrome and doesn't relayout properly until you refresh */ - max-height: 100%; - /* object-fit hack needed for Chrome due to Chrome not re-laying-out until you refresh */ - object-fit: contain; - /* background-image: url('$(res)/img/trans.png'); */ - pointer-events: all; -} - -.mx_ImageView_labelWrapper { - position: absolute; - top: 0px; - right: 0px; - height: 100%; - overflow: auto; - pointer-events: all; -} - -.mx_ImageView_label { - text-align: left; - display: flex; - justify-content: center; flex-direction: column; - padding-left: 30px; - padding-right: 30px; - min-height: 100%; - max-width: 240px; +} + +.mx_ImageView_image_wrapper { + pointer-events: initial; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + overflow: hidden; +} + +.mx_ImageView_image { + flex-shrink: 0; +} + +.mx_ImageView_panel { + width: 100%; + height: 68px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.mx_ImageView_info_wrapper { + pointer-events: initial; + padding-left: 32px; + display: flex; + flex-direction: row; + align-items: center; color: $lightbox-fg-color; } -.mx_ImageView_cancel { - position: absolute; - // hack for mx_Dialog having a top padding of 40px - top: 40px; - right: 0px; - padding-top: 35px; - padding-right: 35px; - cursor: pointer; +.mx_ImageView_info { + padding-left: 12px; + display: flex; + flex-direction: column; } -.mx_ImageView_rotateClockwise { - position: absolute; - top: 40px; - right: 70px; - padding-top: 35px; - cursor: pointer; +.mx_ImageView_info_sender { + font-weight: bold; } -.mx_ImageView_rotateCounterClockwise { - position: absolute; - top: 40px; - right: 105px; - padding-top: 35px; - cursor: pointer; -} - -.mx_ImageView_name { - font-size: $font-18px; - margin-bottom: 6px; - word-wrap: break-word; -} - -.mx_ImageView_metadata { - font-size: $font-15px; - opacity: 0.5; -} - -.mx_ImageView_download { - display: table; - margin-top: 24px; - margin-bottom: 6px; - border-radius: 5px; - background-color: $lightbox-bg-color; - font-size: $font-14px; - padding: 9px; - border: 1px solid $lightbox-border-color; -} - -.mx_ImageView_size { - font-size: $font-11px; -} - -.mx_ImageView_link { - color: $lightbox-fg-color !important; - text-decoration: none !important; +.mx_ImageView_toolbar { + padding-right: 16px; + pointer-events: initial; + display: flex; + align-items: center; + gap: calc($button-gap - ($button-size - $icon-size)); } .mx_ImageView_button { - font-size: $font-15px; - opacity: 0.5; - margin-top: 18px; - cursor: pointer; + padding: calc(($button-size - $icon-size) / 2); + display: block; + + &::before { + content: ''; + height: $icon-size; + width: $icon-size; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + display: block; + background-color: $icon-button-color; + } } -.mx_ImageView_shim { - height: 30px; +.mx_ImageView_button_rotateCW::before { + mask-image: url('$(res)/img/image-view/rotate-cw.svg'); } -.mx_ImageView_rhs { - order: 3; - flex: 1 1 10%; - min-width: 300px; - // background-color: #800; - // height: 20px; +.mx_ImageView_button_rotateCCW::before { + mask-image: url('$(res)/img/image-view/rotate-ccw.svg'); +} + +.mx_ImageView_button_zoomOut::before { + mask-image: url('$(res)/img/image-view/zoom-out.svg'); +} + +.mx_ImageView_button_zoomIn::before { + mask-image: url('$(res)/img/image-view/zoom-in.svg'); +} + +.mx_ImageView_button_download::before { + mask-image: url('$(res)/img/image-view/download.svg'); +} + +.mx_ImageView_button_more::before { + mask-image: url('$(res)/img/image-view/more.svg'); +} + +.mx_ImageView_button_close { + padding: calc($button-size - $button-size); + border-radius: 100%; + background: #21262c; // same on all themes + &::before { + width: $button-size; + height: $button-size; + mask-image: url('$(res)/img/image-view/close.svg'); + mask-size: 40%; + } } diff --git a/res/css/views/elements/_InlineSpinner.scss b/res/css/views/elements/_InlineSpinner.scss index 6b91e45923..ca5cb5d3a8 100644 --- a/res/css/views/elements/_InlineSpinner.scss +++ b/res/css/views/elements/_InlineSpinner.scss @@ -18,7 +18,11 @@ limitations under the License. display: inline; } -.mx_InlineSpinner_spin img { +.mx_InlineSpinner img, .mx_InlineSpinner_icon { margin: 0px 6px; vertical-align: -3px; } + +.mx_InlineSpinner_icon { + display: inline-block; +} diff --git a/res/css/views/elements/_InviteReason.scss b/res/css/views/elements/_InviteReason.scss new file mode 100644 index 0000000000..2c2e5687e6 --- /dev/null +++ b/res/css/views/elements/_InviteReason.scss @@ -0,0 +1,57 @@ +/* +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_InviteReason { + position: relative; + margin-bottom: 1em; + + .mx_InviteReason_reason { + visibility: visible; + } + + .mx_InviteReason_view { + display: none; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + justify-content: center; + align-items: center; + cursor: pointer; + color: $secondary-fg-color; + + &::before { + content: ""; + margin-right: 8px; + background-color: $secondary-fg-color; + mask-image: url('$(res)/img/feather-customised/eye.svg'); + display: inline-block; + width: 18px; + height: 14px; + } + } +} + +.mx_InviteReason_hidden { + .mx_InviteReason_reason { + visibility: hidden; + } + + .mx_InviteReason_view { + display: flex; + } +} diff --git a/res/css/views/elements/_MiniAvatarUploader.scss b/res/css/views/elements/_MiniAvatarUploader.scss new file mode 100644 index 0000000000..df4676ab56 --- /dev/null +++ b/res/css/views/elements/_MiniAvatarUploader.scss @@ -0,0 +1,60 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_MiniAvatarUploader { + position: relative; + width: min-content; + + // this isn't a floating tooltip so override some things to not need to bother with z-index and floating + .mx_Tooltip { + display: inline-block; + position: absolute; + z-index: unset; + width: max-content; + left: 72px; + top: 0; + } + + .mx_MiniAvatarUploader_indicator { + position: absolute; + + height: 26px; + width: 26px; + + right: -6px; + bottom: -6px; + + background-color: $primary-bg-color; + border-radius: 50%; + z-index: 1; + + .mx_MiniAvatarUploader_cameraIcon { + height: 100%; + width: 100%; + + background-color: $secondary-fg-color; + mask-position: center; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/camera.svg'); + mask-size: 16px; + z-index: 2; + } + } +} + +.mx_MiniAvatarUploader_input { + display: none; +} diff --git a/res/css/views/elements/_ProgressBar.scss b/res/css/views/elements/_ProgressBar.scss index e49d85af04..c075ac74ff 100644 --- a/res/css/views/elements/_ProgressBar.scss +++ b/res/css/views/elements/_ProgressBar.scss @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 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. @@ -15,15 +15,15 @@ limitations under the License. */ progress.mx_ProgressBar { - height: 4px; + height: 6px; width: 60px; - border-radius: 10px; overflow: hidden; appearance: none; - border: 0; + border: none; - @mixin ProgressBarBorderRadius "10px"; - @mixin ProgressBarColour $accent-color; + @mixin ProgressBarBorderRadius 6px; + @mixin ProgressBarColour $progressbar-fg-color; + @mixin ProgressBarBgColour $progressbar-bg-color; ::-webkit-progress-value { transition: width 1s; } diff --git a/res/css/views/elements/_ReplyThread.scss b/res/css/views/elements/_ReplyThread.scss index bf44a11728..af8ca956ba 100644 --- a/res/css/views/elements/_ReplyThread.scss +++ b/res/css/views/elements/_ReplyThread.scss @@ -18,20 +18,46 @@ limitations under the License. margin-top: 0; } -.mx_ReplyThread .mx_DateSeparator { - font-size: 1em !important; - margin-top: 0; - margin-bottom: 0; - padding-bottom: 1px; - bottom: -5px; -} - .mx_ReplyThread_show { cursor: pointer; } blockquote.mx_ReplyThread { margin-left: 0; + margin-right: 0; + margin-bottom: 8px; padding-left: 10px; - border-left: 4px solid $blockquote-bar-color; + border-left: 4px solid $button-bg-color; + + &.mx_ReplyThread_color1 { + border-left-color: $username-variant1-color; + } + + &.mx_ReplyThread_color2 { + border-left-color: $username-variant2-color; + } + + &.mx_ReplyThread_color3 { + border-left-color: $username-variant3-color; + } + + &.mx_ReplyThread_color4 { + border-left-color: $username-variant4-color; + } + + &.mx_ReplyThread_color5 { + border-left-color: $username-variant5-color; + } + + &.mx_ReplyThread_color6 { + border-left-color: $username-variant6-color; + } + + &.mx_ReplyThread_color7 { + border-left-color: $username-variant7-color; + } + + &.mx_ReplyThread_color8 { + border-left-color: $username-variant8-color; + } } diff --git a/res/css/views/elements/_SSOButtons.scss b/res/css/views/elements/_SSOButtons.scss new file mode 100644 index 0000000000..e02816780f --- /dev/null +++ b/res/css/views/elements/_SSOButtons.scss @@ -0,0 +1,74 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_SSOButtons { + display: flex; + flex-wrap: wrap; + justify-content: center; + + .mx_SSOButtons_row { + & + .mx_SSOButtons_row { + margin-top: 16px; + } + } + + .mx_SSOButton { + position: relative; + width: 100%; + padding: 7px 32px; + text-align: center; + border-radius: 8px; + display: inline-block; + font-size: $font-14px; + font-weight: $font-semi-bold; + border: 1px solid $input-border-color; + color: $primary-fg-color; + + > img { + object-fit: contain; + position: absolute; + left: 8px; + top: 4px; + } + } + + .mx_SSOButton_default { + color: $button-primary-bg-color; + background-color: $button-secondary-bg-color; + border-color: $button-primary-bg-color; + } + .mx_SSOButton_default.mx_SSOButton_primary { + color: $button-primary-fg-color; + background-color: $button-primary-bg-color; + } + + .mx_SSOButton_mini { + box-sizing: border-box; + width: 50px; // 48px + 1px border on all sides + height: 50px; // 48px + 1px border on all sides + min-width: 50px; // prevent crushing by the flexbox + padding: 12px; + + > img { + left: 12px; + top: 12px; + } + + & + .mx_SSOButton_mini { + margin-left: 16px; + } + } +} diff --git a/res/css/views/elements/_ServerPicker.scss b/res/css/views/elements/_ServerPicker.scss new file mode 100644 index 0000000000..188eb5d655 --- /dev/null +++ b/res/css/views/elements/_ServerPicker.scss @@ -0,0 +1,88 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ServerPicker { + margin-bottom: 14px; + border-bottom: 1px solid rgba(141, 151, 165, 0.2); + display: grid; + grid-template-columns: auto min-content; + grid-template-rows: auto auto auto; + font-size: $font-14px; + line-height: $font-20px; + + > h3 { + font-weight: $font-semi-bold; + margin: 0 0 20px; + grid-column: 1; + grid-row: 1; + } + + .mx_ServerPicker_help { + width: 20px; + height: 20px; + background-color: $icon-button-color; + border-radius: 10px; + grid-column: 2; + grid-row: 1; + margin-left: auto; + text-align: center; + color: #ffffff; + font-size: 16px; + position: relative; + + &::before { + content: ''; + width: 24px; + height: 24px; + position: absolute; + top: -2px; + left: -2px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/i.svg'); + background: #ffffff; + } + } + + .mx_ServerPicker_server { + color: $authpage-primary-color; + grid-column: 1; + grid-row: 2; + margin-bottom: 16px; + } + + .mx_ServerPicker_change { + padding: 0; + font-size: inherit; + grid-column: 2; + grid-row: 2; + } + + .mx_ServerPicker_desc { + margin-top: -12px; + color: $tertiary-fg-color; + grid-column: 1 / 2; + grid-row: 3; + margin-bottom: 16px; + } +} + +.mx_ServerPicker_helpDialog { + .mx_Dialog_content { + width: 456px; + } +} diff --git a/res/css/views/elements/_Spinner.scss b/res/css/views/elements/_Spinner.scss index 01b4f23c2c..93d5e2d96c 100644 --- a/res/css/views/elements/_Spinner.scss +++ b/res/css/views/elements/_Spinner.scss @@ -26,3 +26,19 @@ limitations under the License. .mx_MatrixChat_middlePanel .mx_Spinner { height: auto; } + +@keyframes spin { + from { + transform: rotateZ(0deg); + } + to { + transform: rotateZ(360deg); + } +} + +.mx_Spinner_icon { + background-color: $primary-fg-color; + mask: url('$(res)/img/spinner.svg'); + mask-size: contain; + animation: 1.1s steps(12, end) infinite spin; +} diff --git a/res/css/views/messages/_CreateEvent.scss b/res/css/views/messages/_CreateEvent.scss index d45645863f..cb2bf841dd 100644 --- a/res/css/views/messages/_CreateEvent.scss +++ b/res/css/views/messages/_CreateEvent.scss @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2018, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,25 +15,8 @@ limitations under the License. */ .mx_CreateEvent { - background-color: $info-plinth-bg-color; - padding-left: 20px; - padding-right: 20px; - padding-top: 10px; - padding-bottom: 10px; -} - -.mx_CreateEvent_image { - float: left; - margin-right: 20px; - width: 72px; - height: 34px; - - background-color: $primary-fg-color; - mask: url('$(res)/img/room-continuation.svg'); - mask-repeat: no-repeat; - mask-position: center; -} - -.mx_CreateEvent_header { - font-weight: bold; + &::before { + background-color: $composer-e2e-icon-color; + mask-image: url('$(res)/img/element-icons/chat-bubbles.svg'); + } } diff --git a/res/css/views/messages/_EventTileBubble.scss b/res/css/views/messages/_EventTileBubble.scss new file mode 100644 index 0000000000..e0f5d521cb --- /dev/null +++ b/res/css/views/messages/_EventTileBubble.scss @@ -0,0 +1,60 @@ +/* +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_EventTileBubble { + background-color: $dark-panel-bg-color; + padding: 10px; + border-radius: 8px; + margin: 10px auto; + max-width: 75%; + box-sizing: border-box; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) min-content; + + &::before, &::after { + position: relative; + grid-column: 1; + grid-row: 1 / 3; + width: 16px; + height: 16px; + content: ""; + top: 0; + bottom: 0; + left: 0; + right: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + margin-top: 4px; + } + + .mx_EventTileBubble_title, .mx_EventTileBubble_subtitle { + overflow-wrap: break-word; + } + + .mx_EventTileBubble_title { + font-weight: 600; + font-size: $font-15px; + grid-column: 2; + grid-row: 1; + } + + .mx_EventTileBubble_subtitle { + font-size: $font-12px; + grid-column: 2; + grid-row: 2; + } +} diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index 6cbce68745..b91c461ce5 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015, 2016, 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. @@ -16,6 +16,19 @@ limitations under the License. .mx_MFileBody_download { color: $accent-color; + + .mx_MFileBody_download_icon { + // 12px instead of 14px to better match surrounding font size + width: 12px; + height: 12px; + mask-size: 12px; + + mask-position: center; + mask-repeat: no-repeat; + mask-image: url("$(res)/img/download.svg"); + background-color: $accent-color; + display: inline-block; + } } .mx_MFileBody_download a { @@ -45,3 +58,46 @@ limitations under the License. * big the content of the iframe is. */ height: 1.5em; } + +.mx_MFileBody_info { + background-color: $message-body-panel-bg-color; + border-radius: 12px; + width: 243px; // same width as a playable voice message, accounting for padding + padding: 6px 12px; + color: $message-body-panel-fg-color; + + .mx_MFileBody_info_icon { + background-color: $message-body-panel-icon-bg-color; + border-radius: 20px; + display: inline-block; + width: 32px; + height: 32px; + position: relative; + vertical-align: middle; + margin-right: 12px; + + &::before { + content: ''; + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); + background-color: $message-body-panel-icon-fg-color; + width: 15px; + height: 15px; + + position: absolute; + top: 8px; + left: 8px; + } + } + + .mx_MFileBody_info_filename { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + display: inline-block; + width: calc(100% - 32px - 12px); // 32px icon, 12px margin on the icon + vertical-align: middle; + } +} diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss index 1c773c2f06..878a4154cd 100644 --- a/res/css/views/messages/_MImageBody.scss +++ b/res/css/views/messages/_MImageBody.scss @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +$timelineImageBorderRadius: 4px; + .mx_MImageBody { display: block; margin-right: 34px; @@ -25,7 +27,11 @@ limitations under the License. height: 100%; left: 0; top: 0; - border-radius: 4px; + border-radius: $timelineImageBorderRadius; + + > canvas { + border-radius: $timelineImageBorderRadius; + } } .mx_MImageBody_thumbnail_container { @@ -43,7 +49,7 @@ limitations under the License. top: 50%; } -// Inner img and TintableSvg should be centered around 0, 0 +// Inner img should be centered around 0, 0 .mx_MImageBody_thumbnail_spinner > * { transform: translate(-50%, -50%); } diff --git a/src/RoomListSorter.js b/res/css/views/messages/_MImageReplyBody.scss similarity index 58% rename from src/RoomListSorter.js rename to res/css/views/messages/_MImageReplyBody.scss index 0ff37a6af2..70c53f8c9c 100644 --- a/src/RoomListSorter.js +++ b/res/css/views/messages/_MImageReplyBody.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 Tulir Asokan Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,18 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +.mx_MImageReplyBody { + display: flex; -function tsOfNewestEvent(room) { - if (room.timeline.length) { - return room.timeline[room.timeline.length - 1].getTs(); - } else { - return Number.MAX_SAFE_INTEGER; + .mx_MImageBody_thumbnail_container { + flex: 1; + margin-right: 4px; + } + + .mx_MImageReplyBody_info { + flex: 1; + + .mx_MImageReplyBody_sender { + grid-area: sender; + } + + .mx_MImageReplyBody_filename { + grid-area: filename; + } } } -export function mostRecentActivityFirst(roomList) { - return roomList.sort(function(a, b) { - return tsOfNewestEvent(b) - tsOfNewestEvent(a); - }); -} diff --git a/res/css/views/messages/_MJitsiWidgetEvent.scss b/res/css/views/messages/_MJitsiWidgetEvent.scss index 3e51e89744..bea8651543 100644 --- a/res/css/views/messages/_MJitsiWidgetEvent.scss +++ b/res/css/views/messages/_MJitsiWidgetEvent.scss @@ -15,41 +15,8 @@ limitations under the License. */ .mx_MJitsiWidgetEvent { - display: grid; - grid-template-columns: 24px minmax(0, 1fr) min-content; - &::before { - grid-column: 1; - grid-row: 1 / 3; - width: 16px; - height: 16px; - content: ""; - top: 0; - bottom: 0; - left: 0; - right: 0; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; background-color: $composer-e2e-icon-color; // XXX: Variable abuse - margin-top: 4px; mask-image: url('$(res)/img/element-icons/call/video-call.svg'); } - - .mx_MJitsiWidgetEvent_title { - font-weight: 600; - font-size: $font-15px; - grid-column: 2; - grid-row: 1; - } - - .mx_MJitsiWidgetEvent_subtitle { - grid-column: 2; - grid-row: 2; - } - - .mx_MJitsiWidgetEvent_title, - .mx_MJitsiWidgetEvent_subtitle { - overflow-wrap: break-word; - } } diff --git a/res/css/views/messages/_MVideoBody.scss b/res/css/views/messages/_MVideoBody.scss index 3b05c53f34..ac3491bc8f 100644 --- a/res/css/views/messages/_MVideoBody.scss +++ b/res/css/views/messages/_MVideoBody.scss @@ -18,5 +18,6 @@ span.mx_MVideoBody { video.mx_MVideoBody { max-width: 100%; height: auto; + border-radius: 4px; } } diff --git a/res/css/views/messages/_MVoiceMessageBody.scss b/res/css/views/messages/_MVoiceMessageBody.scss new file mode 100644 index 0000000000..3dfb98f778 --- /dev/null +++ b/res/css/views/messages/_MVoiceMessageBody.scss @@ -0,0 +1,19 @@ +/* +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_MVoiceMessageBody { + display: inline-block; // makes the playback controls magically line up +} diff --git a/res/css/views/messages/_MediaBody.scss b/res/css/views/messages/_MediaBody.scss new file mode 100644 index 0000000000..12e441750c --- /dev/null +++ b/res/css/views/messages/_MediaBody.scss @@ -0,0 +1,28 @@ +/* +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. +*/ + +// A "media body" is any file upload looking thing, apart from images and videos (they +// have unique styles). + +.mx_MediaBody { + background-color: $message-body-panel-bg-color; + border-radius: 12px; + + color: $message-body-panel-fg-color; + font-size: $font-14px; + line-height: $font-24px; +} + diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 1254b496b5..e2fafe6c62 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -20,11 +20,12 @@ limitations under the License. visibility: hidden; cursor: pointer; display: flex; - height: 24px; + height: 32px; line-height: $font-24px; - border-radius: 4px; - background: $message-action-bar-bg-color; - top: -26px; + border-radius: 8px; + background: $primary-bg-color; + border: 1px solid $input-border-color; + top: -32px; right: 8px; user-select: none; // Ensure the action bar appears above over things, like the read marker. @@ -51,31 +52,19 @@ limitations under the License. white-space: nowrap; display: inline-block; position: relative; - border: 1px solid $message-action-bar-border-color; - margin-left: -1px; + margin: 2px; &:hover { - border-color: $message-action-bar-hover-border-color; + background: $roomlist-button-bg-color; + border-radius: 6px; z-index: 1; } - - &:first-child { - border-radius: 3px 0 0 3px; - } - - &:last-child { - border-radius: 0 3px 3px 0; - } - - &:only-child { - border-radius: 3px; - } } } - .mx_MessageActionBar_maskButton { - width: 27px; + width: 28px; + height: 28px; } .mx_MessageActionBar_maskButton::after { @@ -85,9 +74,14 @@ limitations under the License. left: 0; height: 100%; width: 100%; + mask-size: 18px; mask-repeat: no-repeat; mask-position: center; - background-color: $message-action-bar-fg-color; + background-color: $secondary-fg-color; +} + +.mx_MessageActionBar_maskButton:hover::after { + background-color: $primary-fg-color; } .mx_MessageActionBar_reactButton::after { @@ -105,3 +99,11 @@ limitations under the License. .mx_MessageActionBar_optionsButton::after { mask-image: url('$(res)/img/element-icons/context-menu.svg'); } + +.mx_MessageActionBar_resendButton::after { + mask-image: url('$(res)/img/element-icons/retry.svg'); +} + +.mx_MessageActionBar_cancelButton::after { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); +} diff --git a/res/css/views/messages/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss index 2f5695e1fb..e05065eb02 100644 --- a/res/css/views/messages/_ReactionsRow.scss +++ b/res/css/views/messages/_ReactionsRow.scss @@ -17,18 +17,56 @@ limitations under the License. .mx_ReactionsRow { margin: 6px 0; color: $primary-fg-color; + + .mx_ReactionsRow_addReactionButton { + position: relative; + display: inline-block; + visibility: hidden; // show on hover of the .mx_EventTile + width: 24px; + height: 24px; + vertical-align: middle; + margin-left: 4px; + + &::before { + content: ''; + position: absolute; + height: 100%; + width: 100%; + mask-size: 16px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $tertiary-fg-color; + mask-image: url('$(res)/img/element-icons/room/message-bar/emoji.svg'); + } + + &.mx_ReactionsRow_addReactionButton_active { + visibility: visible; // keep showing whilst the context menu is shown + } + + &:hover, &.mx_ReactionsRow_addReactionButton_active { + &::before { + background-color: $primary-fg-color; + } + } + } +} + +.mx_EventTile:hover .mx_ReactionsRow_addReactionButton { + visibility: visible; } .mx_ReactionsRow_showAll { text-decoration: none; - font-size: $font-10px; - font-weight: 600; - margin-left: 6px; - vertical-align: top; + font-size: $font-12px; + line-height: $font-20px; + margin-left: 4px; + vertical-align: middle; - &:hover, - &:link, - &:visited { - color: $accent-color; + &:link, &:visited { + color: $tertiary-fg-color; + } + + &:hover { + color: $primary-fg-color; } } diff --git a/res/css/views/messages/_ReactionsRowButton.scss b/res/css/views/messages/_ReactionsRowButton.scss index 7158ffc027..766fea2f8f 100644 --- a/res/css/views/messages/_ReactionsRowButton.scss +++ b/res/css/views/messages/_ReactionsRowButton.scss @@ -16,14 +16,15 @@ limitations under the License. .mx_ReactionsRowButton { display: inline-flex; - line-height: $font-21px; + line-height: $font-20px; margin-right: 6px; - padding: 0 6px; + padding: 1px 6px; border: 1px solid $reaction-row-button-border-color; border-radius: 10px; background-color: $reaction-row-button-bg-color; cursor: pointer; user-select: none; + vertical-align: middle; &:hover { border-color: $reaction-row-button-hover-border-color; @@ -34,6 +35,10 @@ limitations under the License. border-color: $reaction-row-button-selected-border-color; } + &.mx_AccessibleButton_disabled { + cursor: not-allowed; + } + .mx_ReactionsRowButton_content { max-width: 100px; overflow: hidden; diff --git a/res/css/views/messages/_RedactedBody.scss b/res/css/views/messages/_RedactedBody.scss index e4ab0c0835..600ac0c6b7 100644 --- a/res/css/views/messages/_RedactedBody.scss +++ b/res/css/views/messages/_RedactedBody.scss @@ -30,7 +30,7 @@ limitations under the License. mask-size: contain; content: ''; position: absolute; - top: 2px; + top: 1px; left: 0; } } diff --git a/res/css/views/messages/_SenderProfile.scss b/res/css/views/messages/_SenderProfile.scss index 655cb39489..08644b14e3 100644 --- a/res/css/views/messages/_SenderProfile.scss +++ b/res/css/views/messages/_SenderProfile.scss @@ -14,7 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SenderProfile_name { +.mx_SenderProfile_displayName { font-weight: 600; } +.mx_SenderProfile_mxid { + font-weight: 600; + font-size: 1.1rem; + margin-left: 5px; + opacity: 0.5; // Match mx_TextualEvent +} diff --git a/res/css/views/messages/_TextualEvent.scss b/res/css/views/messages/_TextualEvent.scss index be7565b3c5..e87fed90de 100644 --- a/res/css/views/messages/_TextualEvent.scss +++ b/res/css/views/messages/_TextualEvent.scss @@ -17,4 +17,9 @@ limitations under the License. .mx_TextualEvent { opacity: 0.5; overflow-y: hidden; + + a { + color: $accent-color; + cursor: pointer; + } } diff --git a/res/css/views/messages/_ViewSourceEvent.scss b/res/css/views/messages/_ViewSourceEvent.scss index 076932ee97..66825030e0 100644 --- a/res/css/views/messages/_ViewSourceEvent.scss +++ b/res/css/views/messages/_ViewSourceEvent.scss @@ -35,13 +35,13 @@ limitations under the License. mask-size: auto 12px; visibility: hidden; background-color: $accent-color; - mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); + mask-image: url('$(res)/img/feather-customised/maximise.svg'); } &.mx_ViewSourceEvent_expanded .mx_ViewSourceEvent_toggle { mask-position: 0 bottom; margin-bottom: 7px; - mask-image: url('$(res)/img/feather-customised/widget/minimise.svg'); + mask-image: url('$(res)/img/feather-customised/minimise.svg'); } &:hover .mx_ViewSourceEvent_toggle { diff --git a/res/css/views/messages/_common_CryptoEvent.scss b/res/css/views/messages/_common_CryptoEvent.scss index 09c78ae5b4..afaed50fa4 100644 --- a/res/css/views/messages/_common_CryptoEvent.scss +++ b/res/css/views/messages/_common_CryptoEvent.scss @@ -15,35 +15,18 @@ limitations under the License. */ .mx_cryptoEvent { - display: grid; - grid-template-columns: 24px minmax(0, 1fr) min-content; - - &.mx_cryptoEvent_icon::before, - &.mx_cryptoEvent_icon::after { - grid-column: 1; - grid-row: 1 / 3; - width: 16px; - height: 16px; - content: ""; - top: 0; - bottom: 0; - left: 0; - right: 0; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/e2e/normal.svg'); - background-color: $composer-e2e-icon-color; - margin-top: 4px; - } - // white infill for the transparency &.mx_cryptoEvent_icon::before { background-color: #ffffff; mask-image: url('$(res)/img/e2e/normal.svg'); mask-repeat: no-repeat; mask-position: center; - mask-size: 90%; + mask-size: 80%; + } + + &.mx_cryptoEvent_icon::after { + mask-image: url('$(res)/img/e2e/normal.svg'); + background-color: $composer-e2e-icon-color; } &.mx_cryptoEvent_icon_verified::after { @@ -56,25 +39,6 @@ limitations under the License. background-color: $notice-primary-color; } - .mx_cryptoEvent_title, .mx_cryptoEvent_subtitle, .mx_cryptoEvent_state { - overflow-wrap: break-word; - } - - .mx_cryptoEvent_title { - font-weight: 600; - font-size: $font-15px; - grid-column: 2; - grid-row: 1; - } - - .mx_cryptoEvent_subtitle { - grid-column: 2; - grid-row: 2; - } - - .mx_cryptoEvent_state, .mx_cryptoEvent_subtitle { - font-size: $font-12px; - } .mx_cryptoEvent_state, .mx_cryptoEvent_buttons { grid-column: 3; @@ -84,6 +48,7 @@ limitations under the License. .mx_cryptoEvent_buttons { align-items: center; display: flex; + gap: 5px; } .mx_cryptoEvent_state { @@ -92,5 +57,7 @@ limitations under the License. margin: auto 0; text-align: center; color: $notice-secondary-color; + overflow-wrap: break-word; + font-size: $font-12px; } } diff --git a/res/css/views/right_panel/_EncryptionInfo.scss b/res/css/views/right_panel/_EncryptionInfo.scss index e13b1b6802..b3d4275f60 100644 --- a/res/css/views/right_panel/_EncryptionInfo.scss +++ b/res/css/views/right_panel/_EncryptionInfo.scss @@ -14,13 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_UserInfo { - .mx_EncryptionInfo_spinner { - .mx_Spinner { - margin-top: 25px; - margin-bottom: 15px; - } - - text-align: center; +.mx_EncryptionInfo_spinner { + .mx_Spinner { + margin-top: 25px; + margin-bottom: 15px; } + + text-align: center; } diff --git a/res/css/views/right_panel/_PinnedMessagesCard.scss b/res/css/views/right_panel/_PinnedMessagesCard.scss new file mode 100644 index 0000000000..785aee09ca --- /dev/null +++ b/res/css/views/right_panel/_PinnedMessagesCard.scss @@ -0,0 +1,90 @@ +/* +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_PinnedMessagesCard { + padding-top: 0; + + .mx_BaseCard_header { + text-align: center; + margin-top: 0; + border-bottom: 1px solid $menu-border-color; + + > h2 { + font-weight: $font-semi-bold; + font-size: $font-18px; + margin: 8px 0; + } + + .mx_BaseCard_close { + margin-right: 6px; + } + } + + .mx_PinnedMessagesCard_empty { + display: flex; + height: 100%; + + > div { + height: max-content; + text-align: center; + margin: auto 40px; + + .mx_PinnedMessagesCard_MessageActionBar { + pointer-events: none; + display: flex; + height: 32px; + line-height: $font-24px; + border-radius: 8px; + background: $primary-bg-color; + border: 1px solid $input-border-color; + padding: 1px; + width: max-content; + margin: 0 auto; + box-sizing: border-box; + + .mx_MessageActionBar_maskButton { + display: inline-block; + position: relative; + } + + .mx_MessageActionBar_optionsButton { + background: $roomlist-button-bg-color; + border-radius: 6px; + z-index: 1; + + &::after { + background-color: $primary-fg-color; + } + } + } + + > h2 { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + margin-top: 24px; + margin-bottom: 20px; + } + + > span { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + } + } + } +} diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index 36882f4e8b..dc7804d072 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -36,6 +36,7 @@ limitations under the License. -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; + white-space: pre-wrap; } .mx_RoomSummaryCard_avatar { diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index f20c9b7868..6632ccddf9 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -173,26 +173,12 @@ limitations under the License. margin: 6px 0; - .mx_IconButton, .mx_Spinner { - margin-left: 20px; - width: 16px; - height: 16px; - - &::before { - mask-size: 80%; - } - } - .mx_UserInfo_roleDescription { display: flex; justify-content: center; align-items: center; // try to make it the same height as the dropdown margin: 11px 0 12px 0; - - .mx_IconButton { - margin-left: 6px; - } } .mx_Field { @@ -273,16 +259,6 @@ limitations under the License. .mx_AccessibleButton.mx_AccessibleButton_hasKind { padding: 8px 18px; - - &.mx_AccessibleButton_kind_primary { - color: $accent-color; - background-color: $accent-bg-color; - } - - &.mx_AccessibleButton_kind_danger { - color: $notice-primary-color; - background-color: $notice-primary-bg-color; - } } .mx_VerificationShowSas .mx_AccessibleButton, diff --git a/res/css/views/right_panel/_VerificationPanel.scss b/res/css/views/right_panel/_VerificationPanel.scss index a8466a1626..12148b09de 100644 --- a/res/css/views/right_panel/_VerificationPanel.scss +++ b/res/css/views/right_panel/_VerificationPanel.scss @@ -58,7 +58,7 @@ limitations under the License. } .mx_VerificationPanel_reciprocate_section { - .mx_FormButton { + .mx_AccessibleButton { width: 100%; box-sizing: border-box; padding: 10px; diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 8731d22660..fd80836237 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -24,26 +24,45 @@ $MiniAppTileHeight: 200px; flex-direction: column; overflow: hidden; + .mx_AppsContainer_resizerHandleContainer { + width: 100%; + height: 10px; + margin-top: -3px; // move it up so the interactions are slightly more comfortable + display: block; + position: relative; + } + .mx_AppsContainer_resizerHandle { cursor: ns-resize; - border-radius: 3px; - // Override styles from library - width: unset !important; - height: 4px !important; + // Override styles from library, making the whole area the target area + width: 100% !important; + height: 100% !important; // This is positioned directly below frame position: absolute; - bottom: -8px !important; // override from library + bottom: 0 !important; // override from library - // Together, these make the bar 64px wide - // These are also overridden from the library - left: calc(50% - 32px) !important; - right: calc(50% - 32px) !important; + // We then render the pill handle in an ::after to keep it in the handle's + // area without being a massive line across the screen + &::after { + content: ''; + position: absolute; + border-radius: 3px; + + // The combination of these two should make the pill 4px high + top: 6px; + bottom: 0; + + // Together, these make the bar 64px wide + // These are also overridden from the library + left: calc(50% - 32px); + right: calc(50% - 32px); + } } &:hover { - .mx_AppsContainer_resizerHandle { + .mx_AppsContainer_resizerHandle::after { opacity: 0.8; background: $primary-fg-color; } @@ -351,11 +370,6 @@ $MinWidth: 240px; display: none; } -/* Avoid apptile iframes capturing mouse event focus when resizing */ -.mx_AppsDrawer_resizing iframe { - pointer-events: none; -} - .mx_AppsDrawer_resizing .mx_AppTile_persistedWrapper { z-index: 1; } diff --git a/res/css/views/rooms/_AuxPanel.scss b/res/css/views/rooms/_AuxPanel.scss index 34ef5e01d4..17a6294bf0 100644 --- a/res/css/views/rooms/_AuxPanel.scss +++ b/res/css/views/rooms/_AuxPanel.scss @@ -17,7 +17,7 @@ limitations under the License. .m_RoomView_auxPanel_stateViews { padding: 5px; padding-left: 19px; - border-bottom: 1px solid #e5e5e5; + border-bottom: 1px solid $primary-hairline-color; } .m_RoomView_auxPanel_stateViews_span a { diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index e126e523a6..e1ba468204 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -66,6 +66,11 @@ limitations under the License. } } } + + &.mx_BasicMessageComposer_input_disabled { + // Ignore all user input to avoid accidentally triggering the composer + pointer-events: none; + } } .mx_BasicMessageComposer_AutoCompleteWrapper { diff --git a/res/css/views/rooms/_E2EIcon.scss b/res/css/views/rooms/_E2EIcon.scss index a3473dedec..68ad44cf6a 100644 --- a/res/css/views/rooms/_E2EIcon.scss +++ b/res/css/views/rooms/_E2EIcon.scss @@ -45,7 +45,7 @@ limitations under the License. mask-image: url('$(res)/img/e2e/normal.svg'); mask-repeat: no-repeat; mask-position: center; - mask-size: 90%; + mask-size: 80%; } // transparent-looking border surrounding the shield for when overlain over avatars @@ -59,7 +59,7 @@ limitations under the License. } // shrink the infill of the badge &::before { - mask-size: 65%; + mask-size: 60%; } } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 3b9a491db5..55f73c0315 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -16,6 +16,7 @@ limitations under the License. */ $left-gutter: 64px; +$hover-select-border: 4px; .mx_EventTile { max-width: 100%; @@ -25,17 +26,8 @@ $left-gutter: 64px; position: relative; } -.mx_EventTile_bubble { - background-color: $dark-panel-bg-color; - padding: 10px; - border-radius: 5px; - margin: 10px auto; - max-width: 75%; - box-sizing: border-box; -} - .mx_EventTile.mx_EventTile_info { - padding-top: 0px; + padding-top: 1px; } .mx_EventTile_avatar { @@ -46,7 +38,7 @@ $left-gutter: 64px; } .mx_EventTile.mx_EventTile_info .mx_EventTile_avatar { - top: $font-8px; + top: $font-6px; left: $left-gutter; } @@ -83,7 +75,6 @@ $left-gutter: 64px; margin-left: 5px; display: inline-block; vertical-align: top; - height: 16px; overflow: hidden; user-select: none; @@ -95,12 +86,11 @@ $left-gutter: 64px; } .mx_EventTile_isEditing .mx_MessageTimestamp { - visibility: hidden !important; + visibility: hidden; } .mx_EventTile .mx_MessageTimestamp { display: block; - visibility: hidden; white-space: nowrap; left: 0px; text-align: center; @@ -114,7 +104,7 @@ $left-gutter: 64px; .mx_EventTile_line, .mx_EventTile_reply { position: relative; padding-left: $left-gutter; - border-radius: 4px; + border-radius: 8px; } .mx_RoomView_timeline_rr_enabled, @@ -131,9 +121,10 @@ $left-gutter: 64px; grid-template-columns: 1fr 100px; .mx_EventTile_line { - margin-right: 0px; + margin-right: 0; grid-column: 1 / 3; - padding: 0; + // override default padding of mx_EventTile_line so that we can be centered + padding: 0 !important; } .mx_EventTile_msgOption { @@ -151,26 +142,8 @@ $left-gutter: 64px; line-height: 57px !important; } -.mx_MessagePanel_alwaysShowTimestamps .mx_MessageTimestamp { - visibility: visible; -} - .mx_EventTile_selected > div > a > .mx_MessageTimestamp { - left: 3px; - width: auto; -} - -// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) -// The first set is to handle the 'group layout' (default) and the second for the IRC layout -.mx_EventTile_last > div > a > .mx_MessageTimestamp, -.mx_EventTile:hover > div > a > .mx_MessageTimestamp, -.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp, -.mx_EventTile.focus-visible:focus-within > div > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile_last > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile:hover > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile.mx_EventTile_actionBarFocused > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile.focus-visible:focus-within > a > .mx_MessageTimestamp { - visibility: visible; + left: calc(-$hover-select-border); } .mx_EventTile:hover .mx_MessageActionBar, @@ -185,7 +158,7 @@ $left-gutter: 64px; */ .mx_EventTile_selected > .mx_EventTile_line { border-left: $accent-color 4px solid; - padding-left: 60px; + padding-left: calc($left-gutter - $hover-select-border); background-color: $event-selected-color; } @@ -198,8 +171,12 @@ $left-gutter: 64px; } } +.mx_EventTile_info .mx_EventTile_line { + padding-left: calc($left-gutter + 18px); +} + .mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { - padding-left: 78px; + padding-left: calc($left-gutter + 18px - $hover-select-border); } .mx_EventTile:hover .mx_EventTile_line, @@ -222,21 +199,30 @@ $left-gutter: 64px; color: $accent-fg-color; } -.mx_EventTile_encrypting { - color: $event-encrypting-color !important; -} +.mx_EventTile_receiptSent, +.mx_EventTile_receiptSending { + // We don't use `position: relative` on the element because then it won't line + // up with the other read receipts -.mx_EventTile_sending { - color: $event-sending-color; + &::before { + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 14px; + width: 14px; + height: 14px; + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + } } - -.mx_EventTile_sending .mx_UserPill, -.mx_EventTile_sending .mx_RoomPill { - opacity: 0.5; +.mx_EventTile_receiptSent::before { + mask-image: url('$(res)/img/element-icons/circle-sent.svg'); } - -.mx_EventTile_notSent { - color: $event-notsent-color; +.mx_EventTile_receiptSending::before { + mask-image: url('$(res)/img/element-icons/circle-sending.svg'); } .mx_EventTile_contextual { @@ -266,22 +252,23 @@ $left-gutter: 64px; display: inline-block; width: 14px; height: 14px; - top: 29px; + // This aligns the avatar with the last line of the + // message. We want to move it one line up - 2.2rem + top: -2.2rem; user-select: none; z-index: 1; } -.mx_EventTile_continuation .mx_EventTile_readAvatars, -.mx_EventTile_info .mx_EventTile_readAvatars, -.mx_EventTile_emote .mx_EventTile_readAvatars { - top: 7px; -} - .mx_EventTile_readAvatars .mx_BaseAvatar { position: absolute; display: inline-block; height: $font-14px; width: $font-14px; + + will-change: left, top; + transition: + left var(--transition-short) ease-out, + top var(--transition-standard) ease-out; } .mx_EventTile_readAvatarRemainder { @@ -358,7 +345,7 @@ $left-gutter: 64px; mask-image: url('$(res)/img/e2e/normal.svg'); mask-repeat: no-repeat; mask-position: center; - mask-size: 90%; + mask-size: 80%; } } @@ -425,25 +412,25 @@ $left-gutter: 64px; .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line { - padding-left: 60px; + padding-left: calc($left-gutter - $hover-select-border); } .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line { - border-left: $e2e-verified-color 4px solid; + border-left: $e2e-verified-color $EventTile_e2e_state_indicator_width solid; } .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line { - border-left: $e2e-unverified-color 4px solid; + border-left: $e2e-unverified-color $EventTile_e2e_state_indicator_width solid; } .mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line { - border-left: $e2e-unknown-color 4px solid; + border-left: $e2e-unknown-color $EventTile_e2e_state_indicator_width solid; } .mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line { - padding-left: 78px; + padding-left: calc($left-gutter + 18px - $hover-select-border); } /* End to end encryption stuff */ @@ -455,8 +442,7 @@ $left-gutter: 64px; .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp, .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp, .mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp { - left: 3px; - width: auto; + left: calc(-$hover-select-border); } // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) @@ -491,8 +477,7 @@ $left-gutter: 64px; pre, code { font-family: $monospace-font-family !important; - // deliberate constants as we're behind an invert filter - color: #333; + background-color: $header-panel-bg-color; } pre { @@ -501,13 +486,23 @@ $left-gutter: 64px; // https://github.com/vector-im/vector-web/issues/754 overflow-x: overlay; overflow-y: visible; - max-height: 30vh; } +} - code { - // deliberate constants as we're behind an invert filter - background-color: #f8f8f8; - } +.mx_EventTile_lineNumbers { + float: left; + margin: 0 0.5em 0 -1.5em; + color: gray; +} + +.mx_EventTile_lineNumber { + text-align: right; + display: block; + padding-left: 1em; +} + +.mx_EventTile_collapsedCodeBlock { + max-height: 30vh; } .mx_EventTile:hover .mx_EventTile_body pre, @@ -521,21 +516,42 @@ $left-gutter: 64px; } // Inserted adjacent to
 blocks, (See TextualBody)
-.mx_EventTile_copyButton {
+.mx_EventTile_button {
     position: absolute;
     display: inline-block;
     visibility: hidden;
     cursor: pointer;
-    top: 6px;
-    right: 6px;
+    top: 8px;
+    right: 8px;
     width: 19px;
     height: 19px;
-    mask-image: url($copy-button-url);
     background-color: $message-action-bar-fg-color;
 }
+.mx_EventTile_buttonBottom {
+    top: 33px;
+}
+.mx_EventTile_copyButton {
+    mask-image: url($copy-button-url);
+}
+.mx_EventTile_collapseButton {
+    mask-size: 75%;
+    mask-position: center;
+    mask-repeat: no-repeat;
+    mask-image: url($collapse-button-url);
+}
+.mx_EventTile_expandButton {
+    mask-size: 75%;
+    mask-position: center;
+    mask-repeat: no-repeat;
+    mask-image: url($expand-button-url);
+}
 
 .mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_copyButton,
-.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton {
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton,
+.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_collapseButton,
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_collapseButton,
+.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_expandButton,
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_expandButton {
     visibility: visible;
 }
 
diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss
index 2b447be44a..ddee81a914 100644
--- a/res/css/views/rooms/_GroupLayout.scss
+++ b/res/css/views/rooms/_GroupLayout.scss
@@ -20,12 +20,8 @@ $left-gutter: 64px;
 .mx_GroupLayout {
     .mx_EventTile {
         > .mx_SenderProfile {
-            line-height: $font-17px;
-            padding-left: $left-gutter;
-        }
-
-        > .mx_EventTile_line {
-            padding-left: $left-gutter;
+            line-height: $font-20px;
+            margin-left: $left-gutter;
         }
 
         > .mx_EventTile_avatar {
@@ -34,19 +30,15 @@ $left-gutter: 64px;
 
         .mx_MessageTimestamp {
             position: absolute;
-            width: 46px; /* 8 + 30 (avatar) + 8 */
+            width: $MessageTimestamp_width;
         }
 
         .mx_EventTile_line, .mx_EventTile_reply {
-            padding-top: 3px;
+            padding-top: 1px;
             padding-bottom: 3px;
             line-height: $font-22px;
         }
     }
-
-    .mx_EventTile_info .mx_EventTile_line {
-        padding-left: calc($left-gutter + 18px);
-    }
 }
 
 /* Compact layout overrides */
@@ -105,16 +97,9 @@ $left-gutter: 64px;
         }
 
         .mx_EventTile_readAvatars {
-            top: 27px;
-        }
-
-        &.mx_EventTile_continuation .mx_EventTile_readAvatars,
-        &.mx_EventTile_emote .mx_EventTile_readAvatars {
-            top: 5px;
-        }
-
-        &.mx_EventTile_info .mx_EventTile_readAvatars {
-            top: 4px;
+            // This aligns the avatar with the last line of the
+            // message. We want to move it one line up - 2rem
+            top: -2rem;
         }
 
         .mx_EventTile_content .markdown-body {
diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss
index 958d718b11..97190807ca 100644
--- a/res/css/views/rooms/_IRCLayout.scss
+++ b/res/css/views/rooms/_IRCLayout.scss
@@ -29,6 +29,7 @@ $irc-line-height: $font-18px;
         // timestamps are links which shouldn't be underlined
         > a {
             text-decoration: none;
+            min-width: 45px;
         }
 
         display: flex;
@@ -49,18 +50,6 @@ $irc-line-height: $font-18px;
             }
         }
 
-        > .mx_SenderProfile {
-            order: 2;
-            flex-shrink: 0;
-            width: var(--name-width);
-            text-overflow: ellipsis;
-            text-align: left;
-            display: flex;
-            align-items: center;
-            overflow: visible;
-            justify-content: flex-end;
-        }
-
         .mx_EventTile_line, .mx_EventTile_reply {
             padding: 0;
             display: flex;
@@ -115,8 +104,7 @@ $irc-line-height: $font-18px;
         .mx_EventTile_line {
             .mx_EventTile_e2eIcon,
             .mx_TextualEvent,
-            .mx_MTextBody,
-            .mx_ReplyThread_wrapper_empty {
+            .mx_MTextBody {
                 display: inline-block;
             }
         }
@@ -174,37 +162,65 @@ $irc-line-height: $font-18px;
         border-left: 0;
     }
 
-    .mx_SenderProfile_hover {
-        background-color: $primary-bg-color;
-        overflow: hidden;
+    .mx_SenderProfile {
+        width: var(--name-width);
+        display: flex;
+        order: 2;
+        flex-shrink: 0;
+        justify-content: flex-start;
+        align-items: center;
 
-        > span {
-            display: flex;
+        > .mx_SenderProfile_displayName {
+            width: 100%;
+            text-align: end;
+            overflow: hidden;
+            text-overflow: ellipsis;
+        }
 
-            > .mx_SenderProfile_name,
-            > .mx_SenderProfile_aux {
-                overflow: hidden;
-                text-overflow: ellipsis;
-                min-width: var(--name-width);
-            }
+        > .mx_SenderProfile_mxid {
+            visibility: collapse;
         }
     }
 
     .mx_SenderProfile:hover {
-        justify-content: flex-start;
-    }
-
-    .mx_SenderProfile_hover:hover {
         overflow: visible;
-        width: max(auto, 100%);
         z-index: 10;
+
+        > .mx_SenderProfile_displayName {
+            overflow: visible;
+        }
+
+        > .mx_SenderProfile_mxid {
+            visibility: visible;
+        }
     }
 
     .mx_ReplyThread {
         margin: 0;
         .mx_SenderProfile {
+            order: unset;
+            max-width: unset;
             width: unset;
-            max-width: var(--name-width);
+            background: transparent;
+        }
+
+        .mx_EventTile_emote {
+            > .mx_EventTile_avatar {
+                margin-left: initial;
+            }
+        }
+
+        .mx_MessageTimestamp {
+            width: initial;
+        }
+
+        /**
+         * adding the icon back in the document flow
+         * if it's not present, there's no unwanted wasted space
+         */
+        .mx_EventTile_e2eIcon {
+            position: relative;
+            order: -1;
         }
     }
 
diff --git a/res/css/views/rooms/_JumpToBottomButton.scss b/res/css/views/rooms/_JumpToBottomButton.scss
index 6cb3b6bce9..a8dc2ce11c 100644
--- a/res/css/views/rooms/_JumpToBottomButton.scss
+++ b/res/css/views/rooms/_JumpToBottomButton.scss
@@ -52,6 +52,7 @@ limitations under the License.
 
 .mx_JumpToBottomButton_scrollDown {
     position: relative;
+    display: block;
     height: 38px;
     border-radius: 19px;
     box-sizing: border-box;
diff --git a/res/css/views/elements/_FormButton.scss b/res/css/views/rooms/_LinkPreviewGroup.scss
similarity index 57%
rename from res/css/views/elements/_FormButton.scss
rename to res/css/views/rooms/_LinkPreviewGroup.scss
index 7ec01f17e6..ed341904fd 100644
--- a/res/css/views/elements/_FormButton.scss
+++ b/res/css/views/rooms/_LinkPreviewGroup.scss
@@ -1,5 +1,5 @@
 /*
-Copyright 2019 The Matrix.org Foundation C.I.C.
+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.
@@ -14,23 +14,25 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_FormButton {
-    line-height: $font-16px;
-    padding: 5px 15px;
-    font-size: $font-12px;
-    height: min-content;
+.mx_LinkPreviewGroup {
+    .mx_LinkPreviewGroup_hide {
+        cursor: pointer;
+        width: 18px;
+        height: 18px;
 
-    &:not(:last-child) {
-        margin-right: 8px;
+        img {
+            flex: 0 0 40px;
+            visibility: hidden;
+        }
     }
 
-    &.mx_AccessibleButton_kind_primary {
+    &:hover .mx_LinkPreviewGroup_hide img,
+    .mx_LinkPreviewGroup_hide.focus-visible:focus img {
+        visibility: visible;
+    }
+
+    > .mx_AccessibleButton {
         color: $accent-color;
-        background-color: $accent-bg-color;
-    }
-
-    &.mx_AccessibleButton_kind_danger {
-        color: $notice-primary-color;
-        background-color: $notice-primary-bg-color;
+        text-align: center;
     }
 }
diff --git a/res/css/views/rooms/_LinkPreviewWidget.scss b/res/css/views/rooms/_LinkPreviewWidget.scss
index 022cf3ed28..0832337ecd 100644
--- a/res/css/views/rooms/_LinkPreviewWidget.scss
+++ b/res/css/views/rooms/_LinkPreviewWidget.scss
@@ -33,38 +33,29 @@ limitations under the License.
 .mx_LinkPreviewWidget_caption {
     margin-left: 15px;
     flex: 1 1 auto;
+    overflow-x: hidden; // cause it to wrap rather than clip
 }
 
 .mx_LinkPreviewWidget_title {
-    display: inline;
     font-weight: bold;
     white-space: normal;
-}
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
 
-.mx_LinkPreviewWidget_siteName {
-    display: inline;
+    .mx_LinkPreviewWidget_siteName {
+        font-weight: normal;
+    }
 }
 
 .mx_LinkPreviewWidget_description {
     margin-top: 8px;
     white-space: normal;
     word-wrap: break-word;
-}
-
-.mx_LinkPreviewWidget_cancel {
-    cursor: pointer;
-    width: 18px;
-    height: 18px;
-
-    img {
-        flex: 0 0 40px;
-        visibility: hidden;
-    }
-}
-
-.mx_LinkPreviewWidget:hover .mx_LinkPreviewWidget_cancel img,
-.mx_LinkPreviewWidget_cancel.focus-visible:focus img {
-    visibility: visible;
+    display: -webkit-box;
+    -webkit-line-clamp: 3;
+    -webkit-box-orient: vertical;
 }
 
 .mx_MatrixChat_useCompactLayout {
diff --git a/res/css/views/rooms/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss
index 182c280217..3f7f83d334 100644
--- a/res/css/views/rooms/_MemberInfo.scss
+++ b/res/css/views/rooms/_MemberInfo.scss
@@ -19,6 +19,7 @@ limitations under the License.
     flex-direction: column;
     flex: 1;
     overflow-y: auto;
+    margin-top: 8px;
 }
 
 .mx_MemberInfo_name {
diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss
index f00907aeef..075e9ff585 100644
--- a/res/css/views/rooms/_MemberList.scss
+++ b/res/css/views/rooms/_MemberList.scss
@@ -44,6 +44,17 @@ limitations under the License.
     .mx_AutoHideScrollbar {
         flex: 1 1 0;
     }
+
+    .mx_RightPanel_scopeHeader {
+        // vertically align with position on other right panel cards
+        // to prevent it bouncing as user navigates right panel
+        margin-top: -8px;
+    }
+}
+
+.mx_GroupMemberList_query,
+.mx_GroupRoomList_query {
+    flex: 0 0 auto;
 }
 
 .mx_MemberList_chevron {
@@ -59,10 +70,8 @@ limitations under the License.
     flex: 1 1 0px;
 }
 
-.mx_MemberList_query,
-.mx_GroupMemberList_query,
-.mx_GroupRoomList_query {
-    flex: 1 1 0;
+.mx_MemberList_query {
+    height: 16px;
 
     // stricter rule to override the one in _common.scss
     &[type="text"] {
@@ -70,10 +79,6 @@ limitations under the License.
     }
 }
 
-.mx_MemberList_query {
-    height: 16px;
-}
-
 .mx_MemberList_wrapper {
     padding: 10px;
 }
@@ -113,10 +118,10 @@ limitations under the License.
     }
 }
 
-.mx_MemberList_inviteCommunity span {
-    background-image: url('$(res)/img/icon-invite-people.svg');
+.mx_MemberList_inviteCommunity span::before {
+    mask-image: url('$(res)/img/icon-invite-people.svg');
 }
 
-.mx_MemberList_addRoomToCommunity span {
-    background-image: url('$(res)/img/icons-room-add.svg');
+.mx_MemberList_addRoomToCommunity span::before {
+    mask-image: url('$(res)/img/icons-room-add.svg');
 }
diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss
index 71c0db947e..e6c0cc3f46 100644
--- a/res/css/views/rooms/_MessageComposer.scss
+++ b/res/css/views/rooms/_MessageComposer.scss
@@ -227,16 +227,8 @@ limitations under the License.
     mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
 }
 
-.mx_MessageComposer_hangup::before {
-    mask-image: url('$(res)/img/element-icons/call/hangup.svg');
-}
-
-.mx_MessageComposer_voicecall::before {
-    mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
-}
-
-.mx_MessageComposer_videocall::before {
-    mask-image: url('$(res)/img/element-icons/call/video-call.svg');
+.mx_MessageComposer_voiceMessage::before {
+    mask-image: url('$(res)/img/voip/mic-on-mask.svg');
 }
 
 .mx_MessageComposer_emoji::before {
@@ -247,6 +239,32 @@ limitations under the License.
     mask-image: url('$(res)/img/element-icons/room/composer/sticker.svg');
 }
 
+.mx_MessageComposer_sendMessage {
+    cursor: pointer;
+    position: relative;
+    margin-right: 6px;
+    width: 32px;
+    height: 32px;
+    border-radius: 100%;
+    background-color: $button-bg-color;
+
+    &::before {
+        position: absolute;
+        height: 16px;
+        width: 16px;
+        top: 8px;
+        left: 9px;
+
+        mask-image: url('$(res)/img/element-icons/send-message.svg');
+        mask-repeat: no-repeat;
+        mask-size: contain;
+        mask-position: center;
+
+        background-color: $button-fg-color;
+        content: '';
+    }
+}
+
 .mx_MessageComposer_formatting {
     cursor: pointer;
     margin: 0 11px;
diff --git a/res/css/views/rooms/_MessageComposerFormatBar.scss b/res/css/views/rooms/_MessageComposerFormatBar.scss
index d97c49630a..b305e91db0 100644
--- a/res/css/views/rooms/_MessageComposerFormatBar.scss
+++ b/res/css/views/rooms/_MessageComposerFormatBar.scss
@@ -60,6 +60,8 @@ limitations under the License.
         width: 27px;
         height: 24px;
         box-sizing: border-box;
+        background: none;
+        vertical-align: middle;
     }
 
     .mx_MessageComposerFormatBar_button::after {
diff --git a/res/css/views/rooms/_NewRoomIntro.scss b/res/css/views/rooms/_NewRoomIntro.scss
new file mode 100644
index 0000000000..e0cccfa885
--- /dev/null
+++ b/res/css/views/rooms/_NewRoomIntro.scss
@@ -0,0 +1,72 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_NewRoomIntro {
+    margin: 40px 0 48px 64px;
+
+    .mx_MiniAvatarUploader_hasAvatar:not(.mx_MiniAvatarUploader_busy):not(:hover) {
+        .mx_MiniAvatarUploader_indicator {
+            display: none;
+        }
+    }
+
+    .mx_AccessibleButton_kind_link {
+        padding: 0;
+        font-size: inherit;
+    }
+
+    .mx_NewRoomIntro_buttons {
+        margin-top: 28px;
+
+        .mx_AccessibleButton {
+            line-height: $font-24px;
+            display: inline-block;
+
+            & + .mx_AccessibleButton {
+                margin-left: 12px;
+            }
+
+            &:not(.mx_AccessibleButton_kind_primary_outline)::before {
+                content: '';
+                display: inline-block;
+                background-color: $button-fg-color;
+                mask-position: center;
+                mask-repeat: no-repeat;
+                mask-size: 20px;
+                width: 20px;
+                height: 20px;
+                margin-right: 5px;
+                vertical-align: text-bottom;
+            }
+        }
+
+        .mx_NewRoomIntro_inviteButton::before {
+            mask-image: url('$(res)/img/element-icons/room/invite.svg');
+        }
+    }
+
+    > h2 {
+        margin-top: 24px;
+        font-size: $font-24px;
+        font-weight: 600;
+    }
+
+    > p {
+        margin: 0;
+        font-size: $font-15px;
+        color: $secondary-fg-color;
+    }
+}
diff --git a/res/css/views/rooms/_PinnedEventTile.scss b/res/css/views/rooms/_PinnedEventTile.scss
index 030a76674a..15b3c16faa 100644
--- a/res/css/views/rooms/_PinnedEventTile.scss
+++ b/res/css/views/rooms/_PinnedEventTile.scss
@@ -16,62 +16,91 @@ limitations under the License.
 
 .mx_PinnedEventTile {
     min-height: 40px;
-    margin-bottom: 5px;
     width: 100%;
-    border-radius: 5px; // for the hover
-}
+    padding: 0 4px 12px;
 
-.mx_PinnedEventTile:hover {
-    background-color: $event-selected-color;
-}
+    display: grid;
+    grid-template-areas:
+        "avatar name remove"
+        "content content content"
+        "footer footer footer";
+    grid-template-rows: max-content auto max-content;
+    grid-template-columns: 24px auto 24px;
+    grid-row-gap: 12px;
+    grid-column-gap: 8px;
 
-.mx_PinnedEventTile .mx_PinnedEventTile_sender,
-.mx_PinnedEventTile .mx_PinnedEventTile_timestamp {
-    color: #868686;
-    font-size: 0.8em;
-    vertical-align: top;
-    display: inline-block;
-    padding-bottom: 3px;
-}
+    & + .mx_PinnedEventTile {
+        padding: 12px 4px;
+        border-top: 1px solid $menu-border-color;
+    }
 
-.mx_PinnedEventTile .mx_PinnedEventTile_timestamp {
-    padding-left: 15px;
-    display: none;
-}
+    .mx_PinnedEventTile_senderAvatar {
+        grid-area: avatar;
+    }
 
-.mx_PinnedEventTile .mx_PinnedEventTile_senderAvatar .mx_BaseAvatar {
-    float: left;
-    margin-right: 10px;
-}
+    .mx_PinnedEventTile_sender {
+        grid-area: name;
+        font-weight: $font-semi-bold;
+        font-size: $font-15px;
+        line-height: $font-24px;
+        text-overflow: ellipsis;
+        overflow: hidden;
+        white-space: nowrap;
+    }
 
-.mx_PinnedEventTile_actions {
-    float: right;
-    margin-right: 10px;
-    display: none;
-}
+    .mx_PinnedEventTile_unpinButton {
+        visibility: hidden;
+        grid-area: remove;
+        position: relative;
+        width: 24px;
+        height: 24px;
+        border-radius: 8px;
 
-.mx_PinnedEventTile:hover .mx_PinnedEventTile_timestamp {
-    display: inline-block;
-}
+        &:hover {
+            background-color: $roomheader-addroom-bg-color;
+        }
 
-.mx_PinnedEventTile:hover .mx_PinnedEventTile_actions {
-    display: block;
-}
+        &::before {
+            content: "";
+            position: absolute;
+            //top: 0;
+            //left: 0;
+            height: inherit;
+            width: inherit;
+            background: $secondary-fg-color;
+            mask-position: center;
+            mask-size: 8px;
+            mask-repeat: no-repeat;
+            mask-image: url('$(res)/img/image-view/close.svg');
+        }
+    }
 
-.mx_PinnedEventTile_unpinButton {
-    display: inline-block;
-    cursor: pointer;
-    margin-left: 10px;
-}
+    .mx_PinnedEventTile_message {
+        grid-area: content;
+    }
 
-.mx_PinnedEventTile_gotoButton {
-    display: inline-block;
-    font-size: 0.7em; // Smaller text to avoid conflicting with the layout
-}
+    .mx_PinnedEventTile_footer {
+        grid-area: footer;
+        font-size: 10px;
+        line-height: 12px;
 
-.mx_PinnedEventTile_message {
-    margin-left: 50px;
-    position: relative;
-    top: 0;
-    left: 0;
+        .mx_PinnedEventTile_timestamp {
+            font-size: inherit;
+            line-height: inherit;
+            color: $secondary-fg-color;
+        }
+
+        .mx_AccessibleButton_kind_link {
+            padding: 0;
+            margin-left: 12px;
+            font-size: inherit;
+            line-height: inherit;
+        }
+    }
+
+    &:hover {
+        .mx_PinnedEventTile_unpinButton {
+            visibility: visible;
+        }
+    }
 }
diff --git a/res/css/views/rooms/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss
index 10f8e21e43..c1fe1d9a8b 100644
--- a/res/css/views/rooms/_ReplyPreview.scss
+++ b/res/css/views/rooms/_ReplyPreview.scss
@@ -29,12 +29,16 @@ limitations under the License.
 }
 
 .mx_ReplyPreview_header {
-    margin: 12px;
+    margin: 8px;
     color: $primary-fg-color;
     font-weight: 400;
     opacity: 0.4;
 }
 
+.mx_ReplyPreview_tile {
+    margin: 0 8px;
+}
+
 .mx_ReplyPreview_title {
     float: left;
 }
@@ -42,6 +46,7 @@ limitations under the License.
 .mx_ReplyPreview_cancel {
     float: right;
     cursor: pointer;
+    display: flex;
 }
 
 .mx_ReplyPreview_clear {
diff --git a/res/css/views/rooms/_ReplyTile.scss b/res/css/views/rooms/_ReplyTile.scss
new file mode 100644
index 0000000000..c8f76ee995
--- /dev/null
+++ b/res/css/views/rooms/_ReplyTile.scss
@@ -0,0 +1,123 @@
+/*
+Copyright 2020 Tulir Asokan 
+
+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_ReplyTile {
+    padding-top: 2px;
+    padding-bottom: 2px;
+    font-size: $font-14px;
+    position: relative;
+    line-height: $font-16px;
+
+    &.mx_ReplyTile_audio .mx_MFileBody_info_icon::before {
+        mask-image: url("$(res)/img/element-icons/speaker.svg");
+    }
+
+    &.mx_ReplyTile_video .mx_MFileBody_info_icon::before {
+        mask-image: url("$(res)/img/element-icons/call/video-call.svg");
+    }
+
+    .mx_MFileBody {
+        .mx_MFileBody_info {
+            margin: 5px 0;
+        }
+
+        .mx_MFileBody_download {
+            display: none;
+        }
+    }
+}
+
+.mx_ReplyTile > a {
+    display: flex;
+    flex-direction: column;
+    text-decoration: none;
+    color: $primary-fg-color;
+}
+
+.mx_ReplyTile .mx_RedactedBody {
+    padding: 4px 0 2px 20px;
+
+    &::before {
+        height: 13px;
+        width: 13px;
+        top: 5px;
+    }
+}
+
+// We do reply size limiting with CSS to avoid duplicating the TextualBody component.
+.mx_ReplyTile .mx_EventTile_content {
+    $reply-lines: 2;
+    $line-height: $font-22px;
+
+    pointer-events: none;
+
+    text-overflow: ellipsis;
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: $reply-lines;
+    line-height: $line-height;
+
+    .mx_EventTile_body.mx_EventTile_bigEmoji {
+        line-height: $line-height !important;
+        // Override the big emoji override
+        font-size: $font-14px !important;
+    }
+
+    // Hide line numbers
+    .mx_EventTile_lineNumbers {
+        display: none;
+    }
+
+    // Hack to cut content in 
 tags too
+    .mx_EventTile_pre_container > pre {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        display: -webkit-box;
+        -webkit-box-orient: vertical;
+        -webkit-line-clamp: $reply-lines;
+        padding: 4px;
+    }
+
+    .markdown-body blockquote,
+    .markdown-body dl,
+    .markdown-body ol,
+    .markdown-body p,
+    .markdown-body pre,
+    .markdown-body table,
+    .markdown-body ul {
+        margin-bottom: 4px;
+    }
+}
+
+.mx_ReplyTile.mx_ReplyTile_info {
+    padding-top: 0;
+}
+
+.mx_ReplyTile .mx_SenderProfile {
+    color: $primary-fg-color;
+    font-size: $font-14px;
+    display: inline-block; /* anti-zalgo, with overflow hidden */
+    overflow: hidden;
+    cursor: pointer;
+    padding-left: 0; /* left gutter */
+    padding-bottom: 0;
+    padding-top: 0;
+    margin: 0;
+    line-height: $font-17px;
+    /* the next three lines, along with overflow hidden, truncate long display names */
+    white-space: nowrap;
+    text-overflow: ellipsis;
+}
diff --git a/res/css/views/rooms/_RoomBreadcrumbs.scss b/res/css/views/rooms/_RoomBreadcrumbs.scss
index 6512797401..152b0a45cd 100644
--- a/res/css/views/rooms/_RoomBreadcrumbs.scss
+++ b/res/css/views/rooms/_RoomBreadcrumbs.scss
@@ -32,14 +32,14 @@ limitations under the License.
     // first triggering the enter state with the newest breadcrumb off screen (-40px) then
     // sliding it into view.
     &.mx_RoomBreadcrumbs-enter {
-        margin-left: -40px; // 32px for the avatar, 8px for the margin
+        transform: translateX(-40px); // 32px for the avatar, 8px for the margin
     }
     &.mx_RoomBreadcrumbs-enter-active {
-        margin-left: 0;
+        transform: translateX(0);
 
         // Timing function is as-requested by design.
         // NOTE: The transition time MUST match the value passed to CSSTransition!
-        transition: margin-left 640ms cubic-bezier(0.66, 0.02, 0.36, 1);
+        transition: transform 640ms cubic-bezier(0.66, 0.02, 0.36, 1);
     }
 
     .mx_RoomBreadcrumbs_placeholder {
diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss
index a23a44906f..4142b0a2ef 100644
--- a/res/css/views/rooms/_RoomHeader.scss
+++ b/res/css/views/rooms/_RoomHeader.scss
@@ -252,6 +252,19 @@ limitations under the License.
     mask-image: url('$(res)/img/element-icons/room/search-inset.svg');
 }
 
+.mx_RoomHeader_voiceCallButton::before {
+    mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
+
+    // The call button SVG is padded slightly differently, so match it up to the size
+    // of the other icons
+    mask-size: 20px;
+    mask-position: center;
+}
+
+.mx_RoomHeader_videoCallButton::before {
+    mask-image: url('$(res)/img/element-icons/call/video-call.svg');
+}
+
 .mx_RoomHeader_showPanel {
     height: 16px;
 }
@@ -264,24 +277,6 @@ limitations under the License.
     margin-top: 18px;
 }
 
-.mx_RoomHeader_pinnedButton::before {
-    mask-image: url('$(res)/img/element-icons/room/pin.svg');
-}
-
-.mx_RoomHeader_pinsIndicator {
-    position: absolute;
-    right: 0;
-    bottom: 4px;
-    width: 8px;
-    height: 8px;
-    border-radius: 8px;
-    background-color: $pinned-color;
-}
-
-.mx_RoomHeader_pinsIndicatorUnread {
-    background-color: $pinned-unread-color;
-}
-
 @media only screen and (max-width: 480px) {
     .mx_RoomHeader_wrapper {
         padding: 0;
diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss
index 78e7307bc0..8eda25d0c9 100644
--- a/res/css/views/rooms/_RoomList.scss
+++ b/res/css/views/rooms/_RoomList.scss
@@ -19,41 +19,71 @@ limitations under the License.
 }
 
 .mx_RoomList_iconPlus::before {
-    mask-image: url('$(res)/img/element-icons/roomlist/plus.svg');
+    mask-image: url('$(res)/img/element-icons/roomlist/plus-circle.svg');
+}
+.mx_RoomList_iconHash::before {
+    mask-image: url('$(res)/img/element-icons/roomlist/hash-circle.svg');
 }
 .mx_RoomList_iconExplore::before {
     mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
 }
+.mx_RoomList_iconBrowse::before {
+    mask-image: url('$(res)/img/element-icons/roomlist/browse.svg');
+}
+.mx_RoomList_iconDialpad::before {
+    mask-image: url('$(res)/img/element-icons/roomlist/dialpad.svg');
+}
 
 .mx_RoomList_explorePrompt {
     margin: 4px 12px 4px;
     padding-top: 12px;
-    border-top: 1px solid $tertiary-fg-color;
-    font-size: $font-13px;
+    border-top: 1px solid $input-border-color;
+    font-size: $font-14px;
 
     div:first-child {
         font-weight: $font-semi-bold;
-        margin-bottom: 8px;
+        line-height: $font-18px;
+        color: $primary-fg-color;
     }
 
     .mx_AccessibleButton {
-        color: $secondary-fg-color;
+        color: $primary-fg-color;
         position: relative;
-        padding: 0 0 0 24px;
+        padding: 8px 8px 8px 32px;
         font-size: inherit;
+        margin-top: 12px;
+        display: block;
+        text-align: start;
+        background-color: $roomlist-button-bg-color;
+        border-radius: 4px;
 
         &::before {
             content: '';
             width: 16px;
             height: 16px;
             position: absolute;
-            top: 0;
-            left: 0;
+            top: 8px;
+            left: 8px;
             background: $secondary-fg-color;
             mask-position: center;
             mask-size: contain;
             mask-repeat: no-repeat;
+        }
+
+        &.mx_RoomList_explorePrompt_startChat::before {
+            mask-image: url('$(res)/img/element-icons/feedback.svg');
+        }
+
+        &.mx_RoomList_explorePrompt_explore::before {
             mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
         }
+
+        &.mx_RoomList_explorePrompt_spaceInvite::before {
+            mask-image: url('$(res)/img/element-icons/room/invite.svg');
+        }
+
+        &.mx_RoomList_explorePrompt_spaceExplore::before {
+            mask-image: url('$(res)/img/element-icons/roomlist/browse.svg');
+        }
     }
 }
diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss
index 82bba40167..146b3edf71 100644
--- a/res/css/views/rooms/_RoomSublist.scss
+++ b/res/css/views/rooms/_RoomSublist.scss
@@ -18,6 +18,10 @@ limitations under the License.
     margin-left: 8px;
     margin-bottom: 4px;
 
+    &.mx_RoomSublist_hidden {
+        display: none;
+    }
+
     .mx_RoomSublist_headerContainer {
         // Create a flexbox to make alignment easy
         display: flex;
@@ -37,7 +41,9 @@ limitations under the License.
         // The combined height must be set in the LeftPanel component for sticky headers
         // to work correctly.
         padding-bottom: 8px;
-        height: 24px;
+        // Allow the container to collapse on itself if its children
+        // are not in the normal document flow
+        max-height: 24px;
         color: $roomlist-header-color;
 
         .mx_RoomSublist_stickable {
@@ -55,8 +61,8 @@ limitations under the License.
             &.mx_RoomSublist_headerContainer_sticky {
                 position: fixed;
                 height: 32px; // to match the header container
-                // width set by JS
-                width: calc(100% - 22px);
+                // width set by JS because of a compat issue between Firefox and Chrome
+                width: calc(100% - 15px);
             }
 
             // We don't have a top style because the top is dependent on the room list header's
@@ -92,7 +98,7 @@ limitations under the License.
             position: relative;
             width: 24px;
             height: 24px;
-            border-radius: 32px;
+            border-radius: 8px;
 
             &::before {
                 content: '';
@@ -108,6 +114,11 @@ limitations under the License.
             }
         }
 
+        .mx_RoomSublist_auxButton:hover,
+        .mx_RoomSublist_menuButton:hover {
+            background: $roomlist-button-bg-color;
+        }
+
         // Hide the menu button by default
         .mx_RoomSublist_menuButton {
             visibility: hidden;
@@ -187,6 +198,7 @@ limitations under the License.
             // as the box model should be top aligned. Happens in both FF and Chromium
             display: flex;
             flex-direction: column;
+            align-self: stretch;
 
             mask-image: linear-gradient(0deg, transparent, black 4px);
         }
@@ -197,6 +209,9 @@ limitations under the License.
 
         .mx_RoomSublist_resizerHandles {
             flex: 0 0 4px;
+            display: flex;
+            justify-content: center;
+            width: 100%;
         }
 
         // Class name comes from the ResizableBox component
@@ -207,17 +222,12 @@ limitations under the License.
             border-radius: 3px;
 
             // Override styles from library
-            width: unset !important;
+            max-width: 64px;
             height: 4px !important; // Update RESIZE_HANDLE_HEIGHT if this changes
 
             // This is positioned directly below the 'show more' button.
-            position: absolute;
+            position: relative !important;
             bottom: 0 !important; // override from library
-
-            // Together, these make the bar 64px wide
-            // These are also overridden from the library
-            left: calc(50% - 32px) !important;
-            right: calc(50% - 32px) !important;
         }
 
         &:hover, &.mx_RoomSublist_hasMenuOpen {
@@ -383,3 +393,22 @@ limitations under the License.
 .mx_RoomSublist_addRoomTooltip {
     margin-top: -3px;
 }
+
+.mx_RoomSublist_skeletonUI {
+    position: relative;
+    margin-left: 4px;
+    height: 288px;
+
+    &::before {
+        background: $roomsublist-skeleton-ui-bg;
+
+        width: 100%;
+        height: 100%;
+
+        content: '';
+        position: absolute;
+        mask-repeat: repeat-y;
+        mask-size: auto 48px;
+        mask-image: url('$(res)/img/element-icons/roomlist/skeleton-ui.svg');
+    }
+}
diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss
index 8eca3f1efa..b8f4aeb6e7 100644
--- a/res/css/views/rooms/_RoomTile.scss
+++ b/res/css/views/rooms/_RoomTile.scss
@@ -19,6 +19,10 @@ limitations under the License.
     margin-bottom: 4px;
     padding: 4px;
 
+    contain: content; // Not strict as it will break when resizing a sublist vertically
+    height: 40px;
+    box-sizing: border-box;
+
     // The tile is also a flexbox row itself
     display: flex;
 
@@ -189,6 +193,14 @@ limitations under the License.
         mask-image: url('$(res)/img/element-icons/settings.svg');
     }
 
+    .mx_RoomTile_iconCopyLink::before {
+        mask-image: url('$(res)/img/element-icons/link.svg');
+    }
+
+    .mx_RoomTile_iconInvite::before {
+        mask-image: url('$(res)/img/element-icons/room/invite.svg');
+    }
+
     .mx_RoomTile_iconSignOut::before {
         mask-image: url('$(res)/img/element-icons/leave.svg');
     }
diff --git a/res/css/views/rooms/_Stickers.scss b/res/css/views/rooms/_Stickers.scss
index 94f42efe83..da86797f42 100644
--- a/res/css/views/rooms/_Stickers.scss
+++ b/res/css/views/rooms/_Stickers.scss
@@ -22,7 +22,7 @@
 
     iframe {
         // Sticker picker depends on the fixed height previously used for all tiles
-        height: 273px;
+        height: 283px; // height of the popout minus the AppTile menu bar
     }
 }
 
diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss
new file mode 100644
index 0000000000..5501ab343e
--- /dev/null
+++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss
@@ -0,0 +1,98 @@
+/*
+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_VoiceRecordComposerTile_stop {
+    // 28px plus a 2px border makes this a 32px square (as intended)
+    width: 28px;
+    height: 28px;
+    border: 2px solid $voice-record-stop-border-color;
+    border-radius: 32px;
+    margin-right: 16px; // between us and the send button
+    position: relative;
+
+    &::after {
+        content: '';
+        width: 14px;
+        height: 14px;
+        position: absolute;
+        top: 7px;
+        left: 7px;
+        border-radius: 2px;
+        background-color: $voice-record-stop-symbol-color;
+    }
+}
+
+.mx_VoiceRecordComposerTile_delete {
+    width: 24px;
+    height: 24px;
+    vertical-align: middle;
+    margin-right: 8px; // distance from left edge of waveform container (container has some margin too)
+    background-color: $voice-record-icon-color;
+    mask-repeat: no-repeat;
+    mask-size: contain;
+    mask-image: url('$(res)/img/element-icons/trashcan.svg');
+}
+
+.mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer {
+    // Note: remaining class properties are in the PlayerContainer CSS.
+
+    margin: 6px; // force the composer area to put a gutter around us
+    margin-right: 12px; // isolate from stop/send button
+
+    position: relative; // important for the live circle
+
+    &.mx_VoiceRecordComposerTile_recording {
+        // We are putting the circle in this padding, so we need +10px from the regular
+        // padding on the left side.
+        padding-left: 22px;
+
+        &::before {
+            animation: recording-pulse 2s infinite;
+
+            content: '';
+            background-color: $voice-record-live-circle-color;
+            width: 10px;
+            height: 10px;
+            position: absolute;
+            left: 12px; // 12px from the left edge for container padding
+            top: 18px; // vertically center (middle align with clock)
+            border-radius: 10px;
+        }
+    }
+}
+
+// The keyframes are slightly weird here to help make a ramping/punch effect
+// for the recording dot. We start and end at 100% opacity to help make the
+// dot feel a bit like a real lamp that is blinking: the animation ends up
+// spending a lot of its time showing a steady state without a fade effect.
+// This lamp effect extends into why the 0% opacity keyframe is not in the
+// midpoint: lamps take longer to turn off than they do to turn on, and the
+// extra frames give it a bit of a realistic punch for when the animation is
+// ramping back up to 100% opacity.
+//
+// Target animation timings: steady for 1.5s, fade out for 0.3s, fade in for 0.2s
+// (intended to be used in a loop for 2s animation speed)
+@keyframes recording-pulse {
+    0% {
+        opacity: 1;
+    }
+    35% {
+        opacity: 0;
+    }
+    65% {
+        opacity: 1;
+    }
+}
diff --git a/res/css/views/settings/_Notifications.scss b/res/css/views/settings/_Notifications.scss
index e6d09b9a2a..77a7bc5b68 100644
--- a/res/css/views/settings/_Notifications.scss
+++ b/res/css/views/settings/_Notifications.scss
@@ -64,6 +64,7 @@ limitations under the License.
 
 .mx_UserNotifSettings_notifTable {
     display: table;
+    position: relative;
 }
 
 .mx_UserNotifSettings_notifTable .mx_Spinner {
diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss
index 732cbedf02..4cbcb8e708 100644
--- a/res/css/views/settings/_ProfileSettings.scss
+++ b/res/css/views/settings/_ProfileSettings.scss
@@ -14,6 +14,12 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+.mx_ProfileSettings_controls_topic {
+    & > textarea {
+        resize: vertical;
+    }
+}
+
 .mx_ProfileSettings_profile {
     display: flex;
 }
diff --git a/res/css/views/rooms/_PinnedEventsPanel.scss b/res/css/views/settings/_SpellCheckLanguages.scss
similarity index 57%
rename from res/css/views/rooms/_PinnedEventsPanel.scss
rename to res/css/views/settings/_SpellCheckLanguages.scss
index 663d5bdf6e..bb322c983f 100644
--- a/res/css/views/rooms/_PinnedEventsPanel.scss
+++ b/res/css/views/settings/_SpellCheckLanguages.scss
@@ -1,5 +1,5 @@
 /*
-Copyright 2017 Travis Ralston
+Copyright 2021 Šimon Brandner 
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,24 +14,22 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_PinnedEventsPanel {
-    border-top: 1px solid $primary-hairline-color;
+.mx_ExistingSpellCheckLanguage {
+    display: flex;
+    align-items: center;
+    margin-bottom: 5px;
 }
 
-.mx_PinnedEventsPanel_body {
-    max-height: 300px;
-    overflow-y: auto;
-    padding-bottom: 15px;
+.mx_ExistingSpellCheckLanguage_language {
+    flex: 1;
+    margin-right: 10px;
 }
 
-.mx_PinnedEventsPanel_header {
-    margin: 0;
-    padding-top: 8px;
-    padding-bottom: 15px;
+.mx_GeneralUserSettingsTab_spellCheckLanguageInput {
+    margin-top: 1em;
+    margin-bottom: 1em;
 }
 
-.mx_PinnedEventsPanel_cancel {
-    margin: 12px;
-    float: right;
-    display: inline-block;
+.mx_SpellCheckLanguages {
+    @mixin mx_Settings_fullWidthField;
 }
diff --git a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss
index 109edfff81..0f879d209e 100644
--- a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss
+++ b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss
@@ -22,3 +22,34 @@ limitations under the License.
 .mx_HelpUserSettingsTab span.mx_AccessibleButton {
     word-break: break-word;
 }
+
+.mx_HelpUserSettingsTab code {
+    word-break: break-all;
+    user-select: all;
+}
+
+.mx_HelpUserSettingsTab_accessToken {
+    display: flex;
+    justify-content: space-between;
+    border-radius: 5px;
+    border: solid 1px $light-fg-color;
+    margin-bottom: 10px;
+    margin-top: 10px;
+    padding: 10px;
+}
+
+.mx_HelpUserSettingsTab_accessToken_copy {
+    flex-shrink: 0;
+    cursor: pointer;
+    margin-left: 20px;
+    display: inherit;
+}
+
+.mx_HelpUserSettingsTab_accessToken_copy > div {
+    mask-image: url($copy-button-url);
+    background-color: $message-action-bar-fg-color;
+    margin-left: 5px;
+    width: 20px;
+    height: 20px;
+    background-repeat: no-repeat;
+}
diff --git a/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss b/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss
new file mode 100644
index 0000000000..540db48d65
--- /dev/null
+++ b/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss
@@ -0,0 +1,25 @@
+/*
+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_LabsUserSettingsTab {
+    .mx_SettingsTab_section {
+        margin-top: 32px;
+
+        .mx_SettingsFlag {
+            margin-right: 0; // remove right margin to align with beta cards
+        }
+    }
+}
diff --git a/res/css/views/spaces/_SpaceBasicSettings.scss b/res/css/views/spaces/_SpaceBasicSettings.scss
new file mode 100644
index 0000000000..c73e0715dd
--- /dev/null
+++ b/res/css/views/spaces/_SpaceBasicSettings.scss
@@ -0,0 +1,86 @@
+/*
+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_SpaceBasicSettings {
+    .mx_Field {
+        margin: 24px 0;
+    }
+
+    .mx_SpaceBasicSettings_avatarContainer {
+        display: flex;
+        margin-top: 24px;
+
+        .mx_SpaceBasicSettings_avatar {
+            position: relative;
+            height: 80px;
+            width: 80px;
+            background-color: $tertiary-fg-color;
+            border-radius: 16px;
+        }
+
+        img.mx_SpaceBasicSettings_avatar {
+            width: 80px;
+            height: 80px;
+            object-fit: cover;
+            border-radius: 16px;
+        }
+
+        // only show it when the button is a div and not an img (has avatar)
+        div.mx_SpaceBasicSettings_avatar {
+            cursor: pointer;
+
+            &::before {
+                content: "";
+                position: absolute;
+                height: 80px;
+                width: 80px;
+                top: 0;
+                left: 0;
+                background-color: #ffffff; // white icon fill
+                mask-repeat: no-repeat;
+                mask-position: center;
+                mask-size: 20px;
+                mask-image: url('$(res)/img/element-icons/camera.svg');
+            }
+        }
+
+        > input[type="file"] {
+            display: none;
+        }
+
+        > .mx_AccessibleButton_kind_link {
+            display: inline-block;
+            padding: 0;
+            margin: auto 16px;
+            color: #368bd6;
+        }
+
+        > .mx_SpaceBasicSettings_avatar_remove {
+            color: $notice-primary-color;
+        }
+    }
+
+    .mx_AccessibleButton_hasKind {
+        padding: 8px 22px;
+        margin-left: auto;
+        display: block;
+        width: min-content;
+    }
+
+    .mx_AccessibleButton_disabled {
+        cursor: not-allowed;
+    }
+}
diff --git a/res/css/views/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss
new file mode 100644
index 0000000000..88b9d8f693
--- /dev/null
+++ b/res/css/views/spaces/_SpaceCreateMenu.scss
@@ -0,0 +1,101 @@
+/*
+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.
+*/
+
+$spacePanelWidth: 71px;
+
+.mx_SpaceCreateMenu_wrapper {
+    // background blur everything except SpacePanel
+    .mx_ContextualMenu_background {
+        background-color: $dialog-backdrop-color;
+        opacity: 0.6;
+        left: $spacePanelWidth;
+    }
+
+    .mx_ContextualMenu {
+        padding: 24px;
+        width: 480px;
+        box-sizing: border-box;
+        background-color: $primary-bg-color;
+        position: relative;
+
+        > div {
+            > h2 {
+                font-weight: $font-semi-bold;
+                font-size: $font-18px;
+                margin-top: 4px;
+            }
+
+            > p {
+                font-size: $font-15px;
+                color: $secondary-fg-color;
+                margin: 0;
+            }
+        }
+
+        // XXX remove this when spaces leaves Beta
+        .mx_BetaCard_betaPill {
+            position: absolute;
+            top: 24px;
+            right: 24px;
+        }
+
+        .mx_SpaceCreateMenuType {
+            @mixin SpacePillButton;
+        }
+
+        .mx_SpaceCreateMenuType_public::before {
+            mask-image: url('$(res)/img/globe.svg');
+        }
+        .mx_SpaceCreateMenuType_private::before {
+            mask-image: url('$(res)/img/element-icons/lock.svg');
+        }
+
+        .mx_SpaceCreateMenu_back {
+            width: 28px;
+            height: 28px;
+            position: relative;
+            background-color: $roomlist-button-bg-color;
+            border-radius: 14px;
+            margin-bottom: 12px;
+
+            &::before {
+                content: "";
+                position: absolute;
+                height: 28px;
+                width: 28px;
+                top: 0;
+                left: 0;
+                background-color: $tertiary-fg-color;
+                transform: rotate(90deg);
+                mask-repeat: no-repeat;
+                mask-position: 2px 3px;
+                mask-size: 24px;
+                mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
+            }
+        }
+
+        .mx_AccessibleButton_kind_primary {
+            padding: 8px 22px;
+            margin-left: auto;
+            display: block;
+            width: min-content;
+        }
+
+        .mx_AccessibleButton_disabled {
+            cursor: not-allowed;
+        }
+    }
+}
diff --git a/res/css/views/auth/_ServerConfig.scss b/res/css/views/spaces/_SpacePublicShare.scss
similarity index 57%
rename from res/css/views/auth/_ServerConfig.scss
rename to res/css/views/spaces/_SpacePublicShare.scss
index a7e0057ab3..373fa94e00 100644
--- a/res/css/views/auth/_ServerConfig.scss
+++ b/res/css/views/spaces/_SpacePublicShare.scss
@@ -1,6 +1,5 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+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.
@@ -15,21 +14,16 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_ServerConfig_help:link {
-    opacity: 0.8;
-}
+.mx_SpacePublicShare {
+    .mx_AccessibleButton {
+        @mixin SpacePillButton;
 
-.mx_ServerConfig_error {
-    display: block;
-    color: $warning-color;
-}
+        &.mx_SpacePublicShare_shareButton::before {
+            mask-image: url('$(res)/img/element-icons/link.svg');
+        }
 
-.mx_ServerConfig_identityServer {
-    transform: scaleY(0);
-    transform-origin: top;
-    transition: transform 0.25s;
-
-    &.mx_ServerConfig_identityServer_shown {
-        transform: scaleY(1);
+        &.mx_SpacePublicShare_inviteButton::before {
+            mask-image: url('$(res)/img/element-icons/room/invite.svg');
+        }
     }
 }
diff --git a/res/css/views/avatars/_PulsedAvatar.scss b/res/css/views/toasts/_AnalyticsToast.scss
similarity index 68%
rename from res/css/views/avatars/_PulsedAvatar.scss
rename to res/css/views/toasts/_AnalyticsToast.scss
index ce9e3382ab..fdbe7f1c76 100644
--- a/res/css/views/avatars/_PulsedAvatar.scss
+++ b/res/css/views/toasts/_AnalyticsToast.scss
@@ -14,17 +14,14 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_PulsedAvatar {
-    @keyframes shadow-pulse {
-        0% {
-            box-shadow: 0 0 0 0px rgba($accent-color, 0.2);
-        }
-        100% {
-            box-shadow: 0 0 0 6px rgba($accent-color, 0);
-        }
+.mx_AnalyticsToast {
+    .mx_AccessibleButton_kind_danger {
+        background: none;
+        color: $accent-color;
     }
 
-    img {
-        animation: shadow-pulse 1s infinite;
+    .mx_AccessibleButton_kind_primary {
+        background: $accent-color;
+        color: #ffffff;
     }
 }
diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss
index 759797ae7b..0c09070334 100644
--- a/res/css/views/voip/_CallContainer.scss
+++ b/res/css/views/voip/_CallContainer.scss
@@ -18,10 +18,7 @@ limitations under the License.
     position: absolute;
     right: 20px;
     bottom: 72px;
-    border-radius: 8px;
-    overflow: hidden;
     z-index: 100;
-    box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
 
     // Disable pointer events for Jitsi widgets to function. Direct
     // calls have their own cursor and behaviour, but we need to make
@@ -33,11 +30,11 @@ limitations under the License.
         pointer-events: initial; // restore pointer events so the user can leave/interact
         cursor: pointer;
 
-        .mx_VideoView {
-            width: 350px;
+        .mx_VideoFeed_remote.mx_VideoFeed_voice {
+            min-height: 150px;
         }
 
-        .mx_VideoView_localVideoFeed {
+        .mx_VideoFeed_local {
             border-radius: 8px;
             overflow: hidden;
         }
@@ -49,8 +46,10 @@ limitations under the License.
 
     .mx_IncomingCallBox {
         min-width: 250px;
-        background-color: $primary-bg-color;
+        background-color: $voipcall-plinth-color;
         padding: 8px;
+        box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
+        border-radius: 8px;
 
         pointer-events: initial; // restore pointer events so the user can accept/decline
         cursor: pointer;
@@ -99,5 +98,29 @@ limitations under the License.
                 line-height: $font-24px;
             }
         }
+
+        .mx_IncomingCallBox_iconButton {
+            position: absolute;
+            right: 8px;
+
+            &::before {
+                content: '';
+
+                height: 20px;
+                width: 20px;
+                background-color: $icon-button-color;
+                mask-repeat: no-repeat;
+                mask-size: contain;
+                mask-position: center;
+            }
+        }
+
+        .mx_IncomingCallBox_silence::before {
+            mask-image: url('$(res)/img/voip/silence.svg');
+        }
+
+        .mx_IncomingCallBox_unSilence::before {
+            mask-image: url('$(res)/img/voip/un-silence.svg');
+        }
     }
 }
diff --git a/res/css/views/voip/_CallPreview.scss b/res/css/views/voip/_CallPreview.scss
new file mode 100644
index 0000000000..92348fb465
--- /dev/null
+++ b/res/css/views/voip/_CallPreview.scss
@@ -0,0 +1,21 @@
+/*
+Copyright 2021 Šimon Brandner 
+
+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_CallPreview {
+    position: fixed;
+    left: 0;
+    top: 0;
+}
diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss
index f6f3d40308..205d431752 100644
--- a/res/css/views/voip/_CallView.scss
+++ b/res/css/views/voip/_CallView.scss
@@ -15,80 +15,363 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_CallView_voice {
-    background-color: $accent-color;
-    color: $accent-fg-color;
-    cursor: pointer;
-    padding: 6px;
-    font-weight: bold;
-
+.mx_CallView {
     border-radius: 8px;
-    min-width: 200px;
+    background-color: $dark-panel-bg-color;
+    padding-left: 8px;
+    padding-right: 8px;
+    // XXX: CallContainer sets pointer-events: none - should probably be set back in a better place
+    pointer-events: initial;
+}
 
+.mx_CallView_large {
+    padding-bottom: 10px;
+    margin: 5px 5px 5px 18px;
     display: flex;
-    align-items: center;
+    flex-direction: column;
+    flex: 1;
 
-    img {
-        margin: 4px;
-        margin-right: 10px;
-    }
-
-    > div {
-        display: flex;
-        flex-direction: column;
-        // Hacky vertical align
-        padding-top: 3px;
-    }
-
-    > div > p,
-    > div > h1 {
-        padding: 0;
-        margin: 0;
-        font-size: $font-13px;
-        line-height: $font-15px;
-    }
-
-    > div > p {
-        font-weight: bold;
-    }
-
-    > * {
-        flex-grow: 0;
-        flex-shrink: 0;
+    .mx_CallView_voice {
+        flex: 1;
     }
 }
 
-.mx_CallView_hangup {
+.mx_CallView_pip {
+    width: 320px;
+    padding-bottom: 8px;
+    background-color: $voipcall-plinth-color;
+    box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
+    border-radius: 8px;
+
+    .mx_CallView_voice {
+        height: 180px;
+    }
+
+    .mx_CallView_callControls {
+        bottom: 0px;
+    }
+
+    .mx_CallView_callControls_button {
+        &::before {
+            width: 36px;
+            height: 36px;
+        }
+    }
+
+    .mx_CallView_holdTransferContent {
+        padding-top: 10px;
+        padding-bottom: 25px;
+    }
+}
+
+.mx_CallView_content {
+    position: relative;
+    display: flex;
+    border-radius: 8px;
+}
+
+.mx_CallView_voice {
+    align-items: center;
+    justify-content: center;
+    flex-direction: column;
+    background-color: $inverted-bg-color;
+}
+
+.mx_CallView_voice_avatarsContainer {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: center;
+    div {
+        margin-left: 12px;
+        margin-right: 12px;
+    }
+}
+
+.mx_CallView_voice .mx_CallView_holdTransferContent {
+    // This masks the avatar image so when it's blurred, the edge is still crisp
+    .mx_CallView_voice_avatarContainer {
+        border-radius: 2000px;
+        overflow: hidden;
+        position: relative;
+    }
+}
+
+.mx_CallView_holdTransferContent {
+    height: 20px;
+    padding-top: 20px;
+    padding-bottom: 15px;
+    color: $accent-fg-color;
+    .mx_AccessibleButton_hasKind {
+        padding: 0px;
+        font-weight: bold;
+    }
+}
+
+.mx_CallView_video {
+    width: 100%;
+    height: 100%;
+    z-index: 30;
+    overflow: hidden;
+}
+
+.mx_CallView_video_hold {
+    overflow: hidden;
+
+    // we keep these around in the DOM: it saved wiring them up again when the call
+    // is resumed and keeps the container the right size
+    .mx_VideoFeed {
+        visibility: hidden;
+    }
+}
+
+.mx_CallView_video_holdBackground {
     position: absolute;
+    width: 100%;
+    height: 100%;
+    left: 0;
+    right: 0;
+    background-repeat: no-repeat;
+    background-size: cover;
+    background-position: center;
+    filter: blur(20px);
+    &::after {
+        content: '';
+        display: block;
+        position: absolute;
+        width: 100%;
+        height: 100%;
+        left: 0;
+        right: 0;
+        background-color: rgba(0, 0, 0, 0.6);
+    }
+}
 
-    right: 8px;
-    bottom: 10px;
+.mx_CallView_video .mx_CallView_holdTransferContent {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    font-weight: bold;
+    color: $accent-fg-color;
+    text-align: center;
 
-    height: 35px;
-    width: 35px;
+    &::before {
+        display: block;
+        margin-left: auto;
+        margin-right: auto;
+        content: '';
+        width: 40px;
+        height: 40px;
+        background-image: url('$(res)/img/voip/paused.svg');
+        background-position: center;
+        background-size: cover;
+    }
+    .mx_CallView_pip &::before {
+        width: 30px;
+        height: 30px;
+    }
+    .mx_AccessibleButton_hasKind {
+        padding: 0px;
+    }
+}
 
-    border-radius: 35px;
+.mx_CallView_header {
+    height: 44px;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: left;
+    flex-shrink: 0;
+}
 
-    background-color: $notice-primary-color;
+.mx_CallView_header_callType {
+    font-size: 1.2rem;
+    font-weight: bold;
+    vertical-align: middle;
+}
 
-    z-index: 101;
+.mx_CallView_header_secondaryCallInfo {
+    &::before {
+        content: '·';
+        margin-left: 6px;
+        margin-right: 6px;
+    }
+}
 
+.mx_CallView_header_controls {
+    margin-left: auto;
+}
+
+.mx_CallView_header_button {
+    display: inline-block;
+    vertical-align: middle;
     cursor: pointer;
 
     &::before {
         content: '';
-        position: absolute;
-
+        display: inline-block;
         height: 20px;
         width: 20px;
-
-        top: 6.5px;
-        left: 7.5px;
-
-        mask: url('$(res)/img/hangup.svg');
+        vertical-align: middle;
+        background-color: $secondary-fg-color;
+        mask-repeat: no-repeat;
         mask-size: contain;
-        background-size: contain;
-
-        background-color: $primary-fg-color;
+        mask-position: center;
     }
 }
+
+.mx_CallView_header_button_fullscreen {
+    &::before {
+        mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
+    }
+}
+
+.mx_CallView_header_button_expand {
+    &::before {
+        mask-image: url('$(res)/img/element-icons/call/expand.svg');
+    }
+}
+
+.mx_CallView_header_callInfo {
+    margin-left: 12px;
+    margin-right: 16px;
+}
+
+.mx_CallView_header_roomName {
+    font-weight: bold;
+    font-size: 12px;
+    line-height: initial;
+    height: 15px;
+}
+
+.mx_CallView_secondaryCall_roomName {
+    margin-left: 4px;
+}
+
+.mx_CallView_header_callTypeSmall {
+    font-size: 12px;
+    color: $secondary-fg-color;
+    line-height: initial;
+    height: 15px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    max-width: 240px;
+}
+
+.mx_CallView_header_phoneIcon {
+    display: inline-block;
+    margin-right: 6px;
+    height: 16px;
+    width: 16px;
+    vertical-align: middle;
+
+    &::before {
+        content: '';
+        display: inline-block;
+        vertical-align: top;
+
+        height: 16px;
+        width: 16px;
+        background-color: $warning-color;
+        mask-repeat: no-repeat;
+        mask-size: contain;
+        mask-position: center;
+        mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
+    }
+}
+
+.mx_CallView_callControls {
+    position: absolute;
+    display: flex;
+    justify-content: center;
+    bottom: 5px;
+    width: 100%;
+    opacity: 1;
+    transition: opacity 0.5s;
+}
+
+.mx_CallView_callControls_hidden {
+    opacity: 0.001; // opacity 0 can cause a re-layout
+    pointer-events: none;
+}
+
+.mx_CallView_callControls_button {
+    cursor: pointer;
+    margin-left: 8px;
+    margin-right: 8px;
+
+
+    &::before {
+        content: '';
+        display: inline-block;
+
+        height: 48px;
+        width: 48px;
+
+        background-repeat: no-repeat;
+        background-size: contain;
+        background-position: center;
+    }
+}
+
+.mx_CallView_callControls_dialpad {
+    margin-right: auto;
+    &::before {
+        background-image: url('$(res)/img/voip/dialpad.svg');
+    }
+}
+
+.mx_CallView_callControls_button_dialpad_hidden {
+    margin-right: auto;
+    cursor: initial;
+}
+
+.mx_CallView_callControls_button_micOn {
+    &::before {
+        background-image: url('$(res)/img/voip/mic-on.svg');
+    }
+}
+
+.mx_CallView_callControls_button_micOff {
+    &::before {
+        background-image: url('$(res)/img/voip/mic-off.svg');
+    }
+}
+
+.mx_CallView_callControls_button_vidOn {
+    &::before {
+        background-image: url('$(res)/img/voip/vid-on.svg');
+    }
+}
+
+.mx_CallView_callControls_button_vidOff {
+    &::before {
+        background-image: url('$(res)/img/voip/vid-off.svg');
+    }
+}
+
+.mx_CallView_callControls_button_hangup {
+    &::before {
+        background-image: url('$(res)/img/voip/hangup.svg');
+    }
+}
+
+.mx_CallView_callControls_button_more {
+    margin-left: auto;
+    &::before {
+        background-image: url('$(res)/img/voip/more.svg');
+    }
+}
+
+.mx_CallView_callControls_button_more_hidden {
+    margin-left: auto;
+    cursor: initial;
+}
+
+.mx_CallView_callControls_button_invisible {
+    visibility: hidden;
+    pointer-events: none;
+    position: absolute;
+}
diff --git a/res/css/views/voip/_CallViewForRoom.scss b/res/css/views/voip/_CallViewForRoom.scss
new file mode 100644
index 0000000000..769e00338e
--- /dev/null
+++ b/res/css/views/voip/_CallViewForRoom.scss
@@ -0,0 +1,46 @@
+/*
+Copyright 2021 Šimon Brandner 
+
+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_CallViewForRoom {
+    overflow: hidden;
+
+    .mx_CallViewForRoom_ResizeWrapper {
+        display: flex;
+        margin-bottom: 8px;
+
+        &:hover .mx_CallViewForRoom_ResizeHandle {
+            // Need to use important to override element style attributes
+            // set by re-resizable
+            width: 100% !important;
+
+            display: flex;
+            justify-content: center;
+
+            &::after {
+                content: '';
+                margin-top: 3px;
+
+                border-radius: 4px;
+
+                height: 4px;
+                width: 100%;
+                max-width: 64px;
+
+                background-color: $primary-fg-color;
+            }
+        }
+    }
+}
diff --git a/res/css/views/voip/_DialPad.scss b/res/css/views/voip/_DialPad.scss
new file mode 100644
index 0000000000..eefd2e9ba5
--- /dev/null
+++ b/res/css/views/voip/_DialPad.scss
@@ -0,0 +1,67 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_DialPad {
+    display: grid;
+    row-gap: 16px;
+    column-gap: 0px;
+    margin-top: 24px;
+    margin-left: auto;
+    margin-right: auto;
+
+    /* squeeze the dial pad buttons together horizontally */
+    grid-template-columns: repeat(3, 1fr);
+}
+
+.mx_DialPad_button {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+
+    width: 40px;
+    height: 40px;
+    background-color: $dialpad-button-bg-color;
+    border-radius: 40px;
+    font-size: 18px;
+    font-weight: 600;
+    text-align: center;
+    vertical-align: middle;
+    margin-left: auto;
+    margin-right: auto;
+}
+
+.mx_DialPad_button .mx_DialPad_buttonSubText {
+    font-size: 8px;
+}
+
+.mx_DialPad_dialButton {
+    /* Always show the dial button in the center grid column */
+    grid-column: 2;
+    background-color: $accent-color;
+
+    &::before {
+        content: '';
+        display: inline-block;
+        height: 40px;
+        width: 40px;
+        vertical-align: middle;
+        mask-repeat: no-repeat;
+        mask-size: 20px;
+        mask-position: center;
+        background-color: #FFF; // on all themes
+        mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
+    }
+}
diff --git a/res/css/views/voip/_DialPadContextMenu.scss b/res/css/views/voip/_DialPadContextMenu.scss
new file mode 100644
index 0000000000..0019994e72
--- /dev/null
+++ b/res/css/views/voip/_DialPadContextMenu.scss
@@ -0,0 +1,79 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_DialPadContextMenu_dialPad .mx_DialPad {
+    row-gap: 16px;
+    column-gap: 32px;
+}
+
+.mx_DialPadContextMenuWrapper {
+    padding: 15px;
+}
+
+.mx_DialPadContextMenu_header {
+    border: none;
+    margin-top: 32px;
+    margin-left: 20px;
+    margin-right: 20px;
+
+    /* a separator between the input line and the dial buttons */
+    border-bottom: 1px solid $quaternary-fg-color;
+    transition: border-bottom 0.25s;
+}
+
+.mx_DialPadContextMenu_cancel {
+    float: right;
+    mask: url('$(res)/img/feather-customised/cancel.svg');
+    mask-repeat: no-repeat;
+    mask-position: center;
+    mask-size: cover;
+    width: 14px;
+    height: 14px;
+    background-color: $dialog-close-fg-color;
+    cursor: pointer;
+}
+
+.mx_DialPadContextMenu_header:focus-within {
+    border-bottom: 1px solid $accent-color;
+}
+
+.mx_DialPadContextMenu_title {
+    color: $muted-fg-color;
+    font-size: 12px;
+    font-weight: 600;
+}
+
+.mx_DialPadContextMenu_dialled {
+    height: 1.5em;
+    font-size: 18px;
+    font-weight: 600;
+    border: none;
+    margin: 0px;
+}
+.mx_DialPadContextMenu_dialled input {
+    font-size: 18px;
+    font-weight: 600;
+    overflow: hidden;
+    max-width: 185px;
+    text-align: left;
+    direction: rtl;
+    padding: 8px 0px;
+    background-color: rgb(0, 0, 0, 0);
+}
+
+.mx_DialPadContextMenu_dialPad {
+    margin: 16px;
+}
diff --git a/res/css/views/voip/_DialPadModal.scss b/res/css/views/voip/_DialPadModal.scss
new file mode 100644
index 0000000000..b8042f77ae
--- /dev/null
+++ b/res/css/views/voip/_DialPadModal.scss
@@ -0,0 +1,80 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_Dialog_dialPadWrapper .mx_Dialog {
+    padding: 0px;
+}
+
+.mx_DialPadModal {
+    width: 292px;
+    height: 370px;
+    padding: 16px 0px 0px 0px;
+}
+
+.mx_DialPadModal_header {
+    margin-top: 32px;
+    margin-left: 40px;
+    margin-right: 40px;
+
+    /* a separator between the input line and the dial buttons */
+    border-bottom: 1px solid $quaternary-fg-color;
+    transition: border-bottom 0.25s;
+}
+
+.mx_DialPadModal_header:focus-within {
+    border-bottom: 1px solid $accent-color;
+}
+
+.mx_DialPadModal_title {
+    color: $muted-fg-color;
+    font-size: 12px;
+    font-weight: 600;
+}
+
+.mx_DialPadModal_cancel {
+    float: right;
+    mask: url('$(res)/img/feather-customised/cancel.svg');
+    mask-repeat: no-repeat;
+    mask-position: center;
+    mask-size: cover;
+    width: 14px;
+    height: 14px;
+    background-color: $dialog-close-fg-color;
+    cursor: pointer;
+    margin-right: 16px;
+}
+
+.mx_DialPadModal_field {
+    border: none;
+    margin: 0px;
+    height: 30px;
+}
+
+.mx_DialPadModal_field .mx_Field_postfix {
+    /* Remove border separator between postfix and field content */
+    border-left: none;
+}
+
+.mx_DialPadModal_field input {
+    font-size: 18px;
+    font-weight: 600;
+}
+
+.mx_DialPadModal_dialPad {
+    margin-left: 16px;
+    margin-right: 16px;
+    margin-top: 16px;
+}
diff --git a/res/css/views/voip/_VideoView.scss b/res/css/views/voip/_VideoFeed.scss
similarity index 56%
rename from res/css/views/voip/_VideoView.scss
rename to res/css/views/voip/_VideoFeed.scss
index feb60f4763..4a3fbdf597 100644
--- a/res/css/views/voip/_VideoView.scss
+++ b/res/css/views/voip/_VideoFeed.scss
@@ -1,5 +1,5 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2015, 2016, 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,36 +14,37 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_VideoView {
+.mx_VideoFeed_voice {
+    background-color: $inverted-bg-color;
+}
+
+
+.mx_VideoFeed_remote {
     width: 100%;
-    position: relative;
-    z-index: 30;
-}
-
-.mx_VideoView video {
-    width: 100%;
-}
-
-.mx_VideoView_remoteVideoFeed {
-    width: 100%;
-    background-color: #000;
-    z-index: 50;
-}
-
-.mx_VideoView_localVideoFeed {
-    width: 25%;
-    height: 25%;
-    position: absolute;
-    left: 10px;
-    bottom: 10px;
-    z-index: 100;
-}
-
-.mx_VideoView_localVideoFeed video {
-    width: auto;
     height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    &.mx_VideoFeed_video {
+        background-color: #000;
+    }
 }
 
-.mx_VideoView_localVideoFeed.mx_VideoView_localVideoFeed_flipped video {
+.mx_VideoFeed_local {
+    max-width: 25%;
+    max-height: 25%;
+    position: absolute;
+    right: 10px;
+    top: 10px;
+    z-index: 100;
+    border-radius: 4px;
+
+    &.mx_VideoFeed_video {
+        background-color: transparent;
+    }
+}
+
+.mx_VideoFeed_mirror {
     transform: scale(-1, 1);
 }
diff --git a/res/fonts/Inter/Inter-Bold.woff b/res/fonts/Inter/Inter-Bold.woff
index 61e1c25e64..2ec7ac3d21 100644
Binary files a/res/fonts/Inter/Inter-Bold.woff and b/res/fonts/Inter/Inter-Bold.woff differ
diff --git a/res/fonts/Inter/Inter-Bold.woff2 b/res/fonts/Inter/Inter-Bold.woff2
index 6c401bb09b..6989c99229 100644
Binary files a/res/fonts/Inter/Inter-Bold.woff2 and b/res/fonts/Inter/Inter-Bold.woff2 differ
diff --git a/res/fonts/Inter/Inter-BoldItalic.woff b/res/fonts/Inter/Inter-BoldItalic.woff
index 2de403edd1..aa35b79745 100644
Binary files a/res/fonts/Inter/Inter-BoldItalic.woff and b/res/fonts/Inter/Inter-BoldItalic.woff differ
diff --git a/res/fonts/Inter/Inter-BoldItalic.woff2 b/res/fonts/Inter/Inter-BoldItalic.woff2
index 80efd4848d..18b4c1ce5e 100644
Binary files a/res/fonts/Inter/Inter-BoldItalic.woff2 and b/res/fonts/Inter/Inter-BoldItalic.woff2 differ
diff --git a/res/fonts/Inter/Inter-Italic.woff b/res/fonts/Inter/Inter-Italic.woff
index e7da6663fe..4b765bd592 100644
Binary files a/res/fonts/Inter/Inter-Italic.woff and b/res/fonts/Inter/Inter-Italic.woff differ
diff --git a/res/fonts/Inter/Inter-Italic.woff2 b/res/fonts/Inter/Inter-Italic.woff2
index 8559dfde38..bd5f255a98 100644
Binary files a/res/fonts/Inter/Inter-Italic.woff2 and b/res/fonts/Inter/Inter-Italic.woff2 differ
diff --git a/res/fonts/Inter/Inter-Medium.woff b/res/fonts/Inter/Inter-Medium.woff
index 8c36a6345e..7d55f34cca 100644
Binary files a/res/fonts/Inter/Inter-Medium.woff and b/res/fonts/Inter/Inter-Medium.woff differ
diff --git a/res/fonts/Inter/Inter-Medium.woff2 b/res/fonts/Inter/Inter-Medium.woff2
index 3b31d3350a..a916b47fc8 100644
Binary files a/res/fonts/Inter/Inter-Medium.woff2 and b/res/fonts/Inter/Inter-Medium.woff2 differ
diff --git a/res/fonts/Inter/Inter-MediumItalic.woff b/res/fonts/Inter/Inter-MediumItalic.woff
index fb79e91ff4..422ab0576a 100644
Binary files a/res/fonts/Inter/Inter-MediumItalic.woff and b/res/fonts/Inter/Inter-MediumItalic.woff differ
diff --git a/res/fonts/Inter/Inter-MediumItalic.woff2 b/res/fonts/Inter/Inter-MediumItalic.woff2
index d32c111f9c..f623924aea 100644
Binary files a/res/fonts/Inter/Inter-MediumItalic.woff2 and b/res/fonts/Inter/Inter-MediumItalic.woff2 differ
diff --git a/res/fonts/Inter/Inter-Regular.woff b/res/fonts/Inter/Inter-Regular.woff
index 7d587c40bf..7ff51b7d8f 100644
Binary files a/res/fonts/Inter/Inter-Regular.woff and b/res/fonts/Inter/Inter-Regular.woff differ
diff --git a/res/fonts/Inter/Inter-Regular.woff2 b/res/fonts/Inter/Inter-Regular.woff2
index d5ffd2a1f1..554aed6612 100644
Binary files a/res/fonts/Inter/Inter-Regular.woff2 and b/res/fonts/Inter/Inter-Regular.woff2 differ
diff --git a/res/fonts/Inter/Inter-SemiBold.woff b/res/fonts/Inter/Inter-SemiBold.woff
index 99df06cbee..76e507a515 100644
Binary files a/res/fonts/Inter/Inter-SemiBold.woff and b/res/fonts/Inter/Inter-SemiBold.woff differ
diff --git a/res/fonts/Inter/Inter-SemiBold.woff2 b/res/fonts/Inter/Inter-SemiBold.woff2
index df746af999..9307998993 100644
Binary files a/res/fonts/Inter/Inter-SemiBold.woff2 and b/res/fonts/Inter/Inter-SemiBold.woff2 differ
diff --git a/res/fonts/Inter/Inter-SemiBoldItalic.woff b/res/fonts/Inter/Inter-SemiBoldItalic.woff
index 91e192b9f1..382181212d 100644
Binary files a/res/fonts/Inter/Inter-SemiBoldItalic.woff and b/res/fonts/Inter/Inter-SemiBoldItalic.woff differ
diff --git a/res/fonts/Inter/Inter-SemiBoldItalic.woff2 b/res/fonts/Inter/Inter-SemiBoldItalic.woff2
index ff8774ccb4..f19f5505ec 100644
Binary files a/res/fonts/Inter/Inter-SemiBoldItalic.woff2 and b/res/fonts/Inter/Inter-SemiBoldItalic.woff2 differ
diff --git a/res/img/betas/spaces.png b/res/img/betas/spaces.png
new file mode 100644
index 0000000000..f4cfa90b4e
Binary files /dev/null and b/res/img/betas/spaces.png differ
diff --git a/res/img/cancel-white.svg b/res/img/cancel-white.svg
deleted file mode 100644
index 65e14c2fbc..0000000000
--- a/res/img/cancel-white.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-    
-    Slice 1
-    Created with Sketch.
-    
-    
-        
-    
-
\ No newline at end of file
diff --git a/res/img/element-icons/brands/apple.svg b/res/img/element-icons/brands/apple.svg
new file mode 100644
index 0000000000..308c3c5d5a
--- /dev/null
+++ b/res/img/element-icons/brands/apple.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/brands/element.svg b/res/img/element-icons/brands/element.svg
new file mode 100644
index 0000000000..6861de0955
--- /dev/null
+++ b/res/img/element-icons/brands/element.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/res/img/element-icons/brands/facebook.svg b/res/img/element-icons/brands/facebook.svg
new file mode 100644
index 0000000000..2742785424
--- /dev/null
+++ b/res/img/element-icons/brands/facebook.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/brands/github.svg b/res/img/element-icons/brands/github.svg
new file mode 100644
index 0000000000..503719520b
--- /dev/null
+++ b/res/img/element-icons/brands/github.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/brands/gitlab.svg b/res/img/element-icons/brands/gitlab.svg
new file mode 100644
index 0000000000..df84c41e21
--- /dev/null
+++ b/res/img/element-icons/brands/gitlab.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/brands/google.svg b/res/img/element-icons/brands/google.svg
new file mode 100644
index 0000000000..1b0b19ae5b
--- /dev/null
+++ b/res/img/element-icons/brands/google.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/res/img/element-icons/brands/twitter.svg b/res/img/element-icons/brands/twitter.svg
new file mode 100644
index 0000000000..43eb825a59
--- /dev/null
+++ b/res/img/element-icons/brands/twitter.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/call/delete.svg b/res/img/element-icons/call/delete.svg
new file mode 100644
index 0000000000..133bdad4ca
--- /dev/null
+++ b/res/img/element-icons/call/delete.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/call/dialpad.svg b/res/img/element-icons/call/dialpad.svg
new file mode 100644
index 0000000000..a97e80aa0b
--- /dev/null
+++ b/res/img/element-icons/call/dialpad.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/call/expand.svg b/res/img/element-icons/call/expand.svg
new file mode 100644
index 0000000000..91ef4d8a76
--- /dev/null
+++ b/res/img/element-icons/call/expand.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/call/video-muted.svg b/res/img/element-icons/call/video-muted.svg
deleted file mode 100644
index d2aea71d11..0000000000
--- a/res/img/element-icons/call/video-muted.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/res/img/element-icons/call/voice-muted.svg b/res/img/element-icons/call/voice-muted.svg
deleted file mode 100644
index 32abafb04a..0000000000
--- a/res/img/element-icons/call/voice-muted.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/res/img/element-icons/call/voice-unmuted.svg b/res/img/element-icons/call/voice-unmuted.svg
deleted file mode 100644
index e664080217..0000000000
--- a/res/img/element-icons/call/voice-unmuted.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/res/img/element-icons/camera.svg b/res/img/element-icons/camera.svg
new file mode 100644
index 0000000000..92d1f91dec
--- /dev/null
+++ b/res/img/element-icons/camera.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/chat-bubbles.svg b/res/img/element-icons/chat-bubbles.svg
new file mode 100644
index 0000000000..ac9db61f29
--- /dev/null
+++ b/res/img/element-icons/chat-bubbles.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/circle-sending.svg b/res/img/element-icons/circle-sending.svg
new file mode 100644
index 0000000000..2d15a0f716
--- /dev/null
+++ b/res/img/element-icons/circle-sending.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/circle-sent.svg b/res/img/element-icons/circle-sent.svg
new file mode 100644
index 0000000000..04a00ceff7
--- /dev/null
+++ b/res/img/element-icons/circle-sent.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/img/element-icons/email-prompt.svg b/res/img/element-icons/email-prompt.svg
new file mode 100644
index 0000000000..19b8f82449
--- /dev/null
+++ b/res/img/element-icons/email-prompt.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/expand-space-panel.svg b/res/img/element-icons/expand-space-panel.svg
new file mode 100644
index 0000000000..11232acd58
--- /dev/null
+++ b/res/img/element-icons/expand-space-panel.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/img/element-icons/eye.svg b/res/img/element-icons/eye.svg
new file mode 100644
index 0000000000..0460a6201d
--- /dev/null
+++ b/res/img/element-icons/eye.svg
@@ -0,0 +1,3 @@
+
+    
+
diff --git a/res/img/element-icons/i.svg b/res/img/element-icons/i.svg
new file mode 100644
index 0000000000..6674f1ed8d
--- /dev/null
+++ b/res/img/element-icons/i.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/link.svg b/res/img/element-icons/link.svg
new file mode 100644
index 0000000000..ab3d54b838
--- /dev/null
+++ b/res/img/element-icons/link.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/lock.svg b/res/img/element-icons/lock.svg
new file mode 100644
index 0000000000..06fe52a391
--- /dev/null
+++ b/res/img/element-icons/lock.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/message/chevron-up.svg b/res/img/element-icons/message/chevron-up.svg
new file mode 100644
index 0000000000..4eb5ecc33e
--- /dev/null
+++ b/res/img/element-icons/message/chevron-up.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/message/corner-up-right.svg b/res/img/element-icons/message/corner-up-right.svg
new file mode 100644
index 0000000000..0b8f961b7b
--- /dev/null
+++ b/res/img/element-icons/message/corner-up-right.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/message/fwd.svg b/res/img/element-icons/message/fwd.svg
new file mode 100644
index 0000000000..8bcc70d092
--- /dev/null
+++ b/res/img/element-icons/message/fwd.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/message/link.svg b/res/img/element-icons/message/link.svg
new file mode 100644
index 0000000000..c89dd41c23
--- /dev/null
+++ b/res/img/element-icons/message/link.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/message/repeat.svg b/res/img/element-icons/message/repeat.svg
new file mode 100644
index 0000000000..c7657b08ed
--- /dev/null
+++ b/res/img/element-icons/message/repeat.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/message/share.svg b/res/img/element-icons/message/share.svg
new file mode 100644
index 0000000000..df38c14d63
--- /dev/null
+++ b/res/img/element-icons/message/share.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/pause.svg b/res/img/element-icons/pause.svg
new file mode 100644
index 0000000000..293c0a10d8
--- /dev/null
+++ b/res/img/element-icons/pause.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/img/element-icons/play.svg b/res/img/element-icons/play.svg
new file mode 100644
index 0000000000..339e20b729
--- /dev/null
+++ b/res/img/element-icons/play.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/plus.svg b/res/img/element-icons/plus.svg
new file mode 100644
index 0000000000..ea1972237d
--- /dev/null
+++ b/res/img/element-icons/plus.svg
@@ -0,0 +1,3 @@
+
+    
+
diff --git a/res/img/element-icons/retry.svg b/res/img/element-icons/retry.svg
new file mode 100644
index 0000000000..09448d6458
--- /dev/null
+++ b/res/img/element-icons/retry.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/room/composer/emoji.svg b/res/img/element-icons/room/composer/emoji.svg
index 9613d9edd9..b02cb69364 100644
--- a/res/img/element-icons/room/composer/emoji.svg
+++ b/res/img/element-icons/room/composer/emoji.svg
@@ -1,7 +1,7 @@
 
-
-
-
-
-
+    
+        
+    
+    
+    
 
diff --git a/res/img/element-icons/room/in-call.svg b/res/img/element-icons/room/in-call.svg
deleted file mode 100644
index 0e574faa84..0000000000
--- a/res/img/element-icons/room/in-call.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/res/img/element-icons/room/invite.svg b/res/img/element-icons/room/invite.svg
index 655f9f118a..d2ecb837b2 100644
--- a/res/img/element-icons/room/invite.svg
+++ b/res/img/element-icons/room/invite.svg
@@ -1,3 +1,3 @@
-
+
 
 
diff --git a/res/img/element-icons/room/message-bar/emoji.svg b/res/img/element-icons/room/message-bar/emoji.svg
index 697f656b8a..07fee5b834 100644
--- a/res/img/element-icons/room/message-bar/emoji.svg
+++ b/res/img/element-icons/room/message-bar/emoji.svg
@@ -1,5 +1,3 @@
-
-
-
-
+
+    
 
diff --git a/res/img/element-icons/room/pin.svg b/res/img/element-icons/room/pin.svg
index 16941b329b..2448fc61c5 100644
--- a/res/img/element-icons/room/pin.svg
+++ b/res/img/element-icons/room/pin.svg
@@ -1,7 +1,7 @@
 
-
-
-
-
-
+    
+    
+    
+    
+    
 
diff --git a/res/img/element-icons/roomlist/browse.svg b/res/img/element-icons/roomlist/browse.svg
new file mode 100644
index 0000000000..04714e2881
--- /dev/null
+++ b/res/img/element-icons/roomlist/browse.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/img/element-icons/roomlist/dialpad.svg b/res/img/element-icons/roomlist/dialpad.svg
new file mode 100644
index 0000000000..b51d4a4dc9
--- /dev/null
+++ b/res/img/element-icons/roomlist/dialpad.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/roomlist/hash-circle.svg b/res/img/element-icons/roomlist/hash-circle.svg
new file mode 100644
index 0000000000..924b22cf32
--- /dev/null
+++ b/res/img/element-icons/roomlist/hash-circle.svg
@@ -0,0 +1,7 @@
+
+    
+        
+    
+    
+    
+
diff --git a/res/img/element-icons/roomlist/plus-circle.svg b/res/img/element-icons/roomlist/plus-circle.svg
new file mode 100644
index 0000000000..251ded225c
--- /dev/null
+++ b/res/img/element-icons/roomlist/plus-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/roomlist/skeleton-ui.svg b/res/img/element-icons/roomlist/skeleton-ui.svg
new file mode 100644
index 0000000000..e95692536c
--- /dev/null
+++ b/res/img/element-icons/roomlist/skeleton-ui.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/res/img/element-icons/send-message.svg b/res/img/element-icons/send-message.svg
new file mode 100644
index 0000000000..ce35bf8bc8
--- /dev/null
+++ b/res/img/element-icons/send-message.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/speaker.svg b/res/img/element-icons/speaker.svg
new file mode 100644
index 0000000000..fd811d2cda
--- /dev/null
+++ b/res/img/element-icons/speaker.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/res/img/element-icons/trashcan.svg b/res/img/element-icons/trashcan.svg
new file mode 100644
index 0000000000..4106f0bd60
--- /dev/null
+++ b/res/img/element-icons/trashcan.svg
@@ -0,0 +1,3 @@
+
+    
+
diff --git a/res/img/element-icons/upload.svg b/res/img/element-icons/upload.svg
new file mode 100644
index 0000000000..71ad7ba1cf
--- /dev/null
+++ b/res/img/element-icons/upload.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/img/element-icons/warning-badge.svg b/res/img/element-icons/warning-badge.svg
index ac5991f221..1c8da9aa8e 100644
--- a/res/img/element-icons/warning-badge.svg
+++ b/res/img/element-icons/warning-badge.svg
@@ -1,5 +1,32 @@
-
-
-
-
+
+
+  
+    
+      
+        image/svg+xml
+        
+        
+      
+    
+  
+  
+  
 
diff --git a/res/img/feather-customised/widget/maximise.svg b/res/img/feather-customised/maximise.svg
similarity index 100%
rename from res/img/feather-customised/widget/maximise.svg
rename to res/img/feather-customised/maximise.svg
diff --git a/res/img/feather-customised/widget/minimise.svg b/res/img/feather-customised/minimise.svg
similarity index 100%
rename from res/img/feather-customised/widget/minimise.svg
rename to res/img/feather-customised/minimise.svg
diff --git a/res/img/feather-customised/monitor.svg b/res/img/feather-customised/monitor.svg
deleted file mode 100644
index 231811d5a6..0000000000
--- a/res/img/feather-customised/monitor.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/res/img/feather-customised/smartphone.svg b/res/img/feather-customised/smartphone.svg
deleted file mode 100644
index fde78c82e2..0000000000
--- a/res/img/feather-customised/smartphone.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/res/img/hangup.svg b/res/img/hangup.svg
deleted file mode 100644
index be038d2b30..0000000000
--- a/res/img/hangup.svg
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-    
-    Fill 72 + Path 98
-    Created with Sketch.
-    
-    
-        
-            
-                
-                
-            
-        
-    
-
\ No newline at end of file
diff --git a/res/img/image-view/close.svg b/res/img/image-view/close.svg
new file mode 100644
index 0000000000..d603b7f5cc
--- /dev/null
+++ b/res/img/image-view/close.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/image-view/download.svg b/res/img/image-view/download.svg
new file mode 100644
index 0000000000..c51deed876
--- /dev/null
+++ b/res/img/image-view/download.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/image-view/more.svg b/res/img/image-view/more.svg
new file mode 100644
index 0000000000..4f5fa6f9b9
--- /dev/null
+++ b/res/img/image-view/more.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/image-view/rotate-ccw.svg b/res/img/image-view/rotate-ccw.svg
new file mode 100644
index 0000000000..85ea3198de
--- /dev/null
+++ b/res/img/image-view/rotate-ccw.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/image-view/rotate-cw.svg b/res/img/image-view/rotate-cw.svg
new file mode 100644
index 0000000000..e337f3420e
--- /dev/null
+++ b/res/img/image-view/rotate-cw.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/image-view/zoom-in.svg b/res/img/image-view/zoom-in.svg
new file mode 100644
index 0000000000..c0816d489e
--- /dev/null
+++ b/res/img/image-view/zoom-in.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/image-view/zoom-out.svg b/res/img/image-view/zoom-out.svg
new file mode 100644
index 0000000000..0539e8c81a
--- /dev/null
+++ b/res/img/image-view/zoom-out.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/room-continuation.svg b/res/img/room-continuation.svg
deleted file mode 100644
index dc7e15462a..0000000000
--- a/res/img/room-continuation.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/res/img/rotate-ccw.svg b/res/img/rotate-ccw.svg
deleted file mode 100644
index 3924eca040..0000000000
--- a/res/img/rotate-ccw.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/res/img/rotate-cw.svg b/res/img/rotate-cw.svg
deleted file mode 100644
index 91021c96d8..0000000000
--- a/res/img/rotate-cw.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/res/img/spinner.gif b/res/img/spinner.gif
deleted file mode 100644
index ab4871214b..0000000000
Binary files a/res/img/spinner.gif and /dev/null differ
diff --git a/res/img/spinner.svg b/res/img/spinner.svg
index 08965e982e..c3680f19d2 100644
--- a/res/img/spinner.svg
+++ b/res/img/spinner.svg
@@ -1,141 +1,96 @@
-
-
-    start
-    
-    
-    
-        
-        
-        
-            
-                
-                
-                    
-                    
-                    
-                    
-                    
-                        
-                            
-                            
-                                
-                                
-                                
-                                    
-                                        
-                                            
-                                            
-                                            
-                                            
-                                            
-                                                
-                                                    
-                                                    
-                                                        
-                                                        
-                                                        
-                                                            
-                                                                
-                                                                    
-                                                                    
-                                                                    
-                                                                    
-                                                                    
-                                                                        
-                                                                            
-                                                                                
-                                                                            
-
-                                                                        
-                                                                    
-                                                                
-                                                            
-                                                        
-                                                    
-                                                    
-                                                    
-                                                        
-                                                        
-                                                        
-                                                            
-                                                                
-                                                                    
-                                                                    
-                                                                    
-                                                                    
-                                                                    
-                                                                        
-                                                                            
-                                                                                
-                                                                            
-
-                                                                        
-                                                                    
-                                                                
-                                                            
-                                                        
-                                                    
-                                                    
-                                                    
-                                                        
-                                                        
-                                                        
-                                                            
-                                                                
-                                                                    
-                                                                    
-                                                                    
-                                                                    
-                                                                    
-                                                                        
-                                                                            
-                                                                                
-                                                                            
-
-                                                                        
-                                                                    
-                                                                
-                                                            
-                                                        
-                                                    
-                                                    
-                                                    
-                                                        
-                                                        
-                                                        
-                                                            
-                                                                
-                                                                    
-                                                                    
-                                                                    
-                                                                    
-                                                                    
-                                                                        
-                                                                            
-                                                                                
-                                                                            
-
-                                                                        
-                                                                    
-                                                                
-                                                            
-                                                        
-                                                    
-
-                                                
-                                            
-                                        
-                                    
-                                
-                            
-
-                        
-                    
-                
-            
-        
-    
-
-
+
+
+  
+  
+    
+      
+        image/svg+xml
+        
+        
+      
+    
+  
+  
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+  
 
diff --git a/res/img/upload-big.svg b/res/img/upload-big.svg
index 6099c2e976..9a6a265fdb 100644
--- a/res/img/upload-big.svg
+++ b/res/img/upload-big.svg
@@ -1,19 +1,3 @@
-
-
-    
-    icons_upload_drop
-    Created with bin/sketchtool.
-    
-    
-        
-            
-                
-                    
-                    
-                    
-                
-                
-            
-        
-    
+
+
 
diff --git a/res/img/voip/dialpad.svg b/res/img/voip/dialpad.svg
new file mode 100644
index 0000000000..79c9ba1612
--- /dev/null
+++ b/res/img/voip/dialpad.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/hangup.svg b/res/img/voip/hangup.svg
new file mode 100644
index 0000000000..dfb20bd519
--- /dev/null
+++ b/res/img/voip/hangup.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/mic-off.svg b/res/img/voip/mic-off.svg
new file mode 100644
index 0000000000..6409f1fd07
--- /dev/null
+++ b/res/img/voip/mic-off.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/mic-on-mask.svg b/res/img/voip/mic-on-mask.svg
new file mode 100644
index 0000000000..418316b164
--- /dev/null
+++ b/res/img/voip/mic-on-mask.svg
@@ -0,0 +1,3 @@
+
+    
+
diff --git a/res/img/voip/mic-on.svg b/res/img/voip/mic-on.svg
new file mode 100644
index 0000000000..3493b3c581
--- /dev/null
+++ b/res/img/voip/mic-on.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/more.svg b/res/img/voip/more.svg
new file mode 100644
index 0000000000..7990f6bcff
--- /dev/null
+++ b/res/img/voip/more.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/paused.svg b/res/img/voip/paused.svg
new file mode 100644
index 0000000000..a967bf8ddf
--- /dev/null
+++ b/res/img/voip/paused.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/silence.svg b/res/img/voip/silence.svg
new file mode 100644
index 0000000000..332932dfff
--- /dev/null
+++ b/res/img/voip/silence.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/tab-dialpad.svg b/res/img/voip/tab-dialpad.svg
new file mode 100644
index 0000000000..b7add0addb
--- /dev/null
+++ b/res/img/voip/tab-dialpad.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/tab-userdirectory.svg b/res/img/voip/tab-userdirectory.svg
new file mode 100644
index 0000000000..792ded7be4
--- /dev/null
+++ b/res/img/voip/tab-userdirectory.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/res/img/voip/un-silence.svg b/res/img/voip/un-silence.svg
new file mode 100644
index 0000000000..c00b366f84
--- /dev/null
+++ b/res/img/voip/un-silence.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/img/voip/vid-off.svg b/res/img/voip/vid-off.svg
new file mode 100644
index 0000000000..199d97ab97
--- /dev/null
+++ b/res/img/voip/vid-off.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/vid-on.svg b/res/img/voip/vid-on.svg
new file mode 100644
index 0000000000..d8146d01d3
--- /dev/null
+++ b/res/img/voip/vid-on.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss
index bd709473ef..74b33fbd02 100644
--- a/res/themes/dark/css/_dark.scss
+++ b/res/themes/dark/css/_dark.scss
@@ -9,6 +9,7 @@ $header-panel-text-primary-color: #B9BEC6;
 $header-panel-text-secondary-color: #c8c8cd;
 $text-primary-color: #ffffff;
 $text-secondary-color: #B9BEC6;
+$quaternary-fg-color: #6F7882;
 $search-bg-color: #181b21;
 $search-placeholder-color: #61708b;
 $room-highlight-color: #343a46;
@@ -63,6 +64,8 @@ $input-invalid-border-color: $warning-color;
 
 $field-focused-label-bg-color: $bg-color;
 
+$resend-button-divider-color: #b9bec64a; // muted-text with a 4A opacity.
+
 // scrollbars
 $scrollbar-thumb-color: rgba(255, 255, 255, 0.2);
 $scrollbar-track-color: transparent;
@@ -85,6 +88,7 @@ $dialog-close-fg-color: #9fa9ba;
 
 $dialog-background-bg-color: $header-panel-bg-color;
 $lightbox-background-bg-color: #000;
+$lightbox-background-bg-opacity: 0.85;
 
 $settings-grey-fg-color: #a2a2a2;
 $settings-profile-placeholder-bg-color: #21262c;
@@ -108,15 +112,20 @@ $eventtile-meta-color: $roomtopic-color;
 $header-divider-color: $header-panel-text-primary-color;
 $composer-e2e-icon-color: $header-panel-text-primary-color;
 
+// this probably shouldn't have it's own colour
+$voipcall-plinth-color: #394049;
+
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
+$dialpad-button-bg-color: #394049;
 
 $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
 $roomlist-filter-active-bg-color: $bg-color;
 $roomlist-bg-color: rgba(33, 38, 44, 0.90);
 $roomlist-header-color: $tertiary-fg-color;
 $roomsublist-divider-color: $primary-fg-color;
+$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%);
 
 $groupFilterPanel-divider-color: $roomlist-header-color;
 
@@ -133,9 +142,6 @@ $panel-divider-color: transparent;
 $widget-menu-bar-bg-color: $header-panel-bg-color;
 $widget-body-bg-color: rgba(141, 151, 165, 0.2);
 
-// event tile lifecycle
-$event-sending-color: $text-secondary-color;
-
 // event redaction
 $event-redacted-fg-color: #606060;
 $event-redacted-border-color: #000000;
@@ -168,6 +174,9 @@ $button-link-bg-color: transparent;
 // Toggle switch
 $togglesw-off-color: $room-highlight-color;
 
+$progressbar-fg-color: $accent-color;
+$progressbar-bg-color: #21262c;
+
 $visual-bell-bg-color: #800;
 
 $room-warning-bg-color: $header-panel-bg-color;
@@ -198,6 +207,17 @@ $breadcrumb-placeholder-bg-color: #272c35;
 
 $user-tile-hover-bg-color: $header-panel-bg-color;
 
+$message-body-panel-fg-color: $secondary-fg-color;
+$message-body-panel-bg-color: #394049; // "Dark Tile"
+$message-body-panel-icon-fg-color: #21262C; // "Separator"
+$message-body-panel-icon-bg-color: $tertiary-fg-color;
+
+$voice-record-stop-border-color: $quaternary-fg-color;
+$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
+$voice-record-icon-color: $quaternary-fg-color;
+$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
+$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
+
 // Appearance tab colors
 $appearance-tab-border-color: $room-highlight-color;
 
@@ -213,7 +233,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
     /* align images in buttons (eg spinners) */
     vertical-align: middle;
     border: 0px;
-    border-radius: 4px;
+    border-radius: 8px;
     font-family: $font-family;
     font-size: $font-14px;
     color: $button-fg-color;
@@ -234,7 +254,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
 @define-mixin mx_DialogButton_secondary {
     // flip colours for the secondary ones
     font-weight: 600;
-    border: 1px solid $accent-color ! important;
+    border: 1px solid $accent-color !important;
     color: $accent-color;
     background-color: $button-secondary-bg-color;
 }
@@ -252,18 +272,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
 }
 
 // markdown overrides:
-.mx_EventTile_content .markdown-body pre:hover {
-    border-color: #808080 !important; // inverted due to rules below
-}
 .mx_EventTile_content .markdown-body {
-    pre, code {
-        filter: invert(1);
-    }
-
-    pre code {
-        filter: none;
-    }
-
     table {
         tr {
             background-color: #000000;
@@ -275,12 +284,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
     }
 }
 
-// diff highlight colors
-// intentionally swapped to avoid inversion
-.hljs-addition {
-    background: #fdd;
-}
-
-.hljs-deletion {
-    background: #dfd;
+// highlight.js overrides
+.hljs-tag {
+    color: inherit; // Without this they'd be weirdly blue which doesn't match the theme
 }
diff --git a/res/themes/dark/css/dark.scss b/res/themes/dark/css/dark.scss
index f9695018e4..600cfd528a 100644
--- a/res/themes/dark/css/dark.scss
+++ b/res/themes/dark/css/dark.scss
@@ -9,3 +9,4 @@
 @import "_dark.scss";
 @import "../../light/css/_mods.scss";
 @import "../../../../res/css/_components.scss";
+@import url("highlight.js/styles/atom-one-dark.css");
diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss
index 60d44b1c31..555ef4f66c 100644
--- a/res/themes/legacy-dark/css/_legacy-dark.scss
+++ b/res/themes/legacy-dark/css/_legacy-dark.scss
@@ -20,6 +20,9 @@ $tertiary-fg-color: $primary-fg-color;
 $primary-bg-color: $bg-color;
 $muted-fg-color: $header-panel-text-primary-color;
 
+// Legacy theme backports
+$quaternary-fg-color: #6F7882;
+
 // used for dialog box text
 $light-fg-color: $header-panel-text-secondary-color;
 
@@ -61,6 +64,8 @@ $input-invalid-border-color: $warning-color;
 
 $field-focused-label-bg-color: $bg-color;
 
+$resend-button-divider-color: $muted-fg-color;
+
 // scrollbars
 $scrollbar-thumb-color: rgba(255, 255, 255, 0.2);
 $scrollbar-track-color: transparent;
@@ -83,6 +88,7 @@ $dialog-close-fg-color: #9fa9ba;
 
 $dialog-background-bg-color: $header-panel-bg-color;
 $lightbox-background-bg-color: #000;
+$lightbox-background-bg-opacity: 0.85;
 
 $settings-grey-fg-color: #a2a2a2;
 $settings-profile-placeholder-bg-color: #e7e7e7;
@@ -105,15 +111,21 @@ $eventtile-meta-color: $roomtopic-color;
 $header-divider-color: $header-panel-text-primary-color;
 $composer-e2e-icon-color: $header-panel-text-primary-color;
 
+// this probably shouldn't have it's own colour
+$voipcall-plinth-color: #394049;
+
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
+$dialpad-button-bg-color: #6F7882;
+
 
 $roomlist-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons
 $roomlist-filter-active-bg-color: $roomlist-button-bg-color;
 $roomlist-bg-color: $header-panel-bg-color;
 
 $roomsublist-divider-color: $primary-fg-color;
+$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%);
 
 $groupFilterPanel-divider-color: $roomlist-header-color;
 
@@ -128,9 +140,6 @@ $panel-divider-color: $header-panel-border-color;
 $widget-menu-bar-bg-color: $header-panel-bg-color;
 $widget-body-bg-color: #1A1D23;
 
-// event tile lifecycle
-$event-sending-color: $text-secondary-color;
-
 // event redaction
 $event-redacted-fg-color: #606060;
 $event-redacted-border-color: #000000;
@@ -163,6 +172,9 @@ $button-link-bg-color: transparent;
 // Toggle switch
 $togglesw-off-color: $room-highlight-color;
 
+$progressbar-fg-color: $accent-color;
+$progressbar-bg-color: #21262c;
+
 $visual-bell-bg-color: #800;
 
 $room-warning-bg-color: $header-panel-bg-color;
@@ -193,6 +205,18 @@ $breadcrumb-placeholder-bg-color: #272c35;
 
 $user-tile-hover-bg-color: $header-panel-bg-color;
 
+$message-body-panel-fg-color: $secondary-fg-color;
+$message-body-panel-bg-color: #394049;
+$message-body-panel-icon-fg-color: $primary-bg-color;
+$message-body-panel-icon-bg-color: $secondary-fg-color;
+
+// See non-legacy dark for variable information
+$voice-record-stop-border-color: #6F7882;
+$voice-record-waveform-incomplete-fg-color: #6F7882;
+$voice-record-icon-color: #6F7882;
+$voice-playback-button-bg-color: $tertiary-fg-color;
+$voice-playback-button-fg-color: #21262C;
+
 // Appearance tab colors
 $appearance-tab-border-color: $room-highlight-color;
 
@@ -204,7 +228,7 @@ $composer-shadow-color: tranparent;
     /* align images in buttons (eg spinners) */
     vertical-align: middle;
     border: 0px;
-    border-radius: 4px;
+    border-radius: 8px;
     font-family: $font-family;
     font-size: $font-14px;
     color: $button-fg-color;
@@ -225,7 +249,7 @@ $composer-shadow-color: tranparent;
 @define-mixin mx_DialogButton_secondary {
     // flip colours for the secondary ones
     font-weight: 600;
-    border: 1px solid $accent-color ! important;
+    border: 1px solid $accent-color !important;
     color: $accent-color;
     background-color: $button-secondary-bg-color;
 }
@@ -243,18 +267,7 @@ $composer-shadow-color: tranparent;
 }
 
 // markdown overrides:
-.mx_EventTile_content .markdown-body pre:hover {
-    border-color: #808080 !important; // inverted due to rules below
-}
 .mx_EventTile_content .markdown-body {
-    pre, code {
-        filter: invert(1);
-    }
-
-    pre code {
-        filter: none;
-    }
-
     table {
         tr {
             background-color: #000000;
@@ -266,12 +279,7 @@ $composer-shadow-color: tranparent;
     }
 }
 
-// diff highlight colors
-// intentionally swapped to avoid inversion
-.hljs-addition {
-    background: #fdd;
-}
-
-.hljs-deletion {
-    background: #dfd;
+// highlight.js overrides:
+.hljs-tag {
+    color: inherit; // Without this they'd be weirdly blue which doesn't match the theme
 }
diff --git a/res/themes/legacy-dark/css/legacy-dark.scss b/res/themes/legacy-dark/css/legacy-dark.scss
index 2a4d432d26..840794f7c0 100644
--- a/res/themes/legacy-dark/css/legacy-dark.scss
+++ b/res/themes/legacy-dark/css/legacy-dark.scss
@@ -4,3 +4,4 @@
 @import "../../legacy-light/css/_legacy-light.scss";
 @import "_legacy-dark.scss";
 @import "../../../../res/css/_components.scss";
+@import url("highlight.js/styles/atom-one-dark.css");
diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss
index 52fb1c8ef2..c7debcdabe 100644
--- a/res/themes/legacy-light/css/_legacy-light.scss
+++ b/res/themes/legacy-light/css/_legacy-light.scss
@@ -28,6 +28,9 @@ $tertiary-fg-color: $primary-fg-color;
 $primary-bg-color: #ffffff;
 $muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
 
+// Legacy theme backports
+$quaternary-fg-color: #C1C6CD;
+
 // used for dialog box text
 $light-fg-color: #747474;
 
@@ -97,6 +100,8 @@ $input-invalid-border-color: $warning-color;
 
 $field-focused-label-bg-color: #ffffff;
 
+$resend-button-divider-color: $input-darker-bg-color;
+
 $button-bg-color: $accent-color;
 $button-fg-color: white;
 
@@ -127,6 +132,7 @@ $dialog-close-fg-color: #c1c1c1;
 
 $dialog-background-bg-color: #e9e9e9;
 $lightbox-background-bg-color: #000;
+$lightbox-background-bg-opacity: 0.95;
 
 $imagebody-giflabel: rgba(0, 0, 0, 0.7);
 $imagebody-giflabel-border: rgba(0, 0, 0, 0.2);
@@ -172,15 +178,21 @@ $eventtile-meta-color: $roomtopic-color;
 $composer-e2e-icon-color: #91a1c0;
 $header-divider-color: #91a1c0;
 
+// this probably shouldn't have it's own colour
+$voipcall-plinth-color: #F4F6FA;
+
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
+$dialpad-button-bg-color: #e3e8f0;
+
 
 $roomlist-button-bg-color: #fff; // Buttons include the filter box, explore button, and sublist buttons
 $roomlist-filter-active-bg-color: $roomlist-button-bg-color;
 $roomlist-bg-color: $header-panel-bg-color;
 $roomlist-header-color: $primary-fg-color;
 $roomsublist-divider-color: $primary-fg-color;
+$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%);
 
 $groupFilterPanel-divider-color: $roomlist-header-color;
 
@@ -218,8 +230,6 @@ $widget-body-bg-color: #fff;
 $yellow-background: #fff8e3;
 
 // event tile lifecycle
-$event-encrypting-color: #abddbc;
-$event-sending-color: #ddd;
 $event-notsent-color: #f44;
 
 $event-highlight-fg-color: $warning-color;
@@ -233,7 +243,8 @@ $event-redacted-border-color: #cccccc;
 $event-timestamp-color: #acacac;
 
 $copy-button-url: "$(res)/img/feather-customised/clipboard.svg";
-
+$collapse-button-url: "$(res)/img/feather-customised/minimise.svg";
+$expand-button-url: "$(res)/img/feather-customised/maximise.svg";
 
 // e2e
 $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
@@ -276,7 +287,8 @@ $togglesw-ball-color: #fff;
 $slider-selection-color: $accent-color;
 $slider-background-color: #c1c9d6;
 
-$progressbar-color: #000;
+$progressbar-fg-color: $accent-color;
+$progressbar-bg-color: rgba(141, 151, 165, 0.2);
 
 $room-warning-bg-color: $yellow-background;
 
@@ -316,6 +328,20 @@ $breadcrumb-placeholder-bg-color: #e8eef5;
 
 $user-tile-hover-bg-color: $header-panel-bg-color;
 
+$message-body-panel-fg-color: $secondary-fg-color;
+$message-body-panel-bg-color: #E3E8F0;
+$message-body-panel-icon-fg-color: $secondary-fg-color;
+$message-body-panel-icon-bg-color: $primary-bg-color;
+
+// See non-legacy _light for variable information
+$voice-record-stop-symbol-color: #ff4b55;
+$voice-record-live-circle-color: #ff4b55;
+$voice-record-stop-border-color: #E3E8F0;
+$voice-record-waveform-incomplete-fg-color: #C1C6CD;
+$voice-record-icon-color: $tertiary-fg-color;
+$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
+$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
+
 // FontSlider colors
 $appearance-tab-border-color: $input-darker-bg-color;
 
@@ -327,7 +353,7 @@ $composer-shadow-color: tranparent;
     /* align images in buttons (eg spinners) */
     vertical-align: middle;
     border: 0px;
-    border-radius: 4px;
+    border-radius: 8px;
     font-family: $font-family;
     font-size: $font-14px;
     color: $button-fg-color;
diff --git a/res/themes/legacy-light/css/legacy-light.scss b/res/themes/legacy-light/css/legacy-light.scss
index e39a1765f3..347d240fc6 100644
--- a/res/themes/legacy-light/css/legacy-light.scss
+++ b/res/themes/legacy-light/css/legacy-light.scss
@@ -3,3 +3,4 @@
 @import "_fonts.scss";
 @import "_legacy-light.scss";
 @import "../../../../res/css/_components.scss";
+@import url("highlight.js/styles/atom-one-light.css");
diff --git a/res/themes/light/css/_fonts.scss b/res/themes/light/css/_fonts.scss
index ba64830f15..68d9496276 100644
--- a/res/themes/light/css/_fonts.scss
+++ b/res/themes/light/css/_fonts.scss
@@ -15,8 +15,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266
   font-weight: 400;
   font-display: swap;
   unicode-range: $inter-unicode-range;
-  src: url("$(res)/fonts/Inter/Inter-Regular.woff2?v=3.13") format("woff2"),
-       url("$(res)/fonts/Inter/Inter-Regular.woff?v=3.13") format("woff");
+  src: url("$(res)/fonts/Inter/Inter-Regular.woff2?v=3.18") format("woff2"),
+       url("$(res)/fonts/Inter/Inter-Regular.woff?v=3.18") format("woff");
 }
 @font-face {
   font-family: 'Inter';
@@ -24,8 +24,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266
   font-weight: 400;
   font-display: swap;
   unicode-range: $inter-unicode-range;
-  src: url("$(res)/fonts/Inter/Inter-Italic.woff2?v=3.13") format("woff2"),
-       url("$(res)/fonts/Inter/Inter-Italic.woff?v=3.13") format("woff");
+  src: url("$(res)/fonts/Inter/Inter-Italic.woff2?v=3.18") format("woff2"),
+       url("$(res)/fonts/Inter/Inter-Italic.woff?v=3.18") format("woff");
 }
 
 @font-face {
@@ -34,8 +34,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266
   font-weight: 500;
   font-display: swap;
   unicode-range: $inter-unicode-range;
-  src: url("$(res)/fonts/Inter/Inter-Medium.woff2?v=3.13") format("woff2"),
-       url("$(res)/fonts/Inter/Inter-Medium.woff?v=3.13") format("woff");
+  src: url("$(res)/fonts/Inter/Inter-Medium.woff2?v=3.18") format("woff2"),
+       url("$(res)/fonts/Inter/Inter-Medium.woff?v=3.18") format("woff");
 }
 @font-face {
   font-family: 'Inter';
@@ -43,8 +43,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266
   font-weight: 500;
   font-display: swap;
   unicode-range: $inter-unicode-range;
-  src: url("$(res)/fonts/Inter/Inter-MediumItalic.woff2?v=3.13") format("woff2"),
-       url("$(res)/fonts/Inter/Inter-MediumItalic.woff?v=3.13") format("woff");
+  src: url("$(res)/fonts/Inter/Inter-MediumItalic.woff2?v=3.18") format("woff2"),
+       url("$(res)/fonts/Inter/Inter-MediumItalic.woff?v=3.18") format("woff");
 }
 
 @font-face {
@@ -53,8 +53,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266
   font-weight: 600;
   font-display: swap;
   unicode-range: $inter-unicode-range;
-  src: url("$(res)/fonts/Inter/Inter-SemiBold.woff2?v=3.13") format("woff2"),
-       url("$(res)/fonts/Inter/Inter-SemiBold.woff?v=3.13") format("woff");
+  src: url("$(res)/fonts/Inter/Inter-SemiBold.woff2?v=3.18") format("woff2"),
+       url("$(res)/fonts/Inter/Inter-SemiBold.woff?v=3.18") format("woff");
 }
 @font-face {
   font-family: 'Inter';
@@ -62,8 +62,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266
   font-weight: 600;
   font-display: swap;
   unicode-range: $inter-unicode-range;
-  src: url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff2?v=3.13") format("woff2"),
-       url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff?v=3.13") format("woff");
+  src: url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff2?v=3.18") format("woff2"),
+       url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff?v=3.18") format("woff");
 }
 
 @font-face {
@@ -72,8 +72,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266
   font-weight: 700;
   font-display: swap;
   unicode-range: $inter-unicode-range;
-  src: url("$(res)/fonts/Inter/Inter-Bold.woff2?v=3.13") format("woff2"),
-       url("$(res)/fonts/Inter/Inter-Bold.woff?v=3.13") format("woff");
+  src: url("$(res)/fonts/Inter/Inter-Bold.woff2?v=3.18") format("woff2"),
+       url("$(res)/fonts/Inter/Inter-Bold.woff?v=3.18") format("woff");
 }
 @font-face {
   font-family: 'Inter';
@@ -81,8 +81,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266
   font-weight: 700;
   font-display: swap;
   unicode-range: $inter-unicode-range;
-  src: url("$(res)/fonts/Inter/Inter-BoldItalic.woff2?v=3.13") format("woff2"),
-       url("$(res)/fonts/Inter/Inter-BoldItalic.woff?v=3.13") format("woff");
+  src: url("$(res)/fonts/Inter/Inter-BoldItalic.woff2?v=3.18") format("woff2"),
+       url("$(res)/fonts/Inter/Inter-BoldItalic.woff?v=3.18") format("woff");
 }
 
 /* latin-ext */
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index 0c5e271860..7e958c2af6 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -21,6 +21,7 @@ $notice-primary-bg-color: rgba(255, 75, 85, 0.16);
 $primary-fg-color: #2e2f32;
 $secondary-fg-color: #737D8C;
 $tertiary-fg-color: #8D99A5;
+$quaternary-fg-color: #C1C6CD;
 $header-panel-bg-color: #f3f8fd;
 
 // typical text (dark-on-white in light skin)
@@ -67,9 +68,6 @@ $groupFilterPanel-bg-color: rgba(232, 232, 232, 0.77);
 // used by RoomDirectory permissions
 $plinth-bg-color: $secondary-accent-color;
 
-// used by RoomDropTarget
-$droptarget-bg-color: rgba(255,255,255,0.5);
-
 // used by AddressSelector
 $selected-color: $secondary-accent-color;
 
@@ -94,6 +92,8 @@ $field-focused-label-bg-color: #ffffff;
 $button-bg-color: $accent-color;
 $button-fg-color: white;
 
+$resend-button-divider-color: $input-darker-bg-color;
+
 // apart from login forms, which have stronger border
 $strong-input-border-color: #c7c7c7;
 
@@ -121,6 +121,7 @@ $dialog-close-fg-color: #c1c1c1;
 
 $dialog-background-bg-color: #e9e9e9;
 $lightbox-background-bg-color: #000;
+$lightbox-background-bg-opacity: 0.95;
 
 $imagebody-giflabel: rgba(0, 0, 0, 0.7);
 $imagebody-giflabel-border: rgba(0, 0, 0, 0.2);
@@ -166,15 +167,21 @@ $eventtile-meta-color: $roomtopic-color;
 $composer-e2e-icon-color: #91A1C0;
 $header-divider-color: #91A1C0;
 
+// this probably shouldn't have it's own colour
+$voipcall-plinth-color: #F4F6FA;
+
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
+$dialpad-button-bg-color: #e3e8f0;
+
 
 $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
 $roomlist-filter-active-bg-color: #ffffff;
 $roomlist-bg-color: rgba(245, 245, 245, 0.90);
 $roomlist-header-color: $tertiary-fg-color;
 $roomsublist-divider-color: $primary-fg-color;
+$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%);
 
 $groupFilterPanel-divider-color: $roomlist-header-color;
 
@@ -218,8 +225,6 @@ $widget-body-bg-color: #FFF;
 $yellow-background: #fff8e3;
 
 // event tile lifecycle
-$event-encrypting-color: #abddbc;
-$event-sending-color: #ddd;
 $event-notsent-color: #f44;
 
 $event-highlight-fg-color: $warning-color;
@@ -233,6 +238,8 @@ $event-redacted-border-color: #cccccc;
 $event-timestamp-color: #acacac;
 
 $copy-button-url: "$(res)/img/feather-customised/clipboard.svg";
+$collapse-button-url: "$(res)/img/feather-customised/minimise.svg";
+$expand-button-url: "$(res)/img/feather-customised/maximise.svg";
 
 // e2e
 $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
@@ -275,7 +282,8 @@ $togglesw-ball-color: #fff;
 $slider-selection-color: $accent-color;
 $slider-background-color: #c1c9d6;
 
-$progressbar-color: #000;
+$progressbar-fg-color: $accent-color;
+$progressbar-bg-color: rgba(141, 151, 165, 0.2);
 
 $room-warning-bg-color: $yellow-background;
 
@@ -316,6 +324,22 @@ $breadcrumb-placeholder-bg-color: #e8eef5;
 
 $user-tile-hover-bg-color: $header-panel-bg-color;
 
+$message-body-panel-fg-color: $secondary-fg-color;
+$message-body-panel-bg-color: #E3E8F0; // "Separator"
+$message-body-panel-icon-fg-color: $secondary-fg-color;
+$message-body-panel-icon-bg-color: $primary-bg-color;
+
+// These two don't change between themes. They are the $warning-color, but we don't
+// want custom themes to affect them by accident.
+$voice-record-stop-symbol-color: #ff4b55;
+$voice-record-live-circle-color: #ff4b55;
+
+$voice-record-stop-border-color: #E3E8F0; // "Separator"
+$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
+$voice-record-icon-color: $tertiary-fg-color;
+$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
+$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
+
 // FontSlider colors
 $appearance-tab-border-color: $input-darker-bg-color;
 
@@ -331,7 +355,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.04);
     /* align images in buttons (eg spinners) */
     vertical-align: middle;
     border: 0px;
-    border-radius: 4px;
+    border-radius: 8px;
     font-family: $font-family;
     font-size: $font-14px;
     color: $button-fg-color;
diff --git a/res/themes/light/css/_mods.scss b/res/themes/light/css/_mods.scss
index 30aaeedf8f..fbca58dfb1 100644
--- a/res/themes/light/css/_mods.scss
+++ b/res/themes/light/css/_mods.scss
@@ -16,6 +16,10 @@
         backdrop-filter: blur($groupFilterPanel-background-blur-amount);
     }
 
+    .mx_SpacePanel {
+        backdrop-filter: blur($groupFilterPanel-background-blur-amount);
+    }
+
     .mx_LeftPanel .mx_LeftPanel_roomListContainer {
         backdrop-filter: blur($roomlist-background-blur-amount);
     }
diff --git a/res/themes/light/css/light.scss b/res/themes/light/css/light.scss
index f31ce5c139..4e912bc756 100644
--- a/res/themes/light/css/light.scss
+++ b/res/themes/light/css/light.scss
@@ -4,3 +4,4 @@
 @import "_light.scss";
 @import "_mods.scss";
 @import "../../../../res/css/_components.scss";
+@import url("highlight.js/styles/atom-one-light.css");
diff --git a/scripts/ci/Dockerfile b/scripts/ci/Dockerfile
index c153d11cc7..6d33987d8c 100644
--- a/scripts/ci/Dockerfile
+++ b/scripts/ci/Dockerfile
@@ -1,9 +1,8 @@
 # Update on docker hub with the following commands in the directory of this file:
-# docker build -t matrixdotorg/riotweb-ci-e2etests-env:latest .
-# docker log
-# docker push matrixdotorg/riotweb-ci-e2etests-env:latest
-FROM node:10
+# docker build -t vectorim/element-web-ci-e2etests-env:latest .
+# docker push vectorim/element-web-ci-e2etests-env:latest
+FROM node:14-buster
 RUN apt-get update
-RUN apt-get -y install build-essential python3-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev uuid-runtime
+RUN apt-get -y install jq build-essential python3-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev uuid-runtime
 # dependencies for chrome (installed by puppeteer)
-RUN apt-get -y install gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget
+RUN apt-get -y install gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm-dev libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget
diff --git a/scripts/ci/riot-unit-tests.sh b/scripts/ci/app-tests.sh
similarity index 56%
rename from scripts/ci/riot-unit-tests.sh
rename to scripts/ci/app-tests.sh
index 337c0fe6c3..97e54dce66 100755
--- a/scripts/ci/riot-unit-tests.sh
+++ b/scripts/ci/app-tests.sh
@@ -2,11 +2,11 @@
 #
 # script which is run by the CI build (after `yarn test`).
 #
-# clones riot-web develop and runs the tests against our version of react-sdk.
+# clones element-web develop and runs the tests against our version of react-sdk.
 
 set -ev
 
-scripts/ci/layered-riot-web.sh
-cd ../riot-web
+scripts/ci/layered.sh
+cd element-web
 yarn build:genfiles # so the tests can run. Faster version of `build`
 yarn test
diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/end-to-end-tests.sh
deleted file mode 100755
index 7a62c03b12..0000000000
--- a/scripts/ci/end-to-end-tests.sh
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/bin/bash
-#
-# script which is run by the CI build (after `yarn test`).
-#
-# clones riot-web develop and runs the tests against our version of react-sdk.
-
-set -ev
-
-handle_error() {
-    EXIT_CODE=$?
-    exit $EXIT_CODE
-}
-
-trap 'handle_error' ERR
-
-echo "--- Building Element"
-scripts/ci/layered-riot-web.sh
-cd ../riot-web
-riot_web_dir=`pwd`
-CI_PACKAGE=true yarn build
-cd ../matrix-react-sdk
-# run end to end tests
-pushd test/end-to-end-tests
-ln -s $riot_web_dir riot/riot-web
-# PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh
-# CHROME_PATH=$(which google-chrome-stable) ./run.sh
-echo "--- Install synapse & other dependencies"
-./install.sh
-# install static webserver to server symlinked local copy of riot
-./riot/install-webserver.sh
-rm -r logs || true
-mkdir logs
-echo "+++ Running end-to-end tests"
-TESTS_STARTED=1
-./run.sh --no-sandbox --log-directory logs/
-popd
diff --git a/scripts/ci/install-deps.sh b/scripts/ci/install-deps.sh
index 14b5fc5393..fcbf6b1198 100755
--- a/scripts/ci/install-deps.sh
+++ b/scripts/ci/install-deps.sh
@@ -6,9 +6,8 @@ scripts/fetchdep.sh matrix-org matrix-js-sdk
 
 pushd matrix-js-sdk
 yarn link
-yarn install $@
-yarn build
+yarn install --pure-lockfile $@
 popd
 
 yarn link matrix-js-sdk
-yarn install $@
+yarn install --pure-lockfile $@
diff --git a/scripts/ci/layered-riot-web.sh b/scripts/ci/layered-riot-web.sh
deleted file mode 100755
index f58794b451..0000000000
--- a/scripts/ci/layered-riot-web.sh
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/bin/bash
-
-# Creates an environment similar to one that riot-web would expect for
-# development. This means going one directory up (and assuming we're in
-# a directory like /workdir/matrix-react-sdk) and putting riot-web and
-# the js-sdk there.
-
-cd ../  # Assume we're at something like /workdir/matrix-react-sdk
-
-# Set up the js-sdk first
-matrix-react-sdk/scripts/fetchdep.sh matrix-org matrix-js-sdk
-pushd matrix-js-sdk
-yarn link
-yarn install
-popd
-
-# Now set up the react-sdk
-pushd matrix-react-sdk
-yarn link matrix-js-sdk
-yarn link
-yarn install
-popd
-
-# Finally, set up riot-web
-matrix-react-sdk/scripts/fetchdep.sh vector-im riot-web
-pushd riot-web
-yarn link matrix-js-sdk
-yarn link matrix-react-sdk
-yarn install
-yarn build:res
-popd
diff --git a/scripts/ci/layered.sh b/scripts/ci/layered.sh
new file mode 100755
index 0000000000..2e163456fe
--- /dev/null
+++ b/scripts/ci/layered.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+
+# Creates a layered environment with the full repo for the app and SDKs cloned
+# and linked.
+
+# Note that this style is different from the recommended developer setup: this
+# file nests js-sdk and element-web inside react-sdk, while the local
+# development setup places them all at the same level. We are nesting them here
+# because some CI systems do not allow moving to a directory above the checkout
+# for the primary repo (react-sdk in this case).
+
+# Set up the js-sdk first
+scripts/fetchdep.sh matrix-org matrix-js-sdk
+pushd matrix-js-sdk
+yarn link
+yarn install --pure-lockfile
+popd
+
+# Now set up the react-sdk
+yarn link matrix-js-sdk
+yarn link
+yarn install --pure-lockfile
+yarn reskindex
+
+# Finally, set up element-web
+scripts/fetchdep.sh vector-im element-web
+pushd element-web
+yarn link matrix-js-sdk
+yarn link matrix-react-sdk
+yarn install --pure-lockfile
+yarn build:res
+popd
diff --git a/scripts/ci/prepare-end-to-end-tests.sh b/scripts/ci/prepare-end-to-end-tests.sh
new file mode 100755
index 0000000000..147e1f6445
--- /dev/null
+++ b/scripts/ci/prepare-end-to-end-tests.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+set -ev
+
+handle_error() {
+    EXIT_CODE=$?
+    exit $EXIT_CODE
+}
+
+trap 'handle_error' ERR
+
+echo "--- Building Element"
+scripts/ci/layered.sh
+cd element-web
+element_web_dir=`pwd`
+CI_PACKAGE=true yarn build
+cd ..
+# prepare end to end tests
+pushd test/end-to-end-tests
+ln -s $element_web_dir element/element-web
+# PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh
+# CHROME_PATH=$(which google-chrome-stable) ./run.sh
+echo "--- Install synapse & other dependencies"
+./install.sh
+# install static webserver to server symlinked local copy of element
+./element/install-webserver.sh
+popd
diff --git a/scripts/ci/run-end-to-end-tests.sh b/scripts/ci/run-end-to-end-tests.sh
new file mode 100755
index 0000000000..3c99391fc7
--- /dev/null
+++ b/scripts/ci/run-end-to-end-tests.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+set -ev
+
+handle_error() {
+    EXIT_CODE=$?
+    exit $EXIT_CODE
+}
+
+trap 'handle_error' ERR
+
+# run end to end tests
+pushd test/end-to-end-tests
+rm -r logs || true
+mkdir logs
+echo "--- Running end-to-end tests"
+TESTS_STARTED=1
+./run.sh --no-sandbox --log-directory logs/
+popd
diff --git a/scripts/compare-file.js b/scripts/compare-file.js
deleted file mode 100644
index f53275ebfa..0000000000
--- a/scripts/compare-file.js
+++ /dev/null
@@ -1,10 +0,0 @@
-const fs = require("fs");
-
-if (process.argv.length < 4) throw new Error("Missing source and target file arguments");
-
-const sourceFile = fs.readFileSync(process.argv[2], 'utf8');
-const targetFile = fs.readFileSync(process.argv[3], 'utf8');
-
-if (sourceFile !== targetFile) {
-    throw new Error("Files do not match");
-}
diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh
index 0142305797..0990af70ce 100755
--- a/scripts/fetchdep.sh
+++ b/scripts/fetchdep.sh
@@ -22,19 +22,52 @@ clone() {
 }
 
 # Try the PR author's branch in case it exists on the deps as well.
-# If BUILDKITE_BRANCH is set, it will contain either:
-#   * "branch" when the author's branch and target branch are in the same repo
-#   * "author:branch" when the author's branch is in their fork
-# We can split on `:` into an array to check.
-BUILDKITE_BRANCH_ARRAY=(${BUILDKITE_BRANCH//:/ })
-if [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "1" ]]; then
-    clone $deforg $defrepo $BUILDKITE_BRANCH
-elif [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "2" ]]; then
-    clone ${BUILDKITE_BRANCH_ARRAY[0]} $defrepo ${BUILDKITE_BRANCH_ARRAY[1]}
+# First we check if GITHUB_HEAD_REF is defined,
+# Then we check if BUILDKITE_BRANCH is defined,
+# if they aren't we can assume this is a Netlify build
+if [ -n "$GITHUB_HEAD_REF" ]; then
+    head=$GITHUB_HEAD_REF
+elif [ -n "$BUILDKITE_BRANCH" ]; then
+	head=$BUILDKITE_BRANCH
+else
+    # Netlify doesn't give us info about the fork so we have to get it from GitHub API
+    apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/"
+    apiEndpoint+=$REVIEW_ID
+    head=$(curl $apiEndpoint | jq -r '.head.label')
 fi
+
+# If head is set, it will contain on Buildkite either:
+#   * "branch" when the author's branch and target branch are in the same repo
+#   * "fork:branch" when the author's branch is in their fork or if this is a Netlify build
+# We can split on `:` into an array to check.
+# For GitHub Actions we need to inspect GITHUB_REPOSITORY and GITHUB_ACTOR
+# to determine whether the branch is from a fork or not
+BRANCH_ARRAY=(${head//:/ })
+if [[ "${#BRANCH_ARRAY[@]}" == "1" ]]; then
+
+    if [ -n "$GITHUB_HEAD_REF" ]; then
+        if [[ "$GITHUB_REPOSITORY" == "$deforg"* ]]; then
+            clone $deforg $defrepo $GITHUB_HEAD_REF
+        else
+            REPO_ARRAY=(${GITHUB_REPOSITORY//\// })
+            clone $REPO_ARRAY[0] $defrepo $GITHUB_HEAD_REF
+        fi
+    else
+        clone $deforg $defrepo $BUILDKITE_BRANCH
+    fi
+
+elif [[ "${#BRANCH_ARRAY[@]}" == "2" ]]; then
+    clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]}
+fi
+
 # Try the target branch of the push or PR.
-clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH
-# Try the current branch from Jenkins.
-clone $deforg $defrepo `"echo $GIT_BRANCH" | sed -e 's/^origin\///'`
+if [ -n $GITHUB_BASE_REF ]; then
+    clone $deforg $defrepo $GITHUB_BASE_REF
+elif [ -n $BUILDKITE_PULL_REQUEST_BASE_BRANCH ]; then
+    clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH
+fi
+
+# Try HEAD which is the branch name in Netlify (not BRANCH which is pull/xxxx/head for PR builds)
+clone $deforg $defrepo $HEAD
 # Use the default branch as the last resort.
 clone $deforg $defrepo $defbranch
diff --git a/scripts/gen-i18n.js b/scripts/gen-i18n.js
deleted file mode 100755
index 91733469f7..0000000000
--- a/scripts/gen-i18n.js
+++ /dev/null
@@ -1,304 +0,0 @@
-#!/usr/bin/env node
-
-/*
-Copyright 2017 New Vector Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-/**
- * Regenerates the translations en_EN file by walking the source tree and
- * parsing each file with the appropriate parser. Emits a JSON file with the
- * translatable strings mapped to themselves in the order they appeared
- * in the files and grouped by the file they appeared in.
- *
- * Usage: node scripts/gen-i18n.js
- */
-const fs = require('fs');
-const path = require('path');
-
-const walk = require('walk');
-
-const parser = require("@babel/parser");
-const traverse = require("@babel/traverse");
-
-const TRANSLATIONS_FUNCS = ['_t', '_td'];
-
-const INPUT_TRANSLATIONS_FILE = 'src/i18n/strings/en_EN.json';
-const OUTPUT_FILE = 'src/i18n/strings/en_EN.json';
-
-// NB. The sync version of walk is broken for single files so we walk
-// all of res rather than just res/home.html.
-// https://git.daplie.com/Daplie/node-walk/merge_requests/1 fixes it,
-// or if we get bored waiting for it to be merged, we could switch
-// to a project that's actively maintained.
-const SEARCH_PATHS = ['src', 'res'];
-
-function getObjectValue(obj, key) {
-    for (const prop of obj.properties) {
-        if (prop.key.type === 'Identifier' && prop.key.name === key) {
-            return prop.value;
-        }
-    }
-    return null;
-}
-
-function getTKey(arg) {
-    if (arg.type === 'Literal' || arg.type === "StringLiteral") {
-        return arg.value;
-    } else if (arg.type === 'BinaryExpression' && arg.operator === '+') {
-        return getTKey(arg.left) + getTKey(arg.right);
-    } else if (arg.type === 'TemplateLiteral') {
-        return arg.quasis.map((q) => {
-            return q.value.raw;
-        }).join('');
-    }
-    return null;
-}
-
-function getFormatStrings(str) {
-    // Match anything that starts with %
-    // We could make a regex that matched the full placeholder, but this
-    // would just not match invalid placeholders and so wouldn't help us
-    // detect the invalid ones.
-    // Also note that for simplicity, this just matches a % character and then
-    // anything up to the next % character (or a single %, or end of string).
-    const formatStringRe = /%([^%]+|%|$)/g;
-    const formatStrings = new Set();
-
-    let match;
-    while ( (match = formatStringRe.exec(str)) !== null ) {
-        const placeholder = match[1]; // Minus the leading '%'
-        if (placeholder === '%') continue; // Literal % is %%
-
-        const placeholderMatch = placeholder.match(/^\((.*?)\)(.)/);
-        if (placeholderMatch === null) {
-            throw new Error("Invalid format specifier: '"+match[0]+"'");
-        }
-        if (placeholderMatch.length < 3) {
-            throw new Error("Malformed format specifier");
-        }
-        const placeholderName = placeholderMatch[1];
-        const placeholderFormat = placeholderMatch[2];
-
-        if (placeholderFormat !== 's') {
-            throw new Error(`'${placeholderFormat}' used as format character: you probably meant 's'`);
-        }
-
-        formatStrings.add(placeholderName);
-    }
-
-    return formatStrings;
-}
-
-function getTranslationsJs(file) {
-    const contents = fs.readFileSync(file, { encoding: 'utf8' });
-
-    const trs = new Set();
-
-    try {
-        const plugins = [
-            // https://babeljs.io/docs/en/babel-parser#plugins
-            "classProperties",
-            "objectRestSpread",
-            "throwExpressions",
-            "exportDefaultFrom",
-            "decorators-legacy",
-        ];
-
-        if (file.endsWith(".js") || file.endsWith(".jsx")) {
-            // all JS is assumed to be flow or react
-            plugins.push("flow", "jsx");
-        } else if (file.endsWith(".ts")) {
-            // TS can't use JSX unless it's a TSX file (otherwise angle casts fail)
-            plugins.push("typescript");
-        } else if (file.endsWith(".tsx")) {
-            // When the file is a TSX file though, enable JSX parsing
-            plugins.push("typescript", "jsx");
-        }
-
-        const babelParsed = parser.parse(contents, {
-            allowImportExportEverywhere: true,
-            errorRecovery: true,
-            sourceFilename: file,
-            tokens: true,
-            plugins,
-        });
-        traverse.default(babelParsed, {
-            enter: (p) => {
-                const node = p.node;
-                if (p.isCallExpression() && node.callee && TRANSLATIONS_FUNCS.includes(node.callee.name)) {
-                    const tKey = getTKey(node.arguments[0]);
-
-                    // This happens whenever we call _t with non-literals (ie. whenever we've
-                    // had to use a _td to compensate) so is expected.
-                    if (tKey === null) return;
-
-                    // check the format string against the args
-                    // We only check _t: _td has no args
-                    if (node.callee.name === '_t') {
-                        try {
-                            const placeholders = getFormatStrings(tKey);
-                            for (const placeholder of placeholders) {
-                                if (node.arguments.length < 2) {
-                                    throw new Error(`Placeholder found ('${placeholder}') but no substitutions given`);
-                                }
-                                const value = getObjectValue(node.arguments[1], placeholder);
-                                if (value === null) {
-                                    throw new Error(`No value found for placeholder '${placeholder}'`);
-                                }
-                            }
-
-                            // Validate tag replacements
-                            if (node.arguments.length > 2) {
-                                const tagMap = node.arguments[2];
-                                for (const prop of tagMap.properties || []) {
-                                    if (prop.key.type === 'Literal') {
-                                        const tag = prop.key.value;
-                                        // RegExp same as in src/languageHandler.js
-                                        const regexp = new RegExp(`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`);
-                                        if (!tKey.match(regexp)) {
-                                            throw new Error(`No match for ${regexp} in ${tKey}`);
-                                        }
-                                    }
-                                }
-                            }
-
-                        } catch (e) {
-                            console.log();
-                            console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`);
-                            console.error(e);
-                            process.exit(1);
-                        }
-                    }
-
-                    let isPlural = false;
-                    if (node.arguments.length > 1 && node.arguments[1].type === 'ObjectExpression') {
-                        const countVal = getObjectValue(node.arguments[1], 'count');
-                        if (countVal) {
-                            isPlural = true;
-                        }
-                    }
-
-                    if (isPlural) {
-                        trs.add(tKey + "|other");
-                        const plurals = enPlurals[tKey];
-                        if (plurals) {
-                            for (const pluralType of Object.keys(plurals)) {
-                                trs.add(tKey + "|" + pluralType);
-                            }
-                        }
-                    } else {
-                        trs.add(tKey);
-                    }
-                }
-            },
-        });
-    } catch (e) {
-        console.error(e);
-        process.exit(1);
-    }
-
-    return trs;
-}
-
-function getTranslationsOther(file) {
-    const contents = fs.readFileSync(file, { encoding: 'utf8' });
-
-    const trs = new Set();
-
-    // Taken from element-web src/components/structures/HomePage.js
-    const translationsRegex = /_t\(['"]([\s\S]*?)['"]\)/mg;
-    let matches;
-    while (matches = translationsRegex.exec(contents)) {
-        trs.add(matches[1]);
-    }
-    return trs;
-}
-
-// gather en_EN plural strings from the input translations file:
-// the en_EN strings are all in the source with the exception of
-// pluralised strings, which we need to pull in from elsewhere.
-const inputTranslationsRaw = JSON.parse(fs.readFileSync(INPUT_TRANSLATIONS_FILE, { encoding: 'utf8' }));
-const enPlurals = {};
-
-for (const key of Object.keys(inputTranslationsRaw)) {
-    const parts = key.split("|");
-    if (parts.length > 1) {
-        const plurals = enPlurals[parts[0]] || {};
-        plurals[parts[1]] = inputTranslationsRaw[key];
-        enPlurals[parts[0]] = plurals;
-    }
-}
-
-const translatables = new Set();
-
-const walkOpts = {
-    listeners: {
-        names: function(root, nodeNamesArray) {
-            // Sort the names case insensitively and alphabetically to
-            // maintain some sense of order between the different strings.
-            nodeNamesArray.sort((a, b) => {
-                a = a.toLowerCase();
-                b = b.toLowerCase();
-                if (a > b) return 1;
-                if (a < b) return -1;
-                return 0;
-            });
-        },
-        file: function(root, fileStats, next) {
-            const fullPath = path.join(root, fileStats.name);
-
-            let trs;
-            if (fileStats.name.endsWith('.js') || fileStats.name.endsWith('.ts') || fileStats.name.endsWith('.tsx')) {
-                trs = getTranslationsJs(fullPath);
-            } else if (fileStats.name.endsWith('.html')) {
-                trs = getTranslationsOther(fullPath);
-            } else {
-                return;
-            }
-            console.log(`${fullPath} (${trs.size} strings)`);
-            for (const tr of trs.values()) {
-                // Convert DOS line endings to unix
-                translatables.add(tr.replace(/\r\n/g, "\n"));
-            }
-        },
-    }
-};
-
-for (const path of SEARCH_PATHS) {
-    if (fs.existsSync(path)) {
-        walk.walkSync(path, walkOpts);
-    }
-}
-
-const trObj = {};
-for (const tr of translatables) {
-    if (tr.includes("|")) {
-        if (inputTranslationsRaw[tr]) {
-            trObj[tr] = inputTranslationsRaw[tr];
-        } else {
-            trObj[tr] = tr.split("|")[0];
-        }
-    } else {
-        trObj[tr] = tr;
-    }
-}
-
-fs.writeFileSync(
-    OUTPUT_FILE,
-    JSON.stringify(trObj, translatables.values(), 4) + "\n"
-);
-
-console.log();
-console.log(`Wrote ${translatables.size} strings to ${OUTPUT_FILE}`);
diff --git a/scripts/generate-eslint-error-ignore-file b/scripts/generate-eslint-error-ignore-file
deleted file mode 100755
index 54aacfc9fa..0000000000
--- a/scripts/generate-eslint-error-ignore-file
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/bin/sh
-#
-# generates .eslintignore.errorfiles to list the files which have errors in,
-# so that they can be ignored in future automated linting.
-
-out=.eslintignore.errorfiles
-
-cd `dirname $0`/..
-
-echo "generating $out"
-
-{
-    cat < 0) | .filePath' |
-        sed -e 's/.*matrix-react-sdk\///';
-} > "$out"
-# also append rules from eslintignore file
-cat .eslintignore >> $out
diff --git a/scripts/prune-i18n.js b/scripts/prune-i18n.js
deleted file mode 100755
index b4fe8d69f5..0000000000
--- a/scripts/prune-i18n.js
+++ /dev/null
@@ -1,68 +0,0 @@
-#!/usr/bin/env node
-
-/*
-Copyright 2017 New Vector Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-/*
- * Looks through all the translation files and removes any strings
- * which don't appear in en_EN.json.
- * Use this if you remove a translation, but merge any outstanding changes
- * from weblate first or you'll need to resolve the conflict in weblate.
- */
-
-const fs = require('fs');
-const path = require('path');
-
-const I18NDIR = 'src/i18n/strings';
-
-const enStringsRaw = JSON.parse(fs.readFileSync(path.join(I18NDIR, 'en_EN.json')));
-
-const enStrings = new Set();
-for (const str of Object.keys(enStringsRaw)) {
-    const parts = str.split('|');
-    if (parts.length > 1) {
-        enStrings.add(parts[0]);
-    } else {
-        enStrings.add(str);
-    }
-}
-
-for (const filename of fs.readdirSync(I18NDIR)) {
-    if (filename === 'en_EN.json') continue;
-    if (filename === 'basefile.json') continue;
-    if (!filename.endsWith('.json')) continue;
-
-    const trs = JSON.parse(fs.readFileSync(path.join(I18NDIR, filename)));
-    const oldLen = Object.keys(trs).length;
-    for (const tr of Object.keys(trs)) {
-        const parts = tr.split('|');
-        const trKey = parts.length > 1 ? parts[0] : tr;
-        if (!enStrings.has(trKey)) {
-            delete trs[tr];
-        }
-    }
-
-    const removed = oldLen - Object.keys(trs).length;
-    if (removed > 0) {
-        console.log(`${filename}: removed ${removed} translations`);
-        // XXX: This is totally relying on the impl serialising the JSON object in the
-        // same order as they were parsed from the file. JSON.stringify() has a specific argument
-        // that can be used to control the order, but JSON.parse() lacks any kind of equivalent.
-        // Empirically this does maintain the order on my system, so I'm going to leave it like
-        // this for now.
-        fs.writeFileSync(path.join(I18NDIR, filename), JSON.stringify(trs, undefined, 4) + "\n");
-    }
-}
diff --git a/scripts/reskindex.js b/scripts/reskindex.js
index 9fb0e1a7c0..5eaec4d1d5 100755
--- a/scripts/reskindex.js
+++ b/scripts/reskindex.js
@@ -1,29 +1,33 @@
 #!/usr/bin/env node
-var fs = require('fs');
-var path = require('path');
-var glob = require('glob');
-var args = require('minimist')(process.argv);
-var chokidar = require('chokidar');
+const fs = require('fs');
+const { promises: fsp } = fs;
+const path = require('path');
+const glob = require('glob');
+const util = require('util');
+const args = require('minimist')(process.argv);
+const chokidar = require('chokidar');
 
-var componentIndex = path.join('src', 'component-index.js');
-var componentIndexTmp = componentIndex+".tmp";
-var componentsDir = path.join('src', 'components');
-var componentJsGlob = '**/*.js';
-var componentTsGlob = '**/*.tsx';
-var prevFiles = [];
+const componentIndex = path.join('src', 'component-index.js');
+const componentIndexTmp = componentIndex+".tmp";
+const componentsDir = path.join('src', 'components');
+const componentJsGlob = '**/*.js';
+const componentTsGlob = '**/*.tsx';
+let prevFiles = [];
 
-function reskindex() {
-    var jsFiles = glob.sync(componentJsGlob, {cwd: componentsDir}).sort();
-    var tsFiles = glob.sync(componentTsGlob, {cwd: componentsDir}).sort();
-    var files = [...tsFiles, ...jsFiles];
+async function reskindex() {
+    const jsFiles = glob.sync(componentJsGlob, {cwd: componentsDir}).sort();
+    const tsFiles = glob.sync(componentTsGlob, {cwd: componentsDir}).sort();
+    const files = [...tsFiles, ...jsFiles];
     if (!filesHaveChanged(files, prevFiles)) {
         return;
     }
     prevFiles = files;
 
-    var header = args.h || args.header;
+    const header = args.h || args.header;
 
-    var strm = fs.createWriteStream(componentIndexTmp);
+    const strm = fs.createWriteStream(componentIndexTmp);
+    // Wait for the open event to ensure the file descriptor is set
+    await new Promise(resolve => strm.once("open", resolve));
 
     if (header) {
        strm.write(fs.readFileSync(header));
@@ -38,11 +42,11 @@ function reskindex() {
     strm.write(" */\n\n");
     strm.write("let components = {};\n");
 
-    for (var i = 0; i < files.length; ++i) {
-        var file = files[i].replace('.js', '').replace('.tsx', '');
+    for (let i = 0; i < files.length; ++i) {
+        const file = files[i].replace('.js', '').replace('.tsx', '');
 
-        var moduleName = (file.replace(/\//g, '.'));
-        var importName = moduleName.replace(/\./g, "$");
+        const moduleName = (file.replace(/\//g, '.'));
+        const importName = moduleName.replace(/\./g, "$");
 
         strm.write("import " + importName + " from './components/" + file + "';\n");
         strm.write(importName + " && (components['"+moduleName+"'] = " + importName + ");");
@@ -51,14 +55,10 @@ function reskindex() {
     }
 
     strm.write("export {components};\n");
-    strm.end();
-    fs.rename(componentIndexTmp, componentIndex, function(err) {
-        if(err) {
-            console.error("Error moving new index into place: " + err);
-        } else {
-            console.log('Reskindex: completed');
-        }
-    });
+    // Ensure the file has been fully written to disk before proceeding
+    await util.promisify(fs.fsync)(strm.fd);
+    await util.promisify(strm.end);
+    await fsp.rename(componentIndexTmp, componentIndex);
 }
 
 // Expects both arrays of file names to be sorted
@@ -67,7 +67,7 @@ function filesHaveChanged(files, prevFiles) {
         return true;
     }
     // Check for name changes
-    for (var i = 0; i < files.length; i++) {
+    for (let i = 0; i < files.length; i++) {
         if (prevFiles[i] !== files[i]) {
             return true;
         }
@@ -75,15 +75,23 @@ function filesHaveChanged(files, prevFiles) {
     return false;
 }
 
+// Wrapper since await at the top level is not well supported yet
+function run() {
+    (async function() {
+        await reskindex();
+        console.log("Reskindex completed");
+    })();
+}
+
 // -w indicates watch mode where any FS events will trigger reskindex
 if (!args.w) {
-    reskindex();
+    run();
     return;
 }
 
-var watchDebouncer = null;
+let watchDebouncer = null;
 chokidar.watch(path.join(componentsDir, componentJsGlob)).on('all', (event, path) => {
     if (path === componentIndex) return;
     if (watchDebouncer) clearTimeout(watchDebouncer);
-    watchDebouncer = setTimeout(reskindex, 1000);
+    watchDebouncer = setTimeout(run, 1000);
 });
diff --git a/src/@types/common.ts b/src/@types/common.ts
index b887bd4090..1fb9ba4303 100644
--- a/src/@types/common.ts
+++ b/src/@types/common.ts
@@ -17,7 +17,7 @@ limitations under the License.
 import { JSXElementConstructor } from "react";
 
 // Based on https://stackoverflow.com/a/53229857/3532235
-export type Without = {[P in Exclude] ? : never};
+export type Without = {[P in Exclude]?: never};
 export type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U;
 export type Writeable = { -readonly [P in keyof T]: T[P] };
 
diff --git a/src/@types/diff-dom.ts b/src/@types/diff-dom.ts
new file mode 100644
index 0000000000..38ff6432cf
--- /dev/null
+++ b/src/@types/diff-dom.ts
@@ -0,0 +1,38 @@
+/*
+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.
+*/
+
+declare module "diff-dom" {
+    export interface IDiff {
+        action: string;
+        name: string;
+        text?: string;
+        route: number[];
+        value: string;
+        element: unknown;
+        oldValue: string;
+        newValue: string;
+    }
+
+    interface IOpts {
+    }
+
+    export class DiffDOM {
+        public constructor(opts?: IOpts);
+        public apply(tree: unknown, diffs: IDiff[]): unknown;
+        public undo(tree: unknown, diffs: IDiff[]): unknown;
+        public diff(a: HTMLElement | string, b: HTMLElement | string): IDiff[];
+    }
+}
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 0285107660..7192eb81cc 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -1,5 +1,5 @@
 /*
-Copyright 2020 The Matrix.org Foundation C.I.C.
+Copyright 2020-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.
@@ -15,7 +15,10 @@ limitations under the License.
 */
 
 import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
-import * as ModernizrStatic from "modernizr";
+// Load types for the WG CSS Font Loading APIs https://github.com/Microsoft/TypeScript/issues/13569
+import "@types/css-font-loading-module";
+import "@types/modernizr";
+
 import ContentMessages from "../ContentMessages";
 import { IMatrixClientPeg } from "../MatrixClientPeg";
 import ToastStore from "../stores/ToastStore";
@@ -23,29 +26,41 @@ import DeviceListener from "../DeviceListener";
 import { RoomListStoreClass } from "../stores/room-list/RoomListStore";
 import { PlatformPeg } from "../PlatformPeg";
 import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
-import {IntegrationManagers} from "../integrations/IntegrationManagers";
-import {ModalManager} from "../Modal";
+import { IntegrationManagers } from "../integrations/IntegrationManagers";
+import { ModalManager } from "../Modal";
 import SettingsStore from "../settings/SettingsStore";
-import {ActiveRoomObserver} from "../ActiveRoomObserver";
-import {Notifier} from "../Notifier";
-import type {Renderer} from "react-dom";
+import { ActiveRoomObserver } from "../ActiveRoomObserver";
+import { Notifier } from "../Notifier";
+import type { Renderer } from "react-dom";
 import RightPanelStore from "../stores/RightPanelStore";
 import WidgetStore from "../stores/WidgetStore";
 import CallHandler from "../CallHandler";
-import {Analytics} from "../Analytics";
+import { Analytics } from "../Analytics";
 import CountlyAnalytics from "../CountlyAnalytics";
 import UserActivity from "../UserActivity";
-import {ModalWidgetStore} from "../stores/ModalWidgetStore";
+import { ModalWidgetStore } from "../stores/ModalWidgetStore";
+import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
+import VoipUserMapper from "../VoipUserMapper";
+import { SpaceStoreClass } from "../stores/SpaceStore";
+import TypingStore from "../stores/TypingStore";
+import { EventIndexPeg } from "../indexing/EventIndexPeg";
+import { VoiceRecordingStore } from "../stores/VoiceRecordingStore";
+import PerformanceMonitor from "../performance";
+import UIStore from "../stores/UIStore";
+import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
+import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
 
 declare global {
     interface Window {
-        Modernizr: ModernizrStatic;
         matrixChat: ReturnType;
         mxMatrixClientPeg: IMatrixClientPeg;
         Olm: {
             init: () => Promise;
         };
 
+        // Needed for Safari, unknown to TypeScript
+        webkitAudioContext: typeof AudioContext;
+
         mxContentMessages: ContentMessages;
         mxToastStore: ToastStore;
         mxDeviceListener: DeviceListener;
@@ -59,16 +74,36 @@ declare global {
         mxNotifier: typeof Notifier;
         mxRightPanelStore: RightPanelStore;
         mxWidgetStore: WidgetStore;
+        mxWidgetLayoutStore: WidgetLayoutStore;
         mxCallHandler: CallHandler;
         mxAnalytics: Analytics;
         mxCountlyAnalytics: typeof CountlyAnalytics;
         mxUserActivity: UserActivity;
         mxModalWidgetStore: ModalWidgetStore;
+        mxVoipUserMapper: VoipUserMapper;
+        mxSpaceStore: SpaceStoreClass;
+        mxVoiceRecordingStore: VoiceRecordingStore;
+        mxTypingStore: TypingStore;
+        mxEventIndexPeg: EventIndexPeg;
+        mxPerformanceMonitor: PerformanceMonitor;
+        mxPerformanceEntryNames: any;
+        mxUIStore: UIStore;
+        mxSetupEncryptionStore?: SetupEncryptionStore;
+        mxRoomScrollStateStore?: RoomScrollStateStore;
     }
 
     interface Document {
         // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess
         hasStorageAccess?: () => Promise;
+        // https://developer.mozilla.org/en-US/docs/Web/API/Document/requestStorageAccess
+        requestStorageAccess?: () => Promise;
+
+        // Safari & IE11 only have this prefixed: we used prefixed versions
+        // previously so let's continue to support them for now
+        webkitExitFullscreen(): Promise;
+        msExitFullscreen(): Promise;
+        readonly webkitFullscreenElement: Element | null;
+        readonly msFullscreenElement: Element | null;
     }
 
     interface Navigator {
@@ -82,21 +117,38 @@ declare global {
         usageDetails?: {[key: string]: number};
     }
 
-    export interface ISettledFulfilled {
-        status: "fulfilled";
-        value: T;
-    }
-    export interface ISettledRejected {
-        status: "rejected";
-        reason: any;
-    }
-
-    interface PromiseConstructor {
-        allSettled(promises: Promise[]): Promise | ISettledRejected>>;
-    }
-
     interface HTMLAudioElement {
         type?: string;
+        // sinkId & setSinkId are experimental and typescript doesn't know about them
+        sinkId: string;
+        setSinkId(outputId: string);
+    }
+
+    interface HTMLVideoElement {
+        type?: string;
+        // sinkId & setSinkId are experimental and typescript doesn't know about them
+        sinkId: string;
+        setSinkId(outputId: string);
+    }
+
+    // Add Chrome-specific `instant` ScrollBehaviour
+    type _ScrollBehavior = ScrollBehavior | "instant";
+
+    interface _ScrollOptions {
+        behavior?: _ScrollBehavior;
+    }
+
+    interface _ScrollIntoViewOptions extends _ScrollOptions {
+        block?: ScrollLogicalPosition;
+        inline?: ScrollLogicalPosition;
+    }
+
+    interface Element {
+        // Safari & IE11 only have this prefixed: we used prefixed versions
+        // previously so let's continue to support them for now
+        webkitRequestFullScreen(options?: FullscreenOptions): Promise;
+        msRequestFullscreen(options?: FullscreenOptions): Promise;
+        scrollIntoView(arg?: boolean | _ScrollIntoViewOptions): void;
     }
 
     interface Error {
@@ -107,4 +159,30 @@ declare global {
         // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber
         columnNumber?: number;
     }
+
+    // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
+    interface AudioWorkletProcessor {
+        readonly port: MessagePort;
+        process(
+            inputs: Float32Array[][],
+            outputs: Float32Array[][],
+            parameters: Record
+        ): boolean;
+    }
+
+    // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
+    const AudioWorkletProcessor: {
+        prototype: AudioWorkletProcessor;
+        new (options?: AudioWorkletNodeOptions): AudioWorkletProcessor;
+    };
+
+    // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
+    function registerProcessor(
+        name: string,
+        processorCtor: (new (
+            options?: AudioWorkletNodeOptions
+        ) => AudioWorkletProcessor) & {
+            parameterDescriptors?: AudioParamDescriptor[];
+        }
+    );
 }
diff --git a/src/@types/worker-loader.d.ts b/src/@types/worker-loader.d.ts
new file mode 100644
index 0000000000..a8f5d8e9a4
--- /dev/null
+++ b/src/@types/worker-loader.d.ts
@@ -0,0 +1,23 @@
+/*
+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.
+*/
+
+declare module "*.worker.ts" {
+    class WebpackWorker extends Worker {
+        constructor();
+    }
+
+    export default WebpackWorker;
+}
diff --git a/src/AddThreepid.js b/src/AddThreepid.js
index f06f7c187d..ab291128a7 100644
--- a/src/AddThreepid.js
+++ b/src/AddThreepid.js
@@ -16,12 +16,12 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import {MatrixClientPeg} from './MatrixClientPeg';
+import { MatrixClientPeg } from './MatrixClientPeg';
 import * as sdk from './index';
 import Modal from './Modal';
 import { _t } from './languageHandler';
 import IdentityAuthClient from './IdentityAuthClient';
-import {SSOAuthEntry} from "./components/views/auth/InteractiveAuthEntryComponents";
+import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents";
 
 function getIdServerDomain() {
     return MatrixClientPeg.get().idBaseUrl.split("://")[1];
@@ -189,7 +189,6 @@ export default class AddThreepid {
                         // pop up an interactive auth dialog
                         const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
 
-
                         const dialogAesthetics = {
                             [SSOAuthEntry.PHASE_PREAUTH]: {
                                 title: _t("Use Single Sign On to continue"),
@@ -249,7 +248,7 @@ export default class AddThreepid {
 
     /**
      * Takes a phone number verification code as entered by the user and validates
-     * it with the ID server, then if successful, adds the phone number.
+     * it with the identity server, then if successful, adds the phone number.
      * @param {string} msisdnToken phone number verification code as entered by the user
      * @return {Promise} Resolves if the phone number was added. Rejects with an object
      * with a "message" property which contains a human-readable message detailing why
diff --git a/src/Analytics.tsx b/src/Analytics.tsx
index 212bfd3757..ce8287de56 100644
--- a/src/Analytics.tsx
+++ b/src/Analytics.tsx
@@ -17,7 +17,7 @@ limitations under the License.
 
 import React from 'react';
 
-import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler';
+import { getCurrentLanguage, _t, _td, IVariables } from './languageHandler';
 import PlatformPeg from './PlatformPeg';
 import SdkConfig from './SdkConfig';
 import Modal from './Modal';
@@ -390,6 +390,7 @@ export class Analytics {
             { expl: _td('Your device resolution'), value: resolution },
         ];
 
+        // FIXME: Using an import will result in test failures
         const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
         Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, {
             title: _t('Analytics'),
diff --git a/src/AsyncWrapper.js b/src/AsyncWrapper.tsx
similarity index 64%
rename from src/AsyncWrapper.js
rename to src/AsyncWrapper.tsx
index 359828b312..ef8924add8 100644
--- a/src/AsyncWrapper.js
+++ b/src/AsyncWrapper.tsx
@@ -1,6 +1,5 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
-Copyright 2020 The Matrix.org Foundation C.I.C.
+Copyright 2015-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.
@@ -15,52 +14,61 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from "react";
+import React, { ComponentType } from "react";
+
 import * as sdk from './index';
-import PropTypes from 'prop-types';
 import { _t } from './languageHandler';
+import { IDialogProps } from "./components/views/dialogs/IDialogProps";
+
+type AsyncImport = { default: T };
+
+interface IProps extends IDialogProps {
+    // A promise which resolves with the real component
+    prom: Promise>;
+}
+
+interface IState {
+    component?: ComponentType;
+    error?: Error;
+}
 
 /**
  * Wrap an asynchronous loader function with a react component which shows a
  * spinner until the real component loads.
  */
-export default class AsyncWrapper extends React.Component {
-    static propTypes = {
-        /** A promise which resolves with the real component
-         */
-        prom: PropTypes.object.isRequired,
-    };
+export default class AsyncWrapper extends React.Component {
+    private unmounted = false;
 
-    state = {
+    public state = {
         component: null,
         error: null,
     };
 
     componentDidMount() {
-        this._unmounted = false;
         // XXX: temporary logging to try to diagnose
         // https://github.com/vector-im/element-web/issues/3148
         console.log('Starting load of AsyncWrapper for modal');
         this.props.prom.then((result) => {
-            if (this._unmounted) {
-                return;
-            }
+            if (this.unmounted) return;
+
             // Take the 'default' member if it's there, then we support
             // passing in just an import()ed module, since ES6 async import
             // always returns a module *namespace*.
-            const component = result.default ? result.default : result;
-            this.setState({component});
+            const component = (result as AsyncImport).default
+                ? (result as AsyncImport).default
+                : result as ComponentType;
+            this.setState({ component });
         }).catch((e) => {
             console.warn('AsyncWrapper promise failed', e);
-            this.setState({error: e});
+            this.setState({ error: e });
         });
     }
 
     componentWillUnmount() {
-        this._unmounted = true;
+        this.unmounted = true;
     }
 
-    _onWrapperCancelClick = () => {
+    private onWrapperCancelClick = () => {
         this.props.onFinished(false);
     };
 
@@ -69,14 +77,13 @@ export default class AsyncWrapper extends React.Component {
             const Component = this.state.component;
             return ;
         } else if (this.state.error) {
+            // FIXME: Using an import will result in test failures
             const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
             const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
-            return 
-                {_t("Unable to load! Check your network connectivity and try again.")}
+            return 
+                { _t("Unable to load! Check your network connectivity and try again.") }
                 
             ;
diff --git a/src/Avatar.ts b/src/Avatar.ts
index 60bdfdcf75..198d4162a0 100644
--- a/src/Avatar.ts
+++ b/src/Avatar.ts
@@ -14,28 +14,25 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
-import {RoomMember} from "matrix-js-sdk/src/models/room-member";
-import {User} from "matrix-js-sdk/src/models/user";
-import {Room} from "matrix-js-sdk/src/models/room";
+import { RoomMember } from "matrix-js-sdk/src/models/room-member";
+import { User } from "matrix-js-sdk/src/models/user";
+import { Room } from "matrix-js-sdk/src/models/room";
+import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
 
-import {MatrixClientPeg} from './MatrixClientPeg';
 import DMRoomMap from './utils/DMRoomMap';
-
-export type ResizeMethod = "crop" | "scale";
+import { mediaFromMxc } from "./customisations/Media";
+import SpaceStore from "./stores/SpaceStore";
 
 // Not to be used for BaseAvatar urls as that has similar default avatar fallback already
-export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
+export function avatarUrlForMember(
+    member: RoomMember,
+    width: number,
+    height: number,
+    resizeMethod: ResizeMethod,
+): string {
     let url: string;
-    if (member && member.getAvatarUrl) {
-        url = member.getAvatarUrl(
-            MatrixClientPeg.get().getHomeserverUrl(),
-            Math.floor(width * window.devicePixelRatio),
-            Math.floor(height * window.devicePixelRatio),
-            resizeMethod,
-            false,
-            false,
-        );
+    if (member?.getMxcAvatarUrl()) {
+        url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
     }
     if (!url) {
         // member can be null here currently since on invites, the JS SDK
@@ -46,17 +43,14 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu
     return url;
 }
 
-export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
-    const url = getHttpUriForMxc(
-        MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
-        Math.floor(width * window.devicePixelRatio),
-        Math.floor(height * window.devicePixelRatio),
-        resizeMethod,
-    );
-    if (!url || url.length === 0) {
-        return null;
-    }
-    return url;
+export function avatarUrlForUser(
+    user: Pick,
+    width: number,
+    height: number,
+    resizeMethod?: ResizeMethod,
+): string | null {
+    if (!user.avatarUrl) return null;
+    return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
 }
 
 function isValidHexColor(color: string): boolean {
@@ -154,17 +148,13 @@ export function getInitialLetter(name: string): string {
 export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
     if (!room) return null; // null-guard
 
-    const explicitRoomAvatar = room.getAvatarUrl(
-        MatrixClientPeg.get().getHomeserverUrl(),
-        width,
-        height,
-        resizeMethod,
-        false,
-    );
-    if (explicitRoomAvatar) {
-        return explicitRoomAvatar;
+    if (room.getMxcAvatarUrl()) {
+        return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
     }
 
+    // space rooms cannot be DMs so skip the rest
+    if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null;
+
     let otherMember = null;
     const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
     if (otherUserId) {
@@ -174,14 +164,8 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
         // then still try to show any avatar (pref. other member)
         otherMember = room.getAvatarFallbackMember();
     }
-    if (otherMember) {
-        return otherMember.getAvatarUrl(
-            MatrixClientPeg.get().getHomeserverUrl(),
-            width,
-            height,
-            resizeMethod,
-            false,
-        );
+    if (otherMember?.getMxcAvatarUrl()) {
+        return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
     }
     return null;
 }
diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts
index 4d06c5df73..5b4b15cc67 100644
--- a/src/BasePlatform.ts
+++ b/src/BasePlatform.ts
@@ -17,16 +17,20 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import {MatrixClient} from "matrix-js-sdk/src/client";
+import { MatrixClient } from "matrix-js-sdk/src/client";
+import { encodeUnpaddedBase64 } from "matrix-js-sdk/src/crypto/olmlib";
 import dis from './dispatcher/dispatcher';
 import BaseEventIndexManager from './indexing/BaseEventIndexManager';
-import {ActionPayload} from "./dispatcher/payloads";
-import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload";
-import {Action} from "./dispatcher/actions";
-import {hideToast as hideUpdateToast} from "./toasts/UpdateToast";
+import { ActionPayload } from "./dispatcher/payloads";
+import { CheckUpdatesPayload } from "./dispatcher/payloads/CheckUpdatesPayload";
+import { Action } from "./dispatcher/actions";
+import { hideToast as hideUpdateToast } from "./toasts/UpdateToast";
+import { MatrixClientPeg } from "./MatrixClientPeg";
+import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager";
 
 export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
 export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
+export const SSO_IDP_ID_KEY = "mx_sso_idp_id";
 
 export enum UpdateCheckStatus {
     Checking = "CHECKING",
@@ -53,7 +57,7 @@ export default abstract class BasePlatform {
         this.startUpdateCheck = this.startUpdateCheck.bind(this);
     }
 
-    abstract async getConfig(): Promise<{}>;
+    abstract getConfig(): Promise<{}>;
 
     abstract getDefaultDeviceDisplayName(): string;
 
@@ -105,6 +109,9 @@ export default abstract class BasePlatform {
      * @param newVersion the version string to check
      */
     protected shouldShowUpdate(newVersion: string): boolean {
+        // If the user registered on this client in the last 24 hours then do not show them the update toast
+        if (MatrixClientPeg.userRegisteredWithinLastHours(24)) return false;
+
         try {
             const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY));
             return newVersion !== version || Date.now() > deferUntil;
@@ -124,6 +131,14 @@ export default abstract class BasePlatform {
         hideUpdateToast();
     }
 
+    /**
+     * Return true if platform supports multi-language
+     * spell-checking, otherwise false.
+     */
+    supportsMultiLanguageSpellCheck(): boolean {
+        return false;
+    }
+
     /**
      * Returns true if the platform supports displaying
      * notifications, otherwise false.
@@ -197,6 +212,18 @@ export default abstract class BasePlatform {
         throw new Error("Unimplemented");
     }
 
+    supportsWarnBeforeExit(): boolean {
+        return false;
+    }
+
+    async shouldWarnBeforeExit(): Promise {
+        return false;
+    }
+
+    async setWarnBeforeExit(enabled: boolean): Promise {
+        throw new Error("Unimplemented");
+    }
+
     supportsAutoHideMenuBar(): boolean {
         return false;
     }
@@ -231,7 +258,17 @@ export default abstract class BasePlatform {
         return null;
     }
 
-    setLanguage(preferredLangs: string[]) {}
+    async setLanguage(preferredLangs: string[]) {}
+
+    setSpellCheckLanguages(preferredLangs: string[]) {}
+
+    getSpellCheckLanguages(): Promise | null {
+        return null;
+    }
+
+    getAvailableSpellCheckLanguages(): Promise | null {
+        return null;
+    }
 
     protected getSSOCallbackUrl(fragmentAfterLogin: string): URL {
         const url = new URL(window.location.href);
@@ -244,15 +281,19 @@ export default abstract class BasePlatform {
      * @param {MatrixClient} mxClient the matrix client using which we should start the flow
      * @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
      * @param {string} fragmentAfterLogin the hash to pass to the app during sso callback.
+     * @param {string} idpId The ID of the Identity Provider being targeted, optional.
      */
-    startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string) {
+    startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string, idpId?: string) {
         // persist hs url and is url for when the user is returned to the app with the login token
         localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl());
         if (mxClient.getIdentityServerUrl()) {
             localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl());
         }
+        if (idpId) {
+            localStorage.setItem(SSO_IDP_ID_KEY, idpId);
+        }
         const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin);
-        window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO
+        window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO
     }
 
     onKeyDown(ev: KeyboardEvent): boolean {
@@ -268,18 +309,81 @@ export default abstract class BasePlatform {
      *     pickle key has been stored.
      */
     async getPickleKey(userId: string, deviceId: string): Promise {
-        return null;
+        if (!window.crypto || !window.crypto.subtle) {
+            return null;
+        }
+        let data;
+        try {
+            data = await idbLoad("pickleKey", [userId, deviceId]);
+        } catch (e) {}
+        if (!data) {
+            return null;
+        }
+        if (!data.encrypted || !data.iv || !data.cryptoKey) {
+            console.error("Badly formatted pickle key");
+            return null;
+        }
+
+        const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
+        for (let i = 0; i < userId.length; i++) {
+            additionalData[i] = userId.charCodeAt(i);
+        }
+        additionalData[userId.length] = 124; // "|"
+        for (let i = 0; i < deviceId.length; i++) {
+            additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
+        }
+
+        try {
+            const key = await crypto.subtle.decrypt(
+                { name: "AES-GCM", iv: data.iv, additionalData }, data.cryptoKey,
+                data.encrypted,
+            );
+            return encodeUnpaddedBase64(key);
+        } catch (e) {
+            console.error("Error decrypting pickle key");
+            return null;
+        }
     }
 
     /**
      * Create and store a pickle key for encrypting libolm objects.
      * @param {string} userId the user ID for the user that the pickle key is for.
-     * @param {string} userId the device ID that the pickle key is for.
+     * @param {string} deviceId the device ID that the pickle key is for.
      * @returns {string|null} the pickle key, or null if the platform does not
      *     support storing pickle keys.
      */
     async createPickleKey(userId: string, deviceId: string): Promise {
-        return null;
+        if (!window.crypto || !window.crypto.subtle) {
+            return null;
+        }
+        const crypto = window.crypto;
+        const randomArray = new Uint8Array(32);
+        crypto.getRandomValues(randomArray);
+        const cryptoKey = await crypto.subtle.generateKey(
+            { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
+        );
+        const iv = new Uint8Array(32);
+        crypto.getRandomValues(iv);
+
+        const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
+        for (let i = 0; i < userId.length; i++) {
+            additionalData[i] = userId.charCodeAt(i);
+        }
+        additionalData[userId.length] = 124; // "|"
+        for (let i = 0; i < deviceId.length; i++) {
+            additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
+        }
+
+        const encrypted = await crypto.subtle.encrypt(
+            { name: "AES-GCM", iv, additionalData }, cryptoKey, randomArray,
+        );
+
+        try {
+            await idbSave("pickleKey", [userId, deviceId], { encrypted, iv, cryptoKey });
+        } catch (e) {
+            return null;
+        }
+        return encodeUnpaddedBase64(randomArray);
     }
 
     /**
@@ -288,5 +392,8 @@ export default abstract class BasePlatform {
      * @param {string} userId the device ID that the pickle key is for.
      */
     async destroyPickleKey(userId: string, deviceId: string): Promise {
+        try {
+            await idbDelete("pickleKey", [userId, deviceId]);
+        } catch (e) {}
     }
 }
diff --git a/src/BlurhashEncoder.ts b/src/BlurhashEncoder.ts
new file mode 100644
index 0000000000..2aee370fe9
--- /dev/null
+++ b/src/BlurhashEncoder.ts
@@ -0,0 +1,60 @@
+/*
+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 { defer, IDeferred } from "matrix-js-sdk/src/utils";
+
+// @ts-ignore - `.ts` is needed here to make TS happy
+import BlurhashWorker from "./workers/blurhash.worker.ts";
+
+interface IBlurhashWorkerResponse {
+    seq: number;
+    blurhash: string;
+}
+
+export class BlurhashEncoder {
+    private static internalInstance = new BlurhashEncoder();
+
+    public static get instance(): BlurhashEncoder {
+        return BlurhashEncoder.internalInstance;
+    }
+
+    private readonly worker: Worker;
+    private seq = 0;
+    private pendingDeferredMap = new Map>();
+
+    constructor() {
+        this.worker = new BlurhashWorker();
+        this.worker.onmessage = this.onMessage;
+    }
+
+    private onMessage = (ev: MessageEvent) => {
+        const { seq, blurhash } = ev.data;
+        const deferred = this.pendingDeferredMap.get(seq);
+        if (deferred) {
+            this.pendingDeferredMap.delete(seq);
+            deferred.resolve(blurhash);
+        }
+    };
+
+    public getBlurhash(imageData: ImageData): Promise {
+        const seq = this.seq++;
+        const deferred = defer();
+        this.pendingDeferredMap.set(seq, deferred);
+        this.worker.postMessage({ seq, imageData });
+        return deferred.promise;
+    }
+}
+
diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx
index f3ce4ac679..f90854ee64 100644
--- a/src/CallHandler.tsx
+++ b/src/CallHandler.tsx
@@ -55,39 +55,80 @@ limitations under the License.
 
 import React from 'react';
 
-import {MatrixClientPeg} from './MatrixClientPeg';
+import { MatrixClientPeg } from './MatrixClientPeg';
 import PlatformPeg from './PlatformPeg';
 import Modal from './Modal';
 import { _t } from './languageHandler';
-// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
-import Matrix from 'matrix-js-sdk';
 import dis from './dispatcher/dispatcher';
 import WidgetUtils from './utils/WidgetUtils';
 import WidgetEchoStore from './stores/WidgetEchoStore';
 import SettingsStore from './settings/SettingsStore';
-import {generateHumanReadableId} from "./utils/NamingUtils";
-import {Jitsi} from "./widgets/Jitsi";
-import {WidgetType} from "./widgets/WidgetType";
-import {SettingLevel} from "./settings/SettingLevel";
+import { Jitsi } from "./widgets/Jitsi";
+import { WidgetType } from "./widgets/WidgetType";
+import { SettingLevel } from "./settings/SettingLevel";
 import { ActionPayload } from "./dispatcher/payloads";
-import {base32} from "rfc4648";
+import { base32 } from "rfc4648";
 
 import QuestionDialog from "./components/views/dialogs/QuestionDialog";
 import ErrorDialog from "./components/views/dialogs/ErrorDialog";
 import WidgetStore from "./stores/WidgetStore";
 import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
 import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
-import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/lib/webrtc/call";
+import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call";
 import Analytics from './Analytics';
 import CountlyAnalytics from "./CountlyAnalytics";
+import { UIFeature } from "./settings/UIFeature";
+import { CallError } from "matrix-js-sdk/src/webrtc/call";
+import { logger } from 'matrix-js-sdk/src/logger';
+import DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker";
+import { Action } from './dispatcher/actions';
+import VoipUserMapper from './VoipUserMapper';
+import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
+import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring";
+import EventEmitter from 'events';
+import SdkConfig from './SdkConfig';
+import { ensureDMExists, findDMForUser } from './createRoom';
 
-enum AudioID {
+export const PROTOCOL_PSTN = 'm.protocol.pstn';
+export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
+export const PROTOCOL_SIP_NATIVE = 'im.vector.protocol.sip_native';
+export const PROTOCOL_SIP_VIRTUAL = 'im.vector.protocol.sip_virtual';
+
+const CHECK_PROTOCOLS_ATTEMPTS = 3;
+// Event type for room account data and room creation content used to mark rooms as virtual rooms
+// (and store the ID of their native room)
+export const VIRTUAL_ROOM_EVENT_TYPE = 'im.vector.is_virtual_room';
+
+export enum AudioID {
     Ring = 'ringAudio',
     Ringback = 'ringbackAudio',
     CallEnd = 'callendAudio',
     Busy = 'busyAudio',
 }
 
+interface ThirdpartyLookupResponseFields {
+    /* eslint-disable camelcase */
+
+    // im.vector.sip_native
+    virtual_mxid?: string;
+    is_virtual?: boolean;
+
+    // im.vector.sip_virtual
+    native_mxid?: string;
+    is_native?: boolean;
+
+    // common
+    lookup_success?: boolean;
+
+    /* eslint-enable camelcase */
+}
+
+interface ThirdpartyLookupResponse {
+    userid: string;
+    protocol: string;
+    fields: ThirdpartyLookupResponseFields;
+}
+
 // Unlike 'CallType' in js-sdk, this one includes screen sharing
 // (because a screen sharing call is only a screen sharing call to the caller,
 // to the callee it's just a video call, at least as far as the current impl
@@ -98,20 +139,61 @@ export enum PlaceCallType {
     ScreenSharing = 'screensharing',
 }
 
-export default class CallHandler {
-    private calls = new Map();
+export enum CallHandlerEvent {
+    CallsChanged = "calls_changed",
+    CallChangeRoom = "call_change_room",
+}
+
+export default class CallHandler extends EventEmitter {
+    private calls = new Map(); // roomId -> call
+    // Calls started as an attended transfer, ie. with the intention of transferring another
+    // call with a different party to this one.
+    private transferees = new Map(); // callId (target) -> call (transferee)
     private audioPromises = new Map>();
+    private dispatcherRef: string = null;
+    private supportsPstnProtocol = null;
+    private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol
+    private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
+    private pstnSupportCheckTimer: number;
+    // For rooms we've been invited to, true if they're from virtual user, false if we've checked and they aren't.
+    private invitedRoomsAreVirtual = new Map();
+    private invitedRoomCheckInProgress = false;
+
+    // Map of the asserted identity users after we've looked them up using the API.
+    // We need to be be able to determine the mapped room synchronously, so we
+    // do the async lookup when we get new information and then store these mappings here
+    private assertedIdentityNativeUsers = new Map();
 
     static sharedInstance() {
         if (!window.mxCallHandler) {
-            window.mxCallHandler = new CallHandler()
+            window.mxCallHandler = new CallHandler();
         }
 
         return window.mxCallHandler;
     }
 
-    constructor() {
-        dis.register(this.onAction);
+    /*
+     * Gets the user-facing room associated with a call (call.roomId may be the call "virtual room"
+     * if a voip_mxid_translate_pattern is set in the config)
+     */
+    public roomIdForCall(call: MatrixCall): string {
+        if (!call) return null;
+
+        const voipConfig = SdkConfig.get()['voip'];
+
+        if (voipConfig && voipConfig.obeyAssertedIdentity) {
+            const nativeUser = this.assertedIdentityNativeUsers[call.callId];
+            if (nativeUser) {
+                const room = findDMForUser(MatrixClientPeg.get(), nativeUser);
+                if (room) return room.roomId;
+            }
+        }
+
+        return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId;
+    }
+
+    start() {
+        this.dispatcherRef = dis.register(this.onAction);
         // add empty handlers for media actions, otherwise the media keys
         // end up causing the audio elements with our ring/ringback etc
         // audio clips in to play.
@@ -123,8 +205,102 @@ export default class CallHandler {
             navigator.mediaSession.setActionHandler('previoustrack', function() {});
             navigator.mediaSession.setActionHandler('nexttrack', function() {});
         }
+
+        if (SettingsStore.getValue(UIFeature.Voip)) {
+            MatrixClientPeg.get().on('Call.incoming', this.onCallIncoming);
+        }
+
+        this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
     }
 
+    stop() {
+        const cli = MatrixClientPeg.get();
+        if (cli) {
+            cli.removeListener('Call.incoming', this.onCallIncoming);
+        }
+        if (this.dispatcherRef !== null) {
+            dis.unregister(this.dispatcherRef);
+            this.dispatcherRef = null;
+        }
+    }
+
+    private async checkProtocols(maxTries) {
+        try {
+            const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
+
+            if (protocols[PROTOCOL_PSTN] !== undefined) {
+                this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN]);
+                if (this.supportsPstnProtocol) this.pstnSupportPrefixed = false;
+            } else if (protocols[PROTOCOL_PSTN_PREFIXED] !== undefined) {
+                this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN_PREFIXED]);
+                if (this.supportsPstnProtocol) this.pstnSupportPrefixed = true;
+            } else {
+                this.supportsPstnProtocol = null;
+            }
+
+            dis.dispatch({ action: Action.PstnSupportUpdated });
+
+            if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) {
+                this.supportsSipNativeVirtual = Boolean(
+                    protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL],
+                );
+            }
+
+            dis.dispatch({ action: Action.VirtualRoomSupportUpdated });
+        } catch (e) {
+            if (maxTries === 1) {
+                console.log("Failed to check for protocol support and no retries remain: assuming no support", e);
+            } else {
+                console.log("Failed to check for protocol support: will retry", e);
+                this.pstnSupportCheckTimer = setTimeout(() => {
+                    this.checkProtocols(maxTries - 1);
+                }, 10000);
+            }
+        }
+    }
+
+    public getSupportsPstnProtocol() {
+        return this.supportsPstnProtocol;
+    }
+
+    public getSupportsVirtualRooms() {
+        return this.supportsSipNativeVirtual;
+    }
+
+    public pstnLookup(phoneNumber: string): Promise {
+        return MatrixClientPeg.get().getThirdpartyUser(
+            this.pstnSupportPrefixed ? PROTOCOL_PSTN_PREFIXED : PROTOCOL_PSTN, {
+                'm.id.phone': phoneNumber,
+            },
+        );
+    }
+
+    public sipVirtualLookup(nativeMxid: string): Promise {
+        return MatrixClientPeg.get().getThirdpartyUser(
+            PROTOCOL_SIP_VIRTUAL, {
+                'native_mxid': nativeMxid,
+            },
+        );
+    }
+
+    public sipNativeLookup(virtualMxid: string): Promise {
+        return MatrixClientPeg.get().getThirdpartyUser(
+            PROTOCOL_SIP_NATIVE, {
+                'virtual_mxid': virtualMxid,
+            },
+        );
+    }
+
+    private onCallIncoming = (call) => {
+        // we dispatch this synchronously to make sure that the event
+        // handlers on the call are set up immediately (so that if
+        // we get an immediate hangup, we don't get a stuck call)
+        dis.dispatch({
+            action: 'incoming_call',
+            call: call,
+        }, true);
+    };
+
     getCallForRoom(roomId: string): MatrixCall {
         return this.calls.get(roomId) || null;
     }
@@ -138,6 +314,32 @@ export default class CallHandler {
         return null;
     }
 
+    getAllActiveCalls() {
+        const activeCalls = [];
+
+        for (const call of this.calls.values()) {
+            if (call.state !== CallState.Ended && call.state !== CallState.Ringing) {
+                activeCalls.push(call);
+            }
+        }
+        return activeCalls;
+    }
+
+    getAllActiveCallsNotInRoom(notInThisRoomId) {
+        const callsNotInThatRoom = [];
+
+        for (const [roomId, call] of this.calls.entries()) {
+            if (roomId !== notInThisRoomId && call.state !== CallState.Ended) {
+                callsNotInThatRoom.push(call);
+            }
+        }
+        return callsNotInThatRoom;
+    }
+
+    getTransfereeForCallId(callId: string): MatrixCall {
+        return this.transferees[callId];
+    }
+
     play(audioId: AudioID) {
         // TODO: Attach an invisible element for this instead
         // which listens?
@@ -185,16 +387,26 @@ export default class CallHandler {
         // We don't allow placing more than one call per room, but that doesn't mean there
         // can't be more than one, eg. in a glare situation. This checks that the given call
         // is the call we consider 'the' call for its room.
-        const callForThisRoom = this.getCallForRoom(call.roomId);
+        const mappedRoomId = this.roomIdForCall(call);
+
+        const callForThisRoom = this.getCallForRoom(mappedRoomId);
         return callForThisRoom && call.callId === callForThisRoom.callId;
     }
 
     private setCallListeners(call: MatrixCall) {
-        call.on(CallEvent.Error, (err) => {
+        let mappedRoomId = this.roomIdForCall(call);
+
+        call.on(CallEvent.Error, (err: CallError) => {
             if (!this.matchesCallForThisRoom(call)) return;
 
-            Analytics.trackEvent('voip', 'callError', 'error', err);
+            Analytics.trackEvent('voip', 'callError', 'error', err.toString());
             console.error("Call error:", err);
+
+            if (err.code === CallErrorCode.NoUserMedia) {
+                this.showMediaCaptureError(call);
+                return;
+            }
+
             if (
                 MatrixClientPeg.get().getTurnServers().length === 0 &&
                 SettingsStore.getValue("fallbackICEServerAllowed") === null
@@ -213,7 +425,7 @@ export default class CallHandler {
 
             Analytics.trackEvent('voip', 'callHangup');
 
-            this.removeCallForRoom(call.roomId);
+            this.removeCallForRoom(mappedRoomId);
         });
         call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
             if (!this.matchesCallForThisRoom(call)) return;
@@ -237,8 +449,9 @@ export default class CallHandler {
                     this.play(AudioID.Ringback);
                     break;
                 case CallState.Ended:
+                {
                     Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
-                    this.removeCallForRoom(call.roomId);
+                    this.removeCallForRoom(mappedRoomId);
                     if (oldState === CallState.InviteSent && (
                         call.hangupParty === CallParty.Remote ||
                         (call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
@@ -249,6 +462,9 @@ export default class CallHandler {
                         if (call.hangupReason === CallErrorCode.UserHangup) {
                             title = _t("Call Declined");
                             description = _t("The other party declined the call.");
+                        } else if (call.hangupReason === CallErrorCode.UserBusy) {
+                            title = _t("User Busy");
+                            description = _t("The user you called is busy.");
                         } else if (call.hangupReason === CallErrorCode.InviteTimeout) {
                             title = _t("Call Failed");
                             // XXX: full stop appended as some relic here, but these
@@ -263,15 +479,21 @@ export default class CallHandler {
                         Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
                             title, description,
                         });
-                    } else if (call.hangupReason === CallErrorCode.AnsweredElsewhere) {
-                        this.play(AudioID.Busy);
+                    } else if (
+                        call.hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
+                    ) {
                         Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
                             title: _t("Answered Elsewhere"),
                             description: _t("The call was answered on another device."),
                         });
-                    } else {
+                    } else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
+                        // don't play the end-call sound for calls that never got off the ground
                         this.play(AudioID.CallEnd);
                     }
+
+                    this.logCallStats(call, mappedRoomId);
+                    break;
+                }
             }
         });
         call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
@@ -285,26 +507,110 @@ export default class CallHandler {
                 this.pause(AudioID.Ringback);
             }
 
-            this.calls.set(newCall.roomId, newCall);
+            this.calls.set(mappedRoomId, newCall);
+            this.emit(CallHandlerEvent.CallsChanged, this.calls);
             this.setCallListeners(newCall);
             this.setCallState(newCall, newCall.state);
         });
+        call.on(CallEvent.AssertedIdentityChanged, async () => {
+            if (!this.matchesCallForThisRoom(call)) return;
+
+            console.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity());
+
+            const newAssertedIdentity = call.getRemoteAssertedIdentity().id;
+            let newNativeAssertedIdentity = newAssertedIdentity;
+            if (newAssertedIdentity) {
+                const response = await this.sipNativeLookup(newAssertedIdentity);
+                if (response.length && response[0].fields.lookup_success) {
+                    newNativeAssertedIdentity = response[0].userid;
+                }
+            }
+            console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
+
+            if (newNativeAssertedIdentity) {
+                this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity;
+
+                // If we don't already have a room with this user, make one. This will be slightly odd
+                // if they called us because we'll be inviting them, but there's not much we can do about
+                // this if we want the actual, native room to exist (which we do). This is why it's
+                // important to only obey asserted identity in trusted environments, since anyone you're
+                // on a call with can cause you to send a room invite to someone.
+                await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity);
+
+                const newMappedRoomId = this.roomIdForCall(call);
+                console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`);
+                if (newMappedRoomId !== mappedRoomId) {
+                    this.removeCallForRoom(mappedRoomId);
+                    mappedRoomId = newMappedRoomId;
+                    console.log("Moving call to room " + mappedRoomId);
+                    this.calls.set(mappedRoomId, call);
+                    this.emit(CallHandlerEvent.CallChangeRoom, call);
+                }
+            }
+        });
+    }
+
+    private async logCallStats(call: MatrixCall, mappedRoomId: string) {
+        const stats = await call.getCurrentCallStats();
+        logger.debug(
+            `Call completed. Call ID: ${call.callId}, virtual room ID: ${call.roomId}, ` +
+            `user-facing room ID: ${mappedRoomId}, direction: ${call.direction}, ` +
+            `our Party ID: ${call.ourPartyId}, hangup party: ${call.hangupParty}, ` +
+            `hangup reason: ${call.hangupReason}`,
+        );
+        if (!stats) {
+            logger.debug(
+                "Call statistics are undefined. The call has " +
+                "probably failed before a peerConn was established",
+            );
+            return;
+        }
+        logger.debug("Local candidates:");
+        for (const cand of stats.filter(item => item.type === 'local-candidate')) {
+            const address = cand.address || cand.ip; // firefox uses 'address', chrome uses 'ip'
+            logger.debug(
+                `${cand.id} - type: ${cand.candidateType}, address: ${address}, port: ${cand.port}, ` +
+                `protocol: ${cand.protocol}, relay protocol: ${cand.relayProtocol}, network type: ${cand.networkType}`,
+            );
+        }
+        logger.debug("Remote candidates:");
+        for (const cand of stats.filter(item => item.type === 'remote-candidate')) {
+            const address = cand.address || cand.ip; // firefox uses 'address', chrome uses 'ip'
+            logger.debug(
+                `${cand.id} - type: ${cand.candidateType}, address: ${address}, port: ${cand.port}, ` +
+                `protocol: ${cand.protocol}`,
+            );
+        }
+        logger.debug("Candidate pairs:");
+        for (const pair of stats.filter(item => item.type === 'candidate-pair')) {
+            logger.debug(
+                `${pair.localCandidateId} / ${pair.remoteCandidateId} - state: ${pair.state}, ` +
+                `nominated: ${pair.nominated}, ` +
+                `requests sent ${pair.requestsSent}, requests received  ${pair.requestsReceived},  ` +
+                `responses received: ${pair.responsesReceived}, responses sent: ${pair.responsesSent}, ` +
+                `bytes received: ${pair.bytesReceived}, bytes sent: ${pair.bytesSent}, `,
+            );
+        }
     }
 
     private setCallState(call: MatrixCall, status: CallState) {
+        const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
+
         console.log(
-            `Call state in ${call.roomId} changed to ${status}`,
+            `Call state in ${mappedRoomId} changed to ${status}`,
         );
 
         dis.dispatch({
             action: 'call_state',
-            room_id: call.roomId,
+            room_id: mappedRoomId,
             state: status,
         });
     }
 
     private removeCallForRoom(roomId: string) {
+        console.log("Removing call for room ", roomId);
         this.calls.delete(roomId);
+        this.emit(CallHandlerEvent.CallsChanged, this.calls);
     }
 
     private showICEFallbackPrompt() {
@@ -336,23 +642,61 @@ export default class CallHandler {
         }, null, true);
     }
 
+    private showMediaCaptureError(call: MatrixCall) {
+        let title;
+        let description;
 
-    private placeCall(
-        roomId: string, type: PlaceCallType,
-        localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
-    ) {
+        if (call.type === CallType.Voice) {
+            title = _t("Unable to access microphone");
+            description = 
+ {_t( + "Call failed because microphone could not be accessed. " + + "Check that a microphone is plugged in and set up correctly.", + )} +
; + } else if (call.type === CallType.Video) { + title = _t("Unable to access webcam / microphone"); + description =
+ {_t("Call failed because webcam or microphone could not be accessed. Check that:")} +
    +
  • {_t("A microphone and webcam are plugged in and set up correctly")}
  • +
  • {_t("Permission is granted to use the webcam")}
  • +
  • {_t("No other application is using the webcam")}
  • +
+
; + } + + Modal.createTrackedDialog('Media capture failed', '', ErrorDialog, { + title, description, + }, null, true); + } + + private async placeCall(roomId: string, type: PlaceCallType, transferee: MatrixCall) { Analytics.trackEvent('voip', 'placeCall', 'type', type); CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); - const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId); + + const mappedRoomId = (await VoipUserMapper.sharedInstance().getOrCreateVirtualRoomForRoom(roomId)) || roomId; + logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId); + + const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now(); + console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); + const call = MatrixClientPeg.get().createCall(mappedRoomId); + + console.log("Adding call for room ", roomId); this.calls.set(roomId, call); + this.emit(CallHandlerEvent.CallsChanged, this.calls); + if (transferee) { + this.transferees[call.callId] = transferee; + } + this.setCallListeners(call); + + this.setActiveCallRoomId(roomId); + if (type === PlaceCallType.Voice) { call.placeVoiceCall(); } else if (type === 'video') { - call.placeVideoCall( - remoteElement, - localElement, - ); + call.placeVideoCall(); } else if (type === PlaceCallType.ScreenSharing) { const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); if (screenCapErrorString) { @@ -364,9 +708,16 @@ export default class CallHandler { }); return; } - call.placeScreenSharingCall(remoteElement, localElement); + + call.placeScreenSharingCall( + async (): Promise => { + const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); + const [source] = await finished; + return source; + }, + ); } else { - console.error("Unknown conf call type: %s", type); + console.error("Unknown conf call type: " + type); } } @@ -374,12 +725,10 @@ export default class CallHandler { switch (payload.action) { case 'place_call': { - if (this.getAnyActiveCall()) { - Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { - title: _t('Existing Call'), - description: _t('You are already in a call.'), - }); - return; // don't allow >1 call to be placed. + // We might be using managed hybrid widgets + if (isManagedHybridWidgetEnabled()) { + addManagedHybridWidget(payload.room_id); + return; } // if the runtime env doesn't do VoIP, whine. @@ -391,9 +740,26 @@ export default class CallHandler { return; } + // don't allow > 2 calls to be placed. + if (this.getAllActiveCalls().length > 1) { + Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { + title: _t('Too Many Calls'), + description: _t("You've reached the maximum number of simultaneous calls."), + }); + return; + } + const room = MatrixClientPeg.get().getRoom(payload.room_id); if (!room) { - console.error("Room %s does not exist.", payload.room_id); + console.error(`Room ${payload.room_id} does not exist.`); + return; + } + + if (this.getCallForRoom(room.roomId)) { + Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, { + title: _t('Already in call'), + description: _t("You're already in a call with this person."), + }); return; } @@ -404,53 +770,61 @@ export default class CallHandler { }); return; } else if (members.length === 2) { - console.info("Place %s call in %s", payload.type, payload.room_id); + console.info(`Place ${payload.type} call in ${payload.room_id}`); - this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element); + this.placeCall(payload.room_id, payload.type, payload.transferee); } else { // > 2 dis.dispatch({ action: "place_conference_call", room_id: payload.room_id, type: payload.type, - remote_element: payload.remote_element, - local_element: payload.local_element, }); } } break; case 'place_conference_call': - console.info("Place conference call in %s", payload.room_id); + console.info("Place conference call in " + payload.room_id); Analytics.trackEvent('voip', 'placeConferenceCall'); CountlyAnalytics.instance.trackStartCall(payload.room_id, payload.type === PlaceCallType.Video, true); this.startCallApp(payload.room_id, payload.type); break; case 'end_conference': - console.info("Terminating conference call in %s", payload.room_id); + console.info("Terminating conference call in " + payload.room_id); this.terminateCallApp(payload.room_id); break; case 'hangup_conference': - console.info("Leaving conference call in %s", payload.room_id); + console.info("Leaving conference call in "+ payload.room_id); this.hangupCallApp(payload.room_id); break; case 'incoming_call': { - if (this.getAnyActiveCall()) { - // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup. - // we avoid rejecting with "busy" in case the user wants to answer it on a different device. - // in future we could signal a "local busy" as a warning to the caller. - // see https://github.com/vector-im/vector-web/issues/1964 - return; - } - // if the runtime env doesn't do VoIP, stop here. if (!MatrixClientPeg.get().supportsVoip()) { return; } const call = payload.call as MatrixCall; + + const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); + if (this.getCallForRoom(mappedRoomId)) { + console.log( + "Got incoming call for room " + mappedRoomId + + " but there's already a call for this room: ignoring", + ); + return; + } + Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); - this.calls.set(call.roomId, call) + console.log("Adding call for room ", mappedRoomId); + this.calls.set(mappedRoomId, call); + this.emit(CallHandlerEvent.CallsChanged, this.calls); this.setCallListeners(call); + + // get ready to send encrypted events in the room, so if the user does answer + // the call, we'll be ready to send. NB. This is the protocol-level room ID not + // the mapped one: that's where we'll send the events. + const cli = MatrixClientPeg.get(); + cli.prepareToEncrypt(cli.getRoom(call.roomId)); } break; case 'hangup': @@ -463,14 +837,30 @@ export default class CallHandler { } else { this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false); } - this.removeCallForRoom(payload.room_id); + // don't remove the call yet: let the hangup event handler do it (otherwise it will throw + // the hangup event away) + break; + case 'hangup_all': + for (const call of this.calls.values()) { + call.hangup(CallErrorCode.UserHangup, false); + } break; case 'answer': { if (!this.calls.has(payload.room_id)) { return; // no call to answer } + + if (this.getAllActiveCalls().length > 1) { + Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { + title: _t('Too Many Calls'), + description: _t("You've reached the maximum number of simultaneous calls."), + }); + return; + } + const call = this.calls.get(payload.room_id); call.answer(); + this.setActiveCallRoomId(payload.room_id); CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false); dis.dispatch({ action: "view_room", @@ -478,7 +868,116 @@ export default class CallHandler { }); break; } + case Action.DialNumber: + this.dialNumber(payload.number); + break; + case Action.TransferCallToMatrixID: + this.startTransferToMatrixID(payload.call, payload.destination, payload.consultFirst); + break; + case Action.TransferCallToPhoneNumber: + this.startTransferToPhoneNumber(payload.call, payload.destination, payload.consultFirst); + break; } + }; + + private async dialNumber(number: string) { + const results = await this.pstnLookup(number); + if (!results || results.length === 0 || !results[0].userid) { + Modal.createTrackedDialog('', '', ErrorDialog, { + title: _t("Unable to look up phone number"), + description: _t("There was an error looking up the phone number"), + }); + return; + } + const userId = results[0].userid; + + // Now check to see if this is a virtual user, in which case we should find the + // native user + let nativeUserId; + if (this.getSupportsVirtualRooms()) { + const nativeLookupResults = await this.sipNativeLookup(userId); + const lookupSuccess = nativeLookupResults.length > 0 && nativeLookupResults[0].fields.lookup_success; + nativeUserId = lookupSuccess ? nativeLookupResults[0].userid : userId; + console.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId); + } else { + nativeUserId = userId; + } + + const roomId = await ensureDMExists(MatrixClientPeg.get(), nativeUserId); + + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); + } + + private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) { + const results = await this.pstnLookup(destination); + if (!results || results.length === 0 || !results[0].userid) { + Modal.createTrackedDialog('', '', ErrorDialog, { + title: _t("Unable to transfer call"), + description: _t("There was an error looking up the phone number"), + }); + return; + } + + await this.startTransferToMatrixID(call, results[0].userid, consultFirst); + } + + private async startTransferToMatrixID(call: MatrixCall, destination: string, consultFirst: boolean) { + if (consultFirst) { + const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), destination); + + dis.dispatch({ + action: 'place_call', + type: call.type, + room_id: dmRoomId, + transferee: call, + }); + dis.dispatch({ + action: 'view_room', + room_id: dmRoomId, + should_peek: false, + joining: false, + }); + } else { + try { + await call.transfer(destination); + } catch (e) { + console.log("Failed to transfer call", e); + Modal.createTrackedDialog('Failed to transfer call', '', ErrorDialog, { + title: _t('Transfer Failed'), + description: _t('Failed to transfer call'), + }); + } + } + } + + setActiveCallRoomId(activeCallRoomId: string) { + logger.info("Setting call in room " + activeCallRoomId + " active"); + + for (const [roomId, call] of this.calls.entries()) { + if (call.state === CallState.Ended) continue; + + if (roomId === activeCallRoomId) { + call.setRemoteOnHold(false); + } else { + logger.info("Holding call in room " + roomId + " because another call is being set active"); + call.setRemoteOnHold(true); + } + } + } + + /** + * @returns true if we are currently in any call where we haven't put the remote party on hold + */ + hasAnyUnheldCall() { + for (const call of this.calls.values()) { + if (call.state === CallState.Ended) continue; + if (!call.isRemoteOnHold()) return true; + } + + return false; } private async startCallApp(roomId: string, type: string) { @@ -510,11 +1009,12 @@ export default class CallHandler { // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification confId = base32.stringify(Buffer.from(roomId), { pad: false }); } else { - // Create a random human readable conference ID - confId = `JitsiConference${generateHumanReadableId()}`; + // Create a random conference ID + const random = randomUppercaseString(1) + randomLowercaseString(23); + confId = 'Jitsi' + random; } - let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth}); + let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({ auth: jitsiAuth }); // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets const parsedUrl = new URL(widgetUrl); @@ -527,6 +1027,7 @@ export default class CallHandler { isAudioOnly: type === 'voice', domain: jitsiDomain, auth: jitsiAuth, + roomName: room.name, }; const widgetId = ( diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js deleted file mode 100644 index 8d56467c57..0000000000 --- a/src/CallMediaHandler.js +++ /dev/null @@ -1,88 +0,0 @@ -/* - Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import * as Matrix from 'matrix-js-sdk'; -import SettingsStore from "./settings/SettingsStore"; -import {SettingLevel} from "./settings/SettingLevel"; - -export default { - hasAnyLabeledDevices: async function() { - const devices = await navigator.mediaDevices.enumerateDevices(); - return devices.some(d => !!d.label); - }, - - getDevices: function() { - // Only needed for Electron atm, though should work in modern browsers - // once permission has been granted to the webapp - return navigator.mediaDevices.enumerateDevices().then(function(devices) { - const audiooutput = []; - const audioinput = []; - const videoinput = []; - - devices.forEach((device) => { - switch (device.kind) { - case 'audiooutput': audiooutput.push(device); break; - case 'audioinput': audioinput.push(device); break; - case 'videoinput': videoinput.push(device); break; - } - }); - - // console.log("Loaded WebRTC Devices", mediaDevices); - return { - audiooutput, - audioinput, - videoinput, - }; - }, (error) => { console.log('Unable to refresh WebRTC Devices: ', error); }); - }, - - loadDevices: function() { - const audioOutDeviceId = SettingsStore.getValue("webrtc_audiooutput"); - const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); - const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); - - Matrix.setMatrixCallAudioOutput(audioOutDeviceId); - Matrix.setMatrixCallAudioInput(audioDeviceId); - Matrix.setMatrixCallVideoInput(videoDeviceId); - }, - - setAudioOutput: function(deviceId) { - SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); - Matrix.setMatrixCallAudioOutput(deviceId); - }, - - setAudioInput: function(deviceId) { - SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); - Matrix.setMatrixCallAudioInput(deviceId); - }, - - setVideoInput: function(deviceId) { - SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); - Matrix.setMatrixCallVideoInput(deviceId); - }, - - getAudioOutput: function() { - return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput"); - }, - - getAudioInput: function() { - return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput"); - }, - - getVideoInput: function() { - return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput"); - }, -}; diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index 5409a606de..0c65a7bd35 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -17,9 +17,9 @@ limitations under the License. */ import React from "react"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + import dis from './dispatcher/dispatcher'; -import {MatrixClientPeg} from './MatrixClientPeg'; -import {MatrixClient} from "matrix-js-sdk/src/client"; import * as sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; @@ -27,11 +27,18 @@ import RoomViewStore from './stores/RoomViewStore'; import encrypt from "browser-encrypt-attachment"; import extractPngChunks from "png-chunks-extract"; import Spinner from "./components/views/elements/Spinner"; - -// Polyfill for Canvas.toBlob API using Canvas.toDataURL -import "blueimp-canvas-to-blob"; import { Action } from "./dispatcher/actions"; import CountlyAnalytics from "./CountlyAnalytics"; +import { + UploadCanceledPayload, + UploadErrorPayload, + UploadFinishedPayload, + UploadProgressPayload, + UploadStartedPayload, +} from "./dispatcher/payloads/UploadPayload"; +import { IUpload } from "./models/IUpload"; +import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials"; +import { BlurhashEncoder } from "./BlurhashEncoder"; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; @@ -40,19 +47,12 @@ const MAX_HEIGHT = 600; // 5669 px (x-axis) , 5669 px (y-axis) , per metre const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01]; +export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448 + export class UploadCanceledError extends Error {} type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; -interface IUpload { - fileName: string; - roomId: string; - total: number; - loaded: number; - promise: Promise; - canceled?: boolean; -} - interface IMediaConfig { "m.upload.size"?: number; } @@ -79,14 +79,11 @@ interface IThumbnail { }; w: number; h: number; + [BLURHASH_FIELD]: string; }; thumbnail: Blob; } -interface IAbortablePromise extends Promise { - abort(): void; -} - /** * Create a thumbnail for a image DOM element. * The image will be smaller than MAX_WIDTH and MAX_HEIGHT. @@ -105,44 +102,62 @@ interface IAbortablePromise extends Promise { * @return {Promise} A promise that resolves with an object with an info key * and a thumbnail key. */ -function createThumbnail( +async function createThumbnail( element: ThumbnailableElement, inputWidth: number, inputHeight: number, mimeType: string, ): Promise { - return new Promise((resolve) => { - let targetWidth = inputWidth; - let targetHeight = inputHeight; - if (targetHeight > MAX_HEIGHT) { - targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); - targetHeight = MAX_HEIGHT; - } - if (targetWidth > MAX_WIDTH) { - targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); - targetWidth = MAX_WIDTH; - } + let targetWidth = inputWidth; + let targetHeight = inputHeight; + if (targetHeight > MAX_HEIGHT) { + targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); + targetHeight = MAX_HEIGHT; + } + if (targetWidth > MAX_WIDTH) { + targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); + targetWidth = MAX_WIDTH; + } - const canvas = document.createElement("canvas"); + let canvas: HTMLCanvasElement | OffscreenCanvas; + if (window.OffscreenCanvas) { + canvas = new window.OffscreenCanvas(targetWidth, targetHeight); + } else { + canvas = document.createElement("canvas"); canvas.width = targetWidth; canvas.height = targetHeight; - canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); - canvas.toBlob(function(thumbnail) { - resolve({ - info: { - thumbnail_info: { - w: targetWidth, - h: targetHeight, - mimetype: thumbnail.type, - size: thumbnail.size, - }, - w: inputWidth, - h: inputHeight, - }, - thumbnail: thumbnail, - }); - }, mimeType); - }); + } + + const context = canvas.getContext("2d"); + context.drawImage(element, 0, 0, targetWidth, targetHeight); + + let thumbnailPromise: Promise; + + if (window.OffscreenCanvas) { + thumbnailPromise = (canvas as OffscreenCanvas).convertToBlob({ type: mimeType }); + } else { + thumbnailPromise = new Promise(resolve => (canvas as HTMLCanvasElement).toBlob(resolve, mimeType)); + } + + const imageData = context.getImageData(0, 0, targetWidth, targetHeight); + // thumbnailPromise and blurhash promise are being awaited concurrently + const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData); + const thumbnail = await thumbnailPromise; + + return { + info: { + thumbnail_info: { + w: targetWidth, + h: targetHeight, + mimetype: thumbnail.type, + size: thumbnail.size, + }, + w: inputWidth, + h: inputHeight, + [BLURHASH_FIELD]: blurhash, + }, + thumbnail, + }; } /** @@ -191,7 +206,7 @@ async function loadImageElement(imageFile: File) { const [hidpi] = await Promise.all([parsePromise, imgPromise]); const width = hidpi ? (img.width >> 1) : img.width; const height = hidpi ? (img.height >> 1) : img.height; - return {width, height, img}; + return { width, height, img }; } /** @@ -209,12 +224,12 @@ function infoForImageFile(matrixClient, roomId, imageFile) { } let imageInfo; - return loadImageElement(imageFile).then(function(r) { + return loadImageElement(imageFile).then((r) => { return createThumbnail(r.img, r.width, r.height, thumbnailType); - }).then(function(result) { + }).then((result) => { imageInfo = result.info; return uploadFile(matrixClient, roomId, result.thumbnail); - }).then(function(result) { + }).then((result) => { imageInfo.thumbnail_url = result.url; imageInfo.thumbnail_file = result.file; return imageInfo; @@ -222,7 +237,8 @@ function infoForImageFile(matrixClient, roomId, imageFile) { } /** - * Load a file into a newly created video element. + * Load a file into a newly created video element and pull some strings + * in an attempt to guarantee the first frame will be showing. * * @param {File} videoFile The file to load in an video element. * @return {Promise} A promise that resolves with the video image element. @@ -231,20 +247,25 @@ function loadVideoElement(videoFile): Promise { return new Promise((resolve, reject) => { // Load the file into an html element const video = document.createElement("video"); + video.preload = "metadata"; + video.playsInline = true; + video.muted = true; const reader = new FileReader(); reader.onload = function(ev) { - video.src = ev.target.result as string; - - // Once ready, returns its size // Wait until we have enough data to thumbnail the first frame. - video.onloadeddata = function() { + video.onloadeddata = async function() { resolve(video); + video.pause(); }; video.onerror = function(e) { reject(e); }; + + video.src = ev.target.result as string; + video.load(); + video.play(); }; reader.onerror = function(e) { reject(e); @@ -265,12 +286,12 @@ function infoForVideoFile(matrixClient, roomId, videoFile) { const thumbnailType = "image/jpeg"; let videoInfo; - return loadVideoElement(videoFile).then(function(video) { + return loadVideoElement(videoFile).then((video) => { return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType); - }).then(function(result) { + }).then((result) => { videoInfo = result.info; return uploadFile(matrixClient, roomId, result.thumbnail); - }).then(function(result) { + }).then((result) => { videoInfo.thumbnail_url = result.url; videoInfo.thumbnail_file = result.file; return videoInfo; @@ -309,7 +330,12 @@ function readFileAsArrayBuffer(file: File | Blob): Promise { * If the file is unencrypted then the object will have a "url" key. * If the file is encrypted then the object will have a "file" key. */ -function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blob, progressHandler?: any) { +export function uploadFile( + matrixClient: MatrixClient, + roomId: string, + file: File | Blob, + progressHandler?: any, // TODO: Types +): IAbortablePromise<{url?: string, file?: any}> { // TODO: Types let canceled = false; if (matrixClient.isRoomEncrypted(roomId)) { // If the room is encrypted then encrypt the file before uploading it. @@ -340,11 +366,11 @@ function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blo if (file.type) { encryptInfo.mimetype = file.type; } - return {"file": encryptInfo}; - }); - (prom as IAbortablePromise).abort = () => { + return { "file": encryptInfo }; + }) as IAbortablePromise<{ file: any }>; + prom.abort = () => { canceled = true; - if (uploadPromise) MatrixClientPeg.get().cancelUpload(uploadPromise); + if (uploadPromise) matrixClient.cancelUpload(uploadPromise); }; return prom; } else { @@ -354,11 +380,11 @@ function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blo const promise1 = basePromise.then(function(url) { if (canceled) throw new UploadCanceledError(); // If the attachment isn't encrypted then include the URL directly. - return {"url": url}; - }); + return { url }; + }) as IAbortablePromise<{ url: string }>; promise1.abort = () => { canceled = true; - MatrixClientPeg.get().cancelUpload(basePromise); + matrixClient.cancelUpload(basePromise); }; return promise1; } @@ -368,13 +394,13 @@ export default class ContentMessages { private inprogress: IUpload[] = []; private mediaConfig: IMediaConfig = null; - sendStickerContentToRoom(url: string, roomId: string, info: string, text: string, matrixClient: MatrixClient) { + sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) { const startTime = CountlyAnalytics.getTimestamp(); - const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { + const prom = matrixClient.sendStickerMessage(roomId, url, info, text).catch((e) => { console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); throw e; }); - CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, {msgtype: "m.sticker"}); + CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, { msgtype: "m.sticker" }); return prom; } @@ -388,14 +414,15 @@ export default class ContentMessages { async sendContentListToRoom(files: File[], roomId: string, matrixClient: MatrixClient) { if (matrixClient.isGuest()) { - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); return; } const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); if (isQuoting) { + // FIXME: Using an import will result in Element crashing const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const {finished} = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, { + const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, { title: _t('Replying With Files'), description: (
{_t( @@ -412,7 +439,7 @@ export default class ContentMessages { if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); - await this.ensureMediaConfigFetched(); + await this.ensureMediaConfigFetched(matrixClient); modal.close(); } @@ -428,8 +455,9 @@ export default class ContentMessages { } if (tooBigFiles.length > 0) { + // FIXME: Using an import will result in Element crashing const UploadFailureDialog = sdk.getComponent("dialogs.UploadFailureDialog"); - const {finished} = Modal.createTrackedDialog<[boolean]>('Upload Failure', '', UploadFailureDialog, { + const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Failure', '', UploadFailureDialog, { badFiles: tooBigFiles, totalFiles: files.length, contentMessages: this, @@ -438,15 +466,16 @@ export default class ContentMessages { if (!shouldContinue) return; } - const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); let uploadAll = false; // Promise to complete before sending next file into room, used for synchronisation of file-sending // to match the order the files were specified in - let promBefore = Promise.resolve(); + let promBefore: Promise = Promise.resolve(); for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; if (!uploadAll) { - const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', + // FIXME: Using an import will result in Element crashing + const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); + const { finished } = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', '', UploadConfirmDialog, { file, currentIndex: i, @@ -467,7 +496,7 @@ export default class ContentMessages { return this.inprogress.filter(u => !u.canceled); } - cancelUpload(promise: Promise) { + cancelUpload(promise: Promise, matrixClient: MatrixClient) { let upload: IUpload; for (let i = 0; i < this.inprogress.length; ++i) { if (this.inprogress[i].promise === promise) { @@ -477,8 +506,8 @@ export default class ContentMessages { } if (upload) { upload.canceled = true; - MatrixClientPeg.get().cancelUpload(upload.promise); - dis.dispatch({action: 'upload_canceled', upload}); + matrixClient.cancelUpload(upload.promise); + dis.dispatch({ action: Action.UploadCanceled, upload }); } } @@ -497,7 +526,7 @@ export default class ContentMessages { content.info.mimetype = file.type; } - const prom = new Promise((resolve) => { + const prom = new Promise((resolve) => { if (file.type.indexOf('image/') === 0) { content.msgtype = 'm.image'; infoForImageFile(matrixClient, roomId, file).then((imageInfo) => { @@ -524,10 +553,10 @@ export default class ContentMessages { content.msgtype = 'm.file'; resolve(); } - }); + }) as IAbortablePromise; // create temporary abort handler for before the actual upload gets passed off to js-sdk - (prom as IAbortablePromise).abort = () => { + prom.abort = () => { upload.canceled = true; }; @@ -539,15 +568,15 @@ export default class ContentMessages { promise: prom, }; this.inprogress.push(upload); - dis.dispatch({action: 'upload_started'}); + dis.dispatch({ action: Action.UploadStarted, upload }); // Focus the composer view - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); function onProgress(ev) { upload.total = ev.total; upload.loaded = ev.loaded; - dis.dispatch({action: 'upload_progress', upload: upload}); + dis.dispatch({ action: Action.UploadProgress, upload }); } let error; @@ -556,9 +585,7 @@ export default class ContentMessages { // XXX: upload.promise must be the promise that // is returned by uploadFile as it has an abort() // method hacked onto it. - upload.promise = uploadFile( - matrixClient, roomId, file, onProgress, - ); + upload.promise = uploadFile(matrixClient, roomId, file, onProgress); return upload.promise.then(function(result) { content.file = result.file; content.url = result.url; @@ -574,13 +601,14 @@ export default class ContentMessages { }, function(err) { error = err; if (!upload.canceled) { - let desc = _t("The file '%(fileName)s' failed to upload.", {fileName: upload.fileName}); + let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName }); if (err.http_status === 413) { desc = _t( "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", - {fileName: upload.fileName}, + { fileName: upload.fileName }, ); } + // FIXME: Using an import will result in Element crashing const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Upload failed', '', ErrorDialog, { title: _t('Upload Failed'), @@ -601,10 +629,10 @@ export default class ContentMessages { if (error && error.http_status === 413) { this.mediaConfig = null; } - dis.dispatch({action: 'upload_failed', upload, error}); + dis.dispatch({ action: Action.UploadFailed, upload, error }); } else { - dis.dispatch({action: 'upload_finished', upload}); - dis.dispatch({action: 'message_sent'}); + dis.dispatch({ action: Action.UploadFinished, upload }); + dis.dispatch({ action: 'message_sent' }); } }); } @@ -618,11 +646,11 @@ export default class ContentMessages { return true; } - private ensureMediaConfigFetched() { + private ensureMediaConfigFetched(matrixClient: MatrixClient) { if (this.mediaConfig !== null) return; console.log("[Media Config] Fetching"); - return MatrixClientPeg.get().getMediaConfig().then((config) => { + return matrixClient.getMediaConfig().then((config) => { console.log("[Media Config] Fetched config:", config); return config; }).catch(() => { diff --git a/src/CountlyAnalytics.ts b/src/CountlyAnalytics.ts index b4727bc88b..72b0462bcd 100644 --- a/src/CountlyAnalytics.ts +++ b/src/CountlyAnalytics.ts @@ -14,21 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {randomString} from "matrix-js-sdk/src/randomstring"; +import { randomString } from "matrix-js-sdk/src/randomstring"; +import { IContent } from "matrix-js-sdk/src/models/event"; +import { sleep } from "matrix-js-sdk/src/utils"; -import {getCurrentLanguage} from './languageHandler'; +import { getCurrentLanguage } from './languageHandler'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import {sleep} from "./utils/promise"; +import { MatrixClientPeg } from "./MatrixClientPeg"; import RoomViewStore from "./stores/RoomViewStore"; - -// polyfill textencoder if necessary -import * as TextEncodingUtf8 from 'text-encoding-utf-8'; -let TextEncoder = window.TextEncoder; -if (!TextEncoder) { - TextEncoder = TextEncodingUtf8.TextEncoder; -} +import { Action } from "./dispatcher/actions"; const INACTIVITY_TIME = 20; // seconds const HEARTBEAT_INTERVAL = 5_000; // ms @@ -261,11 +256,11 @@ interface ICreateRoomEvent extends IEvent { num_users: number; is_encrypted: boolean; is_public: boolean; - } + }; } interface IJoinRoomEvent extends IEvent { - key: "join_room"; + key: Action.JoinRoom; dur: number; // how long it took to join (until remote echo) segmentation: { room_id: string; // hashed @@ -344,8 +339,8 @@ const getRoomStats = (roomId: string) => { "is_encrypted": cli?.isRoomEncrypted(roomId), // eslint-disable-next-line camelcase "is_public": room?.currentState.getStateEvents("m.room.join_rules", "")?.getContent()?.join_rule === "public", - } -} + }; +}; // async wrapper for regex-powered String.prototype.replace const strReplaceAsync = async (str: string, regex: RegExp, fn: (...args: string[]) => Promise) => { @@ -369,8 +364,8 @@ export default class CountlyAnalytics { private initTime = CountlyAnalytics.getTimestamp(); private firstPage = true; - private heartbeatIntervalId: NodeJS.Timeout; - private activityIntervalId: NodeJS.Timeout; + private heartbeatIntervalId: number; + private activityIntervalId: number; private trackTime = true; private lastBeat: number; private storedDuration = 0; @@ -420,7 +415,7 @@ export default class CountlyAnalytics { this.anonymous = anonymous; if (anonymous) { - await this.changeUserKey(randomString(64)) + await this.changeUserKey(randomString(64)); } else { await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true); } @@ -444,7 +439,7 @@ export default class CountlyAnalytics { await this.track("Opt-Out" ); this.endSession(); window.clearInterval(this.heartbeatIntervalId); - window.clearTimeout(this.activityIntervalId) + window.clearTimeout(this.activityIntervalId); this.baseUrl = null; // remove listeners bound in trackSessions() window.removeEventListener("beforeunload", this.endSession); @@ -668,14 +663,14 @@ export default class CountlyAnalytics { } private queue(args: Omit & Partial>) { - const {count = 1, ...rest} = args; + const { count = 1, ...rest } = args; const ev = { ...this.getTimeParams(), ...rest, count, platform: this.appPlatform, app_version: this.appVersion, - } + }; this.pendingEvents.push(ev); if (this.pendingEvents.length > MAX_PENDING_EVENTS) { @@ -684,7 +679,9 @@ export default class CountlyAnalytics { } private getOrientation = (): Orientation => { - return window.innerWidth > window.innerHeight ? Orientation.Landscape : Orientation.Portrait; + return window.matchMedia("(orientation: landscape)").matches + ? Orientation.Landscape + : Orientation.Portrait; }; private reportOrientation = () => { @@ -753,7 +750,7 @@ export default class CountlyAnalytics { const request: Parameters[0] = { begin_session: 1, user_details: JSON.stringify(userDetails), - } + }; const metrics = this.getMetrics(); if (metrics) { @@ -777,7 +774,7 @@ export default class CountlyAnalytics { private endSession = () => { if (this.sessionStarted) { - window.removeEventListener("resize", this.reportOrientation) + window.removeEventListener("resize", this.reportOrientation); this.reportViewDuration(); this.request({ @@ -813,7 +810,9 @@ export default class CountlyAnalytics { window.addEventListener("mousemove", this.onUserActivity); window.addEventListener("click", this.onUserActivity); window.addEventListener("keydown", this.onUserActivity); - window.addEventListener("scroll", this.onUserActivity); + // Using the passive option to not block the main thread + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners + window.addEventListener("scroll", this.onUserActivity, { passive: true }); this.activityIntervalId = setInterval(() => { this.inactivityCounter++; @@ -840,7 +839,7 @@ export default class CountlyAnalytics { let endTime = CountlyAnalytics.getTimestamp(); const cli = MatrixClientPeg.get(); if (!cli.getRoom(roomId)) { - await new Promise(resolve => { + await new Promise(resolve => { const handler = (room) => { if (room.roomId === roomId) { cli.off("Room", handler); @@ -858,7 +857,7 @@ export default class CountlyAnalytics { } public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) { - this.track("join_room", { type }, roomId, { + this.track(Action.JoinRoom, { type }, roomId, { dur: CountlyAnalytics.getTimestamp() - startTime, }); } @@ -870,7 +869,7 @@ export default class CountlyAnalytics { roomId: string, isEdit: boolean, isReply: boolean, - content: {format?: string, msgtype: string}, + content: IContent, ) { if (this.disabled) return; const cli = MatrixClientPeg.get(); @@ -880,7 +879,7 @@ export default class CountlyAnalytics { let endTime = CountlyAnalytics.getTimestamp(); if (!room.findEventById(eventId)) { - await new Promise(resolve => { + await new Promise(resolve => { const handler = (ev) => { if (ev.getId() === eventId) { room.off("Room.localEchoUpdated", handler); diff --git a/src/DateUtils.ts b/src/DateUtils.ts index 9b1edf0775..e4a1175d88 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -97,7 +97,7 @@ export function formatFullDateNoTime(date: Date): string { }); } -export function formatFullDate(date: Date, showTwelveHour = false): string { +export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = true): string { const days = getDaysArray(); const months = getMonthsArray(); return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { @@ -105,7 +105,7 @@ export function formatFullDate(date: Date, showTwelveHour = false): string { monthName: months[date.getMonth()], day: date.getDate(), fullYear: date.getFullYear(), - time: formatFullTime(date, showTwelveHour), + time: showSeconds ? formatFullTime(date, showTwelveHour) : formatTime(date, showTwelveHour), }); } diff --git a/src/DecryptionFailureTracker.js b/src/DecryptionFailureTracker.ts similarity index 79% rename from src/DecryptionFailureTracker.js rename to src/DecryptionFailureTracker.ts index b02a5e937b..df306a54f5 100644 --- a/src/DecryptionFailureTracker.js +++ b/src/DecryptionFailureTracker.ts @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2018 - 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. @@ -14,34 +14,40 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixError } from "matrix-js-sdk/src/http-api"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + export class DecryptionFailure { - constructor(failedEventId, errorCode) { - this.failedEventId = failedEventId; - this.errorCode = errorCode; + public readonly ts: number; + + constructor(public readonly failedEventId: string, public readonly errorCode: string) { this.ts = Date.now(); } } +type TrackingFn = (count: number, trackedErrCode: string) => void; +type ErrCodeMapFn = (errcode: string) => string; + export class DecryptionFailureTracker { // Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list // is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did // are accumulated in `failureCounts`. - failures = []; + public failures: DecryptionFailure[] = []; // A histogram of the number of failures that will be tracked at the next tracking // interval, split by failure error code. - failureCounts = { + public failureCounts: Record = { // [errorCode]: 42 }; // Event IDs of failures that were tracked previously - trackedEventHashMap = { + public trackedEventHashMap: Record = { // [eventId]: true }; // Set to an interval ID when `start` is called - checkInterval = null; - trackInterval = null; + public checkInterval: number = null; + public trackInterval: number = null; // Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`. static TRACK_INTERVAL_MS = 60000; @@ -67,7 +73,7 @@ export class DecryptionFailureTracker { * @param {function?} errorCodeMapFn The function used to map error codes to the * trackedErrorCode. If not provided, the `.code` of errors will be used. */ - constructor(fn, errorCodeMapFn) { + constructor(private readonly fn: TrackingFn, private readonly errorCodeMapFn?: ErrCodeMapFn) { if (!fn || typeof fn !== 'function') { throw new Error('DecryptionFailureTracker requires tracking function'); } @@ -75,9 +81,6 @@ export class DecryptionFailureTracker { if (errorCodeMapFn && typeof errorCodeMapFn !== 'function') { throw new Error('DecryptionFailureTracker second constructor argument should be a function'); } - - this._trackDecryptionFailure = fn; - this._mapErrorCode = errorCodeMapFn; } // loadTrackedEventHashMap() { @@ -88,7 +91,7 @@ export class DecryptionFailureTracker { // localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap)); // } - eventDecrypted(e, err) { + public eventDecrypted(e: MatrixEvent, err: MatrixError | Error): void { if (err) { this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.code)); } else { @@ -97,18 +100,18 @@ export class DecryptionFailureTracker { } } - addDecryptionFailure(failure) { + public addDecryptionFailure(failure: DecryptionFailure): void { this.failures.push(failure); } - removeDecryptionFailuresForEvent(e) { + public removeDecryptionFailuresForEvent(e: MatrixEvent): void { this.failures = this.failures.filter((f) => f.failedEventId !== e.getId()); } /** * Start checking for and tracking failures. */ - start() { + public start(): void { this.checkInterval = setInterval( () => this.checkFailures(Date.now()), DecryptionFailureTracker.CHECK_INTERVAL_MS, @@ -123,7 +126,7 @@ export class DecryptionFailureTracker { /** * Clear state and stop checking for and tracking failures. */ - stop() { + public stop(): void { clearInterval(this.checkInterval); clearInterval(this.trackInterval); @@ -132,11 +135,11 @@ export class DecryptionFailureTracker { } /** - * Mark failures that occured before nowTs - GRACE_PERIOD_MS as failures that should be + * Mark failures that occurred before nowTs - GRACE_PERIOD_MS as failures that should be * tracked. Only mark one failure per event ID. * @param {number} nowTs the timestamp that represents the time now. */ - checkFailures(nowTs) { + public checkFailures(nowTs: number): void { const failuresGivenGrace = []; const failuresNotReady = []; while (this.failures.length > 0) { @@ -165,7 +168,7 @@ export class DecryptionFailureTracker { const trackedEventIds = [...dedupedFailuresMap.keys()]; this.trackedEventHashMap = trackedEventIds.reduce( - (result, eventId) => ({...result, [eventId]: true}), + (result, eventId) => ({ ...result, [eventId]: true }), this.trackedEventHashMap, ); @@ -175,10 +178,10 @@ export class DecryptionFailureTracker { const dedupedFailures = dedupedFailuresMap.values(); - this._aggregateFailures(dedupedFailures); + this.aggregateFailures(dedupedFailures); } - _aggregateFailures(failures) { + private aggregateFailures(failures: DecryptionFailure[]): void { for (const failure of failures) { const errorCode = failure.errorCode; this.failureCounts[errorCode] = (this.failureCounts[errorCode] || 0) + 1; @@ -189,12 +192,12 @@ export class DecryptionFailureTracker { * If there are failures that should be tracked, call the given trackDecryptionFailure * function with the number of failures that should be tracked. */ - trackFailures() { + public trackFailures(): void { for (const errorCode of Object.keys(this.failureCounts)) { if (this.failureCounts[errorCode] > 0) { - const trackedErrorCode = this._mapErrorCode ? this._mapErrorCode(errorCode) : errorCode; + const trackedErrorCode = this.errorCodeMapFn ? this.errorCodeMapFn(errorCode) : errorCode; - this._trackDecryptionFailure(this.failureCounts[errorCode], trackedErrorCode); + this.fn(this.failureCounts[errorCode], trackedErrorCode); this.failureCounts[errorCode] = 0; } } diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index df494e6bdd..d033063677 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from "./dispatcher/dispatcher"; import { hideToast as hideBulkUnverifiedSessionsToast, @@ -160,7 +160,8 @@ export default class DeviceListener { // which result in account data changes affecting checks below. if ( ev.getType().startsWith('m.secret_storage.') || - ev.getType().startsWith('m.cross_signing.') + ev.getType().startsWith('m.cross_signing.') || + ev.getType() === 'm.megolm_backup.v1' ) { this._recheck(); } diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index e7ae3217bb..ea1813876c 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -19,9 +19,8 @@ import Modal from './Modal'; import * as sdk from './'; import MultiInviter from './utils/MultiInviter'; import { _t } from './languageHandler'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import GroupStore from './stores/GroupStore'; -import {allSettled} from "./utils/promise"; import StyledCheckbox from './components/views/elements/StyledCheckbox'; export function showGroupInviteDialog(groupId) { @@ -104,7 +103,7 @@ function _onGroupInviteFinished(groupId, addrs) { if (errorList.length > 0) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite the following users to the group', '', ErrorDialog, { - title: _t("Failed to invite the following users to %(groupId)s:", {groupId: groupId}), + title: _t("Failed to invite the following users to %(groupId)s:", { groupId: groupId }), description: errorList.join(", "), }); } @@ -112,7 +111,7 @@ function _onGroupInviteFinished(groupId, addrs) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite users to group', '', ErrorDialog, { title: _t("Failed to invite users to community"), - description: _t("Failed to invite users to %(groupId)s", {groupId: groupId}), + description: _t("Failed to invite users to %(groupId)s", { groupId: groupId }), }); }); } @@ -120,7 +119,7 @@ function _onGroupInviteFinished(groupId, addrs) { function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { const matrixClient = MatrixClientPeg.get(); const errorList = []; - return allSettled(addrs.map((addr) => { + return Promise.allSettled(addrs.map((addr) => { return GroupStore .addRoomToGroup(groupId, addr.address, addRoomsPublicly) .catch(() => { errorList.push(addr.address); }) @@ -138,7 +137,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { // Add this group as related if (!groups.includes(groupId)) { groups.push(groupId); - return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, ''); + return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', { groups }, ''); } }); })).then(() => { @@ -148,13 +147,15 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog( 'Failed to add the following room to the group', - '', ErrorDialog, - { - title: _t( - "Failed to add the following rooms to %(groupId)s:", - {groupId}, - ), - description: errorList.join(", "), - }); + '', + ErrorDialog, + { + title: _t( + "Failed to add the following rooms to %(groupId)s:", + { groupId }, + ), + description: errorList.join(", "), + }, + ); }); } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 07bfd4858a..5e83fdc2a0 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -17,22 +17,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import sanitizeHtml from 'sanitize-html'; -import { IExtendedSanitizeOptions } from './@types/sanitize-html'; +import cheerio from 'cheerio'; import * as linkify from 'linkifyjs'; -import linkifyMatrix from './linkify-matrix'; import _linkifyElement from 'linkifyjs/element'; import _linkifyString from 'linkifyjs/string'; import classNames from 'classnames'; import EMOJIBASE_REGEX from 'emojibase-regex'; import url from 'url'; +import katex from 'katex'; +import { AllHtmlEntities } from 'html-entities'; +import { IContent } from 'matrix-js-sdk/src/models/event'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { IExtendedSanitizeOptions } from './@types/sanitize-html'; +import linkifyMatrix from './linkify-matrix'; import SettingsStore from './settings/SettingsStore'; -import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; -import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; +import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; +import { SHORTCODE_TO_EMOJI, getEmojiFromUnicode } from "./emoji"; import ReplyThread from "./components/views/elements/ReplyThread"; +import { mediaFromMxc } from "./customisations/Media"; linkifyMatrix(linkify); @@ -56,6 +60,8 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; +const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; + /* * Return true if the given string contains emoji * Uses a much, much simpler regex than emojibase's so will give false @@ -63,7 +69,7 @@ export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet' * need emojification. * unicodeToImage uses this function. */ -function mightContainEmoji(str: string) { +function mightContainEmoji(str: string): boolean { return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); } @@ -73,7 +79,7 @@ function mightContainEmoji(str: string) { * @param {String} char The emoji character * @return {String} The shortcode (such as :thumbup:) */ -export function unicodeToShortcode(char: string) { +export function unicodeToShortcode(char: string): string { const data = getEmojiFromUnicode(char); return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); } @@ -84,7 +90,7 @@ export function unicodeToShortcode(char: string) { * @param {String} shortcode The shortcode (such as :thumbup:) * @return {String} The emoji character; null if none exists */ -export function shortcodeToUnicode(shortcode: string) { +export function shortcodeToUnicode(shortcode: string): string { shortcode = shortcode.slice(1, shortcode.length - 1); const data = SHORTCODE_TO_EMOJI.get(shortcode); return data ? data.unicode : null; @@ -121,17 +127,20 @@ export function processHtmlForSending(html: string): string { * Given an untrusted HTML string, return a React node with an sanitized version * of that HTML. */ -export function sanitizedHtmlNode(insaneHtml: string) { +export function sanitizedHtmlNode(insaneHtml: string): ReactNode { const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); return
; } -export function sanitizedHtmlNodeInnerText(insaneHtml: string) { - const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); - const contentDiv = document.createElement("div"); - contentDiv.innerHTML = saneHtml; - return contentDiv.innerText; +export function getHtmlText(insaneHtml: string): string { + return sanitizeHtml(insaneHtml, { + allowedTags: [], + allowedAttributes: {}, + selfClosing: [], + allowedSchemes: [], + disallowedTagsMode: 'discard', + }); } /** @@ -142,7 +151,7 @@ export function sanitizedHtmlNodeInnerText(insaneHtml: string) { * other places we need to sanitise URLs. * @return true if permitted, otherwise false */ -export function isUrlPermitted(inputUrl: string) { +export function isUrlPermitted(inputUrl: string): boolean { try { const parsed = url.parse(inputUrl); if (!parsed.protocol) return false; @@ -160,7 +169,7 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to attribs.target = '_blank'; // by default const transformed = tryTransformPermalinkToLocalHref(attribs.href); - if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN)) { + if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.ELEMENT_URL_PATTERN)) { attribs.href = transformed; delete attribs.target; } @@ -169,20 +178,31 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to return { tagName, attribs }; }, 'img': function(tagName: string, attribs: sanitizeHtml.Attributes) { + let src = attribs.src; // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // because transformTags is used _before_ we filter by allowedSchemesByTag and // we don't want to allow images with `https?` `src`s. // We also drop inline images (as if they were not present at all) when the "show // images" preference is disabled. Future work might expose some UI to reveal them // like standalone image events have. - if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) { - return { tagName, attribs: {}}; + if (!src || !SettingsStore.getValue("showImages")) { + return { tagName, attribs: {} }; } - attribs.src = MatrixClientPeg.get().mxcUrlToHttp( - attribs.src, - attribs.width || 800, - attribs.height || 600, - ); + + if (!src.startsWith("mxc://")) { + const match = MEDIA_API_MXC_REGEX.exec(src); + if (match) { + src = `mxc://${match[1]}/${match[2]}`; + } + } + + if (!src.startsWith("mxc://")) { + return { tagName, attribs: {} }; + } + + const width = Number(attribs.width) || 800; + const height = Number(attribs.height) || 600; + attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height); return { tagName, attribs }; }, 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) { @@ -236,11 +256,13 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = { 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', + 'details', 'summary', ], allowedAttributes: { // custom ones first: font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix - span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix + span: ['data-mx-maths', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix + div: ['data-mx-maths'], a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix img: ['src', 'width', 'height', 'alt', 'title'], ol: ['start'], @@ -345,20 +367,21 @@ class HtmlHighlighter extends BaseHighlighter { } } -interface IContent { - format?: string; - // eslint-disable-next-line camelcase - formatted_body?: string; - body: string; -} - interface IOpts { highlightLink?: string; disableBigEmoji?: boolean; stripReplyFallback?: boolean; returnString?: boolean; forComposerQuote?: boolean; - ref?: React.Ref; + ref?: React.Ref; +} + +export interface IOptsReturnNode extends IOpts { + returnString: false | undefined; +} + +export interface IOptsReturnString extends IOpts { + returnString: true; } /* turn a matrix event body into html @@ -374,6 +397,8 @@ interface IOpts { * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer * opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString) */ +export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnString): string; +export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnNode): ReactNode; export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) { const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; let bodyHasEmoji = false; @@ -393,9 +418,14 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts try { if (highlights && highlights.length > 0) { const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); - const safeHighlights = highlights.map(function(highlight) { - return sanitizeHtml(highlight, sanitizeParams); - }); + const safeHighlights = highlights + // sanitizeHtml can hang if an unclosed HTML tag is thrown at it + // A search for ` !highlight.includes("<")) + .map((highlight: string): string => sanitizeHtml(highlight, sanitizeParams)); // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. sanitizeParams.textFilter = function(safeText) { return highlighter.applyHighlights(safeText, safeHighlights).join(''); @@ -414,18 +444,41 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts if (isHtmlMessage) { isDisplayedWithHtml = true; safeBody = sanitizeHtml(formattedBody, sanitizeParams); + + if (SettingsStore.getValue("feature_latex_maths")) { + const phtml = cheerio.load(safeBody, { + // @ts-ignore: The `_useHtmlParser2` internal option is the + // simplest way to both parse and render using `htmlparser2`. + _useHtmlParser2: true, + decodeEntities: false, + }); + // @ts-ignore - The types for `replaceWith` wrongly expect + // Cheerio instance to be returned. + phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) { + return katex.renderToString( + AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')), + { + throwOnError: false, + // @ts-ignore - `e` can be an Element, not just a Node + displayMode: e.name == 'div', + output: "htmlAndMathml", + }); + }); + safeBody = phtml.html(); + } } } finally { delete sanitizeParams.textFilter; } + const contentBody = isDisplayedWithHtml ? safeBody : strippedBody; if (opts.returnString) { - return isDisplayedWithHtml ? safeBody : strippedBody; + return contentBody; } let emojiBody = false; if (!opts.disableBigEmoji && bodyHasEmoji) { - let contentBodyTrimmed = strippedBody !== undefined ? strippedBody.trim() : ''; + let contentBodyTrimmed = contentBody !== undefined ? contentBody.trim() : ''; // Ignore spaces in body text. Emojis with spaces in between should // still be counted as purely emoji messages. @@ -472,7 +525,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @returns {string} Linkified string */ -export function linkifyString(str: string, options = linkifyMatrix.options) { +export function linkifyString(str: string, options = linkifyMatrix.options): string { return _linkifyString(str, options); } @@ -483,7 +536,7 @@ export function linkifyString(str: string, options = linkifyMatrix.options) { * @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options * @returns {object} */ -export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) { +export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options): HTMLElement { return _linkifyElement(element, options); } @@ -494,7 +547,7 @@ export function linkifyElement(element: HTMLElement, options = linkifyMatrix.opt * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @returns {string} */ -export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) { +export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options): string { return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } @@ -505,7 +558,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatri * @param {Node} node * @returns {bool} */ -export function checkBlockNode(node: Node) { +export function checkBlockNode(node: Node): boolean { switch (node.nodeName) { case "H1": case "H2": @@ -515,7 +568,6 @@ export function checkBlockNode(node: Node) { case "H6": case "PRE": case "BLOCKQUOTE": - case "DIV": case "P": case "UL": case "OL": @@ -528,6 +580,9 @@ export function checkBlockNode(node: Node) { case "TH": case "TD": return true; + case "DIV": + // don't treat math nodes as block nodes for deserializing + return !(node as HTMLElement).hasAttribute("data-mx-maths"); default: return false; } diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index fbdb6812ee..447c5edd30 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { createClient, SERVICE_TYPES } from 'matrix-js-sdk'; +import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; +import { createClient } from 'matrix-js-sdk/src/matrix'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import Modal from './Modal'; import * as sdk from './index'; import { _t } from './languageHandler'; @@ -126,7 +127,7 @@ export default class IdentityAuthClient { await this._matrixClient.getIdentityAccount(token); } catch (e) { if (e.errcode === "M_TERMS_NOT_SIGNED") { - console.log("Identity Server requires new terms to be agreed to"); + console.log("Identity server requires new terms to be agreed to"); await startTermsFlow([new Service( SERVICE_TYPES.IS, identityServerUrl, @@ -162,9 +163,10 @@ export default class IdentityAuthClient {
), button: _t("Trust"), - }); + }); const [confirmed] = await finished; if (confirmed) { + // eslint-disable-next-line react-hooks/rules-of-hooks useDefaultIdentityServer(); } else { throw new AbortedIdentityActionError( diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts new file mode 100644 index 0000000000..b2f70abff7 --- /dev/null +++ b/src/KeyBindingsDefaults.ts @@ -0,0 +1,407 @@ +/* +Copyright 2021 Clemens Zeidler + +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 { AutocompleteAction, IKeyBindingsProvider, KeyBinding, MessageComposerAction, NavigationAction, RoomAction, + RoomListAction } from "./KeyBindingsManager"; +import { isMac, Key } from "./Keyboard"; +import SettingsStore from "./settings/SettingsStore"; + +const messageComposerBindings = (): KeyBinding[] => { + const bindings: KeyBinding[] = [ + { + action: MessageComposerAction.SelectPrevSendHistory, + keyCombo: { + key: Key.ARROW_UP, + altKey: true, + ctrlKey: true, + }, + }, + { + action: MessageComposerAction.SelectNextSendHistory, + keyCombo: { + key: Key.ARROW_DOWN, + altKey: true, + ctrlKey: true, + }, + }, + { + action: MessageComposerAction.EditPrevMessage, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: MessageComposerAction.EditNextMessage, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + { + action: MessageComposerAction.CancelEditing, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: MessageComposerAction.FormatBold, + keyCombo: { + key: Key.B, + ctrlOrCmd: true, + }, + }, + { + action: MessageComposerAction.FormatItalics, + keyCombo: { + key: Key.I, + ctrlOrCmd: true, + }, + }, + { + action: MessageComposerAction.FormatQuote, + keyCombo: { + key: Key.GREATER_THAN, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: MessageComposerAction.EditUndo, + keyCombo: { + key: Key.Z, + ctrlOrCmd: true, + }, + }, + { + action: MessageComposerAction.MoveCursorToStart, + keyCombo: { + key: Key.HOME, + ctrlOrCmd: true, + }, + }, + { + action: MessageComposerAction.MoveCursorToEnd, + keyCombo: { + key: Key.END, + ctrlOrCmd: true, + }, + }, + ]; + if (isMac) { + bindings.push({ + action: MessageComposerAction.EditRedo, + keyCombo: { + key: Key.Z, + ctrlOrCmd: true, + shiftKey: true, + }, + }); + } else { + bindings.push({ + action: MessageComposerAction.EditRedo, + keyCombo: { + key: Key.Y, + ctrlOrCmd: true, + }, + }); + } + if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) { + bindings.push({ + action: MessageComposerAction.Send, + keyCombo: { + key: Key.ENTER, + ctrlOrCmd: true, + }, + }); + bindings.push({ + action: MessageComposerAction.NewLine, + keyCombo: { + key: Key.ENTER, + }, + }); + } else { + bindings.push({ + action: MessageComposerAction.Send, + keyCombo: { + key: Key.ENTER, + }, + }); + bindings.push({ + action: MessageComposerAction.NewLine, + keyCombo: { + key: Key.ENTER, + shiftKey: true, + }, + }); + if (isMac) { + bindings.push({ + action: MessageComposerAction.NewLine, + keyCombo: { + key: Key.ENTER, + altKey: true, + }, + }); + } + } + return bindings; +}; + +const autocompleteBindings = (): KeyBinding[] => { + return [ + { + action: AutocompleteAction.CompleteOrNextSelection, + keyCombo: { + key: Key.TAB, + }, + }, + { + action: AutocompleteAction.CompleteOrNextSelection, + keyCombo: { + key: Key.TAB, + ctrlKey: true, + }, + }, + { + action: AutocompleteAction.CompleteOrPrevSelection, + keyCombo: { + key: Key.TAB, + shiftKey: true, + }, + }, + { + action: AutocompleteAction.CompleteOrPrevSelection, + keyCombo: { + key: Key.TAB, + ctrlKey: true, + shiftKey: true, + }, + }, + { + action: AutocompleteAction.Cancel, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: AutocompleteAction.PrevSelection, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: AutocompleteAction.NextSelection, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + ]; +}; + +const roomListBindings = (): KeyBinding[] => { + return [ + { + action: RoomListAction.ClearSearch, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: RoomListAction.PrevRoom, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: RoomListAction.NextRoom, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + { + action: RoomListAction.SelectRoom, + keyCombo: { + key: Key.ENTER, + }, + }, + { + action: RoomListAction.CollapseSection, + keyCombo: { + key: Key.ARROW_LEFT, + }, + }, + { + action: RoomListAction.ExpandSection, + keyCombo: { + key: Key.ARROW_RIGHT, + }, + }, + ]; +}; + +const roomBindings = (): KeyBinding[] => { + const bindings: KeyBinding[] = [ + { + action: RoomAction.ScrollUp, + keyCombo: { + key: Key.PAGE_UP, + }, + }, + { + action: RoomAction.RoomScrollDown, + keyCombo: { + key: Key.PAGE_DOWN, + }, + }, + { + action: RoomAction.DismissReadMarker, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: RoomAction.JumpToOldestUnread, + keyCombo: { + key: Key.PAGE_UP, + shiftKey: true, + }, + }, + { + action: RoomAction.UploadFile, + keyCombo: { + key: Key.U, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: RoomAction.JumpToFirstMessage, + keyCombo: { + key: Key.HOME, + ctrlKey: true, + }, + }, + { + action: RoomAction.JumpToLatestMessage, + keyCombo: { + key: Key.END, + ctrlKey: true, + }, + }, + ]; + + if (SettingsStore.getValue('ctrlFForSearch')) { + bindings.push({ + action: RoomAction.FocusSearch, + keyCombo: { + key: Key.F, + ctrlOrCmd: true, + }, + }); + } + + return bindings; +}; + +const navigationBindings = (): KeyBinding[] => { + return [ + { + action: NavigationAction.FocusRoomSearch, + keyCombo: { + key: Key.K, + ctrlOrCmd: true, + }, + }, + { + action: NavigationAction.ToggleRoomSidePanel, + keyCombo: { + key: Key.PERIOD, + ctrlOrCmd: true, + }, + }, + { + action: NavigationAction.ToggleUserMenu, + // Ideally this would be CTRL+P for "Profile", but that's + // taken by the print dialog. CTRL+I for "Information" + // was previously chosen but conflicted with italics in + // composer, so CTRL+` it is + keyCombo: { + key: Key.BACKTICK, + ctrlOrCmd: true, + }, + }, + { + action: NavigationAction.ToggleShortCutDialog, + keyCombo: { + key: Key.SLASH, + ctrlOrCmd: true, + }, + }, + { + action: NavigationAction.ToggleShortCutDialog, + keyCombo: { + key: Key.SLASH, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: NavigationAction.GoToHome, + keyCombo: { + key: Key.H, + ctrlKey: true, + altKey: !isMac, + shiftKey: isMac, + }, + }, + { + action: NavigationAction.SelectPrevRoom, + keyCombo: { + key: Key.ARROW_UP, + altKey: true, + }, + }, + { + action: NavigationAction.SelectNextRoom, + keyCombo: { + key: Key.ARROW_DOWN, + altKey: true, + }, + }, + { + action: NavigationAction.SelectPrevUnreadRoom, + keyCombo: { + key: Key.ARROW_UP, + altKey: true, + shiftKey: true, + }, + }, + { + action: NavigationAction.SelectNextUnreadRoom, + keyCombo: { + key: Key.ARROW_DOWN, + altKey: true, + shiftKey: true, + }, + }, + ]; +}; + +export const defaultBindingsProvider: IKeyBindingsProvider = { + getMessageComposerBindings: messageComposerBindings, + getAutocompleteBindings: autocompleteBindings, + getRoomListBindings: roomListBindings, + getRoomBindings: roomBindings, + getNavigationBindings: navigationBindings, +}; diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts new file mode 100644 index 0000000000..4225d2f449 --- /dev/null +++ b/src/KeyBindingsManager.ts @@ -0,0 +1,273 @@ +/* +Copyright 2021 Clemens Zeidler + +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 { defaultBindingsProvider } from './KeyBindingsDefaults'; +import { isMac } from './Keyboard'; + +/** Actions for the chat message composer component */ +export enum MessageComposerAction { + /** Send a message */ + Send = 'Send', + /** Go backwards through the send history and use the message in composer view */ + SelectPrevSendHistory = 'SelectPrevSendHistory', + /** Go forwards through the send history */ + SelectNextSendHistory = 'SelectNextSendHistory', + /** Start editing the user's last sent message */ + EditPrevMessage = 'EditPrevMessage', + /** Start editing the user's next sent message */ + EditNextMessage = 'EditNextMessage', + /** Cancel editing a message or cancel replying to a message */ + CancelEditing = 'CancelEditing', + + /** Set bold format the current selection */ + FormatBold = 'FormatBold', + /** Set italics format the current selection */ + FormatItalics = 'FormatItalics', + /** Format the current selection as quote */ + FormatQuote = 'FormatQuote', + /** Undo the last editing */ + EditUndo = 'EditUndo', + /** Redo editing */ + EditRedo = 'EditRedo', + /** Insert new line */ + NewLine = 'NewLine', + /** Move the cursor to the start of the message */ + MoveCursorToStart = 'MoveCursorToStart', + /** Move the cursor to the end of the message */ + MoveCursorToEnd = 'MoveCursorToEnd', +} + +/** Actions for text editing autocompletion */ +export enum AutocompleteAction { + /** + * Select previous selection or, if the autocompletion window is not shown, open the window and select the first + * selection. + */ + CompleteOrPrevSelection = 'ApplySelection', + /** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */ + CompleteOrNextSelection = 'CompleteOrNextSelection', + /** Move to the previous autocomplete selection */ + PrevSelection = 'PrevSelection', + /** Move to the next autocomplete selection */ + NextSelection = 'NextSelection', + /** Close the autocompletion window */ + Cancel = 'Cancel', +} + +/** Actions for the room list sidebar */ +export enum RoomListAction { + /** Clear room list filter field */ + ClearSearch = 'ClearSearch', + /** Navigate up/down in the room list */ + PrevRoom = 'PrevRoom', + /** Navigate down in the room list */ + NextRoom = 'NextRoom', + /** Select room from the room list */ + SelectRoom = 'SelectRoom', + /** Collapse room list section */ + CollapseSection = 'CollapseSection', + /** Expand room list section, if already expanded, jump to first room in the selection */ + ExpandSection = 'ExpandSection', +} + +/** Actions for the current room view */ +export enum RoomAction { + /** Scroll up in the timeline */ + ScrollUp = 'ScrollUp', + /** Scroll down in the timeline */ + RoomScrollDown = 'RoomScrollDown', + /** Dismiss read marker and jump to bottom */ + DismissReadMarker = 'DismissReadMarker', + /** Jump to oldest unread message */ + JumpToOldestUnread = 'JumpToOldestUnread', + /** Upload a file */ + UploadFile = 'UploadFile', + /** Focus search message in a room (must be enabled) */ + FocusSearch = 'FocusSearch', + /** Jump to the first (downloaded) message in the room */ + JumpToFirstMessage = 'JumpToFirstMessage', + /** Jump to the latest message in the room */ + JumpToLatestMessage = 'JumpToLatestMessage', +} + +/** Actions for navigating do various menus, dialogs or screens */ +export enum NavigationAction { + /** Jump to room search (search for a room) */ + FocusRoomSearch = 'FocusRoomSearch', + /** Toggle the room side panel */ + ToggleRoomSidePanel = 'ToggleRoomSidePanel', + /** Toggle the user menu */ + ToggleUserMenu = 'ToggleUserMenu', + /** Toggle the short cut help dialog */ + ToggleShortCutDialog = 'ToggleShortCutDialog', + /** Got to the Element home screen */ + GoToHome = 'GoToHome', + /** Select prev room */ + SelectPrevRoom = 'SelectPrevRoom', + /** Select next room */ + SelectNextRoom = 'SelectNextRoom', + /** Select prev room with unread messages */ + SelectPrevUnreadRoom = 'SelectPrevUnreadRoom', + /** Select next room with unread messages */ + SelectNextUnreadRoom = 'SelectNextUnreadRoom', +} + +/** + * Represent a key combination. + * + * The combo is evaluated strictly, i.e. the KeyboardEvent must match exactly what is specified in the KeyCombo. + */ +export type KeyCombo = { + key?: string; + + /** On PC: ctrl is pressed; on Mac: meta is pressed */ + ctrlOrCmd?: boolean; + + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + shiftKey?: boolean; +}; + +export type KeyBinding = { + action: T; + keyCombo: KeyCombo; +}; + +/** + * Helper method to check if a KeyboardEvent matches a KeyCombo + * + * Note, this method is only exported for testing. + */ +export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean { + if (combo.key !== undefined) { + // When shift is pressed, letters are returned as upper case chars. In this case do a lower case comparison. + // This works for letter combos such as shift + U as well for none letter combos such as shift + Escape. + // If shift is not pressed, the toLowerCase conversion can be avoided. + if (ev.shiftKey) { + if (ev.key.toLowerCase() !== combo.key.toLowerCase()) { + return false; + } + } else if (ev.key !== combo.key) { + return false; + } + } + + const comboCtrl = combo.ctrlKey ?? false; + const comboAlt = combo.altKey ?? false; + const comboShift = combo.shiftKey ?? false; + const comboMeta = combo.metaKey ?? false; + // Tests mock events may keep the modifiers undefined; convert them to booleans + const evCtrl = ev.ctrlKey ?? false; + const evAlt = ev.altKey ?? false; + const evShift = ev.shiftKey ?? false; + const evMeta = ev.metaKey ?? false; + // When ctrlOrCmd is set, the keys need do evaluated differently on PC and Mac + if (combo.ctrlOrCmd) { + if (onMac) { + if (!evMeta + || evCtrl !== comboCtrl + || evAlt !== comboAlt + || evShift !== comboShift) { + return false; + } + } else { + if (!evCtrl + || evMeta !== comboMeta + || evAlt !== comboAlt + || evShift !== comboShift) { + return false; + } + } + return true; + } + + if (evMeta !== comboMeta + || evCtrl !== comboCtrl + || evAlt !== comboAlt + || evShift !== comboShift) { + return false; + } + + return true; +} + +export type KeyBindingGetter = () => KeyBinding[]; + +export interface IKeyBindingsProvider { + getMessageComposerBindings: KeyBindingGetter; + getAutocompleteBindings: KeyBindingGetter; + getRoomListBindings: KeyBindingGetter; + getRoomBindings: KeyBindingGetter; + getNavigationBindings: KeyBindingGetter; +} + +export class KeyBindingsManager { + /** + * List of key bindings providers. + * + * Key bindings from the first provider(s) in the list will have precedence over key bindings from later providers. + * + * To overwrite the default key bindings add a new providers before the default provider, e.g. a provider for + * customized key bindings. + */ + bindingsProviders: IKeyBindingsProvider[] = [ + defaultBindingsProvider, + ]; + + /** + * Finds a matching KeyAction for a given KeyboardEvent + */ + private getAction( + getters: KeyBindingGetter[], + ev: KeyboardEvent | React.KeyboardEvent, + ): T | undefined { + for (const getter of getters) { + const bindings = getter(); + const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); + if (binding) { + return binding.action; + } + } + return undefined; + } + + getMessageComposerAction(ev: KeyboardEvent | React.KeyboardEvent): MessageComposerAction | undefined { + return this.getAction(this.bindingsProviders.map(it => it.getMessageComposerBindings), ev); + } + + getAutocompleteAction(ev: KeyboardEvent | React.KeyboardEvent): AutocompleteAction | undefined { + return this.getAction(this.bindingsProviders.map(it => it.getAutocompleteBindings), ev); + } + + getRoomListAction(ev: KeyboardEvent | React.KeyboardEvent): RoomListAction | undefined { + return this.getAction(this.bindingsProviders.map(it => it.getRoomListBindings), ev); + } + + getRoomAction(ev: KeyboardEvent | React.KeyboardEvent): RoomAction | undefined { + return this.getAction(this.bindingsProviders.map(it => it.getRoomBindings), ev); + } + + getNavigationAction(ev: KeyboardEvent | React.KeyboardEvent): NavigationAction | undefined { + return this.getAction(this.bindingsProviders.map(it => it.getNavigationBindings), ev); + } +} + +const manager = new KeyBindingsManager(); + +export function getKeyBindingsManager(): KeyBindingsManager { + return manager; +} diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 7469624f5c..61ded93833 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -17,12 +17,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising -import Matrix from 'matrix-js-sdk'; +import { createClient } from 'matrix-js-sdk/src/matrix'; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; -import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg'; +import { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg'; import SecurityCustomisations from "./customisations/Security"; import EventIndexPeg from './indexing/EventIndexPeg'; import createMatrixClient from './utils/createMatrixClient'; @@ -33,7 +33,6 @@ import Presence from './Presence'; import dis from './dispatcher/dispatcher'; import DMRoomMap from './utils/DMRoomMap'; import Modal from './Modal'; -import * as sdk from './index'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; import PlatformPeg from "./PlatformPeg"; import { sendLoginRequest } from "./Login"; @@ -41,13 +40,21 @@ import * as StorageManager from './utils/StorageManager'; import SettingsStore from "./settings/SettingsStore"; import TypingStore from "./stores/TypingStore"; import ToastStore from "./stores/ToastStore"; -import {IntegrationManagers} from "./integrations/IntegrationManagers"; -import {Mjolnir} from "./mjolnir/Mjolnir"; +import { IntegrationManagers } from "./integrations/IntegrationManagers"; +import { Mjolnir } from "./mjolnir/Mjolnir"; import DeviceListener from "./DeviceListener"; -import {Jitsi} from "./widgets/Jitsi"; -import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; +import { Jitsi } from "./widgets/Jitsi"; +import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; +import CallHandler from './CallHandler'; +import LifecycleCustomisations from "./customisations/Lifecycle"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; +import { _t } from "./languageHandler"; +import LazyLoadingResyncDialog from "./components/views/dialogs/LazyLoadingResyncDialog"; +import LazyLoadingDisabledDialog from "./components/views/dialogs/LazyLoadingDisabledDialog"; +import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog"; +import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -145,20 +152,13 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise * Gets the user ID of the persisted session, if one exists. This does not validate * that the user's credentials still work, just that they exist and that a user ID * is associated with them. The session is not loaded. - * @returns {String} The persisted session's owner, if an owner exists. Null otherwise. + * @returns {[String, bool]} The persisted session's owner and whether the stored + * session is for a guest user, if an owner exists. If there is no stored session, + * return [null, null]. */ -export function getStoredSessionOwner(): string { - const {hsUrl, userId, accessToken} = getLocalStorageSessionVars(); - return hsUrl && userId && accessToken ? userId : null; -} - -/** - * @returns {bool} True if the stored session is for a guest user or false if it is - * for a real user. If there is no stored session, return null. - */ -export function getStoredSessionIsGuest(): boolean { - const sessVars = getLocalStorageSessionVars(); - return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null; +export async function getStoredSessionOwner(): Promise<[string, boolean]> { + const { hsUrl, userId, hasAccessToken, isGuest } = await getStoredSessionVars(); + return hsUrl && userId && hasAccessToken ? [userId, isGuest] : [null, null]; } /** @@ -166,7 +166,8 @@ export function getStoredSessionIsGuest(): boolean { * query-parameters extracted from the real query-string of the starting * URI. * - * @param {String} defaultDeviceDisplayName + * @param {string} defaultDeviceDisplayName + * @param {string} fragmentAfterLogin path to go to after a successful login, only used for "Try again" * * @returns {Promise} promise which resolves to true if we completed the token * login, else false @@ -174,6 +175,7 @@ export function getStoredSessionIsGuest(): boolean { export function attemptTokenLogin( queryParams: Record, defaultDeviceDisplayName?: string, + fragmentAfterLogin?: string, ): Promise { if (!queryParams.loginToken) { return Promise.resolve(false); @@ -183,6 +185,12 @@ export function attemptTokenLogin( const identityServer = localStorage.getItem(SSO_ID_SERVER_URL_KEY); if (!homeserver) { console.warn("Cannot log in with token: can't determine HS URL to use"); + Modal.createTrackedDialog("SSO", "Unknown HS", ErrorDialog, { + title: _t("We couldn't log you in"), + description: _t("We asked the browser to remember which homeserver you use to let you sign in, " + + "but unfortunately your browser has forgotten it. Go to the sign in page and try again."), + button: _t("Try again"), + }); return Promise.resolve(false); } @@ -195,15 +203,35 @@ export function attemptTokenLogin( }, ).then(function(creds) { console.log("Logged in with token"); - return clearStorage().then(() => { - persistCredentialsToLocalStorage(creds); + return clearStorage().then(async () => { + await persistCredentials(creds); // remember that we just logged in sessionStorage.setItem("mx_fresh_login", String(true)); return true; }); }).catch((err) => { - console.error("Failed to log in with login token: " + err + " " + - err.data); + Modal.createTrackedDialog("SSO", "Token Rejected", ErrorDialog, { + title: _t("We couldn't log you in"), + description: err.name === "ConnectionError" + ? _t("Your homeserver was unreachable and was not able to log you in. Please try again. " + + "If this continues, please contact your homeserver administrator.") + : _t("Your homeserver rejected your log in attempt. " + + "This could be due to things just taking too long. Please try again. " + + "If this continues, please contact your homeserver administrator."), + button: _t("Try again"), + onFinished: tryAgain => { + if (tryAgain) { + const cli = createClient({ + baseUrl: homeserver, + idBaseUrl: identityServer, + }); + const idpId = localStorage.getItem(SSO_IDP_ID_KEY) || undefined; + PlatformPeg.get().startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId); + } + }, + }); + console.error("Failed to log in with login token:"); + console.error(err); return false; }); } @@ -213,8 +241,6 @@ export function handleInvalidStoreError(e: InvalidStoreError): Promise { return Promise.resolve().then(() => { const lazyLoadEnabled = e.value; if (lazyLoadEnabled) { - const LazyLoadingResyncDialog = - sdk.getComponent("views.dialogs.LazyLoadingResyncDialog"); return new Promise((resolve) => { Modal.createDialog(LazyLoadingResyncDialog, { onFinished: resolve, @@ -225,8 +251,6 @@ export function handleInvalidStoreError(e: InvalidStoreError): Promise { // between LL/non-LL version on same host. // as disabling LL when previously enabled // is a strong indicator of this (/develop & /app) - const LazyLoadingDisabledDialog = - sdk.getComponent("views.dialogs.LazyLoadingDisabledDialog"); return new Promise((resolve) => { Modal.createDialog(LazyLoadingDisabledDialog, { onFinished: resolve, @@ -250,7 +274,7 @@ function registerAsGuest( console.log(`Doing guest login on ${hsUrl}`); // create a temporary MatrixClient to do the login - const client = Matrix.createClient({ + const client = createClient({ baseUrl: hsUrl, }); @@ -274,24 +298,42 @@ function registerAsGuest( }); } -export interface ILocalStorageSession { +export interface IStoredSession { hsUrl: string; isUrl: string; - accessToken: string; + hasAccessToken: boolean; + accessToken: string | IEncryptedPayload; userId: string; deviceId: string; isGuest: boolean; } /** - * Retrieves information about the stored session in localstorage. The session + * Retrieves information about the stored session from the browser's storage. The session * may not be valid, as it is not tested for consistency here. * @returns {Object} Information about the session - see implementation for variables. */ -export function getLocalStorageSessionVars(): ILocalStorageSession { +export async function getStoredSessionVars(): Promise { const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); const isUrl = localStorage.getItem(ID_SERVER_URL_KEY); - const accessToken = localStorage.getItem("mx_access_token"); + let accessToken; + try { + accessToken = await StorageManager.idbLoad("account", "mx_access_token"); + } catch (e) {} + if (!accessToken) { + accessToken = localStorage.getItem("mx_access_token"); + if (accessToken) { + try { + // try to migrate access token to IndexedDB if we can + await StorageManager.idbSave("account", "mx_access_token", accessToken); + localStorage.removeItem("mx_access_token"); + } catch (e) {} + } + } + // if we pre-date storing "mx_has_access_token", but we retrieved an access + // token, then we should say we have an access token + const hasAccessToken = + (localStorage.getItem("mx_has_access_token") === "true") || !!accessToken; const userId = localStorage.getItem("mx_user_id"); const deviceId = localStorage.getItem("mx_device_id"); @@ -303,7 +345,43 @@ export function getLocalStorageSessionVars(): ILocalStorageSession { isGuest = localStorage.getItem("matrix-is-guest") === "true"; } - return {hsUrl, isUrl, accessToken, userId, deviceId, isGuest}; + return { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest }; +} + +// The pickle key is a string of unspecified length and format. For AES, we +// need a 256-bit Uint8Array. So we HKDF the pickle key to generate the AES +// key. The AES key should be zeroed after it is used. +async function pickleKeyToAesKey(pickleKey: string): Promise { + const pickleKeyBuffer = new Uint8Array(pickleKey.length); + for (let i = 0; i < pickleKey.length; i++) { + pickleKeyBuffer[i] = pickleKey.charCodeAt(i); + } + const hkdfKey = await window.crypto.subtle.importKey( + "raw", pickleKeyBuffer, "HKDF", false, ["deriveBits"], + ); + pickleKeyBuffer.fill(0); + return new Uint8Array(await window.crypto.subtle.deriveBits( + { + name: "HKDF", hash: "SHA-256", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879 + salt: new Uint8Array(32), info: new Uint8Array(0), + }, + hkdfKey, + 256, + )); +} + +async function abortLogin() { + const signOut = await showStorageEvictedDialog(); + if (signOut) { + await clearStorage(); + // This error feels a bit clunky, but we want to make sure we don't go any + // further and instead head back to sign in. + throw new AbortLoginAndRebuildStorage( + "Aborting login in progress because of storage inconsistency", + ); + } } // returns a promise which resolves to true if a session is found in @@ -316,14 +394,18 @@ export function getLocalStorageSessionVars(): ILocalStorageSession { // The plan is to gradually move the localStorage access done here into // SessionStore to avoid bugs where the view becomes out-of-sync with // localStorage (e.g. isGuest etc.) -async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise { +export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise { const ignoreGuest = opts?.ignoreGuest; if (!localStorage) { return false; } - const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = getLocalStorageSessionVars(); + const { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest } = await getStoredSessionVars(); + + if (hasAccessToken && !accessToken) { + abortLogin(); + } if (accessToken && userId && hsUrl) { if (ignoreGuest && isGuest) { @@ -331,9 +413,15 @@ async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promis return false; } + let decryptedAccessToken = accessToken; const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId); if (pickleKey) { console.log("Got pickle key"); + if (typeof accessToken !== "string") { + const encrKey = await pickleKeyToAesKey(pickleKey); + decryptedAccessToken = await decryptAES(accessToken, encrKey, "access_token"); + encrKey.fill(0); + } } else { console.log("No pickle key available"); } @@ -345,7 +433,7 @@ async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promis await doSetLoggedIn({ userId: userId, deviceId: deviceId, - accessToken: accessToken, + accessToken: decryptedAccessToken as string, homeserverUrl: hsUrl, identityServerUrl: isUrl, guest: isGuest, @@ -362,9 +450,6 @@ async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promis async function handleLoadSessionFailure(e: Error): Promise { console.error("Unable to load session", e); - const SessionRestoreErrorDialog = - sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); - const modal = Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, { error: e.message, }); @@ -406,7 +491,7 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise { - const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog'); return new Promise(resolve => { Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, { onFinished: resolve, @@ -543,18 +619,55 @@ function showStorageEvictedDialog(): Promise { // `instanceof`. Babel 7 supports this natively in their class handling. class AbortLoginAndRebuildStorage extends Error { } -function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): void { +async function persistCredentials(credentials: IMatrixClientCreds): Promise { localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl); if (credentials.identityServerUrl) { localStorage.setItem(ID_SERVER_URL_KEY, credentials.identityServerUrl); } localStorage.setItem("mx_user_id", credentials.userId); - localStorage.setItem("mx_access_token", credentials.accessToken); localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); + // store whether we expect to find an access token, to detect the case + // where IndexedDB is blown away + if (credentials.accessToken) { + localStorage.setItem("mx_has_access_token", "true"); + } else { + localStorage.deleteItem("mx_has_access_token"); + } + if (credentials.pickleKey) { + let encryptedAccessToken; + try { + // try to encrypt the access token using the pickle key + const encrKey = await pickleKeyToAesKey(credentials.pickleKey); + encryptedAccessToken = await encryptAES(credentials.accessToken, encrKey, "access_token"); + encrKey.fill(0); + } catch (e) { + console.warn("Could not encrypt access token", e); + } + try { + // save either the encrypted access token, or the plain access + // token if we were unable to encrypt (e.g. if the browser doesn't + // have WebCrypto). + await StorageManager.idbSave( + "account", "mx_access_token", + encryptedAccessToken || credentials.accessToken, + ); + } catch (e) { + // if we couldn't save to indexedDB, fall back to localStorage. We + // store the access token unencrypted since localStorage only saves + // strings. + localStorage.setItem("mx_access_token", credentials.accessToken); + } localStorage.setItem("mx_has_pickle_key", String(true)); } else { + try { + await StorageManager.idbSave( + "account", "mx_access_token", credentials.accessToken, + ); + } catch (e) { + localStorage.setItem("mx_access_token", credentials.accessToken); + } if (localStorage.getItem("mx_has_pickle_key")) { console.error("Expected a pickle key, but none provided. Encryption may not work."); } @@ -588,9 +701,9 @@ export function logout(): void { if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions - // Also we sometimes want to re-log in a guest session - // if we abort the login - onLoggedOut(); + // Also we sometimes want to re-log in a guest session if we abort the login. + // defer until next tick because it calls a synchronous dispatch and we are likely here from a dispatch. + setImmediate(() => onLoggedOut()); return; } @@ -627,7 +740,7 @@ export function softLogout(): void { // Ensure that we dispatch a view change **before** stopping the client so // so that React components unmount first. This avoids React soft crashes // that can occur when components try to use a null client. - dis.dispatch({action: 'on_client_not_viable'}); // generic version of on_logged_out + dis.dispatch({ action: 'on_client_not_viable' }); // generic version of on_logged_out stopMatrixClient(/*unsetClient=*/false); // DO NOT CALL LOGOUT. A soft logout preserves data, logout does not. @@ -654,7 +767,7 @@ async function startMatrixClient(startSyncing = true): Promise { // to add listeners for the 'sync' event so otherwise we'd have // a race condition (and we need to dispatch synchronously for this // to work). - dis.dispatch({action: 'will_start_client'}, true); + dis.dispatch({ action: 'will_start_client' }, true); // reset things first just in case TypingStore.sharedInstance().reset(); @@ -665,6 +778,7 @@ async function startMatrixClient(startSyncing = true): Promise { DMRoomMap.makeShared().start(); IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.start(); + CallHandler.sharedInstance().start(); // Start Mjolnir even though we haven't checked the feature flag yet. Starting // the thing just wastes CPU cycles, but should result in no actual functionality @@ -695,7 +809,7 @@ async function startMatrixClient(startSyncing = true): Promise { // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. - dis.dispatch({action: 'client_started'}); + dis.dispatch({ action: 'client_started' }); if (isSoftLogout()) { softLogout(); @@ -711,9 +825,10 @@ export async function onLoggedOut(): Promise { // Ensure that we dispatch a view change **before** stopping the client so // so that React components unmount first. This avoids React soft crashes // that can occur when components try to use a null client. - dis.dispatch({action: 'on_logged_out'}, true); + dis.dispatch({ action: 'on_logged_out' }, true); stopMatrixClient(); - await clearStorage({deleteEverything: true}); + await clearStorage({ deleteEverything: true }); + LifecycleCustomisations.onLoggedOutAndStorageCleared?.(); } /** @@ -729,6 +844,10 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { @@ -760,6 +879,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise -Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,7 +16,7 @@ limitations under the License. */ // @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising -import Matrix from "matrix-js-sdk"; +import { createClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { IMatrixClientCreds } from "./MatrixClientPeg"; import SecurityCustomisations from "./customisations/Security"; @@ -29,14 +26,38 @@ interface ILoginOptions { } // TODO: Move this to JS SDK -interface ILoginFlow { - type: string; +interface IPasswordFlow { + type: "m.login.password"; } +export enum IdentityProviderBrand { + Gitlab = "gitlab", + Github = "github", + Apple = "apple", + Google = "google", + Facebook = "facebook", + Twitter = "twitter", +} + +export interface IIdentityProvider { + id: string; + name: string; + icon?: string; + brand?: IdentityProviderBrand | string; +} + +export interface ISSOFlow { + type: "m.login.sso" | "m.login.cas"; + // eslint-disable-next-line camelcase + identity_providers: IIdentityProvider[]; +} + +export type LoginFlow = ISSOFlow | IPasswordFlow; + // TODO: Move this to JS SDK /* eslint-disable camelcase */ interface ILoginParams { - identifier?: string; + identifier?: object; password?: string; token?: string; device_id?: string; @@ -48,9 +69,8 @@ export default class Login { private hsUrl: string; private isUrl: string; private fallbackHsUrl: string; - private currentFlowIndex: number; // TODO: Flows need a type in JS SDK - private flows: Array; + private flows: Array; private defaultDeviceDisplayName: string; private tempClient: MatrixClient; @@ -63,7 +83,6 @@ export default class Login { this.hsUrl = hsUrl; this.isUrl = isUrl; this.fallbackHsUrl = fallbackHsUrl; - this.currentFlowIndex = 0; this.flows = []; this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName; this.tempClient = null; // memoize @@ -94,33 +113,19 @@ export default class Login { */ public createTemporaryClient(): MatrixClient { if (this.tempClient) return this.tempClient; // use memoization - return this.tempClient = Matrix.createClient({ + return this.tempClient = createClient({ baseUrl: this.hsUrl, idBaseUrl: this.isUrl, }); } - public async getFlows(): Promise> { + public async getFlows(): Promise> { const client = this.createTemporaryClient(); const { flows } = await client.loginFlows(); this.flows = flows; - this.currentFlowIndex = 0; - // technically the UI should display options for all flows for the - // user to then choose one, so return all the flows here. return this.flows; } - public chooseFlow(flowIndex): void { - this.currentFlowIndex = flowIndex; - } - - public getCurrentFlowStep(): string { - // technically the flow can have multiple steps, but no one does this - // for login so we can ignore it. - const flowStep = this.flows[this.currentFlowIndex]; - return flowStep ? flowStep.type : null; - } - public loginViaPassword( username: string, phoneCountry: string, @@ -185,7 +190,6 @@ export default class Login { } } - /** * Send a login request to the given server, and format the response * as a MatrixClientCreds @@ -203,7 +207,7 @@ export async function sendLoginRequest( loginType: string, loginParams: ILoginParams, ): Promise { - const client = Matrix.createClient({ + const client = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, }); diff --git a/src/Markdown.js b/src/Markdown.ts similarity index 71% rename from src/Markdown.js rename to src/Markdown.ts index 492450e87d..96169d4011 100644 --- a/src/Markdown.js +++ b/src/Markdown.ts @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +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. @@ -14,15 +15,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -import commonmark from 'commonmark'; -import {escape} from "lodash"; +import * as commonmark from 'commonmark'; +import { escape } from "lodash"; const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; // These types of node are definitely text const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; -function is_allowed_html_tag(node) { +// As far as @types/commonmark is concerned, these are not public, so add them +interface CommonmarkHtmlRendererInternal extends commonmark.HtmlRenderer { + paragraph: (node: commonmark.Node, entering: boolean) => void; + link: (node: commonmark.Node, entering: boolean) => void; + html_inline: (node: commonmark.Node) => void; // eslint-disable-line camelcase + html_block: (node: commonmark.Node) => void; // eslint-disable-line camelcase +} + +function isAllowedHtmlTag(node: commonmark.Node): boolean { + if (node.literal != null && + node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) { + return true; + } + // Regex won't work for tags with attrs, but we only // allow anyway. const matches = /^<\/?(.*)>$/.exec(node.literal); @@ -30,16 +44,8 @@ function is_allowed_html_tag(node) { const tag = matches[1]; return ALLOWED_HTML_TAGS.indexOf(tag) > -1; } - return false; -} -function html_if_tag_allowed(node) { - if (is_allowed_html_tag(node)) { - this.lit(node.literal); - return; - } else { - this.lit(escape(node.literal)); - } + return false; } /* @@ -47,7 +53,7 @@ function html_if_tag_allowed(node) { * comprises multiple block level elements (ie. lines), * or false if it is only a single line. */ -function is_multi_line(node) { +function isMultiLine(node: commonmark.Node): boolean { let par = node; while (par.parent) { par = par.parent; @@ -61,6 +67,9 @@ function is_multi_line(node) { * it's plain text. */ export default class Markdown { + private input: string; + private parsed: commonmark.Node; + constructor(input) { this.input = input; @@ -68,7 +77,7 @@ export default class Markdown { this.parsed = parser.parse(this.input); } - isPlainText() { + isPlainText(): boolean { const walker = this.parsed.walker(); let ev; @@ -81,7 +90,7 @@ export default class Markdown { // if it's an allowed html tag, we need to render it and therefore // we will need to use HTML. If it's not allowed, it's not HTML since // we'll just be treating it as text. - if (is_allowed_html_tag(node)) { + if (isAllowedHtmlTag(node)) { return false; } } else { @@ -91,7 +100,7 @@ export default class Markdown { return true; } - toHTML({ externalLinks = false } = {}) { + toHTML({ externalLinks = false } = {}): string { const renderer = new commonmark.HtmlRenderer({ safe: false, @@ -101,7 +110,7 @@ export default class Markdown { // block quote ends up all on one line // (https://github.com/vector-im/element-web/issues/3154) softbreak: '
', - }); + }) as CommonmarkHtmlRendererInternal; // Trying to strip out the wrapping

causes a lot more complication // than it's worth, i think. For instance, this code will go and strip @@ -112,16 +121,16 @@ export default class Markdown { // // Let's try sending with

s anyway for now, though. - const real_paragraph = renderer.paragraph; + const realParagraph = renderer.paragraph; - renderer.paragraph = function(node, entering) { + renderer.paragraph = function(node: commonmark.Node, entering: boolean) { // If there is only one top level node, just return the // bare text: it's a single line of text and so should be // 'inline', rather than unnecessarily wrapped in its own // p tag. If, however, we have multiple nodes, each gets // its own p tag to keep them as separate paragraphs. - if (is_multi_line(node)) { - real_paragraph.call(this, node, entering); + if (isMultiLine(node)) { + realParagraph.call(this, node, entering); } }; @@ -144,19 +153,26 @@ export default class Markdown { } }; - renderer.html_inline = html_if_tag_allowed; + renderer.html_inline = function(node: commonmark.Node) { + if (isAllowedHtmlTag(node)) { + this.lit(node.literal); + return; + } else { + this.lit(escape(node.literal)); + } + }; - renderer.html_block = function(node) { -/* + renderer.html_block = function(node: commonmark.Node) { + /* // as with `paragraph`, we only insert line breaks // if there are multiple lines in the markdown. const isMultiLine = is_multi_line(node); if (isMultiLine) this.cr(); -*/ - html_if_tag_allowed.call(this, node); -/* + */ + renderer.html_inline(node); + /* if (isMultiLine) this.cr(); -*/ + */ }; return renderer.render(this.parsed); @@ -171,23 +187,22 @@ export default class Markdown { * N.B. this does **NOT** render arbitrary MD to plain text - only MD * which has no formatting. Otherwise it emits HTML(!). */ - toPlaintext() { - const renderer = new commonmark.HtmlRenderer({safe: false}); - const real_paragraph = renderer.paragraph; + toPlaintext(): string { + const renderer = new commonmark.HtmlRenderer({ safe: false }) as CommonmarkHtmlRendererInternal; - renderer.paragraph = function(node, entering) { + renderer.paragraph = function(node: commonmark.Node, entering: boolean) { // as with toHTML, only append lines to paragraphs if there are // multiple paragraphs - if (is_multi_line(node)) { + if (isMultiLine(node)) { if (!entering && node.next) { this.lit('\n\n'); } } }; - renderer.html_block = function(node) { + renderer.html_block = function(node: commonmark.Node) { this.lit(node.literal); - if (is_multi_line(node) && node.next) this.lit('\n\n'); + if (isMultiLine(node) && node.next) this.lit('\n\n'); }; return renderer.render(this.parsed); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 5bb10dfa89..e9364b1b47 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -17,23 +17,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix'; -import {MatrixClient} from 'matrix-js-sdk/src/client'; -import {MemoryStore} from 'matrix-js-sdk/src/store/memory'; +import { ICreateClientOpts, PendingEventOrdering } from 'matrix-js-sdk/src/matrix'; +import { IStartClientOpts, MatrixClient } from 'matrix-js-sdk/src/client'; +import { MemoryStore } from 'matrix-js-sdk/src/store/memory'; import * as utils from 'matrix-js-sdk/src/utils'; -import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline'; -import {EventTimelineSet} from 'matrix-js-sdk/src/models/event-timeline-set'; +import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; +import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set'; import * as sdk from './index'; import createMatrixClient from './utils/createMatrixClient'; import SettingsStore from './settings/SettingsStore'; import MatrixActionCreators from './actions/MatrixActionCreators'; import Modal from './Modal'; -import {verificationMethods} from 'matrix-js-sdk/src/crypto'; +import { verificationMethods } from 'matrix-js-sdk/src/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager'; -import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; +import { SHOW_QR_CODE_METHOD } from "matrix-js-sdk/src/crypto/verification/QRCode"; +import SecurityCustomisations from "./customisations/Security"; export interface IMatrixClientCreds { homeserverUrl: string; @@ -46,25 +47,8 @@ export interface IMatrixClientCreds { freshLogin?: boolean; } -// TODO: Move this to the js-sdk -export interface IOpts { - initialSyncLimit?: number; - pendingEventOrdering?: "detached" | "chronological"; - lazyLoadMembers?: boolean; - clientWellKnownPollPeriod?: number; -} - export interface IMatrixClientPeg { - opts: IOpts; - - /** - * Sets the script href passed to the IndexedDB web worker - * If set, a separate web worker will be started to run the IndexedDB - * queries on. - * - * @param {string} script href to the script to be passed to the web worker - */ - setIndexedDbWorkerScript(script: string): void; + opts: IStartClientOpts; /** * Return the server name of the user's homeserver @@ -100,6 +84,12 @@ export interface IMatrixClientPeg { */ currentUserIsJustRegistered(): boolean; + /** + * If the current user has been registered by this device then this + * returns a boolean of whether it was within the last N hours given. + */ + userRegisteredWithinLastHours(hours: number): boolean; + /** * Replace this MatrixClientPeg's client with a client instance that has * homeserver / identity server URLs and active credentials @@ -120,7 +110,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { // client is started in 'start'. These can be altered // at any time up to after the 'will_start_client' // event is finished processing. - public opts: IOpts = { + public opts: IStartClientOpts = { initialSyncLimit: 20, }; @@ -134,10 +124,6 @@ class _MatrixClientPeg implements IMatrixClientPeg { constructor() { } - public setIndexedDbWorkerScript(script: string): void { - createMatrixClient.indexedDbWorkerScript = script; - } - public get(): MatrixClient { return this.matrixClient; } @@ -150,6 +136,9 @@ class _MatrixClientPeg implements IMatrixClientPeg { public setJustRegisteredUserId(uid: string): void { this.justRegisteredUserId = uid; + if (uid) { + window.localStorage.setItem("mx_registration_time", String(new Date().getTime())); + } } public currentUserIsJustRegistered(): boolean { @@ -159,6 +148,15 @@ class _MatrixClientPeg implements IMatrixClientPeg { ); } + public userRegisteredWithinLastHours(hours: number): boolean { + try { + const date = new Date(window.localStorage.getItem("mx_registration_time")); + return ((new Date().getTime() - date.getTime()) / 36e5) <= hours; + } catch (e) { + return false; + } + } + public replaceUsingCreds(creds: IMatrixClientCreds): void { this.currentClientCreds = creds; this.createClient(creds); @@ -200,6 +198,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } catch (e) { if (e && e.name === 'InvalidCryptoStoreError') { // The js-sdk found a crypto DB too new for it to use + // FIXME: Using an import will result in test failures const CryptoStoreTooNewDialog = sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog"); Modal.createDialog(CryptoStoreTooNewDialog); @@ -211,7 +210,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { const opts = utils.deepCopy(this.opts); // the react sdk doesn't work without this, so don't allow - opts.pendingEventOrdering = "detached"; + opts.pendingEventOrdering = PendingEventOrdering.Detached; opts.lazyLoadMembers = true; opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours @@ -242,7 +241,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } public getHomeserverName(): string { - const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId); + const matches = /^@[^:]+:(.+)$/.exec(this.matrixClient.credentials.userId); if (matches === null || matches.length < 1) { throw new Error("Failed to derive homeserver name from user ID!"); } @@ -260,6 +259,10 @@ class _MatrixClientPeg implements IMatrixClientPeg { timelineSupport: true, forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer'), fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'), + // Gather up to 20 ICE candidates when a call arrives: this should be more than we'd + // ever normally need, so effectively this should make all the gathering happen when + // the call arrives. + iceCandidatePoolSize: 20, verificationMethods: [ verificationMethods.SAS, SHOW_QR_CODE_METHOD, @@ -274,6 +277,10 @@ class _MatrixClientPeg implements IMatrixClientPeg { // cross-signing features can toggle on without reloading and also be // accessed immediately after login. Object.assign(opts.cryptoCallbacks, crossSigningCallbacks); + if (SecurityCustomisations.getDehydrationKey) { + opts.cryptoCallbacks.getDehydrationKey = + SecurityCustomisations.getDehydrationKey; + } this.matrixClient = createMatrixClient(opts); diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts new file mode 100644 index 0000000000..073f24523d --- /dev/null +++ b/src/MediaDeviceHandler.ts @@ -0,0 +1,125 @@ +/* +Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2021 Šimon Brandner + +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 SettingsStore from "./settings/SettingsStore"; +import { SettingLevel } from "./settings/SettingLevel"; +import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix"; +import EventEmitter from 'events'; + +// XXX: MediaDeviceKind is a union type, so we make our own enum +export enum MediaDeviceKindEnum { + AudioOutput = "audiooutput", + AudioInput = "audioinput", + VideoInput = "videoinput", +} + +export type IMediaDevices = Record>; + +export enum MediaDeviceHandlerEvent { + AudioOutputChanged = "audio_output_changed", +} + +export default class MediaDeviceHandler extends EventEmitter { + private static internalInstance; + + public static get instance(): MediaDeviceHandler { + if (!MediaDeviceHandler.internalInstance) { + MediaDeviceHandler.internalInstance = new MediaDeviceHandler(); + } + return MediaDeviceHandler.internalInstance; + } + + public static async hasAnyLabeledDevices(): Promise { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.some(d => Boolean(d.label)); + } + + public static async getDevices(): Promise { + // Only needed for Electron atm, though should work in modern browsers + // once permission has been granted to the webapp + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const output = { + [MediaDeviceKindEnum.AudioOutput]: [], + [MediaDeviceKindEnum.AudioInput]: [], + [MediaDeviceKindEnum.VideoInput]: [], + }; + + devices.forEach((device) => output[device.kind].push(device)); + return output; + } catch (error) { + console.warn('Unable to refresh WebRTC Devices: ', error); + } + } + + /** + * Retrieves devices from the SettingsStore and tells the js-sdk to use them + */ + public static loadDevices(): void { + const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); + const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); + + setMatrixCallAudioInput(audioDeviceId); + setMatrixCallVideoInput(videoDeviceId); + } + + public setAudioOutput(deviceId: string): void { + SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); + this.emit(MediaDeviceHandlerEvent.AudioOutputChanged, deviceId); + } + + /** + * This will not change the device that a potential call uses. The call will + * need to be ended and started again for this change to take effect + * @param {string} deviceId + */ + public setAudioInput(deviceId: string): void { + SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); + setMatrixCallAudioInput(deviceId); + } + + /** + * This will not change the device that a potential call uses. The call will + * need to be ended and started again for this change to take effect + * @param {string} deviceId + */ + public setVideoInput(deviceId: string): void { + SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); + setMatrixCallVideoInput(deviceId); + } + + public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { + switch (kind) { + case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break; + case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break; + case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break; + } + } + + public static getAudioOutput(): string { + return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput"); + } + + public static getAudioInput(): string { + return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput"); + } + + public static getVideoInput(): string { + return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput"); + } +} diff --git a/src/Modal.tsx b/src/Modal.tsx index 2f761e7393..55fc871d67 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -15,14 +15,13 @@ See the License for the specific language governing permissions and limitations under the License. */ - import React from 'react'; import ReactDOM from 'react-dom'; import classNames from 'classnames'; +import { defer } from "matrix-js-sdk/src/utils"; import Analytics from './Analytics'; import dis from './dispatcher/dispatcher'; -import {defer} from './utils/promise'; import AsyncWrapper from './AsyncWrapper'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; @@ -36,6 +35,7 @@ export interface IModal { onBeforeClose?(reason?: string): Promise; onFinished(...args: T): void; close(...args: T): void; + hidden?: boolean; } export interface IHandle { @@ -93,6 +93,12 @@ export class ModalManager { return container; } + public toggleCurrentDialogVisibility() { + const modal = this.getCurrentModal(); + if (!modal) return; + modal.hidden = !modal.hidden; + } + public hasDialogs() { return this.priorityModal || this.staticModal || this.modals.length > 0; } @@ -147,6 +153,15 @@ export class ModalManager { return this.appendDialogAsync(...rest); } + public closeCurrentModal(reason: string) { + const modal = this.getCurrentModal(); + if (!modal) { + return; + } + modal.closeReason = reason; + modal.close(); + } + private buildModal( prom: Promise, props?: IProps, @@ -177,7 +192,7 @@ export class ModalManager { modal.elem = ; modal.close = closeDialog; - return {modal, closeDialog, onFinishedProm}; + return { modal, closeDialog, onFinishedProm }; } private getCloseFn( @@ -266,7 +281,7 @@ export class ModalManager { isStaticModal = false, options: IOptions = {}, ): IHandle { - const {modal, closeDialog, onFinishedProm} = this.buildModal(prom, props, className, options); + const { modal, closeDialog, onFinishedProm } = this.buildModal(prom, props, className, options); if (isPriorityModal) { // XXX: This is destructive this.priorityModal = modal; @@ -289,7 +304,7 @@ export class ModalManager { props?: IProps, className?: string, ): IHandle { - const {modal, closeDialog, onFinishedProm} = this.buildModal(prom, props, className, {}); + const { modal, closeDialog, onFinishedProm } = this.buildModal(prom, props, className, {}); this.modals.push(modal); this.reRender(); @@ -355,7 +370,7 @@ export class ModalManager { } const modal = this.getCurrentModal(); - if (modal !== this.staticModal) { + if (modal !== this.staticModal && !modal.hidden) { const classes = classNames("mx_Dialog_wrapper", modal.className, { mx_Dialog_wrapperWithStaticUnder: this.staticModal, }); @@ -369,7 +384,7 @@ export class ModalManager {

); - ReactDOM.render(dialog, ModalManager.getOrCreateContainer()); + setImmediate(() => ReactDOM.render(dialog, ModalManager.getOrCreateContainer())); } else { // This is safe to call repeatedly if we happen to do that ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer()); diff --git a/src/Velociraptor.js b/src/NodeAnimator.js similarity index 50% rename from src/Velociraptor.js rename to src/NodeAnimator.js index ce52f60dbd..8456e6e9fd 100644 --- a/src/Velociraptor.js +++ b/src/NodeAnimator.js @@ -1,16 +1,15 @@ import React from "react"; import ReactDom from "react-dom"; -import Velocity from "velocity-animate"; import PropTypes from 'prop-types'; /** - * The Velociraptor contains components and animates transitions with velocity. + * The NodeAnimator contains components and animates transitions. * It will only pick up direct changes to properties ('left', currently), and so * will not work for animating positional changes where the position is implicit * from DOM order. This makes it a lot simpler and lighter: if you need fully * automatic positional animation, look at react-shuffle or similar libraries. */ -export default class Velociraptor extends React.Component { +export default class NodeAnimator extends React.Component { static propTypes = { // either a list of child nodes, or a single child. children: PropTypes.any, @@ -20,14 +19,10 @@ export default class Velociraptor extends React.Component { // a list of state objects to apply to each child node in turn startStyles: PropTypes.array, - - // a list of transition options from the corresponding startStyle - enterTransitionOpts: PropTypes.array, }; static defaultProps = { startStyles: [], - enterTransitionOpts: [], }; constructor(props) { @@ -41,6 +36,18 @@ export default class Velociraptor extends React.Component { this._updateChildren(this.props.children); } + /** + * + * @param {HTMLElement} node element to apply styles to + * @param {object} styles a key/value pair of CSS properties + * @returns {void} + */ + _applyStyles(node, styles) { + Object.entries(styles).forEach(([property, value]) => { + node.style[property] = value; + }); + } + _updateChildren(newChildren) { const oldChildren = this.children || {}; this.children = {}; @@ -50,17 +57,8 @@ export default class Velociraptor extends React.Component { const oldNode = ReactDom.findDOMNode(this.nodes[old.key]); if (oldNode && oldNode.style.left !== c.props.style.left) { - Velocity(oldNode, { left: c.props.style.left }, this.props.transition).then(() => { - // special case visibility because it's nonsensical to animate an invisible element - // so we always hidden->visible pre-transition and visible->hidden after - if (oldNode.style.visibility === 'visible' && c.props.style.visibility === 'hidden') { - oldNode.style.visibility = c.props.style.visibility; - } - }); - //console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); - } - if (oldNode && oldNode.style.visibility === 'hidden' && c.props.style.visibility === 'visible') { - oldNode.style.visibility = c.props.style.visibility; + this._applyStyles(oldNode, { left: c.props.style.left }); + // console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); } // clone the old element with the props (and children) of the new element // so prop updates are still received by the children. @@ -94,58 +92,30 @@ export default class Velociraptor extends React.Component { this.props.startStyles.length > 0 ) { const startStyles = this.props.startStyles; - const transitionOpts = this.props.enterTransitionOpts; const domNode = ReactDom.findDOMNode(node); // start from startStyle 1: 0 is the one we gave it // to start with, so now we animate 1 etc. - for (var i = 1; i < startStyles.length; ++i) { - Velocity(domNode, startStyles[i], transitionOpts[i-1]); - /* - console.log("start:", - JSON.stringify(transitionOpts[i-1]), - "->", - JSON.stringify(startStyles[i]), - ); - */ + for (let i = 1; i < startStyles.length; ++i) { + this._applyStyles(domNode, startStyles[i]); + // console.log("start:" + // JSON.stringify(startStyles[i]), + // ); } // and then we animate to the resting state - Velocity(domNode, restingStyle, - transitionOpts[i-1]) - .then(() => { - // once we've reached the resting state, hide the element if - // appropriate - domNode.style.visibility = restingStyle.visibility; - }); + setTimeout(() => { + this._applyStyles(domNode, restingStyle); + }, 0); - /* - console.log("enter:", - JSON.stringify(transitionOpts[i-1]), - "->", - JSON.stringify(restingStyle)); - */ - } else if (node === null) { - // Velocity stores data on elements using the jQuery .data() - // method, and assumes you'll be using jQuery's .remove() to - // remove the element, but we don't use jQuery, so we need to - // blow away the element's data explicitly otherwise it will leak. - // This uses Velocity's internal jQuery compatible wrapper. - // See the bug at - // https://github.com/julianshapiro/velocity/issues/300 - // and the FAQ entry, "Preventing memory leaks when - // creating/destroying large numbers of elements" - // (https://github.com/julianshapiro/velocity/issues/47) - const domNode = ReactDom.findDOMNode(this.nodes[k]); - if (domNode) Velocity.Utilities.removeData(domNode); + // console.log("enter:", + // JSON.stringify(restingStyle)); } this.nodes[k] = node; } render() { return ( - - { Object.values(this.children) } - + <>{ Object.values(this.children) } ); } } diff --git a/src/Notifier.ts b/src/Notifier.ts index 1899896f9b..1137e44aec 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -27,13 +27,16 @@ import * as TextForEvent from './TextForEvent'; import Analytics from './Analytics'; import * as Avatar from './Avatar'; import dis from './dispatcher/dispatcher'; -import * as sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; import SettingsStore from "./settings/SettingsStore"; import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast"; -import {SettingLevel} from "./settings/SettingLevel"; -import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers"; +import { SettingLevel } from "./settings/SettingLevel"; +import { isPushNotifyDisabled } from "./settings/controllers/NotificationControllers"; +import RoomViewStore from "./stores/RoomViewStore"; +import UserActivity from "./UserActivity"; +import { mediaFromMxc } from "./customisations/Media"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; /* * Dispatches: @@ -65,7 +68,7 @@ export const Notifier = { // or not pendingEncryptedEventIds: [], - notificationMessageForEvent: function(ev: MatrixEvent) { + notificationMessageForEvent: function(ev: MatrixEvent): string { if (typehandlers.hasOwnProperty(ev.getContent().msgtype)) { return typehandlers[ev.getContent().msgtype](ev); } @@ -148,7 +151,7 @@ export const Notifier = { // Ideally in here we could use MSC1310 to detect the type of file, and reject it. return { - url: MatrixClientPeg.get().mxcUrlToHttp(content.url), + url: mediaFromMxc(content.url).srcHttp, name: content.name, type: content.type, size: content.size, @@ -237,7 +240,6 @@ export const Notifier = { ? _t('%(brand)s does not have permission to send you notifications - ' + 'please check your browser settings', { brand }) : _t('%(brand)s was not given permission to send notifications - please try again', { brand }); - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, { title: _t('Unable to enable Notifications'), description, @@ -326,7 +328,9 @@ export const Notifier = { onEvent: function(ev: MatrixEvent) { if (!this.isSyncing) return; // don't alert for any messages initially - if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return; + if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return; + + MatrixClientPeg.get().decryptEventIfNeeded(ev); // If it's an encrypted event and the type is still 'm.room.encrypted', // it hasn't yet been decrypted, so wait until it is. @@ -376,6 +380,15 @@ export const Notifier = { const room = MatrixClientPeg.get().getRoom(ev.getRoomId()); const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); if (actions && actions.notify) { + if (RoomViewStore.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently()) { + // don't bother notifying as user was recently active in this room + return; + } + if (SettingsStore.getValue("doNotDisturb")) { + // Don't bother the user if they didn't ask to be bothered + return; + } + if (this.isEnabled()) { this._displayPopupNotification(ev, room); } diff --git a/src/ObjectUtils.js b/src/ObjectUtils.js deleted file mode 100644 index 24dfe61d68..0000000000 --- a/src/ObjectUtils.js +++ /dev/null @@ -1,113 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * For two objects of the form { key: [val1, val2, val3] }, work out the added/removed - * values. Entirely new keys will result in the entire value array being added. - * @param {Object} before - * @param {Object} after - * @return {Object[]} An array of objects with the form: - * { key: $KEY, val: $VALUE, place: "add|del" } - */ -export function getKeyValueArrayDiffs(before, after) { - const results = []; - const delta = {}; - Object.keys(before).forEach(function(beforeKey) { - delta[beforeKey] = delta[beforeKey] || 0; // init to 0 initially - delta[beforeKey]--; // keys present in the past have -ve values - }); - Object.keys(after).forEach(function(afterKey) { - delta[afterKey] = delta[afterKey] || 0; // init to 0 initially - delta[afterKey]++; // keys present in the future have +ve values - }); - - Object.keys(delta).forEach(function(muxedKey) { - switch (delta[muxedKey]) { - case 1: // A new key in after - after[muxedKey].forEach(function(afterVal) { - results.push({ place: "add", key: muxedKey, val: afterVal }); - }); - break; - case -1: // A before key was removed - before[muxedKey].forEach(function(beforeVal) { - results.push({ place: "del", key: muxedKey, val: beforeVal }); - }); - break; - case 0: {// A mix of added/removed keys - // compare old & new vals - const itemDelta = {}; - before[muxedKey].forEach(function(beforeVal) { - itemDelta[beforeVal] = itemDelta[beforeVal] || 0; - itemDelta[beforeVal]--; - }); - after[muxedKey].forEach(function(afterVal) { - itemDelta[afterVal] = itemDelta[afterVal] || 0; - itemDelta[afterVal]++; - }); - - Object.keys(itemDelta).forEach(function(item) { - if (itemDelta[item] === 1) { - results.push({ place: "add", key: muxedKey, val: item }); - } else if (itemDelta[item] === -1) { - results.push({ place: "del", key: muxedKey, val: item }); - } else { - // itemDelta of 0 means it was unchanged between before/after - } - }); - break; - } - default: - console.error("Calculated key delta of " + delta[muxedKey] + " - this should never happen!"); - break; - } - }); - - return results; -} - -/** - * Shallow-compare two objects for equality: each key and value must be identical - * @param {Object} objA First object to compare against the second - * @param {Object} objB Second object to compare against the first - * @return {boolean} whether the two objects have same key=values - */ -export function shallowEqual(objA, objB) { - if (objA === objB) { - return true; - } - - if (typeof objA !== 'object' || objA === null || - typeof objB !== 'object' || objB === null) { - return false; - } - - const keysA = Object.keys(objA); - const keysB = Object.keys(objB); - - if (keysA.length !== keysB.length) { - return false; - } - - for (let i = 0; i < keysA.length; i++) { - const key = keysA[i]; - if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) { - return false; - } - } - - return true; -} diff --git a/src/PasswordReset.js b/src/PasswordReset.js index 9472ddc633..88ae00d088 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as Matrix from 'matrix-js-sdk'; +import { createClient } from 'matrix-js-sdk/src/matrix'; import { _t } from './languageHandler'; /** @@ -32,7 +32,7 @@ export default class PasswordReset { * @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping. */ constructor(homeserverUrl, identityUrl) { - this.client = Matrix.createClient({ + this.client = createClient({ baseUrl: homeserverUrl, idBaseUrl: identityUrl, }); @@ -40,10 +40,6 @@ export default class PasswordReset { this.identityServerDomain = identityUrl ? identityUrl.split("://")[1] : null; } - doesServerRequireIdServerParam() { - return this.client.doesServerRequireIdServerParam(); - } - /** * Attempt to reset the user's password. This will trigger a side-effect of * sending an email to the provided email address. @@ -58,7 +54,7 @@ export default class PasswordReset { return res; }, function(err) { if (err.errcode === 'M_THREEPID_NOT_FOUND') { - err.message = _t('This email address was not found'); + err.message = _t('This email address was not found'); } else if (err.httpStatus) { err.message = err.message + ` (Status ${err.httpStatus})`; } @@ -78,9 +74,6 @@ export default class PasswordReset { sid: this.sessionId, client_secret: this.clientSecret, }; - if (await this.doesServerRequireIdServerParam()) { - creds.id_server = this.identityServerDomain; - } try { await this.client.setPassword({ diff --git a/src/PhasedRollOut.js b/src/PhasedRollOut.js deleted file mode 100644 index b17ed37974..0000000000 --- a/src/PhasedRollOut.js +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import SdkConfig from './SdkConfig'; -import {hashCode} from './utils/FormattingUtils'; - -export function phasedRollOutExpiredForUser(username, feature, now, rollOutConfig = SdkConfig.get().phasedRollOut) { - if (!rollOutConfig) { - console.log(`no phased rollout configuration, so enabling ${feature}`); - return true; - } - const featureConfig = rollOutConfig[feature]; - if (!featureConfig) { - console.log(`${feature} doesn't have phased rollout configured, so enabling`); - return true; - } - if (!Number.isFinite(featureConfig.offset) || !Number.isFinite(featureConfig.period)) { - console.error(`phased rollout of ${feature} is misconfigured, ` + - `offset and/or period are not numbers, so disabling`, featureConfig); - return false; - } - - const hash = hashCode(username); - //ms -> min, enable users at minute granularity - const bucketRatio = 1000 * 60; - const bucketCount = featureConfig.period / bucketRatio; - const userBucket = hash % bucketCount; - const userMs = userBucket * bucketRatio; - const enableAt = featureConfig.offset + userMs; - const result = now >= enableAt; - const bucketStr = `(bucket ${userBucket}/${bucketCount})`; - if (result) { - console.log(`${feature} enabled for ${username} ${bucketStr}`); - } else { - console.log(`${feature} will be enabled for ${username} in ${Math.ceil((enableAt - now)/1000)}s ${bucketStr}`); - } - return result; -} diff --git a/src/Presence.ts b/src/Presence.ts index 660bb0ac94..af35060363 100644 --- a/src/Presence.ts +++ b/src/Presence.ts @@ -16,10 +16,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { MatrixClientPeg } from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; import Timer from './utils/Timer'; -import {ActionPayload} from "./dispatcher/payloads"; +import { ActionPayload } from "./dispatcher/payloads"; // Time in ms after that a user is considered as unavailable/away const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins @@ -78,7 +78,7 @@ class Presence { this.setState(State.Online); this.unavailableTimer.restart(); } - } + }; /** * Set the presence state. @@ -98,10 +98,10 @@ class Presence { } try { - await MatrixClientPeg.get().setPresence(this.state); - console.info("Presence: %s", newState); + await MatrixClientPeg.get().setPresence({ presence: this.state }); + console.info("Presence:", newState); } catch (err) { - console.error("Failed to set presence: %s", err); + console.error("Failed to set presence:", err); this.state = oldState; } } diff --git a/src/Registration.js b/src/Registration.js index 0df2ec3eb3..70dcd38454 100644 --- a/src/Registration.js +++ b/src/Registration.js @@ -53,16 +53,16 @@ export async function startAnyRegistrationFlow(options) { extraButtons: [ , ], onFinished: (proceed) => { if (proceed) { - dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after}); + dis.dispatch({ action: 'start_registration', screenAfterLogin: options.screen_after }); } else if (options.go_home_on_cancel) { - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({ action: 'view_home_page' }); } else if (options.go_welcome_on_cancel) { - dis.dispatch({action: 'view_welcome_page'}); + dis.dispatch({ action: 'view_welcome_page' }); } }, }); diff --git a/src/Resend.js b/src/Resend.ts similarity index 61% rename from src/Resend.js rename to src/Resend.ts index 5638313306..38b84a28e0 100644 --- a/src/Resend.js +++ b/src/Resend.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015-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. @@ -15,35 +14,37 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event'; +import { Room } from 'matrix-js-sdk/src/models/room'; + +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; -import { EventStatus } from 'matrix-js-sdk'; export default class Resend { - static resendUnsentEvents(room) { - room.getPendingEvents().filter(function(ev) { + static resendUnsentEvents(room: Room): Promise { + return Promise.all(room.getPendingEvents().filter(function(ev: MatrixEvent) { return ev.status === EventStatus.NOT_SENT; - }).forEach(function(event) { - Resend.resend(event); - }); + }).map(function(event: MatrixEvent) { + return Resend.resend(event); + })); } - static cancelUnsentEvents(room) { - room.getPendingEvents().filter(function(ev) { + static cancelUnsentEvents(room: Room): void { + room.getPendingEvents().filter(function(ev: MatrixEvent) { return ev.status === EventStatus.NOT_SENT; - }).forEach(function(event) { + }).forEach(function(event: MatrixEvent) { Resend.removeFromQueue(event); }); } - static resend(event) { + static resend(event: MatrixEvent): Promise { const room = MatrixClientPeg.get().getRoom(event.getRoomId()); - MatrixClientPeg.get().resendEvent(event, room).then(function(res) { + return MatrixClientPeg.get().resendEvent(event, room).then(function(res) { dis.dispatch({ action: 'message_sent', event: event, }); - }, function(err) { + }, function(err: Error) { // XXX: temporary logging to try to diagnose // https://github.com/vector-im/element-web/issues/3148 console.log('Resend got send failure: ' + err.name + '(' + err + ')'); @@ -55,7 +56,7 @@ export default class Resend { }); } - static removeFromQueue(event) { + static removeFromQueue(event: MatrixEvent): void { MatrixClientPeg.get().cancelPendingEvent(event); } } diff --git a/src/Roles.ts b/src/Roles.ts index b4be97fdce..ae0d316d30 100644 --- a/src/Roles.ts +++ b/src/Roles.ts @@ -31,6 +31,6 @@ export function textualPowerLevel(level: number, usersDefault: number): string { if (LEVEL_ROLE_MAP[level]) { return LEVEL_ROLE_MAP[level]; } else { - return _t("Custom (%(level)s)", {level}); + return _t("Custom (%(level)s)", { level }); } } diff --git a/src/RoomAliasCache.js b/src/RoomAliasCache.ts similarity index 81% rename from src/RoomAliasCache.js rename to src/RoomAliasCache.ts index bb511ba4d7..c318db2d3f 100644 --- a/src/RoomAliasCache.js +++ b/src/RoomAliasCache.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -24,12 +24,12 @@ limitations under the License. * A similar thing could also be achieved via `pushState` with a state object, * but keeping it separate like this seems easier in case we do want to extend. */ -const aliasToIDMap = new Map(); +const aliasToIDMap = new Map(); -export function storeRoomAliasInCache(alias, id) { +export function storeRoomAliasInCache(alias: string, id: string): void { aliasToIDMap.set(alias, id); } -export function getCachedRoomIDForAlias(alias) { +export function getCachedRoomIDForAlias(alias: string): string { return aliasToIDMap.get(alias); } diff --git a/src/RoomInvite.js b/src/RoomInvite.js deleted file mode 100644 index 7eb7f5dbb2..0000000000 --- a/src/RoomInvite.js +++ /dev/null @@ -1,146 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2017, 2018 New Vector Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import {MatrixClientPeg} from './MatrixClientPeg'; -import MultiInviter from './utils/MultiInviter'; -import Modal from './Modal'; -import * as sdk from './'; -import { _t } from './languageHandler'; -import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; -import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; -import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; - -/** - * Invites multiple addresses to a room - * Simpler interface to utils/MultiInviter but with - * no option to cancel. - * - * @param {string} roomId The ID of the room to invite to - * @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids. - * @returns {Promise} Promise - */ -export function inviteMultipleToRoom(roomId, addrs) { - const inviter = new MultiInviter(roomId); - return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); -} - -export function showStartChatInviteDialog() { - // This dialog handles the room creation internally - we don't need to worry about it. - const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); - Modal.createTrackedDialog( - 'Start DM', '', InviteDialog, {kind: KIND_DM}, - /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, - ); -} - -export function showRoomInviteDialog(roomId) { - // This dialog handles the room creation internally - we don't need to worry about it. - const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); - Modal.createTrackedDialog( - 'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId}, - /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, - ); -} - -export function showCommunityRoomInviteDialog(roomId, communityName) { - Modal.createTrackedDialog( - 'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId}, - /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, - ); -} - -export function showCommunityInviteDialog(communityId) { - const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId); - if (chat) { - const name = CommunityPrototypeStore.instance.getCommunityName(communityId); - showCommunityRoomInviteDialog(chat.roomId, name); - } else { - throw new Error("Failed to locate appropriate room to start an invite in"); - } -} - -/** - * Checks if the given MatrixEvent is a valid 3rd party user invite. - * @param {MatrixEvent} event The event to check - * @returns {boolean} True if valid, false otherwise - */ -export function isValid3pidInvite(event) { - if (!event || event.getType() !== "m.room.third_party_invite") return false; - - // any events without these keys are not valid 3pid invites, so we ignore them - const requiredKeys = ['key_validity_url', 'public_key', 'display_name']; - for (let i = 0; i < requiredKeys.length; ++i) { - if (!event.getContent()[requiredKeys[i]]) return false; - } - - // Valid enough by our standards - return true; -} - -export function inviteUsersToRoom(roomId, userIds) { - return inviteMultipleToRoom(roomId, userIds).then((result) => { - const room = MatrixClientPeg.get().getRoom(roomId); - showAnyInviteErrors(result.states, room, result.inviter); - }).catch((err) => { - console.error(err.stack); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { - title: _t("Failed to invite"), - description: ((err && err.message) ? err.message : _t("Operation failed")), - }); - }); -} - -export function showAnyInviteErrors(addrs, room, inviter) { - // Show user any errors - const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error'); - if (failedUsers.length === 1 && inviter.fatal) { - // Just get the first message because there was a fatal problem on the first - // user. This usually means that no other users were attempted, making it - // pointless for us to list who failed exactly. - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to invite users to the room', '', ErrorDialog, { - title: _t("Failed to invite users to the room:", {roomName: room.name}), - description: inviter.getErrorText(failedUsers[0]), - }); - return false; - } else { - const errorList = []; - for (const addr of failedUsers) { - if (addrs[addr] === "error") { - const reason = inviter.getErrorText(addr); - errorList.push(addr + ": " + reason); - } - } - - if (errorList.length > 0) { - // React 16 doesn't let us use `errorList.join(
)` anymore, so this is our solution - const description =
{errorList.map(e =>
{e}
)}
; - - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, { - title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), - description, - }); - return false; - } - } - - return true; -} diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx new file mode 100644 index 0000000000..7d093f4092 --- /dev/null +++ b/src/RoomInvite.tsx @@ -0,0 +1,187 @@ +/* +Copyright 2016 - 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 from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { User } from "matrix-js-sdk/src/models/user"; + +import { MatrixClientPeg } from './MatrixClientPeg'; +import MultiInviter, { CompletionStates } from './utils/MultiInviter'; +import Modal from './Modal'; +import { _t } from './languageHandler'; +import InviteDialog, { KIND_DM, KIND_INVITE, Member } from "./components/views/dialogs/InviteDialog"; +import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; +import { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore"; +import BaseAvatar from "./components/views/avatars/BaseAvatar"; +import { mediaFromMxc } from "./customisations/Media"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; + +export interface IInviteResult { + states: CompletionStates; + inviter: MultiInviter; +} + +/** + * Invites multiple addresses to a room + * Simpler interface to utils/MultiInviter but with + * no option to cancel. + * + * @param {string} roomId The ID of the room to invite to + * @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids. + * @returns {Promise} Promise + */ +export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise { + const inviter = new MultiInviter(roomId); + return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter })); +} + +export function showStartChatInviteDialog(initialText = ""): void { + // This dialog handles the room creation internally - we don't need to worry about it. + Modal.createTrackedDialog( + 'Start DM', '', InviteDialog, { kind: KIND_DM, initialText }, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); +} + +export function showRoomInviteDialog(roomId: string, initialText = ""): void { + // This dialog handles the room creation internally - we don't need to worry about it. + Modal.createTrackedDialog( + "Invite Users", "", InviteDialog, { + kind: KIND_INVITE, + initialText, + roomId, + }, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); +} + +export function showCommunityRoomInviteDialog(roomId: string, communityName: string): void { + Modal.createTrackedDialog( + 'Invite Users to Community', '', CommunityPrototypeInviteDialog, { communityName, roomId }, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); +} + +export function showCommunityInviteDialog(communityId: string): void { + const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId); + if (chat) { + const name = CommunityPrototypeStore.instance.getCommunityName(communityId); + showCommunityRoomInviteDialog(chat.roomId, name); + } else { + throw new Error("Failed to locate appropriate room to start an invite in"); + } +} + +/** + * Checks if the given MatrixEvent is a valid 3rd party user invite. + * @param {MatrixEvent} event The event to check + * @returns {boolean} True if valid, false otherwise + */ +export function isValid3pidInvite(event: MatrixEvent): boolean { + if (!event || event.getType() !== "m.room.third_party_invite") return false; + + // any events without these keys are not valid 3pid invites, so we ignore them + const requiredKeys = ['key_validity_url', 'public_key', 'display_name']; + for (let i = 0; i < requiredKeys.length; ++i) { + if (!event.getContent()[requiredKeys[i]]) return false; + } + + // Valid enough by our standards + return true; +} + +export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise { + return inviteMultipleToRoom(roomId, userIds).then((result) => { + const room = MatrixClientPeg.get().getRoom(roomId); + showAnyInviteErrors(result.states, room, result.inviter); + }).catch((err) => { + console.error(err.stack); + Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { + title: _t("Failed to invite"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); +} + +export function showAnyInviteErrors( + states: CompletionStates, + room: Room, + inviter: MultiInviter, + userMap?: Map, +): boolean { + // Show user any errors + const failedUsers = Object.keys(states).filter(a => states[a] === 'error'); + if (failedUsers.length === 1 && inviter.fatal) { + // Just get the first message because there was a fatal problem on the first + // user. This usually means that no other users were attempted, making it + // pointless for us to list who failed exactly. + Modal.createTrackedDialog('Failed to invite users to the room', '', ErrorDialog, { + title: _t("Failed to invite users to the room:", { roomName: room.name }), + description: inviter.getErrorText(failedUsers[0]), + }); + return false; + } else { + const errorList = []; + for (const addr of failedUsers) { + if (states[addr] === "error") { + const reason = inviter.getErrorText(addr); + errorList.push(addr + ": " + reason); + } + } + + const cli = MatrixClientPeg.get(); + if (errorList.length > 0) { + // React 16 doesn't let us use `errorList.join(
)` anymore, so this is our solution + const description =
+

{ _t("We sent the others, but the below people couldn't be invited to ", {}, { + RoomName: () => { room.name }, + }) }

+
+ { failedUsers.map(addr => { + const user = userMap?.get(addr) || cli.getUser(addr); + const name = (user as Member).name || (user as User).rawDisplayName; + const avatarUrl = (user as Member).getMxcAvatarUrl?.() || (user as User).avatarUrl; + return
+
+ + { name } + { user.userId } +
+
+ { inviter.getErrorText(addr) } +
+
; + }) } +
+
; + + Modal.createTrackedDialog("Some invites could not be sent", "", ErrorDialog, { + title: _t("Some invites couldn't be sent"), + description, + }); + return false; + } + } + + return true; +} diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index a86c521ac4..5d109094af 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -15,8 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; -import {PushProcessor} from 'matrix-js-sdk/src/pushprocessor'; +import { MatrixClientPeg } from './MatrixClientPeg'; +import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; export const ALL_MESSAGES_LOUD = 'all_messages_loud'; export const ALL_MESSAGES = 'all_messages'; @@ -52,7 +52,7 @@ export function aggregateNotificationCount(rooms) { } } return result; - }, {count: 0, highlight: false}); + }, { count: 0, highlight: false }); } export function getRoomHasBadge(room) { @@ -202,12 +202,13 @@ function setRoomNotifsStateUnmuted(roomId, newState) { } function findOverrideMuteRule(roomId) { - if (!MatrixClientPeg.get().pushRules || - !MatrixClientPeg.get().pushRules['global'] || - !MatrixClientPeg.get().pushRules['global'].override) { + const cli = MatrixClientPeg.get(); + if (!cli.pushRules || + !cli.pushRules['global'] || + !cli.pushRules['global'].override) { return null; } - for (const rule of MatrixClientPeg.get().pushRules['global'].override) { + for (const rule of cli.pushRules['global'].override) { if (isRuleForRoom(roomId, rule)) { if (isMuteRule(rule) && rule.enabled) { return rule; diff --git a/src/Rooms.js b/src/Rooms.ts similarity index 77% rename from src/Rooms.js rename to src/Rooms.ts index 955498faaa..6e2fd4d3a2 100644 --- a/src/Rooms.js +++ b/src/Rooms.ts @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015 - 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. @@ -14,7 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { Room } from "matrix-js-sdk/src/models/room"; + +import { MatrixClientPeg } from './MatrixClientPeg'; +import AliasCustomisations from './customisations/Alias'; /** * Given a room object, return the alias we should use for it, @@ -25,11 +28,22 @@ import {MatrixClientPeg} from './MatrixClientPeg'; * @param {Object} room The room object * @returns {string} A display alias for the given room */ -export function getDisplayAliasForRoom(room) { - return room.getCanonicalAlias() || room.getAltAliases()[0]; +export function getDisplayAliasForRoom(room: Room): string { + return getDisplayAliasForAliasSet( + room.getCanonicalAlias(), room.getAltAliases(), + ); } -export function looksLikeDirectMessageRoom(room, myUserId) { +// The various display alias getters should all feed through this one path so +// there's a single place to change the logic. +export function getDisplayAliasForAliasSet(canonicalAlias: string, altAliases: string[]): string { + if (AliasCustomisations.getDisplayAliasForAliasSet) { + return AliasCustomisations.getDisplayAliasForAliasSet(canonicalAlias, altAliases); + } + return canonicalAlias || altAliases?.[0]; +} + +export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean { const myMembership = room.getMyMembership(); const me = room.getMember(myUserId); @@ -48,7 +62,7 @@ export function looksLikeDirectMessageRoom(room, myUserId) { return false; } -export function guessAndSetDMRoom(room, isDirect) { +export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise { let newTarget; if (isDirect) { const guessedUserId = guessDMRoomTargetId( @@ -70,10 +84,8 @@ export function guessAndSetDMRoom(room, isDirect) { this room as a DM room * @returns {object} A promise */ -export function setDMRoom(roomId, userId) { - if (MatrixClientPeg.get().isGuest()) { - return Promise.resolve(); - } +export async function setDMRoom(roomId: string, userId: string): Promise { + if (MatrixClientPeg.get().isGuest()) return; const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct'); let dmRoomMap = {}; @@ -102,8 +114,7 @@ export function setDMRoom(roomId, userId) { dmRoomMap[userId] = roomList; } - - return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap); + await MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap); } /** @@ -114,7 +125,7 @@ export function setDMRoom(roomId, userId) { * @param {string} myUserId User ID of the current user * @returns {string} User ID of the user that the room is probably a DM with */ -function guessDMRoomTargetId(room, myUserId) { +function guessDMRoomTargetId(room: Room, myUserId: string): string { let oldestTs; let oldestUser; diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.ts similarity index 82% rename from src/ScalarAuthClient.js rename to src/ScalarAuthClient.ts index 1ea9d39e2f..86dd4b7a0f 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016, 2019, 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. @@ -17,13 +16,14 @@ limitations under the License. import url from 'url'; import SettingsStore from "./settings/SettingsStore"; -import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms'; +import { MatrixClientPeg } from "./MatrixClientPeg"; import request from "browser-request"; -import * as Matrix from 'matrix-js-sdk'; import SdkConfig from "./SdkConfig"; -import {WidgetType} from "./widgets/WidgetType"; +import { WidgetType } from "./widgets/WidgetType"; +import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; +import { Room } from "matrix-js-sdk/src/models/room"; // The version of the integration manager API we're intending to work with const imApiVersion = "1.1"; @@ -31,9 +31,11 @@ const imApiVersion = "1.1"; // TODO: Generify the name of this class and all components within - it's not just for Scalar. export default class ScalarAuthClient { - constructor(apiUrl, uiUrl) { - this.apiUrl = apiUrl; - this.uiUrl = uiUrl; + private scalarToken: string; + private termsInteractionCallback: TermsInteractionCallback; + private isDefaultManager: boolean; + + constructor(private apiUrl: string, private uiUrl: string) { this.scalarToken = null; // `undefined` to allow `startTermsFlow` to fallback to a default // callback if this is unset. @@ -46,7 +48,7 @@ export default class ScalarAuthClient { this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl; } - _writeTokenToStore() { + private writeTokenToStore() { window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken); if (this.isDefaultManager) { // We remove the old token from storage to migrate upwards. This is safe @@ -56,7 +58,7 @@ export default class ScalarAuthClient { } } - _readTokenFromStore() { + private readTokenFromStore(): string { let token = window.localStorage.getItem("mx_scalar_token_at_" + this.apiUrl); if (!token && this.isDefaultManager) { token = window.localStorage.getItem("mx_scalar_token"); @@ -64,33 +66,33 @@ export default class ScalarAuthClient { return token; } - _readToken() { + private readToken(): string { if (this.scalarToken) return this.scalarToken; - return this._readTokenFromStore(); + return this.readTokenFromStore(); } setTermsInteractionCallback(callback) { this.termsInteractionCallback = callback; } - connect() { + connect(): Promise { return this.getScalarToken().then((tok) => { this.scalarToken = tok; }); } - hasCredentials() { + hasCredentials(): boolean { return this.scalarToken != null; // undef or null } // Returns a promise that resolves to a scalar_token string - getScalarToken() { - const token = this._readToken(); + getScalarToken(): Promise { + const token = this.readToken(); if (!token) { return this.registerForToken(); } else { - return this._checkToken(token).catch((e) => { + return this.checkToken(token).catch((e) => { if (e instanceof TermsNotSignedError) { // retrying won't help this throw e; @@ -100,14 +102,14 @@ export default class ScalarAuthClient { } } - _getAccountName(token) { + private getAccountName(token: string): Promise { const url = this.apiUrl + "/account"; return new Promise(function(resolve, reject) { request({ method: "GET", uri: url, - qs: {scalar_token: token, v: imApiVersion}, + qs: { scalar_token: token, v: imApiVersion }, json: true, }, (err, response, body) => { if (err) { @@ -125,8 +127,8 @@ export default class ScalarAuthClient { }); } - _checkToken(token) { - return this._getAccountName(token).then(userId => { + private checkToken(token: string): Promise { + return this.getAccountName(token).then(userId => { const me = MatrixClientPeg.get().getUserId(); if (userId !== me) { throw new Error("Scalar token is owned by someone else: " + me); @@ -153,8 +155,8 @@ export default class ScalarAuthClient { parsedImRestUrl.path = ''; parsedImRestUrl.pathname = ''; return startTermsFlow([new Service( - Matrix.SERVICE_TYPES.IM, - parsedImRestUrl.format(), + SERVICE_TYPES.IM, + url.format(parsedImRestUrl), token, )], this.termsInteractionCallback).then(() => { return token; @@ -165,36 +167,36 @@ export default class ScalarAuthClient { }); } - registerForToken() { + registerForToken(): Promise { // Get openid bearer token from the HS as the first part of our dance return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => { // Now we can send that to scalar and exchange it for a scalar token return this.exchangeForScalarToken(tokenObject); }).then((token) => { // Validate it (this mostly checks to see if the IM needs us to agree to some terms) - return this._checkToken(token); + return this.checkToken(token); }).then((token) => { this.scalarToken = token; - this._writeTokenToStore(); + this.writeTokenToStore(); return token; }); } - exchangeForScalarToken(openidTokenObject) { + exchangeForScalarToken(openidTokenObject: any): Promise { const scalarRestUrl = this.apiUrl; return new Promise(function(resolve, reject) { request({ method: 'POST', uri: scalarRestUrl + '/register', - qs: {v: imApiVersion}, + qs: { v: imApiVersion }, body: openidTokenObject, json: true, }, (err, response, body) => { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body || !body.scalar_token) { reject(new Error("Missing scalar_token in response")); } else { @@ -204,7 +206,7 @@ export default class ScalarAuthClient { }); } - getScalarPageTitle(url) { + getScalarPageTitle(url: string): Promise { let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup'; scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); scalarPageLookupUrl += '&curl=' + encodeURIComponent(url); @@ -218,7 +220,7 @@ export default class ScalarAuthClient { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body) { reject(new Error("Missing page title in response")); } else { @@ -240,10 +242,10 @@ export default class ScalarAuthClient { * @param {string} widgetId The widget ID to disable assets for * @return {Promise} Resolves on completion */ - disableWidgetAssets(widgetType: WidgetType, widgetId) { + disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise { let url = this.apiUrl + '/widgets/set_assets_state'; url = this.getStarterLink(url); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { request({ method: 'GET', // XXX: Actions shouldn't be GET requests uri: url, @@ -257,7 +259,7 @@ export default class ScalarAuthClient { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body) { reject(new Error("Failed to set widget assets state")); } else { @@ -267,7 +269,7 @@ export default class ScalarAuthClient { }); } - getScalarInterfaceUrlForRoom(room, screen, id) { + getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string { const roomId = room.roomId; const roomName = room.name; let url = this.uiUrl; @@ -284,7 +286,7 @@ export default class ScalarAuthClient { return url; } - getStarterLink(starterLinkUrl) { + getStarterLink(starterLinkUrl: string): string { return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken); } } diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 896e27d92c..600241bc06 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -208,7 +208,6 @@ Example: ] } - membership_state AND bot_options -------------------------------- Get the content of the "m.room.member" or "m.room.bot.options" state event respectively. @@ -236,15 +235,15 @@ Example: } */ -import {MatrixClientPeg} from './MatrixClientPeg'; -import { MatrixEvent } from 'matrix-js-sdk'; +import { MatrixClientPeg } from './MatrixClientPeg'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import RoomViewStore from './stores/RoomViewStore'; import { _t } from './languageHandler'; -import {IntegrationManagers} from "./integrations/IntegrationManagers"; -import {WidgetType} from "./widgets/WidgetType"; -import {objectClone} from "./utils/objects"; +import { IntegrationManagers } from "./integrations/IntegrationManagers"; +import { WidgetType } from "./widgets/WidgetType"; +import { objectClone } from "./utils/objects"; function sendResponse(event, res) { const data = objectClone(event.data); @@ -608,7 +607,7 @@ const onMessage = function(event) { } if (roomId !== RoomViewStore.getRoomId()) { - sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId})); + sendError(event, _t('Room %(roomId)s not visible', { roomId: roomId })); return; } diff --git a/src/Searching.js b/src/Searching.ts similarity index 76% rename from src/Searching.js rename to src/Searching.ts index f65b8920b3..37f85efa77 100644 --- a/src/Searching.js +++ b/src/Searching.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 - 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. @@ -14,26 +14,42 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { + IResultRoomEvents, + ISearchRequestBody, + ISearchResponse, + ISearchResult, + ISearchResults, + SearchOrderBy, +} from "matrix-js-sdk/src/@types/search"; +import { IRoomEventFilter } from "matrix-js-sdk/src/filter"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { ISearchArgs } from "./indexing/BaseEventIndexManager"; import EventIndexPeg from "./indexing/EventIndexPeg"; -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { MatrixClientPeg } from "./MatrixClientPeg"; +import { SearchResult } from "matrix-js-sdk/src/models/search-result"; const SEARCH_LIMIT = 10; -async function serverSideSearch(term, roomId = undefined) { +async function serverSideSearch( + term: string, + roomId: string = undefined, +): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> { const client = MatrixClientPeg.get(); - const filter = { + const filter: IRoomEventFilter = { limit: SEARCH_LIMIT, }; if (roomId !== undefined) filter.rooms = [roomId]; - const body = { + const body: ISearchRequestBody = { search_categories: { room_events: { search_term: term, filter: filter, - order_by: "recent", + order_by: SearchOrderBy.Recent, event_context: { before_limit: 1, after_limit: 1, @@ -43,33 +59,28 @@ async function serverSideSearch(term, roomId = undefined) { }, }; - const response = await client.search({body: body}); + const response = await client.search({ body: body }); - const result = { - response: response, - query: body, - }; - - return result; + return { response, query: body }; } -async function serverSideSearchProcess(term, roomId = undefined) { +async function serverSideSearchProcess(term: string, roomId: string = undefined): Promise { const client = MatrixClientPeg.get(); const result = await serverSideSearch(term, roomId); // The js-sdk method backPaginateRoomEventsSearch() uses _query internally - // so we're reusing the concept here since we wan't to delegate the + // so we're reusing the concept here since we want to delegate the // pagination back to backPaginateRoomEventsSearch() in some cases. - const searchResult = { + const searchResults: ISearchResults = { _query: result.query, results: [], highlights: [], }; - return client._processRoomEventsSearch(searchResult, result.response); + return client.processRoomEventsSearch(searchResults, result.response); } -function compareEvents(a, b) { +function compareEvents(a: ISearchResult, b: ISearchResult): number { const aEvent = a.result; const bEvent = b.result; @@ -79,7 +90,7 @@ function compareEvents(a, b) { return 0; } -async function combinedSearch(searchTerm) { +async function combinedSearch(searchTerm: string): Promise { const client = MatrixClientPeg.get(); // Create two promises, one for the local search, one for the @@ -111,10 +122,10 @@ async function combinedSearch(searchTerm) { // returns since that one can be either a server-side one, a local one or a // fake one to fetch the remaining cached events. See the docs for // combineEvents() for an explanation why we need to cache events. - const emptyResult = { + const emptyResult: ISeshatSearchResults = { seshatQuery: localQuery, _query: serverQuery, - serverSideNextBatch: serverResponse.next_batch, + serverSideNextBatch: serverResponse.search_categories.room_events.next_batch, cachedEvents: [], oldestEventFrom: "server", results: [], @@ -125,13 +136,13 @@ async function combinedSearch(searchTerm) { const combinedResult = combineResponses(emptyResult, localResponse, serverResponse.search_categories.room_events); // Let the client process the combined result. - const response = { + const response: ISearchResponse = { search_categories: { room_events: combinedResult, }, }; - const result = client._processRoomEventsSearch(emptyResult, response); + const result = client.processRoomEventsSearch(emptyResult, response); // Restore our encryption info so we can properly re-verify the events. restoreEncryptionInfo(result.results); @@ -139,10 +150,14 @@ async function combinedSearch(searchTerm) { return result; } -async function localSearch(searchTerm, roomId = undefined, processResult = true) { +async function localSearch( + searchTerm: string, + roomId: string = undefined, + processResult = true, +): Promise<{ response: IResultRoomEvents, query: ISearchArgs }> { const eventIndex = EventIndexPeg.get(); - const searchArgs = { + const searchArgs: ISearchArgs = { search_term: searchTerm, before_limit: 1, after_limit: 1, @@ -167,11 +182,18 @@ async function localSearch(searchTerm, roomId = undefined, processResult = true) return result; } -async function localSearchProcess(searchTerm, roomId = undefined) { +export interface ISeshatSearchResults extends ISearchResults { + seshatQuery?: ISearchArgs; + cachedEvents?: ISearchResult[]; + oldestEventFrom?: "local" | "server"; + serverSideNextBatch?: string; +} + +async function localSearchProcess(searchTerm: string, roomId: string = undefined): Promise { const emptyResult = { results: [], highlights: [], - }; + } as ISeshatSearchResults; if (searchTerm === "") return emptyResult; @@ -179,20 +201,20 @@ async function localSearchProcess(searchTerm, roomId = undefined) { emptyResult.seshatQuery = result.query; - const response = { + const response: ISearchResponse = { search_categories: { room_events: result.response, }, }; - const processedResult = MatrixClientPeg.get()._processRoomEventsSearch(emptyResult, response); + const processedResult = MatrixClientPeg.get().processRoomEventsSearch(emptyResult, response); // Restore our encryption info so we can properly re-verify the events. restoreEncryptionInfo(processedResult.results); return processedResult; } -async function localPagination(searchResult) { +async function localPagination(searchResult: ISeshatSearchResults): Promise { const eventIndex = EventIndexPeg.get(); const searchArgs = searchResult.seshatQuery; @@ -210,7 +232,7 @@ async function localPagination(searchResult) { }, }; - const result = MatrixClientPeg.get()._processRoomEventsSearch(searchResult, response); + const result = MatrixClientPeg.get().processRoomEventsSearch(searchResult, response); // Restore our encryption info so we can properly re-verify the events. const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0)); @@ -221,10 +243,10 @@ async function localPagination(searchResult) { return result; } -function compareOldestEvents(firstResults, secondResults) { +function compareOldestEvents(firstResults: ISearchResult[], secondResults: ISearchResult[]): number { try { - const oldestFirstEvent = firstResults.results[firstResults.results.length - 1].result; - const oldestSecondEvent = secondResults.results[secondResults.results.length - 1].result; + const oldestFirstEvent = firstResults[firstResults.length - 1].result; + const oldestSecondEvent = secondResults[secondResults.length - 1].result; if (oldestFirstEvent.origin_server_ts <= oldestSecondEvent.origin_server_ts) { return -1; @@ -236,7 +258,12 @@ function compareOldestEvents(firstResults, secondResults) { } } -function combineEventSources(previousSearchResult, response, a, b) { +function combineEventSources( + previousSearchResult: ISeshatSearchResults, + response: IResultRoomEvents, + a: ISearchResult[], + b: ISearchResult[], +): void { // Merge event sources and sort the events. const combinedEvents = a.concat(b).sort(compareEvents); // Put half of the events in the response, and cache the other half. @@ -353,8 +380,12 @@ function combineEventSources(previousSearchResult, response, a, b) { * different event sources. * */ -function combineEvents(previousSearchResult, localEvents = undefined, serverEvents = undefined) { - const response = {}; +function combineEvents( + previousSearchResult: ISeshatSearchResults, + localEvents: IResultRoomEvents = undefined, + serverEvents: IResultRoomEvents = undefined, +): IResultRoomEvents { + const response = {} as IResultRoomEvents; const cachedEvents = previousSearchResult.cachedEvents; let oldestEventFrom = previousSearchResult.oldestEventFrom; @@ -364,7 +395,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven // This is a first search call, combine the events from the server and // the local index. Note where our oldest event came from, we shall // fetch the next batch of events from the other source. - if (compareOldestEvents(localEvents, serverEvents) < 0) { + if (compareOldestEvents(localEvents.results, serverEvents.results) < 0) { oldestEventFrom = "local"; } @@ -375,7 +406,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven // meaning that our oldest event was on the server. // Change the source of the oldest event if our local event is older // than the cached one. - if (compareOldestEvents(localEvents, cachedEvents) < 0) { + if (compareOldestEvents(localEvents.results, cachedEvents) < 0) { oldestEventFrom = "local"; } combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents); @@ -384,7 +415,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven // meaning that our oldest event was in the local index. // Change the source of the oldest event if our server event is older // than the cached one. - if (compareOldestEvents(serverEvents, cachedEvents) < 0) { + if (compareOldestEvents(serverEvents.results, cachedEvents) < 0) { oldestEventFrom = "server"; } combineEventSources(previousSearchResult, response, serverEvents.results, cachedEvents); @@ -412,7 +443,11 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven * @return {object} A response object that combines the events from the * different event sources. */ -function combineResponses(previousSearchResult, localEvents = undefined, serverEvents = undefined) { +function combineResponses( + previousSearchResult: ISeshatSearchResults, + localEvents: IResultRoomEvents = undefined, + serverEvents: IResultRoomEvents = undefined, +): IResultRoomEvents { // Combine our events first. const response = combineEvents(previousSearchResult, localEvents, serverEvents); @@ -454,42 +489,51 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE return response; } -function restoreEncryptionInfo(searchResultSlice = []) { +interface IEncryptedSeshatEvent { + curve25519Key: string; + ed25519Key: string; + algorithm: string; + forwardingCurve25519KeyChain: string[]; +} + +function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void { for (let i = 0; i < searchResultSlice.length; i++) { const timeline = searchResultSlice[i].context.getTimeline(); for (let j = 0; j < timeline.length; j++) { - const ev = timeline[j]; + const mxEv = timeline[j]; + const ev = mxEv.event as IEncryptedSeshatEvent; - if (ev.event.curve25519Key) { - ev.makeEncrypted( - "m.room.encrypted", - { algorithm: ev.event.algorithm }, - ev.event.curve25519Key, - ev.event.ed25519Key, + if (ev.curve25519Key) { + mxEv.makeEncrypted( + EventType.RoomMessageEncrypted, + { algorithm: ev.algorithm }, + ev.curve25519Key, + ev.ed25519Key, ); - ev._forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain; + // @ts-ignore + mxEv.forwardingCurve25519KeyChain = ev.forwardingCurve25519KeyChain; - delete ev.event.curve25519Key; - delete ev.event.ed25519Key; - delete ev.event.algorithm; - delete ev.event.forwardingCurve25519KeyChain; + delete ev.curve25519Key; + delete ev.ed25519Key; + delete ev.algorithm; + delete ev.forwardingCurve25519KeyChain; } } } } -async function combinedPagination(searchResult) { +async function combinedPagination(searchResult: ISeshatSearchResults): Promise { const eventIndex = EventIndexPeg.get(); const client = MatrixClientPeg.get(); const searchArgs = searchResult.seshatQuery; const oldestEventFrom = searchResult.oldestEventFrom; - let localResult; - let serverSideResult; + let localResult: IResultRoomEvents; + let serverSideResult: ISearchResponse; - // Fetch events from the local index if we have a token for itand if it's + // Fetch events from the local index if we have a token for it and if it's // the local indexes turn or the server has exhausted its results. if (searchArgs.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) { localResult = await eventIndex.search(searchArgs); @@ -498,11 +542,11 @@ async function combinedPagination(searchResult) { // Fetch events from the server if we have a token for it and if it's the // local indexes turn or the local index has exhausted its results. if (searchResult.serverSideNextBatch && (oldestEventFrom === "local" || !searchArgs.next_batch)) { - const body = {body: searchResult._query, next_batch: searchResult.serverSideNextBatch}; + const body = { body: searchResult._query, next_batch: searchResult.serverSideNextBatch }; serverSideResult = await client.search(body); } - let serverEvents; + let serverEvents: IResultRoomEvents; if (serverSideResult) { serverEvents = serverSideResult.search_categories.room_events; @@ -520,7 +564,7 @@ async function combinedPagination(searchResult) { const oldResultCount = searchResult.results ? searchResult.results.length : 0; // Let the client process the combined result. - const result = client._processRoomEventsSearch(searchResult, response); + const result = client.processRoomEventsSearch(searchResult, response); // Restore our encryption info so we can properly re-verify the events. const newResultCount = result.results.length - oldResultCount; @@ -532,8 +576,8 @@ async function combinedPagination(searchResult) { return result; } -function eventIndexSearch(term, roomId = undefined) { - let searchPromise; +function eventIndexSearch(term: string, roomId: string = undefined): Promise { + let searchPromise: Promise; if (roomId !== undefined) { if (MatrixClientPeg.get().isRoomEncrypted(roomId)) { @@ -554,7 +598,7 @@ function eventIndexSearch(term, roomId = undefined) { return searchPromise; } -function eventIndexSearchPagination(searchResult) { +function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise { const client = MatrixClientPeg.get(); const seshatQuery = searchResult.seshatQuery; @@ -580,7 +624,7 @@ function eventIndexSearchPagination(searchResult) { } } -export function searchPagination(searchResult) { +export function searchPagination(searchResult: ISearchResults): Promise { const eventIndex = EventIndexPeg.get(); const client = MatrixClientPeg.get(); @@ -590,7 +634,7 @@ export function searchPagination(searchResult) { else return eventIndexSearchPagination(searchResult); } -export default function eventSearch(term, roomId = undefined) { +export default function eventSearch(term: string, roomId: string = undefined): Promise { const eventIndex = EventIndexPeg.get(); if (eventIndex === null) return serverSideSearchProcess(term, roomId); diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 220320470a..370b21b396 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -14,11 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix'; +import { ICryptoCallbacks } from 'matrix-js-sdk/src/matrix'; +import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import Modal from './Modal'; import * as sdk from './index'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; import { _t } from './languageHandler'; @@ -28,6 +29,7 @@ import AccessSecretStorageDialog from './components/views/dialogs/security/Acces import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; import SettingsStore from "./settings/SettingsStore"; import SecurityCustomisations from "./customisations/Security"; +import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; // This stores the secret storage private keys in memory for the JS SDK. This is // only meant to act as a cache to avoid prompting the user multiple times @@ -41,8 +43,8 @@ let secretStorageBeingAccessed = false; let nonInteractive = false; let dehydrationCache: { - key?: Uint8Array, - keyInfo?: ISecretStorageKeyInfo, + key?: Uint8Array; + keyInfo?: ISecretStorageKeyInfo; } = {}; function isCachingAllowed(): boolean { @@ -98,11 +100,27 @@ async function getSecretStorageKey( { keys: keyInfos }: { keys: Record }, ssssItemName, ): Promise<[string, Uint8Array]> { - const keyInfoEntries = Object.entries(keyInfos); - if (keyInfoEntries.length > 1) { - throw new Error("Multiple storage key requests not implemented"); + const cli = MatrixClientPeg.get(); + let keyId = await cli.getDefaultSecretStorageKeyId(); + let keyInfo; + if (keyId) { + // use the default SSSS key if set + keyInfo = keyInfos[keyId]; + if (!keyInfo) { + // if the default key is not available, pretend the default key + // isn't set + keyId = undefined; + } + } + if (!keyId) { + // if no default SSSS key is set, fall back to a heuristic of using the + // only available key, if only one key is set + const keyInfoEntries = Object.entries(keyInfos); + if (keyInfoEntries.length > 1) { + throw new Error("Multiple storage key requests not implemented"); + } + [keyId, keyInfo] = keyInfoEntries[0]; } - const [keyId, keyInfo] = keyInfoEntries[0]; // Check the in-memory cache if (isCachingAllowed() && secretStorageKeys[keyId]) { @@ -118,7 +136,7 @@ async function getSecretStorageKey( const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); if (keyFromCustomisations) { - console.log("Using key from security customisations (secret storage)") + console.log("Using key from security customisations (secret storage)"); cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations); return [keyId, keyFromCustomisations]; } @@ -168,7 +186,7 @@ export async function getDehydrationKey( ): Promise { const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); if (keyFromCustomisations) { - console.log("Using key from security customisations (dehydration)") + console.log("Using key from security customisations (dehydration)"); return keyFromCustomisations; } @@ -207,7 +225,7 @@ export async function getDehydrationKey( const key = await inputToKey(input); // need to copy the key because rehydration (unpickling) will clobber it - dehydrationCache = {key: new Uint8Array(key), keyInfo}; + dehydrationCache = { key: new Uint8Array(key), keyInfo }; return key; } @@ -228,7 +246,7 @@ async function onSecretRequested( deviceId: string, requestId: string, name: string, - deviceTrust: IDeviceTrustLevel, + deviceTrust: DeviceTrustLevel, ): Promise { console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust); const client = MatrixClientPeg.get(); @@ -255,7 +273,7 @@ async function onSecretRequested( } return key && encodeBase64(key); } else if (name === "m.megolm_backup.v1") { - const key = await client._crypto.getSessionBackupPrivateKey(); + const key = await client.crypto.getSessionBackupPrivateKey(); if (!key) { console.log( `session backup key requested by ${deviceId}, but not found in cache`, @@ -337,6 +355,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f throw new Error("Secret storage creation canceled"); } } else { + // FIXME: Using an import will result in test failures const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); await cli.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async (makeRequest) => { @@ -379,6 +398,8 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f } catch (e) { SecurityCustomisations.catchAccessSecretStorageError?.(e); console.error(e); + // Re-throw so that higher level logic can abort as needed + throw e; } finally { // Clear secret storage key cache now that work is complete secretStorageBeingAccessed = false; diff --git a/src/SendHistoryManager.ts b/src/SendHistoryManager.ts index e9268ad642..eeba643d81 100644 --- a/src/SendHistoryManager.ts +++ b/src/SendHistoryManager.ts @@ -1,4 +1,3 @@ -//@flow /* Copyright 2017 Aviral Dasgupta @@ -15,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {clamp} from "lodash"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import { clamp } from "lodash"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import {SerializedPart} from "./editor/parts"; +import { SerializedPart } from "./editor/parts"; import EditorModel from "./editor/model"; interface IHistoryItem { diff --git a/src/Skinner.js b/src/Skinner.js index 87c5a7be7f..ef340e4052 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -23,7 +23,7 @@ class Skinner { if (!name) throw new Error(`Invalid component name: ${name}`); if (this.components === null) { throw new Error( - "Attempted to get a component before a skin has been loaded."+ + `Attempted to get a component (${name}) before a skin has been loaded.`+ " This is probably because either:"+ " a) Your app has not called sdk.loadSkin(), or"+ " b) A component has called getComponent at the root level", @@ -50,8 +50,8 @@ class Skinner { return null; } - // components have to be functions. - const validType = typeof comp === 'function'; + // components have to be functions or forwardRef objects with a render function. + const validType = typeof comp === 'function' || comp.render; if (!validType) { throw new Error(`Not a valid component: ${name} (type = ${typeof(comp)}).`); } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index e94cf7a37c..7753ff6f75 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -17,27 +17,27 @@ See the License for the specific language governing permissions and limitations under the License. */ - import * as React from 'react'; +import { User } from "matrix-js-sdk/src/models/user"; -import {MatrixClientPeg} from './MatrixClientPeg'; +import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; -import * as sdk from './index'; -import {_t, _td} from './languageHandler'; +import { _t, _td } from './languageHandler'; import Modal from './Modal'; import MultiInviter from './utils/MultiInviter'; import { linkifyAndSanitizeHtml } from './HtmlUtils'; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import WidgetUtils from "./utils/WidgetUtils"; -import {textToHtmlRainbow} from "./utils/colour"; +import { textToHtmlRainbow } from "./utils/colour"; import { getAddressType } from './UserAddress'; import { abbreviateUrl } from './utils/UrlUtils'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils'; -import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks"; -import {inviteUsersToRoom} from "./RoomInvite"; +import { isPermalinkHost, parsePermalink } from "./utils/permalinks/Permalinks"; +import { inviteUsersToRoom } from "./RoomInvite"; import { WidgetType } from "./widgets/WidgetType"; import { Jitsi } from "./widgets/Jitsi"; -import { parseFragment as parseHtml } from "parse5"; +import { parseFragment as parseHtml, Element as ChildElement } from "parse5"; import BugReportDialog from "./components/views/dialogs/BugReportDialog"; import { ensureDMExists } from "./createRoom"; import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; @@ -45,7 +45,16 @@ import { Action } from "./dispatcher/actions"; import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from "./utils/membership"; import SdkConfig from "./SdkConfig"; import SettingsStore from "./settings/SettingsStore"; -import {UIFeature} from "./settings/UIFeature"; +import { UIFeature } from "./settings/UIFeature"; +import { CHAT_EFFECTS } from "./effects"; +import CallHandler from "./CallHandler"; +import { guessAndSetDMRoom } from "./Rooms"; +import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog'; +import ErrorDialog from './components/views/dialogs/ErrorDialog'; +import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog'; +import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog"; +import InfoDialog from "./components/views/dialogs/InfoDialog"; +import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -59,7 +68,6 @@ const singleMxcUpload = async (): Promise => { fileSelector.onchange = (ev: HTMLInputEvent) => { const file = ev.target.files[0]; - const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { file, onFinished: (shouldContinue) => { @@ -77,6 +85,7 @@ export const CommandCategories = { "actions": _td("Actions"), "admin": _td("Admin"), "advanced": _td("Advanced"), + "effects": _td("Effects"), "other": _td("Other"), }; @@ -122,10 +131,10 @@ export class Command { return this.getCommand() + " " + this.args; } - run(roomId: string, args: string, cmd: string) { + run(roomId: string, args: string) { // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` if (!this.runFn) return reject(_t("Command error")); - return this.runFn.bind(this)(roomId, args, cmd); + return this.runFn.bind(this)(roomId, args); } getUsage() { @@ -138,11 +147,15 @@ export class Command { } function reject(error) { - return {error}; + return { error }; } function success(promise?: Promise) { - return {promise}; + return { promise }; +} + +function successSync(value: any) { + return success(Promise.resolve(value)); } /* Disable the "unexpected this" error for these commands - all of the run @@ -150,6 +163,18 @@ function success(promise?: Promise) { */ export const Commands = [ + new Command({ + command: 'spoiler', + args: '', + description: _td('Sends the given message as a spoiler'), + runFn: function(roomId, message) { + return successSync(ContentHelpers.makeHtmlMessage( + message, + `${message}`, + )); + }, + category: CommandCategories.messages, + }), new Command({ command: 'shrug', args: '', @@ -159,7 +184,33 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); + return successSync(ContentHelpers.makeTextMessage(message)); + }, + category: CommandCategories.messages, + }), + new Command({ + command: 'tableflip', + args: '', + description: _td('Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message'), + runFn: function(roomId, args) { + let message = '(╯°□°)╯︵ ┻━┻'; + if (args) { + message = message + ' ' + args; + } + return successSync(ContentHelpers.makeTextMessage(message)); + }, + category: CommandCategories.messages, + }), + new Command({ + command: 'unflip', + args: '', + description: _td('Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message'), + runFn: function(roomId, args) { + let message = '┬──┬ ノ( ゜-゜ノ)'; + if (args) { + message = message + ' ' + args; + } + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -172,7 +223,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -181,7 +232,7 @@ export const Commands = [ args: '', description: _td('Sends a message as plain text, without interpreting it as markdown'), runFn: function(roomId, messages) { - return success(MatrixClientPeg.get().sendTextMessage(roomId, messages)); + return successSync(ContentHelpers.makeTextMessage(messages)); }, category: CommandCategories.messages, }), @@ -190,7 +241,7 @@ export const Commands = [ args: '', description: _td('Sends a message as html, without interpreting it as markdown'), runFn: function(roomId, messages) { - return success(MatrixClientPeg.get().sendHtmlMessage(roomId, messages, messages)); + return successSync(ContentHelpers.makeHtmlMessage(messages, messages)); }, category: CommandCategories.messages, }), @@ -199,7 +250,6 @@ export const Commands = [ args: '', description: _td('Searches DuckDuckGo for results'), runFn: function() { - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); // TODO Don't explain this away, actually show a search UI here. Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { title: _t('/ddg is not a command'), @@ -222,10 +272,8 @@ export const Commands = [ return reject(_t("You do not have the required permissions to use this command.")); } - const RoomUpgradeWarningDialog = sdk.getComponent("dialogs.RoomUpgradeWarningDialog"); - - const {finished} = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', - RoomUpgradeWarningDialog, {roomId: roomId, targetVersion: args}, /*className=*/null, + const { finished } = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', + RoomUpgradeWarningDialog, { roomId: roomId, targetVersion: args }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); return success(finished.then(async ([resp]) => { @@ -241,7 +289,7 @@ export const Commands = [ if (resp.invite) { checkForUpgradeFn = async (newRoom) => { // The upgradePromise should be done by the time we await it here. - const {replacement_room: newRoomId} = await upgradePromise; + const { replacement_room: newRoomId } = await upgradePromise; if (newRoom.roomId !== newRoomId) return; const toInvite = [ @@ -267,7 +315,6 @@ export const Commands = [ if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn); - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, { title: _t('Error upgrading room'), description: _t( @@ -323,7 +370,7 @@ export const Commands = [ return success(promise.then((url) => { if (!url) return; - return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.avatar', {url}, ''); + return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.avatar', { url }, ''); })); }, category: CommandCategories.actions, @@ -387,7 +434,6 @@ export const Commands = [ const topic = topicEvents && topicEvents.getContent().topic; const topicHtml = topic ? linkifyAndSanitizeHtml(topic) : _t('This room has no topic.'); - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, { title: room.name, description:
, @@ -411,15 +457,14 @@ export const Commands = [ }), new Command({ command: 'invite', - args: '', + args: ' []', description: _td('Invites user with given id to current room'), runFn: function(roomId, args) { if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { + const [address, reason] = args.split(/\s+(.+)/); + if (address) { // We use a MultiInviter to re-use the invite logic, even though // we're only inviting one user. - const address = matches[1]; // If we need an identity server but don't have one, things // get a bit more complex here, but we try to show something // meaningful. @@ -460,7 +505,7 @@ export const Commands = [ } const inviter = new MultiInviter(roomId); return success(prom.then(() => { - return inviter.invite([address]); + return inviter.invite([address], reason); }).then(() => { if (inviter.getCompletionState(address) !== "invited") { throw new Error(inviter.getErrorText(address)); @@ -691,11 +736,10 @@ export const Commands = [ ignoredUsers.push(userId); // de-duped internally in the js-sdk return success( cli.setIgnoredUsers(ignoredUsers).then(() => { - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'User ignored', InfoDialog, { title: _t('Ignored user'), description:
-

{ _t('You are now ignoring %(userId)s', {userId}) }

+

{ _t('You are now ignoring %(userId)s', { userId }) }

, }); }), @@ -722,11 +766,10 @@ export const Commands = [ if (index !== -1) ignoredUsers.splice(index, 1); return success( cli.setIgnoredUsers(ignoredUsers).then(() => { - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'User unignored', InfoDialog, { title: _t('Unignored user'), description:
-

{ _t('You are no longer ignoring %(userId)s', {userId}) }

+

{ _t('You are no longer ignoring %(userId)s', { userId }) }

, }); }), @@ -792,8 +835,7 @@ export const Commands = [ command: 'devtools', description: _td('Opens the Developer Tools dialog'), runFn: function(roomId) { - const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog'); - Modal.createDialog(DevtoolsDialog, {roomId}); + Modal.createDialog(DevtoolsDialog, { roomId }); return success(); }, category: CommandCategories.advanced, @@ -814,7 +856,7 @@ export const Commands = [ // some superfast regex over the text so we don't have to. const embed = parseHtml(widgetUrl); if (embed && embed.childNodes && embed.childNodes.length === 1) { - const iframe = embed.childNodes[0]; + const iframe = embed.childNodes[0] as ChildElement; if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) { const srcAttr = iframe.attrs.find(a => a.name === 'src'); console.log("Pulling URL out of iframe (embed code)"); @@ -897,7 +939,6 @@ export const Commands = [ await cli.setDeviceVerified(userId, deviceId, true); // Tell the user we verified everything - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, { title: _t('Verified key'), description:
@@ -905,7 +946,7 @@ export const Commands = [ { _t('The signing key you provided matches the signing key you received ' + 'from %(userId)s\'s session %(deviceId)s. Session marked as verified.', - {userId, deviceId}) + { userId, deviceId }) }

, @@ -936,7 +977,7 @@ export const Commands = [ args: '', runFn: function(roomId, args) { if (!args) return reject(this.getUserId()); - return success(MatrixClientPeg.get().sendHtmlMessage(roomId, args, textToHtmlRainbow(args))); + return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args))); }, category: CommandCategories.messages, }), @@ -946,7 +987,7 @@ export const Commands = [ args: '', runFn: function(roomId, args) { if (!args) return reject(this.getUserId()); - return success(MatrixClientPeg.get().sendHtmlEmote(roomId, args, textToHtmlRainbow(args))); + return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args))); }, category: CommandCategories.messages, }), @@ -954,8 +995,6 @@ export const Commands = [ command: "help", description: _td("Displays list of commands with usages and descriptions"), runFn: function() { - const SlashCommandHelpDialog = sdk.getComponent('dialogs.SlashCommandHelpDialog'); - Modal.createTrackedDialog('Slash Commands', 'Help', SlashCommandHelpDialog); return success(); }, @@ -973,9 +1012,8 @@ export const Commands = [ const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId); dis.dispatch({ action: Action.ViewUser, - // XXX: We should be using a real member object and not assuming what the - // receiver wants. - member: member || {userId}, + // XXX: We should be using a real member object and not assuming what the receiver wants. + member: member || { userId } as User, }); return success(); }, @@ -1001,14 +1039,27 @@ export const Commands = [ description: _td("Opens chat with the given user"), args: "", runFn: function(roomId, userId) { - if (!userId || !userId.startsWith("@") || !userId.includes(":")) { + // easter-egg for now: look up phone numbers through the thirdparty API + // (very dumb phone number detection...) + const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId); + if (!userId || (!userId.startsWith("@") || !userId.includes(":")) && !isPhoneNumber) { return reject(this.getUsage()); } return success((async () => { + if (isPhoneNumber) { + const results = await CallHandler.sharedInstance().pstnLookup(this.state.value); + if (!results || results.length === 0 || !results[0].userid) { + throw new Error("Unable to find Matrix ID for phone number"); + } + userId = results[0].userid; + } + + const roomId = await ensureDMExists(MatrixClientPeg.get(), userId); + dis.dispatch({ action: 'view_room', - room_id: await ensureDMExists(MatrixClientPeg.get(), userId), + room_id: roomId, }); })()); }, @@ -1042,6 +1093,50 @@ export const Commands = [ }, category: CommandCategories.actions, }), + new Command({ + command: "holdcall", + description: _td("Places the call in the current room on hold"), + category: CommandCategories.other, + runFn: function(roomId, args) { + const call = CallHandler.sharedInstance().getCallForRoom(roomId); + if (!call) { + return reject("No active call in this room"); + } + call.setRemoteOnHold(true); + return success(); + }, + }), + new Command({ + command: "unholdcall", + description: _td("Takes the call in the current room off hold"), + category: CommandCategories.other, + runFn: function(roomId, args) { + const call = CallHandler.sharedInstance().getCallForRoom(roomId); + if (!call) { + return reject("No active call in this room"); + } + call.setRemoteOnHold(false); + return success(); + }, + }), + new Command({ + command: "converttodm", + description: _td("Converts the room to a DM"), + category: CommandCategories.other, + runFn: function(roomId, args) { + const room = MatrixClientPeg.get().getRoom(roomId); + return success(guessAndSetDMRoom(room, true)); + }, + }), + new Command({ + command: "converttoroom", + description: _td("Converts the DM to a room"), + category: CommandCategories.other, + runFn: function(roomId, args) { + const room = MatrixClientPeg.get().getRoom(roomId); + return success(guessAndSetDMRoom(room, false)); + }, + }), // Command definitions for autocompletion ONLY: // /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes @@ -1052,10 +1147,34 @@ export const Commands = [ category: CommandCategories.messages, hideCompletionAfterSpace: true, }), + + ...CHAT_EFFECTS.map((effect) => { + return new Command({ + command: effect.command, + description: effect.description(), + args: '', + runFn: function(roomId, args) { + return success((async () => { + if (!args) { + args = effect.fallbackMessage(); + MatrixClientPeg.get().sendEmoteMessage(roomId, args); + } else { + const content = { + msgtype: effect.msgType, + body: args, + }; + MatrixClientPeg.get().sendMessage(roomId, content); + } + dis.dispatch({ action: `effects.${effect.command}` }); + })()); + }, + category: CommandCategories.effects, + }); + }), ]; // build a map from names and aliases to the Command objects. -export const CommandMap = new Map(); +export const CommandMap = new Map(); Commands.forEach(cmd => { CommandMap.set(cmd.command, cmd); cmd.aliases.forEach(alias => { @@ -1063,15 +1182,15 @@ Commands.forEach(cmd => { }); }); -export function parseCommandString(input: string) { +export function parseCommandString(input: string): { cmd?: string, args?: string } { // trim any trailing whitespace, as it can confuse the parser for // IRC-style commands input = input.replace(/\s+$/, ''); if (input[0] !== '/') return {}; // not a command - const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/); - let cmd; - let args; + const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/); + let cmd: string; + let args: string; if (bits) { cmd = bits[1].substring(1).toLowerCase(); args = bits[2]; @@ -1079,7 +1198,12 @@ export function parseCommandString(input: string) { cmd = input; } - return {cmd, args}; + return { cmd, args }; +} + +interface ICmd { + cmd?: Command; + args?: string; } /** @@ -1090,10 +1214,14 @@ export function parseCommandString(input: string) { * processing the command, or 'promise' if a request was sent out. * Returns null if the input didn't match a command. */ -export function getCommand(roomId: string, input: string) { - const {cmd, args} = parseCommandString(input); +export function getCommand(input: string): ICmd { + const { cmd, args } = parseCommandString(input); if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) { - return () => CommandMap.get(cmd).run(roomId, args, cmd); + return { + cmd: CommandMap.get(cmd), + args, + }; } + return {}; } diff --git a/src/Terms.js b/src/Terms.ts similarity index 80% rename from src/Terms.js rename to src/Terms.ts index 6ae89f9a2c..351d1c0951 100644 --- a/src/Terms.js +++ b/src/Terms.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -15,9 +15,10 @@ limitations under the License. */ import classNames from 'classnames'; +import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; -import {MatrixClientPeg} from './MatrixClientPeg'; -import * as sdk from './'; +import { MatrixClientPeg } from './MatrixClientPeg'; +import * as sdk from '.'; import Modal from './Modal'; export class TermsNotSignedError extends Error {} @@ -32,13 +33,34 @@ export class Service { * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') * @param {string} accessToken The user's access token for the service */ - constructor(serviceType, baseUrl, accessToken) { - this.serviceType = serviceType; - this.baseUrl = baseUrl; - this.accessToken = accessToken; + constructor(public serviceType: SERVICE_TYPES, public baseUrl: string, public accessToken: string) { } } +export interface LocalisedPolicy { + name: string; + url: string; +} + +export interface Policy { + // @ts-ignore: No great way to express indexed types together with other keys + version: string; + [lang: string]: LocalisedPolicy; +} + +export type Policies = { + [policy: string]: Policy; +}; + +export type TermsInteractionCallback = ( + policiesAndServicePairs: { + service: Service; + policies: Policies; + }[], + agreedUrls: string[], + extraClassNames?: string, +) => Promise; + /** * Start a flow where the user is presented with terms & conditions for some services * @@ -51,8 +73,8 @@ export class Service { * if they cancel. */ export async function startTermsFlow( - services, - interactionCallback = dialogTermsInteractionCallback, + services: Service[], + interactionCallback: TermsInteractionCallback = dialogTermsInteractionCallback, ) { const termsPromises = services.map( (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl), @@ -77,12 +99,12 @@ export async function startTermsFlow( * } */ - const terms = await Promise.all(termsPromises); + const terms: { policies: Policies }[] = await Promise.all(termsPromises); const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; }); // fetch the set of agreed policy URLs from account data const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms'); - let agreedUrlSet; + let agreedUrlSet: Set; if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) { agreedUrlSet = new Set(); } else { @@ -96,7 +118,7 @@ export async function startTermsFlow( // but that is not a thing the API supports, so probably best to just show // things they've not agreed to yet. const unagreedPoliciesAndServicePairs = []; - for (const {service, policies} of policiesAndServicePairs) { + for (const { service, policies } of policiesAndServicePairs) { const unagreedPolicies = {}; for (const [policyName, policy] of Object.entries(policies)) { let policyAgreed = false; @@ -110,7 +132,7 @@ export async function startTermsFlow( if (!policyAgreed) unagreedPolicies[policyName] = policy; } if (Object.keys(unagreedPolicies).length > 0) { - unagreedPoliciesAndServicePairs.push({service, policies: unagreedPolicies}); + unagreedPoliciesAndServicePairs.push({ service, policies: unagreedPolicies }); } } @@ -127,7 +149,7 @@ export async function startTermsFlow( // We only ever add to the set of URLs, so if anything has changed then we'd see a different length if (agreedUrlSet.size !== numAcceptedBeforeAgreement) { - const newAcceptedTerms = {accepted: Array.from(agreedUrlSet)}; + const newAcceptedTerms = { accepted: Array.from(agreedUrlSet) }; await MatrixClientPeg.get().setAccountData('m.accepted_terms', newAcceptedTerms); } @@ -158,12 +180,16 @@ export async function startTermsFlow( } export function dialogTermsInteractionCallback( - policiesAndServicePairs, - agreedUrls, - extraClassNames, -) { + policiesAndServicePairs: { + service: Service; + policies: { [policy: string]: Policy }; + }[], + agreedUrls: string[], + extraClassNames?: string, +): Promise { return new Promise((resolve, reject) => { console.log("Terms that need agreement", policiesAndServicePairs); + // FIXME: Using an import will result in test failures const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); Modal.createTrackedDialog('Terms of Service', '', TermsDialog, { diff --git a/src/TextForEvent.js b/src/TextForEvent.js deleted file mode 100644 index d86d88a697..0000000000 --- a/src/TextForEvent.js +++ /dev/null @@ -1,597 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ -import {MatrixClientPeg} from './MatrixClientPeg'; -import { _t } from './languageHandler'; -import * as Roles from './Roles'; -import {isValid3pidInvite} from "./RoomInvite"; -import SettingsStore from "./settings/SettingsStore"; -import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; - -function textForMemberEvent(ev) { - // XXX: SYJS-16 "sender is sometimes null for join messages" - const senderName = ev.sender ? ev.sender.name : ev.getSender(); - const targetName = ev.target ? ev.target.name : ev.getStateKey(); - const prevContent = ev.getPrevContent(); - const content = ev.getContent(); - - const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : ''; - switch (content.membership) { - case 'invite': { - const threePidContent = content.third_party_invite; - if (threePidContent) { - if (threePidContent.display_name) { - return _t('%(targetName)s accepted the invitation for %(displayName)s.', { - targetName, - displayName: threePidContent.display_name, - }); - } else { - return _t('%(targetName)s accepted an invitation.', {targetName}); - } - } else { - return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); - } - } - case 'ban': - return _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + reason; - case 'join': - if (prevContent && prevContent.membership === 'join') { - if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { - return _t('%(oldDisplayName)s changed their display name to %(displayName)s.', { - oldDisplayName: prevContent.displayname, - displayName: content.displayname, - }); - } else if (!prevContent.displayname && content.displayname) { - return _t('%(senderName)s set their display name to %(displayName)s.', { - senderName: ev.getSender(), - displayName: content.displayname, - }); - } else if (prevContent.displayname && !content.displayname) { - return _t('%(senderName)s removed their display name (%(oldDisplayName)s).', { - senderName, - oldDisplayName: prevContent.displayname, - }); - } else if (prevContent.avatar_url && !content.avatar_url) { - return _t('%(senderName)s removed their profile picture.', {senderName}); - } else if (prevContent.avatar_url && content.avatar_url && - prevContent.avatar_url !== content.avatar_url) { - return _t('%(senderName)s changed their profile picture.', {senderName}); - } else if (!prevContent.avatar_url && content.avatar_url) { - return _t('%(senderName)s set a profile picture.', {senderName}); - } else if (SettingsStore.getValue("showHiddenEventsInTimeline")) { - // This is a null rejoin, it will only be visible if the Labs option is enabled - return _t("%(senderName)s made no change.", {senderName}); - } else { - return ""; - } - } else { - if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); - return _t('%(targetName)s joined the room.', {targetName}); - } - case 'leave': - if (ev.getSender() === ev.getStateKey()) { - if (prevContent.membership === "invite") { - return _t('%(targetName)s rejected the invitation.', {targetName}); - } else { - return _t('%(targetName)s left the room.', {targetName}); - } - } else if (prevContent.membership === "ban") { - return _t('%(senderName)s unbanned %(targetName)s.', {senderName, targetName}); - } else if (prevContent.membership === "invite") { - return _t('%(senderName)s withdrew %(targetName)s\'s invitation.', { - senderName, - targetName, - }) + ' ' + reason; - } else { - // sender is not target and made the target leave, if not from invite/ban then this is a kick - return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason; - } - } -} - -function textForTopicEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - return _t('%(senderDisplayName)s changed the topic to "%(topic)s".', { - senderDisplayName, - topic: ev.getContent().topic, - }); -} - -function textForRoomNameEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - - if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { - return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName}); - } - if (ev.getPrevContent().name) { - return _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', { - senderDisplayName, - oldRoomName: ev.getPrevContent().name, - newRoomName: ev.getContent().name, - }); - } - return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', { - senderDisplayName, - roomName: ev.getContent().name, - }); -} - -function textForTombstoneEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - return _t('%(senderDisplayName)s upgraded this room.', {senderDisplayName}); -} - -function textForJoinRulesEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - switch (ev.getContent().join_rule) { - case "public": - return _t('%(senderDisplayName)s made the room public to whoever knows the link.', {senderDisplayName}); - case "invite": - return _t('%(senderDisplayName)s made the room invite only.', {senderDisplayName}); - default: - // The spec supports "knock" and "private", however nothing implements these. - return _t('%(senderDisplayName)s changed the join rule to %(rule)s', { - senderDisplayName, - rule: ev.getContent().join_rule, - }); - } -} - -function textForGuestAccessEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - switch (ev.getContent().guest_access) { - case "can_join": - return _t('%(senderDisplayName)s has allowed guests to join the room.', {senderDisplayName}); - case "forbidden": - return _t('%(senderDisplayName)s has prevented guests from joining the room.', {senderDisplayName}); - default: - // There's no other options we can expect, however just for safety's sake we'll do this. - return _t('%(senderDisplayName)s changed guest access to %(rule)s', { - senderDisplayName, - rule: ev.getContent().guest_access, - }); - } -} - -function textForRelatedGroupsEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - const groups = ev.getContent().groups || []; - const prevGroups = ev.getPrevContent().groups || []; - const added = groups.filter((g) => !prevGroups.includes(g)); - const removed = prevGroups.filter((g) => !groups.includes(g)); - - if (added.length && !removed.length) { - return _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', { - senderDisplayName, - groups: added.join(', '), - }); - } else if (!added.length && removed.length) { - return _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', { - senderDisplayName, - groups: removed.join(', '), - }); - } else if (added.length && removed.length) { - return _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' + - '%(oldGroups)s in this room.', { - senderDisplayName, - newGroups: added.join(', '), - oldGroups: removed.join(', '), - }); - } else { - // Don't bother rendering this change (because there were no changes) - return ''; - } -} - -function textForServerACLEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - const prevContent = ev.getPrevContent(); - const current = ev.getContent(); - const prev = { - deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], - allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], - allow_ip_literals: !(prevContent.allow_ip_literals === false), - }; - - let text = ""; - if (prev.deny.length === 0 && prev.allow.length === 0) { - text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName}); - } else { - text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName}); - } - - if (!Array.isArray(current.allow)) { - current.allow = []; - } - - // If we know for sure everyone is banned, mark the room as obliterated - if (current.allow.length === 0) { - return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used."); - } - - return text; -} - -function textForMessageEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - let message = senderDisplayName + ': ' + ev.getContent().body; - if (ev.getContent().msgtype === "m.emote") { - message = "* " + senderDisplayName + " " + message; - } else if (ev.getContent().msgtype === "m.image") { - message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); - } - return message; -} - -function textForCanonicalAliasEvent(ev) { - const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - const oldAlias = ev.getPrevContent().alias; - const oldAltAliases = ev.getPrevContent().alt_aliases || []; - const newAlias = ev.getContent().alias; - const newAltAliases = ev.getContent().alt_aliases || []; - const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias)); - const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias)); - - if (!removedAltAliases.length && !addedAltAliases.length) { - if (newAlias) { - return _t('%(senderName)s set the main address for this room to %(address)s.', { - senderName: senderName, - address: ev.getContent().alias, - }); - } else if (oldAlias) { - return _t('%(senderName)s removed the main address for this room.', { - senderName: senderName, - }); - } - } else if (newAlias === oldAlias) { - if (addedAltAliases.length && !removedAltAliases.length) { - return _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', { - senderName: senderName, - addresses: addedAltAliases.join(", "), - count: addedAltAliases.length, - }); - } if (removedAltAliases.length && !addedAltAliases.length) { - return _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', { - senderName: senderName, - addresses: removedAltAliases.join(", "), - count: removedAltAliases.length, - }); - } if (removedAltAliases.length && addedAltAliases.length) { - return _t('%(senderName)s changed the alternative addresses for this room.', { - senderName: senderName, - }); - } - } else { - // both alias and alt_aliases where modified - return _t('%(senderName)s changed the main and alternative addresses for this room.', { - senderName: senderName, - }); - } - // in case there is no difference between the two events, - // say something as we can't simply hide the tile from here - return _t('%(senderName)s changed the addresses for this room.', { - senderName: senderName, - }); -} - -function textForCallAnswerEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)'); - return _t('%(senderName)s answered the call.', {senderName}) + ' ' + supported; -} - -function textForCallHangupEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - const eventContent = event.getContent(); - let reason = ""; - if (!MatrixClientPeg.get().supportsVoip()) { - reason = _t('(not supported by this browser)'); - } else if (eventContent.reason) { - if (eventContent.reason === "ice_failed") { - // We couldn't establish a connection at all - reason = _t('(could not connect media)'); - } else if (eventContent.reason === "ice_timeout") { - // We established a connection but it died - reason = _t('(connection failed)'); - } else if (eventContent.reason === "user_media_failed") { - // The other side couldn't open capture devices - reason = _t("(their device couldn't start the camera / microphone)"); - } else if (eventContent.reason === "unknown_error") { - // An error code the other side doesn't have a way to express - // (as opposed to an error code they gave but we don't know about, - // in which case we show the error code) - reason = _t("(an error occurred)"); - } else if (eventContent.reason === "invite_timeout") { - reason = _t('(no answer)'); - } else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") { - // workaround for https://github.com/vector-im/element-web/issues/5178 - // it seems Android randomly sets a reason of "user hangup" which is - // interpreted as an error code :( - // https://github.com/vector-im/riot-android/issues/2623 - // Also the correct hangup code as of VoIP v1 (with underscore) - reason = ''; - } else { - reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason}); - } - } - return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason; -} - -function textForCallRejectEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - return _t('%(senderName)s declined the call.', {senderName}); -} - -function textForCallInviteEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - // FIXME: Find a better way to determine this from the event? - let isVoice = true; - if (event.getContent().offer && event.getContent().offer.sdp && - event.getContent().offer.sdp.indexOf('m=video') !== -1) { - isVoice = false; - } - const isSupported = MatrixClientPeg.get().supportsVoip(); - - // This ladder could be reduced down to a couple string variables, however other languages - // can have a hard time translating those strings. In an effort to make translations easier - // and more accurate, we break out the string-based variables to a couple booleans. - if (isVoice && isSupported) { - return _t("%(senderName)s placed a voice call.", {senderName}); - } else if (isVoice && !isSupported) { - return _t("%(senderName)s placed a voice call. (not supported by this browser)", {senderName}); - } else if (!isVoice && isSupported) { - return _t("%(senderName)s placed a video call.", {senderName}); - } else if (!isVoice && !isSupported) { - return _t("%(senderName)s placed a video call. (not supported by this browser)", {senderName}); - } -} - -function textForThreePidInviteEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - - if (!isValid3pidInvite(event)) { - const targetDisplayName = event.getPrevContent().display_name || _t("Someone"); - return _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', { - senderName, - targetDisplayName, - }); - } - - return _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', { - senderName, - targetDisplayName: event.getContent().display_name, - }); -} - -function textForHistoryVisibilityEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - switch (event.getContent().history_visibility) { - case 'invited': - return _t('%(senderName)s made future room history visible to all room members, ' - + 'from the point they are invited.', {senderName}); - case 'joined': - return _t('%(senderName)s made future room history visible to all room members, ' - + 'from the point they joined.', {senderName}); - case 'shared': - return _t('%(senderName)s made future room history visible to all room members.', {senderName}); - case 'world_readable': - return _t('%(senderName)s made future room history visible to anyone.', {senderName}); - default: - return _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', { - senderName, - visibility: event.getContent().history_visibility, - }); - } -} - -// Currently will only display a change if a user's power level is changed -function textForPowerEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - if (!event.getPrevContent() || !event.getPrevContent().users || - !event.getContent() || !event.getContent().users) { - return ''; - } - const userDefault = event.getContent().users_default || 0; - // Construct set of userIds - const users = []; - Object.keys(event.getContent().users).forEach( - (userId) => { - if (users.indexOf(userId) === -1) users.push(userId); - }, - ); - Object.keys(event.getPrevContent().users).forEach( - (userId) => { - if (users.indexOf(userId) === -1) users.push(userId); - }, - ); - const diff = []; - // XXX: This is also surely broken for i18n - users.forEach((userId) => { - // Previous power level - const from = event.getPrevContent().users[userId]; - // Current power level - const to = event.getContent().users[userId]; - if (to !== from) { - diff.push( - _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { - userId, - fromPowerLevel: Roles.textualPowerLevel(from, userDefault), - toPowerLevel: Roles.textualPowerLevel(to, userDefault), - }), - ); - } - }); - if (!diff.length) { - return ''; - } - return _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', { - senderName, - powerLevelDiffText: diff.join(", "), - }); -} - -function textForPinnedEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - return _t("%(senderName)s changed the pinned messages for the room.", {senderName}); -} - -function textForWidgetEvent(event) { - const senderName = event.getSender(); - const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent(); - const {name, type, url} = event.getContent() || {}; - - let widgetName = name || prevName || type || prevType || ''; - // Apply sentence case to widget name - if (widgetName && widgetName.length > 0) { - widgetName = widgetName[0].toUpperCase() + widgetName.slice(1) + ' '; - } - - // If the widget was removed, its content should be {}, but this is sufficiently - // equivalent to that condition. - if (url) { - if (prevUrl) { - return _t('%(widgetName)s widget modified by %(senderName)s', { - widgetName, senderName, - }); - } else { - return _t('%(widgetName)s widget added by %(senderName)s', { - widgetName, senderName, - }); - } - } else { - return _t('%(widgetName)s widget removed by %(senderName)s', { - widgetName, senderName, - }); - } -} - -function textForMjolnirEvent(event) { - const senderName = event.getSender(); - const {entity: prevEntity} = event.getPrevContent(); - const {entity, recommendation, reason} = event.getContent(); - - // Rule removed - if (!entity) { - if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s removed the rule banning users matching %(glob)s", - {senderName, glob: prevEntity}); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s removed the rule banning rooms matching %(glob)s", - {senderName, glob: prevEntity}); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s removed the rule banning servers matching %(glob)s", - {senderName, glob: prevEntity}); - } - - // Unknown type. We'll say something, but we shouldn't end up here. - return _t("%(senderName)s removed a ban rule matching %(glob)s", {senderName, glob: prevEntity}); - } - - // Invalid rule - if (!recommendation || !reason) return _t(`%(senderName)s updated an invalid ban rule`, {senderName}); - - // Rule updated - if (entity === prevEntity) { - if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // Unknown type. We'll say something but we shouldn't end up here. - return _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // New rule - if (!prevEntity) { - if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // Unknown type. We'll say something but we shouldn't end up here. - return _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // else the entity !== prevEntity - count as a removal & add - if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + - "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + - "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + - "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}); - } - - // Unknown type. We'll say something but we shouldn't end up here. - return _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " + - "for %(reason)s", {senderName, oldGlob: prevEntity, newGlob: entity, reason}); -} - -const handlers = { - 'm.room.message': textForMessageEvent, - 'm.call.invite': textForCallInviteEvent, - 'm.call.answer': textForCallAnswerEvent, - 'm.call.hangup': textForCallHangupEvent, - 'm.call.reject': textForCallRejectEvent, -}; - -const stateHandlers = { - 'm.room.canonical_alias': textForCanonicalAliasEvent, - 'm.room.name': textForRoomNameEvent, - 'm.room.topic': textForTopicEvent, - 'm.room.member': textForMemberEvent, - 'm.room.third_party_invite': textForThreePidInviteEvent, - 'm.room.history_visibility': textForHistoryVisibilityEvent, - 'm.room.power_levels': textForPowerEvent, - 'm.room.pinned_events': textForPinnedEvent, - 'm.room.server_acl': textForServerACLEvent, - 'm.room.tombstone': textForTombstoneEvent, - 'm.room.join_rules': textForJoinRulesEvent, - 'm.room.guest_access': textForGuestAccessEvent, - 'm.room.related_groups': textForRelatedGroupsEvent, - - // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) - 'im.vector.modular.widgets': textForWidgetEvent, -}; - -// Add all the Mjolnir stuff to the renderer -for (const evType of ALL_RULE_TYPES) { - stateHandlers[evType] = textForMjolnirEvent; -} - -export function textForEvent(ev) { - const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; - if (handler) return handler(ev); - return ''; -} diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx new file mode 100644 index 0000000000..95341705bf --- /dev/null +++ b/src/TextForEvent.tsx @@ -0,0 +1,695 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { MatrixClientPeg } from './MatrixClientPeg'; +import { _t } from './languageHandler'; +import * as Roles from './Roles'; +import { isValid3pidInvite } from "./RoomInvite"; +import SettingsStore from "./settings/SettingsStore"; +import { ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./mjolnir/BanList"; +import { WIDGET_LAYOUT_EVENT_TYPE } from "./stores/widgets/WidgetLayoutStore"; +import { RightPanelPhases } from './stores/RightPanelStorePhases'; +import { Action } from './dispatcher/actions'; +import defaultDispatcher from './dispatcher/dispatcher'; +import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +// These functions are frequently used just to check whether an event has +// any text to display at all. For this reason they return deferred values +// to avoid the expense of looking up translations when they're not needed. + +function textForMemberEvent(ev: MatrixEvent): () => string | null { + // XXX: SYJS-16 "sender is sometimes null for join messages" + const senderName = ev.sender ? ev.sender.name : ev.getSender(); + const targetName = ev.target ? ev.target.name : ev.getStateKey(); + const prevContent = ev.getPrevContent(); + const content = ev.getContent(); + const reason = content.reason; + + switch (content.membership) { + case 'invite': { + const threePidContent = content.third_party_invite; + if (threePidContent) { + if (threePidContent.display_name) { + return () => _t('%(targetName)s accepted the invitation for %(displayName)s', { + targetName, + displayName: threePidContent.display_name, + }); + } else { + return () => _t('%(targetName)s accepted an invitation', { targetName }); + } + } else { + return () => _t('%(senderName)s invited %(targetName)s', { senderName, targetName }); + } + } + case 'ban': + return () => reason + ? _t('%(senderName)s banned %(targetName)s: %(reason)s', { senderName, targetName, reason }) + : _t('%(senderName)s banned %(targetName)s', { senderName, targetName }); + case 'join': + if (prevContent && prevContent.membership === 'join') { + if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { + return () => _t('%(oldDisplayName)s changed their display name to %(displayName)s', { + oldDisplayName: prevContent.displayname, + displayName: content.displayname, + }); + } else if (!prevContent.displayname && content.displayname) { + return () => _t('%(senderName)s set their display name to %(displayName)s', { + senderName: ev.getSender(), + displayName: content.displayname, + }); + } else if (prevContent.displayname && !content.displayname) { + return () => _t('%(senderName)s removed their display name (%(oldDisplayName)s)', { + senderName, + oldDisplayName: prevContent.displayname, + }); + } else if (prevContent.avatar_url && !content.avatar_url) { + return () => _t('%(senderName)s removed their profile picture', { senderName }); + } else if (prevContent.avatar_url && content.avatar_url && + prevContent.avatar_url !== content.avatar_url) { + return () => _t('%(senderName)s changed their profile picture', { senderName }); + } else if (!prevContent.avatar_url && content.avatar_url) { + return () => _t('%(senderName)s set a profile picture', { senderName }); + } else if (SettingsStore.getValue("showHiddenEventsInTimeline")) { + // This is a null rejoin, it will only be visible if using 'show hidden events' (labs) + return () => _t("%(senderName)s made no change", { senderName }); + } else { + return null; + } + } else { + if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); + return () => _t('%(targetName)s joined the room', { targetName }); + } + case 'leave': + if (ev.getSender() === ev.getStateKey()) { + if (prevContent.membership === "invite") { + return () => _t('%(targetName)s rejected the invitation', { targetName }); + } else { + return () => reason + ? _t('%(targetName)s left the room: %(reason)s', { targetName, reason }) + : _t('%(targetName)s left the room', { targetName }); + } + } else if (prevContent.membership === "ban") { + return () => _t('%(senderName)s unbanned %(targetName)s', { senderName, targetName }); + } else if (prevContent.membership === "invite") { + return () => reason + ? _t('%(senderName)s withdrew %(targetName)s\'s invitation: %(reason)s', { + senderName, + targetName, + reason, + }) + : _t('%(senderName)s withdrew %(targetName)s\'s invitation', { senderName, targetName }); + } else if (prevContent.membership === "join") { + return () => reason + ? _t('%(senderName)s kicked %(targetName)s: %(reason)s', { + senderName, + targetName, + reason, + }) + : _t('%(senderName)s kicked %(targetName)s', { senderName, targetName }); + } else { + return null; + } + } +} + +function textForTopicEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', { + senderDisplayName, + topic: ev.getContent().topic, + }); +} + +function textForRoomNameEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + + if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { + return () => _t('%(senderDisplayName)s removed the room name.', { senderDisplayName }); + } + if (ev.getPrevContent().name) { + return () => _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', { + senderDisplayName, + oldRoomName: ev.getPrevContent().name, + newRoomName: ev.getContent().name, + }); + } + return () => _t('%(senderDisplayName)s changed the room name to %(roomName)s.', { + senderDisplayName, + roomName: ev.getContent().name, + }); +} + +function textForTombstoneEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName }); +} + +function textForJoinRulesEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + switch (ev.getContent().join_rule) { + case "public": + return () => _t('%(senderDisplayName)s made the room public to whoever knows the link.', { + senderDisplayName, + }); + case "invite": + return () => _t('%(senderDisplayName)s made the room invite only.', { + senderDisplayName, + }); + default: + // The spec supports "knock" and "private", however nothing implements these. + return () => _t('%(senderDisplayName)s changed the join rule to %(rule)s', { + senderDisplayName, + rule: ev.getContent().join_rule, + }); + } +} + +function textForGuestAccessEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + switch (ev.getContent().guest_access) { + case "can_join": + return () => _t('%(senderDisplayName)s has allowed guests to join the room.', { senderDisplayName }); + case "forbidden": + return () => _t('%(senderDisplayName)s has prevented guests from joining the room.', { senderDisplayName }); + default: + // There's no other options we can expect, however just for safety's sake we'll do this. + return () => _t('%(senderDisplayName)s changed guest access to %(rule)s', { + senderDisplayName, + rule: ev.getContent().guest_access, + }); + } +} + +function textForRelatedGroupsEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const groups = ev.getContent().groups || []; + const prevGroups = ev.getPrevContent().groups || []; + const added = groups.filter((g) => !prevGroups.includes(g)); + const removed = prevGroups.filter((g) => !groups.includes(g)); + + if (added.length && !removed.length) { + return () => _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', { + senderDisplayName, + groups: added.join(', '), + }); + } else if (!added.length && removed.length) { + return () => _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', { + senderDisplayName, + groups: removed.join(', '), + }); + } else if (added.length && removed.length) { + return () => _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' + + '%(oldGroups)s in this room.', { + senderDisplayName, + newGroups: added.join(', '), + oldGroups: removed.join(', '), + }); + } else { + // Don't bother rendering this change (because there were no changes) + return null; + } +} + +function textForServerACLEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const prevContent = ev.getPrevContent(); + const current = ev.getContent(); + const prev = { + deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], + allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], + allow_ip_literals: !(prevContent.allow_ip_literals === false), + }; + + let getText = null; + if (prev.deny.length === 0 && prev.allow.length === 0) { + getText = () => _t("%(senderDisplayName)s set the server ACLs for this room.", { senderDisplayName }); + } else { + getText = () => _t("%(senderDisplayName)s changed the server ACLs for this room.", { senderDisplayName }); + } + + if (!Array.isArray(current.allow)) { + current.allow = []; + } + + // If we know for sure everyone is banned, mark the room as obliterated + if (current.allow.length === 0) { + return () => getText() + " " + + _t("🎉 All servers are banned from participating! This room can no longer be used."); + } + + return getText; +} + +function textForMessageEvent(ev: MatrixEvent): () => string | null { + return () => { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + let message = senderDisplayName + ': ' + ev.getContent().body; + if (ev.getContent().msgtype === "m.emote") { + message = "* " + senderDisplayName + " " + message; + } else if (ev.getContent().msgtype === "m.image") { + message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName }); + } + return message; + }; +} + +function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null { + const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const oldAlias = ev.getPrevContent().alias; + const oldAltAliases = ev.getPrevContent().alt_aliases || []; + const newAlias = ev.getContent().alias; + const newAltAliases = ev.getContent().alt_aliases || []; + const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias)); + const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias)); + + if (!removedAltAliases.length && !addedAltAliases.length) { + if (newAlias) { + return () => _t('%(senderName)s set the main address for this room to %(address)s.', { + senderName: senderName, + address: ev.getContent().alias, + }); + } else if (oldAlias) { + return () => _t('%(senderName)s removed the main address for this room.', { + senderName: senderName, + }); + } + } else if (newAlias === oldAlias) { + if (addedAltAliases.length && !removedAltAliases.length) { + return () => _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', { + senderName: senderName, + addresses: addedAltAliases.join(", "), + count: addedAltAliases.length, + }); + } if (removedAltAliases.length && !addedAltAliases.length) { + return () => _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', { + senderName: senderName, + addresses: removedAltAliases.join(", "), + count: removedAltAliases.length, + }); + } if (removedAltAliases.length && addedAltAliases.length) { + return () => _t('%(senderName)s changed the alternative addresses for this room.', { + senderName: senderName, + }); + } + } else { + // both alias and alt_aliases where modified + return () => _t('%(senderName)s changed the main and alternative addresses for this room.', { + senderName: senderName, + }); + } + // in case there is no difference between the two events, + // say something as we can't simply hide the tile from here + return () => _t('%(senderName)s changed the addresses for this room.', { + senderName: senderName, + }); +} + +function textForCallAnswerEvent(event): () => string | null { + return () => { + const senderName = event.sender ? event.sender.name : _t('Someone'); + const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)'); + return _t('%(senderName)s answered the call.', { senderName }) + ' ' + supported; + }; +} + +function textForCallHangupEvent(event): () => string | null { + const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); + const eventContent = event.getContent(); + let getReason = () => ""; + if (!MatrixClientPeg.get().supportsVoip()) { + getReason = () => _t('(not supported by this browser)'); + } else if (eventContent.reason) { + if (eventContent.reason === "ice_failed") { + // We couldn't establish a connection at all + getReason = () => _t('(could not connect media)'); + } else if (eventContent.reason === "ice_timeout") { + // We established a connection but it died + getReason = () => _t('(connection failed)'); + } else if (eventContent.reason === "user_media_failed") { + // The other side couldn't open capture devices + getReason = () => _t("(their device couldn't start the camera / microphone)"); + } else if (eventContent.reason === "unknown_error") { + // An error code the other side doesn't have a way to express + // (as opposed to an error code they gave but we don't know about, + // in which case we show the error code) + getReason = () => _t("(an error occurred)"); + } else if (eventContent.reason === "invite_timeout") { + getReason = () => _t('(no answer)'); + } else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") { + // workaround for https://github.com/vector-im/element-web/issues/5178 + // it seems Android randomly sets a reason of "user hangup" which is + // interpreted as an error code :( + // https://github.com/vector-im/riot-android/issues/2623 + // Also the correct hangup code as of VoIP v1 (with underscore) + getReason = () => ''; + } else { + getReason = () => _t('(unknown failure: %(reason)s)', { reason: eventContent.reason }); + } + } + return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason(); +} + +function textForCallRejectEvent(event): () => string | null { + return () => { + const senderName = event.sender ? event.sender.name : _t('Someone'); + return _t('%(senderName)s declined the call.', { senderName }); + }; +} + +function textForCallInviteEvent(event): () => string | null { + const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); + // FIXME: Find a better way to determine this from the event? + let isVoice = true; + if (event.getContent().offer && event.getContent().offer.sdp && + event.getContent().offer.sdp.indexOf('m=video') !== -1) { + isVoice = false; + } + const isSupported = MatrixClientPeg.get().supportsVoip(); + + // This ladder could be reduced down to a couple string variables, however other languages + // can have a hard time translating those strings. In an effort to make translations easier + // and more accurate, we break out the string-based variables to a couple booleans. + if (isVoice && isSupported) { + return () => _t("%(senderName)s placed a voice call.", { + senderName: getSenderName(), + }); + } else if (isVoice && !isSupported) { + return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", { + senderName: getSenderName(), + }); + } else if (!isVoice && isSupported) { + return () => _t("%(senderName)s placed a video call.", { + senderName: getSenderName(), + }); + } else if (!isVoice && !isSupported) { + return () => _t("%(senderName)s placed a video call. (not supported by this browser)", { + senderName: getSenderName(), + }); + } +} + +function textForThreePidInviteEvent(event): () => string | null { + const senderName = event.sender ? event.sender.name : event.getSender(); + + if (!isValid3pidInvite(event)) { + return () => _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', { + senderName, + targetDisplayName: event.getPrevContent().display_name || _t("Someone"), + }); + } + + return () => _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', { + senderName, + targetDisplayName: event.getContent().display_name, + }); +} + +function textForHistoryVisibilityEvent(event): () => string | null { + const senderName = event.sender ? event.sender.name : event.getSender(); + switch (event.getContent().history_visibility) { + case 'invited': + return () => _t('%(senderName)s made future room history visible to all room members, ' + + 'from the point they are invited.', { senderName }); + case 'joined': + return () => _t('%(senderName)s made future room history visible to all room members, ' + + 'from the point they joined.', { senderName }); + case 'shared': + return () => _t('%(senderName)s made future room history visible to all room members.', { senderName }); + case 'world_readable': + return () => _t('%(senderName)s made future room history visible to anyone.', { senderName }); + default: + return () => _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', { + senderName, + visibility: event.getContent().history_visibility, + }); + } +} + +// Currently will only display a change if a user's power level is changed +function textForPowerEvent(event): () => string | null { + const senderName = event.sender ? event.sender.name : event.getSender(); + if (!event.getPrevContent() || !event.getPrevContent().users || + !event.getContent() || !event.getContent().users) { + return null; + } + const previousUserDefault = event.getPrevContent().users_default || 0; + const currentUserDefault = event.getContent().users_default || 0; + // Construct set of userIds + const users = []; + Object.keys(event.getContent().users).forEach( + (userId) => { + if (users.indexOf(userId) === -1) users.push(userId); + }, + ); + Object.keys(event.getPrevContent().users).forEach( + (userId) => { + if (users.indexOf(userId) === -1) users.push(userId); + }, + ); + const diffs = []; + users.forEach((userId) => { + // Previous power level + let from = event.getPrevContent().users[userId]; + if (!Number.isInteger(from)) { + from = previousUserDefault; + } + // Current power level + let to = event.getContent().users[userId]; + if (!Number.isInteger(to)) { + to = currentUserDefault; + } + if (from === previousUserDefault && to === currentUserDefault) { return; } + if (to !== from) { + diffs.push({ userId, from, to }); + } + }); + if (!diffs.length) { + return null; + } + // XXX: This is also surely broken for i18n + return () => _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', { + senderName, + powerLevelDiffText: diffs.map(diff => + _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { + userId: diff.userId, + fromPowerLevel: Roles.textualPowerLevel(diff.from, previousUserDefault), + toPowerLevel: Roles.textualPowerLevel(diff.to, currentUserDefault), + }), + ).join(", "), + }); +} + +const onPinnedMessagesClick = (): void => { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.PinnedMessages, + allowClose: false, + }); +}; + +function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null { + if (!SettingsStore.getValue("feature_pinning")) return null; + const senderName = event.sender ? event.sender.name : event.getSender(); + + if (allowJSX) { + return () => ( + + { + _t( + "%(senderName)s changed the pinned messages for the room.", + { senderName }, + { "a": (sub) => { sub } }, + ) + } + + ); + } + + return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName }); +} + +function textForWidgetEvent(event): () => string | null { + const senderName = event.getSender(); + const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent(); + const { name, type, url } = event.getContent() || {}; + + let widgetName = name || prevName || type || prevType || ''; + // Apply sentence case to widget name + if (widgetName && widgetName.length > 0) { + widgetName = widgetName[0].toUpperCase() + widgetName.slice(1); + } + + // If the widget was removed, its content should be {}, but this is sufficiently + // equivalent to that condition. + if (url) { + if (prevUrl) { + return () => _t('%(widgetName)s widget modified by %(senderName)s', { + widgetName, senderName, + }); + } else { + return () => _t('%(widgetName)s widget added by %(senderName)s', { + widgetName, senderName, + }); + } + } else { + return () => _t('%(widgetName)s widget removed by %(senderName)s', { + widgetName, senderName, + }); + } +} + +function textForWidgetLayoutEvent(event): () => string | null { + const senderName = event.sender?.name || event.getSender(); + return () => _t("%(senderName)s has updated the widget layout", { senderName }); +} + +function textForMjolnirEvent(event): () => string | null { + const senderName = event.getSender(); + const { entity: prevEntity } = event.getPrevContent(); + const { entity, recommendation, reason } = event.getContent(); + + // Rule removed + if (!entity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s removed the rule banning users matching %(glob)s", + { senderName, glob: prevEntity }); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s removed the rule banning rooms matching %(glob)s", + { senderName, glob: prevEntity }); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s removed the rule banning servers matching %(glob)s", + { senderName, glob: prevEntity }); + } + + // Unknown type. We'll say something, but we shouldn't end up here. + return () => _t("%(senderName)s removed a ban rule matching %(glob)s", { senderName, glob: prevEntity }); + } + + // Invalid rule + if (!recommendation || !reason) return () => _t(`%(senderName)s updated an invalid ban rule`, { senderName }); + + // Rule updated + if (entity === prevEntity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return () => _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // New rule + if (!prevEntity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return () => _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // else the entity !== prevEntity - count as a removal & add + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t( + "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + { senderName, oldGlob: prevEntity, newGlob: entity, reason }, + ); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t( + "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + { senderName, oldGlob: prevEntity, newGlob: entity, reason }, + ); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t( + "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + { senderName, oldGlob: prevEntity, newGlob: entity, reason }, + ); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return () => _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " + + "for %(reason)s", { senderName, oldGlob: prevEntity, newGlob: entity, reason }); +} + +interface IHandlers { + [type: string]: (ev: MatrixEvent, allowJSX?: boolean) => (() => string | JSX.Element | null); +} + +const handlers: IHandlers = { + 'm.room.message': textForMessageEvent, + 'm.call.invite': textForCallInviteEvent, + 'm.call.answer': textForCallAnswerEvent, + 'm.call.hangup': textForCallHangupEvent, + 'm.call.reject': textForCallRejectEvent, +}; + +const stateHandlers: IHandlers = { + 'm.room.canonical_alias': textForCanonicalAliasEvent, + 'm.room.name': textForRoomNameEvent, + 'm.room.topic': textForTopicEvent, + 'm.room.member': textForMemberEvent, + 'm.room.third_party_invite': textForThreePidInviteEvent, + 'm.room.history_visibility': textForHistoryVisibilityEvent, + 'm.room.power_levels': textForPowerEvent, + 'm.room.pinned_events': textForPinnedEvent, + 'm.room.server_acl': textForServerACLEvent, + 'm.room.tombstone': textForTombstoneEvent, + 'm.room.join_rules': textForJoinRulesEvent, + 'm.room.guest_access': textForGuestAccessEvent, + 'm.room.related_groups': textForRelatedGroupsEvent, + + // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) + 'im.vector.modular.widgets': textForWidgetEvent, + [WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent, +}; + +// Add all the Mjolnir stuff to the renderer +for (const evType of ALL_RULE_TYPES) { + stateHandlers[evType] = textForMjolnirEvent; +} + +export function hasText(ev: MatrixEvent): boolean { + const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; + return Boolean(handler?.(ev)); +} + +export function textForEvent(ev: MatrixEvent): string; +export function textForEvent(ev: MatrixEvent, allowJSX: true): string | JSX.Element; +export function textForEvent(ev: MatrixEvent, allowJSX = false): string | JSX.Element { + const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; + return handler?.(ev, allowJSX)?.() || ''; +} diff --git a/src/Tinter.js b/src/Tinter.js deleted file mode 100644 index ca5a460e16..0000000000 --- a/src/Tinter.js +++ /dev/null @@ -1,458 +0,0 @@ -/* -Copyright 2015 OpenMarket Ltd -Copyright 2017 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -const DEBUG = 0; - -// utility to turn #rrggbb or rgb(r,g,b) into [red,green,blue] -function colorToRgb(color) { - if (!color) { - return [0, 0, 0]; - } - - if (color[0] === '#') { - color = color.slice(1); - if (color.length === 3) { - color = color[0] + color[0] + - color[1] + color[1] + - color[2] + color[2]; - } - const val = parseInt(color, 16); - const r = (val >> 16) & 255; - const g = (val >> 8) & 255; - const b = val & 255; - return [r, g, b]; - } else { - const match = color.match(/rgb\((.*?),(.*?),(.*?)\)/); - if (match) { - return [ - parseInt(match[1]), - parseInt(match[2]), - parseInt(match[3]), - ]; - } - } - return [0, 0, 0]; -} - -// utility to turn [red,green,blue] into #rrggbb -function rgbToColor(rgb) { - const val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; - return '#' + (0x1000000 + val).toString(16).slice(1); -} - -class Tinter { - constructor() { - // The default colour keys to be replaced as referred to in CSS - // (should be overridden by .mx_theme_accentColor and .mx_theme_secondaryAccentColor) - this.keyRgb = [ - "rgb(118, 207, 166)", // Vector Green - "rgb(234, 245, 240)", // Vector Light Green - "rgb(211, 239, 225)", // roomsublist-label-bg-color (20% Green overlaid on Light Green) - ]; - - // Some algebra workings for calculating the tint % of Vector Green & Light Green - // x * 118 + (1 - x) * 255 = 234 - // x * 118 + 255 - 255 * x = 234 - // x * 118 - x * 255 = 234 - 255 - // (255 - 118) x = 255 - 234 - // x = (255 - 234) / (255 - 118) = 0.16 - - // The colour keys to be replaced as referred to in SVGs - this.keyHex = [ - "#76CFA6", // Vector Green - "#EAF5F0", // Vector Light Green - "#D3EFE1", // roomsublist-label-bg-color (20% Green overlaid on Light Green) - "#FFFFFF", // white highlights of the SVGs (for switching to dark theme) - "#000000", // black lowlights of the SVGs (for switching to dark theme) - ]; - - // track the replacement colours actually being used - // defaults to our keys. - this.colors = [ - this.keyHex[0], - this.keyHex[1], - this.keyHex[2], - this.keyHex[3], - this.keyHex[4], - ]; - - // track the most current tint request inputs (which may differ from the - // end result stored in this.colors - this.currentTint = [ - undefined, - undefined, - undefined, - undefined, - undefined, - ]; - - this.cssFixups = [ - // { theme: { - // style: a style object that should be fixed up taken from a stylesheet - // attr: name of the attribute to be clobbered, e.g. 'color' - // index: ordinal of primary, secondary or tertiary - // }, - // } - ]; - - // CSS attributes to be fixed up - this.cssAttrs = [ - "color", - "backgroundColor", - "borderColor", - "borderTopColor", - "borderBottomColor", - "borderLeftColor", - ]; - - this.svgAttrs = [ - "fill", - "stroke", - ]; - - // List of functions to call when the tint changes. - this.tintables = []; - - // the currently loaded theme (if any) - this.theme = undefined; - - // whether to force a tint (e.g. after changing theme) - this.forceTint = false; - } - - /** - * Register a callback to fire when the tint changes. - * This is used to rewrite the tintable SVGs with the new tint. - * - * It's not possible to unregister a tintable callback. So this can only be - * used to register a static callback. If a set of tintables will change - * over time then the best bet is to register a single callback for the - * entire set. - * - * To ensure the tintable work happens at least once, it is also called as - * part of registration. - * - * @param {Function} tintable Function to call when the tint changes. - */ - registerTintable(tintable) { - this.tintables.push(tintable); - tintable(); - } - - getKeyRgb() { - return this.keyRgb; - } - - tint(primaryColor, secondaryColor, tertiaryColor) { - return; - // eslint-disable-next-line no-unreachable - this.currentTint[0] = primaryColor; - this.currentTint[1] = secondaryColor; - this.currentTint[2] = tertiaryColor; - - this.calcCssFixups(); - - if (DEBUG) { - console.log("Tinter.tint(" + primaryColor + ", " + - secondaryColor + ", " + - tertiaryColor + ")"); - } - - if (!primaryColor) { - primaryColor = this.keyRgb[0]; - secondaryColor = this.keyRgb[1]; - tertiaryColor = this.keyRgb[2]; - } - - if (!secondaryColor) { - const x = 0.16; // average weighting factor calculated from vector green & light green - const rgb = colorToRgb(primaryColor); - rgb[0] = x * rgb[0] + (1 - x) * 255; - rgb[1] = x * rgb[1] + (1 - x) * 255; - rgb[2] = x * rgb[2] + (1 - x) * 255; - secondaryColor = rgbToColor(rgb); - } - - if (!tertiaryColor) { - const x = 0.19; - const rgb1 = colorToRgb(primaryColor); - const rgb2 = colorToRgb(secondaryColor); - rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0]; - rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1]; - rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2]; - tertiaryColor = rgbToColor(rgb1); - } - - if (this.forceTint == false && - this.colors[0] === primaryColor && - this.colors[1] === secondaryColor && - this.colors[2] === tertiaryColor) { - return; - } - - this.forceTint = false; - - this.colors[0] = primaryColor; - this.colors[1] = secondaryColor; - this.colors[2] = tertiaryColor; - - if (DEBUG) { - console.log("Tinter.tint final: (" + primaryColor + ", " + - secondaryColor + ", " + - tertiaryColor + ")"); - } - - // go through manually fixing up the stylesheets. - this.applyCssFixups(); - - // tell all the SVGs to go fix themselves up - // we don't do this as a dispatch otherwise it will visually lag - this.tintables.forEach(function(tintable) { - tintable(); - }); - } - - tintSvgWhite(whiteColor) { - this.currentTint[3] = whiteColor; - - if (!whiteColor) { - whiteColor = this.colors[3]; - } - if (this.colors[3] === whiteColor) { - return; - } - this.colors[3] = whiteColor; - this.tintables.forEach(function(tintable) { - tintable(); - }); - } - - tintSvgBlack(blackColor) { - this.currentTint[4] = blackColor; - - if (!blackColor) { - blackColor = this.colors[4]; - } - if (this.colors[4] === blackColor) { - return; - } - this.colors[4] = blackColor; - this.tintables.forEach(function(tintable) { - tintable(); - }); - } - - - setTheme(theme) { - this.theme = theme; - - // update keyRgb from the current theme CSS itself, if it defines it - if (document.getElementById('mx_theme_accentColor')) { - this.keyRgb[0] = window.getComputedStyle( - document.getElementById('mx_theme_accentColor')).color; - } - if (document.getElementById('mx_theme_secondaryAccentColor')) { - this.keyRgb[1] = window.getComputedStyle( - document.getElementById('mx_theme_secondaryAccentColor')).color; - } - if (document.getElementById('mx_theme_tertiaryAccentColor')) { - this.keyRgb[2] = window.getComputedStyle( - document.getElementById('mx_theme_tertiaryAccentColor')).color; - } - - this.calcCssFixups(); - this.forceTint = true; - - this.tint(this.currentTint[0], this.currentTint[1], this.currentTint[2]); - - if (theme === 'dark') { - // abuse the tinter to change all the SVG's #fff to #2d2d2d - // XXX: obviously this shouldn't be hardcoded here. - this.tintSvgWhite('#2d2d2d'); - this.tintSvgBlack('#dddddd'); - } else { - this.tintSvgWhite('#ffffff'); - this.tintSvgBlack('#000000'); - } - } - - calcCssFixups() { - // cache our fixups - if (this.cssFixups[this.theme]) return; - - if (DEBUG) { - console.debug("calcCssFixups start for " + this.theme + " (checking " + - document.styleSheets.length + - " stylesheets)"); - } - - this.cssFixups[this.theme] = []; - - for (let i = 0; i < document.styleSheets.length; i++) { - const ss = document.styleSheets[i]; - try { - if (!ss) continue; // well done safari >:( - // Chromium apparently sometimes returns null here; unsure why. - // see $14534907369972FRXBx:matrix.org in HQ - // ...ah, it's because there's a third party extension like - // privacybadger inserting its own stylesheet in there with a - // resource:// URI or something which results in a XSS error. - // See also #vector:matrix.org/$145357669685386ebCfr:matrix.org - // ...except some browsers apparently return stylesheets without - // hrefs, which we have no choice but ignore right now - - // XXX seriously? we are hardcoding the name of vector's CSS file in - // here? - // - // Why do we need to limit it to vector's CSS file anyway - if there - // are other CSS files affecting the doc don't we want to apply the - // same transformations to them? - // - // Iterating through the CSS looking for matches to hack on feels - // pretty horrible anyway. And what if the application skin doesn't use - // Vector Green as its primary color? - // --richvdh - - // Yes, tinting assumes that you are using the Element skin for now. - // The right solution will be to move the CSS over to react-sdk. - // And yes, the default assets for the base skin might as well use - // Vector Green as any other colour. - // --matthew - - // stylesheets we don't have permission to access (eg. ones from extensions) have a null - // href and will throw exceptions if we try to access their rules. - if (!ss.href || !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue; - if (ss.disabled) continue; - if (!ss.cssRules) continue; - - if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href); - - for (let j = 0; j < ss.cssRules.length; j++) { - const rule = ss.cssRules[j]; - if (!rule.style) continue; - if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue; - for (let k = 0; k < this.cssAttrs.length; k++) { - const attr = this.cssAttrs[k]; - for (let l = 0; l < this.keyRgb.length; l++) { - if (rule.style[attr] === this.keyRgb[l]) { - this.cssFixups[this.theme].push({ - style: rule.style, - attr: attr, - index: l, - }); - } - } - } - } - } catch (e) { - // Catch any random exceptions that happen here: all sorts of things can go - // wrong with this (nulls, SecurityErrors) and mostly it's for other - // stylesheets that we don't want to proces anyway. We should not propagate an - // exception out since this will cause the app to fail to start. - console.log("Failed to calculate CSS fixups for a stylesheet: " + ss.href, e); - } - } - if (DEBUG) { - console.log("calcCssFixups end (" + - this.cssFixups[this.theme].length + - " fixups)"); - } - } - - applyCssFixups() { - if (DEBUG) { - console.log("applyCssFixups start (" + - this.cssFixups[this.theme].length + - " fixups)"); - } - for (let i = 0; i < this.cssFixups[this.theme].length; i++) { - const cssFixup = this.cssFixups[this.theme][i]; - try { - cssFixup.style[cssFixup.attr] = this.colors[cssFixup.index]; - } catch (e) { - // Firefox Quantum explodes if you manually edit the CSS in the - // inspector and then try to do a tint, as apparently all the - // fixups are then stale. - console.error("Failed to apply cssFixup in Tinter! ", e.name); - } - } - if (DEBUG) console.log("applyCssFixups end"); - } - - // XXX: we could just move this all into TintableSvg, but as it's so similar - // to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg) - // keeping it here for now. - calcSvgFixups(svgs) { - // go through manually fixing up SVG colours. - // we could do this by stylesheets, but keeping the stylesheets - // updated would be a PITA, so just brute-force search for the - // key colour; cache the element and apply. - - if (DEBUG) console.log("calcSvgFixups start for " + svgs); - const fixups = []; - for (let i = 0; i < svgs.length; i++) { - let svgDoc; - try { - svgDoc = svgs[i].contentDocument; - } catch (e) { - let msg = 'Failed to get svg.contentDocument of ' + svgs[i].toString(); - if (e.message) { - msg += e.message; - } - if (e.stack) { - msg += ' | stack: ' + e.stack; - } - console.error(msg); - } - if (!svgDoc) continue; - const tags = svgDoc.getElementsByTagName("*"); - for (let j = 0; j < tags.length; j++) { - const tag = tags[j]; - for (let k = 0; k < this.svgAttrs.length; k++) { - const attr = this.svgAttrs[k]; - for (let l = 0; l < this.keyHex.length; l++) { - if (tag.getAttribute(attr) && - tag.getAttribute(attr).toUpperCase() === this.keyHex[l]) { - fixups.push({ - node: tag, - attr: attr, - index: l, - }); - } - } - } - } - } - if (DEBUG) console.log("calcSvgFixups end"); - - return fixups; - } - - applySvgFixups(fixups) { - if (DEBUG) console.log("applySvgFixups start for " + fixups); - for (let i = 0; i < fixups.length; i++) { - const svgFixup = fixups[i]; - svgFixup.node.setAttribute(svgFixup.attr, this.colors[svgFixup.index]); - } - if (DEBUG) console.log("applySvgFixups end"); - } -} - -if (global.singletonTinter === undefined) { - global.singletonTinter = new Tinter(); -} -export default global.singletonTinter; diff --git a/src/Unread.js b/src/Unread.ts similarity index 71% rename from src/Unread.js rename to src/Unread.ts index ddf225ac64..da5b883f92 100644 --- a/src/Unread.js +++ b/src/Unread.ts @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015 - 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. @@ -14,9 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { MatrixClientPeg } from "./MatrixClientPeg"; import shouldHideEvent from './shouldHideEvent'; -import {haveTileForEvent} from "./components/views/rooms/EventTile"; +import { haveTileForEvent } from "./components/views/rooms/EventTile"; /** * Returns true iff this event arriving in a room should affect the room's @@ -25,27 +29,28 @@ import {haveTileForEvent} from "./components/views/rooms/EventTile"; * @param {Object} ev The event * @returns {boolean} True if the given event should affect the unread message count */ -export function eventTriggersUnreadCount(ev) { - if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) { - return false; - } else if (ev.getType() == 'm.room.member') { - return false; - } else if (ev.getType() == 'm.room.third_party_invite') { - return false; - } else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') { - return false; - } else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') { - return false; - } else if (ev.getType() == 'm.room.aliases' || ev.getType() == 'm.room.canonical_alias') { - return false; - } else if (ev.getType() == 'm.room.server_acl') { +export function eventTriggersUnreadCount(ev: MatrixEvent): boolean { + if (ev.getSender() === MatrixClientPeg.get().credentials.userId) { return false; } + + switch (ev.getType()) { + case EventType.RoomMember: + case EventType.RoomThirdPartyInvite: + case EventType.CallAnswer: + case EventType.CallHangup: + case EventType.RoomAliases: + case EventType.RoomCanonicalAlias: + case EventType.RoomServerAcl: + return false; + } + + if (ev.isRedacted()) return false; return haveTileForEvent(ev); } -export function doesRoomHaveUnreadMessages(room) { - const myUserId = MatrixClientPeg.get().credentials.userId; +export function doesRoomHaveUnreadMessages(room: Room): boolean { + const myUserId = MatrixClientPeg.get().getUserId(); // get the most recent read receipt sent by our account. // N.B. this is NOT a read marker (RM, aka "read up to marker"), @@ -58,9 +63,7 @@ export function doesRoomHaveUnreadMessages(room) { // https://github.com/vector-im/element-web/issues/2427 // ...and possibly some of the others at // https://github.com/vector-im/element-web/issues/3363 - if (room.timeline.length && - room.timeline[room.timeline.length - 1].sender && - room.timeline[room.timeline.length - 1].sender.userId === myUserId) { + if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) { return false; } diff --git a/src/UserActivity.ts b/src/UserActivity.ts index 606075ec7c..c35ced0cc4 100644 --- a/src/UserActivity.ts +++ b/src/UserActivity.ts @@ -191,10 +191,10 @@ export default class UserActivity { this.lastScreenY = event.screenY; } - dis.dispatch({action: 'user_activity'}); + dis.dispatch({ action: 'user_activity' }); if (!this.activeNowTimeout.isRunning()) { this.activeNowTimeout.start(); - dis.dispatch({action: 'user_activity_start'}); + dis.dispatch({ action: 'user_activity_start' }); UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout); } else { diff --git a/src/UserAddress.js b/src/UserAddress.ts similarity index 69% rename from src/UserAddress.js rename to src/UserAddress.ts index e7501a0d91..a2c546deb7 100644 --- a/src/UserAddress.js +++ b/src/UserAddress.ts @@ -1,5 +1,5 @@ /* -Copyright 2017 New Vector Ltd +Copyright 2017 - 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. @@ -14,15 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -const emailRegex = /^\S+@\S+\.\S+$/; +import PropTypes from "prop-types"; +const emailRegex = /^\S+@\S+\.\S+$/; const mxUserIdRegex = /^@\S+:\S+$/; const mxRoomIdRegex = /^!\S+:\S+$/; -import PropTypes from 'prop-types'; -export const addressTypes = [ - 'mx-user-id', 'mx-room-id', 'email', -]; +export const addressTypes = ['mx-user-id', 'mx-room-id', 'email']; + +export enum AddressType { + Email = "email", + MatrixUserId = "mx-user-id", + MatrixRoomId = "mx-room-id", +} // PropType definition for an object describing // an address that can be invited to a room (which @@ -40,18 +44,13 @@ export const UserAddressType = PropTypes.shape({ isKnown: PropTypes.bool, }); -export function getAddressType(inputText) { - const isEmailAddress = emailRegex.test(inputText); - const isUserId = mxUserIdRegex.test(inputText); - const isRoomId = mxRoomIdRegex.test(inputText); - - // sanity check the input for user IDs - if (isEmailAddress) { - return 'email'; - } else if (isUserId) { - return 'mx-user-id'; - } else if (isRoomId) { - return 'mx-room-id'; +export function getAddressType(inputText: string): AddressType | null { + if (emailRegex.test(inputText)) { + return AddressType.Email; + } else if (mxUserIdRegex.test(inputText)) { + return AddressType.MatrixUserId; + } else if (mxRoomIdRegex.test(inputText)) { + return AddressType.MatrixRoomId; } else { return null; } diff --git a/src/VelocityBounce.js b/src/VelocityBounce.js deleted file mode 100644 index ffbf7de829..0000000000 --- a/src/VelocityBounce.js +++ /dev/null @@ -1,17 +0,0 @@ -import Velocity from "velocity-animate"; - -// courtesy of https://github.com/julianshapiro/velocity/issues/283 -// We only use easeOutBounce (easeInBounce is just sort of nonsensical) -function bounce( p ) { - let pow2; - let bounce = 4; - - while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) { - // just sets pow2 - } - return 1 / Math.pow( 4, 3 - bounce ) - 7.5625 * Math.pow( ( pow2 * 3 - 2 ) / 22 - p, 2 ); -} - -Velocity.Easings.easeOutBounce = function(p) { - return 1 - bounce(1 - p); -}; diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts new file mode 100644 index 0000000000..dacb4262bd --- /dev/null +++ b/src/VoipUserMapper.ts @@ -0,0 +1,128 @@ +/* +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 { ensureVirtualRoomExists, findDMForUser } from './createRoom'; +import { MatrixClientPeg } from "./MatrixClientPeg"; +import DMRoomMap from "./utils/DMRoomMap"; +import CallHandler, { VIRTUAL_ROOM_EVENT_TYPE } from './CallHandler'; +import { Room } from 'matrix-js-sdk/src/models/room'; + +// Functions for mapping virtual users & rooms. Currently the only lookup +// is sip virtual: there could be others in the future. + +export default class VoipUserMapper { + // We store mappings of virtual -> native room IDs here until the local echo for the + // account data arrives. + private virtualToNativeRoomIdCache = new Map(); + + public static sharedInstance(): VoipUserMapper { + if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper(); + return window.mxVoipUserMapper; + } + + private async userToVirtualUser(userId: string): Promise { + const results = await CallHandler.sharedInstance().sipVirtualLookup(userId); + if (results.length === 0 || !results[0].fields.lookup_success) return null; + return results[0].userid; + } + + public async getOrCreateVirtualRoomForRoom(roomId: string): Promise { + const userId = DMRoomMap.shared().getUserIdForRoomId(roomId); + if (!userId) return null; + + const virtualUser = await this.userToVirtualUser(userId); + if (!virtualUser) return null; + + const virtualRoomId = await ensureVirtualRoomExists(MatrixClientPeg.get(), virtualUser, roomId); + MatrixClientPeg.get().setRoomAccountData(virtualRoomId, VIRTUAL_ROOM_EVENT_TYPE, { + native_room: roomId, + }); + + this.virtualToNativeRoomIdCache.set(virtualRoomId, roomId); + + return virtualRoomId; + } + + public nativeRoomForVirtualRoom(roomId: string): string { + const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId); + if (cachedNativeRoomId) { + console.log( + "Returning native room ID " + cachedNativeRoomId + " for virtual room ID " + roomId + " from cache", + ); + return cachedNativeRoomId; + } + + const virtualRoom = MatrixClientPeg.get().getRoom(roomId); + if (!virtualRoom) return null; + const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE); + if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null; + const nativeRoomID = virtualRoomEvent.getContent()['native_room']; + const nativeRoom = MatrixClientPeg.get().getRoom(nativeRoomID); + if (!nativeRoom || nativeRoom.getMyMembership() !== 'join') return null; + + return nativeRoomID; + } + + public isVirtualRoom(room: Room): boolean { + if (this.nativeRoomForVirtualRoom(room.roomId)) return true; + + if (this.virtualToNativeRoomIdCache.has(room.roomId)) return true; + + // also look in the create event for the claimed native room ID, which is the only + // way we can recognise a virtual room we've created when it first arrives down + // our stream. We don't trust this in general though, as it could be faked by an + // inviter: our main source of truth is the DM state. + const roomCreateEvent = room.currentState.getStateEvents("m.room.create", ""); + if (!roomCreateEvent || !roomCreateEvent.getContent()) return false; + // we only look at this for rooms we created (so inviters can't just cause rooms + // to be invisible) + if (roomCreateEvent.getSender() !== MatrixClientPeg.get().getUserId()) return false; + const claimedNativeRoomId = roomCreateEvent.getContent()[VIRTUAL_ROOM_EVENT_TYPE]; + return Boolean(claimedNativeRoomId); + } + + public async onNewInvitedRoom(invitedRoom: Room): Promise { + if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return; + + const inviterId = invitedRoom.getDMInviter(); + console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`); + const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId); + if (result.length === 0) { + return; + } + + if (result[0].fields.is_virtual) { + const nativeUser = result[0].userid; + const nativeRoom = findDMForUser(MatrixClientPeg.get(), nativeUser); + if (nativeRoom) { + // It's a virtual room with a matching native room, so set the room account data. This + // will make sure we know where how to map calls and also allow us know not to display + // it in the future. + MatrixClientPeg.get().setRoomAccountData(invitedRoom.roomId, VIRTUAL_ROOM_EVENT_TYPE, { + native_room: nativeRoom.roomId, + }); + // also auto-join the virtual room if we have a matching native room + // (possibly we should only join if we've also joined the native room, then we'd also have + // to make sure we joined virtual rooms on joining a native one) + MatrixClientPeg.get().joinRoom(invitedRoom.roomId); + } + + // also put this room in the virtual room ID cache so isVirtualRoom return the right answer + // in however long it takes for the echo of setAccountData to come down the sync + this.virtualToNativeRoomIdCache.set(invitedRoom.roomId, nativeRoom.roomId); + } + } +} diff --git a/src/WhoIsTyping.ts b/src/WhoIsTyping.ts index a8ca425ea8..938218d270 100644 --- a/src/WhoIsTyping.ts +++ b/src/WhoIsTyping.ts @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Room} from "matrix-js-sdk/src/models/room"; -import {RoomMember} from "matrix-js-sdk/src/models/room-member"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { MatrixClientPeg } from "./MatrixClientPeg"; import { _t } from './languageHandler'; export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] { @@ -61,7 +61,7 @@ export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): str if (whoIsTyping.length === 0) { return ''; } else if (whoIsTyping.length === 1) { - return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name}); + return _t('%(displayName)s is typing …', { displayName: whoIsTyping[0].name }); } const names = whoIsTyping.map(m => m.name); @@ -73,6 +73,6 @@ export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): str }); } else { const lastPerson = names.pop(); - return _t('%(names)s and %(lastPerson)s are typing …', {names: names.join(', '), lastPerson: lastPerson}); + return _t('%(names)s and %(lastPerson)s are typing …', { names: names.join(', '), lastPerson: lastPerson }); } } diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index 58d8124122..c5cf85facd 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -17,10 +17,10 @@ limitations under the License. import * as React from "react"; import classNames from "classnames"; -import * as sdk from "../index"; import Modal from "../Modal"; import { _t, _td } from "../languageHandler"; -import {isMac, Key} from "../Keyboard"; +import { isMac, Key } from "../Keyboard"; +import InfoDialog from "../components/views/dialogs/InfoDialog"; // TS: once languageHandler is TS we can probably inline this into the enum _td("Navigation"); @@ -57,6 +57,8 @@ export enum Modifiers { // Meta-modifier: isMac ? CMD : CONTROL export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL; +// Meta-key representing the digits [0-9] often found at the top of standard keyboard layouts +export const DIGITS = "digits"; interface IKeybind { modifiers?: Modifiers[]; @@ -168,6 +170,12 @@ const shortcuts: Record = { key: Key.U, }], description: _td("Upload a file"), + }, { + keybinds: [{ + modifiers: [CMD_OR_CTRL], + key: Key.F, + }], + description: _td("Search (must be enabled)"), }, ], @@ -257,6 +265,12 @@ const shortcuts: Record = { key: Key.SLASH, }], description: _td("Toggle this dialog"), + }, { + keybinds: [{ + modifiers: [Modifiers.CONTROL, isMac ? Modifiers.SHIFT : Modifiers.ALT], + key: Key.H, + }], + description: _td("Go to Home View"), }, ], @@ -307,6 +321,7 @@ const alternateKeyName: Record = { [Key.SPACE]: _td("Space"), [Key.HOME]: _td("Home"), [Key.END]: _td("End"), + [DIGITS]: _td("[number]"), }; const keyIcon: Record = { [Key.ARROW_UP]: "↑", @@ -317,7 +332,7 @@ const keyIcon: Record = { const Shortcut: React.FC<{ shortcut: IShortcut; -}> = ({shortcut}) => { +}> = ({ shortcut }) => { const classes = classNames({ "mx_KeyboardShortcutsDialog_inline": shortcut.keybinds.every(k => !k.modifiers || k.modifiers.length === 0), }); @@ -360,7 +375,6 @@ export const toggleDialog = () => {
; }); - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, { className: "mx_KeyboardShortcutsDialog", title: _t("Keyboard Shortcuts"), diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index b49a90d175..87f525bdfc 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -26,8 +26,8 @@ import React, { Dispatch, } from "react"; -import {Key} from "../Keyboard"; -import {FocusHandler, Ref} from "./roving/types"; +import { Key } from "../Keyboard"; +import { FocusHandler, Ref } from "./roving/types"; /** * Module to simplify implementing the Roving TabIndex accessibility technique @@ -156,18 +156,18 @@ interface IProps { onKeyDown?(ev: React.KeyboardEvent, state: IState); } -export const RovingTabIndexProvider: React.FC = ({children, handleHomeEnd, onKeyDown}) => { +export const RovingTabIndexProvider: React.FC = ({ children, handleHomeEnd, onKeyDown }) => { const [state, dispatch] = useReducer>(reducer, { activeRef: null, refs: [], }); - const context = useMemo(() => ({state, dispatch}), [state]); + const context = useMemo(() => ({ state, dispatch }), [state]); const onKeyDownHandler = useCallback((ev) => { let handled = false; // Don't interfere with input default keydown behaviour - if (handleHomeEnd && ev.target.tagName !== "INPUT") { + if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { // check if we actually have any items switch (ev.key) { case Key.HOME: @@ -196,7 +196,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn }, [context.state, onKeyDown, handleHomeEnd]); return - { children({onKeyDownHandler}) } + { children({ onKeyDownHandler }) } ; }; @@ -218,13 +218,13 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] useLayoutEffect(() => { context.dispatch({ type: Type.Register, - payload: {ref}, + payload: { ref }, }); // teardown return () => { context.dispatch({ type: Type.Unregister, - payload: {ref}, + payload: { ref }, }); }; }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -232,7 +232,7 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] const onFocus = useCallback(() => { context.dispatch({ type: Type.SetFocus, - payload: {ref}, + payload: { ref }, }); }, [ref, context]); @@ -241,6 +241,6 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] }; // re-export the semantic helper components for simplicity -export {RovingTabIndexWrapper} from "./roving/RovingTabIndexWrapper"; -export {RovingAccessibleButton} from "./roving/RovingAccessibleButton"; -export {RovingAccessibleTooltipButton} from "./roving/RovingAccessibleTooltipButton"; +export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper"; +export { RovingAccessibleButton } from "./roving/RovingAccessibleButton"; +export { RovingAccessibleTooltipButton } from "./roving/RovingAccessibleTooltipButton"; diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx index e756d948e5..8d882fadea 100644 --- a/src/accessibility/Toolbar.tsx +++ b/src/accessibility/Toolbar.tsx @@ -16,8 +16,8 @@ limitations under the License. import React from "react"; -import {IState, RovingTabIndexProvider} from "./RovingTabIndex"; -import {Key} from "../Keyboard"; +import { IState, RovingTabIndexProvider } from "./RovingTabIndex"; +import { Key } from "../Keyboard"; interface IProps extends Omit, "onKeyDown"> { } @@ -25,7 +25,7 @@ interface IProps extends Omit, "onKeyDown"> { // This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines. // https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar // All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref` -const Toolbar: React.FC = ({children, ...props}) => { +const Toolbar: React.FC = ({ children, ...props }) => { const onKeyDown = (ev: React.KeyboardEvent, state: IState) => { const target = ev.target as HTMLElement; // Don't interfere with input default keydown behaviour @@ -62,7 +62,7 @@ const Toolbar: React.FC = ({children, ...props}) => { }; return - {({onKeyDownHandler}) =>
+ {({ onKeyDownHandler }) =>
{ children }
} ; diff --git a/src/accessibility/context_menu/MenuGroup.tsx b/src/accessibility/context_menu/MenuGroup.tsx index 9334e17a18..97f9694f83 100644 --- a/src/accessibility/context_menu/MenuGroup.tsx +++ b/src/accessibility/context_menu/MenuGroup.tsx @@ -23,7 +23,7 @@ interface IProps extends React.HTMLAttributes { } // Semantic component for representing a role=group for grouping menu radios/checkboxes -export const MenuGroup: React.FC = ({children, label, ...props}) => { +export const MenuGroup: React.FC = ({ children, label, ...props }) => { return
{ children }
; diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx index 0bb169abf8..9c0b248274 100644 --- a/src/accessibility/context_menu/MenuItem.tsx +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -19,14 +19,23 @@ limitations under the License. import React from "react"; import AccessibleButton from "../../components/views/elements/AccessibleButton"; +import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton"; interface IProps extends React.ComponentProps { label?: string; + tooltip?: string; } // Semantic component for representing a role=menuitem -export const MenuItem: React.FC = ({children, label, ...props}) => { +export const MenuItem: React.FC = ({ children, label, tooltip, ...props }) => { const ariaLabel = props["aria-label"] || label; + + if (tooltip) { + return + { children } + ; + } + return ( { children } diff --git a/src/accessibility/context_menu/MenuItemCheckbox.tsx b/src/accessibility/context_menu/MenuItemCheckbox.tsx index 5eb8cc4819..67da4cc85a 100644 --- a/src/accessibility/context_menu/MenuItemCheckbox.tsx +++ b/src/accessibility/context_menu/MenuItemCheckbox.tsx @@ -26,7 +26,7 @@ interface IProps extends React.ComponentProps { } // Semantic component for representing a role=menuitemcheckbox -export const MenuItemCheckbox: React.FC = ({children, label, active, disabled, ...props}) => { +export const MenuItemCheckbox: React.FC = ({ children, label, active, disabled, ...props }) => { return ( { } // Semantic component for representing a role=menuitemradio -export const MenuItemRadio: React.FC = ({children, label, active, disabled, ...props}) => { +export const MenuItemRadio: React.FC = ({ children, label, active, disabled, ...props }) => { return ( { @@ -28,7 +28,7 @@ interface IProps extends React.ComponentProps { } // Semantic component for representing a styled role=menuitemcheckbox -export const StyledMenuItemCheckbox: React.FC = ({children, label, onChange, onClose, ...props}) => { +export const StyledMenuItemCheckbox: React.FC = ({ children, label, onChange, onClose, ...props }) => { const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === Key.ENTER || e.key === Key.SPACE) { e.stopPropagation(); diff --git a/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/src/accessibility/context_menu/StyledMenuItemRadio.tsx index 5e5aa90a38..e3d340ef3e 100644 --- a/src/accessibility/context_menu/StyledMenuItemRadio.tsx +++ b/src/accessibility/context_menu/StyledMenuItemRadio.tsx @@ -18,7 +18,7 @@ limitations under the License. import React from "react"; -import {Key} from "../../Keyboard"; +import { Key } from "../../Keyboard"; import StyledRadioButton from "../../components/views/elements/StyledRadioButton"; interface IProps extends React.ComponentProps { @@ -28,7 +28,7 @@ interface IProps extends React.ComponentProps { } // Semantic component for representing a styled role=menuitemradio -export const StyledMenuItemRadio: React.FC = ({children, label, onChange, onClose, ...props}) => { +export const StyledMenuItemRadio: React.FC = ({ children, label, onChange, onClose, ...props }) => { const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === Key.ENTER || e.key === Key.SPACE) { e.stopPropagation(); diff --git a/src/accessibility/roving/RovingAccessibleButton.tsx b/src/accessibility/roving/RovingAccessibleButton.tsx index 3473ef1bc9..f9ce87db6a 100644 --- a/src/accessibility/roving/RovingAccessibleButton.tsx +++ b/src/accessibility/roving/RovingAccessibleButton.tsx @@ -17,15 +17,15 @@ limitations under the License. import React from "react"; import AccessibleButton from "../../components/views/elements/AccessibleButton"; -import {useRovingTabIndex} from "../RovingTabIndex"; -import {Ref} from "./types"; +import { useRovingTabIndex } from "../RovingTabIndex"; +import { Ref } from "./types"; interface IProps extends Omit, "onFocus" | "inputRef" | "tabIndex"> { inputRef?: Ref; } // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleButton: React.FC = ({inputRef, ...props}) => { +export const RovingAccessibleButton: React.FC = ({ inputRef, ...props }) => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); return ; }; diff --git a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx index 2cb974d60e..d9e393d728 100644 --- a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx +++ b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx @@ -17,8 +17,8 @@ limitations under the License. import React from "react"; import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton"; -import {useRovingTabIndex} from "../RovingTabIndex"; -import {Ref} from "./types"; +import { useRovingTabIndex } from "../RovingTabIndex"; +import { Ref } from "./types"; type ATBProps = React.ComponentProps; interface IProps extends Omit { @@ -26,7 +26,7 @@ interface IProps extends Omit { } // Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components. -export const RovingAccessibleTooltipButton: React.FC = ({inputRef, ...props}) => { +export const RovingAccessibleTooltipButton: React.FC = ({ inputRef, ...props }) => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); return ; }; diff --git a/src/accessibility/roving/RovingTabIndexWrapper.tsx b/src/accessibility/roving/RovingTabIndexWrapper.tsx index 5211f30215..974bb9a388 100644 --- a/src/accessibility/roving/RovingTabIndexWrapper.tsx +++ b/src/accessibility/roving/RovingTabIndexWrapper.tsx @@ -16,8 +16,8 @@ limitations under the License. import React from "react"; -import {useRovingTabIndex} from "../RovingTabIndex"; -import {FocusHandler, Ref} from "./types"; +import { useRovingTabIndex } from "../RovingTabIndex"; +import { FocusHandler, Ref } from "./types"; interface IProps { inputRef?: Ref; @@ -29,7 +29,7 @@ interface IProps { } // Wrapper to allow use of useRovingTabIndex outside of React Functional Components. -export const RovingTabIndexWrapper: React.FC = ({children, inputRef}) => { +export const RovingTabIndexWrapper: React.FC = ({ children, inputRef }) => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return children({onFocus, isActive, ref}); + return children({ onFocus, isActive, ref }); }; diff --git a/src/accessibility/roving/types.ts b/src/accessibility/roving/types.ts index f0a43e5fb8..cc6a98e1d7 100644 --- a/src/accessibility/roving/types.ts +++ b/src/accessibility/roving/types.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {RefObject} from "react"; +import { RefObject } from "react"; export type Ref = RefObject; diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.ts similarity index 68% rename from src/actions/MatrixActionCreators.js rename to src/actions/MatrixActionCreators.ts index 93a4fcf07c..70b86f8ee5 100644 --- a/src/actions/MatrixActionCreators.js +++ b/src/actions/MatrixActionCreators.ts @@ -1,5 +1,5 @@ /* -Copyright 2017 New Vector Ltd +Copyright 2017, 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. @@ -14,7 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import dis from '../dispatcher/dispatcher'; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; + +import dis from "../dispatcher/dispatcher"; +import { ActionPayload } from "../dispatcher/payloads"; // TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events // become dispatches in the same place. @@ -27,7 +33,7 @@ import dis from '../dispatcher/dispatcher'; * @param {string} prevState the previous sync state. * @returns {Object} an action of type MatrixActions.sync. */ -function createSyncAction(matrixClient, state, prevState) { +function createSyncAction(matrixClient: MatrixClient, state: string, prevState: string): ActionPayload { return { action: 'MatrixActions.sync', state, @@ -53,7 +59,7 @@ function createSyncAction(matrixClient, state, prevState) { * @param {MatrixEvent} accountDataEvent the account data event. * @returns {AccountDataAction} an action of type MatrixActions.accountData. */ -function createAccountDataAction(matrixClient, accountDataEvent) { +function createAccountDataAction(matrixClient: MatrixClient, accountDataEvent: MatrixEvent): ActionPayload { return { action: 'MatrixActions.accountData', event: accountDataEvent, @@ -81,7 +87,11 @@ function createAccountDataAction(matrixClient, accountDataEvent) { * @param {Room} room the room where account data was changed * @returns {RoomAccountDataAction} an action of type MatrixActions.Room.accountData. */ -function createRoomAccountDataAction(matrixClient, accountDataEvent, room) { +function createRoomAccountDataAction( + matrixClient: MatrixClient, + accountDataEvent: MatrixEvent, + room: Room, +): ActionPayload { return { action: 'MatrixActions.Room.accountData', event: accountDataEvent, @@ -106,7 +116,7 @@ function createRoomAccountDataAction(matrixClient, accountDataEvent, room) { * @param {Room} room the Room that was stored. * @returns {RoomAction} an action of type `MatrixActions.Room`. */ -function createRoomAction(matrixClient, room) { +function createRoomAction(matrixClient: MatrixClient, room: Room): ActionPayload { return { action: 'MatrixActions.Room', room }; } @@ -127,7 +137,7 @@ function createRoomAction(matrixClient, room) { * @param {Room} room the Room whose tags were changed. * @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`. */ -function createRoomTagsAction(matrixClient, roomTagsEvent, room) { +function createRoomTagsAction(matrixClient: MatrixClient, roomTagsEvent: MatrixEvent, room: Room): ActionPayload { return { action: 'MatrixActions.Room.tags', room }; } @@ -140,7 +150,7 @@ function createRoomTagsAction(matrixClient, roomTagsEvent, room) { * @param {Room} room the room the receipt happened in. * @returns {Object} an action of type MatrixActions.Room.receipt. */ -function createRoomReceiptAction(matrixClient, event, room) { +function createRoomReceiptAction(matrixClient: MatrixClient, event: MatrixEvent, room: Room): ActionPayload { return { action: 'MatrixActions.Room.receipt', event, @@ -178,7 +188,17 @@ function createRoomReceiptAction(matrixClient, event, room) { * @param {EventTimeline} data.timeline the timeline being altered. * @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`. */ -function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTimeline, removed, data) { +function createRoomTimelineAction( + matrixClient: MatrixClient, + timelineEvent: MatrixEvent, + room: Room, + toStartOfTimeline: boolean, + removed: boolean, + data: { + liveEvent: boolean; + timeline: EventTimeline; + }, +): ActionPayload { return { action: 'MatrixActions.Room.timeline', event: timelineEvent, @@ -208,8 +228,13 @@ function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTi * @param {string} oldMembership the previous membership, can be null. * @returns {RoomMembershipAction} an action of type `MatrixActions.Room.myMembership`. */ -function createSelfMembershipAction(matrixClient, room, membership, oldMembership) { - return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership}; +function createSelfMembershipAction( + matrixClient: MatrixClient, + room: Room, + membership: string, + oldMembership: string, +): ActionPayload { + return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership }; } /** @@ -228,61 +253,65 @@ function createSelfMembershipAction(matrixClient, room, membership, oldMembershi * @param {MatrixEvent} event the matrix event that was decrypted. * @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`. */ -function createEventDecryptedAction(matrixClient, event) { +function createEventDecryptedAction(matrixClient: MatrixClient, event: MatrixEvent): ActionPayload { return { action: 'MatrixActions.Event.decrypted', event }; } +type Listener = () => void; +type ActionCreator = (matrixClient: MatrixClient, ...args: any) => ActionPayload; + +// A list of callbacks to call to unregister all listeners added +let matrixClientListenersStop: Listener[] = []; + +/** + * Start listening to events of type eventName on matrixClient and when they are emitted, + * dispatch an action created by the actionCreator function. + * @param {MatrixClient} matrixClient a MatrixClient to register a listener with. + * @param {string} eventName the event to listen to on MatrixClient. + * @param {function} actionCreator a function that should return an action to dispatch + * when given the MatrixClient as an argument as well as + * arguments emitted in the MatrixClient event. + */ +function addMatrixClientListener(matrixClient: MatrixClient, eventName: string, actionCreator: ActionCreator): void { + const listener: Listener = (...args) => { + const payload = actionCreator(matrixClient, ...args); + if (payload) { + dis.dispatch(payload, true); + } + }; + matrixClient.on(eventName, listener); + matrixClientListenersStop.push(() => { + matrixClient.removeListener(eventName, listener); + }); +} + /** * This object is responsible for dispatching actions when certain events are emitted by * the given MatrixClient. */ export default { - // A list of callbacks to call to unregister all listeners added - _matrixClientListenersStop: [], - /** * Start listening to certain events from the MatrixClient and dispatch actions when * they are emitted. * @param {MatrixClient} matrixClient the MatrixClient to listen to events from */ - start(matrixClient) { - this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); - this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); - this._addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction); - this._addMatrixClientListener(matrixClient, 'Room', createRoomAction); - this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); - this._addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction); - this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction); - this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction); - this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction); - }, - - /** - * Start listening to events of type eventName on matrixClient and when they are emitted, - * dispatch an action created by the actionCreator function. - * @param {MatrixClient} matrixClient a MatrixClient to register a listener with. - * @param {string} eventName the event to listen to on MatrixClient. - * @param {function} actionCreator a function that should return an action to dispatch - * when given the MatrixClient as an argument as well as - * arguments emitted in the MatrixClient event. - */ - _addMatrixClientListener(matrixClient, eventName, actionCreator) { - const listener = (...args) => { - const payload = actionCreator(matrixClient, ...args); - if (payload) { - dis.dispatch(payload, true); - } - }; - matrixClient.on(eventName, listener); - this._matrixClientListenersStop.push(() => { - matrixClient.removeListener(eventName, listener); - }); + start(matrixClient: MatrixClient) { + addMatrixClientListener(matrixClient, 'sync', createSyncAction); + addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); + addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction); + addMatrixClientListener(matrixClient, 'Room', createRoomAction); + addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); + addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction); + addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction); + addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction); + addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction); }, /** * Stop listening to events. */ stop() { - this._matrixClientListenersStop.forEach((stopListener) => stopListener()); + matrixClientListenersStop.forEach((stopListener) => stopListener()); + matrixClientListenersStop = []; }, }; diff --git a/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index 88946ee26f..a7f629c40d 100644 --- a/src/actions/RoomListActions.ts +++ b/src/actions/RoomListActions.ts @@ -19,13 +19,13 @@ import { asyncAction } from './actionCreators'; import Modal from '../Modal'; import * as Rooms from '../Rooms'; import { _t } from '../languageHandler'; -import * as sdk from '../index'; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { AsyncActionPayload } from "../dispatcher/payloads"; import RoomListStore from "../stores/room-list/RoomListStore"; import { SortAlgorithm } from "../stores/room-list/algorithms/models"; import { DefaultTagID } from "../stores/room-list/models"; +import ErrorDialog from '../components/views/dialogs/ErrorDialog'; export default class RoomListActions { /** @@ -88,7 +88,6 @@ export default class RoomListActions { return Rooms.guessAndSetDMRoom( room, newTag === DefaultTagID.DM, ).catch((err) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to set direct chat tag " + err); Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, { title: _t('Failed to set direct chat tag'), @@ -109,10 +108,9 @@ export default class RoomListActions { const promiseToDelete = matrixClient.deleteRoomTag( roomId, oldTag, ).catch(function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to remove tag " + oldTag + " from room: " + err); Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, { - title: _t('Failed to remove tag %(tagName)s from room', {tagName: oldTag}), + title: _t('Failed to remove tag %(tagName)s from room', { tagName: oldTag }), description: ((err && err.message) ? err.message : _t('Operation failed')), }); }); @@ -129,10 +127,9 @@ export default class RoomListActions { metaData = metaData || {}; const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to add tag " + newTag + " to room: " + err); Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, { - title: _t('Failed to add tag %(tagName)s to room', {tagName: newTag}), + title: _t('Failed to add tag %(tagName)s to room', { tagName: newTag }), description: ((err && err.message) ? err.message : _t('Operation failed')), }); diff --git a/src/actions/TagOrderActions.ts b/src/actions/TagOrderActions.ts index 021cd11b55..dc538134a1 100644 --- a/src/actions/TagOrderActions.ts +++ b/src/actions/TagOrderActions.ts @@ -53,11 +53,11 @@ export default class TagOrderActions { Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); return matrixClient.setAccountData( 'im.vector.web.tag_ordering', - {tags, removedTags, _storeId: storeId}, + { tags, removedTags, _storeId: storeId }, ); }, () => { // For an optimistic update - return {tags, removedTags}; + return { tags, removedTags }; }); } @@ -100,11 +100,11 @@ export default class TagOrderActions { Analytics.trackEvent('TagOrderActions', 'removeTag'); return matrixClient.setAccountData( 'im.vector.web.tag_ordering', - {tags, removedTags, _storeId: storeId}, + { tags, removedTags, _storeId: storeId }, ); }, () => { // For an optimistic update - return {removedTags}; + return { removedTags }; }); } } diff --git a/src/actions/actionCreators.ts b/src/actions/actionCreators.ts index c789e3cd07..81e0b95098 100644 --- a/src/actions/actionCreators.ts +++ b/src/actions/actionCreators.ts @@ -51,9 +51,9 @@ export function asyncAction(id: string, fn: () => Promise, pendingFn: () => request: typeof pendingFn === 'function' ? pendingFn() : undefined, }); fn().then((result) => { - dispatch({action: id + '.success', result}); + dispatch({ action: id + '.success', result }); }).catch((err) => { - dispatch({action: id + '.failure', err}); + dispatch({ action: id + '.failure', err }); }); }; return new AsyncActionPayload(helper); diff --git a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js index de50feaedb..a19494c753 100644 --- a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js @@ -22,8 +22,8 @@ import { _t } from '../../../../languageHandler'; import SettingsStore from "../../../../settings/SettingsStore"; import EventIndexPeg from "../../../../indexing/EventIndexPeg"; -import {Action} from "../../../../dispatcher/actions"; -import {SettingLevel} from "../../../../settings/SettingLevel"; +import { Action } from "../../../../dispatcher/actions"; +import { SettingLevel } from "../../../../settings/SettingLevel"; /* * Allows the user to disable the Event Index. diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx similarity index 83% rename from src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js rename to src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx index be3368b87b..c5c8022346 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-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. @@ -15,25 +15,35 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../../index'; -import PropTypes from 'prop-types'; import { _t } from '../../../../languageHandler'; import SdkConfig from '../../../../SdkConfig'; import SettingsStore from "../../../../settings/SettingsStore"; import Modal from '../../../../Modal'; -import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils"; +import { formatBytes, formatCountLong } from "../../../../utils/FormattingUtils"; import EventIndexPeg from "../../../../indexing/EventIndexPeg"; -import {SettingLevel} from "../../../../settings/SettingLevel"; +import { SettingLevel } from "../../../../settings/SettingLevel"; +import Field from '../../../../components/views/elements/Field'; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; + +interface IProps { + onFinished: (confirmed: boolean) => void; +} + +interface IState { + eventIndexSize: number; + eventCount: number; + crawlingRoomsCount: number; + roomCount: number; + currentRoom: string; + crawlerSleepTime: number; +} /* * Allows the user to introspect the event index state and disable it. */ -export default class ManageEventIndexDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; - +export default class ManageEventIndexDialog extends React.Component { constructor(props) { super(props); @@ -84,7 +94,7 @@ export default class ManageEventIndexDialog extends React.Component { } } - async componentDidMount(): void { + async componentDidMount(): Promise { let eventIndexSize = 0; let crawlingRoomsCount = 0; let roomCount = 0; @@ -123,28 +133,27 @@ export default class ManageEventIndexDialog extends React.Component { }); } - _onDisable = async () => { + private onDisable = async () => { Modal.createTrackedDialogAsync("Disable message search", "Disable message search", import("./DisableEventIndexDialog"), null, null, /* priority = */ false, /* static = */ true, ); }; - _onCrawlerSleepTimeChange = (e) => { - this.setState({crawlerSleepTime: e.target.value}); + private onCrawlerSleepTimeChange = (e) => { + this.setState({ crawlerSleepTime: e.target.value }); SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value); }; render() { const brand = SdkConfig.get().brand; - const Field = sdk.getComponent('views.elements.Field'); let crawlerState; if (this.state.currentRoom === null) { crawlerState = _t("Not currently indexing messages for any room."); } else { crawlerState = ( - _t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom }) + _t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom }) ); } @@ -168,15 +177,12 @@ export default class ManageEventIndexDialog extends React.Component { + value={this.state.crawlerSleepTime.toString()} + onChange={this.onCrawlerSleepTimeChange} />
); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return ( diff --git a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js index ab39a094db..92fb37ef16 100644 --- a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js @@ -15,15 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import FileSaver from 'file-saver'; import * as sdk from '../../../../index'; -import {MatrixClientPeg} from '../../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../../MatrixClientPeg'; import PropTypes from 'prop-types'; -import {_t, _td} from '../../../../languageHandler'; +import { _t, _td } from '../../../../languageHandler'; import { accessSecretStorage } from '../../../../SecurityManager'; import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; -import {copyNode} from "../../../../utils/strings"; +import { copyNode } from "../../../../utils/strings"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; const PHASE_PASSPHRASE = 0; @@ -95,7 +95,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { const blob = new Blob([this._keyBackupInfo.recovery_key], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'recovery-key.txt'); + FileSaver.saveAs(blob, 'security-key.txt'); this.setState({ downloaded: true, @@ -152,11 +152,11 @@ export default class CreateKeyBackupDialog extends React.PureComponent { } _onOptOutClick = () => { - this.setState({phase: PHASE_OPTOUT_CONFIRM}); + this.setState({ phase: PHASE_OPTOUT_CONFIRM }); } _onSetUpClick = () => { - this.setState({phase: PHASE_PASSPHRASE}); + this.setState({ phase: PHASE_PASSPHRASE }); } _onSkipPassPhraseClick = async () => { @@ -179,7 +179,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { return; } - this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + this.setState({ phase: PHASE_PASSPHRASE_CONFIRM }); }; _onPassPhraseConfirmNextClick = async (e) => { @@ -238,7 +238,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { )}

{_t( "We'll store an encrypted copy of your keys on our server. " + - "Secure your backup with a recovery passphrase.", + "Secure your backup with a Security Phrase.", )}

{_t("For maximum security, this should be different from your account password.")}

@@ -252,10 +252,10 @@ export default class CreateKeyBackupDialog extends React.PureComponent { onValidate={this._onPassPhraseValidate} fieldRef={this._passphraseField} autoFocus={true} - label={_td("Enter a recovery passphrase")} - labelEnterPassword={_td("Enter a recovery passphrase")} - labelStrongPassword={_td("Great! This recovery passphrase looks strong enough.")} - labelAllowedButUnsafe={_td("Great! This recovery passphrase looks strong enough.")} + label={_td("Enter a Security Phrase")} + labelEnterPassword={_td("Enter a Security Phrase")} + labelStrongPassword={_td("Great! This Security Phrase looks strong enough.")} + labelAllowedButUnsafe={_td("Great! This Security Phrase looks strong enough.")} /> @@ -270,7 +270,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
{_t("Advanced")} - {_t("Set up with a recovery key")} + {_t("Set up with a Security Key")}
; @@ -310,7 +310,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

{_t( - "Please enter your recovery passphrase a second time to confirm.", + "Enter your Security Phrase a second time to confirm it.", )}

@@ -319,7 +319,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { onChange={this._onPassPhraseConfirmChange} value={this.state.passPhraseConfirm} className="mx_CreateKeyBackupDialog_passPhraseInput" - placeholder={_t("Repeat your recovery passphrase...")} + placeholder={_t("Repeat your Security Phrase...")} autoFocus={true} />
@@ -338,15 +338,15 @@ export default class CreateKeyBackupDialog extends React.PureComponent { _renderPhaseShowKey() { return

{_t( - "Your recovery key is a safety net - you can use it to restore " + - "access to your encrypted messages if you forget your recovery passphrase.", + "Your Security Key is a safety net - you can use it to restore " + + "access to your encrypted messages if you forget your Security Phrase.", )}

{_t( "Keep a copy of it somewhere secure, like a password manager or even a safe.", )}

- {_t("Your recovery key")} + {_t("Your Security Key")}
@@ -369,22 +369,22 @@ export default class CreateKeyBackupDialog extends React.PureComponent { let introText; if (this.state.copied) { introText = _t( - "Your recovery key has been copied to your clipboard, paste it to:", - {}, {b: s => {s}}, + "Your Security Key has been copied to your clipboard, paste it to:", + {}, { b: s => {s} }, ); } else if (this.state.downloaded) { introText = _t( - "Your recovery key is in your Downloads folder.", - {}, {b: s => {s}}, + "Your Security Key is in your Downloads folder.", + {}, { b: s => {s} }, ); } const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
{introText}
    -
  • {_t("Print it and store it somewhere safe", {}, {b: s => {s}})}
  • -
  • {_t("Save it on a USB key or backup drive", {}, {b: s => {s}})}
  • -
  • {_t("Copy it to your personal cloud storage", {}, {b: s => {s}})}
  • +
  • {_t("Print it and store it somewhere safe", {}, { b: s => {s} })}
  • +
  • {_t("Save it on a USB key or backup drive", {}, { b: s => {s} })}
  • +
  • {_t("Copy it to your personal cloud storage", {}, { b: s => {s} })}
-
- {content} -
+
+ {content} +
); } diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index b1a14062f4..e1254929db 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -15,16 +15,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../../index'; -import {MatrixClientPeg} from '../../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../../MatrixClientPeg'; import FileSaver from 'file-saver'; -import {_t, _td} from '../../../../languageHandler'; +import { _t, _td } from '../../../../languageHandler'; import Modal from '../../../../Modal'; import { promptForBackupPassphrase } from '../../../../SecurityManager'; -import {copyNode} from "../../../../utils/strings"; -import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents"; +import { copyNode } from "../../../../utils/strings"; +import { SSOAuthEntry } from "../../../../components/views/auth/InteractiveAuthEntryComponents"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; import StyledRadioButton from '../../../../components/views/elements/StyledRadioButton'; import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; @@ -155,7 +155,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { backupSigStatus, }; } catch (e) { - this.setState({phase: PHASE_LOADERROR}); + this.setState({ phase: PHASE_LOADERROR }); } } @@ -235,7 +235,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const blob = new Blob([this._recoveryKey.encodedPrivateKey], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'recovery-key.txt'); + FileSaver.saveAs(blob, 'security-key.txt'); this.setState({ downloaded: true, @@ -385,7 +385,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _onLoadRetryClick = () => { - this.setState({phase: PHASE_LOADING}); + this.setState({ phase: PHASE_LOADING }); this._fetchBackupInfo(); } @@ -394,11 +394,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _onCancelClick = () => { - this.setState({phase: PHASE_CONFIRM_SKIP}); + this.setState({ phase: PHASE_CONFIRM_SKIP }); } _onGoBackClick = () => { - this.setState({phase: PHASE_CHOOSE_KEY_PASSPHRASE}); + this.setState({ phase: PHASE_CHOOSE_KEY_PASSPHRASE }); } _onPassPhraseNextClick = async (e) => { @@ -412,7 +412,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return; } - this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + this.setState({ phase: PHASE_PASSPHRASE_CONFIRM }); }; _onPassPhraseConfirmNextClick = async (e) => { @@ -593,10 +593,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { onValidate={this._onPassPhraseValidate} fieldRef={this._passphraseField} autoFocus={true} - label={_td("Enter a recovery passphrase")} - labelEnterPassword={_td("Enter a recovery passphrase")} - labelStrongPassword={_td("Great! This recovery passphrase looks strong enough.")} - labelAllowedButUnsafe={_td("Great! This recovery passphrase looks strong enough.")} + label={_td("Enter a Security Phrase")} + labelEnterPassword={_td("Enter a Security Phrase")} + labelStrongPassword={_td("Great! This Security Phrase looks strong enough.")} + labelAllowedButUnsafe={_td("Great! This Security Phrase looks strong enough.")} />
@@ -647,7 +647,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } return

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

@@ -856,9 +856,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)} fixedWidth={false} > -
- {content} -
+
+ {content} +
); } diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js index 4dd296a8f1..0435d81968 100644 --- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js @@ -15,11 +15,11 @@ limitations under the License. */ import FileSaver from 'file-saver'; -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../../languageHandler'; -import { MatrixClient } from 'matrix-js-sdk'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; import * as sdk from '../../../../index'; @@ -55,11 +55,11 @@ export default class ExportE2eKeysDialog extends React.Component { const passphrase = this._passphrase1.current.value; if (passphrase !== this._passphrase2.current.value) { - this.setState({errStr: _t('Passphrases must match')}); + this.setState({ errStr: _t('Passphrases must match') }); return false; } if (!passphrase) { - this.setState({errStr: _t('Passphrase must not be empty')}); + this.setState({ errStr: _t('Passphrase must not be empty') }); return false; } @@ -170,8 +170,11 @@ export default class ExportE2eKeysDialog extends React.Component {
-
-
- -
-
- -
+
+ +
+
+ +
-
- -
-
- -
+
+ +
+
+ +
diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js index 9f5045635d..4a0aa37da0 100644 --- a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js @@ -18,12 +18,12 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; import * as sdk from "../../../../index"; -import {MatrixClientPeg} from '../../../../MatrixClientPeg'; +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 { Action } from "../../../../dispatcher/actions"; export default class NewRecoveryMethodDialog extends React.PureComponent { static propTypes = { @@ -58,7 +58,7 @@ export default class NewRecoveryMethodDialog extends React.PureComponent { ; const newMethodDetected =

{_t( - "A new recovery passphrase and key for Secure Messages have been detected.", + "A new Security Phrase and key for Secure Messages have been detected.", )}

; const hackWarning =

{_t( diff --git a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js index cda353e717..f0f8a5273b 100644 --- a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js +++ b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js @@ -21,7 +21,7 @@ import * as sdk from "../../../../index"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; import Modal from "../../../../Modal"; -import {Action} from "../../../../dispatcher/actions"; +import { Action } from "../../../../dispatcher/actions"; export default class RecoveryMethodRemovedDialog extends React.PureComponent { static propTypes = { @@ -56,7 +56,7 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent { >

{_t( - "This session has detected that your recovery passphrase and key " + + "This session has detected that your Security Phrase and key " + "for Secure Messages have been removed.", )}

{_t( diff --git a/src/autocomplete/AutocompleteProvider.tsx b/src/autocomplete/AutocompleteProvider.tsx index a40ce7144d..51ab2e2cf7 100644 --- a/src/autocomplete/AutocompleteProvider.tsx +++ b/src/autocomplete/AutocompleteProvider.tsx @@ -17,7 +17,7 @@ limitations under the License. */ import React from 'react'; -import type {ICompletion, ISelectionRange} from './Autocompleter'; +import type { ICompletion, ISelectionRange } from './Autocompleter'; export interface ICommand { command: string | null; @@ -93,7 +93,12 @@ export default class AutocompleteProvider { }; } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { return []; } diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 2615736e09..acc7846510 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -15,8 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ReactElement} from 'react'; -import Room from 'matrix-js-sdk/src/models/room'; +import { ReactElement } from 'react'; +import { Room } from 'matrix-js-sdk/src/models/room'; + import CommandProvider from './CommandProvider'; import CommunityProvider from './CommunityProvider'; import DuckDuckGoProvider from './DuckDuckGoProvider'; @@ -24,8 +25,10 @@ import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; import EmojiProvider from './EmojiProvider'; import NotifProvider from './NotifProvider'; -import {timeout} from "../utils/promise"; -import AutocompleteProvider, {ICommand} from "./AutocompleteProvider"; +import { timeout } from "../utils/promise"; +import AutocompleteProvider, { ICommand } from "./AutocompleteProvider"; +import SpaceProvider from "./SpaceProvider"; +import SpaceStore from "../stores/SpaceStore"; export interface ISelectionRange { beginning?: boolean; // whether the selection is in the first block of the editor or not @@ -52,10 +55,15 @@ const PROVIDERS = [ EmojiProvider, NotifProvider, CommandProvider, - CommunityProvider, DuckDuckGoProvider, ]; +if (SpaceStore.spacesEnabled) { + PROVIDERS.push(SpaceProvider); +} else { + PROVIDERS.push(CommunityProvider); +} + // Providers will get rejected if they take longer than this. const PROVIDER_COMPLETION_TIMEOUT = 3000; @@ -82,15 +90,24 @@ export default class Autocompleter { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { /* Note: This intentionally waits for all providers to return, otherwise, we run into a condition where new completions are displayed while the user is interacting with the list, which makes it difficult to predict whether an action will actually do what is intended */ // list of results from each provider, each being a list of completions or null if it times out - const completionsList: ICompletion[][] = await Promise.all(this.providers.map(provider => { - return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT); + const completionsList: ICompletion[][] = await Promise.all(this.providers.map(async provider => { + return await timeout( + provider.getCompletions(query, selection, force, limit), + null, + PROVIDER_COMPLETION_TIMEOUT, + ); })); // map then filter to maintain the index for the map-operation, for this.providers to line up diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index c2d1290e08..e9a7742dee 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -18,12 +18,12 @@ limitations under the License. */ import React from 'react'; -import {_t} from '../languageHandler'; +import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import QueryMatcher from './QueryMatcher'; -import {TextualCompletion} from './Components'; -import {ICompletion, ISelectionRange} from "./Autocompleter"; -import {Command, Commands, CommandMap} from '../SlashCommands'; +import { TextualCompletion } from './Components'; +import { ICompletion, ISelectionRange } from "./Autocompleter"; +import { Command, Commands, CommandMap } from '../SlashCommands'; const COMMAND_RE = /(^\/\w*)(?: .*)?/g; @@ -34,12 +34,17 @@ export default class CommandProvider extends AutocompleteProvider { super(COMMAND_RE); this.matcher = new QueryMatcher(Commands, { keys: ['command', 'args', 'description'], - funcs: [({aliases}) => aliases.join(" ")], // aliases + funcs: [({ aliases }) => aliases.join(" ")], // aliases }); } - async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise { - const {command, range} = this.getCurrentCommand(query, selection); + async getCompletions( + query: string, + selection: ISelectionRange, + force?: boolean, + limit = -1, + ): Promise { + const { command, range } = this.getCurrentCommand(query, selection); if (!command) return []; let matches = []; @@ -55,14 +60,14 @@ export default class CommandProvider extends AutocompleteProvider { } else { if (query === '/') { // If they have just entered `/` show everything + // We exclude the limit on purpose to have a comprehensive list matches = Commands; } else { // otherwise fuzzy match against all of the fields - matches = this.matcher.match(command[1]); + matches = this.matcher.match(command[1], limit); } } - return matches.filter(cmd => cmd.isEnabled()).map((result) => { let completion = result.getCommand() + ' '; const usedAlias = result.aliases.find(alias => `/${alias}` === command[1]); diff --git a/src/autocomplete/CommunityProvider.tsx b/src/autocomplete/CommunityProvider.tsx index ebf5d536ec..de99675b4b 100644 --- a/src/autocomplete/CommunityProvider.tsx +++ b/src/autocomplete/CommunityProvider.tsx @@ -19,14 +19,15 @@ import React from 'react'; import Group from "matrix-js-sdk/src/models/group"; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { MatrixClientPeg } from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; -import {PillCompletion} from './Components'; -import * as sdk from '../index'; -import {sortBy} from "lodash"; -import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; -import {ICompletion, ISelectionRange} from "./Autocompleter"; +import { PillCompletion } from './Components'; +import { sortBy } from "lodash"; +import { makeGroupPermalink } from "../utils/permalinks/Permalinks"; +import { ICompletion, ISelectionRange } from "./Autocompleter"; import FlairStore from "../stores/FlairStore"; +import { mediaFromMxc } from "../customisations/Media"; +import BaseAvatar from '../components/views/avatars/BaseAvatar'; const COMMUNITY_REGEX = /\B\+\S*/g; @@ -49,9 +50,12 @@ export default class CommunityProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { - const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar'); - + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { // Disable autocompletions when composing commands because of various issues // (see https://github.com/vector-im/element-web/issues/4762) if (/^(\/join|\/leave)/.test(query)) { @@ -60,11 +64,11 @@ export default class CommunityProvider extends AutocompleteProvider { const cli = MatrixClientPeg.get(); let completions = []; - const {command, range} = this.getCurrentCommand(query, selection, force); + const { command, range } = this.getCurrentCommand(query, selection, force); if (command) { - const joinedGroups = cli.getGroups().filter(({myMembership}) => myMembership === 'join'); + const joinedGroups = cli.getGroups().filter(({ myMembership }) => myMembership === 'join'); - const groups = (await Promise.all(joinedGroups.map(async ({groupId}) => { + const groups = (await Promise.all(joinedGroups.map(async ({ groupId }) => { try { return FlairStore.getGroupProfileCached(cli, groupId); } catch (e) { // if FlairStore failed, fall back to just groupId @@ -80,11 +84,11 @@ export default class CommunityProvider extends AutocompleteProvider { this.matcher.setObjects(groups); const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); completions = sortBy(completions, [ (c) => score(matchedString, c.groupId), (c) => c.groupId.length, - ]).map(({avatarUrl, groupId, name}) => ({ + ]).map(({ avatarUrl, groupId, name }) => ({ completion: groupId, suffix: ' ', type: "community", @@ -95,7 +99,7 @@ export default class CommunityProvider extends AutocompleteProvider { name={name || groupId} width={24} height={24} - url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 24, 24) : null} /> + url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null} /> ), range, diff --git a/src/autocomplete/Components.tsx b/src/autocomplete/Components.tsx index 4b0d35698d..8f155f7f55 100644 --- a/src/autocomplete/Components.tsx +++ b/src/autocomplete/Components.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {forwardRef} from 'react'; +import React, { forwardRef } from 'react'; import classNames from 'classnames'; /* These were earlier stateless functional components but had to be converted @@ -31,7 +31,7 @@ interface ITextualCompletionProps { } export const TextualCompletion = forwardRef((props, ref) => { - const {title, subtitle, description, className, ...restProps} = props; + const { title, subtitle, description, className, ...restProps } = props; return (

((props, ref) => { - const {title, subtitle, description, className, children, ...restProps} = props; + const { title, subtitle, description, className, children, ...restProps } = props; return (
{ - const {command, range} = this.getCurrentCommand(query, selection); + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { + const { command, range } = this.getCurrentCommand(query, selection); if (!query || !command) { return []; } @@ -46,7 +51,8 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { method: 'GET', }); const json = await response.json(); - const results = json.Results.map((result) => { + const maxLength = limit > -1 ? limit : json.Results.length; + const results = json.Results.slice(0, maxLength).map((result) => { return { completion: result.Text, component: ( diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 705474f8d0..2fc77e9a17 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -21,9 +21,9 @@ import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import QueryMatcher from './QueryMatcher'; -import {PillCompletion} from './Components'; -import {ICompletion, ISelectionRange} from './Autocompleter'; -import {uniq, sortBy} from 'lodash'; +import { PillCompletion } from './Components'; +import { ICompletion, ISelectionRange } from './Autocompleter'; +import { uniq, sortBy } from 'lodash'; import SettingsStore from "../settings/SettingsStore"; import { shortcodeToUnicode } from '../HtmlUtils'; import { EMOJI, IEmoji } from '../emoji'; @@ -84,16 +84,21 @@ export default class EmojiProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force?: boolean, + limit = -1, + ): Promise { if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) { return []; // don't give any suggestions if the user doesn't want them } let completions = []; - const {command, range} = this.getCurrentCommand(query, selection); + const { command, range } = this.getCurrentCommand(query, selection); if (command) { const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); // Do second match with shouldMatchWordsOnly in order to match against 'name' completions = completions.concat(this.nameMatcher.match(matchedString)); @@ -116,7 +121,7 @@ export default class EmojiProvider extends AutocompleteProvider { sorters.push((c) => c._orderBy); completions = sortBy(uniq(completions), sorters); - completions = completions.map(({shortname}) => { + completions = completions.map(({ shortname }) => { const unicode = shortcodeToUnicode(shortname); return { completion: unicode, diff --git a/src/autocomplete/NotifProvider.tsx b/src/autocomplete/NotifProvider.tsx index ef1823c0ca..31b834ccfe 100644 --- a/src/autocomplete/NotifProvider.tsx +++ b/src/autocomplete/NotifProvider.tsx @@ -15,13 +15,14 @@ limitations under the License. */ import React from 'react'; -import Room from "matrix-js-sdk/src/models/room"; +import { Room } from "matrix-js-sdk/src/models/room"; + import AutocompleteProvider from './AutocompleteProvider'; import { _t } from '../languageHandler'; -import {MatrixClientPeg} from '../MatrixClientPeg'; -import {PillCompletion} from './Components'; -import * as sdk from '../index'; -import {ICompletion, ISelectionRange} from "./Autocompleter"; +import { MatrixClientPeg } from '../MatrixClientPeg'; +import { PillCompletion } from './Components'; +import { ICompletion, ISelectionRange } from "./Autocompleter"; +import RoomAvatar from '../components/views/avatars/RoomAvatar'; const AT_ROOM_REGEX = /@\S*/g; @@ -33,14 +34,17 @@ export default class NotifProvider extends AutocompleteProvider { this.room = room; } - async getCompletions(query: string, selection: ISelectionRange, force= false): Promise { - const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); - + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { const client = MatrixClientPeg.get(); if (!this.room.currentState.mayTriggerNotifOfType('room', client.credentials.userId)) return []; - const {command, range} = this.getCurrentCommand(query, selection, force); + const { command, range } = this.getCurrentCommand(query, selection, force); if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) { return [{ completion: '@room', diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index a07ed29c7e..3948be301c 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -16,14 +16,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {at, uniq} from 'lodash'; -import {removeHiddenChars} from "matrix-js-sdk/src/utils"; +import { at, uniq } from 'lodash'; +import { removeHiddenChars } from "matrix-js-sdk/src/utils"; interface IOptions { keys: Array; - funcs?: Array<(T) => string>; + funcs?: Array<(T) => string | string[]>; shouldMatchWordsOnly?: boolean; - shouldMatchPrefix?: boolean; // whether to apply unhomoglyph and strip diacritics to fuzz up the search. Defaults to true fuzzy?: boolean; } @@ -56,12 +55,6 @@ export default class QueryMatcher { if (this._options.shouldMatchWordsOnly === undefined) { this._options.shouldMatchWordsOnly = true; } - - // By default, match anywhere in the string being searched. If enabled, only return - // matches that are prefixed with the query. - if (this._options.shouldMatchPrefix === undefined) { - this._options.shouldMatchPrefix = false; - } } setObjects(objects: T[]) { @@ -76,7 +69,12 @@ export default class QueryMatcher { if (this._options.funcs) { for (const f of this._options.funcs) { - keyValues.push(f(object)); + const v = f(object); + if (Array.isArray(v)) { + keyValues.push(...v); + } else { + keyValues.push(v); + } } } @@ -94,7 +92,7 @@ export default class QueryMatcher { } } - match(query: string): T[] { + match(query: string, limit = -1): T[] { query = this.processQuery(query); if (this._options.shouldMatchWordsOnly) { query = query.replace(/[^\w]/g, ''); @@ -112,9 +110,9 @@ export default class QueryMatcher { resultKey = resultKey.replace(/[^\w]/g, ''); } const index = resultKey.indexOf(query); - if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) { + if (index !== -1) { matches.push( - ...candidates.map((candidate) => ({index, ...candidate})), + ...candidates.map((candidate) => ({ index, ...candidate })), ); } } @@ -136,7 +134,10 @@ export default class QueryMatcher { }); // Now map the keys to the result objects. Also remove any duplicates. - return uniq(matches.map((match) => match.object)); + const dedupped = uniq(matches.map((match) => match.object)); + const maxLength = limit === -1 ? dedupped.length : limit; + + return dedupped.slice(0, maxLength); } private processQuery(query: string): string { diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index 74deacf61f..37ddf2c387 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -1,8 +1,7 @@ /* Copyright 2016 Aviral Dasgupta -Copyright 2017 Vector Creations Ltd -Copyright 2017, 2018 New Vector Ltd Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2017, 2018, 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. @@ -17,27 +16,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import Room from "matrix-js-sdk/src/models/room"; +import React from "react"; +import { uniqBy, sortBy } from "lodash"; +import { Room } from "matrix-js-sdk/src/models/room"; + import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { MatrixClientPeg } from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; -import {PillCompletion} from './Components'; -import * as sdk from '../index'; -import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; -import {ICompletion, ISelectionRange} from "./Autocompleter"; -import {uniqBy, sortBy} from "lodash"; +import { PillCompletion } from './Components'; +import { makeRoomPermalink } from "../utils/permalinks/Permalinks"; +import { ICompletion, ISelectionRange } from "./Autocompleter"; +import RoomAvatar from '../components/views/avatars/RoomAvatar'; +import SpaceStore from "../stores/SpaceStore"; const ROOM_REGEX = /\B#\S*/g; -function score(query: string, space: string) { - const index = space.indexOf(query); - if (index === -1) { - return Infinity; - } else { - return index; - } +// Prefer canonical aliases over non-canonical ones +function canonicalScore(displayedAlias: string, room: Room): number { + return displayedAlias === room.getCanonicalAlias() ? 0 : 1; } function matcherObject(room: Room, displayedAlias: string, matchName = "") { @@ -49,7 +46,7 @@ function matcherObject(room: Room, displayedAlias: string, matchName = "") { } export default class RoomProvider extends AutocompleteProvider { - matcher: QueryMatcher; + protected matcher: QueryMatcher; constructor() { super(ROOM_REGEX); @@ -58,15 +55,29 @@ export default class RoomProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { - const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); + protected getRooms() { + const cli = MatrixClientPeg.get(); + let rooms = cli.getVisibleRooms(); - const client = MatrixClientPeg.get(); + // if spaces are enabled then filter them out here as they get their own autocomplete provider + if (SpaceStore.spacesEnabled) { + rooms = rooms.filter(r => !r.isSpaceRoom()); + } + + return rooms; + } + + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { let completions = []; - const {command, range} = this.getCurrentCommand(query, selection, force); + const { command, range } = this.getCurrentCommand(query, selection, force); if (command) { // the only reason we need to do this is because Fuse only matches on properties - let matcherObjects = client.getVisibleRooms().reduce((aliases, room) => { + let matcherObjects = this.getRooms().reduce((aliases, room) => { if (room.getCanonicalAlias()) { aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name)); } @@ -90,9 +101,9 @@ export default class RoomProvider extends AutocompleteProvider { this.matcher.setObjects(matcherObjects); const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); completions = sortBy(completions, [ - (c) => score(matchedString, c.displayedAlias), + (c) => canonicalScore(c.displayedAlias, c.room), (c) => c.displayedAlias.length, ]); completions = uniqBy(completions, (match) => match.room); @@ -110,7 +121,7 @@ export default class RoomProvider extends AutocompleteProvider { ), range, }; - }).filter((completion) => !!completion.completion && completion.completion.length > 0).slice(0, 4); + }).filter((completion) => !!completion.completion && completion.completion.length > 0); } return completions; } diff --git a/src/autocomplete/SpaceProvider.tsx b/src/autocomplete/SpaceProvider.tsx new file mode 100644 index 0000000000..1c99aee5ac --- /dev/null +++ b/src/autocomplete/SpaceProvider.tsx @@ -0,0 +1,43 @@ +/* +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 from "react"; + +import { _t } from '../languageHandler'; +import { MatrixClientPeg } from '../MatrixClientPeg'; +import RoomProvider from "./RoomProvider"; + +export default class SpaceProvider extends RoomProvider { + protected getRooms() { + return MatrixClientPeg.get().getVisibleRooms().filter(r => r.isSpaceRoom()); + } + + getName() { + return _t("Spaces"); + } + + renderCompletions(completions: React.ReactNode[]): React.ReactNode { + return ( +
+ { completions } +
+ ); + } +} diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 32eea55b0b..d8f17c54d0 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -20,19 +20,19 @@ limitations under the License. import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import {PillCompletion} from './Components'; -import * as sdk from '../index'; +import { PillCompletion } from './Components'; import QueryMatcher from './QueryMatcher'; -import {sortBy} from 'lodash'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { sortBy } from 'lodash'; +import { MatrixClientPeg } from '../MatrixClientPeg'; -import MatrixEvent from "matrix-js-sdk/src/models/event"; -import Room from "matrix-js-sdk/src/models/room"; -import RoomMember from "matrix-js-sdk/src/models/room-member"; -import RoomState from "matrix-js-sdk/src/models/room-state"; -import EventTimeline from "matrix-js-sdk/src/models/event-timeline"; -import {makeUserPermalink} from "../utils/permalinks/Permalinks"; -import {ICompletion, ISelectionRange} from "./Autocompleter"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { RoomState } from "matrix-js-sdk/src/models/room-state"; +import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; +import { makeUserPermalink } from "../utils/permalinks/Permalinks"; +import { ICompletion, ISelectionRange } from "./Autocompleter"; +import MemberAvatar from '../components/views/avatars/MemberAvatar'; const USER_REGEX = /\B@\S*/g; @@ -56,7 +56,6 @@ export default class UserProvider extends AutocompleteProvider { this.matcher = new QueryMatcher([], { keys: ['name'], funcs: [obj => obj.userId.slice(1)], // index by user id minus the leading '@' - shouldMatchPrefix: true, shouldMatchWordsOnly: false, }); @@ -103,14 +102,17 @@ export default class UserProvider extends AutocompleteProvider { this.users = null; }; - async getCompletions(rawQuery: string, selection: ISelectionRange, force = false): Promise { - const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); - + async getCompletions( + rawQuery: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { // lazy-load user list into matcher if (!this.users) this._makeUsers(); let completions = []; - const {command, range} = this.getCurrentCommand(rawQuery, selection, force); + const { command, range } = this.getCurrentCommand(rawQuery, selection, force); if (!command) return completions; @@ -119,7 +121,7 @@ export default class UserProvider extends AutocompleteProvider { if (fullMatch && fullMatch !== '@') { // Don't include the '@' in our search query - it's only used as a way to trigger completion const query = fullMatch.startsWith('@') ? fullMatch.substring(1) : fullMatch; - completions = this.matcher.match(query).map((user) => { + completions = this.matcher.match(query, limit).map((user) => { const displayName = (user.name || user.userId || ''); return { // Length of completion should equal length of text in decorator. draft-js @@ -154,7 +156,8 @@ export default class UserProvider extends AutocompleteProvider { } const currentUserId = MatrixClientPeg.get().credentials.userId; - this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId); + this.users = this.room.getJoinedMembers().filter(({ userId }) => userId !== currentUserId); + this.users = this.users.concat(this.room.getMembersWithMembership("invite")); this.users = sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20); diff --git a/src/components/structures/AutoHideScrollbar.js b/src/components/structures/AutoHideScrollbar.js deleted file mode 100644 index 14f7c9ca83..0000000000 --- a/src/components/structures/AutoHideScrollbar.js +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; - -export default class AutoHideScrollbar extends React.Component { - constructor(props) { - super(props); - this._collectContainerRef = this._collectContainerRef.bind(this); - } - - _collectContainerRef(ref) { - if (ref && !this.containerRef) { - this.containerRef = ref; - } - if (this.props.wrappedRef) { - this.props.wrappedRef(ref); - } - } - - getScrollTop() { - return this.containerRef.scrollTop; - } - - render() { - return (
- { this.props.children } -
); - } -} diff --git a/src/components/structures/AutoHideScrollbar.tsx b/src/components/structures/AutoHideScrollbar.tsx new file mode 100644 index 0000000000..184d883dda --- /dev/null +++ b/src/components/structures/AutoHideScrollbar.tsx @@ -0,0 +1,69 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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, { HTMLAttributes, WheelEvent } from "react"; + +interface IProps extends Omit, "onScroll"> { + className?: string; + onScroll?: (event: Event) => void; + onWheel?: (event: WheelEvent) => void; + style?: React.CSSProperties; + tabIndex?: number; + wrappedRef?: (ref: HTMLDivElement) => void; +} + +export default class AutoHideScrollbar extends React.Component { + private containerRef: React.RefObject = React.createRef(); + + public componentDidMount() { + if (this.containerRef.current && this.props.onScroll) { + // Using the passive option to not block the main thread + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners + this.containerRef.current.addEventListener("scroll", this.props.onScroll, { passive: true }); + } + + if (this.props.wrappedRef) { + this.props.wrappedRef(this.containerRef.current); + } + } + + public componentWillUnmount() { + if (this.containerRef.current && this.props.onScroll) { + this.containerRef.current.removeEventListener("scroll", this.props.onScroll); + } + } + + public getScrollTop(): number { + return this.containerRef.current.scrollTop; + } + + public render() { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { className, onScroll, onWheel, style, tabIndex, wrappedRef, children, ...otherProps } = this.props; + + return (
+ { children } +
); + } +} diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index fa0d6682dd..407dc6f04c 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -16,12 +16,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {CSSProperties, RefObject, useRef, useState} from "react"; +import React, { CSSProperties, RefObject, useRef, useState } from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; -import {Key} from "../../Keyboard"; -import {Writeable} from "../../@types/common"; +import { Key } from "../../Keyboard"; +import { Writeable } from "../../@types/common"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import UIStore from "../../stores/UIStore"; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -76,6 +78,7 @@ export interface IProps extends IPosition { hasBackground?: boolean; // whether this context menu should be focus managed. If false it must handle itself managed?: boolean; + wrapperClassName?: string; // Function to be called on menu close onFinished(); @@ -90,6 +93,7 @@ interface IState { // Generic ContextMenu Portal wrapper // all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1} // this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. +@replaceableComponent("structures.ContextMenu") export class ContextMenu extends React.PureComponent { private initialFocus: HTMLElement; @@ -219,10 +223,12 @@ export class ContextMenu extends React.PureComponent { }; private onKeyDown = (ev: React.KeyboardEvent) => { + // don't let keyboard handling escape the context menu + ev.stopPropagation(); + if (!this.props.managed) { if (ev.key === Key.ESCAPE) { this.props.onFinished(); - ev.stopPropagation(); ev.preventDefault(); } return; @@ -255,7 +261,6 @@ export class ContextMenu extends React.PureComponent { if (handled) { // consume all other keys in context menu - ev.stopPropagation(); ev.preventDefault(); } }; @@ -299,7 +304,7 @@ export class ContextMenu extends React.PureComponent { // such that it does not leave the (padded) window. if (contextMenuRect) { const padding = 10; - adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height + padding); + adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding); } position.top = adjusted; @@ -365,8 +370,8 @@ export class ContextMenu extends React.PureComponent { return (
@@ -390,32 +395,68 @@ export class ContextMenu extends React.PureComponent { } // Placement method for to position context menu to right of elementRect with chevronOffset -export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => { +export const toRightOf = (elementRect: Pick, chevronOffset = 12) => { const left = elementRect.right + window.pageXOffset + 3; let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; top -= chevronOffset + 8; // where 8 is half the height of the chevron - return {left, top, chevronOffset}; + return { left, top, chevronOffset }; }; -// Placement method for to position context menu right-aligned and flowing to the left of elementRect -export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => { +// Placement method for to position context menu right-aligned and flowing to the left of elementRect, +// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?) +export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; const buttonRight = elementRect.right + window.pageXOffset; const buttonBottom = elementRect.bottom + window.pageYOffset; const buttonTop = elementRect.top + window.pageYOffset; // Align the right edge of the menu to the right edge of the button - menuOptions.right = window.innerWidth - buttonRight; + menuOptions.right = UIStore.instance.windowWidth - buttonRight; // Align the menu vertically on whichever side of the button has more space available. - if (buttonBottom < window.innerHeight / 2) { - menuOptions.top = buttonBottom; + if (buttonBottom < UIStore.instance.windowHeight / 2) { + menuOptions.top = buttonBottom + vPadding; } else { - menuOptions.bottom = window.innerHeight - buttonTop; + menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding; } return menuOptions; }; +// Placement method for to position context menu right-aligned and flowing to the left of elementRect +// and always above elementRect +export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => { + const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; + + const buttonRight = elementRect.right + window.pageXOffset; + const buttonBottom = elementRect.bottom + window.pageYOffset; + const buttonTop = elementRect.top + window.pageYOffset; + // Align the right edge of the menu to the right edge of the button + menuOptions.right = UIStore.instance.windowWidth - buttonRight; + // Align the menu vertically on whichever side of the button has more space available. + if (buttonBottom < UIStore.instance.windowHeight / 2) { + menuOptions.top = buttonBottom + vPadding; + } else { + menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding; + } + + return menuOptions; +}; + +// Placement method for to position context menu right-aligned and flowing to the right of elementRect +// and always above elementRect +export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => { + const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; + + const buttonLeft = elementRect.left + window.pageXOffset; + const buttonTop = elementRect.top + window.pageYOffset; + // Align the left edge of the menu to the left edge of the button + menuOptions.left = buttonLeft; + // Align the menu vertically above the menu + menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding; + + return menuOptions; +}; + type ContextMenuTuple = [boolean, RefObject, () => void, () => void, (val: boolean) => void]; export const useContextMenu = (): ContextMenuTuple => { const button = useRef(null); @@ -430,6 +471,7 @@ export const useContextMenu = (): ContextMenuTuple< return [isOpen, button, open, close, setIsOpen]; }; +@replaceableComponent("structures.LegacyContextMenu") export default class LegacyContextMenu extends ContextMenu { render() { return this.renderMenu(false); @@ -456,15 +498,15 @@ export function createMenu(ElementClass, props) { ReactDOM.render(menu, getOrCreateContainer()); - return {close: onFinished}; + return { close: onFinished }; } // re-export the semantic helper components for simplicity -export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton"; -export {ContextMenuTooltipButton} from "../../accessibility/context_menu/ContextMenuTooltipButton"; -export {MenuGroup} from "../../accessibility/context_menu/MenuGroup"; -export {MenuItem} from "../../accessibility/context_menu/MenuItem"; -export {MenuItemCheckbox} from "../../accessibility/context_menu/MenuItemCheckbox"; -export {MenuItemRadio} from "../../accessibility/context_menu/MenuItemRadio"; -export {StyledMenuItemCheckbox} from "../../accessibility/context_menu/StyledMenuItemCheckbox"; -export {StyledMenuItemRadio} from "../../accessibility/context_menu/StyledMenuItemRadio"; +export { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton"; +export { ContextMenuTooltipButton } from "../../accessibility/context_menu/ContextMenuTooltipButton"; +export { MenuGroup } from "../../accessibility/context_menu/MenuGroup"; +export { MenuItem } from "../../accessibility/context_menu/MenuItem"; +export { MenuItemCheckbox } from "../../accessibility/context_menu/MenuItemCheckbox"; +export { MenuItemRadio } from "../../accessibility/context_menu/MenuItemRadio"; +export { StyledMenuItemCheckbox } from "../../accessibility/context_menu/StyledMenuItemCheckbox"; +export { StyledMenuItemRadio } from "../../accessibility/context_menu/StyledMenuItemRadio"; diff --git a/src/components/structures/CustomRoomTagPanel.js b/src/components/structures/CustomRoomTagPanel.js index a79bdafeb5..037d7c251c 100644 --- a/src/components/structures/CustomRoomTagPanel.js +++ b/src/components/structures/CustomRoomTagPanel.js @@ -21,7 +21,9 @@ import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; import classNames from 'classnames'; import * as FormattingUtils from '../../utils/FormattingUtils'; +import { replaceableComponent } from "../../utils/replaceableComponent"; +@replaceableComponent("structures.CustomRoomTagPanel") class CustomRoomTagPanel extends React.Component { constructor(props) { super(props); @@ -32,7 +34,7 @@ class CustomRoomTagPanel extends React.Component { componentDidMount() { this._tagStoreToken = CustomRoomTagStore.addListener(() => { - this.setState({tags: CustomRoomTagStore.getSortedTags()}); + this.setState({ tags: CustomRoomTagStore.getSortedTags() }); }); } @@ -62,7 +64,7 @@ class CustomRoomTagPanel extends React.Component { class CustomRoomTagTile extends React.Component { onClick = () => { - dis.dispatch({action: 'select_custom_room_tag', tag: this.props.tag.name}); + dis.dispatch({ action: 'select_custom_room_tag', tag: this.props.tag.name }); }; render() { diff --git a/src/components/structures/EmbeddedPage.js b/src/components/structures/EmbeddedPage.js index cbfeff7582..628c16f322 100644 --- a/src/components/structures/EmbeddedPage.js +++ b/src/components/structures/EmbeddedPage.js @@ -16,15 +16,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import React from 'react'; import PropTypes from 'prop-types'; import request from 'browser-request'; import { _t } from '../../languageHandler'; import sanitizeHtml from 'sanitize-html'; import dis from '../../dispatcher/dispatcher'; -import {MatrixClientPeg} from '../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../MatrixClientPeg'; import classnames from 'classnames'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.tsx similarity index 78% rename from src/components/structures/FilePanel.js rename to src/components/structures/FilePanel.tsx index 4836b0f554..36f774a130 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.tsx @@ -16,38 +16,58 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {Filter} from 'matrix-js-sdk'; -import * as sdk from '../../index'; -import {MatrixClientPeg} from '../../MatrixClientPeg'; +import { Filter } from 'matrix-js-sdk/src/filter'; +import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; +import { Direction } from "matrix-js-sdk/src/models/event-timeline"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from 'matrix-js-sdk/src/models/room'; +import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; + +import { MatrixClientPeg } from '../../MatrixClientPeg'; import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from '../../languageHandler'; import BaseCard from "../views/right_panel/BaseCard"; -import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; -import DesktopBuildsNotice, {WarningKind} from "../views/elements/DesktopBuildsNotice"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import DesktopBuildsNotice, { WarningKind } from "../views/elements/DesktopBuildsNotice"; +import { replaceableComponent } from "../../utils/replaceableComponent"; + +import ResizeNotifier from '../../utils/ResizeNotifier'; +import TimelinePanel from "./TimelinePanel"; +import Spinner from "../views/elements/Spinner"; +import { TileShape } from '../views/rooms/EventTile'; + +interface IProps { + roomId: string; + onClose: () => void; + resizeNotifier: ResizeNotifier; +} + +interface IState { + timelineSet: EventTimelineSet; +} /* * Component which shows the filtered file using a TimelinePanel */ -class FilePanel extends React.Component { - static propTypes = { - roomId: PropTypes.string.isRequired, - onClose: PropTypes.func.isRequired, - }; - +@replaceableComponent("structures.FilePanel") +class FilePanel extends React.Component { // This is used to track if a decrypted event was a live event and should be // added to the timeline. - decryptingEvents = new Set(); + private decryptingEvents = new Set(); + public noRoom: boolean; state = { timelineSet: null, }; - onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => { - if (room.roomId !== this.props.roomId) return; + private onRoomTimeline = (ev: MatrixEvent, room: Room, toStartOfTimeline: true, removed: true, data: any): void => { + if (room?.roomId !== this.props?.roomId) return; if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return; + const client = MatrixClientPeg.get(); + client.decryptEventIfNeeded(ev); + if (ev.isBeingDecrypted()) { this.decryptingEvents.add(ev.getId()); } else { @@ -55,7 +75,7 @@ class FilePanel extends React.Component { } }; - onEventDecrypted = (ev, err) => { + private onEventDecrypted = (ev: MatrixEvent, err?: any): void => { if (ev.getRoomId() !== this.props.roomId) return; const eventId = ev.getId(); @@ -65,7 +85,7 @@ class FilePanel extends React.Component { this.addEncryptedLiveEvent(ev); }; - addEncryptedLiveEvent(ev, toStartOfTimeline) { + public addEncryptedLiveEvent(ev: MatrixEvent): void { if (!this.state.timelineSet) return; const timeline = this.state.timelineSet.getLiveTimeline(); @@ -79,7 +99,7 @@ class FilePanel extends React.Component { } } - async componentDidMount() { + public async componentDidMount(): Promise { const client = MatrixClientPeg.get(); await this.updateTimelineSet(this.props.roomId); @@ -100,7 +120,7 @@ class FilePanel extends React.Component { } } - componentWillUnmount() { + public componentWillUnmount(): void { const client = MatrixClientPeg.get(); if (client === null) return; @@ -112,7 +132,7 @@ class FilePanel extends React.Component { } } - async fetchFileEventsServer(room) { + public async fetchFileEventsServer(room: Room): Promise { const client = MatrixClientPeg.get(); const filter = new Filter(client.credentials.userId); @@ -136,7 +156,11 @@ class FilePanel extends React.Component { return timelineSet; } - onPaginationRequest = (timelineWindow, direction, limit) => { + private onPaginationRequest = ( + timelineWindow: TimelineWindow, + direction: Direction, + limit: number, + ): Promise => { const client = MatrixClientPeg.get(); const eventIndex = EventIndexPeg.get(); const roomId = this.props.roomId; @@ -154,7 +178,7 @@ class FilePanel extends React.Component { } }; - async updateTimelineSet(roomId: string) { + public async updateTimelineSet(roomId: string): Promise { const client = MatrixClientPeg.get(); const room = client.getRoom(roomId); const eventIndex = EventIndexPeg.get(); @@ -190,7 +214,7 @@ class FilePanel extends React.Component { } } - render() { + public render() { if (MatrixClientPeg.get().isGuest()) { return
- { _t("You must register to use this functionality", - {}, - { 'a': (sub) => { sub } }) - } + { _t("You must register to use this functionality", + {}, + { 'a': (sub) => { sub } }) + }
; } else if (this.noRoom) { @@ -215,8 +239,6 @@ class FilePanel extends React.Component { } // wrap a TimelinePanel with the jump-to-event bits turned off. - const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); - const Loader = sdk.getComponent("elements.Spinner"); const emptyState = (

{_t('No files visible in this room')}

@@ -242,7 +264,7 @@ class FilePanel extends React.Component { timelineSet={this.state.timelineSet} showUrlPreview = {false} onPaginationRequest={this.onPaginationRequest} - tileShape="file_grid" + tileShape={TileShape.FileGrid} resizeNotifier={this.props.resizeNotifier} empty={emptyState} /> @@ -255,7 +277,7 @@ class FilePanel extends React.Component { onClose={this.props.onClose} previousPhase={RightPanelPhases.RoomSummary} > - + ); } diff --git a/src/components/structures/GenericErrorPage.js b/src/components/structures/GenericErrorPage.js index ab7d4f9311..c9ed4ae622 100644 --- a/src/components/structures/GenericErrorPage.js +++ b/src/components/structures/GenericErrorPage.js @@ -16,7 +16,9 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import { replaceableComponent } from "../../utils/replaceableComponent"; +@replaceableComponent("structures.GenericErrorPage") export default class GenericErrorPage extends React.PureComponent { static propTypes = { title: PropTypes.object.isRequired, // jsx for title diff --git a/src/components/structures/GroupFilterPanel.js b/src/components/structures/GroupFilterPanel.js index 96aa1ba728..5d1be64f25 100644 --- a/src/components/structures/GroupFilterPanel.js +++ b/src/components/structures/GroupFilterPanel.js @@ -24,13 +24,14 @@ import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; import { _t } from '../../languageHandler'; -import { Droppable } from 'react-beautiful-dnd'; import classNames from 'classnames'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; import SettingsStore from "../../settings/SettingsStore"; import UserTagTile from "../views/elements/UserTagTile"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +@replaceableComponent("structures.GroupFilterPanel") class GroupFilterPanel extends React.Component { static contextType = MatrixClientContext; @@ -81,15 +82,15 @@ class GroupFilterPanel extends React.Component { } }; - onMouseDown = e => { + onClick = e => { // only dispatch if its not a no-op if (this.state.selectedTags.length > 0) { - dis.dispatch({action: 'deselect_tags'}); + dis.dispatch({ action: 'deselect_tags' }); } }; onClearFilterClick = ev => { - dis.dispatch({action: 'deselect_tags'}); + dis.dispatch({ action: 'deselect_tags' }); }; renderGlobalIcon() { @@ -121,12 +122,19 @@ class GroupFilterPanel extends React.Component { mx_GroupFilterPanel_items_selected: itemsSelected, }); + let betaDot; + if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) { + betaDot =
; + } + let createButton = ( + className="mx_TagTile mx_TagTile_plus"> + { betaDot } + ); if (SettingsStore.getValue("feature_communities_v2_prototypes")) { @@ -142,28 +150,15 @@ class GroupFilterPanel extends React.Component { return
- - { (provided, snapshot) => ( -
- { this.renderGlobalIcon() } - { tags } -
- {createButton} -
- { provided.placeholder } -
- ) } -
+
+ { this.renderGlobalIcon() } + { tags } +
+ { createButton } +
+
; } diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 482b9f6da2..f31f302b29 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -18,7 +18,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {MatrixClientPeg} from '../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../MatrixClientPeg'; import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; import { getHostingLink } from '../../utils/HostingLink'; @@ -34,20 +34,22 @@ import classnames from 'classnames'; import GroupStore from '../../stores/GroupStore'; import FlairStore from '../../stores/FlairStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; -import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks"; -import {Group} from "matrix-js-sdk"; -import {allSettled, sleep} from "../../utils/promise"; +import { makeGroupPermalink, makeUserPermalink } from "../../utils/permalinks/Permalinks"; +import { Group } from "matrix-js-sdk/src/models/group"; +import { sleep } from "matrix-js-sdk/src/utils"; import RightPanelStore from "../../stores/RightPanelStore"; import AutoHideScrollbar from "./AutoHideScrollbar"; +import { mediaFromMxc } from "../../customisations/Media"; +import { replaceableComponent } from "../../utils/replaceableComponent"; const LONG_DESC_PLACEHOLDER = _td( -`

HTML for your community's page

+ `

HTML for your community's page

Use the long description to introduce new members to the community, or distribute some important links

- You can even use 'img' tags + You can even add images with Matrix URLs

`); @@ -97,7 +99,7 @@ class CategoryRoomList extends React.Component { onFinished: (success, addrs) => { if (!success) return; const errorList = []; - allSettled(addrs.map((addr) => { + Promise.allSettled(addrs.map((addr) => { return GroupStore .addRoomToGroupSummary(this.props.groupId, addr.address) .catch(() => { errorList.push(addr.address); }); @@ -108,26 +110,27 @@ class CategoryRoomList extends React.Component { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog( 'Failed to add the following room to the group summary', - '', ErrorDialog, - { - title: _t( - "Failed to add the following rooms to the summary of %(groupId)s:", - {groupId: this.props.groupId}, - ), - description: errorList.join(", "), - }); + '', + ErrorDialog, + { + title: _t( + "Failed to add the following rooms to the summary of %(groupId)s:", + { groupId: this.props.groupId }, + ), + description: errorList.join(", "), + }, + ); }); }, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); }; render() { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); const addButton = this.props.editing ? ( - +
{ _t('Add a Room') }
@@ -144,8 +147,8 @@ class CategoryRoomList extends React.Component { let catHeader =
; if (this.props.category && this.props.category.profile) { catHeader =
- { this.props.category.profile.name } -
; + { this.props.category.profile.name } +
; } return
{ catHeader } @@ -188,13 +191,14 @@ class FeaturedRoom extends React.Component { Modal.createTrackedDialog( 'Failed to remove room from group summary', '', ErrorDialog, - { - title: _t( - "Failed to remove the room from the summary of %(groupId)s", - {groupId: this.props.groupId}, - ), - description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}), - }); + { + title: _t( + "Failed to remove the room from the summary of %(groupId)s", + { groupId: this.props.groupId }, + ), + description: _t("The room '%(roomName)s' could not be removed from the summary.", { roomName }), + }, + ); }); }; @@ -269,7 +273,7 @@ class RoleUserList extends React.Component { onFinished: (success, addrs) => { if (!success) return; const errorList = []; - allSettled(addrs.map((addr) => { + Promise.allSettled(addrs.map((addr) => { return GroupStore .addUserToGroupSummary(addr.address) .catch(() => { errorList.push(addr.address); }); @@ -281,27 +285,27 @@ class RoleUserList extends React.Component { Modal.createTrackedDialog( 'Failed to add the following users to the community summary', '', ErrorDialog, - { - title: _t( - "Failed to add the following users to the summary of %(groupId)s:", - {groupId: this.props.groupId}, - ), - description: errorList.join(", "), - }); + { + title: _t( + "Failed to add the following users to the summary of %(groupId)s:", + { groupId: this.props.groupId }, + ), + description: errorList.join(", "), + }, + ); }); }, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); }; render() { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); const addButton = this.props.editing ? ( - -
- { _t('Add a User') } -
-
) :
; + +
+ { _t('Add a User') } +
+ ) :
; const userNodes = this.props.users.map((u) => { return { name }; - const httpUrl = MatrixClientPeg.get() - .mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64); + const httpUrl = mediaFromMxc(this.props.summaryInfo.avatar_url).getSquareThumbnailHttp(64); const deleteButton = this.props.editing ? { - dis.dispatch({action: 'close_settings'}); + dis.dispatch({ action: 'close_settings' }); }; _onNameChange = (value) => { @@ -612,7 +621,7 @@ export default class GroupView extends React.Component { const file = ev.target.files[0]; if (!file) return; - this.setState({uploadingAvatar: true}); + this.setState({ uploadingAvatar: true }); this._matrixClient.uploadContent(file).then((url) => { const newProfileForm = Object.assign(this.state.profileForm, { avatar_url: url }); this.setState({ @@ -624,7 +633,7 @@ export default class GroupView extends React.Component { avatarChanged: true, }); }).catch((e) => { - this.setState({uploadingAvatar: false}); + this.setState({ uploadingAvatar: false }); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to upload avatar image", e); Modal.createTrackedDialog('Failed to upload image', '', ErrorDialog, { @@ -641,7 +650,7 @@ export default class GroupView extends React.Component { }; _onSaveClick = () => { - this.setState({saving: true}); + this.setState({ saving: true }); const savePromise = this.state.isUserPrivileged ? this._saveGroup() : Promise.resolve(); savePromise.then((result) => { this.setState({ @@ -680,7 +689,7 @@ export default class GroupView extends React.Component { } _onAcceptInviteClick = async () => { - this.setState({membershipBusy: true}); + this.setState({ membershipBusy: true }); // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. @@ -689,7 +698,7 @@ export default class GroupView extends React.Component { GroupStore.acceptGroupInvite(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync }).catch((e) => { - this.setState({membershipBusy: false}); + this.setState({ membershipBusy: false }); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Error accepting invite', '', ErrorDialog, { title: _t("Error"), @@ -699,7 +708,7 @@ export default class GroupView extends React.Component { }; _onRejectInviteClick = async () => { - this.setState({membershipBusy: true}); + this.setState({ membershipBusy: true }); // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. @@ -708,7 +717,7 @@ export default class GroupView extends React.Component { GroupStore.leaveGroup(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync }).catch((e) => { - this.setState({membershipBusy: false}); + this.setState({ membershipBusy: false }); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Error rejecting invite', '', ErrorDialog, { title: _t("Error"), @@ -719,11 +728,11 @@ export default class GroupView extends React.Component { _onJoinClick = async () => { if (this._matrixClient.isGuest()) { - dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}}); + dis.dispatch({ action: 'require_registration', screen_after: { screen: `group/${this.props.groupId}` } }); return; } - this.setState({membershipBusy: true}); + this.setState({ membershipBusy: true }); // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. @@ -732,7 +741,7 @@ export default class GroupView extends React.Component { GroupStore.joinGroup(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync }).catch((e) => { - this.setState({membershipBusy: false}); + this.setState({ membershipBusy: false }); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Error joining room', '', ErrorDialog, { title: _t("Error"), @@ -765,8 +774,8 @@ export default class GroupView extends React.Component { title: _t("Leave Community"), description: ( - { _t("Leave %(groupName)s?", {groupName: this.props.groupId}) } - { warnings } + { _t("Leave %(groupName)s?", { groupName: this.props.groupId }) } + { warnings } ), button: _t("Leave"), @@ -774,7 +783,7 @@ export default class GroupView extends React.Component { onFinished: async (confirmed) => { if (!confirmed) return; - this.setState({membershipBusy: true}); + this.setState({ membershipBusy: true }); // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. @@ -783,7 +792,7 @@ export default class GroupView extends React.Component { GroupStore.leaveGroup(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync }).catch((e) => { - this.setState({membershipBusy: false}); + this.setState({ membershipBusy: false }); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Error leaving community', '', ErrorDialog, { title: _t("Error"), @@ -847,7 +856,6 @@ export default class GroupView extends React.Component { _getRoomsNode() { const RoomDetailList = sdk.getComponent('rooms.RoomDetailList'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const TintableSvg = sdk.getComponent('elements.TintableSvg'); const Spinner = sdk.getComponent('elements.Spinner'); const TooltipButton = sdk.getComponent('elements.TooltipButton'); @@ -863,7 +871,7 @@ export default class GroupView extends React.Component { onClick={this._onAddRoomsClick} >
- +
{ _t('Add rooms to this community') } @@ -979,10 +987,9 @@ export default class GroupView extends React.Component {
; } - const httpInviterAvatar = this.state.inviterProfile ? - this._matrixClient.mxcUrlToHttp( - this.state.inviterProfile.avatarUrl, 36, 36, - ) : null; + const httpInviterAvatar = this.state.inviterProfile && this.state.inviterProfile.avatarUrl + ? mediaFromMxc(this.state.inviterProfile.avatarUrl).getSquareThumbnailHttp(36) + : null; const inviter = group.inviter || {}; let inviterName = inviter.userId; @@ -1054,10 +1061,11 @@ export default class GroupView extends React.Component { return null; } - const membershipButtonClasses = classnames([ - 'mx_RoomHeader_textButton', - 'mx_GroupView_textButton', - ], + const membershipButtonClasses = classnames( + [ + 'mx_RoomHeader_textButton', + 'mx_GroupView_textButton', + ], membershipButtonExtraClasses, ); @@ -1328,7 +1336,7 @@ export default class GroupView extends React.Component { if (this.state.error.httpStatus === 404) { return (
- { _t('Community %(groupId)s not found', {groupId: this.props.groupId}) } + { _t('Community %(groupId)s not found', { groupId: this.props.groupId }) }
); } else { @@ -1338,7 +1346,7 @@ export default class GroupView extends React.Component { } return (
- { _t('Failed to load %(groupId)s', {groupId: this.props.groupId }) } + { _t('Failed to load %(groupId)s', { groupId: this.props.groupId }) } { extraText }
); diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index a42032c9fe..4ed160d493 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import * as React from "react"; +import { useContext, useState } from "react"; import AutoHideScrollbar from './AutoHideScrollbar'; import { getHomePageUrl } from "../../utils/pages"; @@ -23,32 +24,103 @@ import SdkConfig from "../../SdkConfig"; import * as sdk from "../../index"; import dis from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; +import BaseAvatar from "../views/avatars/BaseAvatar"; +import { OwnProfileStore } from "../../stores/OwnProfileStore"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import { useEventEmitter } from "../../hooks/useEventEmitter"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import MiniAvatarUploader, { AVATAR_SIZE } from "../views/elements/MiniAvatarUploader"; +import Analytics from "../../Analytics"; +import CountlyAnalytics from "../../CountlyAnalytics"; -const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'}); -const onClickExplore = () => dis.fire(Action.ViewRoomDirectory); -const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'}); +const onClickSendDm = () => { + Analytics.trackEvent('home_page', 'button', 'dm'); + CountlyAnalytics.instance.track("home_page_button", { button: "dm" }); + dis.dispatch({ action: 'view_create_chat' }); +}; -const HomePage = () => { +const onClickExplore = () => { + Analytics.trackEvent('home_page', 'button', 'room_directory'); + CountlyAnalytics.instance.track("home_page_button", { button: "room_directory" }); + dis.fire(Action.ViewRoomDirectory); +}; + +const onClickNewRoom = () => { + Analytics.trackEvent('home_page', 'button', 'create_room'); + CountlyAnalytics.instance.track("home_page_button", { button: "create_room" }); + dis.dispatch({ action: 'view_create_room' }); +}; + +interface IProps { + justRegistered?: boolean; +} + +const getOwnProfile = (userId: string) => ({ + displayName: OwnProfileStore.instance.displayName || userId, + avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE), +}); + +const UserWelcomeTop = () => { + const cli = useContext(MatrixClientContext); + const userId = cli.getUserId(); + const [ownProfile, setOwnProfile] = useState(getOwnProfile(userId)); + useEventEmitter(OwnProfileStore.instance, UPDATE_EVENT, () => { + setOwnProfile(getOwnProfile(userId)); + }); + + return
+ cli.setAvatarUrl(url)} + > + + + +

{ _t("Welcome %(name)s", { name: ownProfile.displayName }) }

+

{ _t("Now, let's help you get started") }

+
; +}; + +const HomePage: React.FC = ({ justRegistered = false }) => { const config = SdkConfig.get(); const pageUrl = getHomePageUrl(config); if (pageUrl) { + // FIXME: Using an import will result in wrench-element-tests failures const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); return ; } - const brandingConfig = config.branding; - let logoUrl = "themes/element/img/logos/element-logo.svg"; - if (brandingConfig && brandingConfig.authHeaderLogoUrl) { - logoUrl = brandingConfig.authHeaderLogoUrl; + let introSection; + if (justRegistered) { + introSection = ; + } else { + const brandingConfig = config.branding; + let logoUrl = "themes/element/img/logos/element-logo.svg"; + if (brandingConfig && brandingConfig.authHeaderLogoUrl) { + logoUrl = brandingConfig.authHeaderLogoUrl; + } + + introSection = + {config.brand} +

{ _t("Welcome to %(appName)s", { appName: config.brand }) }

+

{ _t("Liberate your communication") }

+
; } - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); return
- {config.brand -

{ _t("Welcome to %(appName)s", { appName: config.brand || "Element" }) }

-

{ _t("Liberate your communication") }

+ { introSection }
{ _t("Send a Direct Message") } diff --git a/src/components/structures/HostSignupAction.tsx b/src/components/structures/HostSignupAction.tsx new file mode 100644 index 0000000000..41dc8a6da8 --- /dev/null +++ b/src/components/structures/HostSignupAction.tsx @@ -0,0 +1,61 @@ +/* +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 from "react"; +import { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from "../views/context_menus/IconizedContextMenu"; +import { _t } from "../../languageHandler"; +import { HostSignupStore } from "../../stores/HostSignupStore"; +import SdkConfig from "../../SdkConfig"; +import { replaceableComponent } from "../../utils/replaceableComponent"; + +interface IProps { + onClick?(): void; +} + +interface IState {} + +@replaceableComponent("structures.HostSignupAction") +export default class HostSignupAction extends React.PureComponent { + private openDialog = async () => { + this.props.onClick?.(); + await HostSignupStore.instance.setHostSignupActive(true); + }; + + public render(): React.ReactNode { + const hostSignupConfig = SdkConfig.get().hostSignup; + if (!hostSignupConfig?.brand) { + return null; + } + + return ( + + + + ); + } +} diff --git a/src/components/structures/IndicatorScrollbar.js b/src/components/structures/IndicatorScrollbar.js index cd5510de9d..3e1940955b 100644 --- a/src/components/structures/IndicatorScrollbar.js +++ b/src/components/structures/IndicatorScrollbar.js @@ -17,7 +17,9 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; import AutoHideScrollbar from "./AutoHideScrollbar"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +@replaceableComponent("structures.IndicatorScrollbar") export default class IndicatorScrollbar extends React.Component { static propTypes = { // If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator @@ -57,7 +59,9 @@ export default class IndicatorScrollbar extends React.Component { _collectScroller(scroller) { if (scroller && !this._scrollElement) { this._scrollElement = scroller; - this._scrollElement.addEventListener("scroll", this.checkOverflow); + // Using the passive option to not block the main thread + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners + this._scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true }); this.checkOverflow(); } } @@ -66,7 +70,6 @@ export default class IndicatorScrollbar extends React.Component { this._autoHideScrollbar = autoHideScrollbar; } - componentDidUpdate(prevProps) { const prevLen = prevProps && prevProps.children && prevProps.children.length || 0; const curLen = this.props.children && this.props.children.length || 0; @@ -181,21 +184,24 @@ export default class IndicatorScrollbar extends React.Component { }; render() { - const leftIndicatorStyle = {left: this.state.leftIndicatorOffset}; - const rightIndicatorStyle = {right: this.state.rightIndicatorOffset}; - const leftOverflowIndicator = this.props.trackHorizontalOverflow + // eslint-disable-next-line no-unused-vars + const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props; + + const leftIndicatorStyle = { left: this.state.leftIndicatorOffset }; + const rightIndicatorStyle = { right: this.state.rightIndicatorOffset }; + const leftOverflowIndicator = trackHorizontalOverflow ?
: null; - const rightOverflowIndicator = this.props.trackHorizontalOverflow + const rightOverflowIndicator = trackHorizontalOverflow ?
: null; return ( { leftOverflowIndicator } - { this.props.children } + { children } { rightOverflowIndicator } ); } diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index c8fcd7e9ca..61ae1882df 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -15,16 +15,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {InteractiveAuth} from "matrix-js-sdk"; -import React, {createRef} from 'react'; +import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth"; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents'; import * as sdk from '../../index'; +import { replaceableComponent } from "../../utils/replaceableComponent"; export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); +@replaceableComponent("structures.InteractiveAuthComponent") export default class InteractiveAuthComponent extends React.Component { static propTypes = { // matrix client to use for UI auth requests @@ -52,7 +54,7 @@ export default class InteractiveAuthComponent extends React.Component { // * emailSid {string} If email auth was performed, the sid of // the auth session. // * clientSecret {string} The client secret used in auth - // sessions with the ID server. + // sessions with the identity server. onAuthFinished: PropTypes.func.isRequired, // Inputs provided by the user to the auth process @@ -177,7 +179,14 @@ export default class InteractiveAuthComponent extends React.Component { stageState: stageState, errorText: stageState.error, }, () => { - if (oldStage != stageType) this._setFocus(); + if (oldStage !== stageType) { + this._setFocus(); + } else if ( + !stageState.error && this._stageComponent.current && + this._stageComponent.current.attemptFailed + ) { + this._stageComponent.current.attemptFailed(); + } }); }; diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 4445ff3ff8..3d5e386b00 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -16,12 +16,15 @@ limitations under the License. import * as React from "react"; import { createRef } from "react"; +import classNames from "classnames"; +import { Room } from "matrix-js-sdk/src/models/room"; + import GroupFilterPanel from "./GroupFilterPanel"; import CustomRoomTagPanel from "./CustomRoomTagPanel"; -import classNames from "classnames"; import dis from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import RoomList from "../views/rooms/RoomList"; +import CallHandler from "../../CallHandler"; import { HEADER_HEIGHT } from "../views/rooms/RoomSublist"; import { Action } from "../../dispatcher/actions"; import UserMenu from "./UserMenu"; @@ -32,13 +35,16 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore"; import ResizeNotifier from "../../utils/ResizeNotifier"; import SettingsStore from "../../settings/SettingsStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore"; -import {Key} from "../../Keyboard"; import IndicatorScrollbar from "../structures/IndicatorScrollbar"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; import RoomListNumResults from "../views/rooms/RoomListNumResults"; import LeftPanelWidget from "./LeftPanelWidget"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../customisations/Media"; +import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore"; +import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; +import UIStore from "../../stores/UIStore"; interface IProps { isMinimized: boolean; @@ -48,6 +54,7 @@ interface IProps { interface IState { showBreadcrumbs: boolean; showGroupFilterPanel: boolean; + activeSpace?: Room; } // List of CSS classes which should be included in keyboard navigation within the room list @@ -59,7 +66,9 @@ const cssClasses = [ "mx_RoomSublist_showNButton", ]; +@replaceableComponent("structures.LeftPanel") export default class LeftPanel extends React.Component { + private ref: React.RefObject = createRef(); private listContainerRef: React.RefObject = createRef(); private groupFilterPanelWatcherRef: string; private bgImageWatcherRef: string; @@ -72,20 +81,26 @@ export default class LeftPanel extends React.Component { this.state = { showBreadcrumbs: BreadcrumbsStore.instance.visible, showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), + activeSpace: SpaceStore.instance.activeSpace, }; BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate); + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); this.bgImageWatcherRef = SettingsStore.watchSetting( "RoomList.backgroundImage", null, this.onBackgroundImageUpdate); this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { - this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); + this.setState({ showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel") }); }); + } - // We watch the middle panel because we don't actually get resized, the middle panel does. - // We listen to the noisy channel to avoid choppy reaction times. - this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); + public componentDidMount() { + UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current); + UIStore.instance.on("ListContainer", this.refreshStickyHeaders); + // Using the passive option to not block the main thread + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners + this.listContainerRef.current?.addEventListener("scroll", this.onScroll, { passive: true }); } public componentWillUnmount() { @@ -94,17 +109,39 @@ export default class LeftPanel extends React.Component { BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate); - this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize); + SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + UIStore.instance.stopTrackingElementDimensions("ListContainer"); + UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders); + this.listContainerRef.current?.removeEventListener("scroll", this.onScroll); } + public componentDidUpdate(prevProps: IProps, prevState: IState): void { + if (prevState.activeSpace !== this.state.activeSpace) { + this.refreshStickyHeaders(); + } + } + + private updateActiveSpace = (activeSpace: Room) => { + this.setState({ activeSpace }); + }; + + private onDialPad = () => { + dis.fire(Action.OpenDialPad); + }; + private onExplore = () => { dis.fire(Action.ViewRoomDirectory); }; + private refreshStickyHeaders = () => { + if (!this.listContainerRef.current) return; // ignore: no headers to sticky + this.handleStickyHeaders(this.listContainerRef.current); + }; + private onBreadcrumbsUpdate = () => { const newVal = BreadcrumbsStore.instance.visible; if (newVal !== this.state.showBreadcrumbs) { - this.setState({showBreadcrumbs: newVal}); + this.setState({ showBreadcrumbs: newVal }); // Update the sticky headers too as the breadcrumbs will be popping in or out. if (!this.listContainerRef.current) return; // ignore: no headers to sticky @@ -118,7 +155,7 @@ export default class LeftPanel extends React.Component { let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage"); if (settingBgMxc) { - avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize); + avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize); } const avatarUrlProp = `url(${avatarUrl})`; @@ -141,10 +178,7 @@ export default class LeftPanel extends React.Component { private doStickyHeaders(list: HTMLDivElement) { const topEdge = list.scrollTop; const bottomEdge = list.offsetHeight + list.scrollTop; - const sublists = list.querySelectorAll(".mx_RoomSublist"); - - const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles - const headerStickyWidth = list.clientWidth - headerRightMargin; + const sublists = list.querySelectorAll(".mx_RoomSublist:not(.mx_RoomSublist_hidden)"); // We track which styles we want on a target before making the changes to avoid // excessive layout updates. @@ -215,7 +249,8 @@ export default class LeftPanel extends React.Component { header.classList.add("mx_RoomSublist_headerContainer_stickyBottom"); } - const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight); + const offset = UIStore.instance.windowHeight - + (list.parentElement.offsetTop + list.parentElement.offsetHeight); const newBottom = `${offset}px`; if (header.style.bottom !== newBottom) { header.style.bottom = newBottom; @@ -234,14 +269,20 @@ export default class LeftPanel extends React.Component { header.classList.add("mx_RoomSublist_headerContainer_sticky"); } - const newWidth = `${headerStickyWidth}px`; - if (header.style.width !== newWidth) { - header.style.width = newWidth; + const listDimensions = UIStore.instance.getElementDimensions("ListContainer"); + if (listDimensions) { + const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles + const headerStickyWidth = listDimensions.width - headerRightMargin; + const newWidth = `${headerStickyWidth}px`; + if (header.style.width !== newWidth) { + header.style.width = newWidth; + } } } else if (!style.stickyTop && !style.stickyBottom) { if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) { header.classList.remove("mx_RoomSublist_headerContainer_sticky"); } + if (header.style.width) { header.style.removeProperty('width'); } @@ -263,16 +304,11 @@ export default class LeftPanel extends React.Component { } } - private onScroll = (ev: React.MouseEvent) => { + private onScroll = (ev: Event) => { const list = ev.target as HTMLDivElement; this.handleStickyHeaders(list); }; - private onResize = () => { - if (!this.listContainerRef.current) return; // ignore: no headers to sticky - this.handleStickyHeaders(this.listContainerRef.current); - }; - private onFocus = (ev: React.FocusEvent) => { this.focusedElement = ev.target; }; @@ -284,17 +320,18 @@ export default class LeftPanel extends React.Component { private onKeyDown = (ev: React.KeyboardEvent) => { if (!this.focusedElement) return; - switch (ev.key) { - case Key.ARROW_UP: - case Key.ARROW_DOWN: + const action = getKeyBindingsManager().getRoomListAction(ev); + switch (action) { + case RoomListAction.NextRoom: + case RoomListAction.PrevRoom: ev.stopPropagation(); ev.preventDefault(); - this.onMoveFocus(ev.key === Key.ARROW_UP); + this.onMoveFocus(action === RoomListAction.PrevRoom); break; } }; - private onEnter = () => { + private selectRoom = () => { const firstRoom = this.listContainerRef.current.querySelector(".mx_RoomTile"); if (firstRoom) { firstRoom.click(); @@ -333,7 +370,7 @@ export default class LeftPanel extends React.Component { if (element) { classes = element.classList; } - } while (element && !cssClasses.some(c => classes.contains(c))); + } while (element && (!cssClasses.some(c => classes.contains(c)) || element.offsetParent === null)); if (element) { element.focus(); @@ -365,7 +402,20 @@ export default class LeftPanel extends React.Component { } } - private renderSearchExplore(): React.ReactNode { + private renderSearchDialExplore(): React.ReactNode { + let dialPadButton = null; + + // If we have dialer support, show a button to bring up the dial pad + // to start a new call + if (CallHandler.sharedInstance().getSupportsPstnProtocol()) { + dialPadButton = + ; + } + return (
{ > + + {dialPadButton} + @@ -388,25 +443,29 @@ export default class LeftPanel extends React.Component { } public render(): React.ReactNode { - const groupFilterPanel = !this.state.showGroupFilterPanel ? null : ( -
- - {SettingsStore.getValue("feature_custom_tags") ? : null} -
- ); + let leftLeftPanel; + if (this.state.showGroupFilterPanel) { + leftLeftPanel = ( +
+ + {SettingsStore.getValue("feature_custom_tags") ? : null} +
+ ); + } const roomList = ; const containerClasses = classNames({ "mx_LeftPanel": true, - "mx_LeftPanel_hasGroupFilterPanel": !!groupFilterPanel, "mx_LeftPanel_minimized": this.props.isMinimized, }); @@ -416,17 +475,16 @@ export default class LeftPanel extends React.Component { ); return ( -
- {groupFilterPanel} +
+ {leftLeftPanel}
); diff --git a/src/components/structures/LeftPanelWidget.tsx b/src/components/structures/LeftPanelWidget.tsx index 4daec76d08..e0b597b883 100644 --- a/src/components/structures/LeftPanelWidget.tsx +++ b/src/components/structures/LeftPanelWidget.tsx @@ -14,29 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useContext, useEffect, useMemo} from "react"; -import {Resizable} from "re-resizable"; +import React, { useContext, useMemo } from "react"; +import { Resizable } from "re-resizable"; import classNames from "classnames"; import AccessibleButton from "../views/elements/AccessibleButton"; -import {useRovingTabIndex} from "../../accessibility/RovingTabIndex"; -import {Key} from "../../Keyboard"; -import {useLocalStorageState} from "../../hooks/useLocalStorageState"; +import { useRovingTabIndex } from "../../accessibility/RovingTabIndex"; +import { Key } from "../../Keyboard"; +import { useLocalStorageState } from "../../hooks/useLocalStorageState"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils"; -import {useAccountData} from "../../hooks/useAccountData"; +import WidgetUtils, { IWidgetEvent } from "../../utils/WidgetUtils"; +import { useAccountData } from "../../hooks/useAccountData"; import AppTile from "../views/elements/AppTile"; -import {useSettingValue} from "../../hooks/useSettings"; - -interface IProps { - onResize(): void; -} +import { useSettingValue } from "../../hooks/useSettings"; +import UIStore from "../../stores/UIStore"; const MIN_HEIGHT = 100; const MAX_HEIGHT = 500; // or 50% of the window height const INITIAL_HEIGHT = 280; -const LeftPanelWidget: React.FC = ({ onResize }) => { +const LeftPanelWidget: React.FC = () => { const cli = useContext(MatrixClientContext); const mWidgetsEvent = useAccountData>(cli, "m.widgets"); @@ -56,7 +53,6 @@ const LeftPanelWidget: React.FC = ({ onResize }) => { const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT); const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true); - useEffect(onResize, [expanded]); const [onFocus, isActive, ref] = useRovingTabIndex(); const tabIndex = isActive ? 0 : -1; @@ -66,15 +62,14 @@ const LeftPanelWidget: React.FC = ({ onResize }) => { let content; if (expanded) { content = { setHeight(height + d.height); }} handleWrapperClass="mx_LeftPanelWidget_resizerHandles" - handleClasses={{top: "mx_LeftPanelWidget_resizerHandle"}} + handleClasses={{ top: "mx_LeftPanelWidget_resizerHandle" }} className="mx_LeftPanelWidget_resizeBox" enable={{ top: true }} > diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 03277a84f9..6c086ed17c 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -19,21 +19,17 @@ limitations under the License. import * as React from 'react'; import * as PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; -import { DragDropContext } from 'react-beautiful-dnd'; -import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard'; +import { Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; -import CallMediaHandler from '../../CallMediaHandler'; +import MediaDeviceHandler from '../../MediaDeviceHandler'; import { fixupColorFonts } from '../../utils/FontManager'; -import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; -import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg'; +import { IMatrixClientCreds } from '../../MatrixClientPeg'; import SettingsStore from "../../settings/SettingsStore"; -import TagOrderActions from '../../actions/TagOrderActions'; -import RoomListActions from '../../actions/RoomListActions'; import ResizeHandle from '../views/elements/ResizeHandle'; -import {Resizer, CollapseDistributor} from '../../resizer'; +import { Resizer, CollapseDistributor } from '../../resizer'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts"; import HomePage from "./HomePage"; @@ -51,8 +47,23 @@ import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPay import RoomListStore from "../../stores/room-list/RoomListStore"; import NonUrgentToastContainer from "./NonUrgentToastContainer"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; -import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import Modal from "../../Modal"; import { ICollapseConfig } from "../../resizer/distributors/collapse"; +import HostSignupContainer from '../views/host_signup/HostSignupContainer'; +import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBindingsManager'; +import { IOpts } from "../../createRoom"; +import SpacePanel from "../views/spaces/SpacePanel"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import CallHandler, { CallHandlerEvent } from '../../CallHandler'; +import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; +import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall'; +import RoomView from './RoomView'; +import ToastContainer from './ToastContainer'; +import MyGroups from "./MyGroups"; +import UserView from "./UserView"; +import GroupView from "./GroupView"; +import SpaceStore from "../../stores/SpaceStore"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -69,30 +80,33 @@ function canElementReceiveInput(el) { interface IProps { matrixClient: MatrixClient; onRegistered: (credentials: IMatrixClientCreds) => Promise; - viaServers?: string[]; hideToSRUsers: boolean; resizeNotifier: ResizeNotifier; // eslint-disable-next-line camelcase - page_type: string; - autoJoin: boolean; + page_type?: string; + autoJoin?: boolean; threepidInvite?: IThreepidInvite; - roomOobData?: object; + roomOobData?: IOOBData; currentRoomId: string; collapseLhs: boolean; config: { piwik: { policyUrl: string; - }, - [key: string]: any, + }; + [key: string]: any; }; currentUserId?: string; currentGroupId?: string; currentGroupIsNew?: boolean; + justRegistered?: boolean; + roomJustCreatedOpts?: IOpts; } interface IUsageLimit { + // "hs_disabled" is NOT a specced string, but is used in Synapse + // This is tracked over at https://github.com/matrix-org/synapse/issues/9237 // eslint-disable-next-line camelcase - limit_type: "monthly_active_user" | string; + limit_type: "monthly_active_user" | "hs_disabled" | string; // eslint-disable-next-line camelcase admin_contact?: string; } @@ -100,12 +114,17 @@ interface IUsageLimit { interface IState { syncErrorData?: { error: { + // This is not specced, but used in Synapse. See + // https://github.com/matrix-org/synapse/issues/9237#issuecomment-768238922 data: IUsageLimit; errcode: string; }; }; + usageLimitDismissed: boolean; usageLimitEventContent?: IUsageLimit; + usageLimitEventTs?: number; useCompactLayout: boolean; + activeCalls: Array; } /** @@ -117,6 +136,7 @@ interface IState { * * Components mounted below us can access the matrix client via the react context. */ +@replaceableComponent("structures.LoggedInView") class LoggedInView extends React.Component { static displayName = 'LoggedInView'; @@ -129,16 +149,13 @@ class LoggedInView extends React.Component { // transitioned to PWLU) onRegistered: PropTypes.func, - // Used by the RoomView to handle joining rooms - viaServers: PropTypes.arrayOf(PropTypes.string), - // and lots and lots of other stuff. }; protected readonly _matrixClient: MatrixClient; protected readonly _roomView: React.RefObject; protected readonly _resizeContainer: React.RefObject; - protected readonly _compactLayoutWatcherRef: string; + protected compactLayoutWatcherRef: string; protected resizer: Resizer; constructor(props, context) { @@ -148,24 +165,14 @@ class LoggedInView extends React.Component { syncErrorData: undefined, // use compact timeline view useCompactLayout: SettingsStore.getValue('useCompactLayout'), + usageLimitDismissed: false, + activeCalls: [], }; // stash the MatrixClient in case we log out before we are unmounted this._matrixClient = this.props.matrixClient; - CallMediaHandler.loadDevices(); - - document.addEventListener('keydown', this._onNativeKeyDown, false); - - this._updateServerNoticeEvents(); - - this._matrixClient.on("accountData", this.onAccountData); - this._matrixClient.on("sync", this.onSync); - this._matrixClient.on("RoomState.events", this.onRoomStateEvents); - - this._compactLayoutWatcherRef = SettingsStore.watchSetting( - "useCompactLayout", null, this.onCompactLayoutChanged, - ); + MediaDeviceHandler.loadDevices(); fixupColorFonts(); @@ -174,6 +181,25 @@ class LoggedInView extends React.Component { } componentDidMount() { + document.addEventListener('keydown', this._onNativeKeyDown, false); + CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged); + + this._updateServerNoticeEvents(); + + this._matrixClient.on("accountData", this.onAccountData); + this._matrixClient.on("sync", this.onSync); + // Call `onSync` with the current state as well + this.onSync( + this._matrixClient.getSyncState(), + null, + this._matrixClient.getSyncStateData(), + ); + this._matrixClient.on("RoomState.events", this.onRoomStateEvents); + + this.compactLayoutWatcherRef = SettingsStore.watchSetting( + "useCompactLayout", null, this.onCompactLayoutChanged, + ); + this.resizer = this._createResizer(); this.resizer.attach(); this._loadResizerPreferences(); @@ -181,22 +207,19 @@ class LoggedInView extends React.Component { componentWillUnmount() { document.removeEventListener('keydown', this._onNativeKeyDown, false); + CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged); this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); - SettingsStore.unwatchSetting(this._compactLayoutWatcherRef); + SettingsStore.unwatchSetting(this.compactLayoutWatcherRef); this.resizer.detach(); } - // Child components assume that the client peg will not be null, so give them some - // sort of assurance here by only allowing a re-render if the client is truthy. - // - // This is required because `LoggedInView` maintains its own state and if this state - // updates after the client peg has been made null (during logout), then it will - // attempt to re-render and the children will throw errors. - shouldComponentUpdate() { - return Boolean(MatrixClientPeg.get()); - } + private onCallsChanged = () => { + this.setState({ + activeCalls: CallHandler.sharedInstance().getAllActiveCalls(), + }); + }; canResetTimelineInRoom = (roomId) => { if (!this._roomView.current) { @@ -207,14 +230,17 @@ class LoggedInView extends React.Component { _createResizer() { let size; + let collapsed; const collapseConfig: ICollapseConfig = { - toggleSize: 260 - 50, - onCollapsed: (collapsed) => { - if (collapsed) { - dis.dispatch({action: "hide_left_panel"}, true); + // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel + toggleSize: 206 - 50, + onCollapsed: (_collapsed) => { + collapsed = _collapsed; + if (_collapsed) { + dis.dispatch({ action: "hide_left_panel" }); window.localStorage.setItem("mx_lhs_size", '0'); } else { - dis.dispatch({action: "show_left_panel"}, true); + dis.dispatch({ action: "show_left_panel" }); } }, onResized: (_size) => { @@ -225,9 +251,12 @@ class LoggedInView extends React.Component { this.props.resizeNotifier.startResizing(); }, onResizeStop: () => { - window.localStorage.setItem("mx_lhs_size", '' + size); + if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size); this.props.resizeNotifier.stopResizing(); }, + isItemCollapsed: domNode => { + return domNode.classList.contains("mx_LeftPanel_minimized"); + }, }; const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig); resizer.setClassNames({ @@ -248,7 +277,7 @@ class LoggedInView extends React.Component { onAccountData = (event) => { if (event.getType() === "m.ignored_user_list") { - dis.dispatch({action: "ignore_state_changed"}); + dis.dispatch({ action: "ignore_state_changed" }); } }; @@ -291,14 +320,27 @@ class LoggedInView extends React.Component { } }; + private onUsageLimitDismissed = () => { + this.setState({ + usageLimitDismissed: true, + }); + }; + _calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) { const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; if (error) { usageLimitEventContent = syncError.error.data; } - if (usageLimitEventContent) { - showServerLimitToast(usageLimitEventContent.limit_type, usageLimitEventContent.admin_contact, error); + // usageLimitDismissed is true when the user has explicitly hidden the toast + // and it will be reset to false if a *new* usage alert comes in. + if (usageLimitEventContent && this.state.usageLimitDismissed) { + showServerLimitToast( + usageLimitEventContent.limit_type, + this.onUsageLimitDismissed, + usageLimitEventContent.admin_contact, + error, + ); } else { hideServerLimitToast(); } @@ -309,19 +351,26 @@ class LoggedInView extends React.Component { if (!serverNoticeList) return []; const events = []; + let pinnedEventTs = 0; for (const room of serverNoticeList) { const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", ""); if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue; + pinnedEventTs = pinStateEvent.getTs(); const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM); for (const eventId of pinnedEventIds) { - const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0); + const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId); const event = timeline.getEvents().find(ev => ev.getId() === eventId); if (event) events.push(event); } } + if (pinnedEventTs && this.state.usageLimitEventTs > pinnedEventTs) { + // We've processed a newer event than this one, so ignore it. + return; + } + const usageLimitEvent = events.find((e) => { return ( e && e.getType() === 'm.room.message' && @@ -330,7 +379,12 @@ class LoggedInView extends React.Component { }); const usageLimitEventContent = usageLimitEvent && usageLimitEvent.getContent(); this._calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent); - this.setState({ usageLimitEventContent }); + this.setState({ + usageLimitEventContent, + usageLimitEventTs: pinnedEventTs, + // This is a fresh toast, we can show toasts again + usageLimitDismissed: false, + }); }; _onPaste = (ev) => { @@ -345,7 +399,7 @@ class LoggedInView extends React.Component { // refocusing during a paste event will make the // paste end up in the newly focused element, // so dispatch synchronously before paste happens - dis.fire(Action.FocusComposer, true); + dis.fire(Action.FocusSendMessageComposer, true); } }; @@ -388,67 +442,55 @@ class LoggedInView extends React.Component { _onKeyDown = (ev) => { let handled = false; - const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); - const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey; - const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT; - switch (ev.key) { - case Key.PAGE_UP: - case Key.PAGE_DOWN: - if (!hasModifier && !isModifier) { - this._onScrollKeyPressed(ev); - handled = true; - } + const roomAction = getKeyBindingsManager().getRoomAction(ev); + switch (roomAction) { + case RoomAction.ScrollUp: + case RoomAction.RoomScrollDown: + case RoomAction.JumpToFirstMessage: + case RoomAction.JumpToLatestMessage: + // pass the event down to the scroll panel + this._onScrollKeyPressed(ev); + handled = true; break; + case RoomAction.FocusSearch: + dis.dispatch({ + action: 'focus_search', + }); + handled = true; + break; + } + if (handled) { + ev.stopPropagation(); + ev.preventDefault(); + return; + } - case Key.HOME: - case Key.END: - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this._onScrollKeyPressed(ev); - handled = true; - } + const navAction = getKeyBindingsManager().getNavigationAction(ev); + switch (navAction) { + case NavigationAction.FocusRoomSearch: + dis.dispatch({ + action: 'focus_room_filter', + }); + handled = true; break; - case Key.K: - if (ctrlCmdOnly) { - dis.dispatch({ - action: 'focus_room_filter', - }); - handled = true; - } + case NavigationAction.ToggleUserMenu: + dis.fire(Action.ToggleUserMenu); + handled = true; break; - case Key.BACKTICK: - // Ideally this would be CTRL+P for "Profile", but that's - // taken by the print dialog. CTRL+I for "Information" - // was previously chosen but conflicted with italics in - // composer, so CTRL+` it is - - if (ctrlCmdOnly) { - dis.fire(Action.ToggleUserMenu); - handled = true; - } + case NavigationAction.ToggleShortCutDialog: + KeyboardShortcuts.toggleDialog(); + handled = true; break; - - case Key.SLASH: - if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev)) { - KeyboardShortcuts.toggleDialog(); - handled = true; - } + case NavigationAction.GoToHome: + dis.dispatch({ + action: 'view_home_page', + }); + Modal.closeCurrentModal("homeKeyboardShortcut"); + handled = true; break; - - case Key.ARROW_UP: - case Key.ARROW_DOWN: - if (ev.altKey && !ev.ctrlKey && !ev.metaKey) { - dis.dispatch({ - action: Action.ViewRoomDelta, - delta: ev.key === Key.ARROW_UP ? -1 : 1, - unread: ev.shiftKey, - }); - handled = true; - } - break; - - case Key.PERIOD: - if (ctrlCmdOnly && (this.props.page_type === "room_view" || this.props.page_type === "group_view")) { + case NavigationAction.ToggleRoomSidePanel: + if (this.props.page_type === "room_view" || this.props.page_type === "group_view") { dis.dispatch({ action: Action.ToggleRightPanel, type: this.props.page_type === "room_view" ? "room" : "group", @@ -456,16 +498,48 @@ class LoggedInView extends React.Component { handled = true; } break; - + case NavigationAction.SelectPrevRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: -1, + unread: false, + }); + handled = true; + break; + case NavigationAction.SelectNextRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: 1, + unread: false, + }); + handled = true; + break; + case NavigationAction.SelectPrevUnreadRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: -1, + unread: true, + }); + break; + case NavigationAction.SelectNextUnreadRoom: + dis.dispatch({ + action: Action.ViewRoomDelta, + delta: 1, + unread: true, + }); + break; default: // if we do not have a handler for it, pass it to the platform which might handled = PlatformPeg.get().onKeyDown(ev); } - if (handled) { ev.stopPropagation(); ev.preventDefault(); - } else if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { + return; + } + + const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT; + if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { // The above condition is crafted to _allow_ characters with Shift // already pressed (but not the Shift key down itself). @@ -479,7 +553,7 @@ class LoggedInView extends React.Component { if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) { // synchronous dispatch so we focus before key generates input - dis.fire(Action.FocusComposer, true); + dis.fire(Action.FocusSendMessageComposer, true); ev.stopPropagation(); // we should *not* preventDefault() here as // that would prevent typing in the now-focussed composer @@ -497,70 +571,19 @@ class LoggedInView extends React.Component { } }; - _onDragEnd = (result) => { - // Dragged to an invalid destination, not onto a droppable - if (!result.destination) { - return; - } - - const dest = result.destination.droppableId; - - if (dest === 'tag-panel-droppable') { - // Could be "GroupTile +groupId:domain" - const draggableId = result.draggableId.split(' ').pop(); - - // Dispatch synchronously so that the GroupFilterPanel receives an - // optimistic update from GroupFilterOrderStore before the previous - // state is shown. - dis.dispatch(TagOrderActions.moveTag( - this._matrixClient, - draggableId, - result.destination.index, - ), true); - } else if (dest.startsWith('room-sub-list-droppable_')) { - this._onRoomTileEndDrag(result); - } - }; - - _onRoomTileEndDrag = (result) => { - let newTag = result.destination.droppableId.split('_')[1]; - let prevTag = result.source.droppableId.split('_')[1]; - if (newTag === 'undefined') newTag = undefined; - if (prevTag === 'undefined') prevTag = undefined; - - const roomId = result.draggableId.split('_')[1]; - - const oldIndex = result.source.index; - const newIndex = result.destination.index; - - dis.dispatch(RoomListActions.tagRoom( - this._matrixClient, - this._matrixClient.getRoom(roomId), - prevTag, newTag, - oldIndex, newIndex, - ), true); - }; - render() { - const RoomView = sdk.getComponent('structures.RoomView'); - const UserView = sdk.getComponent('structures.UserView'); - const GroupView = sdk.getComponent('structures.GroupView'); - const MyGroups = sdk.getComponent('structures.MyGroups'); - const ToastContainer = sdk.getComponent('structures.ToastContainer'); - let pageElement; switch (this.props.page_type) { case PageTypes.RoomView: pageElement = ; break; @@ -573,7 +596,7 @@ class LoggedInView extends React.Component { break; case PageTypes.HomePage: - pageElement = ; + pageElement = ; break; case PageTypes.UserView: @@ -593,12 +616,11 @@ class LoggedInView extends React.Component { bodyClasses += ' mx_MatrixChat_useCompactLayout'; } - const leftPanel = ( - - ); + const audioFeedArraysForCalls = this.state.activeCalls.map((call) => { + return ( + + ); + }); return ( @@ -609,16 +631,20 @@ class LoggedInView extends React.Component { aria-hidden={this.props.hideToSRUsers} > - -
- { leftPanel } - - { pageElement } -
-
+
+ { SpaceStore.spacesEnabled ? : null } + + + { pageElement } +
+ + {audioFeedArraysForCalls} ); } diff --git a/src/components/structures/MainSplit.js b/src/components/structures/MainSplit.js index 47dfe83ad6..69d3bd0b51 100644 --- a/src/components/structures/MainSplit.js +++ b/src/components/structures/MainSplit.js @@ -17,7 +17,9 @@ limitations under the License. import React from 'react'; import { Resizable } from 're-resizable'; +import { replaceableComponent } from "../../utils/replaceableComponent"; +@replaceableComponent("structures.MainSplit") export default class MainSplit extends React.Component { _onResizeStart = () => { this.props.resizeNotifier.startResizing(); @@ -71,7 +73,7 @@ export default class MainSplit extends React.Component { onResize={this._onResize} onResizeStop={this._onResizeStop} className="mx_RightPanel_ResizeWrapper" - handleClasses={{left: "mx_RightPanel_ResizeHandle"}} + handleClasses={{ left: "mx_RightPanel_ResizeHandle" }} > { panelView } ; diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 3a4b74762e..15536f260d 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2017-2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-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. @@ -18,11 +15,12 @@ limitations under the License. */ import React, { createRef } from 'react'; -// @ts-ignore - XXX: no idea why this import fails -import * as Matrix from "matrix-js-sdk"; +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"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { sleep, defer, IDeferred } from "matrix-js-sdk/src/utils"; + // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss import 'focus-visible'; // what-input helps improve keyboard accessibility @@ -34,13 +32,10 @@ import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg"; import PlatformPeg from "../../PlatformPeg"; import SdkConfig from "../../SdkConfig"; -import * as RoomListSorter from "../../RoomListSorter"; import dis from "../../dispatcher/dispatcher"; import Notifier from '../../Notifier'; import Modal from "../../Modal"; -import Tinter from "../../Tinter"; -import * as sdk from '../../index'; import { showRoomInviteDialog, showStartChatInviteDialog } from '../../RoomInvite'; import * as Rooms from '../../Rooms'; import linkifyMatrix from "../../linkify-matrix"; @@ -48,13 +43,12 @@ import * as Lifecycle from '../../Lifecycle'; // LifecycleStore is not used but does listen to and dispatch actions import '../../stores/LifecycleStore'; import PageTypes from '../../PageTypes'; -import { getHomePageUrl } from '../../utils/pages'; -import createRoom from "../../createRoom"; -import {_t, _td, getCurrentLanguage} from '../../languageHandler'; +import createRoom, { IOpts } from "../../createRoom"; +import { _t, _td, getCurrentLanguage } from '../../languageHandler'; import SettingsStore from "../../settings/SettingsStore"; import ThemeController from "../../settings/controllers/ThemeController"; -import { startAnyRegistrationFlow } from "../../Registration.js"; +import { startAnyRegistrationFlow } from "../../Registration"; import { messageForSyncError } from '../../utils/ErrorUtils'; import ResizeNotifier from "../../utils/ResizeNotifier"; import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils"; @@ -62,7 +56,6 @@ import DMRoomMap from '../../utils/DMRoomMap'; import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import { FontWatcher } from '../../settings/watchers/FontWatcher'; import { storeRoomAliasInCache } from '../../RoomAliasCache'; -import { defer, IDeferred } from "../../utils/promise"; import ToastStore from "../../stores/ToastStore"; import * as StorageManager from "../../utils/StorageManager"; import type LoggedInViewType from "./LoggedInView"; @@ -72,7 +65,7 @@ import { showToast as showAnalyticsToast, hideToast as hideAnalyticsToast, } from "../../toasts/AnalyticsToast"; -import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast"; +import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import ErrorDialog from "../views/dialogs/ErrorDialog"; import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; @@ -80,45 +73,76 @@ import { SettingLevel } from "../../settings/SettingLevel"; import { leaveRoomBehaviour } from "../../utils/membership"; import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog"; import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore"; -import {UIFeature} from "../../settings/UIFeature"; +import { UIFeature } from "../../settings/UIFeature"; import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; +import DialPadModal from "../views/voip/DialPadModal"; +import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast'; +import { shouldUseLoginForWelcome } from "../../utils/pages"; +import SpaceStore from "../../stores/SpaceStore"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import RoomListStore from "../../stores/room-list/RoomListStore"; +import { RoomUpdateCause } from "../../stores/room-list/models"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import SecurityCustomisations from "../../customisations/Security"; +import Spinner from "../views/elements/Spinner"; +import QuestionDialog from "../views/dialogs/QuestionDialog"; +import UserSettingsDialog from '../views/dialogs/UserSettingsDialog'; +import CreateGroupDialog from '../views/dialogs/CreateGroupDialog'; +import CreateRoomDialog from '../views/dialogs/CreateRoomDialog'; +import RoomDirectory from './RoomDirectory'; +import KeySignatureUploadFailedDialog from "../views/dialogs/KeySignatureUploadFailedDialog"; +import IncomingSasDialog from "../views/dialogs/IncomingSasDialog"; +import CompleteSecurity from "./auth/CompleteSecurity"; +import LoggedInView from './LoggedInView'; +import Welcome from "../views/auth/Welcome"; +import ForgotPassword from "./auth/ForgotPassword"; +import E2eSetup from "./auth/E2eSetup"; +import Registration from './auth/Registration'; +import Login from "./auth/Login"; +import ErrorBoundary from '../views/elements/ErrorBoundary'; +import VerificationRequestToast from '../views/toasts/VerificationRequestToast'; + +import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; +import UIStore, { UI_EVENTS } from "../../stores/UIStore"; +import SoftLogout from './auth/SoftLogout'; +import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; +import { copyPlaintext } from "../../utils/strings"; /** constants for MatrixChat.state.view */ export enum Views { // a special initial state which is only used at startup, while we are // trying to re-animate a matrix client or register as a guest. - LOADING = 0, + LOADING, // we are showing the welcome view - WELCOME = 1, + WELCOME, // we are showing the login view - LOGIN = 2, + LOGIN, // we are showing the registration view - REGISTER = 3, - - // completing the registration flow - POST_REGISTRATION = 4, + REGISTER, // showing the 'forgot password' view - FORGOT_PASSWORD = 5, + FORGOT_PASSWORD, // showing flow to trust this new device with cross-signing - COMPLETE_SECURITY = 6, + COMPLETE_SECURITY, // flow to setup SSSS / cross-signing on this account - E2E_SETUP = 7, + E2E_SETUP, // we are logged in with an active matrix client. The logged_in state also // includes guests users as they too are logged in at the client level. - LOGGED_IN = 8, + LOGGED_IN, // We are logged out (invalid token) but have our local state again. The user // should log back in to rehydrate the client. - SOFT_LOGOUT = 9, + SOFT_LOGOUT, } +const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"]; + // Actions that are redirected through the onboarding process prior to being // re-dispatched. NOTE: some actions are non-trivial and would require // re-factoring to be included in this list in future. @@ -145,11 +169,18 @@ interface IRoomInfo { oob_data?: object; via_servers?: string[]; threepid_invite?: IThreepidInvite; + + justCreatedOpts?: IOpts; } /* eslint-enable camelcase */ interface IProps { // TODO type things better - config: Record; + config: { + piwik: { + policyUrl: string; + }; + [key: string]: any; + }; serverConfig?: ValidatedServerConfig; onNewScreen: (screen: string, replaceLast: boolean) => void; enableGuest?: boolean; @@ -197,12 +228,14 @@ interface IState { resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; ready: boolean; - threepidInvite?: IThreepidInvite, + threepidInvite?: IThreepidInvite; roomOobData?: object; - viaServers?: string[]; pendingInitialSync?: boolean; + justRegistered?: boolean; + roomJustCreatedOpts?: IOpts; } +@replaceableComponent("structures.MatrixChat") export default class MatrixChat extends React.PureComponent { static displayName = "MatrixChat"; @@ -217,12 +250,13 @@ export default class MatrixChat extends React.PureComponent { firstSyncPromise: IDeferred; private screenAfterLogin?: IScreen; - private windowWidth: number; private pageChanging: boolean; + private tokenLogin?: boolean; private accountPassword?: string; - private accountPasswordTimer?: NodeJS.Timeout; + private accountPasswordTimer?: number; private focusComposer: boolean; private subTitleStatus: string; + private prevWindowWidth: number; private readonly loggedInView: React.RefObject; private readonly dispatcherRef: any; @@ -268,17 +302,11 @@ export default class MatrixChat extends React.PureComponent { } } - this.windowWidth = 10000; - this.handleResize(); - window.addEventListener('resize', this.handleResize); + this.prevWindowWidth = UIStore.instance.windowWidth || 1000; + UIStore.instance.on(UI_EVENTS.Resize, this.handleResize); this.pageChanging = false; - // check we have the right tint applied for this theme. - // N.B. we don't call the whole of setTheme() here as we may be - // racing with the theme CSS download finishing from index.js - Tinter.tint(); - // For PersistentElement this.state.resizeNotifier.on("middlePanelResized", this.dispatchTimelineResize); @@ -324,13 +352,21 @@ export default class MatrixChat extends React.PureComponent { Lifecycle.attemptTokenLogin( this.props.realQueryParams, this.props.defaultDeviceDisplayName, - ).then((loggedIn) => { - if (loggedIn) { + this.getFragmentAfterLogin(), + ).then(async (loggedIn) => { + if (this.props.realQueryParams?.loginToken) { + // remove the loginToken from the URL regardless this.props.onTokenLoginCompleted(); + } - // don't do anything else until the page reloads - just stay in - // the 'loading' state. - return; + if (loggedIn) { + this.tokenLogin = true; + + // Create and start the client + await Lifecycle.restoreFromLocalStorage({ + ignoreGuest: true, + }); + return this.postLoginSetup(); } // if the user has followed a login or register link, don't reanimate @@ -354,6 +390,46 @@ export default class MatrixChat extends React.PureComponent { CountlyAnalytics.instance.enable(/* anonymous = */ true); } + private async postLoginSetup() { + const cli = MatrixClientPeg.get(); + const cryptoEnabled = cli.isCryptoEnabled(); + if (!cryptoEnabled) { + this.onLoggedIn(); + } + + const promisesList: Promise[] = [this.firstSyncPromise.promise]; + if (cryptoEnabled) { + // wait for the client to finish downloading cross-signing keys for us so we + // know whether or not we have keys set up on this account + promisesList.push(cli.downloadKeys([cli.getUserId()])); + } + + // Now update the state to say we're waiting for the first sync to complete rather + // than for the login to finish. + this.setState({ pendingInitialSync: true }); + + await Promise.all(promisesList); + + if (!cryptoEnabled) { + this.setState({ pendingInitialSync: false }); + return; + } + + const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId()); + if (crossSigningIsSetUp) { + if (SecurityCustomisations.SHOW_ENCRYPTION_SETUP_UI === false) { + this.onLoggedIn(); + } else { + this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); + } + } else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) { + this.setStateForNewView({ view: Views.E2E_SETUP }); + } else { + this.onLoggedIn(); + } + this.setState({ pendingInitialSync: false }); + } + // TODO: [REACT-WARNING] Replace with appropriate lifecycle stage // eslint-disable-next-line camelcase UNSAFE_componentWillUpdate(props, state) { @@ -369,7 +445,7 @@ export default class MatrixChat extends React.PureComponent { CountlyAnalytics.instance.trackPageChange(durationMs); } if (this.focusComposer) { - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); this.focusComposer = false; } } @@ -379,7 +455,7 @@ export default class MatrixChat extends React.PureComponent { dis.unregister(this.dispatcherRef); this.themeWatcher.stop(); this.fontWatcher.stop(); - window.removeEventListener('resize', this.handleResize); + UIStore.destroy(); this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize); if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer); @@ -397,7 +473,7 @@ export default class MatrixChat extends React.PureComponent { let props = this.state.serverConfig; if (!props) props = this.props.serverConfig; // for unit tests if (!props) props = SdkConfig.get()["validated_server_config"]; - return {serverConfig: props}; + return { serverConfig: props }; } private loadSession() { @@ -415,9 +491,9 @@ export default class MatrixChat extends React.PureComponent { if (!loadedSession) { // fall back to showing the welcome screen... unless we have a 3pid invite pending if (ThreepidInviteStore.instance.pickBestInvite()) { - dis.dispatch({action: 'start_registration'}); + dis.dispatch({ action: 'start_registration' }); } else { - dis.dispatch({action: "view_welcome_page"}); + dis.dispatch({ action: "view_welcome_page" }); } } else if (SettingsStore.getValue("analyticsOptIn")) { CountlyAnalytics.instance.enable(/* anonymous = */ false); @@ -429,42 +505,22 @@ export default class MatrixChat extends React.PureComponent { } startPageChangeTimer() { - // Tor doesn't support performance - if (!performance || !performance.mark) return null; - - // This shouldn't happen because UNSAFE_componentWillUpdate and componentDidUpdate - // are used. - if (this.pageChanging) { - console.warn('MatrixChat.startPageChangeTimer: timer already started'); - return; - } - this.pageChanging = true; - performance.mark('element_MatrixChat_page_change_start'); + PerformanceMonitor.instance.start(PerformanceEntryNames.PAGE_CHANGE); } stopPageChangeTimer() { - // Tor doesn't support performance - if (!performance || !performance.mark) return null; + const perfMonitor = PerformanceMonitor.instance; - if (!this.pageChanging) { - console.warn('MatrixChat.stopPageChangeTimer: timer not started'); - return; - } - this.pageChanging = false; - performance.mark('element_MatrixChat_page_change_stop'); - performance.measure( - 'element_MatrixChat_page_change_delta', - 'element_MatrixChat_page_change_start', - 'element_MatrixChat_page_change_stop', - ); - performance.clearMarks('element_MatrixChat_page_change_start'); - performance.clearMarks('element_MatrixChat_page_change_stop'); - const measurement = performance.getEntriesByName('element_MatrixChat_page_change_delta').pop(); + perfMonitor.stop(PerformanceEntryNames.PAGE_CHANGE); - // In practice, sometimes the entries list is empty, so we get no measurement - if (!measurement) return null; + const entries = perfMonitor.getEntries({ + name: PerformanceEntryNames.PAGE_CHANGE, + }); + const measurement = entries.pop(); - return measurement.duration; + return measurement + ? measurement.duration + : null; } shouldTrackPageChange(prevState: IState, state: IState) { @@ -479,6 +535,7 @@ export default class MatrixChat extends React.PureComponent { } const newState = { currentUserId: null, + justRegistered: false, }; Object.assign(newState, state); this.setState(newState); @@ -486,7 +543,6 @@ export default class MatrixChat extends React.PureComponent { onAction = (payload) => { // console.log(`MatrixClientPeg.onAction: ${payload.action}`); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); // Start the onboarding process for certain actions if (MatrixClientPeg.get() && MatrixClientPeg.get().isGuest() && @@ -500,14 +556,14 @@ export default class MatrixChat extends React.PureComponent { action: 'do_after_sync_prepared', deferred_action: payload, }); - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); return; } switch (payload.action) { case 'MatrixActions.accountData': // XXX: This is a collection of several hacks to solve a minor problem. We want to - // update our local state when the ID server changes, but don't want to put that in + // update our local state when the identity server changes, but don't want to put that in // the js-sdk as we'd be then dictating how all consumers need to behave. However, // this component is already bloated and we probably don't want this tiny logic in // here, but there's no better place in the react-sdk for it. Additionally, we're @@ -525,10 +581,11 @@ export default class MatrixChat extends React.PureComponent { } // redispatch the change with a more specific action - dis.dispatch({action: 'id_server_changed'}); + dis.dispatch({ action: 'id_server_changed' }); } break; case 'logout': + dis.dispatch({ action: "hangup_all" }); Lifecycle.logout(); break; case 'require_registration': @@ -553,17 +610,7 @@ export default class MatrixChat extends React.PureComponent { if (payload.screenAfterLogin) { this.screenAfterLogin = payload.screenAfterLogin; } - this.setStateForNewView({ - view: Views.LOGIN, - }); - this.notifyNewScreen('login'); - ThemeController.isLogin = true; - this.themeWatcher.recheck(); - break; - case 'start_post_registration': - this.setState({ - view: Views.POST_REGISTRATION, - }); + this.viewLogin(); break; case 'start_password_recovery': this.setStateForNewView({ @@ -582,6 +629,9 @@ export default class MatrixChat extends React.PureComponent { case 'forget_room': this.forgetRoom(payload.room_id); break; + case 'copy_room': + this.copyRoom(payload.room_id); + break; case 'reject_invite': Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, { title: _t('Reject invitation'), @@ -589,13 +639,12 @@ export default class MatrixChat extends React.PureComponent { onFinished: (confirm) => { if (confirm) { // FIXME: controller shouldn't be loading a view :( - const Loader = sdk.getComponent("elements.Spinner"); - const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); + const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); MatrixClientPeg.get().leave(payload.room_id).then(() => { modal.close(); if (this.state.currentRoomId === payload.room_id) { - dis.dispatch({action: 'view_next_room'}); + dis.dispatch({ action: 'view_home_page' }); } }, (err) => { modal.close(); @@ -624,14 +673,10 @@ export default class MatrixChat extends React.PureComponent { } break; } - case 'view_next_room': - this.viewNextRoom(1); - break; case Action.ViewUserSettings: { const tabPayload = payload as OpenToTabPayload; - const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog"); Modal.createTrackedDialog('User settings', '', UserSettingsDialog, - {initialTabId: tabPayload.initialTabId}, + { initialTabId: tabPayload.initialTabId }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); // View the welcome or home page if we need something to look at @@ -639,20 +684,28 @@ export default class MatrixChat extends React.PureComponent { break; } case 'view_create_room': - this.createRoom(payload.public); + this.createRoom(payload.public, payload.defaultName); break; case 'view_create_group': { - let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog") - if (SettingsStore.getValue("feature_communities_v2_prototypes")) { - CreateGroupDialog = CreateCommunityPrototypeDialog; - } - Modal.createTrackedDialog('Create Community', '', CreateGroupDialog); + const prototype = SettingsStore.getValue("feature_communities_v2_prototypes"); + Modal.createTrackedDialog( + 'Create Community', + '', + prototype ? CreateCommunityPrototypeDialog : CreateGroupDialog, + ); break; } case Action.ViewRoomDirectory: { - const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); - Modal.createTrackedDialog('Room directory', '', RoomDirectory, {}, - 'mx_RoomDirectory_dialogWrapper', false, true); + if (SpaceStore.instance.activeSpace) { + defaultDispatcher.dispatch({ + action: "view_room", + room_id: SpaceStore.instance.activeSpace.roomId, + }); + } else { + Modal.createTrackedDialog('Room directory', '', RoomDirectory, { + initialText: payload.initialText, + }, 'mx_RoomDirectory_dialogWrapper', false, true); + } // View the welcome or home page if we need something to look at this.viewSomethingBehindModal(); @@ -669,13 +722,13 @@ export default class MatrixChat extends React.PureComponent { this.viewWelcome(); break; case 'view_home_page': - this.viewHome(); + this.viewHome(payload.justRegistered); break; case 'view_start_chat_or_reuse': this.chatCreateOrReuse(payload.user_id); break; case 'view_create_chat': - showStartChatInviteDialog(); + showStartChatInviteDialog(payload.initialText || ""); break; case 'view_invite': showRoomInviteDialog(payload.roomId); @@ -688,12 +741,14 @@ export default class MatrixChat extends React.PureComponent { this.showScreenAfterLogin(); break; case 'toggle_my_groups': + // persist that the user has interacted with this, use it to dismiss the beta dot + localStorage.setItem("mx_seenSpacesBeta", "1"); // We just dispatch the page change rather than have to worry about // what the logic is for each of these branches. if (this.state.page_type === PageTypes.MyGroups) { - dis.dispatch({action: 'view_last_screen'}); + dis.dispatch({ action: 'view_last_screen' }); } else { - dis.dispatch({action: 'view_my_groups'}); + dis.dispatch({ action: 'view_my_groups' }); } break; case 'hide_left_panel': @@ -711,8 +766,13 @@ export default class MatrixChat extends React.PureComponent { this.state.resizeNotifier.notifyLeftHandleResized(); }); break; + case Action.OpenDialPad: + Modal.createTrackedDialog('Dial pad', '', DialPadModal, {}, "mx_Dialog_dialPadWrapper"); + break; case 'on_logged_in': if ( + // Skip this handling for token login as that always calls onLoggedIn itself + !this.tokenLogin && !Lifecycle.isSoftLogout() && this.state.view !== Views.LOGIN && this.state.view !== Views.REGISTER && @@ -729,7 +789,7 @@ export default class MatrixChat extends React.PureComponent { this.onLoggedOut(); break; case 'will_start_client': - this.setState({ready: false}, () => { + this.setState({ ready: false }, () => { // if the client is about to start, we are, by definition, not ready. // Set ready to false now, then it'll be set to true when the sync // listener we set below fires. @@ -805,35 +865,6 @@ export default class MatrixChat extends React.PureComponent { this.notifyNewScreen('register'); } - // TODO: Move to RoomViewStore - private viewNextRoom(roomIndexDelta: number) { - const allRooms = RoomListSorter.mostRecentActivityFirst( - MatrixClientPeg.get().getRooms(), - ); - // If there are 0 rooms or 1 room, view the home page because otherwise - // if there are 0, we end up trying to index into an empty array, and - // if there is 1, we end up viewing the same room. - if (allRooms.length < 2) { - dis.dispatch({ - action: 'view_home_page', - }); - return; - } - let roomIndex = -1; - for (let i = 0; i < allRooms.length; ++i) { - if (allRooms[i].roomId === this.state.currentRoomId) { - roomIndex = i; - break; - } - } - roomIndex = (roomIndex + roomIndexDelta) % allRooms.length; - if (roomIndex < 0) roomIndex = allRooms.length - 1; - dis.dispatch({ - action: 'view_room', - room_id: allRooms[roomIndex].roomId, - }); - } - // switch view to the given room // // @param {Object} roomInfo Object containing data about the room to be joined @@ -878,6 +909,11 @@ export default class MatrixChat extends React.PureComponent { let presentedId = roomInfo.room_alias || roomInfo.room_id; const room = MatrixClientPeg.get().getRoom(roomInfo.room_id); if (room) { + // Not all timeline events are decrypted ahead of time anymore + // Only the critical ones for a typical UI are + // This will start the decryption process for all events when a + // user views a room + room.decryptAllEvents(); const theAlias = Rooms.getDisplayAliasForRoom(room); if (theAlias) { presentedId = theAlias; @@ -905,8 +941,8 @@ export default class MatrixChat extends React.PureComponent { page_type: PageTypes.RoomView, threepidInvite: roomInfo.threepid_invite, roomOobData: roomInfo.oob_data, - viaServers: roomInfo.via_servers, ready: true, + roomJustCreatedOpts: roomInfo.justCreatedOpts, }, () => { this.notifyNewScreen('room/' + presentedId, replaceLast); }); @@ -945,6 +981,9 @@ export default class MatrixChat extends React.PureComponent { } private viewWelcome() { + if (shouldUseLoginForWelcome(SdkConfig.get())) { + return this.viewLogin(); + } this.setStateForNewView({ view: Views.WELCOME, }); @@ -953,10 +992,21 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.recheck(); } - private viewHome() { + private viewLogin(otherState?: any) { + this.setStateForNewView({ + view: Views.LOGIN, + ...otherState, + }); + this.notifyNewScreen('login'); + ThemeController.isLogin = true; + this.themeWatcher.recheck(); + } + + private viewHome(justRegistered = false) { // The home page requires the "logged in" view, so we'll set that. this.setStateForNewView({ view: Views.LOGGED_IN, + justRegistered, }); this.setPage(PageTypes.HomePage); this.notifyNewScreen('home'); @@ -975,12 +1025,12 @@ export default class MatrixChat extends React.PureComponent { return; } this.notifyNewScreen('user/' + userId); - this.setState({currentUserId: userId}); + this.setState({ currentUserId: userId }); this.setPage(PageTypes.UserView); }); } - private async createRoom(defaultPublic = false) { + private async createRoom(defaultPublic = false, defaultName?: string) { const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId(); if (communityId) { // double check the user will have permission to associate this room with the community @@ -993,8 +1043,10 @@ export default class MatrixChat extends React.PureComponent { } } - const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); - const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic }); + const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { + defaultPublic, + defaultName, + }); const [shouldCreate, opts] = await modal.finished; if (shouldCreate) { @@ -1052,16 +1104,33 @@ export default class MatrixChat extends React.PureComponent { private leaveRoomWarnings(roomId: string) { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); + const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom(); // Show a warning if there are additional complications. - const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', ''); const warnings = []; + + const memberCount = roomToLeave.currentState.getJoinedMemberCount(); + if (memberCount === 1) { + warnings.push(( + + {' '/* Whitespace, otherwise the sentences get smashed together */ } + { _t("You are the only person here. " + + "If you leave, no one will be able to join in the future, including you.") } + + )); + + return warnings; + } + + const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', ''); if (joinRules) { const rule = joinRules.getContent().join_rule; if (rule !== "public") { warnings.push(( {' '/* Whitespace, otherwise the sentences get smashed together */ } - { _t("This room is not public. You will not be able to rejoin without an invite.") } + { isSpace + ? _t("This space is not public. You will not be able to rejoin without an invite.") + : _t("This room is not public. You will not be able to rejoin without an invite.") } )); } @@ -1070,15 +1139,23 @@ export default class MatrixChat extends React.PureComponent { } private leaveRoom(roomId: string) { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const warnings = this.leaveRoomWarnings(roomId); - Modal.createTrackedDialog('Leave room', '', QuestionDialog, { - title: _t("Leave room"), + const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom(); + Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { + title: isSpace ? _t("Leave space") : _t("Leave room"), description: ( - { _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) } + { isSpace + ? _t( + "Are you sure you want to leave the space '%(spaceName)s'?", + { spaceName: roomToLeave.name }, + ) + : _t( + "Are you sure you want to leave the room '%(roomName)s'?", + { roomName: roomToLeave.name }, + )} { warnings } ), @@ -1088,30 +1165,50 @@ export default class MatrixChat extends React.PureComponent { const d = leaveRoomBehaviour(roomId); // FIXME: controller shouldn't be loading a view :( - const Loader = sdk.getComponent("elements.Spinner"); - const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); + const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); d.finally(() => modal.close()); + dis.dispatch({ + action: "after_leave_room", + room_id: roomId, + }); } }, }); } private forgetRoom(roomId: string) { + const room = MatrixClientPeg.get().getRoom(roomId); MatrixClientPeg.get().forget(roomId).then(() => { - // Switch to another room view if we're currently viewing the historical room + // Switch to home page if we're currently viewing the forgotten room if (this.state.currentRoomId === roomId) { - dis.dispatch({ action: "view_next_room" }); + dis.dispatch({ action: "view_home_page" }); } + + // We have to manually update the room list because the forgotten room will not + // be notified to us, therefore the room list will have no other way of knowing + // the room is forgotten. + RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved); }).catch((err) => { const errCode = err.errcode || _td("unknown error code"); Modal.createTrackedDialog("Failed to forget room", '', ErrorDialog, { - title: _t("Failed to forget room %(errCode)s", {errCode}), + title: _t("Failed to forget room %(errCode)s", { errCode }), description: ((err && err.message) ? err.message : _t("Operation failed")), }); }); } + private async copyRoom(roomId: string) { + const roomLink = makeRoomPermalink(roomId); + const success = await copyPlaintext(roomLink); + if (!success) { + Modal.createTrackedDialog("Unable to copy room link", "", ErrorDialog, { + title: _t("Unable to copy room link"), + description: _t("Unable to copy a link to the room to the clipboard."), + }); + } + } + /** * Starts a chat with the welcome user, if the user doesn't already have one * @returns {string} The room ID of the new room, or null if no room was created @@ -1190,7 +1287,7 @@ export default class MatrixChat extends React.PureComponent { if (welcomeUserRoom === null) { // We didn't redirect to the welcome user room, so show // the homepage. - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({ action: 'view_home_page', justRegistered: true }); } } else if (ThreepidInviteStore.instance.pickBestInvite()) { // The user has a 3pid invite pending - show them that @@ -1199,11 +1296,11 @@ export default class MatrixChat extends React.PureComponent { // HACK: This is a pretty brutal way of threading the invite back through // our systems, but it's the safest we have for now. const params = ThreepidInviteStore.instance.translateToWireFormat(threepidInvite); - this.showScreen(`room/${threepidInvite.roomId}`, params) + this.showScreen(`room/${threepidInvite.roomId}`, params); } else { // The user has just logged in after registering, // so show the homepage. - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({ action: 'view_home_page', justRegistered: true }); } } else { this.showScreenAfterLogin(); @@ -1211,11 +1308,18 @@ export default class MatrixChat extends React.PureComponent { StorageManager.tryPersistStorage(); + // defer the following actions by 30 seconds to not throw them at the user immediately + await sleep(30); if (SettingsStore.getValue("showCookieBar") && (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) ) { showAnalyticsToast(this.props.config.piwik?.policyUrl); } + if (SdkConfig.get().mobileGuideToast) { + // The toast contains further logic to detect mobile platforms, + // check if it has been dismissed before, etc. + showMobileGuideToast(); + } } private showScreenAfterLogin() { @@ -1232,13 +1336,9 @@ export default class MatrixChat extends React.PureComponent { this.viewLastRoom(); } else { if (MatrixClientPeg.get().isGuest()) { - dis.dispatch({action: 'view_welcome_page'}); - } else if (getHomePageUrl(this.props.config)) { - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({ action: 'view_welcome_page' }); } else { - this.firstSyncPromise.promise.then(() => { - dis.dispatch({action: 'view_next_room'}); - }); + dis.dispatch({ action: 'view_home_page' }); } } } @@ -1254,17 +1354,13 @@ export default class MatrixChat extends React.PureComponent { * Called when the session is logged out */ private onLoggedOut() { - this.notifyNewScreen('login'); - this.setStateForNewView({ - view: Views.LOGIN, + this.viewLogin({ ready: false, collapseLhs: false, currentRoomId: null, }); this.subTitleStatus = ''; this.setPageSubtitle(); - ThemeController.isLogin = true; - this.themeWatcher.recheck(); } /** @@ -1322,15 +1418,15 @@ export default class MatrixChat extends React.PureComponent { // So dispatch directly from here. Ideally we'd use a SyncStateStore that // would do this dispatch and expose the sync state itself (by listening to // its own dispatch). - dis.dispatch({action: 'sync_state', prevState, state}); + dis.dispatch({ action: 'sync_state', prevState, state }); if (state === "ERROR" || state === "RECONNECTING") { if (data.error instanceof InvalidStoreError) { Lifecycle.handleInvalidStoreError(data.error); } - this.setState({syncError: data.error || true}); + this.setState({ syncError: data.error || true }); } else if (this.state.syncError) { - this.setState({syncError: null}); + this.setState({ syncError: null }); } this.updateStatusIndicator(state, prevState); @@ -1343,31 +1439,22 @@ export default class MatrixChat extends React.PureComponent { this.firstSyncComplete = true; this.firstSyncPromise.resolve(); - if (Notifier.shouldShowPrompt()) { - showNotificationsToast(); + if (Notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) { + showNotificationsToast(false); } - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); this.setState({ ready: true, }); }); - if (SettingsStore.getValue(UIFeature.Voip)) { - cli.on('Call.incoming', function(call) { - // we dispatch this synchronously to make sure that the event - // handlers on the call are set up immediately (so that if - // we get an immediate hangup, we don't get a stuck call) - dis.dispatch({ - action: 'incoming_call', - call: call, - }, true); - }); - } - cli.on('Session.logged_out', function(errObj) { if (Lifecycle.isLoggingOut()) return; + // A modal might have been open when we were logged out by the server + Modal.closeCurrentModal('Session.logged_out'); + if (errObj.httpStatus === 401 && errObj.data && errObj.data['soft_logout']) { console.warn("Soft logout issued by server - avoiding data deletion"); Lifecycle.softLogout(); @@ -1378,12 +1465,12 @@ export default class MatrixChat extends React.PureComponent { title: _t('Signed Out'), description: _t('For security, this session has been signed out. Please sign in again.'), }); + dis.dispatch({ action: 'logout', }); }); cli.on('no_consent', function(message, consentUri) { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('No Consent Dialog', '', QuestionDialog, { title: _t('Terms and Conditions'), description:
@@ -1406,7 +1493,7 @@ export default class MatrixChat extends React.PureComponent { }); const dft = new DecryptionFailureTracker((total, errorCode) => { - Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total); + Analytics.trackEvent('E2E', 'Decryption failure', errorCode, String(total)); CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total }); }, (errorCode) => { // Map JS-SDK error codes to tracker codes for aggregation @@ -1492,8 +1579,6 @@ export default class MatrixChat extends React.PureComponent { }); cli.on("crypto.keySignatureUploadFailure", (failures, source, continuation) => { - const KeySignatureUploadFailedDialog = - sdk.getComponent('views.dialogs.KeySignatureUploadFailedDialog'); Modal.createTrackedDialog( 'Failed to upload key signatures', 'Failed to upload key signatures', @@ -1503,25 +1588,20 @@ export default class MatrixChat extends React.PureComponent { cli.on("crypto.verification.request", request => { if (request.verifier) { - const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog"); Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, { verifier: request.verifier, }, null, /* priority = */ false, /* static = */ true); } else if (request.pending) { ToastStore.sharedInstance().addOrReplaceToast({ key: 'verifreq_' + request.channel.transactionId, - title: request.isSelfVerification ? _t("Self-verification request") : _t("Verification Request"), + title: _t("Verification requested"), icon: "verification", - props: {request}, - component: sdk.getComponent("toasts.VerificationRequestToast"), + props: { request }, + component: VerificationRequestToast, priority: 90, }); } }); - // Fire the tinter right on startup to ensure the default theme is applied - // A later sync can/will correct the tint to be the right value for the user - const colorScheme = SettingsStore.getValue("roomColor"); - Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color); } /** @@ -1549,23 +1629,33 @@ export default class MatrixChat extends React.PureComponent { } showScreen(screen: string, params?: {[key: string]: any}) { + const cli = MatrixClientPeg.get(); + const isLoggedOutOrGuest = !cli || cli.isGuest(); + if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) { + // user is logged in and landing on an auth page which will uproot their session, redirect them home instead + dis.dispatch({ action: "view_home_page" }); + return; + } + if (screen === 'register') { dis.dispatch({ action: 'start_registration', params: params, }); + PerformanceMonitor.instance.start(PerformanceEntryNames.REGISTER); } else if (screen === 'login') { dis.dispatch({ action: 'start_login', params: params, }); + PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN); } else if (screen === 'forgot_password') { dis.dispatch({ action: 'start_password_recovery', params: params, }); } else if (screen === 'soft_logout') { - if (MatrixClientPeg.get() && MatrixClientPeg.get().getUserId() && !Lifecycle.isSoftLogout()) { + if (cli.getUserId() && !Lifecycle.isSoftLogout()) { // Logged in - visit a room this.viewLastRoom(); } else { @@ -1603,8 +1693,8 @@ export default class MatrixChat extends React.PureComponent { // TODO if logged in, skip SSO let cli = MatrixClientPeg.get(); if (!cli) { - const {hsUrl, isUrl} = this.props.serverConfig; - cli = Matrix.createClient({ + const { hsUrl, isUrl } = this.props.serverConfig; + cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, }); @@ -1613,17 +1703,13 @@ export default class MatrixChat extends React.PureComponent { const type = screen === "start_sso" ? "sso" : "cas"; PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin()); } else if (screen === 'groups') { + if (SpaceStore.spacesEnabled) { + dis.dispatch({ action: "view_home_page" }); + return; + } dis.dispatch({ action: 'view_my_groups', }); - } else if (screen === 'complete_security') { - dis.dispatch({ - action: 'start_complete_security', - }); - } else if (screen === 'post_registration') { - dis.dispatch({ - action: 'start_post_registration', - }); } else if (screen.indexOf('room/') === 0) { // Rooms can have the following formats: // #room_alias:domain or !opaque_id:domain @@ -1647,10 +1733,16 @@ export default class MatrixChat extends React.PureComponent { // TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149 let threepidInvite: IThreepidInvite; + // if we landed here from a 3PID invite, persist it if (params.signurl && params.email) { threepidInvite = ThreepidInviteStore.instance .storeInvite(roomString, params as IThreepidInviteWireFormat); } + // otherwise check that this room doesn't already have a known invite + if (!threepidInvite) { + const invites = ThreepidInviteStore.instance.getInvites(); + threepidInvite = invites.find(invite => invite.roomId === roomString); + } // on our URLs there might be a ?via=matrix.org or similar to help // joins to the room succeed. We'll pass these through as an array @@ -1698,6 +1790,11 @@ export default class MatrixChat extends React.PureComponent { subAction: params.action, }); } else if (screen.indexOf('group/') === 0) { + if (SpaceStore.spacesEnabled) { + dis.dispatch({ action: "view_home_page" }); + return; + } + const groupId = screen.substring(6); // TODO: Check valid group ID @@ -1720,7 +1817,7 @@ export default class MatrixChat extends React.PureComponent { onAliasClick(event: MouseEvent, alias: string) { event.preventDefault(); - dis.dispatch({action: 'view_room', room_alias: alias}); + dis.dispatch({ action: 'view_room', room_alias: alias }); } onUserClick(event: MouseEvent, userId: string) { @@ -1736,7 +1833,7 @@ export default class MatrixChat extends React.PureComponent { onGroupClick(event: MouseEvent, groupId: string) { event.preventDefault(); - dis.dispatch({action: 'view_group', group_id: groupId}); + dis.dispatch({ action: 'view_group', group_id: groupId }); } onLogoutClick(event: React.MouseEvent) { @@ -1748,18 +1845,19 @@ export default class MatrixChat extends React.PureComponent { } handleResize = () => { - const hideLhsThreshold = 1000; - const showLhsThreshold = 1000; + const LHS_THRESHOLD = 1000; + const width = UIStore.instance.windowWidth; - if (this.windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) { - dis.dispatch({ action: 'hide_left_panel' }); - } - if (this.windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) { + if (this.prevWindowWidth < LHS_THRESHOLD && width >= LHS_THRESHOLD) { dis.dispatch({ action: 'show_left_panel' }); } + if (this.prevWindowWidth >= LHS_THRESHOLD && width < LHS_THRESHOLD) { + dis.dispatch({ action: 'hide_left_panel' }); + } + + this.prevWindowWidth = width; this.state.resizeNotifier.notifyWindowResized(); - this.windowWidth = window.innerWidth; }; private dispatchTimelineResize() { @@ -1794,25 +1892,17 @@ export default class MatrixChat extends React.PureComponent { return Lifecycle.setLoggedIn(credentials); } - onFinishPostRegistration = () => { - // Don't confuse this with "PageType" which is the middle window to show - this.setState({ - view: Views.LOGGED_IN, - }); - this.showScreen("settings"); - }; - onSendEvent(roomId: string, event: MatrixEvent) { const cli = MatrixClientPeg.get(); if (!cli) { - dis.dispatch({action: 'message_send_failed'}); + dis.dispatch({ action: 'message_send_failed' }); return; } cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => { - dis.dispatch({action: 'message_sent'}); + dis.dispatch({ action: 'message_sent' }); }, (err) => { - dis.dispatch({action: 'message_send_failed'}); + dis.dispatch({ action: 'message_send_failed' }); }); } @@ -1859,7 +1949,7 @@ export default class MatrixChat extends React.PureComponent { } onServerConfigChange = (serverConfig: ValidatedServerConfig) => { - this.setState({serverConfig}); + this.setState({ serverConfig }); }; private makeRegistrationUrl = (params: {[key: string]: string}) => { @@ -1887,40 +1977,10 @@ export default class MatrixChat extends React.PureComponent { // Create and start the client await Lifecycle.setLoggedIn(credentials); + await this.postLoginSetup(); - const cli = MatrixClientPeg.get(); - const cryptoEnabled = cli.isCryptoEnabled(); - if (!cryptoEnabled) { - this.onLoggedIn(); - } - - const promisesList = [this.firstSyncPromise.promise]; - if (cryptoEnabled) { - // wait for the client to finish downloading cross-signing keys for us so we - // know whether or not we have keys set up on this account - promisesList.push(cli.downloadKeys([cli.getUserId()])); - } - - // Now update the state to say we're waiting for the first sync to complete rather - // than for the login to finish. - this.setState({ pendingInitialSync: true }); - - await Promise.all(promisesList); - - if (!cryptoEnabled) { - this.setState({ pendingInitialSync: false }); - return; - } - - const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId()); - if (crossSigningIsSetUp) { - this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); - } else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) { - this.setStateForNewView({ view: Views.E2E_SETUP }); - } else { - this.onLoggedIn(); - } - this.setState({ pendingInitialSync: false }); + PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN); + PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER); }; // complete security / e2e setup has finished @@ -1945,34 +2005,25 @@ export default class MatrixChat extends React.PureComponent { let view = null; if (this.state.view === Views.LOADING) { - const Spinner = sdk.getComponent('elements.Spinner'); view = (
); } else if (this.state.view === Views.COMPLETE_SECURITY) { - const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity'); view = ( ); } else if (this.state.view === Views.E2E_SETUP) { - const E2eSetup = sdk.getComponent('structures.auth.E2eSetup'); view = ( ); - } else if (this.state.view === Views.POST_REGISTRATION) { - // needs to be before normal PageTypes as you are logged in technically - const PostRegistration = sdk.getComponent('structures.auth.PostRegistration'); - view = ( - - ); } else if (this.state.view === Views.LOGGED_IN) { // store errors stop the client syncing and require user intervention, so we'll // be showing a dialog. Don't show anything else. @@ -1986,7 +2037,6 @@ export default class MatrixChat extends React.PureComponent { * we should go through and figure out what we actually need to pass down, as well * as using something like redux to avoid having a billion bits of state kicking around. */ - const LoggedInView = sdk.getComponent('structures.LoggedInView'); view = ( { ref={this.loggedInView} matrixClient={MatrixClientPeg.get()} onRoomCreated={this.onRoomCreated} - onCloseAllSettings={this.onCloseAllSettings} onRegistered={this.onRegistered} currentRoomId={this.state.currentRoomId} /> ); } else { // we think we are logged in, but are still waiting for the /sync to complete - const Spinner = sdk.getComponent('elements.Spinner'); let errorBox; if (this.state.syncError && !isStoreError) { errorBox =
@@ -2019,10 +2067,8 @@ export default class MatrixChat extends React.PureComponent { ); } } else if (this.state.view === Views.WELCOME) { - const Welcome = sdk.getComponent('auth.Welcome'); view = ; } else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) { - const Registration = sdk.getComponent('structures.auth.Registration'); const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail; view = ( { onLoginClick={this.onLoginClick} onServerConfigChange={this.onServerConfigChange} defaultDeviceDisplayName={this.props.defaultDeviceDisplayName} + fragmentAfterLogin={fragmentAfterLogin} {...this.getServerProperties()} /> ); } else if (this.state.view === Views.FORGOT_PASSWORD && SettingsStore.getValue(UIFeature.PasswordReset)) { - const ForgotPassword = sdk.getComponent('structures.auth.ForgotPassword'); view = ( { ); } else if (this.state.view === Views.LOGIN) { const showPasswordReset = SettingsStore.getValue(UIFeature.PasswordReset); - const Login = sdk.getComponent('structures.auth.Login'); view = ( { onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined} onServerConfigChange={this.onServerConfigChange} fragmentAfterLogin={fragmentAfterLogin} + defaultUsername={this.props.startingFragmentQueryParams.defaultUsername} {...this.getServerProperties()} /> ); } else if (this.state.view === Views.SOFT_LOGOUT) { - const SoftLogout = sdk.getComponent('structures.auth.SoftLogout'); view = ( { console.error(`Unknown view ${this.state.view}`); } - const ErrorBoundary = sdk.getComponent('elements.ErrorBoundary'); return {view} ; diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.tsx similarity index 50% rename from src/components/structures/MessagePanel.js rename to src/components/structures/MessagePanel.tsx index e2e3592536..47f8c218dc 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.tsx @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016 - 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. @@ -16,36 +14,59 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef, KeyboardEvent, ReactNode, SyntheticEvent, TransitionEvent } from 'react'; import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import shouldHideEvent from '../../shouldHideEvent'; -import {wantsDateSeparator} from '../../DateUtils'; -import * as sdk from '../../index'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import { EventType } from 'matrix-js-sdk/src/@types/event'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { Relations } from "matrix-js-sdk/src/models/relations"; +import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; -import {MatrixClientPeg} from '../../MatrixClientPeg'; +import shouldHideEvent from '../../shouldHideEvent'; +import { wantsDateSeparator } from '../../DateUtils'; +import { MatrixClientPeg } from '../../MatrixClientPeg'; import SettingsStore from '../../settings/SettingsStore'; -import {_t} from "../../languageHandler"; -import {haveTileForEvent} from "../views/rooms/EventTile"; -import {textForEvent} from "../../TextForEvent"; +import RoomContext from "../../contexts/RoomContext"; +import { Layout } from "../../settings/Layout"; +import { _t } from "../../languageHandler"; +import EventTile, { haveTileForEvent, IReadReceiptProps, TileShape } from "../views/rooms/EventTile"; +import { hasText } from "../../TextForEvent"; import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer"; +import DMRoomMap from "../../utils/DMRoomMap"; +import NewRoomIntro from "../views/rooms/NewRoomIntro"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import defaultDispatcher from '../../dispatcher/dispatcher'; +import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile'; +import ScrollPanel, { IScrollState } from "./ScrollPanel"; +import EventListSummary from '../views/elements/EventListSummary'; +import MemberEventListSummary from '../views/elements/MemberEventListSummary'; +import DateSeparator from '../views/messages/DateSeparator'; +import ErrorBoundary from '../views/elements/ErrorBoundary'; +import ResizeNotifier from "../../utils/ResizeNotifier"; +import Spinner from "../views/elements/Spinner"; +import TileErrorBoundary from '../views/messages/TileErrorBoundary'; +import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import EditorStateTransfer from "../../utils/EditorStateTransfer"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes -const continuedTypes = ['m.sticker', 'm.room.message']; +const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; +const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl]; // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL -function shouldFormContinuation(prevEvent, mxEvent) { +function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): boolean { // sanity check inputs if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false; // check if within the max continuation period if (mxEvent.getTs() - prevEvent.getTs() > CONTINUATION_MAX_INTERVAL) return false; + // As we summarise redactions, do not continue a redacted event onto a non-redacted one and vice-versa + if (mxEvent.isRedacted() !== prevEvent.isRedacted()) return false; + // Some events should appear as continuations from previous events of different types. if (mxEvent.getType() !== prevEvent.getType() && - (!continuedTypes.includes(mxEvent.getType()) || - !continuedTypes.includes(prevEvent.getType()))) return false; + (!continuedTypes.includes(mxEvent.getType() as EventType) || + !continuedTypes.includes(prevEvent.getType() as EventType))) return false; // Check if the sender is the same and hasn't changed their displayname/avatar between these events if (mxEvent.sender.userId !== prevEvent.sender.userId || @@ -58,91 +79,157 @@ function shouldFormContinuation(prevEvent, mxEvent) { return true; } -const isMembershipChange = (e) => e.getType() === 'm.room.member' || e.getType() === 'm.room.third_party_invite'; +interface IProps { + // the list of MatrixEvents to display + events: MatrixEvent[]; + + // true to give the component a 'display: none' style. + hidden?: boolean; + + // true to show a spinner at the top of the timeline to indicate + // back-pagination in progress + backPaginating?: boolean; + + // true to show a spinner at the end of the timeline to indicate + // forward-pagination in progress + forwardPaginating?: boolean; + + // ID of an event to highlight. If undefined, no event will be highlighted. + highlightedEventId?: string; + + // The room these events are all in together, if any. + // (The notification panel won't have a room here, for example.) + room?: Room; + + // Should we show URL Previews + showUrlPreview?: boolean; + + // event after which we should show a read marker + readMarkerEventId?: string; + + // whether the read marker should be visible + readMarkerVisible?: boolean; + + // the userid of our user. This is used to suppress the read marker + // for pending messages. + ourUserId?: string; + + // true to suppress the date at the start of the timeline + suppressFirstDateSeparator?: boolean; + + // whether to show read receipts + showReadReceipts?: boolean; + + // true if updates to the event list should cause the scroll panel to + // scroll down when we are at the bottom of the window. See ScrollPanel + // for more details. + stickyBottom?: boolean; + + // className for the panel + className: string; + + // shape parameter to be passed to EventTiles + tileShape?: TileShape; + + // show twelve hour timestamps + isTwelveHour?: boolean; + + // show timestamps always + alwaysShowTimestamps?: boolean; + + // whether to show reactions for an event + showReactions?: boolean; + + // which layout to use + layout?: Layout; + + // whether or not to show flair at all + enableFlair?: boolean; + + resizeNotifier: ResizeNotifier; + permalinkCreator?: RoomPermalinkCreator; + editState?: EditorStateTransfer; + + // callback which is called when the panel is scrolled. + onScroll?(event: Event): void; + + // callback which is called when the user interacts with the room timeline + onUserScroll(event: SyntheticEvent): void; + + // callback which is called when more content is needed. + onFillRequest?(backwards: boolean): Promise; + + // helper function to access relations for an event + onUnfillRequest?(backwards: boolean, scrollToken: string): void; + + getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations; +} + +interface IState { + ghostReadMarkers: string[]; + showTypingNotifications: boolean; +} + +interface IReadReceiptForUser { + lastShownEventId: string; + receipt: IReadReceiptProps; +} /* (almost) stateless UI component which builds the event tiles in the room timeline. */ -export default class MessagePanel extends React.Component { - static propTypes = { - // true to give the component a 'display: none' style. - hidden: PropTypes.bool, +@replaceableComponent("structures.MessagePanel") +export default class MessagePanel extends React.Component { + static contextType = RoomContext; - // true to show a spinner at the top of the timeline to indicate - // back-pagination in progress - backPaginating: PropTypes.bool, + // opaque readreceipt info for each userId; used by ReadReceiptMarker + // to manage its animations + private readonly readReceiptMap: Record = {}; - // true to show a spinner at the end of the timeline to indicate - // forward-pagination in progress - forwardPaginating: PropTypes.bool, + // Track read receipts by event ID. For each _shown_ event ID, we store + // the list of read receipts to display: + // [ + // { + // userId: string, + // member: RoomMember, + // ts: number, + // }, + // ] + // This is recomputed on each render. It's only stored on the component + // for ease of passing the data around since it's computed in one pass + // over all events. + private readReceiptsByEvent: Record = {}; - // the list of MatrixEvents to display - events: PropTypes.array.isRequired, + // Track read receipts by user ID. For each user ID we've ever shown a + // a read receipt for, we store an object: + // { + // lastShownEventId: string, + // receipt: { + // userId: string, + // member: RoomMember, + // ts: number, + // }, + // } + // so that we can always keep receipts displayed by reverting back to + // the last shown event for that user ID when needed. This may feel like + // it duplicates the receipt storage in the room, but at this layer, we + // are tracking _shown_ event IDs, which the JS SDK knows nothing about. + // This is recomputed on each render, using the data from the previous + // render as our fallback for any user IDs we can't match a receipt to a + // displayed event in the current render cycle. + private readReceiptsByUserId: Record = {}; - // ID of an event to highlight. If undefined, no event will be highlighted. - highlightedEventId: PropTypes.string, + private readonly showHiddenEventsInTimeline: boolean; + private isMounted = false; - // The room these events are all in together, if any. - // (The notification panel won't have a room here, for example.) - room: PropTypes.object, + private readMarkerNode = createRef(); + private whoIsTyping = createRef(); + private scrollPanel = createRef(); - // Should we show URL Previews - showUrlPreview: PropTypes.bool, + private readonly showTypingNotificationsWatcherRef: string; + private eventNodes: Record; - // event after which we should show a read marker - readMarkerEventId: PropTypes.string, - - // whether the read marker should be visible - readMarkerVisible: PropTypes.bool, - - // the userid of our user. This is used to suppress the read marker - // for pending messages. - ourUserId: PropTypes.string, - - // true to suppress the date at the start of the timeline - suppressFirstDateSeparator: PropTypes.bool, - - // whether to show read receipts - showReadReceipts: PropTypes.bool, - - // true if updates to the event list should cause the scroll panel to - // scroll down when we are at the bottom of the window. See ScrollPanel - // for more details. - stickyBottom: PropTypes.bool, - - // callback which is called when the panel is scrolled. - onScroll: PropTypes.func, - - // callback which is called when more content is needed. - onFillRequest: PropTypes.func, - - // className for the panel - className: PropTypes.string.isRequired, - - // shape parameter to be passed to EventTiles - tileShape: PropTypes.string, - - // show twelve hour timestamps - isTwelveHour: PropTypes.bool, - - // show timestamps always - alwaysShowTimestamps: PropTypes.bool, - - // helper function to access relations for an event - getRelationsForEvent: PropTypes.func, - - // whether to show reactions for an event - showReactions: PropTypes.bool, - - // whether to use the irc layout - useIRCLayout: PropTypes.bool, - - // whether or not to show flair at all - enableFlair: PropTypes.bool, - }; - - // Force props to be loaded for useIRCLayout - constructor(props) { - super(props); + constructor(props, context) { + super(props, context); this.state = { // previous positions the read marker has been in, so we can @@ -151,65 +238,21 @@ export default class MessagePanel extends React.Component { showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), }; - // opaque readreceipt info for each userId; used by ReadReceiptMarker - // to manage its animations - this._readReceiptMap = {}; - - // Track read receipts by event ID. For each _shown_ event ID, we store - // the list of read receipts to display: - // [ - // { - // userId: string, - // member: RoomMember, - // ts: number, - // }, - // ] - // This is recomputed on each render. It's only stored on the component - // for ease of passing the data around since it's computed in one pass - // over all events. - this._readReceiptsByEvent = {}; - - // Track read receipts by user ID. For each user ID we've ever shown a - // a read receipt for, we store an object: - // { - // lastShownEventId: string, - // receipt: { - // userId: string, - // member: RoomMember, - // ts: number, - // }, - // } - // so that we can always keep receipts displayed by reverting back to - // the last shown event for that user ID when needed. This may feel like - // it duplicates the receipt storage in the room, but at this layer, we - // are tracking _shown_ event IDs, which the JS SDK knows nothing about. - // This is recomputed on each render, using the data from the previous - // render as our fallback for any user IDs we can't match a receipt to a - // displayed event in the current render cycle. - this._readReceiptsByUserId = {}; - // Cache hidden events setting on mount since Settings is expensive to // query, and we check this in a hot code path. - this._showHiddenEventsInTimeline = - SettingsStore.getValue("showHiddenEventsInTimeline"); + this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline"); - this._isMounted = false; - - this._readMarkerNode = createRef(); - this._whoIsTyping = createRef(); - this._scrollPanel = createRef(); - - this._showTypingNotificationsWatcherRef = + this.showTypingNotificationsWatcherRef = SettingsStore.watchSetting("showTypingNotifications", null, this.onShowTypingNotificationsChange); } componentDidMount() { - this._isMounted = true; + this.isMounted = true; } componentWillUnmount() { - this._isMounted = false; - SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef); + this.isMounted = false; + SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef); } componentDidUpdate(prevProps, prevState) { @@ -222,14 +265,14 @@ export default class MessagePanel extends React.Component { } } - onShowTypingNotificationsChange = () => { + private onShowTypingNotificationsChange = (): void => { this.setState({ showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), }); }; /* get the DOM node representing the given event */ - getNodeForEventId(eventId) { + public getNodeForEventId(eventId: string): HTMLElement { if (!this.eventNodes) { return undefined; } @@ -239,8 +282,8 @@ export default class MessagePanel extends React.Component { /* return true if the content is fully scrolled down right now; else false. */ - isAtBottom() { - return this._scrollPanel.current && this._scrollPanel.current.isAtBottom(); + public isAtBottom(): boolean { + return this.scrollPanel.current?.isAtBottom(); } /* get the current scroll state. See ScrollPanel.getScrollState for @@ -248,8 +291,8 @@ export default class MessagePanel extends React.Component { * * returns null if we are not mounted. */ - getScrollState() { - return this._scrollPanel.current ? this._scrollPanel.current.getScrollState() : null; + public getScrollState(): IScrollState { + return this.scrollPanel.current?.getScrollState() ?? null; } // returns one of: @@ -258,15 +301,15 @@ export default class MessagePanel extends React.Component { // -1: read marker is above the window // 0: read marker is within the window // +1: read marker is below the window - getReadMarkerPosition() { - const readMarker = this._readMarkerNode.current; - const messageWrapper = this._scrollPanel.current; + public getReadMarkerPosition(): number { + const readMarker = this.readMarkerNode.current; + const messageWrapper = this.scrollPanel.current; if (!readMarker || !messageWrapper) { return null; } - const wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect(); + const wrapperRect = (ReactDOM.findDOMNode(messageWrapper) as HTMLElement).getBoundingClientRect(); const readMarkerRect = readMarker.getBoundingClientRect(); // the read-marker pretends to have zero height when it is actually @@ -282,17 +325,17 @@ export default class MessagePanel extends React.Component { /* jump to the top of the content. */ - scrollToTop() { - if (this._scrollPanel.current) { - this._scrollPanel.current.scrollToTop(); + public scrollToTop(): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.scrollToTop(); } } /* jump to the bottom of the content. */ - scrollToBottom() { - if (this._scrollPanel.current) { - this._scrollPanel.current.scrollToBottom(); + public scrollToBottom(): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.scrollToBottom(); } } @@ -301,9 +344,9 @@ export default class MessagePanel extends React.Component { * * @param {number} mult: -1 to page up, +1 to page down */ - scrollRelative(mult) { - if (this._scrollPanel.current) { - this._scrollPanel.current.scrollRelative(mult); + public scrollRelative(mult: number): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.scrollRelative(mult); } } @@ -312,9 +355,9 @@ export default class MessagePanel extends React.Component { * * @param {KeyboardEvent} ev: the keyboard event to handle */ - handleScrollKey(ev) { - if (this._scrollPanel.current) { - this._scrollPanel.current.handleScrollKey(ev); + public handleScrollKey(ev: KeyboardEvent): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.handleScrollKey(ev); } } @@ -328,38 +371,41 @@ export default class MessagePanel extends React.Component { * node (specifically, the bottom of it) will be positioned. If omitted, it * defaults to 0. */ - scrollToEvent(eventId, pixelOffset, offsetBase) { - if (this._scrollPanel.current) { - this._scrollPanel.current.scrollToToken(eventId, pixelOffset, offsetBase); + public scrollToEvent(eventId: string, pixelOffset: number, offsetBase: number): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.scrollToToken(eventId, pixelOffset, offsetBase); } } - scrollToEventIfNeeded(eventId) { + public scrollToEventIfNeeded(eventId: string): void { const node = this.eventNodes[eventId]; if (node) { - node.scrollIntoView({block: "nearest", behavior: "instant"}); + node.scrollIntoView({ + block: "nearest", + behavior: "instant", + }); } } /* check the scroll state and send out pagination requests if necessary. */ - checkFillState() { - if (this._scrollPanel.current) { - this._scrollPanel.current.checkFillState(); + public checkFillState(): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.checkFillState(); } } - _isUnmounting = () => { - return !this._isMounted; + private isUnmounting = (): boolean => { + return !this.isMounted; }; // TODO: Implement granular (per-room) hide options - _shouldShowEvent(mxEv) { - if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) { + public shouldShowEvent(mxEv: MatrixEvent): boolean { + if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender())) { return false; // ignored = no show (only happens if the ignore happens after an event was received) } - if (this._showHiddenEventsInTimeline) { + if (this.showHiddenEventsInTimeline) { return true; } @@ -370,10 +416,10 @@ export default class MessagePanel extends React.Component { // Always show highlighted event if (this.props.highlightedEventId === mxEv.getId()) return true; - return !shouldHideEvent(mxEv); + return !shouldHideEvent(mxEv, this.context); } - _readMarkerForEvent(eventId, isLastEvent) { + public readMarkerForEvent(eventId: string, isLastEvent: boolean): ReactNode { const visible = !isLastEvent && this.props.readMarkerVisible; if (this.props.readMarkerEventId === eventId) { @@ -386,13 +432,13 @@ export default class MessagePanel extends React.Component { // confused. if (visible) { hr =
; } return (
  • @@ -411,8 +457,8 @@ export default class MessagePanel extends React.Component { // transition (ie. the read markers do but the event tiles do not) // and TransitionGroup requires that all its children are Transitions. const hr =
    ; @@ -420,8 +466,10 @@ export default class MessagePanel extends React.Component { // we get a new DOM node (restarting the animation) when the ghost // moves to a different event. return ( -
  • +
  • { hr }
  • ); @@ -430,7 +478,7 @@ export default class MessagePanel extends React.Component { return null; } - _collectGhostReadMarker = (node) => { + private collectGhostReadMarker = (node: HTMLElement): void => { if (node) { // now the element has appeared, change the style which will trigger the CSS transition requestAnimationFrame(() => { @@ -440,15 +488,33 @@ export default class MessagePanel extends React.Component { } }; - _onGhostTransitionEnd = (ev) => { + private onGhostTransitionEnd = (ev: TransitionEvent): void => { // we can now clean up the ghost element - const finishedEventId = ev.target.dataset.eventid; + const finishedEventId = (ev.target as HTMLElement).dataset.eventid; this.setState({ ghostReadMarkers: this.state.ghostReadMarkers.filter(eid => eid !== finishedEventId), }); }; - _getEventTiles() { + private getNextEventInfo(arr: MatrixEvent[], i: number): { nextEvent: MatrixEvent, nextTile: MatrixEvent } { + const nextEvent = i < arr.length - 1 + ? arr[i + 1] + : null; + + // The next event with tile is used to to determine the 'last successful' flag + // when rendering the tile. The shouldShowEvent function is pretty quick at what + // it does, so this should have no significant cost even when a room is used for + // not-chat purposes. + const nextTile = arr.slice(i + 1).find(e => this.shouldShowEvent(e)); + + return { nextEvent, nextTile }; + } + + private get roomHasPendingEdit(): string { + return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`); + } + + private getEventTiles(): ReactNode[] { this.eventNodes = {}; let i; @@ -464,7 +530,7 @@ export default class MessagePanel extends React.Component { let lastShownNonLocalEchoIndex = -1; for (i = this.props.events.length-1; i >= 0; i--) { const mxEv = this.props.events[i]; - if (!this._shouldShowEvent(mxEv)) { + if (!this.shouldShowEvent(mxEv)) { continue; } @@ -485,17 +551,21 @@ export default class MessagePanel extends React.Component { let prevEvent = null; // the last event we showed - this._readReceiptsByEvent = {}; + // Note: the EventTile might still render a "sent/sending receipt" independent of + // this information. When not providing read receipt information, the tile is likely + // to assume that sent receipts are to be shown more often. + this.readReceiptsByEvent = {}; if (this.props.showReadReceipts) { - this._readReceiptsByEvent = this._getReadReceiptsByShownEvent(); + this.readReceiptsByEvent = this.getReadReceiptsByShownEvent(); } - let grouper = null; + let grouper: BaseGrouper = null; for (i = 0; i < this.props.events.length; i++) { const mxEv = this.props.events[i]; const eventId = mxEv.getId(); const last = (mxEv === lastShownEvent); + const { nextEvent, nextTile } = this.getNextEventInfo(this.props.events, i); if (grouper) { if (grouper.shouldGroup(mxEv)) { @@ -512,27 +582,32 @@ export default class MessagePanel extends React.Component { for (const Grouper of groupers) { if (Grouper.canStartGroup(this, mxEv)) { - grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent); + grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent, nextEvent, nextTile); } } if (!grouper) { - const wantTile = this._shouldShowEvent(mxEv); + const wantTile = this.shouldShowEvent(mxEv); + const isGrouped = false; if (wantTile) { - const nextEvent = i < this.props.events.length - 1 - ? this.props.events[i + 1] - : null; - // make sure we unpack the array returned by _getTilesForEvent, + // make sure we unpack the array returned by getTilesForEvent, // otherwise react will auto-generate keys and we will end up // replacing all of the DOM elements every time we paginate. - ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent)); + ret.push(...this.getTilesForEvent(prevEvent, mxEv, last, isGrouped, nextEvent, nextTile)); prevEvent = mxEv; } - const readMarker = this._readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex); + const readMarker = this.readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex); if (readMarker) ret.push(readMarker); } } + if (!this.props.editState && this.roomHasPendingEdit) { + defaultDispatcher.dispatch({ + action: "edit_event", + event: this.props.room.findEventById(this.roomHasPendingEdit), + }); + } + if (grouper) { ret.push(...grouper.getTiles()); } @@ -540,15 +615,18 @@ export default class MessagePanel extends React.Component { return ret; } - _getTilesForEvent(prevEvent, mxEv, last, nextEvent) { - const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary'); - const EventTile = sdk.getComponent('rooms.EventTile'); - const DateSeparator = sdk.getComponent('messages.DateSeparator'); + public getTilesForEvent( + prevEvent: MatrixEvent, + mxEv: MatrixEvent, + last = false, + isGrouped = false, + nextEvent?: MatrixEvent, + nextEventWithTile?: MatrixEvent, + ): ReactNode[] { const ret = []; const isEditing = this.props.editState && this.props.editState.getEvent().getId() === mxEv.getId(); - // local echoes have a fake date, which could even be yesterday. Treat them // as 'today' for the date separators. let ts1 = mxEv.getTs(); @@ -559,15 +637,15 @@ export default class MessagePanel extends React.Component { } // do we need a date separator since the last event? - const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate); - if (wantsDateSeparator) { + const wantsDateSeparator = this.wantsDateSeparator(prevEvent, eventDate); + if (wantsDateSeparator && !isGrouped) { const dateSeparator =
  • ; ret.push(dateSeparator); } let willWantDateSeparator = false; if (nextEvent) { - willWantDateSeparator = this._wantsDateSeparator(mxEv, nextEvent.getDate() || new Date()); + willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date()); } // is this a continuation of the previous message? @@ -576,51 +654,70 @@ export default class MessagePanel extends React.Component { const eventId = mxEv.getId(); const highlight = (eventId === this.props.highlightedEventId); - // we can't use local echoes as scroll tokens, because their event IDs change. - // Local echos have a send "status". - const scrollToken = mxEv.status ? undefined : eventId; + const readReceipts = this.readReceiptsByEvent[eventId]; - const readReceipts = this._readReceiptsByEvent[eventId]; + let isLastSuccessful = false; + const isSentState = s => !s || s === 'sent'; + const isSent = isSentState(mxEv.getAssociatedStatus()); + const hasNextEvent = nextEvent && this.shouldShowEvent(nextEvent); + if (!hasNextEvent && isSent) { + isLastSuccessful = true; + } else if (hasNextEvent && isSent && !isSentState(nextEvent.getAssociatedStatus())) { + isLastSuccessful = true; + } + + // This is a bit nuanced, but if our next event is hidden but a future event is not + // hidden then we're not the last successful. + if ( + nextEventWithTile && + nextEventWithTile !== nextEvent && + isSentState(nextEventWithTile.getAssociatedStatus()) + ) { + isLastSuccessful = false; + } + + // We only want to consider "last successful" if the event is sent by us, otherwise of course + // it's successful: we received it. + isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId(); // use txnId as key if available so that we don't remount during sending ret.push( -
  • - - - -
  • , + + + , ); return ret; } - _wantsDateSeparator(prevEvent, nextEventDate) { + public wantsDateSeparator(prevEvent: MatrixEvent, nextEventDate: Date): boolean { if (prevEvent == null) { // first event in the panel: depends if we could back-paginate from // here. @@ -631,7 +728,7 @@ export default class MessagePanel extends React.Component { // Get a list of read receipts that should be shown next to this event // Receipts are objects which have a 'userId', 'roomMember' and 'ts'. - _getReadReceiptsForEvent(event) { + private getReadReceiptsForEvent(event: MatrixEvent): IReadReceiptProps[] { const myUserId = MatrixClientPeg.get().credentials.userId; // get list of read receipts, sorted most recent first @@ -639,7 +736,7 @@ export default class MessagePanel extends React.Component { if (!room) { return null; } - const receipts = []; + const receipts: IReadReceiptProps[] = []; room.getReceiptsForEvent(event).forEach((r) => { if (!r.userId || r.type !== "m.read" || r.userId === myUserId) { return; // ignore non-read receipts and receipts from self. @@ -660,13 +757,13 @@ export default class MessagePanel extends React.Component { // Get an object that maps from event ID to a list of read receipts that // should be shown next to that event. If a hidden event has read receipts, // they are folded into the receipts of the last shown event. - _getReadReceiptsByShownEvent() { + private getReadReceiptsByShownEvent(): Record { const receiptsByEvent = {}; const receiptsByUserId = {}; let lastShownEventId; for (const event of this.props.events) { - if (this._shouldShowEvent(event)) { + if (this.shouldShowEvent(event)) { lastShownEventId = event.getId(); } if (!lastShownEventId) { @@ -674,7 +771,7 @@ export default class MessagePanel extends React.Component { } const existingReceipts = receiptsByEvent[lastShownEventId] || []; - const newReceipts = this._getReadReceiptsForEvent(event); + const newReceipts = this.getReadReceiptsForEvent(event); receiptsByEvent[lastShownEventId] = existingReceipts.concat(newReceipts); // Record these receipts along with their last shown event ID for @@ -693,16 +790,16 @@ export default class MessagePanel extends React.Component { // someone which had one in the last. By looking through our previous // mapping of receipts by user ID, we can cover recover any receipts // that would have been lost by using the same event ID from last time. - for (const userId in this._readReceiptsByUserId) { + for (const userId in this.readReceiptsByUserId) { if (receiptsByUserId[userId]) { continue; } - const { lastShownEventId, receipt } = this._readReceiptsByUserId[userId]; + const { lastShownEventId, receipt } = this.readReceiptsByUserId[userId]; const existingReceipts = receiptsByEvent[lastShownEventId] || []; receiptsByEvent[lastShownEventId] = existingReceipts.concat(receipt); receiptsByUserId[userId] = { lastShownEventId, receipt }; } - this._readReceiptsByUserId = receiptsByUserId; + this.readReceiptsByUserId = receiptsByUserId; // After grouping receipts by shown events, do another pass to sort each // receipt list. @@ -715,21 +812,21 @@ export default class MessagePanel extends React.Component { return receiptsByEvent; } - _collectEventNode = (eventId, node) => { - this.eventNodes[eventId] = node; - } + private collectEventNode = (eventId: string, node: EventTile): void => { + this.eventNodes[eventId] = node?.ref?.current; + }; // once dynamic content in the events load, make the scrollPanel check the // scroll offsets. - _onHeightChanged = () => { - const scrollPanel = this._scrollPanel.current; + public onHeightChanged = (): void => { + const scrollPanel = this.scrollPanel.current; if (scrollPanel) { scrollPanel.checkScroll(); } }; - _onTypingShown = () => { - const scrollPanel = this._scrollPanel.current; + private onTypingShown = (): void => { + const scrollPanel = this.scrollPanel.current; // this will make the timeline grow, so checkScroll scrollPanel.checkScroll(); if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { @@ -737,8 +834,8 @@ export default class MessagePanel extends React.Component { } }; - _onTypingHidden = () => { - const scrollPanel = this._scrollPanel.current; + private onTypingHidden = (): void => { + const scrollPanel = this.scrollPanel.current; if (scrollPanel) { // as hiding the typing notifications doesn't // update the scrollPanel, we tell it to apply @@ -750,12 +847,12 @@ export default class MessagePanel extends React.Component { } }; - updateTimelineMinHeight() { - const scrollPanel = this._scrollPanel.current; + public updateTimelineMinHeight(): void { + const scrollPanel = this.scrollPanel.current; if (scrollPanel) { const isAtBottom = scrollPanel.isAtBottom(); - const whoIsTyping = this._whoIsTyping.current; + const whoIsTyping = this.whoIsTyping.current; const isTypingVisible = whoIsTyping && whoIsTyping.isVisible(); // when messages get added to the timeline, // but somebody else is still typing, @@ -767,18 +864,14 @@ export default class MessagePanel extends React.Component { } } - onTimelineReset() { - const scrollPanel = this._scrollPanel.current; + public onTimelineReset(): void { + const scrollPanel = this.scrollPanel.current; if (scrollPanel) { scrollPanel.clearPreventShrinking(); } } render() { - const ErrorBoundary = sdk.getComponent('elements.ErrorBoundary'); - const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); - const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile"); - const Spinner = sdk.getComponent("elements.Spinner"); let topSpinner; let bottomSpinner; if (this.props.backPaginating) { @@ -790,25 +883,18 @@ export default class MessagePanel extends React.Component { const style = this.props.hidden ? { display: 'none' } : {}; - const className = classNames( - this.props.className, - { - "mx_MessagePanel_alwaysShowTimestamps": this.props.alwaysShowTimestamps, - }, - ); - let whoIsTyping; if (this.props.room && !this.props.tileShape && this.state.showTypingNotifications) { whoIsTyping = ( + onShown={this.onTypingShown} + onHidden={this.onTypingHidden} + ref={this.whoIsTyping} /> ); } let ircResizer = null; - if (this.props.useIRCLayout) { + if (this.props.layout == Layout.IRC) { ircResizer = { topSpinner } - { this._getEventTiles() } + { this.getEventTiles() } { whoIsTyping } { bottomSpinner } @@ -840,6 +926,31 @@ export default class MessagePanel extends React.Component { } } +abstract class BaseGrouper { + static canStartGroup = (panel: MessagePanel, ev: MatrixEvent): boolean => true; + + public events: MatrixEvent[] = []; + // events that we include in the group but then eject out and place above the group. + public ejectedEvents: MatrixEvent[] = []; + public readMarker: ReactNode; + + constructor( + public readonly panel: MessagePanel, + public readonly event: MatrixEvent, + public readonly prevEvent: MatrixEvent, + public readonly lastShownEvent: MatrixEvent, + public readonly nextEvent?: MatrixEvent, + public readonly nextEventTile?: MatrixEvent, + ) { + this.readMarker = panel.readMarkerForEvent(event.getId(), event === lastShownEvent); + } + + public abstract shouldGroup(ev: MatrixEvent): boolean; + public abstract add(ev: MatrixEvent): void; + public abstract getTiles(): ReactNode[]; + public abstract getNewPrevEvent(): MatrixEvent; +} + /* Grouper classes determine when events can be grouped together in a summary. * Groupers should have the following methods: * - canStartGroup (static): determines if a new group should be started with the @@ -855,36 +966,21 @@ export default class MessagePanel extends React.Component { // Wrap initial room creation events into an EventListSummary // Grouping only events sent by the same user that sent the `m.room.create` and only until // the first non-state event or membership event which is not regarding the sender of the `m.room.create` event -class CreationGrouper { - static canStartGroup = function(panel, ev) { - return ev.getType() === "m.room.create"; +class CreationGrouper extends BaseGrouper { + static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { + return ev.getType() === EventType.RoomCreate; }; - constructor(panel, createEvent, prevEvent, lastShownEvent) { - this.panel = panel; - this.createEvent = createEvent; - this.prevEvent = prevEvent; - this.lastShownEvent = lastShownEvent; - this.events = []; - // events that we include in the group but then eject out and place - // above the group. - this.ejectedEvents = []; - this.readMarker = panel._readMarkerForEvent( - createEvent.getId(), - createEvent === lastShownEvent, - ); - } - - shouldGroup(ev) { + public shouldGroup(ev: MatrixEvent): boolean { const panel = this.panel; - const createEvent = this.createEvent; - if (!panel._shouldShowEvent(ev)) { + const createEvent = this.event; + if (!panel.shouldShowEvent(ev)) { return true; } - if (panel._wantsDateSeparator(this.createEvent, ev.getDate())) { + if (panel.wantsDateSeparator(this.event, ev.getDate())) { return false; } - if (ev.getType() === "m.room.member" + if (ev.getType() === EventType.RoomMember && (ev.getStateKey() !== createEvent.getSender() || ev.getContent()["membership"] !== "join")) { return false; } @@ -894,37 +990,35 @@ class CreationGrouper { return false; } - add(ev) { + public add(ev: MatrixEvent): void { const panel = this.panel; - this.readMarker = this.readMarker || panel._readMarkerForEvent( + this.readMarker = this.readMarker || panel.readMarkerForEvent( ev.getId(), ev === this.lastShownEvent, ); - if (!panel._shouldShowEvent(ev)) { + if (!panel.shouldShowEvent(ev)) { return; } - if (ev.getType() === "m.room.encryption") { + if (ev.getType() === EventType.RoomEncryption) { this.ejectedEvents.push(ev); } else { this.events.push(ev); } } - getTiles() { + public getTiles(): ReactNode[] { // If we don't have any events to group, don't even try to group them. The logic // below assumes that we have a group of events to deal with, but we might not if // the events we were supposed to group were redacted. if (!this.events || !this.events.length) return []; - const DateSeparator = sdk.getComponent('messages.DateSeparator'); - const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - const panel = this.panel; const ret = []; - const createEvent = this.createEvent; + const isGrouped = true; + const createEvent = this.event; const lastShownEvent = this.lastShownEvent; - if (panel._wantsDateSeparator(this.prevEvent, createEvent.getDate())) { + if (panel.wantsDateSeparator(this.prevEvent, createEvent.getDate())) { const ts = createEvent.getTs(); ret.push(
  • , @@ -932,14 +1026,14 @@ class CreationGrouper { } // If this m.room.create event should be shown (room upgrade) then show it before the summary - if (panel._shouldShowEvent(createEvent)) { + if (panel.shouldShowEvent(createEvent)) { // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered - ret.push(...panel._getTilesForEvent(createEvent, createEvent, false)); + ret.push(...panel.getTilesForEvent(createEvent, createEvent)); } for (const ejected of this.ejectedEvents) { - ret.push(...panel._getTilesForEvent( - createEvent, ejected, createEvent === lastShownEvent, + ret.push(...panel.getTilesForEvent( + createEvent, ejected, createEvent === lastShownEvent, isGrouped, )); } @@ -948,21 +1042,31 @@ class CreationGrouper { // of EventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent); + return panel.getTilesForEvent(e, e, e === lastShownEvent, isGrouped); }).reduce((a, b) => a.concat(b), []); // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one const ev = this.events[this.events.length - 1]; + + let summaryText; + const roomId = ev.getRoomId(); + const creator = ev.sender ? ev.sender.name : ev.getSender(); + if (DMRoomMap.shared().getUserIdForRoomId(roomId)) { + summaryText = _t("%(creator)s created this DM.", { creator }); + } else { + summaryText = _t("%(creator)s created and configured the room.", { creator }); + } + + ret.push(); + ret.push( - { eventTiles } + { eventTiles } , ); @@ -973,64 +1077,153 @@ class CreationGrouper { return ret; } - getNewPrevEvent() { - return this.createEvent; + public getNewPrevEvent(): MatrixEvent { + return this.event; + } +} + +class RedactionGrouper extends BaseGrouper { + static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { + return panel.shouldShowEvent(ev) && ev.isRedacted(); + }; + + constructor( + panel: MessagePanel, + ev: MatrixEvent, + prevEvent: MatrixEvent, + lastShownEvent: MatrixEvent, + nextEvent: MatrixEvent, + nextEventTile: MatrixEvent, + ) { + super(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile); + this.events = [ev]; + } + + public shouldGroup(ev: MatrixEvent): boolean { + // absorb hidden events so that they do not break up streams of messages & redaction events being grouped + if (!this.panel.shouldShowEvent(ev)) { + return true; + } + if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) { + return false; + } + return ev.isRedacted(); + } + + public add(ev: MatrixEvent): void { + this.readMarker = this.readMarker || this.panel.readMarkerForEvent( + ev.getId(), + ev === this.lastShownEvent, + ); + if (!this.panel.shouldShowEvent(ev)) { + return; + } + this.events.push(ev); + } + + public getTiles(): ReactNode[] { + if (!this.events || !this.events.length) return []; + + const isGrouped = true; + const panel = this.panel; + const ret = []; + const lastShownEvent = this.lastShownEvent; + + if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { + const ts = this.events[0].getTs(); + ret.push( +
  • , + ); + } + + const key = "redactioneventlistsummary-" + ( + this.prevEvent ? this.events[0].getId() : "initial" + ); + + const senders = new Set(); + let eventTiles = this.events.map((e, i) => { + senders.add(e.sender); + const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; + return panel.getTilesForEvent( + prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); + }).reduce((a, b) => a.concat(b), []); + + if (eventTiles.length === 0) { + eventTiles = null; + } + + ret.push( + + { eventTiles } + , + ); + + if (this.readMarker) { + ret.push(this.readMarker); + } + + return ret; + } + + public getNewPrevEvent(): MatrixEvent { + return this.events[this.events.length - 1]; } } // Wrap consecutive member events in a ListSummary, ignore if redacted -class MemberGrouper { - static canStartGroup = function(panel, ev) { - return panel._shouldShowEvent(ev) && isMembershipChange(ev); +class MemberGrouper extends BaseGrouper { + static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { + return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType); + }; + + constructor( + public readonly panel: MessagePanel, + public readonly event: MatrixEvent, + public readonly prevEvent: MatrixEvent, + public readonly lastShownEvent: MatrixEvent, + ) { + super(panel, event, prevEvent, lastShownEvent); + this.events = [event]; } - constructor(panel, ev, prevEvent, lastShownEvent) { - this.panel = panel; - this.readMarker = panel._readMarkerForEvent( - ev.getId(), - ev === lastShownEvent, - ); - this.events = [ev]; - this.prevEvent = prevEvent; - this.lastShownEvent = lastShownEvent; - } - - shouldGroup(ev) { - if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) { + public shouldGroup(ev: MatrixEvent): boolean { + if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) { return false; } - return isMembershipChange(ev); + return membershipTypes.includes(ev.getType() as EventType); } - add(ev) { - if (ev.getType() === 'm.room.member') { - // We'll just double check that it's worth our time to do so, through an - // ugly hack. If textForEvent returns something, we should group it for - // rendering but if it doesn't then we'll exclude it. - const renderText = textForEvent(ev); - if (!renderText || renderText.trim().length === 0) return; // quietly ignore + public add(ev: MatrixEvent): void { + if (ev.getType() === EventType.RoomMember) { + // We can ignore any events that don't actually have a message to display + if (!hasText(ev)) return; } - this.readMarker = this.readMarker || this.panel._readMarkerForEvent( + this.readMarker = this.readMarker || this.panel.readMarkerForEvent( ev.getId(), ev === this.lastShownEvent, ); this.events.push(ev); } - getTiles() { + public getTiles(): ReactNode[] { // If we don't have any events to group, don't even try to group them. The logic // below assumes that we have a group of events to deal with, but we might not if // the events we were supposed to group were redacted. if (!this.events || !this.events.length) return []; - const DateSeparator = sdk.getComponent('messages.DateSeparator'); - const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); - + const isGrouped = true; const panel = this.panel; const lastShownEvent = this.lastShownEvent; const ret = []; - if (panel._wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { + if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { const ts = this.events[0].getTs(); ret.push(
  • , @@ -1058,7 +1251,7 @@ class MemberGrouper { // of MemberEventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent); + return panel.getTilesForEvent(e, e, e === lastShownEvent, isGrouped); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { @@ -1066,12 +1259,13 @@ class MemberGrouper { } ret.push( - - { eventTiles } + { eventTiles } , ); @@ -1082,10 +1276,10 @@ class MemberGrouper { return ret; } - getNewPrevEvent() { + public getNewPrevEvent(): MatrixEvent { return this.events[0]; } } // all the grouper classes that we use -const groupers = [CreationGrouper, MemberGrouper]; +const groupers = [CreationGrouper, MemberGrouper, RedactionGrouper]; diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index e0551eecdb..87447b6aba 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -24,7 +24,10 @@ import dis from '../../dispatcher/dispatcher'; import AccessibleButton from '../views/elements/AccessibleButton'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import BetaCard from "../views/beta/BetaCard"; +@replaceableComponent("structures.MyGroups") export default class MyGroups extends React.Component { static contextType = MatrixClientContext; @@ -38,19 +41,19 @@ export default class MyGroups extends React.Component { } _onCreateGroupClick = () => { - dis.dispatch({action: 'view_create_group'}); + dis.dispatch({ action: 'view_create_group' }); }; _fetch() { this.context.getJoinedGroups().then((result) => { - this.setState({groups: result.groups, error: null}); + this.setState({ groups: result.groups, error: null }); }, (err) => { if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') { // Indicate that the guest isn't in any groups (which should be true) - this.setState({groups: [], error: null}); + this.setState({ groups: [], error: null }); return; } - this.setState({groups: null, error: err}); + this.setState({ groups: null, error: err }); }); } @@ -79,8 +82,7 @@ export default class MyGroups extends React.Component {

    { _t( - "To set up a filter, drag a community avatar over to the filter panel on " + - "the far left hand side of the screen. You can click on an avatar in the " + + "You can click on an avatar in the " + "filter panel at any time to see only the rooms and people associated " + "with that community.", ) } @@ -121,7 +123,7 @@ export default class MyGroups extends React.Component {

    {/*
    - +
    @@ -137,6 +139,7 @@ export default class MyGroups extends React.Component {
    */}
    +
    { contentHeader } { content } diff --git a/src/components/structures/NonUrgentToastContainer.tsx b/src/components/structures/NonUrgentToastContainer.tsx index 8d415df4dd..a2d419b4ba 100644 --- a/src/components/structures/NonUrgentToastContainer.tsx +++ b/src/components/structures/NonUrgentToastContainer.tsx @@ -18,14 +18,16 @@ import * as React from "react"; import { ComponentClass } from "../../@types/common"; import NonUrgentToastStore from "../../stores/NonUrgentToastStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import { replaceableComponent } from "../../utils/replaceableComponent"; interface IProps { } interface IState { - toasts: ComponentClass[], + toasts: ComponentClass[]; } +@replaceableComponent("structures.NonUrgentToastContainer") export default class NonUrgentToastContainer extends React.PureComponent { public constructor(props, context) { super(props, context); @@ -42,7 +44,7 @@ export default class NonUrgentToastContainer extends React.PureComponent { - this.setState({toasts: NonUrgentToastStore.instance.components}); + this.setState({ toasts: NonUrgentToastStore.instance.components }); }; public render() { diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.tsx similarity index 66% rename from src/components/structures/NotificationPanel.js rename to src/components/structures/NotificationPanel.tsx index 2889afc1fc..8c8fab7ece 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.tsx @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016, 2019, 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. @@ -16,48 +14,49 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from "prop-types"; +import React from "react"; import { _t } from '../../languageHandler'; -import {MatrixClientPeg} from "../../MatrixClientPeg"; -import * as sdk from "../../index"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; import BaseCard from "../views/right_panel/BaseCard"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import TimelinePanel from "./TimelinePanel"; +import Spinner from "../views/elements/Spinner"; +import { TileShape } from "../views/rooms/EventTile"; + +interface IProps { + onClose(): void; +} /* * Component which shows the global notification list using a TimelinePanel */ -class NotificationPanel extends React.Component { - static propTypes = { - onClose: PropTypes.func.isRequired, - }; - +@replaceableComponent("structures.NotificationPanel") +export default class NotificationPanel extends React.PureComponent { render() { - // wrap a TimelinePanel with the jump-to-event bits turned off. - const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); - const Loader = sdk.getComponent("elements.Spinner"); - const emptyState = (

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

    -

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

    +

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

    ); let content; const timelineSet = MatrixClientPeg.get().getNotifTimelineSet(); if (timelineSet) { + // wrap a TimelinePanel with the jump-to-event bits turned off. content = ( ); } else { console.error("No notifTimelineSet available!"); - content = ; + content = ; } return @@ -65,5 +64,3 @@ class NotificationPanel extends React.Component { ; } } - -export default NotificationPanel; diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.tsx similarity index 65% rename from src/components/structures/RightPanel.js rename to src/components/structures/RightPanel.tsx index 41f4d83743..2a3448b017 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.tsx @@ -1,6 +1,6 @@ /* Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2015 - 2020 The Matrix.org Foundation C.I.C. +Copyright 2015 - 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. @@ -16,70 +16,102 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {Room} from "matrix-js-sdk/src/models/room"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { User } from "matrix-js-sdk/src/models/user"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; -import RateLimitedFunc from '../../ratelimitedfunc'; -import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GroupStore from '../../stores/GroupStore'; -import {RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPanelStorePhases"; +import { + RIGHT_PANEL_PHASES_NO_ARGS, + RIGHT_PANEL_SPACE_PHASES, + RightPanelPhases, +} from "../../stores/RightPanelStorePhases"; import RightPanelStore from "../../stores/RightPanelStore"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import {Action} from "../../dispatcher/actions"; +import { Action } from "../../dispatcher/actions"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import WidgetCard from "../views/right_panel/WidgetCard"; -import defaultDispatcher from "../../dispatcher/dispatcher"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import SettingsStore from "../../settings/SettingsStore"; +import { ActionPayload } from "../../dispatcher/payloads"; +import MemberList from "../views/rooms/MemberList"; +import GroupMemberList from "../views/groups/GroupMemberList"; +import GroupRoomList from "../views/groups/GroupRoomList"; +import GroupRoomInfo from "../views/groups/GroupRoomInfo"; +import UserInfo from "../views/right_panel/UserInfo"; +import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo"; +import FilePanel from "./FilePanel"; +import NotificationPanel from "./NotificationPanel"; +import ResizeNotifier from "../../utils/ResizeNotifier"; +import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard"; +import { throttle } from 'lodash'; +import SpaceStore from "../../stores/SpaceStore"; -export default class RightPanel extends React.Component { - static get propTypes() { - return { - room: PropTypes.instanceOf(Room), // if showing panels for a given room, this is set - groupId: PropTypes.string, // if showing panels for a given group, this is set - user: PropTypes.object, // used if we know the user ahead of opening the panel - }; - } +interface IProps { + room?: Room; // if showing panels for a given room, this is set + groupId?: string; // if showing panels for a given group, this is set + user?: User; // used if we know the user ahead of opening the panel + resizeNotifier: ResizeNotifier; +} +interface IState { + phase: RightPanelPhases; + isUserPrivilegedInGroup?: boolean; + member?: RoomMember; + verificationRequest?: VerificationRequest; + verificationRequestPromise?: Promise; + space?: Room; + widgetId?: string; + groupRoomId?: string; + groupId?: string; + event: MatrixEvent; +} + +@replaceableComponent("structures.RightPanel") +export default class RightPanel extends React.Component { static contextType = MatrixClientContext; + private dispatcherRef: string; + constructor(props, context) { super(props, context); this.state = { ...RightPanelStore.getSharedInstance().roomPanelPhaseParams, - phase: this._getPhaseFromProps(), + phase: this.getPhaseFromProps(), isUserPrivilegedInGroup: null, - member: this._getUserForPanel(), + member: this.getUserForPanel(), }; - this.onAction = this.onAction.bind(this); - this.onRoomStateMember = this.onRoomStateMember.bind(this); - this.onGroupStoreUpdated = this.onGroupStoreUpdated.bind(this); - this.onInviteToGroupButtonClick = this.onInviteToGroupButtonClick.bind(this); - this.onAddRoomToGroupButtonClick = this.onAddRoomToGroupButtonClick.bind(this); - - this._delayedUpdate = new RateLimitedFunc(() => { - this.forceUpdate(); - }, 500); } - // Helper function to split out the logic for _getPhaseFromProps() and the constructor + private readonly delayedUpdate = throttle((): void => { + this.forceUpdate(); + }, 500, { leading: true, trailing: true }); + + // Helper function to split out the logic for getPhaseFromProps() and the constructor // as both are called at the same time in the constructor. - _getUserForPanel() { + private getUserForPanel() { if (this.state && this.state.member) return this.state.member; const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams; return this.props.user || lastParams['member']; } // gets the current phase from the props and also maybe the store - _getPhaseFromProps() { + private getPhaseFromProps() { const rps = RightPanelStore.getSharedInstance(); - const userForPanel = this._getUserForPanel(); + const userForPanel = this.getUserForPanel(); if (this.props.groupId) { if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) { - dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.GroupMemberList}); + dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.GroupMemberList }); return RightPanelPhases.GroupMemberList; } return rps.groupPanelPhase; + } else if (SpaceStore.spacesEnabled && this.props.room?.isSpaceRoom() + && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase) + ) { + return RightPanelPhases.SpaceMemberList; } else if (userForPanel) { // XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state // from its props and some from a store, except if the contents of the store changes @@ -100,16 +132,15 @@ export default class RightPanel extends React.Component { return rps.roomPanelPhase; } return RightPanelPhases.RoomMemberInfo; - } else { - return rps.roomPanelPhase; } + return rps.roomPanelPhase; } componentDidMount() { this.dispatcherRef = dis.register(this.onAction); const cli = this.context; cli.on("RoomState.members", this.onRoomStateMember); - this._initGroupStore(this.props.groupId); + this.initGroupStore(this.props.groupId); } componentWillUnmount() { @@ -117,61 +148,47 @@ export default class RightPanel extends React.Component { if (this.context) { this.context.removeListener("RoomState.members", this.onRoomStateMember); } - this._unregisterGroupStore(this.props.groupId); + this.unregisterGroupStore(); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase if (newProps.groupId !== this.props.groupId) { - this._unregisterGroupStore(this.props.groupId); - this._initGroupStore(newProps.groupId); + this.unregisterGroupStore(); + this.initGroupStore(newProps.groupId); } } - _initGroupStore(groupId) { + private initGroupStore(groupId: string) { if (!groupId) return; GroupStore.registerListener(groupId, this.onGroupStoreUpdated); } - _unregisterGroupStore() { + private unregisterGroupStore() { GroupStore.unregisterListener(this.onGroupStoreUpdated); } - onGroupStoreUpdated() { + private onGroupStoreUpdated = () => { this.setState({ isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId), }); - } + }; - onInviteToGroupButtonClick() { - showGroupInviteDialog(this.props.groupId).then(() => { - this.setState({ - phase: RightPanelPhases.GroupMemberList, - }); - }); - } - - onAddRoomToGroupButtonClick() { - showGroupAddRoomDialog(this.props.groupId).then(() => { - this.forceUpdate(); - }); - } - - onRoomStateMember(ev, state, member) { + private onRoomStateMember = (ev: MatrixEvent, _, member: RoomMember) => { if (!this.props.room || member.roomId !== this.props.room.roomId) { return; } // redraw the badge on the membership list if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) { - this._delayedUpdate(); + this.delayedUpdate(); } else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId && member.userId === this.state.member.userId) { // refresh the member info (e.g. new power level) - this._delayedUpdate(); + this.delayedUpdate(); } - } + }; - onAction(payload) { + private onAction = (payload: ActionPayload) => { if (payload.action === Action.AfterRightPanelPhaseChange) { this.setState({ phase: payload.phase, @@ -182,11 +199,12 @@ export default class RightPanel extends React.Component { verificationRequest: payload.verificationRequest, verificationRequestPromise: payload.verificationRequestPromise, widgetId: payload.widgetId, + space: payload.space, }); } - } + }; - onCloseUserInfo = () => { + private onClose = () => { // XXX: There are three different ways of 'closing' this panel depending on what state // things are in... this knows far more than it should do about the state of the rest // of the app and is generally a bit silly. @@ -198,42 +216,22 @@ export default class RightPanel extends React.Component { dis.dispatch({ action: "view_home_page", }); - } else if (this.state.phase === RightPanelPhases.EncryptionPanel && + } else if ( + this.state.phase === RightPanelPhases.EncryptionPanel && this.state.verificationRequest && this.state.verificationRequest.pending ) { // When the user clicks close on the encryption panel cancel the pending request first if any this.state.verificationRequest.cancel(); } else { - // Otherwise we have got our user from RoomViewStore which means we're being shown - // within a room/group, so go back to the member panel if we were in the encryption panel, - // or the member list if we were in the member panel... phew. - const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel; + // the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here dis.dispatch({ - action: Action.ViewUser, - member: isEncryptionPhase ? this.state.member : null, + action: Action.ToggleRightPanel, + type: this.props.groupId ? "group" : "room", }); } }; - onClose = () => { - // the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here - defaultDispatcher.dispatch({ - action: Action.ToggleRightPanel, - type: this.props.groupId ? "group" : "room", - }); - }; - render() { - const MemberList = sdk.getComponent('rooms.MemberList'); - const UserInfo = sdk.getComponent('right_panel.UserInfo'); - const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo'); - const NotificationPanel = sdk.getComponent('structures.NotificationPanel'); - const FilePanel = sdk.getComponent('structures.FilePanel'); - - const GroupMemberList = sdk.getComponent('groups.GroupMemberList'); - const GroupRoomList = sdk.getComponent('groups.GroupRoomList'); - const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo'); - let panel =
    ; const roomId = this.props.room ? this.props.room.roomId : undefined; @@ -243,6 +241,13 @@ export default class RightPanel extends React.Component { panel = ; } break; + case RightPanelPhases.SpaceMemberList: + panel = ; + break; case RightPanelPhases.GroupMemberList: if (this.props.groupId) { @@ -255,12 +260,13 @@ export default class RightPanel extends React.Component { break; case RightPanelPhases.RoomMemberInfo: + case RightPanelPhases.SpaceMemberInfo: case RightPanelPhases.EncryptionPanel: panel = ; break; @@ -276,7 +283,8 @@ export default class RightPanel extends React.Component { user={this.state.member} groupId={this.props.groupId} key={this.state.member.userId} - onClose={this.onCloseUserInfo} />; + phase={this.state.phase} + onClose={this.onClose} />; break; case RightPanelPhases.GroupRoomInfo: @@ -290,6 +298,12 @@ export default class RightPanel extends React.Component { panel = ; break; + case RightPanelPhases.PinnedMessages: + if (SettingsStore.getValue("feature_pinning")) { + panel = ; + } + break; + case RightPanelPhases.FilePanel: panel = ; break; diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.tsx similarity index 57% rename from src/components/structures/RoomDirectory.js rename to src/components/structures/RoomDirectory.tsx index ece70e3a8f..aa5baaf8c2 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.tsx @@ -1,7 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2019, 2020, 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. @@ -16,36 +15,72 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import {MatrixClientPeg} from "../../MatrixClientPeg"; -import * as sdk from "../../index"; +import React from "react"; +import { IFieldType, IInstance, IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client"; +import { Visibility } from "matrix-js-sdk/src/@types/partials"; +import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests"; + +import { MatrixClientPeg } from "../../MatrixClientPeg"; import dis from "../../dispatcher/dispatcher"; import Modal from "../../Modal"; import { linkifyAndSanitizeHtml } from '../../HtmlUtils'; -import PropTypes from 'prop-types'; import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; -import {ALL_ROOMS} from "../views/directory/NetworkDropdown"; +import NetworkDropdown, { ALL_ROOMS, Protocols } from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupStore from "../../stores/GroupStore"; import FlairStore from "../../stores/FlairStore"; import CountlyAnalytics from "../../CountlyAnalytics"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../customisations/Media"; +import { IDialogProps } from "../views/dialogs/IDialogProps"; +import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; +import BaseAvatar from "../views/avatars/BaseAvatar"; +import ErrorDialog from "../views/dialogs/ErrorDialog"; +import QuestionDialog from "../views/dialogs/QuestionDialog"; +import BaseDialog from "../views/dialogs/BaseDialog"; +import DirectorySearchBox from "../views/elements/DirectorySearchBox"; +import ScrollPanel from "./ScrollPanel"; +import Spinner from "../views/elements/Spinner"; +import { ActionPayload } from "../../dispatcher/payloads"; +import { getDisplayAliasForAliasSet } from "../../Rooms"; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 800; -function track(action) { +const LAST_SERVER_KEY = "mx_last_room_directory_server"; +const LAST_INSTANCE_KEY = "mx_last_room_directory_instance"; + +function track(action: string) { Analytics.trackEvent('RoomDirectory', action); } -export default class RoomDirectory extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; +interface IProps extends IDialogProps { + initialText?: string; +} + +interface IState { + publicRooms: IPublicRoomsChunkRoom[]; + loading: boolean; + protocolsLoading: boolean; + error?: string; + instanceId: string; + roomServer: string; + filterString: string; + selectedCommunityId?: string; + communityName?: string; +} + +@replaceableComponent("structures.RoomDirectory") +export default class RoomDirectory extends React.Component { + private readonly startTime: number; + private unmounted = false; + private nextBatch: string = null; + private filterTimeout: number; + private protocols: Protocols; constructor(props) { super(props); @@ -53,41 +88,51 @@ export default class RoomDirectory extends React.Component { CountlyAnalytics.instance.trackRoomDirectoryBegin(); this.startTime = CountlyAnalytics.getTimestamp(); - const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0]; - this.state = { - publicRooms: [], - loading: true, - protocolsLoading: true, - error: null, - instanceId: undefined, - roomServer: MatrixClientPeg.getHomeserverName(), - filterString: null, - selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes") - ? selectedCommunityId - : null, - communityName: null, - }; + const selectedCommunityId = SettingsStore.getValue("feature_communities_v2_prototypes") + ? GroupFilterOrderStore.getSelectedTags()[0] + : null; - this._unmounted = false; - this.nextBatch = null; - this.filterTimeout = null; - this.scrollPanel = null; - this.protocols = null; - - this.state.protocolsLoading = true; + let protocolsLoading = true; if (!MatrixClientPeg.get()) { // We may not have a client yet when invoked from welcome page - this.state.protocolsLoading = false; - return; - } - - if (!this.state.selectedCommunityId) { + protocolsLoading = false; + } else if (!selectedCommunityId) { MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { this.protocols = response; - this.setState({protocolsLoading: false}); + const myHomeserver = MatrixClientPeg.getHomeserverName(); + const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY); + const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY); + + let roomServer = myHomeserver; + if ( + SdkConfig.get().roomDirectory?.servers?.includes(lsRoomServer) || + SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer) + ) { + roomServer = lsRoomServer; + } + + let instanceId: string = null; + if (roomServer === myHomeserver && ( + lsInstanceId === ALL_ROOMS || + Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId)) + )) { + instanceId = lsInstanceId; + } + + // Refresh the room list only if validation failed and we had to change these + if (this.state.instanceId !== instanceId || this.state.roomServer !== roomServer) { + this.setState({ + protocolsLoading: false, + instanceId, + roomServer, + }); + this.refreshRoomList(); + return; + } + this.setState({ protocolsLoading: false }); }, (err) => { console.warn(`error loading third party protocols: ${err}`); - this.setState({protocolsLoading: false}); + this.setState({ protocolsLoading: false }); if (MatrixClientPeg.get().isGuest()) { // Guests currently aren't allowed to use this API, so // ignore this as otherwise this error is literally the @@ -100,19 +145,31 @@ export default class RoomDirectory extends React.Component { error: _t( '%(brand)s failed to get the protocol list from the homeserver. ' + 'The homeserver may be too old to support third party networks.', - {brand}, + { brand }, ), }); }); } else { // We don't use the protocols in the communities v2 prototype experience - this.state.protocolsLoading = false; + protocolsLoading = false; // Grab the profile info async FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => { - this.setState({communityName: profile.name}); + this.setState({ communityName: profile.name }); }); } + + this.state = { + publicRooms: [], + loading: true, + error: null, + instanceId: localStorage.getItem(LAST_INSTANCE_KEY), + roomServer: localStorage.getItem(LAST_SERVER_KEY), + filterString: this.props.initialText || "", + selectedCommunityId, + communityName: null, + protocolsLoading, + }; } componentDidMount() { @@ -123,10 +180,10 @@ export default class RoomDirectory extends React.Component { if (this.filterTimeout) { clearTimeout(this.filterTimeout); } - this._unmounted = true; + this.unmounted = true; } - refreshRoomList = () => { + private refreshRoomList = () => { if (this.state.selectedCommunityId) { this.setState({ publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => { @@ -162,44 +219,44 @@ export default class RoomDirectory extends React.Component { this.getMoreRooms(); }; - getMoreRooms() { - if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms - if (!MatrixClientPeg.get()) return Promise.resolve(); + private getMoreRooms(): Promise { + if (this.state.selectedCommunityId) return Promise.resolve(false); // no more rooms + if (!MatrixClientPeg.get()) return Promise.resolve(false); this.setState({ loading: true, }); - const my_filter_string = this.state.filterString; - const my_server = this.state.roomServer; + const filterString = this.state.filterString; + const roomServer = this.state.roomServer; // remember the next batch token when we sent the request // too. If it's changed, appending to the list will corrupt it. - const my_next_batch = this.nextBatch; - const opts = {limit: 20}; - if (my_server != MatrixClientPeg.getHomeserverName()) { - opts.server = my_server; + const nextBatch = this.nextBatch; + const opts: IRoomDirectoryOptions = { limit: 20 }; + if (roomServer != MatrixClientPeg.getHomeserverName()) { + opts.server = roomServer; } if (this.state.instanceId === ALL_ROOMS) { opts.include_all_networks = true; } else if (this.state.instanceId) { - opts.third_party_instance_id = this.state.instanceId; + opts.third_party_instance_id = this.state.instanceId as string; } if (this.nextBatch) opts.since = this.nextBatch; - if (my_filter_string) opts.filter = { generic_search_term: my_filter_string }; + if (filterString) opts.filter = { generic_search_term: filterString }; return MatrixClientPeg.get().publicRooms(opts).then((data) => { if ( - my_filter_string != this.state.filterString || - my_server != this.state.roomServer || - my_next_batch != this.nextBatch) { + filterString != this.state.filterString || + roomServer != this.state.roomServer || + nextBatch != this.nextBatch) { // if the filter or server has changed since this request was sent, // throw away the result (don't even clear the busy flag // since we must still have a request in flight) - return; + return false; } - if (this._unmounted) { + if (this.unmounted) { // if we've been unmounted, we don't care either. - return; + return false; } if (this.state.filterString) { @@ -208,25 +265,24 @@ export default class RoomDirectory extends React.Component { } this.nextBatch = data.next_batch; - this.setState((s) => { - s.publicRooms.push(...(data.chunk || [])); - s.loading = false; - return s; - }); + this.setState((s) => ({ + ...s, + publicRooms: [...s.publicRooms, ...(data.chunk || [])], + loading: false, + })); return Boolean(data.next_batch); }, (err) => { if ( - my_filter_string != this.state.filterString || - my_server != this.state.roomServer || - my_next_batch != this.nextBatch) { - // as above: we don't care about errors for old - // requests either - return; + filterString != this.state.filterString || + roomServer != this.state.roomServer || + nextBatch != this.nextBatch) { + // as above: we don't care about errors for old requests either + return false; } - if (this._unmounted) { + if (this.unmounted) { // if we've been unmounted, we don't care either. - return; + return false; } console.error("Failed to get publicRooms: %s", JSON.stringify(err)); @@ -249,31 +305,27 @@ export default class RoomDirectory extends React.Component { * HS admins to do this through the RoomSettings interface, but * this needs SPEC-417. */ - removeFromDirectory(room) { - const alias = get_display_alias_for_room(room); + private removeFromDirectory(room: IPublicRoomsChunkRoom) { + const alias = getDisplayAliasForRoom(room); const name = room.name || alias || _t('Unnamed room'); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - let desc; if (alias) { - desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', {alias, name}); + desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', { alias, name }); } else { - desc = _t('Remove %(name)s from the directory?', {name: name}); + desc = _t('Remove %(name)s from the directory?', { name: name }); } Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, { title: _t('Remove from Directory'), description: desc, - onFinished: (should_delete) => { - if (!should_delete) return; + onFinished: (shouldDelete: boolean) => { + if (!shouldDelete) return; - const Loader = sdk.getComponent("elements.Spinner"); - const modal = Modal.createDialog(Loader); - let step = _t('remove %(name)s from the directory.', {name: name}); + const modal = Modal.createDialog(Spinner); + let step = _t('remove %(name)s from the directory.', { name: name }); - MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => { + MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, Visibility.Private).then(() => { if (!alias) return; step = _t('delete the address.'); return MatrixClientPeg.get().deleteAlias(alias); @@ -286,23 +338,24 @@ export default class RoomDirectory extends React.Component { console.error("Failed to " + step + ": " + err); Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, { title: _t('Error'), - description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')), + description: (err && err.message) + ? err.message + : _t('The server may be unavailable or overloaded'), }); }); }, }); } - onRoomClicked = (room, ev) => { + private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => { + // If room was shift-clicked, remove it from the room directory if (ev.shiftKey && !this.state.selectedCommunityId) { ev.preventDefault(); this.removeFromDirectory(room); - } else { - this.showRoom(room); } }; - onOptionChange = (server, instanceId) => { + private onOptionChange = (server: string, instanceId?: string) => { // clear next batch so we don't try to load more rooms this.nextBatch = null; this.setState({ @@ -320,17 +373,25 @@ export default class RoomDirectory extends React.Component { // find the five gitter ones, at which point we do not want // to render all those rooms when switching back to 'all networks'. // Easiest to just blow away the state & re-fetch. + + // We have to be careful here so that we don't set instanceId = "undefined" + localStorage.setItem(LAST_SERVER_KEY, server); + if (instanceId) { + localStorage.setItem(LAST_INSTANCE_KEY, instanceId); + } else { + localStorage.removeItem(LAST_INSTANCE_KEY); + } }; - onFillRequest = (backwards) => { + private onFillRequest = (backwards: boolean) => { if (backwards || !this.nextBatch) return Promise.resolve(false); return this.getMoreRooms(); }; - onFilterChange = (alias) => { + private onFilterChange = (alias: string) => { this.setState({ - filterString: alias || null, + filterString: alias || "", }); // don't send the request for a little bit, @@ -346,10 +407,10 @@ export default class RoomDirectory extends React.Component { }, 700); }; - onFilterClear = () => { + private onFilterClear = () => { // update immediately this.setState({ - filterString: null, + filterString: "", }, this.refreshRoomList); if (this.filterTimeout) { @@ -357,7 +418,7 @@ export default class RoomDirectory extends React.Component { } }; - onJoinFromSearchClick = (alias) => { + private onJoinFromSearchClick = (alias: string) => { // If we don't have a particular instance id selected, just show that rooms alias if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { // If the user specified an alias without a domain, add on whichever server is selected @@ -370,9 +431,10 @@ export default class RoomDirectory extends React.Component { // This is a 3rd party protocol. Let's see if we can join it const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); const instance = instanceForInstanceId(this.protocols, this.state.instanceId); - const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null; + const fields = protocolName + ? this.getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) + : null; if (!fields) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const brand = SdkConfig.get().brand; Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, { title: _t('Unable to join network'), @@ -384,14 +446,12 @@ export default class RoomDirectory extends React.Component { if (resp.length > 0 && resp[0].alias) { this.showRoomAlias(resp[0].alias, true); } else { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Room not found', '', ErrorDialog, { title: _t('Room not found'), description: _t('Couldn\'t find a matching Matrix room'), }); } }, (e) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, { title: _t('Fetching third party location failed'), description: _t('Unable to look up room ID from server'), @@ -400,36 +460,37 @@ export default class RoomDirectory extends React.Component { } }; - onPreviewClick = (ev, room) => { + private onPreviewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => { this.showRoom(room, null, false, true); ev.stopPropagation(); }; - onViewClick = (ev, room) => { + private onViewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => { this.showRoom(room); ev.stopPropagation(); }; - onJoinClick = (ev, room) => { + private onJoinClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => { this.showRoom(room, null, true); ev.stopPropagation(); }; - onCreateRoomClick = room => { + private onCreateRoomClick = () => { this.onFinished(); dis.dispatch({ action: 'view_create_room', public: true, + defaultName: this.state.filterString.trim(), }); }; - showRoomAlias(alias, autoJoin=false) { + private showRoomAlias(alias: string, autoJoin = false) { this.showRoom(null, alias, autoJoin); } - showRoom(room, room_alias, autoJoin = false, shouldPeek = false) { + private showRoom(room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) { this.onFinished(); - const payload = { + const payload: ActionPayload = { action: 'view_room', auto_join: autoJoin, should_peek: shouldPeek, @@ -441,20 +502,20 @@ export default class RoomDirectory extends React.Component { // to the directory. if (MatrixClientPeg.get().isGuest()) { if (!room.world_readable && !room.guest_can_join) { - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); return; } } - if (!room_alias) { - room_alias = get_display_alias_for_room(room); + if (!roomAlias) { + roomAlias = getDisplayAliasForRoom(room); } payload.oob_data = { avatarUrl: room.avatar_url, // XXX: This logic is duplicated from the JS SDK which // would normally decide what the name is. - name: room.name || room_alias || _t('Unnamed room'), + name: room.name || roomAlias || _t('Unnamed room'), }; if (this.state.roomServer) { @@ -468,40 +529,48 @@ export default class RoomDirectory extends React.Component { // which servers to start querying. However, there's no other way to join rooms in // this list without aliases at present, so if roomAlias isn't set here we have no // choice but to supply the ID. - if (room_alias) { - payload.room_alias = room_alias; + if (roomAlias) { + payload.room_alias = roomAlias; } else { payload.room_id = room.room_id; } dis.dispatch(payload); } - getRow(room) { + private createRoomCells(room: IPublicRoomsChunkRoom) { const client = MatrixClientPeg.get(); const clientRoom = client.getRoom(room.room_id); const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join"; const isGuest = client.isGuest(); - const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); let previewButton; let joinOrViewButton; - if (room.world_readable && !hasJoinedRoom) { + // Element Web currently does not allow guests to join rooms, so we + // instead show them preview buttons for all rooms. If the room is not + // world readable, a modal will appear asking you to register first. If + // it is readable, the preview appears as normal. + if (!hasJoinedRoom && (room.world_readable || isGuest)) { previewButton = ( - this.onPreviewClick(ev, room)}>{_t("Preview")} + this.onPreviewClick(ev, room)}> + { _t("Preview") } + ); } if (hasJoinedRoom) { joinOrViewButton = ( - this.onViewClick(ev, room)}>{_t("View")} + this.onViewClick(ev, room)}> + { _t("View") } + ); - } else if (!isGuest || room.guest_can_join) { + } else if (!isGuest) { joinOrViewButton = ( - this.onJoinClick(ev, room)}>{_t("Join")} + this.onJoinClick(ev, room)}> + { _t("Join") } + ); } - let name = room.name || get_display_alias_for_room(room) || _t('Unnamed room'); + let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room'); if (name.length > MAX_NAME_LENGTH) { name = `${name.substring(0, MAX_NAME_LENGTH)}...`; } @@ -514,51 +583,83 @@ export default class RoomDirectory extends React.Component { topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`; } topic = linkifyAndSanitizeHtml(topic); - const avatarUrl = getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), - room.avatar_url, 32, 32, "crop", - ); - return ( - this.onRoomClicked(room, ev)} - // cancel onMouseDown otherwise shift-clicking highlights text - onMouseDown={(ev) => {ev.preventDefault();}} + let avatarUrl = null; + 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 [ +
    this.onRoomClicked(room, ev)} + className="mx_RoomDirectory_roomAvatar" > - - - - -
    { name }
      -
    { ev.stopPropagation(); } } - dangerouslySetInnerHTML={{ __html: topic }} /> -
    { get_display_alias_for_room(room) }
    - - - { room.num_joined_members } - - {previewButton} - {joinOrViewButton} - - ); + +
    , +
    this.onRoomClicked(room, ev)} + className="mx_RoomDirectory_roomDescription" + > +
    this.onRoomClicked(room, ev)} + > + { name } +
      +
    this.onRoomClicked(room, ev)} + dangerouslySetInnerHTML={{ __html: topic }} + /> +
    this.onRoomClicked(room, ev)} + > + { 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 } +
    , + ]; } - collectScrollPanel = (element) => { - this.scrollPanel = element; - }; - - _stringLooksLikeId(s, field_type) { + private stringLooksLikeId(s: string, fieldType: IFieldType) { let pat = /^#[^\s]+:[^\s]/; - if (field_type && field_type.regexp) { - pat = new RegExp(field_type.regexp); + if (fieldType && fieldType.regexp) { + pat = new RegExp(fieldType.regexp); } return pat.test(s); } - _getFieldsForThirdPartyLocation(userInput, protocol, instance) { + private getFieldsForThirdPartyLocation(userInput: string, protocol: IProtocol, instance: IInstance) { // make an object with the fields specified by that protocol. We // require that the values of all but the last field come from the // instance. The last is the user input. @@ -574,72 +675,73 @@ export default class RoomDirectory extends React.Component { return fields; } - /** - * called by the parent component when PageUp/Down/etc is pressed. - * - * We pass it down to the scroll panel. - */ - handleScrollKey = ev => { - if (this.scrollPanel) { - this.scrollPanel.handleScrollKey(ev); - } - }; - - onFinished = () => { + private onFinished = () => { CountlyAnalytics.instance.trackRoomDirectory(this.startTime); - this.props.onFinished(); + this.props.onFinished(false); }; render() { - const Loader = sdk.getComponent("elements.Spinner"); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let content; if (this.state.error) { content = this.state.error; } else if (this.state.protocolsLoading) { - content = ; + content = ; } else { - const rows = (this.state.publicRooms || []).map(room => this.getRow(room)); + const cells = (this.state.publicRooms || []) + .reduce((cells, room) => cells.concat(this.createRoomCells(room)), []); // we still show the scrollpanel, at least for now, because // otherwise we don't fetch more because we don't get a fill // request from the scrollpanel because there isn't one let spinner; if (this.state.loading) { - spinner = ; + spinner = ; } - let scrollpanel_content; - if (rows.length === 0 && !this.state.loading) { - scrollpanel_content = { _t('No rooms to show') }; + const createNewButton = <> +
    + + { _t("Create new room") } + + ; + + let scrollPanelContent; + let footer; + if (cells.length === 0 && !this.state.loading) { + footer = <> +
    { _t('No results for "%(query)s"', { query: this.state.filterString.trim() }) }
    +

    + { _t("Try different words or check for typos. " + + "Some results may not be visible as they're private and you need an invite to join them.") } +

    + { createNewButton } + ; } else { - scrollpanel_content = - - { rows } - -
    ; + scrollPanelContent =
    + { cells } +
    ; + if (!this.state.loading && !this.nextBatch) { + footer = createNewButton; + } } - const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); - content = - { scrollpanel_content } + { scrollPanelContent } { spinner } + { footer &&
    + { footer } +
    }
    ; } let listHeader; if (!this.state.protocolsLoading) { - const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown'); - const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox'); - const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); - let instance_expected_field_type; + let instanceExpectedFieldType; if ( protocolName && this.protocols && @@ -647,21 +749,27 @@ export default class RoomDirectory extends React.Component { this.protocols[protocolName].location_fields.length > 0 && this.protocols[protocolName].field_types ) { - const last_field = this.protocols[protocolName].location_fields.slice(-1)[0]; - instance_expected_field_type = this.protocols[protocolName].field_types[last_field]; + const lastField = this.protocols[protocolName].location_fields.slice(-1)[0]; + instanceExpectedFieldType = this.protocols[protocolName].field_types[lastField]; } let placeholder = _t('Find a room…'); if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { - placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer}); - } else if (instance_expected_field_type) { - placeholder = instance_expected_field_type.placeholder; + placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", { + exampleRoom: "#example:" + this.state.roomServer, + }); + } else if (instanceExpectedFieldType) { + placeholder = instanceExpectedFieldType.placeholder; } - let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type); + let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType); if (protocolName) { const instance = instanceForInstanceId(this.protocols, this.state.instanceId); - if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) { + if (this.getFieldsForThirdPartyLocation( + this.state.filterString, + this.protocols[protocolName], + instance, + ) === null) { showJoinButton = false; } } @@ -686,18 +794,18 @@ export default class RoomDirectory extends React.Component { onJoinClick={this.onJoinFromSearchClick} placeholder={placeholder} showJoinButton={showJoinButton} + initialText={this.props.initialText} /> {dropdown}
    ; } const explanation = _t("If you can't find the room you're looking for, ask for an invite or Create a new room.", null, - {a: sub => { - return ({sub}); - }}, + { a: sub => ( + + { sub } + + ) }, ); const title = this.state.selectedCommunityId @@ -725,6 +833,6 @@ export default class RoomDirectory extends React.Component { // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // but works with the objects we get from the public room list -function get_display_alias_for_room(room) { - return room.canonical_alias || (room.aliases ? room.aliases[0] : ""); +function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) { + return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases); } diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 526aecddd7..e8080b4f7b 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 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. @@ -17,26 +17,35 @@ limitations under the License. import * as React from "react"; import { createRef } from "react"; import classNames from "classnames"; +import { Room } from "matrix-js-sdk/src/models/room"; + import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import { ActionPayload } from "../../dispatcher/payloads"; -import { Key } from "../../Keyboard"; import AccessibleButton from "../views/elements/AccessibleButton"; import { Action } from "../../dispatcher/actions"; import RoomListStore from "../../stores/room-list/RoomListStore"; import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition"; +import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore"; interface IProps { isMinimized: boolean; - onVerticalArrow(ev: React.KeyboardEvent): void; - onEnter(ev: React.KeyboardEvent): boolean; + onKeyDown(ev: React.KeyboardEvent): void; + /** + * @returns true if a room has been selected and the search field should be cleared + */ + onSelectRoom(): boolean; } interface IState { query: string; focused: boolean; + inSpaces: boolean; } +@replaceableComponent("structures.RoomSearch") export default class RoomSearch extends React.PureComponent { private dispatcherRef: string; private inputRef: React.RefObject = createRef(); @@ -48,9 +57,13 @@ export default class RoomSearch extends React.PureComponent { this.state = { query: "", focused: false, + inSpaces: false, }; this.dispatcherRef = defaultDispatcher.register(this.onAction); + // clear filter when changing spaces, in future we may wish to maintain a filter per-space + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput); + SpaceStore.instance.on(UPDATE_TOP_LEVEL_SPACES, this.onSpaces); } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { @@ -70,8 +83,16 @@ export default class RoomSearch extends React.PureComponent { public componentWillUnmount() { defaultDispatcher.unregister(this.dispatcherRef); + SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput); + SpaceStore.instance.off(UPDATE_TOP_LEVEL_SPACES, this.onSpaces); } + private onSpaces = (spaces: Room[]) => { + this.setState({ + inSpaces: spaces.length > 0, + }); + }; + private onAction = (payload: ActionPayload) => { if (payload.action === 'view_room' && payload.clear_search) { this.clearInput(); @@ -87,37 +108,45 @@ export default class RoomSearch extends React.PureComponent { }; private openSearch = () => { - defaultDispatcher.dispatch({action: "show_left_panel"}); - defaultDispatcher.dispatch({action: "focus_room_filter"}); + defaultDispatcher.dispatch({ action: "show_left_panel" }); + defaultDispatcher.dispatch({ action: "focus_room_filter" }); }; private onChange = () => { if (!this.inputRef.current) return; - this.setState({query: this.inputRef.current.value}); + this.setState({ query: this.inputRef.current.value }); }; private onFocus = (ev: React.FocusEvent) => { - this.setState({focused: true}); + this.setState({ focused: true }); ev.target.select(); }; private onBlur = (ev: React.FocusEvent) => { - this.setState({focused: false}); + this.setState({ focused: false }); }; private onKeyDown = (ev: React.KeyboardEvent) => { - if (ev.key === Key.ESCAPE) { - this.clearInput(); - defaultDispatcher.fire(Action.FocusComposer); - } else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) { - this.props.onVerticalArrow(ev); - } else if (ev.key === Key.ENTER) { - const shouldClear = this.props.onEnter(ev); - if (shouldClear) { - // wrap in set immediate to delay it so that we don't clear the filter & then change room - setImmediate(() => { - this.clearInput(); - }); + const action = getKeyBindingsManager().getRoomListAction(ev); + switch (action) { + case RoomListAction.ClearSearch: + 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) { + // wrap in set immediate to delay it so that we don't clear the filter & then change room + setImmediate(() => { + this.clearInput(); + }); + } + break; } } }; @@ -135,6 +164,11 @@ export default class RoomSearch extends React.PureComponent { 'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused, }); + let placeholder = _t("Filter"); + if (this.state.inSpaces) { + placeholder = _t("Filter all spaces"); + } + let icon = (
    ); @@ -148,7 +182,7 @@ export default class RoomSearch extends React.PureComponent { onBlur={this.onBlur} onChange={this.onChange} onKeyDown={this.onKeyDown} - placeholder={_t("Search")} + placeholder={placeholder} autoComplete="off" /> ); @@ -164,7 +198,7 @@ export default class RoomSearch extends React.PureComponent { if (this.props.isMinimized) { icon = ( diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index e390be6979..80ea26c3f2 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -1,5 +1,5 @@ /* -Copyright 2015-2020 The Matrix.org Foundation C.I.C. +Copyright 2015-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. @@ -16,41 +16,35 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import Matrix from 'matrix-js-sdk'; import { _t, _td } from '../../languageHandler'; -import * as sdk from '../../index'; -import {MatrixClientPeg} from '../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../MatrixClientPeg'; import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; -import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; -import {Action} from "../../dispatcher/actions"; -import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call'; +import { messageForResourceLimitError } from '../../utils/ErrorUtils'; +import { Action } from "../../dispatcher/actions"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { EventStatus } from "matrix-js-sdk/src/models/event"; +import NotificationBadge from "../views/rooms/NotificationBadge"; +import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import InlineSpinner from "../views/elements/InlineSpinner"; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED_LARGE = 2; -function getUnsentMessages(room) { +export function getUnsentMessages(room) { if (!room) { return []; } return room.getPendingEvents().filter(function(ev) { - return ev.status === Matrix.EventStatus.NOT_SENT; + return ev.status === EventStatus.NOT_SENT; }); } -export default class RoomStatusBar extends React.Component { +@replaceableComponent("structures.RoomStatusBar") +export default class RoomStatusBar extends React.PureComponent { static propTypes = { // the room this statusbar is representing. room: PropTypes.object.isRequired, - // This is true when the user is alone in the room, but has also sent a message. - // Used to suggest to the user to invite someone - sentMessageAndIsAlone: PropTypes.bool, - - // The active call in the room, if any (means we show the call bar - // along with the status of the call) - callState: PropTypes.string, - - // The type of the call in progress, or null if no call is in progress - callType: PropTypes.string, // true if the room is being peeked at. This affects components that shouldn't // logically be shown when peeking, such as a prompt to invite people to a room. @@ -68,10 +62,6 @@ export default class RoomStatusBar extends React.Component { // 'you are alone' bar onInviteClick: PropTypes.func, - // callback for when the user clicks on the 'stop warning me' button in the - // 'you are alone' bar - onStopWarningClick: PropTypes.func, - // callback for when we do something that changes the size of the // status bar. This is used to trigger a re-layout in the parent // component. @@ -90,6 +80,7 @@ export default class RoomStatusBar extends React.Component { syncState: MatrixClientPeg.get().getSyncState(), syncStateData: MatrixClientPeg.get().getSyncStateData(), unsentMessages: getUnsentMessages(this.props.room), + isResending: false, }; componentDidMount() { @@ -122,27 +113,25 @@ export default class RoomStatusBar extends React.Component { }); }; - _showCallBar() { - return (this.props.callState && - (this.props.callState !== CallState.Ended && this.props.callState !== CallState.Ringing) - ); - } - _onResendAllClick = () => { - Resend.resendUnsentEvents(this.props.room); - dis.fire(Action.FocusComposer); + Resend.resendUnsentEvents(this.props.room).then(() => { + this.setState({ isResending: false }); + }); + this.setState({ isResending: true }); + dis.fire(Action.FocusSendMessageComposer); }; _onCancelAllClick = () => { Resend.cancelUnsentEvents(this.props.room); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); }; _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { if (room.roomId !== this.props.room.roomId) return; - + const messages = getUnsentMessages(this.props.room); this.setState({ - unsentMessages: getUnsentMessages(this.props.room), + unsentMessages: messages, + isResending: messages.length > 0 && this.state.isResending, }); }; @@ -159,33 +148,14 @@ export default class RoomStatusBar extends React.Component { // changed - so we use '0' to indicate normal size, and other values to // indicate other sizes. _getSize() { - if (this._shouldShowConnectionError() || - this._showCallBar() || - this.props.sentMessageAndIsAlone - ) { + if (this._shouldShowConnectionError()) { return STATUS_BAR_EXPANDED; - } else if (this.state.unsentMessages.length > 0) { + } else if (this.state.unsentMessages.length > 0 || this.state.isResending) { return STATUS_BAR_EXPANDED_LARGE; } return STATUS_BAR_HIDDEN; } - // return suitable content for the image on the left of the status bar. - _getIndicator() { - if (this._showCallBar()) { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); - return ( - - ); - } - - if (this._shouldShowConnectionError()) { - return null; - } - - return null; - } - _shouldShowConnectionError() { // no conn bar trumps the "some not sent" msg since you can't resend without // a connection! @@ -201,7 +171,6 @@ export default class RoomStatusBar extends React.Component { _getUnsentMessageContent() { const unsentMessages = this.state.unsentMessages; - if (!unsentMessages.length) return null; let title; @@ -231,134 +200,92 @@ export default class RoomStatusBar extends React.Component { } else if (resourceLimitError) { title = messageForResourceLimitError( resourceLimitError.data.limit_type, - resourceLimitError.data.admin_contact, { - 'monthly_active_user': _td( - "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " + - "Please contact your service administrator to continue using the service.", - ), - '': _td( - "Your message wasn't sent because this homeserver has exceeded a resource limit. " + - "Please contact your service administrator to continue using the service.", - ), - }); - } else if ( - unsentMessages.length === 1 && - unsentMessages[0].error && - unsentMessages[0].error.data && - unsentMessages[0].error.data.error - ) { - title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error; + resourceLimitError.data.admin_contact, + { + 'monthly_active_user': _td( + "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " + + "Please contact your service administrator to continue using the service.", + ), + 'hs_disabled': _td( + "Your message wasn't sent because this homeserver has been blocked by it's administrator. " + + "Please contact your service administrator to continue using the service.", + ), + '': _td( + "Your message wasn't sent because this homeserver has exceeded a resource limit. " + + "Please contact your service administrator to continue using the service.", + ), + }, + ); } else { - title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length }); + title = _t('Some of your messages have not been sent'); } - const content = _t("%(count)s Resend all or cancel all " + - "now. You can also select individual messages to resend or cancel.", - { count: unsentMessages.length }, - { - 'resendText': (sub) => - { sub }, - 'cancelText': (sub) => - { sub }, - }, - ); + let buttonRow = <> + + {_t("Delete all")} + + + {_t("Retry all")} + + ; + if (this.state.isResending) { + buttonRow = <> + + {/* span for css */} + {_t("Sending")} + ; + } - return
    - -
    -
    - { title } -
    -
    - { content } + return <> +
    +
    +
    + +
    +
    +
    + { title } +
    +
    + { _t("You can select all or individual messages to retry or delete") } +
    +
    +
    + {buttonRow} +
    -
    ; + ; } - _getCallStatusText() { - switch (this.props.callState) { - case CallState.CreateOffer: - case CallState.InviteSent: - return _t('Calling...'); - case CallState.Connecting: - case CallState.CreateAnswer: - return _t('Call connecting...'); - case CallState.Connected: - return _t('Active call'); - case CallState.WaitLocalMedia: - if (this.props.callType === CallType.Video) { - return _t('Starting camera...'); - } else { - return _t('Starting microphone...'); - } - } - } - - // return suitable content for the main (text) part of the status bar. - _getContent() { + render() { if (this._shouldShowConnectionError()) { return ( -
    - /!\ -
    -
    - { _t('Connectivity to the server has been lost.') } -
    -
    - { _t('Sent messages will be stored until your connection has returned.') } +
    +
    +
    + /!\ +
    +
    + {_t('Connectivity to the server has been lost.')} +
    +
    + {_t('Sent messages will be stored until your connection has returned.')} +
    +
    ); } - if (this.state.unsentMessages.length > 0) { + if (this.state.unsentMessages.length > 0 || this.state.isResending) { return this._getUnsentMessageContent(); } - if (this._showCallBar()) { - return ( -
    - { this._getCallStatusText() } -
    - ); - } - - // If you're alone in the room, and have sent a message, suggest to invite someone - if (this.props.sentMessageAndIsAlone && !this.props.isPeeking) { - return ( -
    - { _t("There's no one else here! Would you like to invite others " + - "or stop warning about the empty room?", - {}, - { - 'inviteText': (sub) => - { sub }, - 'nowarnText': (sub) => - { sub }, - }, - ) } -
    - ); - } - return null; } - - render() { - const content = this._getContent(); - const indicator = this._getIndicator(); - - return ( -
    -
    - { indicator } -
    -
    - { content } -
    -
    - ); - } } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 160e9c0ec6..7e3bcbc962 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -21,59 +21,75 @@ limitations under the License. // - Search results component // - Drag and drop -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import classNames from 'classnames'; -import {Room} from "matrix-js-sdk/src/models/room"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; -import {EventSubscription} from "fbemitter"; +import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { EventSubscription } from "fbemitter"; +import { ISearchResults } from 'matrix-js-sdk/src/@types/search'; import shouldHideEvent from '../../shouldHideEvent'; -import {_t} from '../../languageHandler'; -import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks'; +import { _t } from '../../languageHandler'; +import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; import ResizeNotifier from '../../utils/ResizeNotifier'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; -import * as sdk from '../../index'; -import CallHandler from '../../CallHandler'; +import CallHandler, { PlaceCallType } from '../../CallHandler'; import dis from '../../dispatcher/dispatcher'; -import Tinter from '../../Tinter'; -import rateLimitedFunc from '../../ratelimitedfunc'; -import * as ObjectUtils from '../../ObjectUtils'; import * as Rooms from '../../Rooms'; -import eventSearch, {searchPagination} from '../../Searching'; -import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard'; +import eventSearch, { searchPagination } from '../../Searching'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; import RoomViewStore from '../../stores/RoomViewStore'; -import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; +import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore'; import WidgetEchoStore from '../../stores/WidgetEchoStore'; import SettingsStore from "../../settings/SettingsStore"; +import { Layout } from "../../settings/Layout"; import AccessibleButton from "../views/elements/AccessibleButton"; import RightPanelStore from "../../stores/RightPanelStore"; -import {haveTileForEvent} from "../views/rooms/EventTile"; +import { haveTileForEvent } from "../views/rooms/EventTile"; import RoomContext from "../../contexts/RoomContext"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import {E2EStatus, shieldStatusForRoom} from '../../utils/ShieldUtils'; -import {Action} from "../../dispatcher/actions"; -import {SettingLevel} from "../../settings/SettingLevel"; -import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; -import {IMatrixClientCreds} from "../../MatrixClientPeg"; +import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; +import { Action } from "../../dispatcher/actions"; +import { IMatrixClientCreds } from "../../MatrixClientPeg"; import ScrollPanel from "./ScrollPanel"; import TimelinePanel from "./TimelinePanel"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; -import ForwardMessage from "../views/rooms/ForwardMessage"; -import SearchBar from "../views/rooms/SearchBar"; +import SearchBar, { SearchScope } from "../views/rooms/SearchBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; -import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel"; import AuxPanel from "../views/rooms/AuxPanel"; import RoomHeader from "../views/rooms/RoomHeader"; -import TintableSvg from "../views/elements/TintableSvg"; -import {XOR} from "../../@types/common"; -import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; -import { CallState, CallType, MatrixCall } from "matrix-js-sdk/lib/webrtc/call"; +import { XOR } from "../../@types/common"; +import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import EffectsOverlay from "../views/elements/EffectsOverlay"; +import { containsEmoji } from '../../effects/utils'; +import { CHAT_EFFECTS } from '../../effects'; +import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import WidgetStore from "../../stores/WidgetStore"; -import {UPDATE_EVENT} from "../../stores/AsyncStore"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import Notifier from "../../Notifier"; +import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; +import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; +import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; +import { getKeyBindingsManager, RoomAction } from '../../KeyBindingsManager'; +import { objectHasDiff } from "../../utils/objects"; +import SpaceRoomView from "./SpaceRoomView"; +import { IOpts } from "../../createRoom"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import UIStore from "../../stores/UIStore"; +import EditorStateTransfer from "../../utils/EditorStateTransfer"; +import { throttle } from "lodash"; +import ErrorDialog from '../views/dialogs/ErrorDialog'; +import SearchResultTile from '../views/rooms/SearchResultTile'; +import Spinner from "../views/elements/Spinner"; +import UploadBar from './UploadBar'; +import RoomStatusBar from "./RoomStatusBar"; +import MessageComposer from '../views/rooms/MessageComposer'; +import JumpToBottomButton from "../views/rooms/JumpToBottomButton"; +import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; +import SpaceStore from "../../stores/SpaceStore"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -86,28 +102,11 @@ if (DEBUG) { } interface IProps { - threepidInvite: IThreepidInvite, + threepidInvite: IThreepidInvite; + oobData?: IOOBData; - // Any data about the room that would normally come from the homeserver - // but has been passed out-of-band, eg. the room name and avatar URL - // from an email invite (a workaround for the fact that we can't - // get this information from the HS using an email invite). - // Fields: - // * name (string) The room's name - // * avatarUrl (string) The mxc:// avatar URL for the room - // * inviterName (string) The display name of the person who - // * invited us to the room - oobData?: { - name?: string; - avatarUrl?: string; - inviterName?: string; - }; - - // Servers the RoomView can use to try and assist joins - viaServers?: string[]; - - autoJoin?: boolean; resizeNotifier: ResizeNotifier; + justCreatedOpts?: IOpts; // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) onRegistered?(credentials: IMatrixClientCreds): void; @@ -130,28 +129,19 @@ export interface IState { // Whether to highlight the event scrolled to isInitialEventHighlighted?: boolean; replyToEvent?: MatrixEvent; - forwardingEvent?: MatrixEvent; numUnreadMessages: number; draggingFile: boolean; searching: boolean; searchTerm?: string; - searchScope?: "All" | "Room"; - searchResults?: XOR<{}, { - count: number; - highlights: string[]; - results: MatrixEvent[]; - next_batch: string; // eslint-disable-line camelcase - }>; + searchScope?: SearchScope; + searchResults?: XOR<{}, ISearchResults>; searchHighlights?: string[]; searchInProgress?: boolean; callState?: CallState; guestsCanJoin: boolean; canPeek: boolean; showApps: boolean; - isAlone: boolean; isPeeking: boolean; - showingPinned: boolean; - showReadReceipts: boolean; showRightPanel: boolean; // error object, as from the matrix client/server API // If we failed to load information about the room, @@ -170,28 +160,36 @@ export interface IState { statusBarVisible: boolean; // We load this later by asking the js-sdk to suggest a version for us. // This object is the result of Room#getRecommendedVersion() - upgradeRecommendation?: { - version: string; - needsUpgrade: boolean; - urgent: boolean; - }; + + upgradeRecommendation?: IRecommendedVersion; canReact: boolean; canReply: boolean; - useIRCLayout: boolean; + layout: Layout; + lowBandwidth: boolean; + showReadReceipts: boolean; + showRedactions: boolean; + showJoinLeaves: boolean; + showAvatarChanges: boolean; + showDisplaynameChanges: boolean; matrixClientIsReady: boolean; showUrlPreview?: boolean; e2eStatus?: E2EStatus; rejecting?: boolean; rejectError?: Error; hasPinnedWidgets?: boolean; + dragCounter: number; + // whether or not a spaces context switch brought us here, + // if it did we don't want the room to be marked as read as soon as it is loaded. + wasContextSwitch?: boolean; + editState?: EditorStateTransfer; } +@replaceableComponent("structures.RoomView") export default class RoomView extends React.Component { private readonly dispatcherRef: string; private readonly roomStoreToken: EventSubscription; private readonly rightPanelStoreToken: EventSubscription; - private readonly showReadReceiptsWatchRef: string; - private readonly layoutWatcherRef: string; + private settingWatchers: string[]; private unmounted = false; private permalinkCreators: Record = {}; @@ -221,10 +219,7 @@ export default class RoomView extends React.Component { guestsCanJoin: false, canPeek: false, showApps: false, - isAlone: false, isPeeking: false, - showingPinned: false, - showReadReceipts: true, showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, joining: false, atEndOfLiveTimeline: true, @@ -233,8 +228,15 @@ export default class RoomView extends React.Component { statusBarVisible: false, canReact: false, canReply: false, - useIRCLayout: SettingsStore.getValue("useIRCLayout"), + layout: SettingsStore.getValue("layout"), + lowBandwidth: SettingsStore.getValue("lowBandwidth"), + showReadReceipts: true, + showRedactions: true, + showJoinLeaves: true, + showAvatarChanges: true, + showDisplaynameChanges: true, matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), + dragCounter: 0, }; this.dispatcherRef = dis.register(this.onAction); @@ -250,6 +252,7 @@ export default class RoomView extends React.Component { this.context.on("deviceVerificationChanged", this.onDeviceVerificationChanged); this.context.on("userTrustStatusChanged", this.onUserVerificationChanged); this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged); + this.context.on("Event.decrypted", this.onEventDecrypted); // Start listening for RoomViewStore updates this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); @@ -257,27 +260,27 @@ export default class RoomView extends React.Component { WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); - this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null, - this.onReadReceiptsChange); - this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange); - } - - // TODO: [REACT-WARNING] Move into constructor - // eslint-disable-next-line camelcase - UNSAFE_componentWillMount() { - this.onRoomViewStoreUpdate(true); + this.settingWatchers = [ + SettingsStore.watchSetting("layout", null, () => + this.setState({ layout: SettingsStore.getValue("layout") }), + ), + SettingsStore.watchSetting("lowBandwidth", null, () => + this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }), + ), + ]; } private onWidgetStoreUpdate = () => { if (this.state.room) { this.checkWidgets(this.state.room); } - } + }; private checkWidgets = (room) => { this.setState({ - hasPinnedWidgets: WidgetStore.instance.getPinnedApps(room.roomId).length > 0, - }) + hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0, + showApps: this.shouldShowApps(room), + }); }; private onReadReceiptsChange = () => { @@ -317,13 +320,45 @@ export default class RoomView extends React.Component { initialEventId: RoomViewStore.getInitialEventId(), isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), replyToEvent: RoomViewStore.getQuotingEvent(), - forwardingEvent: RoomViewStore.getForwardingEvent(), // we should only peek once we have a ready client shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(), - showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId), showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), + showRedactions: SettingsStore.getValue("showRedactions", roomId), + showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), + showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), + showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), + wasContextSwitch: RoomViewStore.getWasContextSwitch(), }; + // Add watchers for each of the settings we just looked up + this.settingWatchers = this.settingWatchers.concat([ + SettingsStore.watchSetting("showReadReceipts", null, () => + this.setState({ + showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), + }), + ), + SettingsStore.watchSetting("showRedactions", null, () => + this.setState({ + showRedactions: SettingsStore.getValue("showRedactions", roomId), + }), + ), + SettingsStore.watchSetting("showJoinLeaves", null, () => + this.setState({ + showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), + }), + ), + SettingsStore.watchSetting("showAvatarChanges", null, () => + this.setState({ + showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), + }), + ), + SettingsStore.watchSetting("showDisplaynameChanges", null, () => + this.setState({ + showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), + }), + ), + ]); + if (!initial && this.state.shouldPeek && !newState.shouldPeek) { // Stop peeking because we have joined this room now this.context.stopPeeking(); @@ -414,11 +449,17 @@ export default class RoomView extends React.Component { } private onWidgetEchoStoreUpdate = () => { + if (!this.state.room) return; this.setState({ + hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(this.state.room, Container.Top).length > 0, showApps: this.shouldShowApps(this.state.room), }); }; + private onWidgetLayoutChange = () => { + this.onWidgetEchoStoreUpdate(); // we cheat here by calling the thing that matters + }; + private setupRoom(room: Room, roomId: string, joining: boolean, shouldPeek: boolean) { // if this is an unknown room then we're in one of three states: // - This is a room we can peek into (search engine) (we can /peek) @@ -436,9 +477,7 @@ export default class RoomView extends React.Component { // now not joined because the js-sdk peeking API will clobber our historical room, // making it impossible to indicate a newly joined room. if (!joining && roomId) { - if (this.props.autoJoin) { - this.onJoinButtonClicked(); - } else if (!room && shouldPeek) { + if (!room && shouldPeek) { console.info("Attempting to peek into room %s", roomId); this.setState({ peekLoading: true, @@ -478,13 +517,13 @@ export default class RoomView extends React.Component { } else if (room) { // Stop peeking because we have joined this room previously this.context.stopPeeking(); - this.setState({isPeeking: false}); + this.setState({ isPeeking: false }); } } } private shouldShowApps(room: Room) { - if (!BROWSER_SUPPORTS_SANDBOX) return false; + if (!BROWSER_SUPPORTS_SANDBOX || !room) return false; // Check if user has previously chosen to hide the app drawer for this // room. If so, do not show apps @@ -493,10 +532,15 @@ export default class RoomView extends React.Component { // This is confusing, but it means to say that we default to the tray being // hidden unless the user clicked to open it. - return hideWidgetDrawer === "false"; + const isManuallyShown = hideWidgetDrawer === "false"; + + const widgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top); + return widgets.length > 0 || isManuallyShown; } componentDidMount() { + this.onRoomViewStoreUpdate(true); + const call = this.getCallForRoom(); const callState = call ? call.state : null; this.setState({ @@ -508,13 +552,19 @@ export default class RoomView extends React.Component { this.props.resizeNotifier.on("middlePanelResized", this.onResize); } this.onResize(); - - document.addEventListener("keydown", this.onNativeKeyDown); } shouldComponentUpdate(nextProps, nextState) { - return (!ObjectUtils.shallowEqual(this.props, nextProps) || - !ObjectUtils.shallowEqual(this.state, nextState)); + const hasPropsDiff = objectHasDiff(this.props, nextProps); + + const { upgradeRecommendation, ...state } = this.state; + const { upgradeRecommendation: newUpgradeRecommendation, ...newState } = nextState; + + const hasStateDiff = + newUpgradeRecommendation?.needsUpgrade !== upgradeRecommendation?.needsUpgrade || + objectHasDiff(state, newState); + + return hasPropsDiff || hasStateDiff; } componentDidUpdate() { @@ -523,8 +573,8 @@ export default class RoomView extends React.Component { if (!roomView.ondrop) { roomView.addEventListener('drop', this.onDrop); roomView.addEventListener('dragover', this.onDragOver); - roomView.addEventListener('dragleave', this.onDragLeaveOrEnd); - roomView.addEventListener('dragend', this.onDragLeaveOrEnd); + roomView.addEventListener('dragenter', this.onDragEnter); + roomView.addEventListener('dragleave', this.onDragLeave); } } @@ -568,8 +618,8 @@ export default class RoomView extends React.Component { const roomView = this.roomView.current; roomView.removeEventListener('drop', this.onDrop); roomView.removeEventListener('dragover', this.onDragOver); - roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd); - roomView.removeEventListener('dragend', this.onDragLeaveOrEnd); + roomView.removeEventListener('dragenter', this.onDragEnter); + roomView.removeEventListener('dragleave', this.onDragLeave); } dis.unregister(this.dispatcherRef); if (this.context) { @@ -585,6 +635,7 @@ export default class RoomView extends React.Component { this.context.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged); + this.context.removeListener("Event.decrypted", this.onEventDecrypted); } window.removeEventListener('beforeunload', this.onPageUnload); @@ -592,8 +643,6 @@ export default class RoomView extends React.Component { this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); } - document.removeEventListener("keydown", this.onNativeKeyDown); - // Remove RoomStore listener if (this.roomStoreToken) { this.roomStoreToken.remove(); @@ -606,24 +655,31 @@ export default class RoomView extends React.Component { WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); - if (this.showReadReceiptsWatchRef) { - SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef); + if (this.state.room) { + WidgetLayoutStore.instance.off( + WidgetLayoutStore.emissionForRoom(this.state.room), + this.onWidgetLayoutChange, + ); } - // cancel any pending calls to the rate_limited_funcs - this.updateRoomMembers.cancelPendingCall(); + // cancel any pending calls to the throttled updated + this.updateRoomMembers.cancel(); - // no need to do this as Dir & Settings are now overlays. It just burnt CPU. - // console.log("Tinter.tint from RoomView.unmount"); - // Tinter.tint(); // reset colourscheme - - SettingsStore.unwatchSetting(this.layoutWatcherRef); + for (const watcher of this.settingWatchers) { + SettingsStore.unwatchSetting(watcher); + } } - private onLayoutChange = () => { - this.setState({ - useIRCLayout: SettingsStore.getValue("useIRCLayout"), - }); + private onUserScroll = () => { + if (this.state.initialEventId && this.state.isInitialEventHighlighted) { + dis.dispatch({ + action: 'view_room', + room_id: this.state.room.roomId, + event_id: this.state.initialEventId, + highlighted: false, + replyingToEvent: this.state.replyToEvent, + }); + } }; private onRightPanelStoreUpdate = () => { @@ -642,56 +698,23 @@ export default class RoomView extends React.Component { } }; - // we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire - private onNativeKeyDown = ev => { - let handled = false; - const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); - - switch (ev.key) { - case Key.D: - if (ctrlCmdOnly) { - this.onMuteAudioClick(); - handled = true; - } - break; - - case Key.E: - if (ctrlCmdOnly) { - this.onMuteVideoClick(); - handled = true; - } - break; - } - - if (handled) { - ev.stopPropagation(); - ev.preventDefault(); - } - }; - private onReactKeyDown = ev => { let handled = false; - switch (ev.key) { - case Key.ESCAPE: - if (!ev.altKey && !ev.ctrlKey && !ev.shiftKey && !ev.metaKey) { - this.messagePanel.forgetReadMarker(); - this.jumpToLiveTimeline(); - handled = true; - } + const action = getKeyBindingsManager().getRoomAction(ev); + switch (action) { + case RoomAction.DismissReadMarker: + this.messagePanel.forgetReadMarker(); + this.jumpToLiveTimeline(); + handled = true; break; - case Key.PAGE_UP: - if (!ev.altKey && !ev.ctrlKey && ev.shiftKey && !ev.metaKey) { - this.jumpToReadMarker(); - handled = true; - } + case RoomAction.JumpToOldestUnread: + this.jumpToReadMarker(); + handled = true; break; - case Key.U: // Mac returns lowercase - case Key.U.toUpperCase(): - if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) && ev.shiftKey) { - dis.dispatch({ action: "upload_file" }, true); - handled = true; - } + case RoomAction.UploadFile: + dis.dispatch({ action: "upload_file" }, true); + handled = true; break; } @@ -703,9 +726,8 @@ export default class RoomView extends React.Component { private onAction = payload => { switch (payload.action) { - case 'message_send_failed': case 'message_sent': - this.checkIfAlone(this.state.room); + this.checkDesktopNotifications(); break; case 'post_sticker_message': this.injectSticker( @@ -718,9 +740,9 @@ export default class RoomView extends React.Component { [payload.file], this.state.room.roomId, this.context); break; case 'notifier_enabled': - case 'upload_started': - case 'upload_finished': - case 'upload_canceled': + case Action.UploadStarted: + case Action.UploadFinished: + case Action.UploadCanceled: this.forceUpdate(); break; case 'call_state': { @@ -774,6 +796,38 @@ export default class RoomView extends React.Component { }); } break; + case 'focus_search': + this.onSearchClick(); + break; + + case "edit_event": { + const editState = payload.event ? new EditorStateTransfer(payload.event) : null; + this.setState({ editState }, () => { + if (payload.event) { + this.messagePanel?.scrollToEventIfNeeded(payload.event.getId()); + } + }); + break; + } + + case Action.ComposerInsert: { + // re-dispatch to the correct composer + dis.dispatch({ + ...payload, + action: this.state.editState ? "edit_composer_insert" : "send_composer_insert", + }); + break; + } + + case Action.FocusAComposer: { + // re-dispatch to the correct composer + dis.fire(this.state.editState ? Action.FocusEditMessageComposer : Action.FocusSendMessageComposer); + break; + } + + case "scroll_to_bottom": + this.messagePanel?.jumpToLiveTimeline(); + break; } }; @@ -781,8 +835,7 @@ export default class RoomView extends React.Component { if (this.unmounted) return; // ignore events for other rooms - if (!room) return; - if (!this.state.room || room.roomId != this.state.room.roomId) return; + if (!room || room.roomId !== this.state.room?.roomId) return; // ignore events from filtered timelines if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; @@ -803,18 +856,40 @@ export default class RoomView extends React.Component { // we'll only be showing a spinner. if (this.state.joining) return; + if (!ev.isBeingDecrypted() && !ev.isDecryptionFailure()) { + this.handleEffects(ev); + } + if (ev.getSender() !== this.context.credentials.userId) { // update unread count when scrolled up if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { // no change - } else if (!shouldHideEvent(ev)) { + } else if (!shouldHideEvent(ev, this.state)) { this.setState((state, props) => { - return {numUnreadMessages: state.numUnreadMessages + 1}; + return { numUnreadMessages: state.numUnreadMessages + 1 }; }); } } }; + private onEventDecrypted = (ev: MatrixEvent) => { + if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all + if (ev.getRoomId() !== this.state.room.roomId) return; // not for us + if (ev.isDecryptionFailure()) return; + this.handleEffects(ev); + }; + + private handleEffects = (ev: MatrixEvent) => { + const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room); + if (!notifState.isUnread) return; + + CHAT_EFFECTS.forEach(effect => { + if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) { + dis.dispatch({ action: `effects.${effect.command}` }); + } + }); + }; + private onRoomName = (room: Room) => { if (this.state.room && room.roomId == this.state.room.roomId) { this.forceUpdate(); @@ -837,6 +912,11 @@ export default class RoomView extends React.Component { // called when state.room is first initialised (either at initial load, // after a successful peek, or after we join the room). private onRoomLoaded = (room: Room) => { + if (this.unmounted) return; + // Attach a widget store listener only when we get a room + WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); + this.onWidgetLayoutChange(); // provoke an update + this.calculatePeekRules(room); this.updatePreviewUrlVisibility(room); this.loadMembersIfJoined(room); @@ -847,9 +927,9 @@ export default class RoomView extends React.Component { }; private async calculateRecommendedVersion(room: Room) { - this.setState({ - upgradeRecommendation: await room.getRecommendedVersion(), - }); + const upgradeRecommendation = await room.getRecommendedVersion(); + if (this.unmounted) return; + this.setState({ upgradeRecommendation }); } private async loadMembersIfJoined(room: Room) { @@ -859,7 +939,7 @@ export default class RoomView extends React.Component { try { await room.loadMembersIfNeeded(); if (!this.unmounted) { - this.setState({membersLoaded: true}); + this.setState({ membersLoaded: true }); } } catch (err) { const errorMessage = `Fetching room members for ${room.roomId} failed.` + @@ -887,7 +967,7 @@ export default class RoomView extends React.Component { } } - private updatePreviewUrlVisibility({roomId}: Room) { + private updatePreviewUrlVisibility({ roomId }: Room) { // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit const key = this.context.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; this.setState({ @@ -899,6 +979,15 @@ export default class RoomView extends React.Component { if (!room || room.roomId !== this.state.roomId) { return; } + + // Detach the listener if the room is changing for some reason + if (this.state.room) { + WidgetLayoutStore.instance.off( + WidgetLayoutStore.emissionForRoom(this.state.room), + this.onWidgetLayoutChange, + ); + } + this.setState({ room: room, }, () => { @@ -930,32 +1019,19 @@ export default class RoomView extends React.Component { }; private async updateE2EStatus(room: Room) { - if (!this.context.isRoomEncrypted(room.roomId)) { - return; - } - if (!this.context.isCryptoEnabled()) { - // If crypto is not currently enabled, we aren't tracking devices at all, - // so we don't know what the answer is. Let's error on the safe side and show - // a warning for this case. - this.setState({ - e2eStatus: E2EStatus.Warning, - }); - return; + if (!this.context.isRoomEncrypted(room.roomId)) return; + + // If crypto is not currently enabled, we aren't tracking devices at all, + // so we don't know what the answer is. Let's error on the safe side and show + // a warning for this case. + let e2eStatus = E2EStatus.Warning; + if (this.context.isCryptoEnabled()) { + /* At this point, the user has encryption on and cross-signing on */ + e2eStatus = await shieldStatusForRoom(this.context, room); } - /* At this point, the user has encryption on and cross-signing on */ - this.setState({ - e2eStatus: await shieldStatusForRoom(this.context, room), - }); - } - - private updateTint() { - const room = this.state.room; - if (!room) return; - - console.log("Tinter.tint from updateTint"); - const colorScheme = SettingsStore.getValue("roomColor", room.roomId); - Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color); + if (this.unmounted) return; + this.setState({ e2eStatus }); } private onAccountData = (event: MatrixEvent) => { @@ -969,12 +1045,7 @@ export default class RoomView extends React.Component { private onRoomAccountData = (event: MatrixEvent, room: Room) => { if (room.roomId == this.state.roomId) { const type = event.getType(); - if (type === "org.matrix.room.color_scheme") { - const colorScheme = event.getContent(); - // XXX: we should validate the event - console.log("Tinter.tint from onRoomAccountData"); - Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color); - } else if (type === "org.matrix.room.preview_urls" || type === "im.vector.web.settings") { + if (type === "org.matrix.room.preview_urls" || type === "im.vector.web.settings") { // non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls` this.updatePreviewUrlVisibility(room); } @@ -1001,7 +1072,7 @@ export default class RoomView extends React.Component { return; } - this.updateRoomMembers(member); + this.updateRoomMembers(); }; private onMyMembership = (room: Room, membership: string, oldMembership: string) => { @@ -1018,38 +1089,22 @@ export default class RoomView extends React.Component { const canReact = room.getMyMembership() === "join" && room.currentState.maySendEvent("m.reaction", me); const canReply = room.maySendMessage(); - this.setState({canReact, canReply}); + this.setState({ canReact, canReply }); } } // rate limited because a power level change will emit an event for every member in the room. - private updateRoomMembers = rateLimitedFunc((dueToMember) => { + private updateRoomMembers = throttle(() => { this.updateDMState(); - - let memberCountInfluence = 0; - if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) { - // A member got invited, but the room hasn't detected that change yet. Influence the member - // count by 1 to counteract this. - memberCountInfluence = 1; - } - this.checkIfAlone(this.state.room, memberCountInfluence); - this.updateE2EStatus(this.state.room); - }, 500); + }, 500, { leading: true, trailing: true }); - private checkIfAlone(room: Room, countInfluence?: number) { - let warnedAboutLonelyRoom = false; - if (localStorage) { - warnedAboutLonelyRoom = Boolean(localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId)); + private checkDesktopNotifications() { + const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount(); + // if they are not alone prompt the user about notifications so they don't miss replies + if (memberCount > 1 && Notifier.shouldShowPrompt()) { + showNotificationsToast(true); } - if (warnedAboutLonelyRoom) { - if (this.state.isAlone) this.setState({isAlone: false}); - return; - } - - let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount(); - if (countInfluence) joinedOrInvitedMemberCount += countInfluence; - this.setState({isAlone: joinedOrInvitedMemberCount === 1}); } private updateDMState() { @@ -1063,14 +1118,14 @@ export default class RoomView extends React.Component { } } - private onSearchResultsFillRequest = (backwards: boolean) => { + private onSearchResultsFillRequest = (backwards: boolean): Promise => { if (!backwards) { return Promise.resolve(false); } if (this.state.searchResults.next_batch) { debuglog("requesting more search results"); - const searchPromise = searchPagination(this.state.searchResults); + const searchPromise = searchPagination(this.state.searchResults as ISearchResults); return this.handleSearchResult(searchPromise); } else { debuglog("no more search results"); @@ -1084,14 +1139,6 @@ export default class RoomView extends React.Component { action: 'view_invite', roomId: this.state.room.roomId, }); - this.setState({isAlone: false}); // there's a good chance they'll invite someone - }; - - private onStopAloneWarningClick = () => { - if (localStorage) { - localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, String(true)); - } - this.setState({isAlone: false}); }; private onJoinButtonClicked = () => { @@ -1106,13 +1153,14 @@ export default class RoomView extends React.Component { room_id: this.getRoomId(), }, }); - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); } else { Promise.resolve().then(() => { const signUrl = this.props.threepidInvite?.signUrl; dis.dispatch({ - action: 'join_room', - opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, + action: Action.JoinRoom, + roomId: this.getRoomId(), + opts: { inviteSignUrl: signUrl }, _type: "unknown", // TODO: instrumentation }); return Promise.resolve(); @@ -1134,22 +1182,48 @@ export default class RoomView extends React.Component { this.updateTopUnreadMessagesBar(); }; + private onDragEnter = ev => { + ev.stopPropagation(); + ev.preventDefault(); + + // We always increment the counter no matter the types, because dragging is + // still happening. If we didn't, the drag counter would get out of sync. + this.setState({ dragCounter: this.state.dragCounter + 1 }); + + // See: + // https://docs.w3cub.com/dom/datatransfer/types + // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file + if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) { + this.setState({ draggingFile: true }); + } + }; + + private onDragLeave = ev => { + ev.stopPropagation(); + ev.preventDefault(); + + this.setState({ + dragCounter: this.state.dragCounter - 1, + }); + + if (this.state.dragCounter === 0) { + this.setState({ + draggingFile: false, + }); + } + }; + private onDragOver = ev => { ev.stopPropagation(); ev.preventDefault(); ev.dataTransfer.dropEffect = 'none'; - const items = [...ev.dataTransfer.items]; - if (items.length >= 1) { - const isDraggingFiles = items.every(function(item) { - return item.kind == 'file'; - }); - - if (isDraggingFiles) { - this.setState({ draggingFile: true }); - ev.dataTransfer.dropEffect = 'copy'; - } + // See: + // https://docs.w3cub.com/dom/datatransfer/types + // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file + if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) { + ev.dataTransfer.dropEffect = 'copy'; } }; @@ -1159,19 +1233,17 @@ export default class RoomView extends React.Component { ContentMessages.sharedInstance().sendContentListToRoom( ev.dataTransfer.files, this.state.room.roomId, this.context, ); - this.setState({ draggingFile: false }); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); + + this.setState({ + draggingFile: false, + dragCounter: this.state.dragCounter - 1, + }); }; - private onDragLeaveOrEnd = ev => { - ev.stopPropagation(); - ev.preventDefault(); - this.setState({ draggingFile: false }); - }; - - private injectSticker(url, info, text) { + private injectSticker(url: string, info: object, text: string) { if (this.context.isGuest()) { - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); return; } @@ -1184,7 +1256,7 @@ export default class RoomView extends React.Component { }); } - private onSearch = (term: string, scope) => { + private onSearch = (term: string, scope: SearchScope) => { this.setState({ searchTerm: term, searchScope: scope, @@ -1205,14 +1277,14 @@ export default class RoomView extends React.Component { this.searchId = new Date().getTime(); let roomId; - if (scope === "Room") roomId = this.state.room.roomId; + if (scope === SearchScope.Room) roomId = this.state.room.roomId; debuglog("sending search request"); const searchPromise = eventSearch(term, roomId); this.handleSearchResult(searchPromise); }; - private handleSearchResult(searchPromise: Promise) { + private handleSearchResult(searchPromise: Promise): Promise { // keep a record of the current search id, so that if the search terms // change before we get a response, we can ignore the results. const localSearchId = this.searchId; @@ -1225,7 +1297,7 @@ export default class RoomView extends React.Component { debuglog("search complete"); if (this.unmounted || !this.state.searching || this.searchId != localSearchId) { console.error("Discarding stale search results"); - return; + return false; } // postgres on synapse returns us precise details of the strings @@ -1250,13 +1322,13 @@ export default class RoomView extends React.Component { searchResults: results, }); }, (error) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Search failed", error); Modal.createTrackedDialog('Search failed', '', ErrorDialog, { title: _t("Search failed"), description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or search timed out :(")), }); + return false; }).finally(() => { this.setState({ searchInProgress: false, @@ -1265,9 +1337,6 @@ export default class RoomView extends React.Component { } private getSearchResultTiles() { - const SearchResultTile = sdk.getComponent('rooms.SearchResultTile'); - const Spinner = sdk.getComponent("elements.Spinner"); - // XXX: todo: merge overlapping results somehow? // XXX: why doesn't searching on name work? @@ -1348,30 +1417,16 @@ export default class RoomView extends React.Component { return ret; } - private onPinnedClick = () => { - const nowShowingPinned = !this.state.showingPinned; - const roomId = this.state.room.roomId; - this.setState({showingPinned: nowShowingPinned, searching: false}); - SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned); - }; - - private onSettingsClick = () => { + private onCallPlaced = (type: PlaceCallType) => { dis.dispatch({ - action: Action.SetRightPanelPhase, - phase: RightPanelPhases.RoomSummary, + action: 'place_call', + type: type, + room_id: this.state.room.roomId, }); }; - private onCancelClick = () => { - console.log("updateTint from onCancelClick"); - this.updateTint(); - if (this.state.forwardingEvent) { - dis.dispatch({ - action: 'forward_event', - event: null, - }); - } - dis.fire(Action.FocusComposer); + private onSettingsClick = () => { + dis.dispatch({ action: "open_room_settings" }); }; private onAppsClick = () => { @@ -1381,13 +1436,6 @@ export default class RoomView extends React.Component { }); }; - private onLeaveClick = () => { - dis.dispatch({ - action: 'leave_room', - room_id: this.state.room.roomId, - }); - }; - private onForgetClick = () => { dis.dispatch({ action: 'forget_room', @@ -1395,12 +1443,12 @@ export default class RoomView extends React.Component { }); }; - private onRejectButtonClicked = ev => { + private onRejectButtonClicked = () => { this.setState({ rejecting: true, }); this.context.leave(this.state.roomId).then(() => { - dis.dispatch({ action: 'view_next_room' }); + dis.dispatch({ action: 'view_home_page' }); this.setState({ rejecting: false, }); @@ -1408,7 +1456,6 @@ export default class RoomView extends React.Component { console.error("Failed to reject invite: %s", error); const msg = error.message ? error.message : JSON.stringify(error); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, { title: _t("Failed to reject invite"), description: msg, @@ -1434,7 +1481,7 @@ export default class RoomView extends React.Component { await this.context.setIgnoredUsers(ignoredUsers); await this.context.leave(this.state.roomId); - dis.dispatch({ action: 'view_next_room' }); + dis.dispatch({ action: 'view_home_page' }); this.setState({ rejecting: false, }); @@ -1442,7 +1489,6 @@ export default class RoomView extends React.Component { console.error("Failed to reject invite: %s", error); const msg = error.message ? error.message : JSON.stringify(error); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, { title: _t("Failed to reject invite"), description: msg, @@ -1455,7 +1501,7 @@ export default class RoomView extends React.Component { } }; - private onRejectThreepidInviteButtonClicked = ev => { + private onRejectThreepidInviteButtonClicked = () => { // We can reject 3pid invites in the same way that we accept them, // using /leave rather than /join. In the short term though, we // just ignore them. @@ -1466,7 +1512,6 @@ export default class RoomView extends React.Component { private onSearchClick = () => { this.setState({ searching: !this.state.searching, - showingPinned: false, }); }; @@ -1479,8 +1524,19 @@ export default class RoomView extends React.Component { // jump down to the bottom of this room, where new events are arriving private jumpToLiveTimeline = () => { - this.messagePanel.jumpToLiveTimeline(); - dis.fire(Action.FocusComposer); + if (this.state.initialEventId && this.state.isInitialEventHighlighted) { + // If we were viewing a highlighted event, firing view_room without + // an event will take care of both clearing the URL fragment and + // jumping to the bottom + dis.dispatch({ + action: 'view_room', + room_id: this.state.room.roomId, + }); + } else { + // Otherwise we have to jump manually + this.messagePanel.jumpToLiveTimeline(); + dis.fire(Action.FocusSendMessageComposer); + } }; // jump up to wherever our read marker is @@ -1502,14 +1558,14 @@ export default class RoomView extends React.Component { const showBar = this.messagePanel.canJumpToReadMarker(); if (this.state.showTopUnreadMessagesBar != showBar) { - this.setState({showTopUnreadMessagesBar: showBar}); + this.setState({ showTopUnreadMessagesBar: showBar }); } }; // get the current scroll position of the room, so that it can be // restored when we switch back to it. // - private getScrollState() { + private getScrollState(): ScrollState { const messagePanel = this.messagePanel; if (!messagePanel) return null; @@ -1553,59 +1609,30 @@ export default class RoomView extends React.Component { // a maxHeight on the underlying remote video tag. // header + footer + status + give us at least 120px of scrollback at all times. - let auxPanelMaxHeight = window.innerHeight - + let auxPanelMaxHeight = UIStore.instance.windowHeight - (54 + // height of RoomHeader 36 + // height of the status area - 51 + // minimum height of the message compmoser + 51 + // minimum height of the message composer 120); // amount of desired scrollback // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway // but it's better than the video going missing entirely if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; - this.setState({auxPanelMaxHeight: auxPanelMaxHeight}); - }; - - private onFullscreenClick = () => { - dis.dispatch({ - action: 'video_fullscreen', - fullscreen: true, - }, true); - }; - - private onMuteAudioClick = () => { - const call = this.getCallForRoom(); - if (!call) { - return; + if (this.state.auxPanelMaxHeight !== auxPanelMaxHeight) { + this.setState({ auxPanelMaxHeight }); } - const newState = !call.isMicrophoneMuted(); - call.setMicrophoneMuted(newState); - this.forceUpdate(); // TODO: just update the voip buttons - }; - - private onMuteVideoClick = () => { - const call = this.getCallForRoom(); - if (!call) { - return; - } - const newState = !call.isLocalVideoMuted(); - call.setLocalVideoMuted(newState); - this.forceUpdate(); // TODO: just update the voip buttons }; private onStatusBarVisible = () => { - if (this.unmounted) return; - this.setState({ - statusBarVisible: true, - }); + if (this.unmounted || this.state.statusBarVisible) return; + this.setState({ statusBarVisible: true }); }; private onStatusBarHidden = () => { // This is currently not desired as it is annoying if it keeps expanding and collapsing - if (this.unmounted) return; - this.setState({ - statusBarVisible: false, - }); + if (this.unmounted || !this.state.statusBarVisible) return; + this.setState({ statusBarVisible: false }); }; /** @@ -1640,10 +1667,6 @@ export default class RoomView extends React.Component { // otherwise react calls it with null on each update. private gatherTimelinePanelRef = r => { this.messagePanel = r; - if (r) { - console.log("updateTint from RoomView.gatherTimelinePanelRef"); - this.updateTint(); - } }; private getOldRoom() { @@ -1662,7 +1685,7 @@ export default class RoomView extends React.Component { onHiddenHighlightsClick = () => { const oldRoom = this.getOldRoom(); if (!oldRoom) return; - dis.dispatch({action: "view_room", room_id: oldRoom.roomId}); + dis.dispatch({ action: "view_room", room_id: oldRoom.roomId }); }; render() { @@ -1718,7 +1741,8 @@ export default class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); - if (myMembership == 'invite') { + // SpaceRoomView handles invites itself + if (myMembership === "invite" && (!SpaceStore.spacesEnabled || !this.state.room.isSpaceRoom())) { if (this.state.joining || this.state.rejecting) { return ( @@ -1763,6 +1787,19 @@ export default class RoomView extends React.Component { } } + let fileDropTarget = null; + if (this.state.draggingFile) { + fileDropTarget = ( +
    + + { _t("Drop file here to upload") } +
    + ); + } + // We have successfully loaded this room, and are not previewing. // Display the "normal" room view. @@ -1783,19 +1820,13 @@ export default class RoomView extends React.Component { let isStatusAreaExpanded = true; if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { - const UploadBar = sdk.getComponent('structures.UploadBar'); statusBar = ; } else if (!this.state.searchResults) { - const RoomStatusBar = sdk.getComponent('structures.RoomStatusBar'); isStatusAreaExpanded = this.state.statusBarVisible; statusBar = ; @@ -1812,11 +1843,7 @@ export default class RoomView extends React.Component { let aux = null; let previewBar; - let hideCancel = false; - if (this.state.forwardingEvent) { - aux = ; - } else if (this.state.searching) { - hideCancel = true; // has own cancel + if (this.state.searching) { aux = { />; } else if (showRoomUpgradeBar) { aux = ; - hideCancel = true; - } else if (this.state.showingPinned) { - hideCancel = true; // has own cancel - aux = ; } else if (myMembership !== "join") { // We do have a room object for this room, but we're not currently in it. // We may have a 3rd party invite to it. @@ -1837,7 +1860,6 @@ export default class RoomView extends React.Component { inviterName = this.props.oobData.inviterName; } const invitedEmail = this.props.threepidInvite?.toEmail; - hideCancel = true; previewBar = ( { room={this.state.room} /> ); - if (!this.state.canPeek) { + if (!this.state.canPeek && (!SpaceStore.spacesEnabled || !this.state.room?.isSpaceRoom())) { return (
    { previewBar } @@ -1867,18 +1889,29 @@ export default class RoomView extends React.Component { > {_t( "You have %(count)s unread notifications in a prior version of this room.", - {count: hiddenHighlightCount}, + { count: hiddenHighlightCount }, )} ); } + if (this.state.room?.isSpaceRoom()) { + return ; + } + const auxPanel = ( { myMembership === 'join' && !this.state.searchResults ); if (canSpeak) { - const MessageComposer = sdk.getComponent('rooms.MessageComposer'); messageComposer = { }; } - if (activeCall) { - let zoomButton; let videoMuteButton; - - if (activeCall.type === CallType.Video) { - zoomButton = ( -
    - -
    - ); - - videoMuteButton = -
    - -
    ; - } - const voiceMuteButton = -
    - -
    ; - - // wrap the existing status bar into a 'callStatusBar' which adds more knobs. - statusBar = -
    - { voiceMuteButton } - { videoMuteButton } - { zoomButton } - { statusBar } -
    ; - } - // if we have search results, we keep the messagepanel (so that it preserves its // scroll state), but hide it. let searchResultsPanel; @@ -1994,19 +1974,16 @@ export default class RoomView extends React.Component { hideMessagePanel = true; } - const shouldHighlight = this.state.isInitialEventHighlighted; let highlightedEventId = null; - if (this.state.forwardingEvent) { - highlightedEventId = this.state.forwardingEvent.getId(); - } else if (shouldHighlight) { + if (this.state.isInitialEventHighlighted) { highlightedEventId = this.state.initialEventId; } const messagePanelClassNames = classNames( "mx_RoomView_messagePanel", { - "mx_IRCLayout": this.state.useIRCLayout, - "mx_GroupLayout": !this.state.useIRCLayout, + "mx_IRCLayout": this.state.layout == Layout.IRC, + "mx_GroupLayout": this.state.layout == Layout.Group, }); // console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview); @@ -2016,12 +1993,14 @@ export default class RoomView extends React.Component { timelineSet={this.state.room.getUnfilteredTimelineSet()} showReadReceipts={this.state.showReadReceipts} manageReadReceipts={!this.state.isPeeking} + sendReadReceiptOnLoad={!this.state.wasContextSwitch} manageReadMarkers={!this.state.isPeeking} hidden={hideMessagePanel} highlightedEventId={highlightedEventId} eventId={this.state.initialEventId} eventPixelOffset={this.state.initialEventPixelOffset} onScroll={this.onMessageListScroll} + onUserScroll={this.onUserScroll} onReadMarkerUpdated={this.updateTopUnreadMessagesBar} showUrlPreview = {this.state.showUrlPreview} className={messagePanelClassNames} @@ -2029,13 +2008,13 @@ export default class RoomView extends React.Component { permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} resizeNotifier={this.props.resizeNotifier} showReactions={true} - useIRCLayout={this.state.useIRCLayout} + layout={this.state.layout} + editState={this.state.editState} />); let topUnreadMessagesBar = null; // Do not show TopUnreadMessagesBar if we have search results showing, it makes no sense if (this.state.showTopUnreadMessagesBar && !this.state.searchResults) { - const TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar'); topUnreadMessagesBar = ( ); @@ -2043,11 +2022,11 @@ export default class RoomView extends React.Component { let jumpToBottom; // Do not show JumpToBottomButton if we have search results showing, it makes no sense if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) { - const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton'); jumpToBottom = ( 0} + highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0} numUnreadMessages={this.state.numUnreadMessages} onScrollToBottomClick={this.jumpToLiveTimeline} + roomId={this.state.roomId} />); } @@ -2068,9 +2047,14 @@ export default class RoomView extends React.Component { mx_RoomView_inCall: Boolean(activeCall), }); + const showChatEffects = SettingsStore.getValue('showChatEffects'); + return (
    + {showChatEffects && this.roomView.current && + + } { inRoom={myMembership === 'join'} onSearchClick={this.onSearchClick} onSettingsClick={this.onSettingsClick} - onPinnedClick={this.onPinnedClick} - onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null} onForgetClick={(myMembership === "leave") ? this.onForgetClick : null} - onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} e2eStatus={this.state.e2eStatus} onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null} appsShown={this.state.showApps} + onCallPlaced={this.onCallPlaced} />
    {auxPanel}
    + {fileDropTarget} {topUnreadMessagesBar} {jumpToBottom} {messagePanel} diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.tsx similarity index 69% rename from src/components/structures/ScrollPanel.js rename to src/components/structures/ScrollPanel.tsx index 744400df3c..1d16755106 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.tsx @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015 - 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. @@ -14,16 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from "react"; -import PropTypes from 'prop-types'; -import { Key } from '../../Keyboard'; +import React, { createRef, CSSProperties, ReactNode, SyntheticEvent, KeyboardEvent } from "react"; import Timer from '../../utils/Timer'; import AutoHideScrollbar from "./AutoHideScrollbar"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { getKeyBindingsManager, RoomAction } from "../../KeyBindingsManager"; +import ResizeNotifier from "../../utils/ResizeNotifier"; const DEBUG_SCROLL = false; // The amount of extra scroll distance to allow prior to unfilling. -// See _getExcessHeight. +// See getExcessHeight. const UNPAGINATION_PADDING = 6000; // The number of milliseconds to debounce calls to onUnfillRequest, to prevent // many scroll events causing many unfilling requests. @@ -42,6 +43,75 @@ if (DEBUG_SCROLL) { debuglog = function() {}; } +interface IProps { + /* stickyBottom: if set to true, then once the user hits the bottom of + * the list, any new children added to the list will cause the list to + * scroll down to show the new element, rather than preserving the + * existing view. + */ + stickyBottom?: boolean; + + /* startAtBottom: if set to true, the view is assumed to start + * scrolled to the bottom. + * XXX: It's likely this is unnecessary and can be derived from + * stickyBottom, but I'm adding an extra parameter to ensure + * behaviour stays the same for other uses of ScrollPanel. + * If so, let's remove this parameter down the line. + */ + startAtBottom?: boolean; + + /* className: classnames to add to the top-level div + */ + className?: string; + + /* style: styles to add to the top-level div + */ + style?: CSSProperties; + + /* resizeNotifier: ResizeNotifier to know when middle column has changed size + */ + resizeNotifier?: ResizeNotifier; + + /* fixedChildren: allows for children to be passed which are rendered outside + * of the wrapper + */ + fixedChildren?: ReactNode; + + /* onFillRequest(backwards): a callback which is called on scroll when + * the user nears the start (backwards = true) or end (backwards = + * false) of the list. + * + * This should return a promise; no more calls will be made until the + * promise completes. + * + * The promise should resolve to true if there is more data to be + * retrieved in this direction (in which case onFillRequest may be + * called again immediately), or false if there is no more data in this + * directon (at this time) - which will stop the pagination cycle until + * the user scrolls again. + */ + onFillRequest?(backwards: boolean): Promise; + + /* onUnfillRequest(backwards): a callback which is called on scroll when + * there are children elements that are far out of view and could be removed + * without causing pagination to occur. + * + * This function should accept a boolean, which is true to indicate the back/top + * of the panel and false otherwise, and a scroll token, which refers to the + * first element to remove if removing from the front/bottom, and last element + * to remove if removing from the back/top. + */ + onUnfillRequest?(backwards: boolean, scrollToken: string): void; + + /* onScroll: a callback which is called whenever any scroll happens. + */ + onScroll?(event: Event): void; + + /* onUserScroll: callback which is called when the user interacts with the room timeline + */ + onUserScroll?(event: SyntheticEvent): void; +} + /* This component implements an intelligent scrolling list. * * It wraps a list of
  • children; when items are added to the start or end @@ -83,92 +153,54 @@ if (DEBUG_SCROLL) { * offset as normal. */ -export default class ScrollPanel extends React.Component { - static propTypes = { - /* stickyBottom: if set to true, then once the user hits the bottom of - * the list, any new children added to the list will cause the list to - * scroll down to show the new element, rather than preserving the - * existing view. - */ - stickyBottom: PropTypes.bool, +export interface IScrollState { + stuckAtBottom: boolean; + trackedNode?: HTMLElement; + trackedScrollToken?: string; + bottomOffset?: number; + pixelOffset?: number; +} - /* startAtBottom: if set to true, the view is assumed to start - * scrolled to the bottom. - * XXX: It's likely this is unnecessary and can be derived from - * stickyBottom, but I'm adding an extra parameter to ensure - * behaviour stays the same for other uses of ScrollPanel. - * If so, let's remove this parameter down the line. - */ - startAtBottom: PropTypes.bool, - - /* onFillRequest(backwards): a callback which is called on scroll when - * the user nears the start (backwards = true) or end (backwards = - * false) of the list. - * - * This should return a promise; no more calls will be made until the - * promise completes. - * - * The promise should resolve to true if there is more data to be - * retrieved in this direction (in which case onFillRequest may be - * called again immediately), or false if there is no more data in this - * directon (at this time) - which will stop the pagination cycle until - * the user scrolls again. - */ - onFillRequest: PropTypes.func, - - /* onUnfillRequest(backwards): a callback which is called on scroll when - * there are children elements that are far out of view and could be removed - * without causing pagination to occur. - * - * This function should accept a boolean, which is true to indicate the back/top - * of the panel and false otherwise, and a scroll token, which refers to the - * first element to remove if removing from the front/bottom, and last element - * to remove if removing from the back/top. - */ - onUnfillRequest: PropTypes.func, - - /* onScroll: a callback which is called whenever any scroll happens. - */ - onScroll: PropTypes.func, - - /* className: classnames to add to the top-level div - */ - className: PropTypes.string, - - /* style: styles to add to the top-level div - */ - style: PropTypes.object, - - /* resizeNotifier: ResizeNotifier to know when middle column has changed size - */ - resizeNotifier: PropTypes.object, - - /* fixedChildren: allows for children to be passed which are rendered outside - * of the wrapper - */ - fixedChildren: PropTypes.node, - }; +interface IPreventShrinkingState { + offsetFromBottom: number; + offsetNode: HTMLElement; +} +@replaceableComponent("structures.ScrollPanel") +export default class ScrollPanel extends React.Component { static defaultProps = { stickyBottom: true, startAtBottom: true, - onFillRequest: function(backwards) { return Promise.resolve(false); }, - onUnfillRequest: function(backwards, scrollToken) {}, + onFillRequest: function(backwards: boolean) { return Promise.resolve(false); }, + onUnfillRequest: function(backwards: boolean, scrollToken: string) {}, onScroll: function() {}, }; - constructor(props) { - super(props); + private readonly pendingFillRequests: Record<"b" | "f", boolean> = { + b: null, + f: null, + }; + private readonly itemlist = createRef(); + private unmounted = false; + private scrollTimeout: Timer; + private isFilling: boolean; + private fillRequestWhileRunning: boolean; + private scrollState: IScrollState; + private preventShrinkingState: IPreventShrinkingState; + private unfillDebouncer: number; + private bottomGrowth: number; + private pages: number; + private heightUpdateInProgress: boolean; + private divScroll: HTMLDivElement; - this._pendingFillRequests = {b: null, f: null}; + constructor(props, context) { + super(props, context); if (this.props.resizeNotifier) { this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); } this.resetScrollState(); - - this._itemlist = createRef(); } componentDidMount() { @@ -197,18 +229,18 @@ export default class ScrollPanel extends React.Component { } } - onScroll = ev => { + private onScroll = ev => { // skip scroll events caused by resizing if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return; - debuglog("onScroll", this._getScrollNode().scrollTop); - this._scrollTimeout.restart(); - this._saveScrollState(); + debuglog("onScroll", this.getScrollNode().scrollTop); + this.scrollTimeout.restart(); + this.saveScrollState(); this.updatePreventShrinking(); this.props.onScroll(ev); this.checkFillState(); }; - onResize = () => { + private onResize = () => { debuglog("onResize"); this.checkScroll(); // update preventShrinkingState if present @@ -219,11 +251,11 @@ export default class ScrollPanel extends React.Component { // after an update to the contents of the panel, check that the scroll is // where it ought to be, and set off pagination requests if necessary. - checkScroll = () => { + public checkScroll = () => { if (this.unmounted) { return; } - this._restoreSavedScrollState(); + this.restoreSavedScrollState(); this.checkFillState(); }; @@ -232,8 +264,8 @@ export default class ScrollPanel extends React.Component { // note that this is independent of the 'stuckAtBottom' state - it is simply // about whether the content is scrolled down right now, irrespective of // whether it will stay that way when the children update. - isAtBottom = () => { - const sn = this._getScrollNode(); + public isAtBottom = () => { + const sn = this.getScrollNode(); // fractional values (both too big and too small) // for scrollTop happen on certain browsers/platforms // when scrolled all the way down. E.g. Chrome 72 on debian. @@ -272,10 +304,10 @@ export default class ScrollPanel extends React.Component { // |#########| - | // |#########| | // `---------' - - _getExcessHeight(backwards) { - const sn = this._getScrollNode(); - const contentHeight = this._getMessagesHeight(); - const listHeight = this._getListHeight(); + private getExcessHeight(backwards: boolean): number { + const sn = this.getScrollNode(); + const contentHeight = this.getMessagesHeight(); + const listHeight = this.getListHeight(); const clippedHeight = contentHeight - listHeight; const unclippedScrollTop = sn.scrollTop + clippedHeight; @@ -287,13 +319,13 @@ export default class ScrollPanel extends React.Component { } // check the scroll state and send out backfill requests if necessary. - checkFillState = async (depth=0) => { + public checkFillState = async (depth = 0): Promise => { if (this.unmounted) { return; } const isFirstCall = depth === 0; - const sn = this._getScrollNode(); + const sn = this.getScrollNode(); // if there is less than a screenful of messages above or below the // viewport, try to get some more messages. @@ -324,17 +356,17 @@ export default class ScrollPanel extends React.Component { // do make a note when a new request comes in while already running one, // so we can trigger a new chain of calls once done. if (isFirstCall) { - if (this._isFilling) { - debuglog("_isFilling: not entering while request is ongoing, marking for a subsequent request"); - this._fillRequestWhileRunning = true; + if (this.isFilling) { + debuglog("isFilling: not entering while request is ongoing, marking for a subsequent request"); + this.fillRequestWhileRunning = true; return; } - debuglog("_isFilling: setting"); - this._isFilling = true; + debuglog("isFilling: setting"); + this.isFilling = true; } - const itemlist = this._itemlist.current; - const firstTile = itemlist && itemlist.firstElementChild; + const itemlist = this.itemlist.current; + const firstTile = itemlist && itemlist.firstElementChild as HTMLElement; const contentTop = firstTile && firstTile.offsetTop; const fillPromises = []; @@ -342,13 +374,13 @@ export default class ScrollPanel extends React.Component { // try backward filling if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) { // need to back-fill - fillPromises.push(this._maybeFill(depth, true)); + fillPromises.push(this.maybeFill(depth, true)); } // if scrollTop gets to 2 screens from the end (so 1 screen below viewport), // try forward filling if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) { // need to forward-fill - fillPromises.push(this._maybeFill(depth, false)); + fillPromises.push(this.maybeFill(depth, false)); } if (fillPromises.length) { @@ -359,26 +391,26 @@ export default class ScrollPanel extends React.Component { } } if (isFirstCall) { - debuglog("_isFilling: clearing"); - this._isFilling = false; + debuglog("isFilling: clearing"); + this.isFilling = false; } - if (this._fillRequestWhileRunning) { - this._fillRequestWhileRunning = false; + if (this.fillRequestWhileRunning) { + this.fillRequestWhileRunning = false; this.checkFillState(); } }; // check if unfilling is possible and send an unfill request if necessary - _checkUnfillState(backwards) { - let excessHeight = this._getExcessHeight(backwards); + private checkUnfillState(backwards: boolean): void { + let excessHeight = this.getExcessHeight(backwards); if (excessHeight <= 0) { return; } const origExcessHeight = excessHeight; - const tiles = this._itemlist.current.children; + const tiles = this.itemlist.current.children; // The scroll token of the first/last tile to be unpaginated let markerScrollToken = null; @@ -407,11 +439,11 @@ export default class ScrollPanel extends React.Component { if (markerScrollToken) { // Use a debouncer to prevent multiple unfill calls in quick succession // This is to make the unfilling process less aggressive - if (this._unfillDebouncer) { - clearTimeout(this._unfillDebouncer); + if (this.unfillDebouncer) { + clearTimeout(this.unfillDebouncer); } - this._unfillDebouncer = setTimeout(() => { - this._unfillDebouncer = null; + this.unfillDebouncer = setTimeout(() => { + this.unfillDebouncer = null; debuglog("unfilling now", backwards, origExcessHeight); this.props.onUnfillRequest(backwards, markerScrollToken); }, UNFILL_REQUEST_DEBOUNCE_MS); @@ -419,9 +451,9 @@ export default class ScrollPanel extends React.Component { } // check if there is already a pending fill request. If not, set one off. - _maybeFill(depth, backwards) { + private maybeFill(depth: number, backwards: boolean): Promise { const dir = backwards ? 'b' : 'f'; - if (this._pendingFillRequests[dir]) { + if (this.pendingFillRequests[dir]) { debuglog("Already a "+dir+" fill in progress - not starting another"); return; } @@ -430,7 +462,7 @@ export default class ScrollPanel extends React.Component { // onFillRequest can end up calling us recursively (via onScroll // events) so make sure we set this before firing off the call. - this._pendingFillRequests[dir] = true; + this.pendingFillRequests[dir] = true; // wait 1ms before paginating, because otherwise // this will block the scroll event handler for +700ms @@ -439,13 +471,13 @@ export default class ScrollPanel extends React.Component { return new Promise(resolve => setTimeout(resolve, 1)).then(() => { return this.props.onFillRequest(backwards); }).finally(() => { - this._pendingFillRequests[dir] = false; + this.pendingFillRequests[dir] = false; }).then((hasMoreResults) => { if (this.unmounted) { return; } // Unpaginate once filling is complete - this._checkUnfillState(!backwards); + this.checkUnfillState(!backwards); debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults); if (hasMoreResults) { @@ -471,7 +503,7 @@ export default class ScrollPanel extends React.Component { * the number of pixels the bottom of the tracked child is above the * bottom of the scroll panel. */ - getScrollState = () => this.scrollState; + public getScrollState = (): IScrollState => this.scrollState; /* reset the saved scroll state. * @@ -485,35 +517,35 @@ export default class ScrollPanel extends React.Component { * no use if no children exist yet, or if you are about to replace the * child list.) */ - resetScrollState = () => { + public resetScrollState = (): void => { this.scrollState = { stuckAtBottom: this.props.startAtBottom, }; - this._bottomGrowth = 0; - this._pages = 0; - this._scrollTimeout = new Timer(100); - this._heightUpdateInProgress = false; + this.bottomGrowth = 0; + this.pages = 0; + this.scrollTimeout = new Timer(100); + this.heightUpdateInProgress = false; }; /** * jump to the top of the content. */ - scrollToTop = () => { - this._getScrollNode().scrollTop = 0; - this._saveScrollState(); + public scrollToTop = (): void => { + this.getScrollNode().scrollTop = 0; + this.saveScrollState(); }; /** * jump to the bottom of the content. */ - scrollToBottom = () => { + public scrollToBottom = (): void => { // the easiest way to make sure that the scroll state is correctly // saved is to do the scroll, then save the updated state. (Calculating // it ourselves is hard, and we can't rely on an onScroll callback // happening, since there may be no user-visible change here). - const sn = this._getScrollNode(); + const sn = this.getScrollNode(); sn.scrollTop = sn.scrollHeight; - this._saveScrollState(); + this.saveScrollState(); }; /** @@ -521,43 +553,41 @@ export default class ScrollPanel extends React.Component { * * @param {number} mult: -1 to page up, +1 to page down */ - scrollRelative = mult => { - const scrollNode = this._getScrollNode(); - const delta = mult * scrollNode.clientHeight * 0.5; + public scrollRelative = (mult: number): void => { + const scrollNode = this.getScrollNode(); + const delta = mult * scrollNode.clientHeight * 0.9; scrollNode.scrollBy(0, delta); - this._saveScrollState(); + this.saveScrollState(); }; /** * Scroll up/down in response to a scroll key * @param {object} ev the keyboard event */ - handleScrollKey = ev => { - switch (ev.key) { - case Key.PAGE_UP: - if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollRelative(-1); - } + public handleScrollKey = (ev: KeyboardEvent) => { + let isScrolling = false; + const roomAction = getKeyBindingsManager().getRoomAction(ev); + switch (roomAction) { + case RoomAction.ScrollUp: + this.scrollRelative(-1); + isScrolling = true; break; - - case Key.PAGE_DOWN: - if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollRelative(1); - } + case RoomAction.RoomScrollDown: + this.scrollRelative(1); + isScrolling = true; break; - - case Key.HOME: - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollToTop(); - } + case RoomAction.JumpToFirstMessage: + this.scrollToTop(); + isScrolling = true; break; - - case Key.END: - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollToBottom(); - } + case RoomAction.JumpToLatestMessage: + this.scrollToBottom(); + isScrolling = true; break; } + if (isScrolling && this.props.onUserScroll) { + this.props.onUserScroll(ev); + } }; /* Scroll the panel to bring the DOM node with the scroll token @@ -571,17 +601,17 @@ export default class ScrollPanel extends React.Component { * node (specifically, the bottom of it) will be positioned. If omitted, it * defaults to 0. */ - scrollToToken = (scrollToken, pixelOffset, offsetBase) => { + public scrollToToken = (scrollToken: string, pixelOffset: number, offsetBase: number): void => { pixelOffset = pixelOffset || 0; offsetBase = offsetBase || 0; - // set the trackedScrollToken so we can get the node through _getTrackedNode + // set the trackedScrollToken so we can get the node through getTrackedNode this.scrollState = { stuckAtBottom: false, trackedScrollToken: scrollToken, }; - const trackedNode = this._getTrackedNode(); - const scrollNode = this._getScrollNode(); + const trackedNode = this.getTrackedNode(); + const scrollNode = this.getScrollNode(); if (trackedNode) { // set the scrollTop to the position we want. // note though, that this might not succeed if the combination of offsetBase and pixelOffset @@ -589,36 +619,36 @@ export default class ScrollPanel extends React.Component { // This because when setting the scrollTop only 10 or so events might be loaded, // not giving enough content below the trackedNode to scroll downwards // enough so it ends up in the top of the viewport. - debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop}); + debuglog("scrollToken: setting scrollTop", { offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop }); scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset; - this._saveScrollState(); + this.saveScrollState(); } }; - _saveScrollState() { + private saveScrollState(): void { if (this.props.stickyBottom && this.isAtBottom()) { this.scrollState = { stuckAtBottom: true }; debuglog("saved stuckAtBottom state"); return; } - const scrollNode = this._getScrollNode(); + const scrollNode = this.getScrollNode(); const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight); - const itemlist = this._itemlist.current; + const itemlist = this.itemlist.current; const messages = itemlist.children; let node = null; // TODO: do a binary search here, as items are sorted by offsetTop // loop backwards, from bottom-most message (as that is the most common case) - for (let i = messages.length-1; i >= 0; --i) { - if (!messages[i].dataset.scrollTokens) { + for (let i = messages.length - 1; i >= 0; --i) { + if (!(messages[i] as HTMLElement).dataset.scrollTokens) { continue; } node = messages[i]; // break at the first message (coming from the bottom) // that has it's offsetTop above the bottom of the viewport. - if (this._topFromBottom(node) > viewportBottom) { + if (this.topFromBottom(node) > viewportBottom) { // Use this node as the scrollToken break; } @@ -630,7 +660,7 @@ export default class ScrollPanel extends React.Component { } const scrollToken = node.dataset.scrollTokens.split(',')[0]; debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken); - const bottomOffset = this._topFromBottom(node); + const bottomOffset = this.topFromBottom(node); this.scrollState = { stuckAtBottom: false, trackedNode: node, @@ -640,35 +670,35 @@ export default class ScrollPanel extends React.Component { }; } - async _restoreSavedScrollState() { + private async restoreSavedScrollState(): Promise { const scrollState = this.scrollState; if (scrollState.stuckAtBottom) { - const sn = this._getScrollNode(); + const sn = this.getScrollNode(); if (sn.scrollTop !== sn.scrollHeight) { sn.scrollTop = sn.scrollHeight; } } else if (scrollState.trackedScrollToken) { - const itemlist = this._itemlist.current; - const trackedNode = this._getTrackedNode(); + const itemlist = this.itemlist.current; + const trackedNode = this.getTrackedNode(); if (trackedNode) { - const newBottomOffset = this._topFromBottom(trackedNode); + const newBottomOffset = this.topFromBottom(trackedNode); const bottomDiff = newBottomOffset - scrollState.bottomOffset; - this._bottomGrowth += bottomDiff; + this.bottomGrowth += bottomDiff; scrollState.bottomOffset = newBottomOffset; - const newHeight = `${this._getListHeight()}px`; + const newHeight = `${this.getListHeight()}px`; if (itemlist.style.height !== newHeight) { itemlist.style.height = newHeight; } debuglog("balancing height because messages below viewport grew by", bottomDiff); } } - if (!this._heightUpdateInProgress) { - this._heightUpdateInProgress = true; + if (!this.heightUpdateInProgress) { + this.heightUpdateInProgress = true; try { - await this._updateHeight(); + await this.updateHeight(); } finally { - this._heightUpdateInProgress = false; + this.heightUpdateInProgress = false; } } else { debuglog("not updating height because request already in progress"); @@ -676,11 +706,11 @@ export default class ScrollPanel extends React.Component { } // need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content? - async _updateHeight() { + private async updateHeight(): Promise { // wait until user has stopped scrolling - if (this._scrollTimeout.isRunning()) { + if (this.scrollTimeout.isRunning()) { debuglog("updateHeight waiting for scrolling to end ... "); - await this._scrollTimeout.finished(); + await this.scrollTimeout.finished(); } else { debuglog("updateHeight getting straight to business, no scrolling going on."); } @@ -690,14 +720,14 @@ export default class ScrollPanel extends React.Component { return; } - const sn = this._getScrollNode(); - const itemlist = this._itemlist.current; - const contentHeight = this._getMessagesHeight(); + const sn = this.getScrollNode(); + const itemlist = this.itemlist.current; + const contentHeight = this.getMessagesHeight(); const minHeight = sn.clientHeight; const height = Math.max(minHeight, contentHeight); - this._pages = Math.ceil(height / PAGE_SIZE); - this._bottomGrowth = 0; - const newHeight = `${this._getListHeight()}px`; + this.pages = Math.ceil(height / PAGE_SIZE); + this.bottomGrowth = 0; + const newHeight = `${this.getListHeight()}px`; const scrollState = this.scrollState; if (scrollState.stuckAtBottom) { @@ -709,7 +739,7 @@ export default class ScrollPanel extends React.Component { } debuglog("updateHeight to", newHeight); } else if (scrollState.trackedScrollToken) { - const trackedNode = this._getTrackedNode(); + const trackedNode = this.getTrackedNode(); // if the timeline has been reloaded // this can be called before scrollToBottom or whatever has been called // so don't do anything if the node has disappeared from @@ -726,22 +756,22 @@ export default class ScrollPanel extends React.Component { // yield out of date values and cause a jump // when setting it sn.scrollBy(0, topDiff); - debuglog("updateHeight to", {newHeight, topDiff}); + debuglog("updateHeight to", { newHeight, topDiff }); } } } - _getTrackedNode() { + private getTrackedNode(): HTMLElement { const scrollState = this.scrollState; const trackedNode = scrollState.trackedNode; if (!trackedNode || !trackedNode.parentElement) { let node; - const messages = this._itemlist.current.children; + const messages = this.itemlist.current.children; const scrollToken = scrollState.trackedScrollToken; for (let i = messages.length-1; i >= 0; --i) { - const m = messages[i]; + const m = messages[i] as HTMLElement; // 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens // There might only be one scroll token if (m.dataset.scrollTokens && @@ -764,45 +794,45 @@ export default class ScrollPanel extends React.Component { return scrollState.trackedNode; } - _getListHeight() { - return this._bottomGrowth + (this._pages * PAGE_SIZE); + private getListHeight(): number { + return this.bottomGrowth + (this.pages * PAGE_SIZE); } - _getMessagesHeight() { - const itemlist = this._itemlist.current; - const lastNode = itemlist.lastElementChild; + private getMessagesHeight(): number { + const itemlist = this.itemlist.current; + const lastNode = itemlist.lastElementChild as HTMLElement; const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0; - const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0; + const firstNodeTop = itemlist.firstElementChild ? (itemlist.firstElementChild as HTMLElement).offsetTop : 0; // 18 is itemlist padding return lastNodeBottom - firstNodeTop + (18 * 2); } - _topFromBottom(node) { + private topFromBottom(node: HTMLElement): number { // current capped height - distance from top = distance from bottom of container to top of tracked element - return this._itemlist.current.clientHeight - node.offsetTop; + return this.itemlist.current.clientHeight - node.offsetTop; } /* get the DOM node which has the scrollTop property we care about for our * message panel. */ - _getScrollNode() { + private getScrollNode(): HTMLDivElement { if (this.unmounted) { // this shouldn't happen, but when it does, turn the NPE into // something more meaningful. - throw new Error("ScrollPanel._getScrollNode called when unmounted"); + throw new Error("ScrollPanel.getScrollNode called when unmounted"); } - if (!this._divScroll) { + if (!this.divScroll) { // Likewise, we should have the ref by this point, but if not // turn the NPE into something meaningful. - throw new Error("ScrollPanel._getScrollNode called before AutoHideScrollbar ref collected"); + throw new Error("ScrollPanel.getScrollNode called before AutoHideScrollbar ref collected"); } - return this._divScroll; + return this.divScroll; } - _collectScroll = divScroll => { - this._divScroll = divScroll; + private collectScroll = (divScroll: HTMLDivElement) => { + this.divScroll = divScroll; }; /** @@ -810,15 +840,15 @@ export default class ScrollPanel extends React.Component { anything below it changes, by calling updatePreventShrinking, to keep the same minimum bottom offset, effectively preventing the timeline to shrink. */ - preventShrinking = () => { - const messageList = this._itemlist.current; + public preventShrinking = (): void => { + const messageList = this.itemlist.current; const tiles = messageList && messageList.children; if (!messageList) { return; } let lastTileNode; for (let i = tiles.length - 1; i >= 0; i--) { - const node = tiles[i]; + const node = tiles[i] as HTMLElement; if (node.dataset.scrollTokens) { lastTileNode = node; break; @@ -837,8 +867,8 @@ export default class ScrollPanel extends React.Component { }; /** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */ - clearPreventShrinking = () => { - const messageList = this._itemlist.current; + public clearPreventShrinking = (): void => { + const messageList = this.itemlist.current; const balanceElement = messageList && messageList.parentElement; if (balanceElement) balanceElement.style.paddingBottom = null; this.preventShrinkingState = null; @@ -853,12 +883,12 @@ export default class ScrollPanel extends React.Component { from the bottom of the marked tile grows larger than what it was when marking. */ - updatePreventShrinking = () => { + public updatePreventShrinking = (): void => { if (this.preventShrinkingState) { - const sn = this._getScrollNode(); + const sn = this.getScrollNode(); const scrollState = this.scrollState; - const messageList = this._itemlist.current; - const {offsetNode, offsetFromBottom} = this.preventShrinkingState; + const messageList = this.itemlist.current; + const { offsetNode, offsetFromBottom } = this.preventShrinkingState; // element used to set paddingBottom to balance the typing notifs disappearing const balanceElement = messageList.parentElement; // if the offsetNode got unmounted, clear @@ -892,16 +922,21 @@ export default class ScrollPanel extends React.Component { // give the
      an explicit role=list because Safari+VoiceOver seems to think an ordered-list with // list-style-type: none; is no longer a list - return ( - { this.props.fixedChildren } -
      -
        - { this.props.children } -
      -
      -
      - ); + onWheel={this.props.onUserScroll} + className={`mx_ScrollPanel ${this.props.className}`} + style={this.props.style} + > + { this.props.fixedChildren } +
      +
        + { this.props.children } +
      +
      + + ); } } diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index c1e3ad0cf2..5c966d2d3a 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -15,14 +15,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; import { Key } from '../../Keyboard'; import dis from '../../dispatcher/dispatcher'; -import {throttle} from 'lodash'; +import { throttle } from 'lodash'; import AccessibleButton from '../../components/views/elements/AccessibleButton'; import classNames from 'classnames'; +import { replaceableComponent } from "../../utils/replaceableComponent"; +@replaceableComponent("structures.SearchBox") export default class SearchBox extends React.Component { static propTypes = { onSearch: PropTypes.func, @@ -30,6 +32,8 @@ export default class SearchBox extends React.Component { onKeyDown: PropTypes.func, className: PropTypes.string, placeholder: PropTypes.string.isRequired, + autoFocus: PropTypes.bool, + initialValue: PropTypes.string, // If true, the search box will focus and clear itself // on room search focus action (it would be nicer to take @@ -47,7 +51,7 @@ export default class SearchBox extends React.Component { this._search = createRef(); this.state = { - searchTerm: "", + searchTerm: this.props.initialValue || "", blurred: true, }; } @@ -85,7 +89,7 @@ export default class SearchBox extends React.Component { onSearch = throttle(() => { this.props.onSearch(this._search.current.value); - }, 200, {trailing: true, leading: true}); + }, 200, { trailing: true, leading: true }); _onKeyDown = ev => { switch (ev.key) { @@ -97,7 +101,7 @@ export default class SearchBox extends React.Component { }; _onFocus = ev => { - this.setState({blurred: false}); + this.setState({ blurred: false }); ev.target.select(); if (this.props.onFocus) { this.props.onFocus(ev); @@ -105,7 +109,7 @@ export default class SearchBox extends React.Component { }; _onBlur = ev => { - this.setState({blurred: true}); + this.setState({ blurred: true }); if (this.props.onBlur) { this.props.onBlur(ev); } @@ -143,7 +147,7 @@ export default class SearchBox extends React.Component { this.props.placeholder; const className = this.props.className || ""; return ( -
      +
      { clearButton }
      diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx new file mode 100644 index 0000000000..27539a5c3c --- /dev/null +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -0,0 +1,642 @@ +/* +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, { ReactNode, useMemo, useState } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; +import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces"; +import classNames from "classnames"; +import { sortBy } from "lodash"; + +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import dis from "../../dispatcher/dispatcher"; +import { _t } from "../../languageHandler"; +import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; +import BaseDialog from "../views/dialogs/BaseDialog"; +import Spinner from "../views/elements/Spinner"; +import SearchBox from "./SearchBox"; +import RoomAvatar from "../views/avatars/RoomAvatar"; +import RoomName from "../views/elements/RoomName"; +import { useAsyncMemo } from "../../hooks/useAsyncMemo"; +import { EnhancedMap } from "../../utils/maps"; +import StyledCheckbox from "../views/elements/StyledCheckbox"; +import AutoHideScrollbar from "./AutoHideScrollbar"; +import BaseAvatar from "../views/avatars/BaseAvatar"; +import { mediaFromMxc } from "../../customisations/Media"; +import InfoTooltip from "../views/elements/InfoTooltip"; +import TextWithTooltip from "../views/elements/TextWithTooltip"; +import { useStateToggle } from "../../hooks/useStateToggle"; +import { getChildOrder } from "../../stores/SpaceStore"; +import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; +import { linkifyElement } from "../../HtmlUtils"; +import { getDisplayAliasForAliasSet } from "../../Rooms"; + +interface IHierarchyProps { + space: Room; + initialText?: string; + refreshToken?: any; + additionalButtons?: ReactNode; + showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void; +} + +interface ITileProps { + room: ISpaceSummaryRoom; + suggested?: boolean; + selected?: boolean; + numChildRooms?: number; + hasPermissions?: boolean; + onViewRoomClick(autoJoin: boolean): void; + onToggleClick?(): void; +} + +const Tile: React.FC = ({ + room, + suggested, + selected, + hasPermissions, + onToggleClick, + onViewRoomClick, + numChildRooms, + children, +}) => { + const cli = MatrixClientPeg.get(); + const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null; + const name = joinedRoom?.name || 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 onPreviewClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + onViewRoomClick(false); + }; + const onJoinClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + onViewRoomClick(true); + }; + + let button; + if (joinedRoom) { + button = + { _t("View") } + ; + } else if (onJoinClick) { + button = + { _t("Join") } + ; + } + + let checkbox; + if (onToggleClick) { + if (hasPermissions) { + checkbox = ; + } else { + checkbox = { ev.stopPropagation(); }} + > + + ; + } + } + + let avatar; + if (joinedRoom) { + avatar = ; + } else { + avatar = ; + } + + let description = _t("%(count)s members", { count: room.num_joined_members }); + if (numChildRooms !== undefined) { + description += " · " + _t("%(count)s rooms", { count: numChildRooms }); + } + + const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic; + if (topic) { + description += " · " + topic; + } + + let suggestedSection; + if (suggested) { + suggestedSection = + { _t("Suggested") } + ; + } + + const content = + { avatar } +
      + { name } + { suggestedSection } +
      + +
      e && linkifyElement(e)} + onClick={ev => { + // prevent clicks on links from bubbling up to the room tile + if ((ev.target as HTMLElement).tagName === "A") { + ev.stopPropagation(); + } + }} + > + { description } +
      +
      + { button } + { checkbox } +
      +
      ; + + let childToggle; + let childSection; + if (children) { + // the chevron is purposefully a div rather than a button as it should be ignored for a11y + childToggle =
      { + ev.stopPropagation(); + toggleShowChildren(); + }} + />; + if (showChildren) { + childSection =
      + { children } +
      ; + } + } + + return <> + + { content } + { childToggle } + + { childSection } + ; +}; + +export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => { + // 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 (MatrixClientPeg.get().isGuest()) { + if (!room.world_readable && !room.guest_can_join) { + dis.dispatch({ action: "require_registration" }); + return; + } + } + + const roomAlias = getDisplayAliasForRoom(room) || undefined; + dis.dispatch({ + action: "view_room", + auto_join: autoJoin, + should_peek: true, + _type: "room_directory", // instrumentation + room_alias: roomAlias, + room_id: room.room_id, + via_servers: viaServers, + oob_data: { + avatarUrl: room.avatar_url, + // XXX: This logic is duplicated from the JS SDK which would normally decide what the name is. + name: room.name || roomAlias || _t("Unnamed room"), + }, + }); +}; + +interface IHierarchyLevelProps { + spaceId: string; + rooms: Map; + relations: Map>; + parents: Set; + selectedMap?: Map>; + onViewRoomClick(roomId: string, autoJoin: boolean): void; + onToggleClick?(parentId: string, childId: string): void; +} + +export const HierarchyLevel = ({ + spaceId, + rooms, + relations, + parents, + selectedMap, + onViewRoomClick, + onToggleClick, +}: IHierarchyLevelProps) => { + const cli = MatrixClientPeg.get(); + const space = cli.getRoom(spaceId); + const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); + + const children = Array.from(relations.get(spaceId)?.values() || []); + const sortedChildren = sortBy(children, ev => { + // XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting + return getChildOrder(ev.content.order, null, ev.state_key); + }); + const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { + const roomId = ev.state_key; + if (!rooms.has(roomId)) return result; + result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId); + return result; + }, [[], []]) || [[], []]; + + const newParents = new Set(parents).add(spaceId); + return + { + childRooms.map(roomId => ( + { + onViewRoomClick(roomId, autoJoin); + }} + hasPermissions={hasPermissions} + onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined} + /> + )) + } + + { + subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => ( + rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length} + suggested={relations.get(spaceId)?.get(roomId)?.content.suggested} + selected={selectedMap?.get(spaceId)?.has(roomId)} + onViewRoomClick={(autoJoin) => { + onViewRoomClick(roomId, autoJoin); + }} + hasPermissions={hasPermissions} + onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined} + > + + + )) + } + ; +}; + +// mutate argument refreshToken to force a reload +export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [ + null, + ISpaceSummaryRoom[], + Map>?, + Map>?, + Map>?, +] | [Error] => { + // TODO pagination + return useAsyncMemo(async () => { + try { + const data = await cli.getSpaceSummary(space.roomId); + + const parentChildRelations = new EnhancedMap>(); + const childParentRelations = new EnhancedMap>(); + const viaMap = new EnhancedMap>(); + data.events.map((ev: ISpaceSummaryEvent) => { + if (ev.type === EventType.SpaceChild) { + parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev); + childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id); + } + if (Array.isArray(ev.content.via)) { + const set = viaMap.getOrCreate(ev.state_key, new Set()); + ev.content.via.forEach(via => set.add(via)); + } + }); + + return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations]; + } catch (e) { + console.error(e); // TODO + return [e]; + } + }, [space, refreshToken], [undefined]); +}; + +export const SpaceHierarchy: React.FC = ({ + space, + initialText = "", + showRoom, + refreshToken, + additionalButtons, + children, +}) => { + const cli = MatrixClientPeg.get(); + const userId = cli.getUserId(); + const [query, setQuery] = useState(initialText); + + const [selected, setSelected] = useState(new Map>()); // Map> + + const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken); + + const roomsMap = useMemo(() => { + if (!rooms) return null; + const lcQuery = query.toLowerCase().trim(); + + const roomsMap = new Map(rooms.map(r => [r.room_id, r])); + if (!lcQuery) return roomsMap; + + const directMatches = rooms.filter(r => { + return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery); + }); + + // Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy + const visited = new Set(); + const queue = [...directMatches.map(r => r.room_id)]; + while (queue.length) { + const roomId = queue.pop(); + visited.add(roomId); + childParentMap.get(roomId)?.forEach(parentId => { + if (!visited.has(parentId)) { + queue.push(parentId); + } + }); + } + + // Remove any mappings for rooms which were not visited in the walk + Array.from(roomsMap.keys()).forEach(roomId => { + if (!visited.has(roomId)) { + roomsMap.delete(roomId); + } + }); + return roomsMap; + }, [rooms, childParentMap, query]); + + const [error, setError] = useState(""); + const [removing, setRemoving] = useState(false); + const [saving, setSaving] = useState(false); + + if (summaryError) { + return

      {_t("Your server does not support showing space hierarchies.")}

      ; + } + + let content; + if (roomsMap) { + const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length; + const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at + + let countsStr; + if (numSpaces > 1) { + countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces }); + } else if (numSpaces > 0) { + countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces }); + } else { + countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces }); + } + + let manageButtons; + if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { + const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { + return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][]; + }); + + const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { + return parentChildMap.get(parentId)?.get(childId)?.content.suggested; + }); + + const disabled = !selectedRelations.length || removing || saving; + + let Button: React.ComponentType> = AccessibleButton; + let props = {}; + if (!selectedRelations.length) { + Button = AccessibleTooltipButton; + props = { + tooltip: _t("Select a room below first"), + yOffset: -40, + }; + } + + manageButtons = <> + + + ; + } + + let results; + if (roomsMap.size) { + const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); + + results = <> + { + setError(""); + if (!selected.has(parentId)) { + setSelected(new Map(selected.set(parentId, new Set([childId])))); + return; + } + + const parentSet = selected.get(parentId); + if (!parentSet.has(childId)) { + setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId])))); + return; + } + + parentSet.delete(childId); + setSelected(new Map(selected.set(parentId, new Set(parentSet)))); + } : undefined} + onViewRoomClick={(roomId, autoJoin) => { + showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin); + }} + /> + { children &&
      } + ; + } else { + results =
      +

      { _t("No results found") }

      +
      { _t("You may want to try a different search or check for typos.") }
      +
      ; + } + + content = <> +
      + { countsStr } + + { additionalButtons } + { manageButtons } + +
      + { error &&
      + { error } +
      } + + { results } + { children } + + ; + } else { + content = ; + } + + // TODO loading state/error state + return <> + + + { content } + ; +}; + +interface IProps { + space: Room; + initialText?: string; + onFinished(): void; +} + +const SpaceRoomDirectory: React.FC = ({ space, onFinished, initialText }) => { + const onCreateRoomClick = () => { + dis.dispatch({ + action: 'view_create_room', + public: true, + }); + onFinished(); + }; + + const title = + +
      +

      { _t("Explore rooms") }

      +
      +
      +
      ; + + return ( + +
      + { _t("If you can't find the room you're looking for, ask for an invite or create a new room.", + null, + { a: sub => { + return {sub}; + } }, + ) } + + { + showRoom(room, viaServers, autoJoin); + onFinished(); + }} + initialText={initialText} + > + + { _t("Create room") } + + +
      +
      + ); +}; + +export default SpaceRoomDirectory; + +// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom +// but works with the objects we get from the public room list +function getDisplayAliasForRoom(room: ISpaceSummaryRoom) { + return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases); +} diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx new file mode 100644 index 0000000000..0ee68a9578 --- /dev/null +++ b/src/components/structures/SpaceRoomView.tsx @@ -0,0 +1,927 @@ +/* +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, { RefObject, useContext, useRef, useState } from "react"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { Preset } from "matrix-js-sdk/src/@types/partials"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EventSubscription } from "fbemitter"; + +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import RoomAvatar from "../views/avatars/RoomAvatar"; +import { _t } from "../../languageHandler"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import RoomName from "../views/elements/RoomName"; +import RoomTopic from "../views/elements/RoomTopic"; +import InlineSpinner from "../views/elements/InlineSpinner"; +import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite"; +import { useRoomMembers } from "../../hooks/useRoomMembers"; +import createRoom, { IOpts } from "../../createRoom"; +import Field from "../views/elements/Field"; +import { useEventEmitter } from "../../hooks/useEventEmitter"; +import withValidation from "../views/elements/Validation"; +import * as Email from "../../email"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import dis from "../../dispatcher/dispatcher"; +import { Action } from "../../dispatcher/actions"; +import ResizeNotifier from "../../utils/ResizeNotifier"; +import MainSplit from './MainSplit'; +import ErrorBoundary from "../views/elements/ErrorBoundary"; +import { ActionPayload } from "../../dispatcher/payloads"; +import RightPanel from "./RightPanel"; +import RightPanelStore from "../../stores/RightPanelStore"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload"; +import { useStateArray } from "../../hooks/useStateArray"; +import SpacePublicShare from "../views/spaces/SpacePublicShare"; +import { shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, showSpaceSettings } from "../../utils/space"; +import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory"; +import MemberAvatar from "../views/avatars/MemberAvatar"; +import { useStateToggle } from "../../hooks/useStateToggle"; +import SpaceStore from "../../stores/SpaceStore"; +import FacePile from "../views/elements/FacePile"; +import { AddExistingToSpace } from "../views/dialogs/AddExistingToSpaceDialog"; +import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu"; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from "../views/context_menus/IconizedContextMenu"; +import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; +import { BetaPill } from "../views/beta/BetaCard"; +import { UserTab } from "../views/dialogs/UserSettingsDialog"; +import Modal from "../../Modal"; +import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog"; +import SdkConfig from "../../SdkConfig"; +import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; +import { JoinRule } from "../views/settings/tabs/room/SecurityRoomSettingsTab"; + +interface IProps { + space: Room; + justCreatedOpts?: IOpts; + resizeNotifier: ResizeNotifier; + onJoinButtonClicked(): void; + onRejectButtonClicked(): void; +} + +interface IState { + phase: Phase; + createdRooms?: boolean; // internal state for the creation wizard + showRightPanel: boolean; + myMembership: string; +} + +enum Phase { + Landing, + PublicCreateRooms, + PublicShare, + PrivateScope, + PrivateInvite, + PrivateCreateRooms, + PrivateExistingRooms, +} + +// XXX: Temporary for the Spaces Beta only +export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => { + if (!SdkConfig.get().bug_report_endpoint_url) return null; + + return
      +
      +
      + { _t("Spaces are a beta feature.") } + { + if (onClick) onClick(); + Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, { + featureId: "feature_spaces", + }); + }}> + { _t("Feedback") } + +
      +
      ; +}; + +const RoomMemberCount = ({ room, children }) => { + const members = useRoomMembers(room); + const count = members.length; + + if (children) return children(count); + return count; +}; + +const useMyRoomMembership = (room: Room) => { + const [membership, setMembership] = useState(room.getMyMembership()); + useEventEmitter(room, "Room.myMembership", () => { + setMembership(room.getMyMembership()); + }); + return membership; +}; + +const SpaceInfo = ({ space }) => { + const joinRule = space.getJoinRule(); + + let visibilitySection; + if (joinRule === "public") { + visibilitySection = + { _t("Public space") } + ; + } else { + visibilitySection = + { _t("Private space") } + ; + } + + return
      + { visibilitySection } + { joinRule === "public" && + {(count) => count > 0 ? ( + { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomMemberList, + refireParams: { space }, + }); + }} + > + { _t("%(count)s members", { count }) } + + ) : null} + } +
      ; +}; + +const onBetaClick = () => { + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Labs, + }); +}; + +const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { + const cli = useContext(MatrixClientContext); + const myMembership = useMyRoomMembership(space); + + const [busy, setBusy] = useState(false); + + const spacesEnabled = SpaceStore.spacesEnabled; + + const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave + && space.getJoinRule() !== JoinRule.Public; + + let inviterSection; + let joinButtons; + if (myMembership === "join") { + // XXX remove this when spaces leaves Beta + joinButtons = ( + { + dis.dispatch({ + action: "leave_room", + room_id: space.roomId, + }); + }} + > + { _t("Leave") } + + ); + } else if (myMembership === "invite") { + const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender(); + const inviter = inviteSender && space.getMember(inviteSender); + + if (inviteSender) { + inviterSection =
      + +
      +
      + { _t(" invites you", {}, { + inviter: () => { inviter.name || inviteSender }, + }) } +
      + { inviter ?
      + { inviteSender } +
      : null } +
      +
      ; + } + + joinButtons = <> + { + setBusy(true); + onRejectButtonClicked(); + }} + > + { _t("Reject") } + + { + setBusy(true); + onJoinButtonClicked(); + }} + disabled={!spacesEnabled} + > + { _t("Accept") } + + ; + } else { + joinButtons = ( + { + setBusy(true); + onJoinButtonClicked(); + }} + disabled={!spacesEnabled || cannotJoin} + > + { _t("Join") } + + ); + } + + if (busy) { + joinButtons = ; + } + + let footer; + if (!spacesEnabled) { + footer =
      + { myMembership === "join" + ? _t("To view %(spaceName)s, turn on the Spaces beta", { + spaceName: space.name, + }, { + a: sub => { sub }, + }) + : _t("To join %(spaceName)s, turn on the Spaces beta", { + spaceName: space.name, + }, { + a: sub => { sub }, + }) + } +
      ; + } else if (cannotJoin) { + footer =
      + { _t("To view %(spaceName)s, you need an invite", { + spaceName: space.name, + }) } +
      ; + } + + return
      + + { inviterSection } + +

      + +

      + + + {(topic, ref) => +
      + { topic } +
      + } +
      + { space.getJoinRule() === "public" && } +
      + { joinButtons } +
      + { footer } +
      ; +}; + +const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => { + const cli = useContext(MatrixClientContext); + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + + let contextMenu; + if (menuDisplayed) { + const rect = handle.current.getBoundingClientRect(); + contextMenu = + + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + + if (await showCreateNewRoom(cli, space)) { + onNewRoomAdded(); + } + }} + /> + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + + const [added] = await showAddExistingRooms(cli, space); + if (added) { + onNewRoomAdded(); + } + }} + /> + + ; + } + + return <> + + { _t("Add") } + + { contextMenu } + ; +}; + +const SpaceLanding = ({ space }) => { + const cli = useContext(MatrixClientContext); + const myMembership = useMyRoomMembership(space); + const userId = cli.getUserId(); + + let inviteButton; + if (myMembership === "join" && space.canInvite(userId)) { + inviteButton = ( + { + showRoomInviteDialog(space.roomId); + }} + > + { _t("Invite") } + + ); + } + + const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId); + + const [refreshToken, forceUpdate] = useStateToggle(false); + + let addRoomButton; + if (canAddRooms) { + addRoomButton = ; + } + + let settingsButton; + if (shouldShowSpaceSettings(cli, space)) { + settingsButton = { + showSpaceSettings(cli, space); + }} + title={_t("Settings")} + />; + } + + const onMembersClick = () => { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomMemberList, + refireParams: { space }, + }); + }; + + return
      + +
      + + {(name) => { + const tags = { name: () =>
      +

      { name }

      +
      }; + return _t("Welcome to ", {}, tags) as JSX.Element; + }} +
      +
      +
      + + + { inviteButton } + { settingsButton } +
      + + {(topic, ref) => ( +
      + { topic } +
      + )} +
      + +
      + + +
      ; +}; + +const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const numFields = 3; + const placeholders = [_t("General"), _t("Random"), _t("Support")]; + const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]); + const fields = new Array(numFields).fill(0).map((_, i) => { + const name = "roomName" + i; + return setRoomName(i, ev.target.value)} + autoFocus={i === 2} + disabled={busy} + />; + }); + + const onNextClick = async (ev) => { + ev.preventDefault(); + if (busy) return; + setError(""); + setBusy(true); + try { + const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean); + await Promise.all(filteredRoomNames.map(name => { + return createRoom({ + createOpts: { + preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, + name, + }, + spinner: false, + encryption: false, + andView: false, + inlineErrors: true, + parentSpace: space, + }); + })); + onFinished(filteredRoomNames.length > 0); + } catch (e) { + console.error("Failed to create initial space rooms", e); + setError(_t("Failed to create initial space rooms")); + } + setBusy(false); + }; + + let onClick = (ev) => { + ev.preventDefault(); + onFinished(false); + }; + let buttonLabel = _t("Skip for now"); + if (roomNames.some(name => name.trim())) { + onClick = onNextClick; + buttonLabel = busy ? _t("Creating rooms...") : _t("Continue"); + } + + return
      +

      { title }

      +
      { description }
      + + { error &&
      { error }
      } + + { fields } + + +
      + +
      + +
      ; +}; + +const SpaceAddExistingRooms = ({ space, onFinished }) => { + return
      +

      { _t("What do you want to organise?") }

      +
      + { _t("Pick rooms or conversations to add. This is just a space for you, " + + "no one will be informed. You can add more later.") } +
      + + + { _t("Skip for now") } + + } + onFinished={onFinished} + /> + +
      + +
      + +
      ; +}; + +const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => { + return
      +

      { _t("Share %(name)s", { + name: justCreatedOpts?.createOpts?.name || space.name, + }) }

      +
      + { _t("It's just you at the moment, it will be even better with others.") } +
      + + + +
      + + { createdRooms ? _t("Go to my first room") : _t("Go to my space") } + +
      + +
      ; +}; + +const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => { + return
      +

      { _t("Who are you working with?") }

      +
      + { _t("Make sure the right people have access to %(name)s", { + name: justCreatedOpts?.createOpts?.name || space.name, + }) } +
      + + { onFinished(false); }} + > +

      { _t("Just me") }

      +
      { _t("A private space to organise your rooms") }
      +
      + { onFinished(true); }} + > +

      { _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 as part of the beta, but just want to let you know.") }

      +
      + +
      ; +}; + +const validateEmailRules = withValidation({ + rules: [{ + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t("Doesn't look like a valid email address"), + }], +}); + +const SpaceSetupPrivateInvite = ({ space, onFinished }) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const numFields = 3; + const fieldRefs: RefObject[] = [useRef(), useRef(), useRef()]; + const [emailAddresses, setEmailAddress] = useStateArray(numFields, ""); + const fields = new Array(numFields).fill(0).map((_, i) => { + const name = "emailAddress" + i; + return setEmailAddress(i, ev.target.value)} + ref={fieldRefs[i]} + onValidate={validateEmailRules} + autoFocus={i === 0} + disabled={busy} + />; + }); + + const onNextClick = async (ev) => { + ev.preventDefault(); + if (busy) return; + setError(""); + for (let i = 0; i < fieldRefs.length; i++) { + const fieldRef = fieldRefs[i]; + const valid = await fieldRef.current.validate({ allowEmpty: true }); + + if (valid === false) { // true/null are allowed + fieldRef.current.focus(); + fieldRef.current.validate({ allowEmpty: true, focused: true }); + return; + } + } + + setBusy(true); + const targetIds = emailAddresses.map(name => name.trim()).filter(Boolean); + try { + const result = await inviteMultipleToRoom(space.roomId, targetIds); + + const failedUsers = Object.keys(result.states).filter(a => result.states[a] === "error"); + if (failedUsers.length > 0) { + console.log("Failed to invite users to space: ", result); + setError(_t("Failed to invite the following users to your space: %(csvUsers)s", { + csvUsers: failedUsers.join(", "), + })); + } else { + onFinished(); + } + } catch (err) { + console.error("Failed to invite users to space: ", err); + setError(_t("We couldn't invite those users. Please check the users you want to invite and try again.")); + } + setBusy(false); + }; + + let onClick = (ev) => { + ev.preventDefault(); + onFinished(); + }; + let buttonLabel = _t("Skip for now"); + if (emailAddresses.some(name => name.trim())) { + onClick = onNextClick; + buttonLabel = busy ? _t("Inviting...") : _t("Continue"); + } + + return
      +

      { _t("Invite your teammates") }

      +
      + { _t("Make sure the right people have access. You can invite more later.") } +
      + +
      + + { _t("This is an experimental feature. For now, " + + "new users receiving an invite will have to open the invite on to actually join.", {}, { + b: sub => { sub }, + link: () => + app.element.io + , + }) } +
      + + { error &&
      { error }
      } +
      + { fields } +
      + +
      + showRoomInviteDialog(space.roomId)} + > + { _t("Invite by username") } + +
      + +
      + +
      + +
      ; +}; + +export default class SpaceRoomView extends React.PureComponent { + static contextType = MatrixClientContext; + + private readonly creator: string; + private readonly dispatcherRef: string; + private readonly rightPanelStoreToken: EventSubscription; + + constructor(props, context) { + super(props, context); + + let phase = Phase.Landing; + + this.creator = this.props.space.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender(); + const showSetup = this.props.justCreatedOpts && this.context.getUserId() === this.creator; + + if (showSetup) { + phase = this.props.justCreatedOpts.createOpts.preset === Preset.PublicChat + ? Phase.PublicCreateRooms : Phase.PrivateScope; + } + + this.state = { + phase, + showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, + myMembership: this.props.space.getMyMembership(), + }; + + this.dispatcherRef = defaultDispatcher.register(this.onAction); + this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); + this.context.on("Room.myMembership", this.onMyMembership); + } + + componentWillUnmount() { + defaultDispatcher.unregister(this.dispatcherRef); + this.rightPanelStoreToken.remove(); + this.context.off("Room.myMembership", this.onMyMembership); + } + + private onMyMembership = (room: Room, myMembership: string) => { + if (room.roomId === this.props.space.roomId) { + this.setState({ myMembership }); + } + }; + + private onRightPanelStoreUpdate = () => { + this.setState({ + showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, + }); + }; + + private onAction = (payload: ActionPayload) => { + if (payload.action !== Action.ViewUser && payload.action !== "view_3pid_invite") return; + + if (payload.action === Action.ViewUser && payload.member) { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberInfo, + refireParams: { + space: this.props.space, + member: payload.member, + }, + }); + } else if (payload.action === "view_3pid_invite" && payload.event) { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.Space3pidMemberInfo, + refireParams: { + space: this.props.space, + event: payload.event, + }, + }); + } else { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberList, + refireParams: { space: this.props.space }, + }); + } + }; + + private goToFirstRoom = async () => { + // TODO actually go to the first room + + const childRooms = SpaceStore.instance.getChildRooms(this.props.space.roomId); + if (childRooms.length) { + const room = childRooms[0]; + defaultDispatcher.dispatch({ + action: "view_room", + room_id: room.roomId, + }); + return; + } + + let suggestedRooms = SpaceStore.instance.suggestedRooms; + if (SpaceStore.instance.activeSpace !== this.props.space) { + // the space store has the suggested rooms loaded for a different space, fetch the right ones + suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)); + } + + if (suggestedRooms.length) { + const room = suggestedRooms[0]; + defaultDispatcher.dispatch({ + action: "view_room", + room_id: room.room_id, + room_alias: room.canonical_alias || room.aliases?.[0], + via_servers: room.viaServers, + oobData: { + avatarUrl: room.avatar_url, + name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"), + }, + }); + return; + } + + this.setState({ phase: Phase.Landing }); + }; + + private renderBody() { + switch (this.state.phase) { + case Phase.Landing: + if (this.state.myMembership === "join" && SpaceStore.spacesEnabled) { + return ; + } else { + return ; + } + case Phase.PublicCreateRooms: + return this.setState({ phase: Phase.PublicShare, createdRooms })} + />; + case Phase.PublicShare: + return ; + + case Phase.PrivateScope: + return { + this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms }); + }} + />; + case Phase.PrivateInvite: + return this.setState({ phase: Phase.PrivateCreateRooms })} + />; + case Phase.PrivateCreateRooms: + return this.setState({ phase: Phase.Landing, createdRooms })} + />; + case Phase.PrivateExistingRooms: + return this.setState({ phase: Phase.Landing })} + />; + } + } + + render() { + const rightPanel = this.state.showRightPanel && this.state.phase === Phase.Landing + ? + : null; + + return
      + + + { this.renderBody() } + + +
      ; + } +} diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index 6bc35eb2a4..19694cd769 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -17,10 +17,11 @@ limitations under the License. */ import * as React from "react"; -import {_t} from '../../languageHandler'; -import * as sdk from "../../index"; +import { _t } from '../../languageHandler'; import AutoHideScrollbar from './AutoHideScrollbar'; -import { ReactNode } from "react"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import classNames from "classnames"; +import AccessibleButton from "../views/elements/AccessibleButton"; /** * Represents a tab for the TabbedView. @@ -37,15 +38,23 @@ export class Tab { } } +export enum TabLocation { + LEFT = 'left', + TOP = 'top', +} + interface IProps { tabs: Tab[]; initialTabId?: string; + tabLocation: TabLocation; + onChange?: (tabId: string) => void; } interface IState { activeTabIndex: number; } +@replaceableComponent("structures.TabbedView") export default class TabbedView extends React.Component { constructor(props: IProps) { super(props); @@ -61,6 +70,10 @@ export default class TabbedView extends React.Component { }; } + static defaultProps = { + tabLocation: TabLocation.LEFT, + }; + private _getActiveTabIndex() { if (!this.state || !this.state.activeTabIndex) return 0; return this.state.activeTabIndex; @@ -74,15 +87,14 @@ export default class TabbedView extends React.Component { private _setActiveTab(tab: Tab) { const idx = this.props.tabs.indexOf(tab); if (idx !== -1) { - this.setState({activeTabIndex: idx}); + if (this.props.onChange) this.props.onChange(tab.id); + this.setState({ activeTabIndex: idx }); } else { console.error("Could not find tab " + tab.label + " in tabs"); } } private _renderTabLabel(tab: Tab) { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let classes = "mx_TabbedView_tabLabel "; const idx = this.props.tabs.indexOf(tab); @@ -120,8 +132,14 @@ export default class TabbedView extends React.Component { const labels = this.props.tabs.map(tab => this._renderTabLabel(tab)); const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]); + const tabbedViewClasses = classNames({ + 'mx_TabbedView': true, + 'mx_TabbedView_tabsOnLeft': this.props.tabLocation == TabLocation.LEFT, + 'mx_TabbedView_tabsOnTop': this.props.tabLocation == TabLocation.TOP, + }); + return ( -
      +
      {labels}
      diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.tsx similarity index 70% rename from src/components/structures/TimelinePanel.js rename to src/components/structures/TimelinePanel.tsx index 8bbc66bf40..5f9d9b7026 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.tsx @@ -1,8 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2019 New Vector Ltd -Copyright 2019-2020 The Matrix.org Foundation C.I.C. +Copyright 2016 - 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. @@ -17,25 +14,39 @@ See the License for the specific language governing permissions and limitations under the License. */ -import SettingsStore from "../../settings/SettingsStore"; -import React, {createRef} from 'react'; +import React, { createRef, ReactNode, SyntheticEvent } from 'react'; import ReactDOM from "react-dom"; -import PropTypes from 'prop-types'; -import {EventTimeline} from "matrix-js-sdk"; -import * as Matrix from "matrix-js-sdk"; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; +import { Direction, EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; +import { TimelineWindow } from "matrix-js-sdk/src/timeline-window"; +import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event'; +import { SyncState } from 'matrix-js-sdk/src/sync.api'; + +import SettingsStore from "../../settings/SettingsStore"; +import { Layout } from "../../settings/Layout"; import { _t } from '../../languageHandler'; -import {MatrixClientPeg} from "../../MatrixClientPeg"; -import * as ObjectUtils from "../../ObjectUtils"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import RoomContext from "../../contexts/RoomContext"; import UserActivity from "../../UserActivity"; import Modal from "../../Modal"; import dis from "../../dispatcher/dispatcher"; -import * as sdk from "../../index"; import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; import shouldHideEvent from '../../shouldHideEvent'; +import { haveTileForEvent, TileShape } from "../views/rooms/EventTile"; +import { UIFeature } from "../../settings/UIFeature"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { arrayFastClone } from "../../utils/arrays"; +import MessagePanel from "./MessagePanel"; +import { IScrollState } from "./ScrollPanel"; +import { ActionPayload } from "../../dispatcher/payloads"; +import ResizeNotifier from "../../utils/ResizeNotifier"; +import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import Spinner from "../views/elements/Spinner"; import EditorStateTransfer from '../../utils/EditorStateTransfer'; -import {haveTileForEvent} from "../views/rooms/EventTile"; -import {UIFeature} from "../../settings/UIFeature"; +import ErrorDialog from '../views/dialogs/ErrorDialog'; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -43,98 +54,183 @@ const READ_RECEIPT_INTERVAL_MS = 500; const DEBUG = false; -let debuglog = function() {}; +let debuglog = function(...s: any[]) {}; if (DEBUG) { // using bind means that we get to keep useful line numbers in the console debuglog = console.log.bind(console); } +interface IProps { + // The js-sdk EventTimelineSet object for the timeline sequence we are + // representing. This may or may not have a room, depending on what it's + // a timeline representing. If it has a room, we maintain RRs etc for + // that room. + timelineSet: EventTimelineSet; + showReadReceipts?: boolean; + // Enable managing RRs and RMs. These require the timelineSet to have a room. + manageReadReceipts?: boolean; + sendReadReceiptOnLoad?: boolean; + manageReadMarkers?: boolean; + + // true to give the component a 'display: none' style. + hidden?: boolean; + + // ID of an event to highlight. If undefined, no event will be highlighted. + // typically this will be either 'eventId' or undefined. + highlightedEventId?: string; + + // id of an event to jump to. If not given, will go to the end of the live timeline. + eventId?: string; + + // where to position the event given by eventId, in pixels from the bottom of the viewport. + // If not given, will try to put the event half way down the viewport. + eventPixelOffset?: number; + + // Should we show URL Previews + showUrlPreview?: boolean; + + // maximum number of events to show in a timeline + timelineCap?: number; + + // classname to use for the messagepanel + className?: string; + + // shape property to be passed to EventTiles + tileShape?: TileShape; + + // placeholder to use if the timeline is empty + empty?: ReactNode; + + // whether to show reactions for an event + showReactions?: boolean; + + // which layout to use + layout?: Layout; + + // whether to always show timestamps for an event + alwaysShowTimestamps?: boolean; + + resizeNotifier?: ResizeNotifier; + editState?: EditorStateTransfer; + permalinkCreator?: RoomPermalinkCreator; + membersLoaded?: boolean; + + // callback which is called when the panel is scrolled. + onScroll?(event: Event): void; + + // callback which is called when the user interacts with the room timeline + onUserScroll?(event: SyntheticEvent): void; + + // callback which is called when the read-up-to mark is updated. + onReadMarkerUpdated?(): void; + + // callback which is called when we wish to paginate the timeline window. + onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise; +} + +interface IState { + events: MatrixEvent[]; + liveEvents: MatrixEvent[]; + // track whether our room timeline is loading + timelineLoading: boolean; + + // the index of the first event that is to be shown + firstVisibleEventIndex: number; + + // canBackPaginate == false may mean: + // + // * we haven't (successfully) loaded the timeline yet, or: + // + // * we have got to the point where the room was created, or: + // + // * the server indicated that there were no more visible events + // (normally implying we got to the start of the room), or: + // + // * we gave up asking the server for more events + canBackPaginate: boolean; + + // canForwardPaginate == false may mean: + // + // * we haven't (successfully) loaded the timeline yet + // + // * we have got to the end of time and are now tracking the live + // timeline, or: + // + // * the server indicated that there were no more visible events + // (not sure if this ever happens when we're not at the live + // timeline), or: + // + // * we are looking at some historical point, but gave up asking + // the server for more events + canForwardPaginate: boolean; + + // start with the read-marker visible, so that we see its animated + // disappearance when switching into the room. + readMarkerVisible: boolean; + + readMarkerEventId: string; + + backPaginating: boolean; + forwardPaginating: boolean; + + // cache of matrixClient.getSyncState() (but from the 'sync' event) + clientSyncState: SyncState; + + // should the event tiles have twelve hour times + isTwelveHour: boolean; + + // always show timestamps on event tiles? + alwaysShowTimestamps: boolean; + + // how long to show the RM for when it's visible in the window + readMarkerInViewThresholdMs: number; + + // how long to show the RM for when it's scrolled off-screen + readMarkerOutOfViewThresholdMs: number; + + editState?: EditorStateTransfer; +} + +interface IEventIndexOpts { + ignoreOwn?: boolean; + allowPartial?: boolean; +} + /* * Component which shows the event timeline in a room view. * * Also responsible for handling and sending read receipts. */ -class TimelinePanel extends React.Component { - static propTypes = { - // The js-sdk EventTimelineSet object for the timeline sequence we are - // representing. This may or may not have a room, depending on what it's - // a timeline representing. If it has a room, we maintain RRs etc for - // that room. - timelineSet: PropTypes.object.isRequired, - - showReadReceipts: PropTypes.bool, - // Enable managing RRs and RMs. These require the timelineSet to have a room. - manageReadReceipts: PropTypes.bool, - manageReadMarkers: PropTypes.bool, - - // true to give the component a 'display: none' style. - hidden: PropTypes.bool, - - // ID of an event to highlight. If undefined, no event will be highlighted. - // typically this will be either 'eventId' or undefined. - highlightedEventId: PropTypes.string, - - // id of an event to jump to. If not given, will go to the end of the - // live timeline. - eventId: PropTypes.string, - - // where to position the event given by eventId, in pixels from the - // bottom of the viewport. If not given, will try to put the event - // half way down the viewport. - eventPixelOffset: PropTypes.number, - - // Should we show URL Previews - showUrlPreview: PropTypes.bool, - - // callback which is called when the panel is scrolled. - onScroll: PropTypes.func, - - // callback which is called when the read-up-to mark is updated. - onReadMarkerUpdated: PropTypes.func, - - // callback which is called when we wish to paginate the timeline - // window. - onPaginationRequest: PropTypes.func, - - // maximum number of events to show in a timeline - timelineCap: PropTypes.number, - - // classname to use for the messagepanel - className: PropTypes.string, - - // shape property to be passed to EventTiles - tileShape: PropTypes.string, - - // placeholder to use if the timeline is empty - empty: PropTypes.node, - - // whether to show reactions for an event - showReactions: PropTypes.bool, - - // whether to use the irc layout - useIRCLayout: PropTypes.bool, - } +@replaceableComponent("structures.TimelinePanel") +class TimelinePanel extends React.Component { + static contextType = RoomContext; // a map from room id to read marker event timestamp - static roomReadMarkerTsMap = {}; + static roomReadMarkerTsMap: Record = {}; static defaultProps = { // By default, disable the timelineCap in favour of unpaginating based on // event tile heights. (See _unpaginateEvents) timelineCap: Number.MAX_VALUE, className: 'mx_RoomView_messagePanel', + sendReadReceiptOnLoad: true, }; - constructor(props) { - super(props); + private lastRRSentEventId: string = undefined; + private lastRMSentEventId: string = undefined; + + private readonly messagePanel = createRef(); + private readonly dispatcherRef: string; + private timelineWindow?: TimelineWindow; + private unmounted = false; + private readReceiptActivityTimer: Timer; + private readMarkerActivityTimer: Timer; + + constructor(props, context) { + super(props, context); debuglog("TimelinePanel: mounting"); - this.lastRRSentEventId = undefined; - this.lastRMSentEventId = undefined; - - this._messagePanel = createRef(); - // XXX: we could track RM per TimelineSet rather than per Room. // but for now we just do it per room for simplicity. let initialReadMarker = null; @@ -143,82 +239,41 @@ class TimelinePanel extends React.Component { if (readmarker) { initialReadMarker = readmarker.getContent().event_id; } else { - initialReadMarker = this._getCurrentReadReceipt(); + initialReadMarker = this.getCurrentReadReceipt(); } } this.state = { events: [], liveEvents: [], - timelineLoading: true, // track whether our room timeline is loading - - // the index of the first event that is to be shown + timelineLoading: true, firstVisibleEventIndex: 0, - - // canBackPaginate == false may mean: - // - // * we haven't (successfully) loaded the timeline yet, or: - // - // * we have got to the point where the room was created, or: - // - // * the server indicated that there were no more visible events - // (normally implying we got to the start of the room), or: - // - // * we gave up asking the server for more events canBackPaginate: false, - - // canForwardPaginate == false may mean: - // - // * we haven't (successfully) loaded the timeline yet - // - // * we have got to the end of time and are now tracking the live - // timeline, or: - // - // * the server indicated that there were no more visible events - // (not sure if this ever happens when we're not at the live - // timeline), or: - // - // * we are looking at some historical point, but gave up asking - // the server for more events canForwardPaginate: false, - - // start with the read-marker visible, so that we see its animated - // disappearance when switching into the room. readMarkerVisible: true, - readMarkerEventId: initialReadMarker, - backPaginating: false, forwardPaginating: false, - - // cache of matrixClient.getSyncState() (but from the 'sync' event) clientSyncState: MatrixClientPeg.get().getSyncState(), - - // should the event tiles have twelve hour times isTwelveHour: SettingsStore.getValue("showTwelveHourTimestamps"), - - // always show timestamps on event tiles? alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"), - - // how long to show the RM for when it's visible in the window readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"), - - // how long to show the RM for when it's scrolled off-screen readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"), }; this.dispatcherRef = dis.register(this.onAction); - MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); - MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset); - MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction); + const cli = MatrixClientPeg.get(); + cli.on("Room.timeline", this.onRoomTimeline); + cli.on("Room.timelineReset", this.onRoomTimelineReset); + cli.on("Room.redaction", this.onRoomRedaction); // same event handler as Room.redaction as for both we just do forceUpdate - MatrixClientPeg.get().on("Room.redactionCancelled", this.onRoomRedaction); - MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); - MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated); - MatrixClientPeg.get().on("Room.accountData", this.onAccountData); - MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted); - MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced); - MatrixClientPeg.get().on("sync", this.onSync); + cli.on("Room.redactionCancelled", this.onRoomRedaction); + cli.on("Room.receipt", this.onRoomReceipt); + cli.on("Room.localEchoUpdated", this.onLocalEchoUpdated); + cli.on("Room.accountData", this.onAccountData); + cli.on("Event.decrypted", this.onEventDecrypted); + cli.on("Event.replaced", this.onEventReplaced); + cli.on("sync", this.onSync); } // TODO: [REACT-WARNING] Move into constructor @@ -231,7 +286,7 @@ class TimelinePanel extends React.Component { this.updateReadMarkerOnUserActivity(); } - this._initTimeline(this.props); + this.initTimeline(this.props); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -252,50 +307,28 @@ class TimelinePanel extends React.Component { console.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue"); } - if (newProps.eventId != this.props.eventId) { + const differentEventId = newProps.eventId != this.props.eventId; + const differentHighlightedEventId = newProps.highlightedEventId != this.props.highlightedEventId; + if (differentEventId || differentHighlightedEventId) { console.log("TimelinePanel switching to eventId " + newProps.eventId + " (was " + this.props.eventId + ")"); - return this._initTimeline(newProps); + return this.initTimeline(newProps); } } - shouldComponentUpdate(nextProps, nextState) { - if (!ObjectUtils.shallowEqual(this.props, nextProps)) { - if (DEBUG) { - console.group("Timeline.shouldComponentUpdate: props change"); - console.log("props before:", this.props); - console.log("props after:", nextProps); - console.groupEnd(); - } - return true; - } - - if (!ObjectUtils.shallowEqual(this.state, nextState)) { - if (DEBUG) { - console.group("Timeline.shouldComponentUpdate: state change"); - console.log("state before:", this.state); - console.log("state after:", nextState); - console.groupEnd(); - } - return true; - } - - return false; - } - componentWillUnmount() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. // // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; - if (this._readReceiptActivityTimer) { - this._readReceiptActivityTimer.abort(); - this._readReceiptActivityTimer = null; + if (this.readReceiptActivityTimer) { + this.readReceiptActivityTimer.abort(); + this.readReceiptActivityTimer = null; } - if (this._readMarkerActivityTimer) { - this._readMarkerActivityTimer.abort(); - this._readMarkerActivityTimer = null; + if (this.readMarkerActivityTimer) { + this.readMarkerActivityTimer.abort(); + this.readMarkerActivityTimer = null; } dis.unregister(this.dispatcherRef); @@ -315,7 +348,7 @@ class TimelinePanel extends React.Component { } } - onMessageListUnfillRequest = (backwards, scrollToken) => { + private onMessageListUnfillRequest = (backwards: boolean, scrollToken: string): void => { // If backwards, unpaginate from the back (i.e. the start of the timeline) const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; debuglog("TimelinePanel: unpaginating events in direction", dir); @@ -334,21 +367,30 @@ class TimelinePanel extends React.Component { if (count > 0) { debuglog("TimelinePanel: Unpaginating", count, "in direction", dir); - this._timelineWindow.unpaginate(count, backwards); + this.timelineWindow.unpaginate(count, backwards); - // We can now paginate in the unpaginated direction - const canPaginateKey = (backwards) ? 'canBackPaginate' : 'canForwardPaginate'; - const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); - this.setState({ - [canPaginateKey]: true, + const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); + const newState: Partial = { events, liveEvents, firstVisibleEventIndex, - }); + }; + + // We can now paginate in the unpaginated direction + if (backwards) { + newState.canBackPaginate = true; + } else { + newState.canForwardPaginate = true; + } + this.setState(newState); } }; - onPaginationRequest = (timelineWindow, direction, size) => { + private onPaginationRequest = ( + timelineWindow: TimelineWindow, + direction: Direction, + size: number, + ): Promise => { if (this.props.onPaginationRequest) { return this.props.onPaginationRequest(timelineWindow, direction, size); } else { @@ -357,8 +399,8 @@ class TimelinePanel extends React.Component { }; // set off a pagination request. - onMessageListFillRequest = backwards => { - if (!this._shouldPaginate()) return Promise.resolve(false); + private onMessageListFillRequest = (backwards: boolean): Promise => { + if (!this.shouldPaginate()) return Promise.resolve(false); const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate'; @@ -369,9 +411,9 @@ class TimelinePanel extends React.Component { return Promise.resolve(false); } - if (!this._timelineWindow.canPaginate(dir)) { + if (!this.timelineWindow.canPaginate(dir)) { debuglog("TimelinePanel: can't", dir, "paginate any further"); - this.setState({[canPaginateKey]: false}); + this.setState({ [canPaginateKey]: false }); return Promise.resolve(false); } @@ -381,15 +423,15 @@ class TimelinePanel extends React.Component { } debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards); - this.setState({[paginatingKey]: true}); + this.setState({ [paginatingKey]: true }); - return this.onPaginationRequest(this._timelineWindow, dir, PAGINATE_SIZE).then((r) => { + return this.onPaginationRequest(this.timelineWindow, dir, PAGINATE_SIZE).then((r) => { if (this.unmounted) { return; } debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r); - const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); - const newState = { + const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); + const newState: Partial = { [paginatingKey]: false, [canPaginateKey]: r, events, @@ -402,7 +444,7 @@ class TimelinePanel extends React.Component { const otherDirection = backwards ? EventTimeline.FORWARDS : EventTimeline.BACKWARDS; const canPaginateOtherWayKey = backwards ? 'canForwardPaginate' : 'canBackPaginate'; if (!this.state[canPaginateOtherWayKey] && - this._timelineWindow.canPaginate(otherDirection)) { + this.timelineWindow.canPaginate(otherDirection)) { debuglog('TimelinePanel: can now', otherDirection, 'paginate again'); newState[canPaginateOtherWayKey] = true; } @@ -413,9 +455,9 @@ class TimelinePanel extends React.Component { // has in memory because we never gave the component a chance to scroll // itself into the right place return new Promise((resolve) => { - this.setState(newState, () => { + this.setState(newState, () => { // we can continue paginating in the given direction if: - // - _timelineWindow.paginate says we can + // - timelineWindow.paginate says we can // - we're paginating forwards, or we won't be trying to // paginate backwards past the first visible event resolve(r && (!backwards || firstVisibleEventIndex === 0)); @@ -424,7 +466,7 @@ class TimelinePanel extends React.Component { }); }; - onMessageListScroll = e => { + private onMessageListScroll = e => { if (this.props.onScroll) { this.props.onScroll(e); } @@ -435,34 +477,35 @@ class TimelinePanel extends React.Component { // it goes back off the top of the screen (presumably because the user // clicks on the 'jump to bottom' button), we need to re-enable it. if (rmPosition < 0) { - this.setState({readMarkerVisible: true}); + this.setState({ readMarkerVisible: true }); } // if read marker position goes between 0 and -1/1, // (and user is active), switch timeout - const timeout = this._readMarkerTimeout(rmPosition); + const timeout = this.readMarkerTimeout(rmPosition); // NO-OP when timeout already has set to the given value - this._readMarkerActivityTimer.changeTimeout(timeout); + this.readMarkerActivityTimer.changeTimeout(timeout); } }; - onAction = payload => { - if (payload.action === 'ignore_state_changed') { - this.forceUpdate(); - } - if (payload.action === "edit_event") { - const editState = payload.event ? new EditorStateTransfer(payload.event) : null; - this.setState({editState}, () => { - if (payload.event && this._messagePanel.current) { - this._messagePanel.current.scrollToEventIfNeeded( - payload.event.getId(), - ); - } - }); + private onAction = (payload: ActionPayload): void => { + switch (payload.action) { + case "ignore_state_changed": + this.forceUpdate(); + break; } }; - onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => { + private onRoomTimeline = ( + ev: MatrixEvent, + room: Room, + toStartOfTimeline: boolean, + removed: boolean, + data: { + timeline: EventTimeline; + liveEvent?: boolean; + }, + ): void => { // ignore events for other timeline sets if (data.timeline.getTimelineSet() !== this.props.timelineSet) return; @@ -470,13 +513,13 @@ class TimelinePanel extends React.Component { // updates from pagination will happen when the paginate completes. if (toStartOfTimeline || !data || !data.liveEvent) return; - if (!this._messagePanel.current) return; + if (!this.messagePanel.current) return; - if (!this._messagePanel.current.getScrollState().stuckAtBottom) { + if (!this.messagePanel.current.getScrollState().stuckAtBottom) { // we won't load this event now, because we don't want to push any // events off the other end of the timeline. But we need to note // that we can now paginate. - this.setState({canForwardPaginate: true}); + this.setState({ canForwardPaginate: true }); return; } @@ -489,13 +532,13 @@ class TimelinePanel extends React.Component { // timeline window. // // see https://github.com/vector-im/vector-web/issues/1035 - this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => { + this.timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => { if (this.unmounted) { return; } - const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); + const { events, liveEvents, firstVisibleEventIndex } = this.getEvents(); const lastLiveEvent = liveEvents[liveEvents.length - 1]; - const updatedState = { + const updatedState: Partial = { events, liveEvents, firstVisibleEventIndex, @@ -512,23 +555,22 @@ class TimelinePanel extends React.Component { // more than the timeout on userActiveRecently. // const myUserId = MatrixClientPeg.get().credentials.userId; - const sender = ev.sender ? ev.sender.userId : null; callRMUpdated = false; - if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) { + if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) { updatedState.readMarkerVisible = true; } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { // we know we're stuckAtBottom, so we can advance the RM // immediately, to save a later render cycle - this._setReadMarker(lastLiveEvent.getId(), lastLiveEvent.getTs(), true); + this.setReadMarker(lastLiveEvent.getId(), lastLiveEvent.getTs(), true); updatedState.readMarkerVisible = false; updatedState.readMarkerEventId = lastLiveEvent.getId(); callRMUpdated = true; } } - this.setState(updatedState, () => { - this._messagePanel.current.updateTimelineMinHeight(); + this.setState(updatedState, () => { + this.messagePanel.current.updateTimelineMinHeight(); if (callRMUpdated) { this.props.onReadMarkerUpdated(); } @@ -536,17 +578,17 @@ class TimelinePanel extends React.Component { }); }; - onRoomTimelineReset = (room, timelineSet) => { + private onRoomTimelineReset = (room: Room, timelineSet: EventTimelineSet): void => { if (timelineSet !== this.props.timelineSet) return; - if (this._messagePanel.current && this._messagePanel.current.isAtBottom()) { - this._loadTimeline(); + if (this.messagePanel.current && this.messagePanel.current.isAtBottom()) { + this.loadTimeline(); } }; - canResetTimeline = () => this._messagePanel.current && this._messagePanel.current.isAtBottom(); + public canResetTimeline = () => this.messagePanel?.current.isAtBottom(); - onRoomRedaction = (ev, room) => { + private onRoomRedaction = (ev: MatrixEvent, room: Room): void => { if (this.unmounted) return; // ignore events for other rooms @@ -557,7 +599,7 @@ class TimelinePanel extends React.Component { this.forceUpdate(); }; - onEventReplaced = (replacedEvent, room) => { + private onEventReplaced = (replacedEvent: MatrixEvent, room: Room): void => { if (this.unmounted) return; // ignore events for other rooms @@ -568,7 +610,7 @@ class TimelinePanel extends React.Component { this.forceUpdate(); }; - onRoomReceipt = (ev, room) => { + private onRoomReceipt = (ev: MatrixEvent, room: Room): void => { if (this.unmounted) return; // ignore events for other rooms @@ -577,22 +619,22 @@ class TimelinePanel extends React.Component { this.forceUpdate(); }; - onLocalEchoUpdated = (ev, room, oldEventId) => { + private onLocalEchoUpdated = (ev: MatrixEvent, room: Room, oldEventId: string): void => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; - this._reloadEvents(); + this.reloadEvents(); }; - onAccountData = (ev, room) => { + private onAccountData = (ev: MatrixEvent, room: Room): void => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; - if (ev.getType() !== "m.fully_read") return; + if (ev.getType() !== EventType.FullyRead) return; // XXX: roomReadMarkerTsMap not updated here so it is now inconsistent. Replace // this mechanism of determining where the RM is relative to the view-port with @@ -602,7 +644,7 @@ class TimelinePanel extends React.Component { }, this.props.onReadMarkerUpdated); }; - onEventDecrypted = ev => { + private onEventDecrypted = (ev: MatrixEvent): void => { // Can be null for the notification timeline, etc. if (!this.props.timelineSet.room) return; @@ -617,46 +659,46 @@ class TimelinePanel extends React.Component { } }; - onSync = (state, prevState, data) => { - this.setState({clientSyncState: state}); + private onSync = (clientSyncState: SyncState, prevState: SyncState, data: object): void => { + this.setState({ clientSyncState }); }; - _readMarkerTimeout(readMarkerPosition) { + private readMarkerTimeout(readMarkerPosition: number): number { return readMarkerPosition === 0 ? this.state.readMarkerInViewThresholdMs : this.state.readMarkerOutOfViewThresholdMs; } - async updateReadMarkerOnUserActivity() { - const initialTimeout = this._readMarkerTimeout(this.getReadMarkerPosition()); - this._readMarkerActivityTimer = new Timer(initialTimeout); + private async updateReadMarkerOnUserActivity(): Promise { + const initialTimeout = this.readMarkerTimeout(this.getReadMarkerPosition()); + this.readMarkerActivityTimer = new Timer(initialTimeout); - while (this._readMarkerActivityTimer) { //unset on unmount - UserActivity.sharedInstance().timeWhileActiveRecently(this._readMarkerActivityTimer); + while (this.readMarkerActivityTimer) { //unset on unmount + UserActivity.sharedInstance().timeWhileActiveRecently(this.readMarkerActivityTimer); try { - await this._readMarkerActivityTimer.finished(); + await this.readMarkerActivityTimer.finished(); } catch (e) { continue; /* aborted */ } // outside of try/catch to not swallow errors this.updateReadMarker(); } } - async updateReadReceiptOnUserActivity() { - this._readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS); - while (this._readReceiptActivityTimer) { //unset on unmount - UserActivity.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer); + private async updateReadReceiptOnUserActivity(): Promise { + this.readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS); + while (this.readReceiptActivityTimer) { //unset on unmount + UserActivity.sharedInstance().timeWhileActiveNow(this.readReceiptActivityTimer); try { - await this._readReceiptActivityTimer.finished(); + await this.readReceiptActivityTimer.finished(); } catch (e) { continue; /* aborted */ } // outside of try/catch to not swallow errors this.sendReadReceipt(); } } - sendReadReceipt = () => { + private sendReadReceipt = (): void => { if (SettingsStore.getValue("lowBandwidth")) return; - if (!this._messagePanel.current) return; + if (!this.messagePanel.current) return; if (!this.props.manageReadReceipts) return; // This happens on user_activity_end which is delayed, and it's // very possible have logged out within that timeframe, so check @@ -667,8 +709,8 @@ class TimelinePanel extends React.Component { let shouldSendRR = true; - const currentRREventId = this._getCurrentReadReceipt(true); - const currentRREventIndex = this._indexForEventId(currentRREventId); + const currentRREventId = this.getCurrentReadReceipt(true); + const currentRREventIndex = this.indexForEventId(currentRREventId); // We want to avoid sending out read receipts when we are looking at // events in the past which are before the latest RR. // @@ -683,11 +725,11 @@ class TimelinePanel extends React.Component { // the user eventually hits the live timeline. // if (currentRREventId && currentRREventIndex === null && - this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) { shouldSendRR = false; } - const lastReadEventIndex = this._getLastDisplayedEventIndex({ + const lastReadEventIndex = this.getLastDisplayedEventIndex({ ignoreOwn: true, }); if (lastReadEventIndex === null) { @@ -715,26 +757,22 @@ class TimelinePanel extends React.Component { } this.lastRMSentEventId = this.state.readMarkerEventId; - const roomId = this.props.timelineSet.room.roomId; - const hiddenRR = !SettingsStore.getValue("sendReadReceipts", roomId); - debuglog('TimelinePanel: Sending Read Markers for ', this.props.timelineSet.room.roomId, 'rm', this.state.readMarkerEventId, lastReadEvent ? 'rr ' + lastReadEvent.getId() : '', - ' hidden:' + hiddenRR, ); MatrixClientPeg.get().setRoomReadMarkers( this.props.timelineSet.room.roomId, this.state.readMarkerEventId, lastReadEvent, // Could be null, in which case no RR is sent - {hidden: hiddenRR}, + {}, ).catch((e) => { // /read_markers API is not implemented on this HS, fallback to just RR if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { return MatrixClientPeg.get().sendReadReceipt( lastReadEvent, - {hidden: hiddenRR}, + {}, ).catch((e) => { console.error(e); this.lastRRSentEventId = undefined; @@ -753,8 +791,8 @@ class TimelinePanel extends React.Component { // that sending an RR for the latest message will set our notif counter // to zero: it may not do this if we send an RR for somewhere before the end. if (this.isAtEndOfLiveTimeline()) { - this.props.timelineSet.room.setUnreadNotificationCount('total', 0); - this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0); + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); dis.dispatch({ action: 'on_room_read', roomId: this.props.timelineSet.room.roomId, @@ -765,7 +803,7 @@ class TimelinePanel extends React.Component { // if the read marker is on the screen, we can now assume we've caught up to the end // of the screen, so move the marker down to the bottom of the screen. - updateReadMarker = () => { + private updateReadMarker = (): void => { if (!this.props.manageReadMarkers) return; if (this.getReadMarkerPosition() === 1) { // the read marker is at an event below the viewport, @@ -775,7 +813,7 @@ class TimelinePanel extends React.Component { // move the RM to *after* the message at the bottom of the screen. This // avoids a problem whereby we never advance the RM if there is a huge // message which doesn't fit on the screen. - const lastDisplayedIndex = this._getLastDisplayedEventIndex({ + const lastDisplayedIndex = this.getLastDisplayedEventIndex({ allowPartial: true, }); @@ -783,8 +821,10 @@ class TimelinePanel extends React.Component { return; } const lastDisplayedEvent = this.state.events[lastDisplayedIndex]; - this._setReadMarker(lastDisplayedEvent.getId(), - lastDisplayedEvent.getTs()); + this.setReadMarker( + lastDisplayedEvent.getId(), + lastDisplayedEvent.getTs(), + ); // the read-marker should become invisible, so that if the user scrolls // down, they don't see it. @@ -798,15 +838,14 @@ class TimelinePanel extends React.Component { this.sendReadReceipt(); }; - // advance the read marker past any events we sent ourselves. - _advanceReadMarkerPastMyEvents() { + private advanceReadMarkerPastMyEvents(): void { if (!this.props.manageReadMarkers) return; - // we call `_timelineWindow.getEvents()` rather than using + // we call `timelineWindow.getEvents()` rather than using // `this.state.liveEvents`, because React batches the update to the // latter, so it may not have been updated yet. - const events = this._timelineWindow.getEvents(); + const events = this.timelineWindow.getEvents(); // first find where the current RM is let i; @@ -823,7 +862,7 @@ class TimelinePanel extends React.Component { const myUserId = MatrixClientPeg.get().credentials.userId; for (i++; i < events.length; i++) { const ev = events[i]; - if (!ev.sender || ev.sender.userId != myUserId) { + if (ev.getSender() !== myUserId) { break; } } @@ -831,61 +870,63 @@ class TimelinePanel extends React.Component { i--; const ev = events[i]; - this._setReadMarker(ev.getId(), ev.getTs()); + this.setReadMarker(ev.getId(), ev.getTs()); } /* jump down to the bottom of this room, where new events are arriving */ - jumpToLiveTimeline = () => { + public jumpToLiveTimeline = (): void => { // if we can't forward-paginate the existing timeline, then there // is no point reloading it - just jump straight to the bottom. // // Otherwise, reload the timeline rather than trying to paginate // through all of space-time. - if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - this._loadTimeline(); + if (this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + this.loadTimeline(); } else { - if (this._messagePanel.current) { - this._messagePanel.current.scrollToBottom(); - } + this.messagePanel.current?.scrollToBottom(); } }; + public scrollToEventIfNeeded = (eventId: string): void => { + this.messagePanel.current?.scrollToEventIfNeeded(eventId); + }; + /* scroll to show the read-up-to marker. We put it 1/3 of the way down * the container. */ - jumpToReadMarker = () => { + public jumpToReadMarker = (): void => { if (!this.props.manageReadMarkers) return; - if (!this._messagePanel.current) return; + if (!this.messagePanel.current) return; if (!this.state.readMarkerEventId) return; // we may not have loaded the event corresponding to the read-marker - // into the _timelineWindow. In that case, attempts to scroll to it + // into the timelineWindow. In that case, attempts to scroll to it // will fail. // // a quick way to figure out if we've loaded the relevant event is // simply to check if the messagepanel knows where the read-marker is. - const ret = this._messagePanel.current.getReadMarkerPosition(); + const ret = this.messagePanel.current.getReadMarkerPosition(); if (ret !== null) { // The messagepanel knows where the RM is, so we must have loaded // the relevant event. - this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId, - 0, 1/3); + this.messagePanel.current.scrollToEvent(this.state.readMarkerEventId, + 0, 1/3); return; } // Looks like we haven't loaded the event corresponding to the read-marker. // As with jumpToLiveTimeline, we want to reload the timeline around the // read-marker. - this._loadTimeline(this.state.readMarkerEventId, 0, 1/3); + this.loadTimeline(this.state.readMarkerEventId, 0, 1/3); }; /* update the read-up-to marker to match the read receipt */ - forgetReadMarker = () => { + public forgetReadMarker = (): void => { if (!this.props.manageReadMarkers) return; - const rmId = this._getCurrentReadReceipt(); + const rmId = this.getCurrentReadReceipt(); // see if we know the timestamp for the rr event const tl = this.props.timelineSet.getTimelineForEvent(rmId); @@ -897,28 +938,26 @@ class TimelinePanel extends React.Component { } } - this._setReadMarker(rmId, rmTs); + this.setReadMarker(rmId, rmTs); }; /* return true if the content is fully scrolled down and we are * at the end of the live timeline. */ - isAtEndOfLiveTimeline = () => { - return this._messagePanel.current - && this._messagePanel.current.isAtBottom() - && this._timelineWindow - && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); - } - + public isAtEndOfLiveTimeline = (): boolean => { + return this.messagePanel.current?.isAtBottom() + && this.timelineWindow + && !this.timelineWindow.canPaginate(EventTimeline.FORWARDS); + }; /* get the current scroll state. See ScrollPanel.getScrollState for * details. * * returns null if we are not mounted. */ - getScrollState = () => { - if (!this._messagePanel.current) { return null; } - return this._messagePanel.current.getScrollState(); + public getScrollState = (): IScrollState => { + if (!this.messagePanel.current) { return null; } + return this.messagePanel.current.getScrollState(); }; // returns one of: @@ -927,11 +966,11 @@ class TimelinePanel extends React.Component { // -1: read marker is above the window // 0: read marker is visible // +1: read marker is below the window - getReadMarkerPosition = () => { + public getReadMarkerPosition = (): number => { if (!this.props.manageReadMarkers) return null; - if (!this._messagePanel.current) return null; + if (!this.messagePanel.current) return null; - const ret = this._messagePanel.current.getReadMarkerPosition(); + const ret = this.messagePanel.current.getReadMarkerPosition(); if (ret !== null) { return ret; } @@ -950,7 +989,7 @@ class TimelinePanel extends React.Component { return null; }; - canJumpToReadMarker = () => { + public canJumpToReadMarker = (): boolean => { // 1. Do not show jump bar if neither the RM nor the RR are set. // 3. We want to show the bar if the read-marker is off the top of the screen. // 4. Also, if pos === null, the event might not be paginated - show the unread bar @@ -965,19 +1004,19 @@ class TimelinePanel extends React.Component { * * We pass it down to the scroll panel. */ - handleScrollKey = ev => { - if (!this._messagePanel.current) { return; } + public handleScrollKey = ev => { + if (!this.messagePanel.current) { return; } // jump to the live timeline on ctrl-end, rather than the end of the // timeline window. if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === Key.END) { this.jumpToLiveTimeline(); } else { - this._messagePanel.current.handleScrollKey(ev); + this.messagePanel.current.handleScrollKey(ev); } }; - _initTimeline(props) { + private initTimeline(props: IProps): void { const initialEvent = props.eventId; const pixelOffset = props.eventPixelOffset; @@ -988,7 +1027,7 @@ class TimelinePanel extends React.Component { offsetBase = 0.5; } - return this._loadTimeline(initialEvent, pixelOffset, offsetBase); + return this.loadTimeline(initialEvent, pixelOffset, offsetBase); } /** @@ -1004,34 +1043,34 @@ class TimelinePanel extends React.Component { * @param {number?} offsetBase the reference point for the pixelOffset. 0 * means the top of the container, 1 means the bottom, and fractional * values mean somewhere in the middle. If omitted, it defaults to 0. - * - * returns a promise which will resolve when the load completes. */ - _loadTimeline(eventId, pixelOffset, offsetBase) { - this._timelineWindow = new Matrix.TimelineWindow( + private loadTimeline(eventId?: string, pixelOffset?: number, offsetBase?: number): void { + this.timelineWindow = new TimelineWindow( MatrixClientPeg.get(), this.props.timelineSet, - {windowLimit: this.props.timelineCap}); + { windowLimit: this.props.timelineCap }); const onLoaded = () => { + if (this.unmounted) return; + // clear the timeline min-height when // (re)loading the timeline - if (this._messagePanel.current) { - this._messagePanel.current.onTimelineReset(); + if (this.messagePanel.current) { + this.messagePanel.current.onTimelineReset(); } - this._reloadEvents(); + this.reloadEvents(); // If we switched away from the room while there were pending // outgoing events, the read-marker will be before those events. // We need to skip over any which have subsequently been sent. - this._advanceReadMarkerPastMyEvents(); + this.advanceReadMarkerPastMyEvents(); this.setState({ - canBackPaginate: this._timelineWindow.canPaginate(EventTimeline.BACKWARDS), - canForwardPaginate: this._timelineWindow.canPaginate(EventTimeline.FORWARDS), + canBackPaginate: this.timelineWindow.canPaginate(EventTimeline.BACKWARDS), + canForwardPaginate: this.timelineWindow.canPaginate(EventTimeline.FORWARDS), timelineLoading: false, }, () => { // initialise the scroll state of the message panel - if (!this._messagePanel.current) { + if (!this.messagePanel.current) { // this shouldn't happen - we know we're mounted because // we're in a setState callback, and we know // timelineLoading is now false, so render() should have @@ -1041,22 +1080,25 @@ class TimelinePanel extends React.Component { return; } if (eventId) { - this._messagePanel.current.scrollToEvent(eventId, pixelOffset, - offsetBase); + this.messagePanel.current.scrollToEvent(eventId, pixelOffset, + offsetBase); } else { - this._messagePanel.current.scrollToBottom(); + this.messagePanel.current.scrollToBottom(); } - this.sendReadReceipt(); + if (this.props.sendReadReceiptOnLoad) { + this.sendReadReceipt(); + } }); }; const onError = (error) => { + if (this.unmounted) return; + this.setState({ timelineLoading: false }); console.error( `Error loading timeline panel at ${eventId}: ${error}`, ); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); let onFinished; @@ -1104,10 +1146,10 @@ class TimelinePanel extends React.Component { if (timeline) { // This is a hot-path optimization by skipping a promise tick // by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline - this._timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time + this.timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time onLoaded(); } else { - const prom = this._timelineWindow.load(eventId, INITIAL_SIZE); + const prom = this.timelineWindow.load(eventId, INITIAL_SIZE); this.setState({ events: [], liveEvents: [], @@ -1122,25 +1164,36 @@ class TimelinePanel extends React.Component { // handle the completion of a timeline load or localEchoUpdate, by // reloading the events from the timelinewindow and pending event list into // the state. - _reloadEvents() { + private reloadEvents(): void { // we might have switched rooms since the load started - just bin // the results if so. if (this.unmounted) return; - this.setState(this._getEvents()); + this.setState(this.getEvents()); } // get the list of events from the timeline window and the pending event list - _getEvents() { - const events = this._timelineWindow.getEvents(); - const firstVisibleEventIndex = this._checkForPreJoinUISI(events); + private getEvents(): Pick { + const events: MatrixEvent[] = this.timelineWindow.getEvents(); + + // `arrayFastClone` performs a shallow copy of the array + // we want the last event to be decrypted first but displayed last + // `reverse` is destructive and unfortunately mutates the "events" array + arrayFastClone(events) + .reverse() + .forEach(event => { + const client = MatrixClientPeg.get(); + client.decryptEventIfNeeded(event); + }); + + const firstVisibleEventIndex = this.checkForPreJoinUISI(events); // Hold onto the live events separately. The read receipt and read marker // should use this list, so that they don't advance into pending events. const liveEvents = [...events]; // if we're at the end of the live timeline, append the pending events - if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + if (!this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) { events.push(...this.props.timelineSet.getPendingEvents()); } @@ -1161,7 +1214,7 @@ class TimelinePanel extends React.Component { * undecryptable event that was sent while the user was not in the room. If no * such events were found, then it returns 0. */ - _checkForPreJoinUISI(events) { + private checkForPreJoinUISI(events: MatrixEvent[]): number { const room = this.props.timelineSet.room; if (events.length === 0 || !room || @@ -1225,7 +1278,7 @@ class TimelinePanel extends React.Component { return 0; } - _indexForEventId(evId) { + private indexForEventId(evId: string): number | null { for (let i = 0; i < this.state.events.length; ++i) { if (evId == this.state.events[i].getId()) { return i; @@ -1234,15 +1287,14 @@ class TimelinePanel extends React.Component { return null; } - _getLastDisplayedEventIndex(opts) { - opts = opts || {}; + private getLastDisplayedEventIndex(opts: IEventIndexOpts = {}): number | null { const ignoreOwn = opts.ignoreOwn || false; const allowPartial = opts.allowPartial || false; - const messagePanel = this._messagePanel.current; + const messagePanel = this.messagePanel.current; if (!messagePanel) return null; - const messagePanelNode = ReactDOM.findDOMNode(messagePanel); + const messagePanelNode = ReactDOM.findDOMNode(messagePanel) as HTMLElement; if (!messagePanelNode) return null; // sometimes this happens for fresh rooms/post-sync const wrapperRect = messagePanelNode.getBoundingClientRect(); const myUserId = MatrixClientPeg.get().credentials.userId; @@ -1284,8 +1336,8 @@ class TimelinePanel extends React.Component { } const shouldIgnore = !!ev.status || // local echo - (ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message - const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev); + (ignoreOwn && ev.getSender() === myUserId); // own message + const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev, this.context); if (isWithoutTile || !node) { // don't start counting if the event should be ignored, @@ -1319,7 +1371,7 @@ class TimelinePanel extends React.Component { * SDK. * @return {String} the event ID */ - _getCurrentReadReceipt(ignoreSynthesized) { + private getCurrentReadReceipt(ignoreSynthesized = false): string { const client = MatrixClientPeg.get(); // the client can be null on logout if (client == null) { @@ -1330,7 +1382,7 @@ class TimelinePanel extends React.Component { return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized); } - _setReadMarker(eventId, eventTs, inhibitSetState) { + private setReadMarker(eventId: string, eventTs: number, inhibitSetState = false): void { const roomId = this.props.timelineSet.room.roomId; // don't update the state (and cause a re-render) if there is @@ -1355,7 +1407,7 @@ class TimelinePanel extends React.Component { }, this.props.onReadMarkerUpdated); } - _shouldPaginate() { + private shouldPaginate(): boolean { // don't try to paginate while events in the timeline are // still being decrypted. We don't render events while they're // being decrypted, so they don't take up space in the timeline. @@ -1366,12 +1418,13 @@ class TimelinePanel extends React.Component { }); } - getRelationsForEvent = (...args) => this.props.timelineSet.getRelationsForEvent(...args); + private getRelationsForEvent = ( + eventId: string, + relationType: RelationType, + eventType: EventType | string, + ) => this.props.timelineSet.getRelationsForEvent(eventId, relationType, eventType); render() { - const MessagePanel = sdk.getComponent("structures.MessagePanel"); - const Loader = sdk.getComponent("elements.Spinner"); - // just show a spinner while the timeline loads. // // put it in a div of the right class (mx_RoomView_messagePanel) so @@ -1386,7 +1439,7 @@ class TimelinePanel extends React.Component { if (this.state.timelineLoading) { return (
      - +
      ); } @@ -1407,7 +1460,7 @@ class TimelinePanel extends React.Component { // forwards, otherwise if somebody hits the bottom of the loaded // events when viewing historical messages, we get stuck in a loop // of paginating our way through the entire history of the room. - const stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); + const stickyBottom = !this.timelineWindow.canPaginate(EventTimeline.FORWARDS); // If the state is PREPARED or CATCHUP, we're still waiting for the js-sdk to sync with // the HS and fetch the latest events, so we are effectively forward paginating. @@ -1416,11 +1469,11 @@ class TimelinePanel extends React.Component { ['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState) ); const events = this.state.firstVisibleEventIndex - ? this.state.events.slice(this.state.firstVisibleEventIndex) - : this.state.events; + ? this.state.events.slice(this.state.firstVisibleEventIndex) + : this.state.events; return (
      {React.createElement(component, toastProps)}
      ); + + containerClasses = classNames("mx_ToastContainer", { + "mx_ToastContainer_stacked": isStacked, + }); } - - const containerClasses = classNames("mx_ToastContainer", { - "mx_ToastContainer_stacked": isStacked, - }); - - return ( -
      - {toast} -
      - ); + return toast + ? ( +
      + {toast} +
      + ) + : null; } } diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js deleted file mode 100644 index 16cc4cb987..0000000000 --- a/src/components/structures/UploadBar.js +++ /dev/null @@ -1,109 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import ContentMessages from '../../ContentMessages'; -import dis from "../../dispatcher/dispatcher"; -import filesize from "filesize"; -import { _t } from '../../languageHandler'; - -export default class UploadBar extends React.Component { - static propTypes = { - room: PropTypes.object, - }; - - componentDidMount() { - this.dispatcherRef = dis.register(this.onAction); - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - dis.unregister(this.dispatcherRef); - } - - onAction = payload => { - switch (payload.action) { - case 'upload_progress': - case 'upload_finished': - case 'upload_canceled': - case 'upload_failed': - if (this.mounted) this.forceUpdate(); - break; - } - }; - - render() { - const uploads = ContentMessages.sharedInstance().getCurrentUploads(); - - // for testing UI... - also fix up the ContentMessages.getCurrentUploads().length - // check in RoomView - // - // uploads = [{ - // roomId: this.props.room.roomId, - // loaded: 123493, - // total: 347534, - // fileName: "testing_fooble.jpg", - // }]; - - if (uploads.length == 0) { - return
      ; - } - - let upload; - for (let i = 0; i < uploads.length; ++i) { - if (uploads[i].roomId == this.props.room.roomId) { - upload = uploads[i]; - break; - } - } - if (!upload) { - return
      ; - } - - const innerProgressStyle = { - width: ((upload.loaded / (upload.total || 1)) * 100) + '%', - }; - let uploadedSize = filesize(upload.loaded); - const totalSize = filesize(upload.total); - if (uploadedSize.replace(/^.* /, '') === totalSize.replace(/^.* /, '')) { - uploadedSize = uploadedSize.replace(/ .*/, ''); - } - - // MUST use var name 'count' for pluralization to kick in - const uploadText = _t( - "Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)}, - ); - - return ( -
      -
      -
      -
      - - -
      - { uploadedSize } / { totalSize } -
      -
      { uploadText }
      -
      - ); - } -} diff --git a/src/components/structures/UploadBar.tsx b/src/components/structures/UploadBar.tsx new file mode 100644 index 0000000000..c8e90a1c0a --- /dev/null +++ b/src/components/structures/UploadBar.tsx @@ -0,0 +1,113 @@ +/* +Copyright 2015, 2016, 2019, 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 from 'react'; +import { Room } from "matrix-js-sdk/src/models/room"; +import ContentMessages from '../../ContentMessages'; +import dis from "../../dispatcher/dispatcher"; +import filesize from "filesize"; +import { _t } from '../../languageHandler'; +import { ActionPayload } from "../../dispatcher/payloads"; +import { Action } from "../../dispatcher/actions"; +import ProgressBar from "../views/elements/ProgressBar"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import { IUpload } from "../../models/IUpload"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; + +interface IProps { + room: Room; +} + +interface IState { + currentUpload?: IUpload; + uploadsHere: IUpload[]; +} + +@replaceableComponent("structures.UploadBar") +export default class UploadBar extends React.Component { + static contextType = MatrixClientContext; + + private dispatcherRef: string; + private mounted: boolean; + + constructor(props) { + super(props); + + // Set initial state to any available upload in this room - we might be mounting + // earlier than the first progress event, so should show something relevant. + const uploadsHere = this.getUploadsInRoom(); + this.state = { currentUpload: uploadsHere[0], uploadsHere }; + } + + componentDidMount() { + this.dispatcherRef = dis.register(this.onAction); + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + dis.unregister(this.dispatcherRef); + } + + private getUploadsInRoom(): IUpload[] { + const uploads = ContentMessages.sharedInstance().getCurrentUploads(); + return uploads.filter(u => u.roomId === this.props.room.roomId); + } + + private onAction = (payload: ActionPayload) => { + switch (payload.action) { + case Action.UploadStarted: + case Action.UploadProgress: + case Action.UploadFinished: + case Action.UploadCanceled: + case Action.UploadFailed: { + if (!this.mounted) return; + const uploadsHere = this.getUploadsInRoom(); + this.setState({ currentUpload: uploadsHere[0], uploadsHere }); + break; + } + } + }; + + private onCancelClick = (ev) => { + ev.preventDefault(); + ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise, this.context); + }; + + render() { + if (!this.state.currentUpload) { + return null; + } + + // MUST use var name 'count' for pluralization to kick in + const uploadText = _t( + "Uploading %(filename)s and %(count)s others", { + filename: this.state.currentUpload.fileName, + count: this.state.uploadsHere.length - 1, + }, + ); + + const uploadSize = filesize(this.state.currentUpload.total); + return ( +
      +
      {uploadText} ({uploadSize})
      + + +
      + ); + } +} diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 4847d41fa8..34575ba582 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 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. @@ -15,27 +15,30 @@ limitations under the License. */ import React, { createRef } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import classNames from "classnames"; +import * as fbEmitter from "fbemitter"; + import { MatrixClientPeg } from "../../MatrixClientPeg"; import defaultDispatcher from "../../dispatcher/dispatcher"; +import dis from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import { _t } from "../../languageHandler"; import { ContextMenuButton } from "./ContextMenu"; -import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; +import { UserTab } from "../views/dialogs/UserSettingsDialog"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; 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 {getHostingLink} from "../../utils/HostingLink"; -import {ButtonEvent} from "../views/elements/AccessibleButton"; +import { getCustomTheme } from "../../theme"; +import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; -import {getHomePageUrl} from "../../utils/pages"; +import { getHomePageUrl } from "../../utils/pages"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import BaseAvatar from '../views/avatars/BaseAvatar'; -import classNames from "classnames"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { SettingLevel } from "../../settings/SettingLevel"; import IconizedContextMenu, { @@ -43,15 +46,19 @@ import IconizedContextMenu, { IconizedContextMenuOptionList, } from "../views/context_menus/IconizedContextMenu"; import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; -import * as fbEmitter from "fbemitter"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import { showCommunityInviteDialog } from "../../RoomInvite"; -import dis from "../../dispatcher/dispatcher"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import ErrorDialog from "../views/dialogs/ErrorDialog"; import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog"; -import {UIFeature} from "../../settings/UIFeature"; - +import { UIFeature } from "../../settings/UIFeature"; +import HostSignupAction from "./HostSignupAction"; +import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes"; +import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore"; +import RoomName from "../views/elements/RoomName"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import InlineSpinner from "../views/elements/InlineSpinner"; +import TooltipButton from "../views/elements/TooltipButton"; interface IProps { isMinimized: boolean; } @@ -61,11 +68,15 @@ type PartialDOMRect = Pick; interface IState { contextMenuPosition: PartialDOMRect; isDarkTheme: boolean; + selectedSpace?: Room; + pendingRoomJoin: Set; } +@replaceableComponent("structures.UserMenu") export default class UserMenu extends React.Component { private dispatcherRef: string; private themeWatcherRef: string; + private dndWatcherRef: string; private buttonRef: React.RefObject = createRef(); private tagStoreRef: fbEmitter.EventSubscription; @@ -75,9 +86,16 @@ export default class UserMenu extends React.Component { this.state = { contextMenuPosition: null, isDarkTheme: this.isUserOnDarkTheme(), + pendingRoomJoin: new Set(), }; OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); + if (SpaceStore.spacesEnabled) { + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); + } + + // Force update is the easiest way to trigger the UI update (we don't store state for this) + this.dndWatcherRef = SettingsStore.watchSetting("doNotDisturb", null, () => this.forceUpdate()); } private get hasHomePage(): boolean { @@ -88,25 +106,39 @@ export default class UserMenu extends React.Component { this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate); + MatrixClientPeg.get().on("Room", this.onRoom); } public componentWillUnmount() { if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); + if (this.dndWatcherRef) SettingsStore.unwatchSetting(this.dndWatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); this.tagStoreRef.remove(); + if (SpaceStore.spacesEnabled) { + SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); + } + MatrixClientPeg.get().removeListener("Room", this.onRoom); } + private onRoom = (room: Room): void => { + this.removePendingJoinRoom(room.roomId); + }; + private onTagStoreUpdate = () => { this.forceUpdate(); // we don't have anything useful in state to update }; private isUserOnDarkTheme(): boolean { - const theme = SettingsStore.getValue("theme"); - if (theme.startsWith("custom-")) { - return getCustomTheme(theme.substring("custom-".length)).is_dark; + if (SettingsStore.getValue("use_system_theme")) { + return window.matchMedia("(prefers-color-scheme: dark)").matches; + } else { + const theme = SettingsStore.getValue("theme"); + if (theme.startsWith("custom-")) { + return getCustomTheme(theme.substring("custom-".length)).is_dark; + } + return theme === "dark"; } - return theme === "dark"; } private onProfileUpdate = async () => { @@ -115,25 +147,53 @@ export default class UserMenu extends React.Component { this.forceUpdate(); }; + private onSelectedSpaceUpdate = async (selectedSpace?: Room) => { + this.setState({ selectedSpace }); + }; + private onThemeChanged = () => { - this.setState({isDarkTheme: this.isUserOnDarkTheme()}); + this.setState({ isDarkTheme: this.isUserOnDarkTheme() }); }; private onAction = (ev: ActionPayload) => { - if (ev.action !== Action.ToggleUserMenu) return; // not interested - - if (this.state.contextMenuPosition) { - this.setState({contextMenuPosition: null}); - } else { - if (this.buttonRef.current) this.buttonRef.current.click(); + switch (ev.action) { + case Action.ToggleUserMenu: + if (this.state.contextMenuPosition) { + this.setState({ contextMenuPosition: null }); + } else { + if (this.buttonRef.current) this.buttonRef.current.click(); + } + break; + case Action.JoinRoom: + this.addPendingJoinRoom(ev.roomId); + break; + case Action.JoinRoomReady: + case Action.JoinRoomError: + this.removePendingJoinRoom(ev.roomId); + break; } }; + private addPendingJoinRoom(roomId: string): void { + this.setState({ + pendingRoomJoin: new Set(this.state.pendingRoomJoin) + .add(roomId), + }); + } + + private removePendingJoinRoom(roomId: string): void { + if (this.state.pendingRoomJoin.delete(roomId)) { + this.setState({ + pendingRoomJoin: new Set(this.state.pendingRoomJoin), + }); + } + } + private onOpenMenuClick = (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; - this.setState({contextMenuPosition: target.getBoundingClientRect()}); + this.setState({ contextMenuPosition: target.getBoundingClientRect() }); }; private onContextMenu = (ev: React.MouseEvent) => { @@ -150,7 +210,7 @@ export default class UserMenu extends React.Component { }; private onCloseMenu = () => { - this.setState({contextMenuPosition: null}); + this.setState({ contextMenuPosition: null }); }; private onSwitchThemeClick = (ev: React.MouseEvent) => { @@ -168,9 +228,9 @@ export default class UserMenu extends React.Component { ev.preventDefault(); ev.stopPropagation(); - const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId}; + const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId }; defaultDispatcher.dispatch(payload); - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; private onShowArchived = (ev: ButtonEvent) => { @@ -187,22 +247,40 @@ export default class UserMenu extends React.Component { ev.stopPropagation(); Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog); - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; - private onSignOutClick = (ev: ButtonEvent) => { + private onSignOutClick = async (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); - Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); - this.setState({contextMenuPosition: null}); // also close the menu + const cli = MatrixClientPeg.get(); + if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) { + // log out without user prompt if they have no local megolm sessions + dis.dispatch({ action: 'logout' }); + } else { + Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); + } + + this.setState({ contextMenuPosition: null }); // also close the menu + }; + + private onSignInClick = () => { + dis.dispatch({ action: 'start_login' }); + this.setState({ contextMenuPosition: null }); // also close the menu + }; + + private onRegisterClick = () => { + dis.dispatch({ action: 'start_registration' }); + this.setState({ contextMenuPosition: null }); // also close the menu }; private onHomeClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); - defaultDispatcher.dispatch({action: 'view_home_page'}); + defaultDispatcher.dispatch({ action: 'view_home_page' }); + this.setState({ contextMenuPosition: null }); // also close the menu }; private onCommunitySettingsClick = (ev: ButtonEvent) => { @@ -212,7 +290,7 @@ export default class UserMenu extends React.Component { Modal.createTrackedDialog('Edit Community', '', EditCommunityPrototypeDialog, { communityId: CommunityPrototypeStore.instance.getSelectedCommunityId(), }); - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; private onCommunityMembersClick = (ev: ButtonEvent) => { @@ -229,7 +307,7 @@ export default class UserMenu extends React.Component { action: 'view_room', room_id: chat.roomId, }, true); - dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList}); + dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList }); } else { // "This should never happen" clauses go here for the prototype. Modal.createTrackedDialog('Failed to find general chat', '', ErrorDialog, { @@ -237,7 +315,7 @@ export default class UserMenu extends React.Component { description: _t("Failed to find the general chat for this community"), }); } - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; private onCommunityInviteClick = (ev: ButtonEvent) => { @@ -245,7 +323,13 @@ export default class UserMenu extends React.Component { ev.stopPropagation(); showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId()); - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu + }; + + private onDndToggle = (ev) => { + ev.stopPropagation(); + const current = SettingsStore.getValue("doNotDisturb"); + SettingsStore.setValue("doNotDisturb", null, SettingLevel.DEVICE, !current); }; private renderContextMenu = (): React.ReactNode => { @@ -253,26 +337,38 @@ export default class UserMenu extends React.Component { const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); - let hostingLink; - const signupLink = getHostingLink("user-context-menu"); - if (signupLink) { - hostingLink = ( -
      - {_t( - "Upgrade to your own domain", {}, - { - a: sub => ( - {sub} - ), - }, - )} + let topSection; + const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup; + if (MatrixClientPeg.get().isGuest()) { + topSection = ( +
      + {_t("Got an account? Sign in", {}, { + a: sub => ( + + {sub} + + ), + })} + {_t("New here? Create an account", {}, { + a: sub => ( + + {sub} + + ), + })}
      ); + } else if (hostSignupConfig) { + if (hostSignupConfig && hostSignupConfig.url) { + // If hostSignup.domains is set to a non-empty array, only show + // dialog if the user is on the domain or a subdomain. + const hostSignupDomains = hostSignupConfig.domains || []; + const mxDomain = MatrixClientPeg.get().getDomain(); + const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`))); + if (!hostSignupConfig.domains || validDomains.length > 0) { + topSection = ; + } + } } let homeButton = null; @@ -312,12 +408,12 @@ export default class UserMenu extends React.Component { this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)} + onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)} /> this.onSettingsOpen(e, USER_SECURITY_TAB)} + onClick={(e) => this.onSettingsOpen(e, UserTab.Security)} /> { /> - ) + ); + } else if (MatrixClientPeg.get().isGuest()) { + primaryOptionList = ( + + + { homeButton } + this.onSettingsOpen(e, null)} + /> + { feedbackButton } + + + ); } const classes = classNames({ @@ -443,7 +553,7 @@ export default class UserMenu extends React.Component { />
      - {hostingLink} + {topSection} {primaryOptionList} {secondarySection} ; @@ -466,7 +576,17 @@ export default class UserMenu extends React.Component { {/* masked image in CSS */} ); - if (prototypeCommunityName) { + let dnd; + if (this.state.selectedSpace) { + name = ( +
      + {displayName} + + {(roomName) => {roomName}} + +
      + ); + } else if (prototypeCommunityName) { name = (
      {prototypeCommunityName} @@ -483,6 +603,16 @@ export default class UserMenu extends React.Component {
      ); isPrototype = true; + } else if (SettingsStore.getValue("feature_dnd")) { + const isDnd = SettingsStore.getValue("doNotDisturb"); + dnd = ; } if (this.props.isMinimized) { name = null; @@ -518,6 +648,15 @@ export default class UserMenu extends React.Component { /> {name} + {this.state.pendingRoomJoin.size > 0 && ( + + + + )} + {dnd} {buttons}
      diff --git a/src/components/structures/UserView.js b/src/components/structures/UserView.js index 8e21771bb9..eb839be7be 100644 --- a/src/components/structures/UserView.js +++ b/src/components/structures/UserView.js @@ -17,13 +17,16 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; -import Matrix from "matrix-js-sdk"; -import {MatrixClientPeg} from "../../MatrixClientPeg"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; import * as sdk from "../../index"; import Modal from '../../Modal'; import { _t } from '../../languageHandler'; import HomePage from "./HomePage"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +@replaceableComponent("structures.UserView") export default class UserView extends React.Component { static get propTypes() { return { @@ -53,7 +56,7 @@ export default class UserView extends React.Component { async _loadProfileInfo() { const cli = MatrixClientPeg.get(); - this.setState({loading: true}); + this.setState({ loading: true }); let profileInfo; try { profileInfo = await cli.getProfileInfo(this.props.userId); @@ -63,13 +66,13 @@ export default class UserView extends React.Component { title: _t('Could not load user profile'), description: ((err && err.message) ? err.message : _t("Operation failed")), }); - this.setState({loading: false}); + this.setState({ loading: false }); return; } - const fakeEvent = new Matrix.MatrixEvent({type: "m.room.member", content: profileInfo}); - const member = new Matrix.RoomMember(null, this.props.userId); + const fakeEvent = new MatrixEvent({ type: "m.room.member", content: profileInfo }); + const member = new RoomMember(null, this.props.userId); member.setMembershipEvent(fakeEvent); - this.setState({member, loading: false}); + this.setState({ member, loading: false }); } render() { diff --git a/src/components/structures/ViewSource.js b/src/components/structures/ViewSource.js index 0b969784e5..b69a92dd61 100644 --- a/src/components/structures/ViewSource.js +++ b/src/components/structures/ViewSource.js @@ -16,34 +16,176 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import SyntaxHighlight from '../views/elements/SyntaxHighlight'; -import {_t} from "../../languageHandler"; +import React from "react"; +import PropTypes from "prop-types"; +import SyntaxHighlight from "../views/elements/SyntaxHighlight"; +import { _t } from "../../languageHandler"; import * as sdk from "../../index"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog"; +import { canEditContent } from "../../utils/EventUtils"; +import { MatrixClientPeg } from '../../MatrixClientPeg'; +import { replaceableComponent } from "../../utils/replaceableComponent"; - +@replaceableComponent("structures.ViewSource") export default class ViewSource extends React.Component { static propTypes = { - content: PropTypes.object.isRequired, onFinished: PropTypes.func.isRequired, - roomId: PropTypes.string.isRequired, - eventId: PropTypes.string.isRequired, + mxEvent: PropTypes.object.isRequired, // the MatrixEvent associated with the context menu }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - return ( - -
      Room ID: { this.props.roomId }
      -
      Event ID: { this.props.eventId }
      -
      + constructor(props) { + super(props); -
      - - { JSON.stringify(this.props.content, null, 2) } - + this.state = { + isEditing: false, + }; + } + + onBack() { + // TODO: refresh the "Event ID:" modal header + this.setState({ isEditing: false }); + } + + onEdit() { + this.setState({ isEditing: true }); + } + + // returns the dialog body for viewing the event source + viewSourceContent() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + const isEncrypted = mxEvent.isEncrypted(); + const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private + const originalEventSource = mxEvent.event; + + if (isEncrypted) { + return ( + <> +
      + + {_t("Decrypted event source")} + + {JSON.stringify(decryptedEventSource, null, 2)} +
      +
      + + {_t("Original event source")} + + {JSON.stringify(originalEventSource, null, 2)} +
      + + ); + } else { + return ( + <> +
      {_t("Original event source")}
      + {JSON.stringify(originalEventSource, null, 2)} + + ); + } + } + + // returns the id of the initial message, not the id of the previous edit + getBaseEventId() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + const isEncrypted = mxEvent.isEncrypted(); + const baseMxEvent = this.props.mxEvent; + + if (isEncrypted) { + // `relates_to` field is inside the encrypted event + return mxEvent.event.content["m.relates_to"]?.event_id ?? baseMxEvent.getId(); + } else { + return mxEvent.getContent()["m.relates_to"]?.event_id ?? baseMxEvent.getId(); + } + } + + // returns the SendCustomEvent component prefilled with the correct details + editSourceContent() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + + const isStateEvent = mxEvent.isState(); + const roomId = mxEvent.getRoomId(); + const originalContent = mxEvent.getContent(); + + if (isStateEvent) { + return ( + + {(cli) => ( + this.onBack()} + inputs={{ + eventType: mxEvent.getType(), + evContent: JSON.stringify(originalContent, null, "\t"), + stateKey: mxEvent.getStateKey(), + }} + /> + )} + + ); + } else { + // prefill an edit-message event + // keep only the `body` and `msgtype` fields of originalContent + const bodyToStartFrom = originalContent["m.new_content"]?.body ?? originalContent.body; // prefill the last edit body, to start editing from there + const newContent = { + "body": ` * ${bodyToStartFrom}`, + "msgtype": originalContent.msgtype, + "m.new_content": { + body: bodyToStartFrom, + msgtype: originalContent.msgtype, + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: this.getBaseEventId(), + }, + }; + return ( + + {(cli) => ( + this.onBack()} + inputs={{ + eventType: mxEvent.getType(), + evContent: JSON.stringify(newContent, null, "\t"), + }} + /> + )} + + ); + } + } + + canSendStateEvent(mxEvent) { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(mxEvent.getRoomId()); + return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli); + } + + render() { + const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + + const isEditing = this.state.isEditing; + const roomId = mxEvent.getRoomId(); + const eventId = mxEvent.getId(); + const canEdit = mxEvent.isState() ? this.canSendStateEvent(mxEvent) : canEditContent(this.props.mxEvent); + return ( + +
      +
      Room ID: {roomId}
      +
      Event ID: {eventId}
      +
      + {isEditing ? this.editSourceContent() : this.viewSourceContent()}
      + {!isEditing && canEdit && ( +
      + +
      + )} ); } diff --git a/src/components/structures/auth/CompleteSecurity.js b/src/components/structures/auth/CompleteSecurity.tsx similarity index 69% rename from src/components/structures/auth/CompleteSecurity.js rename to src/components/structures/auth/CompleteSecurity.tsx index c73691611d..2f37e60450 100644 --- a/src/components/structures/auth/CompleteSecurity.js +++ b/src/components/structures/auth/CompleteSecurity.tsx @@ -15,59 +15,60 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; -import { - SetupEncryptionStore, - PHASE_INTRO, - PHASE_BUSY, - PHASE_DONE, - PHASE_CONFIRM_SKIP, -} from '../../../stores/SetupEncryptionStore'; +import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore'; import SetupEncryptionBody from "./SetupEncryptionBody"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; -export default class CompleteSecurity extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; +interface IProps { + onFinished: () => void; +} - constructor() { - super(); +interface IState { + phase: Phase; +} + +@replaceableComponent("structures.auth.CompleteSecurity") +export default class CompleteSecurity extends React.Component { + constructor(props: IProps) { + super(props); const store = SetupEncryptionStore.sharedInstance(); - store.on("update", this._onStoreUpdate); + store.on("update", this.onStoreUpdate); store.start(); - this.state = {phase: store.phase}; + this.state = { phase: store.phase }; } - _onStoreUpdate = () => { + private onStoreUpdate = (): void => { const store = SetupEncryptionStore.sharedInstance(); - this.setState({phase: store.phase}); + this.setState({ phase: store.phase }); }; - componentWillUnmount() { + public componentWillUnmount(): void { const store = SetupEncryptionStore.sharedInstance(); - store.off("update", this._onStoreUpdate); + store.off("update", this.onStoreUpdate); store.stop(); } - render() { + public render() { const AuthPage = sdk.getComponent("auth.AuthPage"); const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody"); - const {phase} = this.state; + const { phase } = this.state; let icon; let title; - if (phase === PHASE_INTRO) { + if (phase === Phase.Loading) { + return null; + } else if (phase === Phase.Intro) { icon = ; title = _t("Verify this login"); - } else if (phase === PHASE_DONE) { + } else if (phase === Phase.Done) { icon = ; title = _t("Session verified"); - } else if (phase === PHASE_CONFIRM_SKIP) { + } else if (phase === Phase.ConfirmSkip) { icon = ; title = _t("Are you sure?"); - } else if (phase === PHASE_BUSY) { + } else if (phase === Phase.Busy) { icon = ; title = _t("Verify this login"); } else { diff --git a/src/components/structures/auth/E2eSetup.js b/src/components/structures/auth/E2eSetup.tsx similarity index 77% rename from src/components/structures/auth/E2eSetup.js rename to src/components/structures/auth/E2eSetup.tsx index 6df8158002..93cb92664f 100644 --- a/src/components/structures/auth/E2eSetup.js +++ b/src/components/structures/auth/E2eSetup.tsx @@ -15,17 +15,19 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import AuthPage from '../../views/auth/AuthPage'; import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody'; import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; -export default class E2eSetup extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - accountPassword: PropTypes.string, - }; +interface IProps { + onFinished: () => void; + accountPassword?: string; + tokenLogin?: boolean; +} +@replaceableComponent("structures.auth.E2eSetup") +export default class E2eSetup extends React.Component { render() { return ( @@ -33,6 +35,7 @@ export default class E2eSetup extends React.Component { diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.tsx similarity index 66% rename from src/components/structures/auth/ForgotPassword.js rename to src/components/structures/auth/ForgotPassword.tsx index 54d4b5de83..6382e143f9 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -17,39 +17,63 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; +import { _t, _td } from '../../../languageHandler'; import * as sdk from '../../../index'; import Modal from "../../../Modal"; -import SdkConfig from "../../../SdkConfig"; import PasswordReset from "../../../PasswordReset"; -import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import classNames from 'classnames'; import AuthPage from "../../views/auth/AuthPage"; import CountlyAnalytics from "../../../CountlyAnalytics"; +import ServerPicker from "../../views/elements/ServerPicker"; +import PassphraseField from '../../views/auth/PassphraseField'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm'; -// Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; -// Show the forgot password inputs -const PHASE_FORGOT = 1; -// Email is in the process of being sent -const PHASE_SENDING_EMAIL = 2; -// Email has been sent -const PHASE_EMAIL_SENT = 3; -// User has clicked the link in email and completed reset -const PHASE_DONE = 4; +import { IValidationResult } from "../../views/elements/Validation"; -export default class ForgotPassword extends React.Component { - static propTypes = { - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - onServerConfigChange: PropTypes.func.isRequired, - onLoginClick: PropTypes.func, - onComplete: PropTypes.func.isRequired, - }; +enum Phase { + // Show the forgot password inputs + Forgot = 1, + // Email is in the process of being sent + SendingEmail = 2, + // Email has been sent + EmailSent = 3, + // User has clicked the link in email and completed reset + Done = 4, +} + +interface IProps { + serverConfig: ValidatedServerConfig; + onServerConfigChange: (serverConfig: ValidatedServerConfig) => void; + onLoginClick?: () => void; + onComplete: () => void; +} + +interface IState { + phase: Phase; + email: string; + password: string; + password2: string; + errorText: string; + + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError: string; + + passwordFieldValid: boolean; +} + +@replaceableComponent("structures.auth.ForgotPassword") +export default class ForgotPassword extends React.Component { + private reset: PasswordReset; state = { - phase: PHASE_FORGOT, + phase: Phase.Forgot, email: "", password: "", password2: "", @@ -62,67 +86,63 @@ export default class ForgotPassword extends React.Component { serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", - serverRequiresIdServer: null, + passwordFieldValid: false, }; - constructor(props) { + constructor(props: IProps) { super(props); CountlyAnalytics.instance.track("onboarding_forgot_password_begin"); } - componentDidMount() { + public componentDidMount() { this.reset = null; - this._checkServerLiveliness(this.props.serverConfig); + this.checkServerLiveliness(this.props.serverConfig); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps(newProps) { + public UNSAFE_componentWillReceiveProps(newProps: IProps): void { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; // Do a liveliness check on the new URLs - this._checkServerLiveliness(newProps.serverConfig); + this.checkServerLiveliness(newProps.serverConfig); } - async _checkServerLiveliness(serverConfig) { + private async checkServerLiveliness(serverConfig): Promise { try { await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( serverConfig.hsUrl, serverConfig.isUrl, ); - const pwReset = new PasswordReset(serverConfig.hsUrl, serverConfig.isUrl); - const serverRequiresIdServer = await pwReset.doesServerRequireIdServerParam(); - this.setState({ serverIsAlive: true, - serverRequiresIdServer, }); } catch (e) { - this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password")); + this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password") as IState); } } - submitPasswordReset(email, password) { + public submitPasswordReset(email: string, password: string): void { this.setState({ - phase: PHASE_SENDING_EMAIL, + phase: Phase.SendingEmail, }); this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl); this.reset.resetPassword(email, password).then(() => { this.setState({ - phase: PHASE_EMAIL_SENT, + phase: Phase.EmailSent, }); }, (err) => { this.showErrorDialog(_t('Failed to send email') + ": " + err.message); this.setState({ - phase: PHASE_FORGOT, + phase: Phase.Forgot, }); }); } - onVerify = async ev => { + private onVerify = async (ev: React.MouseEvent): Promise => { ev.preventDefault(); if (!this.reset) { console.error("onVerify called before submitPasswordReset!"); @@ -130,22 +150,26 @@ export default class ForgotPassword extends React.Component { } try { await this.reset.checkEmailLinkClicked(); - this.setState({ phase: PHASE_DONE }); + this.setState({ phase: Phase.Done }); } catch (err) { this.showErrorDialog(err.message); } }; - onSubmitForm = async ev => { + private onSubmitForm = async (ev: React.FormEvent): Promise => { ev.preventDefault(); // refresh the server errors, just in case the server came back online - await this._checkServerLiveliness(this.props.serverConfig); + await this.checkServerLiveliness(this.props.serverConfig); + + 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.password || !this.state.password2) { this.showErrorDialog(_t('A new password must be entered.')); + } else if (!this.state.passwordFieldValid) { + this.showErrorDialog(_t('Please choose a strong password')); } else if (this.state.password !== this.state.password2) { this.showErrorDialog(_t('New passwords must match each other.')); } else { @@ -171,56 +195,30 @@ export default class ForgotPassword extends React.Component { } }; - onInputChanged = (stateKey, ev) => { + private onInputChanged = (stateKey: string, ev: React.FormEvent) => { this.setState({ - [stateKey]: ev.target.value, - }); + [stateKey]: ev.currentTarget.value, + } as any); }; - onServerDetailsNextPhaseClick = async () => { - this.setState({ - phase: PHASE_FORGOT, - }); - }; - - onEditServerDetailsClick = ev => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - }; - - onLoginClick = ev => { + private onLoginClick = (ev: React.MouseEvent): void => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); }; - showErrorDialog(body, title) { + public showErrorDialog(description: string, title?: string) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, { - title: title, - description: body, + title, + description, }); } - renderServerDetails() { - const ServerConfig = sdk.getComponent("auth.ServerConfig"); - - if (SdkConfig.get()['disable_custom_urls']) { - return null; - } - - return ; + private onPasswordValidate(result: IValidationResult) { + this.setState({ + passwordFieldValid: result.valid, + }); } renderForgot() { @@ -246,57 +244,13 @@ export default class ForgotPassword extends React.Component { ); } - let yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', { - serverName: this.props.serverConfig.hsName, - }); - if (this.props.serverConfig.hsNameIsDifferent) { - const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); - - yourMatrixAccountText = _t('Your Matrix account on ', {}, { - 'underlinedServerName': () => { - return ; - }, - }); - } - - // If custom URLs are allowed, wire up the server details edit link. - let editLink = null; - if (!SdkConfig.get()['disable_custom_urls']) { - editLink = - {_t('Change')} - ; - } - - if (!this.props.serverConfig.isUrl && this.state.serverRequiresIdServer) { - return
      -

      - {yourMatrixAccountText} - {editLink} -

      - {_t( - "No identity server is configured: " + - "add one in server settings to reset your password.", - )} - - {_t('Sign in instead')} - -
      ; - } - return
      {errorText} {serverDeadSection} -

      - {yourMatrixAccountText} - {editLink} -

      +
      - this['password_field'] = field} + onValidate={(result) => this.onPasswordValidate(result)} onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")} onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")} + autoComplete="new-password" /> CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")} onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")} + autoComplete="new-password" />
      {_t( @@ -380,19 +339,16 @@ export default class ForgotPassword extends React.Component { let resetPasswordJsx; switch (this.state.phase) { - case PHASE_SERVER_DETAILS: - resetPasswordJsx = this.renderServerDetails(); - break; - case PHASE_FORGOT: + case Phase.Forgot: resetPasswordJsx = this.renderForgot(); break; - case PHASE_SENDING_EMAIL: + case Phase.SendingEmail: resetPasswordJsx = this.renderSendingEmail(); break; - case PHASE_EMAIL_SENT: + case Phase.EmailSent: resetPasswordJsx = this.renderEmailSent(); break; - case PHASE_DONE: + case Phase.Done: resetPasswordJsx = this.renderDone(); break; } diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.tsx similarity index 53% rename from src/components/structures/auth/Login.js rename to src/components/structures/auth/Login.tsx index c3cbac0442..9f12521a34 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.tsx @@ -1,7 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd +Copyright 2015-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. @@ -16,33 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import {_t, _td} from '../../../languageHandler'; -import * as sdk from '../../../index'; -import Login from '../../../Login'; +import React, { ReactNode } from 'react'; +import { MatrixError } from "matrix-js-sdk/src/http-api"; + +import { _t, _td } from '../../../languageHandler'; +import Login, { ISSOFlow, LoginFlow } from '../../../Login'; import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; -import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import classNames from "classnames"; import AuthPage from "../../views/auth/AuthPage"; -import SSOButton from "../../views/elements/SSOButton"; import PlatformPeg from '../../../PlatformPeg'; import SettingsStore from "../../../settings/SettingsStore"; -import {UIFeature} from "../../../settings/UIFeature"; +import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; - -// For validating phone numbers without country codes -const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; - -// Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; -// Show the appropriate login flow(s) for the server -const PHASE_LOGIN = 1; - -// Enable phases for login -const PHASES_ENABLED = true; +import { IMatrixClientCreds } from "../../../MatrixClientPeg"; +import PasswordLogin from "../../views/auth/PasswordLogin"; +import InlineSpinner from "../../views/elements/InlineSpinner"; +import Spinner from "../../views/elements/Spinner"; +import SSOButtons from "../../views/elements/SSOButtons"; +import ServerPicker from "../../views/elements/ServerPicker"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AuthBody from "../../views/auth/AuthBody"; +import AuthHeader from "../../views/auth/AuthHeader"; // These are used in several places, and come from the js-sdk's autodiscovery // stuff. We define them here so that they'll be picked up by i18n. @@ -55,64 +49,82 @@ _td("Invalid base_url for m.identity_server"); _td("Identity server URL does not appear to be a valid identity server"); _td("General failure"); +interface IProps { + serverConfig: ValidatedServerConfig; + // If true, the component will consider itself busy. + busy?: boolean; + isSyncing?: boolean; + // Secondary HS which we try to log into if the user is using + // the default HS but login fails. Useful for migrating to a + // different homeserver without confusing users. + fallbackHsUrl?: string; + defaultDeviceDisplayName?: string; + fragmentAfterLogin?: string; + defaultUsername?: string; + + // Called when the user has logged in. Params: + // - The object returned by the login API + // - The user's password, if applicable, (may be cached in memory for a + // short time so the user is not required to re-enter their password + // for operations like uploading cross-signing keys). + onLoggedIn(data: IMatrixClientCreds, password: string): void; + + // login shouldn't know or care how registration, password recovery, etc is done. + onRegisterClick(): void; + onForgotPasswordClick?(): void; + onServerConfigChange(config: ValidatedServerConfig): void; +} + +interface IState { + busy: boolean; + busyLoggingIn?: boolean; + errorText?: ReactNode; + loginIncorrect: boolean; + // can we attempt to log in or are there validation errors? + canTryLogin: boolean; + + flows?: LoginFlow[]; + + // used for preserving form values when changing homeserver + username: string; + phoneCountry?: string; + phoneNumber: string; + + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError?: ReactNode; +} + /* * A wire component which glues together login UI components and Login logic */ -export default class LoginComponent extends React.Component { - static propTypes = { - // Called when the user has logged in. Params: - // - The object returned by the login API - // - The user's password, if applicable, (may be cached in memory for a - // short time so the user is not required to re-enter their password - // for operations like uploading cross-signing keys). - onLoggedIn: PropTypes.func.isRequired, +@replaceableComponent("structures.auth.LoginComponent") +export default class LoginComponent extends React.PureComponent { + private unmounted = false; + private loginLogic: Login; - // If true, the component will consider itself busy. - busy: PropTypes.bool, - - // Secondary HS which we try to log into if the user is using - // the default HS but login fails. Useful for migrating to a - // different homeserver without confusing users. - fallbackHsUrl: PropTypes.string, - - defaultDeviceDisplayName: PropTypes.string, - - // login shouldn't know or care how registration, password recovery, - // etc is done. - onRegisterClick: PropTypes.func.isRequired, - onForgotPasswordClick: PropTypes.func, - onServerConfigChange: PropTypes.func.isRequired, - - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - isSyncing: PropTypes.bool, - }; + private readonly stepRendererMap: Record ReactNode>; constructor(props) { super(props); - this._unmounted = false; - this.state = { busy: false, busyLoggingIn: null, errorText: null, loginIncorrect: false, - canTryLogin: true, // can we attempt to log in or are there validation errors? + canTryLogin: true, - // used for preserving form values when changing homeserver - username: "", + flows: null, + + username: props.defaultUsername? props.defaultUsername: '', phoneCountry: null, phoneNumber: "", - // Phase of the overall login dialog. - phase: PHASE_LOGIN, - // The current login flow, such as password, SSO, etc. - currentFlow: null, // we need to load the flows from the server - - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", @@ -120,12 +132,12 @@ export default class LoginComponent extends React.Component { // map from login step type to a function which will render a control // letting you do that login type - this._stepRendererMap = { - 'm.login.password': this._renderPasswordStep, + this.stepRendererMap = { + 'm.login.password': this.renderPasswordStep, // CAS and SSO are the same thing, modulo the url we link to - 'm.login.cas': () => this._renderSsoStep("cas"), - 'm.login.sso': () => this._renderSsoStep("sso"), + 'm.login.cas': () => this.renderSsoStep("cas"), + 'm.login.sso': () => this.renderSsoStep("sso"), }; CountlyAnalytics.instance.track("onboarding_login_begin"); @@ -134,11 +146,11 @@ export default class LoginComponent extends React.Component { // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line camelcase UNSAFE_componentWillMount() { - this._initLoginLogic(); + this.initLoginLogic(this.props.serverConfig); } componentWillUnmount() { - this._unmounted = true; + this.unmounted = true; } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -148,21 +160,14 @@ export default class LoginComponent extends React.Component { newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; // Ensure that we end up actually logging in to the right place - this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); + this.initLoginLogic(newProps.serverConfig); } - onPasswordLoginError = errorText => { - this.setState({ - errorText, - loginIncorrect: Boolean(errorText), - }); - }; - isBusy = () => this.state.busy || this.props.busy; onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => { if (!this.state.serverIsAlive) { - this.setState({busy: true}); + this.setState({ busy: true }); // Do a quick liveliness check on the URLs let aliveAgain = true; try { @@ -170,7 +175,7 @@ export default class LoginComponent extends React.Component { this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl, ); - this.setState({serverIsAlive: true, errorText: ""}); + this.setState({ serverIsAlive: true, errorText: "" }); } catch (e) { const componentState = AutoDiscoveryUtils.authComponentStateForError(e); this.setState({ @@ -194,13 +199,13 @@ export default class LoginComponent extends React.Component { loginIncorrect: false, }); - this._loginLogic.loginViaPassword( + this.loginLogic.loginViaPassword( username, phoneCountry, phoneNumber, password, ).then((data) => { - this.setState({serverIsAlive: true}); // it must be, we logged in. + this.setState({ serverIsAlive: true }); // it must be, we logged in. this.props.onLoggedIn(data, password); }, (error) => { - if (this._unmounted) { + if (this.unmounted) { return; } let errorText; @@ -212,21 +217,26 @@ export default class LoginComponent extends React.Component { } else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( error.data.limit_type, - error.data.admin_contact, { - 'monthly_active_user': _td( - "This homeserver has hit its Monthly Active User limit.", - ), - '': _td( - "This homeserver has exceeded one of its resource limits.", - ), - }); + error.data.admin_contact, + { + 'monthly_active_user': _td( + "This homeserver has hit its Monthly Active User limit.", + ), + 'hs_blocked': _td( + "This homeserver has been blocked by it's administrator.", + ), + '': _td( + "This homeserver has exceeded one of its resource limits.", + ), + }, + ); const errorDetail = messageForResourceLimitError( error.data.limit_type, - error.data.admin_contact, { - '': _td( - "Please contact your service administrator to continue using this service.", - ), - }); + error.data.admin_contact, + { + '': _td("Please contact your service administrator to continue using this service."), + }, + ); errorText = (
      {errorTop}
      @@ -243,7 +253,7 @@ export default class LoginComponent extends React.Component {
      {_t( 'Please note you are logging into the %(hs)s server, not matrix.org.', - {hs: this.props.serverConfig.hsName}, + { hs: this.props.serverConfig.hsName }, )}
      @@ -253,7 +263,7 @@ export default class LoginComponent extends React.Component { } } else { // other errors, not specific to doing a password login - errorText = this._errorTextFromError(error); + errorText = this.errorTextFromError(error); } this.setState({ @@ -291,7 +301,7 @@ export default class LoginComponent extends React.Component { // the busy state. In the case of a full MXID that resolves to the same // HS as Element's default HS though, there may not be any server change. // To avoid this trap, we clear busy here. For cases where the server - // actually has changed, `_initLoginLogic` will be called and manages + // actually has changed, `initLoginLogic` will be called and manages // busy state for its own liveness check. this.setState({ busy: false, @@ -304,7 +314,7 @@ export default class LoginComponent extends React.Component { message = e.translatedMessage; } - let errorText = message; + let errorText: ReactNode = message; let discoveryState = {}; if (AutoDiscoveryUtils.isLivelinessError(e)) { errorText = this.state.errorText; @@ -330,21 +340,6 @@ export default class LoginComponent extends React.Component { }); }; - onPhoneNumberBlur = phoneNumber => { - // Validate the phone number entered - if (!PHONE_NUMBER_REGEX.test(phoneNumber)) { - this.setState({ - errorText: _t('The phone number entered looks invalid'), - canTryLogin: false, - }); - } else { - this.setState({ - errorText: null, - canTryLogin: true, - }); - } - }; - onRegisterClick = ev => { ev.preventDefault(); ev.stopPropagation(); @@ -352,14 +347,16 @@ export default class LoginComponent extends React.Component { }; onTryRegisterClick = ev => { - const step = this._getCurrentFlowStep(); - if (step === 'm.login.sso' || step === 'm.login.cas') { - // If we're showing SSO it means that registration is also probably disabled, - // so intercept the click and instead pretend the user clicked 'Sign in with SSO'. + const hasPasswordFlow = this.state.flows?.find(flow => flow.type === "m.login.password"); + const ssoFlow = this.state.flows?.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas"); + // If has no password flow but an SSO flow guess that the user wants to register with SSO. + // TODO: instead hide the Register button if registration is disabled by checking with the server, + // has no specific errCode currently and uses M_FORBIDDEN. + if (ssoFlow && !hasPasswordFlow) { ev.preventDefault(); ev.stopPropagation(); - const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas'; - PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind, + const ssoKind = ssoFlow.type === 'm.login.sso' ? 'sso' : 'cas'; + PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind, this.props.fragmentAfterLogin); } else { // Don't intercept - just go through to the register page @@ -367,24 +364,7 @@ export default class LoginComponent extends React.Component { } }; - onServerDetailsNextPhaseClick = () => { - this.setState({ - phase: PHASE_LOGIN, - }); - }; - - onEditServerDetailsClick = ev => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - }; - - async _initLoginLogic(hsUrl, isUrl) { - hsUrl = hsUrl || this.props.serverConfig.hsUrl; - isUrl = isUrl || this.props.serverConfig.isUrl; - + private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig) { let isDefaultServer = false; if (this.props.serverConfig.isDefault && hsUrl === this.props.serverConfig.hsUrl @@ -397,11 +377,10 @@ export default class LoginComponent extends React.Component { const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, }); - this._loginLogic = loginLogic; + this.loginLogic = loginLogic; this.setState({ busy: true, - currentFlow: null, // reset flow loginIncorrect: false, }); @@ -425,42 +404,26 @@ export default class LoginComponent extends React.Component { busy: false, ...AutoDiscoveryUtils.authComponentStateForError(e), }); - if (this.state.serverErrorIsFatal) { - // Server is dead: show server details prompt instead - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - return; - } } loginLogic.getFlows().then((flows) => { // look for a flow where we understand all of the steps. - for (let i = 0; i < flows.length; i++ ) { - if (!this._isSupportedFlow(flows[i])) { - continue; - } + const supportedFlows = flows.filter(this.isSupportedFlow); - // we just pick the first flow where we support all the - // steps. (we don't have a UI for multiple logins so let's skip - // that for now). - loginLogic.chooseFlow(i); + if (supportedFlows.length > 0) { this.setState({ - currentFlow: this._getCurrentFlowStep(), + flows: supportedFlows, }); return; } - // we got to the end of the list without finding a suitable - // flow. + + // we got to the end of the list without finding a suitable flow. this.setState({ - errorText: _t( - "This homeserver doesn't offer any login flows which are " + - "supported by this client.", - ), + errorText: _t("This homeserver doesn't offer any login flows which are supported by this client."), }); }, (err) => { this.setState({ - errorText: this._errorTextFromError(err), + errorText: this.errorTextFromError(err), loginIncorrect: false, canTryLogin: false, }); @@ -471,28 +434,24 @@ export default class LoginComponent extends React.Component { }); } - _isSupportedFlow(flow) { + private isSupportedFlow = (flow: LoginFlow): boolean => { // technically the flow can have multiple steps, but no one does this // for login and loginLogic doesn't support it so we can ignore it. - if (!this._stepRendererMap[flow.type]) { + if (!this.stepRendererMap[flow.type]) { console.log("Skipping flow", flow, "due to unsupported login type", flow.type); return false; } return true; - } + }; - _getCurrentFlowStep() { - return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null; - } - - _errorTextFromError(err) { + private errorTextFromError(err: MatrixError): ReactNode { let errCode = err.errcode; if (!errCode && err.httpStatus) { errCode = "HTTP " + err.httpStatus; } - let errorText = _t("Error: Problem communicating with the given homeserver.") + - (errCode ? " (" + errCode + ")" : ""); + let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " + + "please try again later.") + (errCode ? " (" + errCode + ")" : ""); if (err.cors === 'rejected') { if (window.location.protocol === 'https:' && @@ -502,29 +461,27 @@ export default class LoginComponent extends React.Component { errorText = { _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + "Either use HTTPS or enable unsafe scripts.", {}, - { - 'a': (sub) => { - return - { sub } - ; - }, + { + 'a': (sub) => { + return + { sub } + ; }, - ) } + }) } ; } else { errorText = { _t("Can't connect to homeserver - please check your connectivity, ensure your " + "homeserver's SSL certificate is trusted, and that a browser extension " + "is not blocking requests.", {}, - { - 'a': (sub) => - - { sub } - , - }, - ) } + { + 'a': (sub) => + + { sub } + , + }) } ; } } @@ -532,121 +489,61 @@ export default class LoginComponent extends React.Component { return errorText; } - renderServerComponent() { - const ServerConfig = sdk.getComponent("auth.ServerConfig"); + renderLoginComponentForFlows() { + if (!this.state.flows) return null; - if (SdkConfig.get()['disable_custom_urls']) { - return null; - } + // this is the ideal order we want to show the flows in + const order = [ + "m.login.password", + "m.login.sso", + ]; - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) { - return null; - } - - const serverDetailsProps = {}; - if (PHASES_ENABLED) { - serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; - serverDetailsProps.submitText = _t("Next"); - serverDetailsProps.submitClass = "mx_Login_submit"; - } - - return ; + const flows = order.map(type => this.state.flows.find(flow => flow.type === type)).filter(Boolean); + return + { flows.map(flow => { + const stepRenderer = this.stepRendererMap[flow.type]; + return { stepRenderer() }; + }) } + ; } - renderLoginComponentForStep() { - if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) { - return null; - } - - const step = this.state.currentFlow; - - if (!step) { - return null; - } - - const stepRenderer = this._stepRendererMap[step]; - - if (stepRenderer) { - return stepRenderer(); - } - - return null; - } - - _renderPasswordStep = () => { - const PasswordLogin = sdk.getComponent('auth.PasswordLogin'); - - let onEditServerDetailsClick = null; - // If custom URLs are allowed, wire up the server details edit link. - if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { - onEditServerDetailsClick = this.onEditServerDetailsClick; - } - + private renderPasswordStep = () => { return ( ); }; - _renderSsoStep = loginType => { - const SignInToText = sdk.getComponent('views.auth.SignInToText'); + private renderSsoStep = loginType => { + const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow; - let onEditServerDetailsClick = null; - // If custom URLs are allowed, wire up the server details edit link. - if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { - onEditServerDetailsClick = this.onEditServerDetailsClick; - } - // XXX: This link does *not* have a target="_blank" because single sign-on relies on - // redirecting the user back to a URI once they're logged in. On the web, this means - // we use the same window and redirect back to Element. On Electron, this actually - // opens the SSO page in the Electron app itself due to - // https://github.com/electron/electron/issues/8841 and so happens to work. - // If this bug gets fixed, it will break SSO since it will open the SSO page in the - // user's browser, let them log into their SSO provider, then redirect their browser - // to vector://vector which, of course, will not work. return ( -
      - - - -
      + flow.type === "m.login.password")} + /> ); }; render() { - const Loader = sdk.getComponent("elements.Spinner"); - const InlineSpinner = sdk.getComponent("elements.InlineSpinner"); - const AuthHeader = sdk.getComponent("auth.AuthHeader"); - const AuthBody = sdk.getComponent("auth.AuthBody"); const loader = this.isBusy() && !this.state.busyLoggingIn ? -
      : null; +
      : null; const errorText = this.state.errorText; @@ -686,9 +583,11 @@ export default class LoginComponent extends React.Component {
      ; } else if (SettingsStore.getValue(UIFeature.Registration)) { footer = ( - - { _t('Create account') } - + + {_t("New? Create account", {}, { + a: sub => { sub }, + })} + ); } @@ -702,8 +601,11 @@ export default class LoginComponent extends React.Component { { errorTextSection } { serverDeadSection } - { this.renderServerComponent() } - { this.renderLoginComponentForStep() } + + { this.renderLoginComponentForFlows() } { footer } diff --git a/src/components/structures/auth/PostRegistration.js b/src/components/structures/auth/PostRegistration.js deleted file mode 100644 index aa36de6596..0000000000 --- a/src/components/structures/auth/PostRegistration.js +++ /dev/null @@ -1,77 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import { _t } from '../../../languageHandler'; -import AuthPage from "../../views/auth/AuthPage"; - -export default class PostRegistration extends React.Component { - static propTypes = { - onComplete: PropTypes.func.isRequired, - }; - - state = { - avatarUrl: null, - errorString: null, - busy: false, - }; - - componentDidMount() { - // There is some assymetry between ChangeDisplayName and ChangeAvatar, - // as ChangeDisplayName will auto-get the name but ChangeAvatar expects - // the URL to be passed to you (because it's also used for room avatars). - const cli = MatrixClientPeg.get(); - this.setState({busy: true}); - const self = this; - cli.getProfileInfo(cli.credentials.userId).then(function(result) { - self.setState({ - avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url), - busy: false, - }); - }, function(error) { - self.setState({ - errorString: _t("Failed to fetch avatar URL"), - busy: false, - }); - }); - } - - render() { - const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName'); - const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); - const AuthHeader = sdk.getComponent('auth.AuthHeader'); - const AuthBody = sdk.getComponent("auth.AuthBody"); - return ( - - - -
      - { _t('Set a display name:') } - - { _t('Upload an avatar:') } - - - { this.state.errorString } -
      -
      -
      - ); - } -} diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.tsx similarity index 51% rename from src/components/structures/auth/Registration.js rename to src/components/structures/auth/Registration.tsx index 630e04da9c..8d32981e57 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.tsx @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-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. @@ -17,110 +14,130 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Matrix from 'matrix-js-sdk'; -import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; +import { createClient } from 'matrix-js-sdk/src/matrix'; +import React, { ReactNode } from 'react'; +import { MatrixClient } from "matrix-js-sdk/src/client"; + import { _t, _td } from '../../../languageHandler'; -import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; -import * as ServerType from '../../views/auth/ServerTypeSelector'; -import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import classNames from "classnames"; import * as Lifecycle from '../../../Lifecycle'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; import AuthPage from "../../views/auth/AuthPage"; -import Login from "../../../Login"; +import Login, { ISSOFlow } from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; +import SSOButtons from "../../views/elements/SSOButtons"; +import ServerPicker from '../../views/elements/ServerPicker'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import RegistrationForm from '../../views/auth/RegistrationForm'; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import AuthBody from "../../views/auth/AuthBody"; +import AuthHeader from "../../views/auth/AuthHeader"; +import InteractiveAuth from "../InteractiveAuth"; +import Spinner from "../../views/elements/Spinner"; -// Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; -// Show the appropriate registration flow(s) for the server -const PHASE_REGISTRATION = 1; +interface IProps { + serverConfig: ValidatedServerConfig; + defaultDeviceDisplayName: string; + email?: string; + brand?: string; + clientSecret?: string; + sessionId?: string; + idSid?: string; + fragmentAfterLogin?: string; -// Enable phases for registration -const PHASES_ENABLED = true; + // Called when the user has logged in. Params: + // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken + // - The user's password, if available and applicable (may be cached in memory + // for a short time so the user is not required to re-enter their password + // for operations like uploading cross-signing keys). + onLoggedIn(params: IMatrixClientCreds, password: string): void; + makeRegistrationUrl(params: { + /* eslint-disable camelcase */ + client_secret: string; + hs_url: string; + is_url?: string; + session_id: string; + /* eslint-enable camelcase */ + }): string; + // registration shouldn't know or care how login is done. + onLoginClick(): void; + onServerConfigChange(config: ValidatedServerConfig): void; +} -export default class Registration extends React.Component { - static propTypes = { - // Called when the user has logged in. Params: - // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken - // - The user's password, if available and applicable (may be cached in memory - // for a short time so the user is not required to re-enter their password - // for operations like uploading cross-signing keys). - onLoggedIn: PropTypes.func.isRequired, +interface IState { + busy: boolean; + errorText?: ReactNode; + // true if we're waiting for the user to complete + // We remember the values entered by the user because + // the registration form will be unmounted during the + // course of registration, but if there's an error we + // want to bring back the registration form with the + // values the user entered still in it. We can keep + // them in this component's state since this component + // persist for the duration of the registration process. + formVals: Record; + // user-interactive auth + // If we've been given a session ID, we're resuming + // straight back into UI auth + doingUIAuth: boolean; + // If set, we've registered but are not going to log + // the user in to their new account automatically. + completedNoSignin: boolean; + flows: { + stages: string[]; + }[]; + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError?: ReactNode; - clientSecret: PropTypes.string, - sessionId: PropTypes.string, - makeRegistrationUrl: PropTypes.func.isRequired, - idSid: PropTypes.string, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - brand: PropTypes.string, - email: PropTypes.string, - // registration shouldn't know or care how login is done. - onLoginClick: PropTypes.func.isRequired, - onServerConfigChange: PropTypes.func.isRequired, - defaultDeviceDisplayName: PropTypes.string, - }; + // Our matrix client - part of state because we can't render the UI auth + // component without it. + matrixClient?: MatrixClient; + // The user ID we've just registered + registeredUsername?: string; + // if a different user ID to the one we just registered is logged in, + // this is the user ID that's logged in. + differentLoggedInUserId?: string; + // the SSO flow definition, this is fetched from /login as that's the only + // place it is exposed. + ssoFlow?: ISSOFlow; +} + +@replaceableComponent("structures.auth.Registration") +export default class Registration extends React.Component { + loginLogic: Login; constructor(props) { super(props); - const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig); this.state = { busy: false, errorText: null, - // We remember the values entered by the user because - // the registration form will be unmounted during the - // course of registration, but if there's an error we - // want to bring back the registration form with the - // values the user entered still in it. We can keep - // them in this component's state since this component - // persist for the duration of the registration process. formVals: { email: this.props.email, }, - // true if we're waiting for the user to complete - // user-interactive auth - // If we've been given a session ID, we're resuming - // straight back into UI auth doingUIAuth: Boolean(this.props.sessionId), - serverType, - // Phase of the overall registration dialog. - phase: PHASE_REGISTRATION, flows: null, - // If set, we've registered but are not going to log - // the user in to their new account automatically. completedNoSignin: false, - - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", - - // Our matrix client - part of state because we can't render the UI auth - // component without it. - matrixClient: null, - - // whether the HS requires an ID server to register with a threepid - serverRequiresIdServer: null, - - // The user ID we've just registered - registeredUsername: null, - - // if a different user ID to the one we just registered is logged in, - // this is the user ID that's logged in. - differentLoggedInUserId: null, }; + + const { hsUrl, isUrl } = this.props.serverConfig; + this.loginLogic = new Login(hsUrl, isUrl, null, { + defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used + }); } componentDidMount() { - this._unmounted = false; - this._replaceClient(); + this.replaceClient(this.props.serverConfig); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -129,63 +146,10 @@ export default class Registration extends React.Component { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; - this._replaceClient(newProps.serverConfig); - - // Handle cases where the user enters "https://matrix.org" for their server - // from the advanced option - we should default to FREE at that point. - const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig); - if (serverType !== this.state.serverType) { - // Reset the phase to default phase for the server type. - this.setState({ - serverType, - phase: this.getDefaultPhaseForServerType(serverType), - }); - } + this.replaceClient(newProps.serverConfig); } - getDefaultPhaseForServerType(type) { - switch (type) { - case ServerType.FREE: { - // Move directly to the registration phase since the server - // details are fixed. - return PHASE_REGISTRATION; - } - case ServerType.PREMIUM: - case ServerType.ADVANCED: - return PHASE_SERVER_DETAILS; - } - } - - onServerTypeChange = type => { - this.setState({ - serverType: type, - }); - - // When changing server types, set the HS / IS URLs to reasonable defaults for the - // the new type. - switch (type) { - case ServerType.FREE: { - const { serverConfig } = ServerType.TYPES.FREE; - this.props.onServerConfigChange(serverConfig); - break; - } - case ServerType.PREMIUM: - // We can accept whatever server config was the default here as this essentially - // acts as a slightly different "custom server"/ADVANCED option. - break; - case ServerType.ADVANCED: - // Use the default config from the config - this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]); - break; - } - - // Reset the phase to default phase for the server type. - this.setState({ - phase: this.getDefaultPhaseForServerType(type), - }); - }; - - async _replaceClient(serverConfig) { + private async replaceClient(serverConfig: ValidatedServerConfig) { this.setState({ errorText: null, serverDeadError: null, @@ -194,7 +158,6 @@ export default class Registration extends React.Component { // the UI auth component while we don't have a matrix client) busy: true, }); - if (!serverConfig) serverConfig = this.props.serverConfig; // Do a liveliness check on the URLs try { @@ -216,22 +179,26 @@ export default class Registration extends React.Component { } } - const {hsUrl, isUrl} = serverConfig; - const cli = Matrix.createClient({ + const { hsUrl, isUrl } = serverConfig; + const cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, }); - let serverRequiresIdServer = true; + this.loginLogic.setHomeserverUrl(hsUrl); + this.loginLogic.setIdentityServerUrl(isUrl); + + let ssoFlow: ISSOFlow; try { - serverRequiresIdServer = await cli.doesServerRequireIdServerParam(); + const loginFlows = await this.loginLogic.getFlows(); + ssoFlow = loginFlows.find(f => f.type === "m.login.sso" || f.type === "m.login.cas") as ISSOFlow; } catch (e) { - console.log("Unable to determine is server needs id_server param", e); + console.error("Failed to get login flows to check for SSO support", e); } this.setState({ matrixClient: cli, - serverRequiresIdServer, + ssoFlow, busy: false, }); const showGenericError = (e) => { @@ -246,7 +213,7 @@ export default class Registration extends React.Component { // do SSO instead. If we've already started the UI Auth process though, we don't // need to. if (!this.state.doingUIAuth) { - await this._makeRegisterRequest(null); + await this.makeRegisterRequest(null); // This should never succeed since we specified no auth object. console.log("Expecting 401 from register request but got success!"); } @@ -255,30 +222,21 @@ export default class Registration extends React.Component { this.setState({ flows: e.data.flows, }); - } else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") { + } else if (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN") { + // Check for 403 or M_FORBIDDEN, Synapse used to send 403 M_UNKNOWN but now sends 403 M_FORBIDDEN. // At this point registration is pretty much disabled, but before we do that let's // quickly check to see if the server supports SSO instead. If it does, we'll send // the user off to the login page to figure their account out. - try { - const loginLogic = new Login(hsUrl, isUrl, null, { - defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used + if (ssoFlow) { + // Redirect to login page - server probably expects SSO only + dis.dispatch({ action: 'start_login' }); + } else { + this.setState({ + serverErrorIsFatal: true, // fatal because user cannot continue on this server + errorText: _t("Registration has been disabled on this homeserver."), + // add empty flows array to get rid of spinner + flows: [], }); - const flows = await loginLogic.getFlows(); - const hasSsoFlow = flows.find(f => f.type === 'm.login.sso' || f.type === 'm.login.cas'); - if (hasSsoFlow) { - // Redirect to login page - server probably expects SSO only - dis.dispatch({action: 'start_login'}); - } else { - this.setState({ - serverErrorIsFatal: true, // fatal because user cannot continue on this server - errorText: _t("Registration has been disabled on this homeserver."), - // add empty flows array to get rid of spinner - flows: [], - }); - } - } catch (e) { - console.error("Failed to get login flows to check for SSO support", e); - showGenericError(e); } } else { console.log("Unable to query for supported registration methods.", e); @@ -287,7 +245,7 @@ export default class Registration extends React.Component { } } - onFormSubmit = formVals => { + private onFormSubmit = async (formVals): Promise => { this.setState({ errorText: "", busy: true, @@ -296,7 +254,7 @@ export default class Registration extends React.Component { }); }; - _requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => { + private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => { return this.state.matrixClient.requestRegisterEmailToken( emailAddress, clientSecret, @@ -308,30 +266,29 @@ export default class Registration extends React.Component { session_id: sessionId, }), ); - } + }; - _onUIAuthFinished = async (success, response, extra) => { + private onUIAuthFinished = async (success: boolean, response: any) => { if (!success) { let msg = response.message || response.toString(); // can we give a better error message? if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( response.data.limit_type, - response.data.admin_contact, { - 'monthly_active_user': _td( - "This homeserver has hit its Monthly Active User limit.", - ), - '': _td( - "This homeserver has exceeded one of its resource limits.", - ), - }); + response.data.admin_contact, + { + 'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."), + 'hs_blocked': _td("This homeserver has been blocked by it's administrator."), + '': _td("This homeserver has exceeded one of its resource limits."), + }, + ); const errorDetail = messageForResourceLimitError( response.data.limit_type, - response.data.admin_contact, { - '': _td( - "Please contact your service administrator to continue using this service.", - ), - }); + response.data.admin_contact, + { + '': _td("Please contact your service administrator to continue using this service."), + }, + ); msg =

      {errorTop}

      {errorDetail}

      @@ -339,11 +296,13 @@ export default class Registration extends React.Component { } else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { let msisdnAvailable = false; for (const flow of response.available_flows) { - msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1; + msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn'); } if (!msisdnAvailable) { msg = _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."); } this.setState({ busy: false, @@ -358,6 +317,10 @@ export default class Registration extends React.Component { const newState = { doingUIAuth: false, registeredUsername: response.user_id, + differentLoggedInUserId: null, + completedNoSignin: false, + // we're still busy until we get unmounted: don't show the registration form again + busy: true, }; // The user came in through an email validation link. To avoid overwriting @@ -365,15 +328,12 @@ export default class Registration extends React.Component { // isn't a guest user since we'll usually have set a guest user session before // starting the registration process. This isn't perfect since it's possible // the user had a separate guest session they didn't actually mean to replace. - const sessionOwner = Lifecycle.getStoredSessionOwner(); - const sessionIsGuest = Lifecycle.getStoredSessionIsGuest(); + const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner(); if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) { console.log( `Found a session for ${sessionOwner} but ${response.userId} has just registered.`, ); newState.differentLoggedInUserId = sessionOwner; - } else { - newState.differentLoggedInUserId = null; } if (response.access_token) { @@ -385,9 +345,7 @@ export default class Registration extends React.Component { accessToken: response.access_token, }, this.state.formVals.password); - this._setupPushers(); - // we're still busy until we get unmounted: don't show the registration form again - newState.busy = true; + this.setupPushers(); } else { newState.busy = false; newState.completedNoSignin = true; @@ -396,7 +354,7 @@ export default class Registration extends React.Component { this.setState(newState); }; - _setupPushers() { + private setupPushers() { if (!this.props.brand) { return Promise.resolve(); } @@ -419,38 +377,23 @@ export default class Registration extends React.Component { }); } - onLoginClick = ev => { + private onLoginClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); }; - onGoToFormClicked = ev => { + private onGoToFormClicked = ev => { ev.preventDefault(); ev.stopPropagation(); - this._replaceClient(); + this.replaceClient(this.props.serverConfig); this.setState({ busy: false, doingUIAuth: false, - phase: PHASE_REGISTRATION, }); }; - onServerDetailsNextPhaseClick = async () => { - this.setState({ - phase: PHASE_REGISTRATION, - }); - }; - - onEditServerDetailsClick = ev => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - }; - - _makeRegisterRequest = auth => { + private makeRegisterRequest = auth => { // We inhibit login if we're trying to register with an email address: this // avoids a lot of complex race conditions that can occur if we try to log // the user in one one or both of the tabs they might end up with after @@ -466,13 +409,15 @@ export default class Registration extends React.Component { username: this.state.formVals.username, password: this.state.formVals.password, initial_device_display_name: this.props.defaultDeviceDisplayName, + auth: undefined, + inhibit_login: undefined, }; if (auth) registerParams.auth = auth; if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin; return this.state.matrixClient.registerRequest(registerParams); }; - _getUIAuthInputs() { + private getUIAuthInputs() { return { emailAddress: this.state.formVals.email, phoneCountry: this.state.formVals.phoneCountry, @@ -483,93 +428,26 @@ export default class Registration extends React.Component { // Links to the login page shown after registration is completed are routed through this // which checks the user hasn't already logged in somewhere else (perhaps we should do // this more generally?) - _onLoginClickWithCheck = async ev => { + private onLoginClickWithCheck = async ev => { ev.preventDefault(); - const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true}); + const sessionLoaded = await Lifecycle.loadSession({ ignoreGuest: true }); if (!sessionLoaded) { // ok fine, there's still no session: really go to the login page this.props.onLoginClick(); } + + return sessionLoaded; }; - renderServerComponent() { - const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector"); - const ServerConfig = sdk.getComponent("auth.ServerConfig"); - const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig"); - - if (SdkConfig.get()['disable_custom_urls']) { - return null; - } - - // If we're on a different phase, we only show the server type selector, - // which is always shown if we allow custom URLs at all. - // (if there's a fatal server error, we need to show the full server - // config as the user may need to change servers to resolve the error). - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) { - return
      - -
      ; - } - - const serverDetailsProps = {}; - if (PHASES_ENABLED) { - serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; - serverDetailsProps.submitText = _t("Next"); - serverDetailsProps.submitClass = "mx_Login_submit"; - } - - let serverDetails = null; - switch (this.state.serverType) { - case ServerType.FREE: - break; - case ServerType.PREMIUM: - serverDetails = ; - break; - case ServerType.ADVANCED: - serverDetails = ; - break; - } - - return
      - - {serverDetails} -
      ; - } - - renderRegisterComponent() { - if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) { - return null; - } - - const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); - const Spinner = sdk.getComponent('elements.Spinner'); - const RegistrationForm = sdk.getComponent('auth.RegistrationForm'); - + private renderRegisterComponent() { if (this.state.matrixClient && this.state.doingUIAuth) { return
      ; } else if (this.state.flows.length) { - let onEditServerDetailsClick = null; - // If custom URLs are allowed and we haven't selected the Free server type, wire - // up the server details edit link. - if ( - PHASES_ENABLED && - !SdkConfig.get()['disable_custom_urls'] && - this.state.serverType !== ServerType.FREE - ) { - onEditServerDetailsClick = this.onEditServerDetailsClick; + let ssoSection; + if (this.state.ssoFlow) { + let continueWithSection; + const providers = this.state.ssoFlow.identity_providers || []; + // when there is only a single (or 0) providers we show a wide button with `Continue with X` text + if (providers.length > 1) { + // i18n: ssoButtons is a placeholder to help translators understand context + continueWithSection =

      + { _t("Continue with %(ssoButtons)s", { ssoButtons: "" }).trim() } +

      ; + } + + // i18n: ssoButtons & usernamePassword are placeholders to help translators understand context + ssoSection = + { continueWithSection } + +

      + {_t( + "%(ssoButtons)s Or %(usernamePassword)s", + { + ssoButtons: "", + usernamePassword: "", + }, + ).trim()} +

      +
      ; } - return ; + return + { ssoSection } + + ; } } render() { - const AuthHeader = sdk.getComponent('auth.AuthHeader'); - const AuthBody = sdk.getComponent("auth.AuthBody"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let errorText; const err = this.state.errorText; if (err) { @@ -634,13 +531,15 @@ export default class Registration extends React.Component { ); } - const signIn = - { _t('Sign in instead') } - ; + const signIn = + {_t("Already have an account? Sign in here", {}, { + a: sub => { sub }, + })} + ; // Only show the 'go back' button if you're not looking at the form let goBack; - if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) { + if (this.state.doingUIAuth) { goBack = { _t('Go back') } ; @@ -658,7 +557,12 @@ export default class Registration extends React.Component { loggedInUserId: this.state.differentLoggedInUserId, }, )}

      -

      +

      { + const sessionLoaded = await this.onLoginClickWithCheck(event); + if (sessionLoaded) { + dis.dispatch({ action: "view_welcome_page" }); + } + }}> {_t("Continue with previous account")}

      ; @@ -667,7 +571,7 @@ export default class Registration extends React.Component { regDoneText =

      {_t( "Log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => {sub}, }, )}

      ; } else { @@ -677,7 +581,7 @@ export default class Registration extends React.Component { regDoneText =

      {_t( "You can now close this window or log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => {sub}, }, )}

      ; } @@ -687,10 +591,15 @@ export default class Registration extends React.Component {
      ; } else { body =
      -

      { _t('Create your account') }

      +

      { _t('Create account') }

      { errorText } { serverDeadSection } - { this.renderServerComponent() } + { this.renderRegisterComponent() } { goBack } { signIn } diff --git a/src/components/structures/auth/SetupEncryptionBody.js b/src/components/structures/auth/SetupEncryptionBody.tsx similarity index 60% rename from src/components/structures/auth/SetupEncryptionBody.js rename to src/components/structures/auth/SetupEncryptionBody.tsx index 6d090936e5..c7ce74077b 100644 --- a/src/components/structures/auth/SetupEncryptionBody.js +++ b/src/components/structures/auth/SetupEncryptionBody.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-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. @@ -15,37 +15,43 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; -import SdkConfig from '../../../SdkConfig'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; -import * as sdk from '../../../index'; -import { - SetupEncryptionStore, - PHASE_INTRO, - PHASE_BUSY, - PHASE_DONE, - PHASE_CONFIRM_SKIP, - PHASE_FINISHED, -} from '../../../stores/SetupEncryptionStore'; +import Modal from '../../../Modal'; +import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog'; +import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api'; +import EncryptionPanel from "../../views/right_panel/EncryptionPanel"; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import Spinner from '../../views/elements/Spinner'; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -function keyHasPassphrase(keyInfo) { - return ( +function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean { + return Boolean( keyInfo.passphrase && keyInfo.passphrase.salt && - keyInfo.passphrase.iterations + keyInfo.passphrase.iterations, ); } -export default class SetupEncryptionBody extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; +interface IProps { + onFinished: (boolean) => void; +} - constructor() { - super(); +interface IState { + phase: Phase; + verificationRequest: VerificationRequest; + backupInfo: IKeyBackupInfo; +} + +@replaceableComponent("structures.auth.SetupEncryptionBody") +export default class SetupEncryptionBody extends React.Component { + constructor(props) { + super(props); const store = SetupEncryptionStore.sharedInstance(); - store.on("update", this._onStoreUpdate); + store.on("update", this.onStoreUpdate); store.start(); this.state = { phase: store.phase, @@ -57,10 +63,10 @@ export default class SetupEncryptionBody extends React.Component { }; } - _onStoreUpdate = () => { + private onStoreUpdate = () => { const store = SetupEncryptionStore.sharedInstance(); - if (store.phase === PHASE_FINISHED) { - this.props.onFinished(); + if (store.phase === Phase.Finished) { + this.props.onFinished(true); return; } this.setState({ @@ -70,94 +76,101 @@ export default class SetupEncryptionBody extends React.Component { }); }; - componentWillUnmount() { + public componentWillUnmount() { const store = SetupEncryptionStore.sharedInstance(); - store.off("update", this._onStoreUpdate); + store.off("update", this.onStoreUpdate); store.stop(); } - _onUsePassphraseClick = async () => { + private onUsePassphraseClick = async () => { const store = SetupEncryptionStore.sharedInstance(); store.usePassPhrase(); - } + }; - onSkipClick = () => { + private onVerifyClick = () => { + const cli = MatrixClientPeg.get(); + const userId = cli.getUserId(); + const requestPromise = cli.requestVerification(userId); + + this.props.onFinished(true); + Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, { + verificationRequestPromise: requestPromise, + member: cli.getUser(userId), + onFinished: async () => { + const request = await requestPromise; + request.cancel(); + }, + }); + }; + + private onSkipClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.skip(); - } + }; - onSkipConfirmClick = () => { + private onSkipConfirmClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.skipConfirm(); - } + }; - onSkipBackClick = () => { + private onSkipBackClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.returnAfterSkip(); - } + }; - onDoneClick = () => { + private onDoneClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.done(); - } + }; - render() { - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); + private onEncryptionPanelClose = () => { + this.props.onFinished(false); + }; + public render() { const { phase, } = this.state; if (this.state.verificationRequest) { - const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel"); return ; - } else if (phase === PHASE_INTRO) { + } else if (phase === Phase.Intro) { const store = SetupEncryptionStore.sharedInstance(); let recoveryKeyPrompt; if (store.keyInfo && keyHasPassphrase(store.keyInfo)) { - recoveryKeyPrompt = _t("Use Recovery Key or Passphrase"); + recoveryKeyPrompt = _t("Use Security Key or Phrase"); } else if (store.keyInfo) { - recoveryKeyPrompt = _t("Use Recovery Key"); + recoveryKeyPrompt = _t("Use Security Key"); } let useRecoveryKeyButton; if (recoveryKeyPrompt) { - useRecoveryKeyButton = + useRecoveryKeyButton = {recoveryKeyPrompt} ; } - const brand = SdkConfig.get().brand; + let verifyButton; + if (store.hasDevicesToVerifyAgainst) { + verifyButton = + { _t("Use another login") } + ; + } return (

      {_t( - "Confirm your identity by verifying this login from one of your other sessions, " + - "granting it access to encrypted messages.", + "Verify your identity to access encrypted messages and prove your identity to others.", )}

      -

      {_t( - "This requires the latest %(brand)s on your other devices:", - { brand }, - )}

      - -
      -
      -
      {_t("%(brand)s Web", { brand })}
      -
      {_t("%(brand)s Desktop", { brand })}
      -
      -
      -
      {_t("%(brand)s iOS", { brand })}
      -
      {_t("%(brand)s Android", { brand })}
      -
      -

      {_t("or another cross-signing capable Matrix client")}

      -
      + {verifyButton} {useRecoveryKeyButton} {_t("Skip")} @@ -165,7 +178,7 @@ export default class SetupEncryptionBody extends React.Component {
      ); - } else if (phase === PHASE_DONE) { + } else if (phase === Phase.Done) { let message; if (this.state.backupInfo) { message =

      {_t( @@ -191,12 +204,12 @@ export default class SetupEncryptionBody extends React.Component {

      ); - } else if (phase === PHASE_CONFIRM_SKIP) { + } else if (phase === Phase.ConfirmSkip) { return (

      {_t( - "Without completing security on this session, it won’t have " + - "access to encrypted messages.", + "Without verifying, you won’t have access to all your messages " + + "and may appear as untrusted to others.", )}

      ); - } else if (phase === PHASE_BUSY) { - const Spinner = sdk.getComponent('views.elements.Spinner'); + } else if (phase === Phase.Busy || phase === Phase.Loading) { return ; } else { console.log(`SetupEncryptionBody: Unknown phase ${phase}`); diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.tsx similarity index 74% rename from src/components/structures/auth/SoftLogout.js rename to src/components/structures/auth/SoftLogout.tsx index a539c8c9ee..d232f55dd1 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-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. @@ -15,17 +15,22 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {_t} from '../../../languageHandler'; -import * as sdk from '../../../index'; +import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import * as Lifecycle from '../../../Lifecycle'; import Modal from '../../../Modal'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {sendLoginRequest} from "../../../Login"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { ISSOFlow, LoginFlow, sendLoginRequest } from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; -import SSOButton from "../../views/elements/SSOButton"; -import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform"; +import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform"; +import SSOButtons from "../../views/elements/SSOButtons"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ConfirmWipeDeviceDialog from '../../views/dialogs/ConfirmWipeDeviceDialog'; +import Field from '../../views/elements/Field'; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import Spinner from "../../views/elements/Spinner"; +import AuthHeader from "../../views/auth/AuthHeader"; +import AuthBody from "../../views/auth/AuthBody"; const LOGIN_VIEW = { LOADING: 1, @@ -41,44 +46,59 @@ const FLOWS_TO_VIEWS = { "m.login.sso": LOGIN_VIEW.SSO, }; -export default class SoftLogout extends React.Component { - static propTypes = { - // Query parameters from MatrixChat - realQueryParams: PropTypes.object, // {loginToken} - - // Called when the SSO login completes - onTokenLoginCompleted: PropTypes.func, +interface IProps { + // Query parameters from MatrixChat + realQueryParams: { + loginToken?: string; }; + fragmentAfterLogin?: string; - constructor() { - super(); + // Called when the SSO login completes + onTokenLoginCompleted: () => void; +} + +interface IState { + loginView: number; + keyBackupNeeded: boolean; + busy: boolean; + password: string; + errorText: string; + flows: LoginFlow[]; +} + +@replaceableComponent("structures.auth.SoftLogout") +export default class SoftLogout extends React.Component { + constructor(props) { + super(props); this.state = { loginView: LOGIN_VIEW.LOADING, keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount) - busy: false, password: "", errorText: "", + flows: [], }; } componentDidMount(): void { // We've ended up here when we don't need to - navigate to login if (!Lifecycle.isSoftLogout()) { - dis.dispatch({action: "start_login"}); + dis.dispatch({ action: "start_login" }); return; } - this._initLogin(); + this.initLogin(); - MatrixClientPeg.get().countSessionsNeedingBackup().then(remaining => { - this.setState({keyBackupNeeded: remaining > 0}); - }); + const cli = MatrixClientPeg.get(); + if (cli.isCryptoEnabled()) { + cli.countSessionsNeedingBackup().then(remaining => { + this.setState({ keyBackupNeeded: remaining > 0 }); + }); + } } onClearAll = () => { - const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog'); Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, { onFinished: (wipeData) => { if (!wipeData) return; @@ -89,11 +109,11 @@ export default class SoftLogout extends React.Component { }); }; - async _initLogin() { + private async initLogin() { const queryParams = this.props.realQueryParams; const hasAllParams = queryParams && queryParams['loginToken']; if (hasAllParams) { - this.setState({loginView: LOGIN_VIEW.LOADING}); + this.setState({ loginView: LOGIN_VIEW.LOADING }); this.trySsoLogin(); return; } @@ -101,25 +121,26 @@ export default class SoftLogout extends React.Component { // Note: we don't use the existing Login class because it is heavily flow-based. We don't // care about login flows here, unless it is the single flow we support. const client = MatrixClientPeg.get(); - const loginViews = (await client.loginFlows()).flows.map(f => FLOWS_TO_VIEWS[f.type]); + const flows = (await client.loginFlows()).flows; + const loginViews = flows.map(f => FLOWS_TO_VIEWS[f.type]); const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED; - this.setState({loginView: chosenView}); + this.setState({ flows, loginView: chosenView }); } onPasswordChange = (ev) => { - this.setState({password: ev.target.value}); + this.setState({ password: ev.target.value }); }; onForgotPassword = () => { - dis.dispatch({action: 'start_password_recovery'}); + dis.dispatch({ action: 'start_password_recovery' }); }; onPasswordLogin = async (ev) => { ev.preventDefault(); ev.stopPropagation(); - this.setState({busy: true}); + this.setState({ busy: true }); const hsUrl = MatrixClientPeg.get().getHomeserverUrl(); const isUrl = MatrixClientPeg.get().getIdentityServerUrl(); @@ -151,12 +172,12 @@ export default class SoftLogout extends React.Component { Lifecycle.hydrateSession(credentials).catch((e) => { console.error(e); - this.setState({busy: false, errorText: _t("Failed to re-authenticate")}); + this.setState({ busy: false, errorText: _t("Failed to re-authenticate") }); }); }; async trySsoLogin() { - this.setState({busy: true}); + this.setState({ busy: true }); const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY); const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl(); @@ -171,7 +192,7 @@ export default class SoftLogout extends React.Component { credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams); } catch (e) { console.error(e); - this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED}); + this.setState({ busy: false, loginView: LOGIN_VIEW.UNSUPPORTED }); return; } @@ -179,13 +200,12 @@ export default class SoftLogout extends React.Component { if (this.props.onTokenLoginCompleted) this.props.onTokenLoginCompleted(); }).catch((e) => { console.error(e); - this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED}); + this.setState({ busy: false, loginView: LOGIN_VIEW.UNSUPPORTED }); }); } - _renderSignInSection() { + private renderSignInSection() { if (this.state.loginView === LOGIN_VIEW.LOADING) { - const Spinner = sdk.getComponent("elements.Spinner"); return ; } @@ -197,9 +217,6 @@ export default class SoftLogout extends React.Component { } if (this.state.loginView === LOGIN_VIEW.PASSWORD) { - const Field = sdk.getComponent("elements.Field"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let error = null; if (this.state.errorText) { error = {this.state.errorText}; @@ -240,13 +257,18 @@ export default class SoftLogout extends React.Component { introText = _t("Sign in and regain access to your account."); } // else we already have a message and should use it (key backup warning) + const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"; + const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow; + return (

      {introText}

      - flow.type === "m.login.password")} />
      ); @@ -264,10 +286,6 @@ export default class SoftLogout extends React.Component { } render() { - const AuthHeader = sdk.getComponent("auth.AuthHeader"); - const AuthBody = sdk.getComponent("auth.AuthBody"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - return ( @@ -278,7 +296,7 @@ export default class SoftLogout extends React.Component {

      {_t("Sign in")}

      - {this._renderSignInSection()} + {this.renderSignInSection()}

      {_t("Clear personal data")}

      diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx new file mode 100644 index 0000000000..66efa64658 --- /dev/null +++ b/src/components/views/audio_messages/AudioPlayer.tsx @@ -0,0 +1,124 @@ +/* +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 { Playback, PlaybackState } from "../../../voice/Playback"; +import React, { createRef, ReactNode, RefObject } from "react"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import PlayPauseButton from "./PlayPauseButton"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { formatBytes } from "../../../utils/FormattingUtils"; +import DurationClock from "./DurationClock"; +import { Key } from "../../../Keyboard"; +import { _t } from "../../../languageHandler"; +import SeekBar from "./SeekBar"; +import PlaybackClock from "./PlaybackClock"; + +interface IProps { + // Playback instance to render. Cannot change during component lifecycle: create + // an all-new component instead. + playback: Playback; + + mediaName: string; +} + +interface IState { + playbackPhase: PlaybackState; +} + +@replaceableComponent("views.audio_messages.AudioPlayer") +export default class AudioPlayer extends React.PureComponent { + private playPauseRef: RefObject = createRef(); + private seekRef: RefObject = createRef(); + + constructor(props: IProps) { + super(props); + + this.state = { + playbackPhase: PlaybackState.Decoding, // default assumption + }; + + // We don't need to de-register: the class handles this for us internally + this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate); + + // Don't wait for the promise to complete - it will emit a progress update when it + // is done, and it's not meant to take long anyhow. + // noinspection JSIgnoredPromiseFromCall + this.props.playback.prepare(); + } + + private onPlaybackUpdate = (ev: PlaybackState) => { + this.setState({ playbackPhase: ev }); + }; + + private onKeyDown = (ev: React.KeyboardEvent) => { + // stopPropagation() prevents the FocusComposer catch-all from triggering, + // but we need to do it on key down instead of press (even though the user + // interaction is typically on press). + if (ev.key === Key.SPACE) { + ev.stopPropagation(); + this.playPauseRef.current?.toggleState(); + } else if (ev.key === Key.ARROW_LEFT) { + ev.stopPropagation(); + this.seekRef.current?.left(); + } else if (ev.key === Key.ARROW_RIGHT) { + ev.stopPropagation(); + this.seekRef.current?.right(); + } + }; + + protected renderFileSize(): string { + const bytes = this.props.playback.sizeBytes; + if (!bytes) return null; + + // Not translated here - we're just presenting the data which should already + // be translated if needed. + return `(${formatBytes(bytes)})`; + } + + public render(): ReactNode { + // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard + // events for accessibility + return
      +
      + +
      + + {this.props.mediaName || _t("Unnamed audio")} + +
      + +   {/* easiest way to introduce a gap between the components */} + { this.renderFileSize() } +
      +
      +
      +
      + + +
      +
      ; + } +} diff --git a/src/components/views/audio_messages/Clock.tsx b/src/components/views/audio_messages/Clock.tsx new file mode 100644 index 0000000000..7f387715f8 --- /dev/null +++ b/src/components/views/audio_messages/Clock.tsx @@ -0,0 +1,48 @@ +/* +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 from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +export interface IProps { + seconds: number; +} + +interface IState { +} + +/** + * Simply converts seconds into minutes and seconds. Note that hours will not be + * displayed, making it possible to see "82:29". + */ +@replaceableComponent("views.audio_messages.Clock") +export default class Clock extends React.Component { + public constructor(props) { + super(props); + } + + shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean { + const currentFloor = Math.floor(this.props.seconds); + const nextFloor = Math.floor(nextProps.seconds); + return currentFloor !== nextFloor; + } + + public render() { + const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0'); + const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis + return {minutes}:{seconds}; + } +} diff --git a/src/components/views/audio_messages/DurationClock.tsx b/src/components/views/audio_messages/DurationClock.tsx new file mode 100644 index 0000000000..81852b5944 --- /dev/null +++ b/src/components/views/audio_messages/DurationClock.tsx @@ -0,0 +1,55 @@ +/* +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 from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Clock from "./Clock"; +import { Playback } from "../../../voice/Playback"; + +interface IProps { + playback: Playback; +} + +interface IState { + durationSeconds: number; +} + +/** + * A clock which shows a clip's maximum duration. + */ +@replaceableComponent("views.audio_messages.DurationClock") +export default class DurationClock extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + // we track the duration on state because we won't really know what the clip duration + // is until the first time update, and as a PureComponent we are trying to dedupe state + // updates as much as possible. This is just the easiest way to avoid a forceUpdate() or + // member property to track "did we get a duration". + durationSeconds: this.props.playback.clockInfo.durationSeconds, + }; + this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); + } + + private onTimeUpdate = (time: number[]) => { + this.setState({ durationSeconds: time[1] }); + }; + + public render() { + return ; + } +} diff --git a/src/components/views/audio_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx new file mode 100644 index 0000000000..a9dbd3c52f --- /dev/null +++ b/src/components/views/audio_messages/LiveRecordingClock.tsx @@ -0,0 +1,65 @@ +/* +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 from "react"; +import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Clock from "./Clock"; +import { MarkedExecution } from "../../../utils/MarkedExecution"; + +interface IProps { + recorder: VoiceRecording; +} + +interface IState { + seconds: number; +} + +/** + * A clock for a live recording. + */ +@replaceableComponent("views.audio_messages.LiveRecordingClock") +export default class LiveRecordingClock extends React.PureComponent { + private seconds = 0; + private scheduledUpdate = new MarkedExecution( + () => this.updateClock(), + () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), + ); + + constructor(props) { + super(props); + this.state = { + seconds: 0, + }; + } + + componentDidMount() { + this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => { + this.seconds = update.timeSeconds; + this.scheduledUpdate.mark(); + }); + } + + private updateClock() { + this.setState({ + seconds: this.seconds, + }); + } + + public render() { + return ; + } +} diff --git a/src/components/views/audio_messages/LiveRecordingWaveform.tsx b/src/components/views/audio_messages/LiveRecordingWaveform.tsx new file mode 100644 index 0000000000..b9c5f80f05 --- /dev/null +++ b/src/components/views/audio_messages/LiveRecordingWaveform.tsx @@ -0,0 +1,74 @@ +/* +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 from "react"; +import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { arrayFastResample } from "../../../utils/arrays"; +import { percentageOf } from "../../../utils/numbers"; +import Waveform from "./Waveform"; +import { MarkedExecution } from "../../../utils/MarkedExecution"; + +interface IProps { + recorder: VoiceRecording; +} + +interface IState { + waveform: number[]; +} + +/** + * A waveform which shows the waveform of a live recording + */ +@replaceableComponent("views.audio_messages.LiveRecordingWaveform") +export default class LiveRecordingWaveform extends React.PureComponent { + public static defaultProps = { + progress: 1, + }; + + private waveform: number[] = []; + private scheduledUpdate = new MarkedExecution( + () => this.updateWaveform(), + () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), + ); + + constructor(props) { + super(props); + this.state = { + waveform: [], + }; + } + + componentDidMount() { + this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => { + const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES); + // The incoming data is between zero and one, but typically even screaming into a + // microphone won't send you over 0.6, so we artificially adjust the gain for the + // waveform. This results in a slightly more cinematic/animated waveform for the + // user. + this.waveform = bars.map(b => percentageOf(b, 0, 0.50)); + this.scheduledUpdate.mark(); + }); + } + + private updateWaveform() { + this.setState({ waveform: this.waveform }); + } + + public render() { + return ; + } +} diff --git a/src/components/views/audio_messages/PlayPauseButton.tsx b/src/components/views/audio_messages/PlayPauseButton.tsx new file mode 100644 index 0000000000..a4f1e770f2 --- /dev/null +++ b/src/components/views/audio_messages/PlayPauseButton.tsx @@ -0,0 +1,69 @@ +/* +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, { ReactNode } from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import { _t } from "../../../languageHandler"; +import { Playback, PlaybackState } from "../../../voice/Playback"; +import classNames from "classnames"; + +// omitted props are handled by render function +interface IProps extends Omit, "title" | "onClick" | "disabled"> { + // Playback instance to manipulate. Cannot change during the component lifecycle. + playback: Playback; + + // The playback phase to render. Able to change during the component lifecycle. + playbackPhase: PlaybackState; +} + +/** + * Displays a play/pause button (activating the play/pause function of the recorder) + * to be displayed in reference to a recording. + */ +@replaceableComponent("views.audio_messages.PlayPauseButton") +export default class PlayPauseButton extends React.PureComponent { + public constructor(props) { + super(props); + } + + private onClick = () => { + // noinspection JSIgnoredPromiseFromCall + this.toggleState(); + }; + + public async toggleState() { + await this.props.playback.toggle(); + } + + public render(): ReactNode { + const { playback, playbackPhase, ...restProps } = this.props; + const isPlaying = playback.isPlaying; + const isDisabled = playbackPhase === PlaybackState.Decoding; + const classes = classNames('mx_PlayPauseButton', { + 'mx_PlayPauseButton_play': !isPlaying, + 'mx_PlayPauseButton_pause': isPlaying, + 'mx_PlayPauseButton_disabled': isDisabled, + }); + return ; + } +} diff --git a/src/components/views/audio_messages/PlaybackClock.tsx b/src/components/views/audio_messages/PlaybackClock.tsx new file mode 100644 index 0000000000..374d47c31d --- /dev/null +++ b/src/components/views/audio_messages/PlaybackClock.tsx @@ -0,0 +1,80 @@ +/* +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 from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Clock from "./Clock"; +import { Playback, PlaybackState } from "../../../voice/Playback"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; + +interface IProps { + playback: Playback; + + // The default number of seconds to show when the playback has completed or + // has not started. Not used during playback, even when paused. Defaults to + // clip duration length. + defaultDisplaySeconds?: number; +} + +interface IState { + seconds: number; + durationSeconds: number; + playbackPhase: PlaybackState; +} + +/** + * A clock for a playback of a recording. + */ +@replaceableComponent("views.audio_messages.PlaybackClock") +export default class PlaybackClock extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + seconds: this.props.playback.clockInfo.timeSeconds, + // we track the duration on state because we won't really know what the clip duration + // is until the first time update, and as a PureComponent we are trying to dedupe state + // updates as much as possible. This is just the easiest way to avoid a forceUpdate() or + // member property to track "did we get a duration". + durationSeconds: this.props.playback.clockInfo.durationSeconds, + playbackPhase: PlaybackState.Stopped, // assume not started, so full clock + }; + this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate); + this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); + } + + private onPlaybackUpdate = (ev: PlaybackState) => { + // Convert Decoding -> Stopped because we don't care about the distinction here + if (ev === PlaybackState.Decoding) ev = PlaybackState.Stopped; + this.setState({ playbackPhase: ev }); + }; + + private onTimeUpdate = (time: number[]) => { + this.setState({ seconds: time[0], durationSeconds: time[1] }); + }; + + public render() { + let seconds = this.state.seconds; + if (this.state.playbackPhase === PlaybackState.Stopped) { + if (Number.isFinite(this.props.defaultDisplaySeconds)) { + seconds = this.props.defaultDisplaySeconds; + } else { + seconds = this.state.durationSeconds; + } + } + return ; + } +} diff --git a/src/components/views/audio_messages/PlaybackWaveform.tsx b/src/components/views/audio_messages/PlaybackWaveform.tsx new file mode 100644 index 0000000000..ea1b846c01 --- /dev/null +++ b/src/components/views/audio_messages/PlaybackWaveform.tsx @@ -0,0 +1,68 @@ +/* +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 from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { arraySeed, arrayTrimFill } from "../../../utils/arrays"; +import Waveform from "./Waveform"; +import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../voice/Playback"; +import { percentageOf } from "../../../utils/numbers"; + +interface IProps { + playback: Playback; +} + +interface IState { + heights: number[]; + progress: number; +} + +/** + * A waveform which shows the waveform of a previously recorded recording + */ +@replaceableComponent("views.audio_messages.PlaybackWaveform") +export default class PlaybackWaveform extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + heights: this.toHeights(this.props.playback.waveform), + progress: 0, // default no progress + }; + + this.props.playback.waveformData.onUpdate(this.onWaveformUpdate); + this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); + } + + private toHeights(waveform: number[]) { + const seed = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); + return arrayTrimFill(waveform, PLAYBACK_WAVEFORM_SAMPLES, seed); + } + + private onWaveformUpdate = (waveform: number[]) => { + this.setState({ heights: this.toHeights(waveform) }); + }; + + private onTimeUpdate = (time: number[]) => { + // Track percentages to a general precision to avoid over-waking the component. + const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(3)); + this.setState({ progress }); + }; + + public render() { + return ; + } +} diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx new file mode 100644 index 0000000000..7d9312f369 --- /dev/null +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -0,0 +1,74 @@ +/* +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 { Playback, PlaybackState } from "../../../voice/Playback"; +import React, { ReactNode } from "react"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import PlayPauseButton from "./PlayPauseButton"; +import PlaybackClock from "./PlaybackClock"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { TileShape } from "../rooms/EventTile"; +import PlaybackWaveform from "./PlaybackWaveform"; + +interface IProps { + // Playback instance to render. Cannot change during component lifecycle: create + // an all-new component instead. + playback: Playback; + + tileShape?: TileShape; +} + +interface IState { + playbackPhase: PlaybackState; +} + +@replaceableComponent("views.audio_messages.RecordingPlayback") +export default class RecordingPlayback extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = { + playbackPhase: PlaybackState.Decoding, // default assumption + }; + + // We don't need to de-register: the class handles this for us internally + this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate); + + // Don't wait for the promise to complete - it will emit a progress update when it + // is done, and it's not meant to take long anyhow. + // noinspection JSIgnoredPromiseFromCall + this.props.playback.prepare(); + } + + private get isWaveformable(): boolean { + return this.props.tileShape !== TileShape.Notif + && this.props.tileShape !== TileShape.FileGrid + && this.props.tileShape !== TileShape.Pinned; + } + + private onPlaybackUpdate = (ev: PlaybackState) => { + this.setState({ playbackPhase: ev }); + }; + + public render(): ReactNode { + const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : ''; + return
      + + + { this.isWaveformable && } +
      ; + } +} diff --git a/src/components/views/audio_messages/SeekBar.tsx b/src/components/views/audio_messages/SeekBar.tsx new file mode 100644 index 0000000000..5231a2fb79 --- /dev/null +++ b/src/components/views/audio_messages/SeekBar.tsx @@ -0,0 +1,112 @@ +/* +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 { Playback, PlaybackState } from "../../../voice/Playback"; +import React, { ChangeEvent, CSSProperties, ReactNode } from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { MarkedExecution } from "../../../utils/MarkedExecution"; +import { percentageOf } from "../../../utils/numbers"; + +interface IProps { + // Playback instance to render. Cannot change during component lifecycle: create + // an all-new component instead. + playback: Playback; + + // Tab index for the underlying component. Useful if the seek bar is in a managed state. + // Defaults to zero. + tabIndex?: number; + + playbackPhase: PlaybackState; +} + +interface IState { + percentage: number; +} + +interface ISeekCSS extends CSSProperties { + '--fillTo': number; +} + +const ARROW_SKIP_SECONDS = 5; // arbitrary + +@replaceableComponent("views.audio_messages.SeekBar") +export default class SeekBar extends React.PureComponent { + // We use an animation frame request to avoid overly spamming prop updates, even if we aren't + // really using anything demanding on the CSS front. + + private animationFrameFn = new MarkedExecution( + () => this.doUpdate(), + () => requestAnimationFrame(() => this.animationFrameFn.trigger())); + + public static defaultProps = { + tabIndex: 0, + }; + + constructor(props: IProps) { + super(props); + + this.state = { + percentage: 0, + }; + + // We don't need to de-register: the class handles this for us internally + this.props.playback.clockInfo.liveData.onUpdate(() => this.animationFrameFn.mark()); + } + + private doUpdate() { + this.setState({ + percentage: percentageOf( + this.props.playback.clockInfo.timeSeconds, + 0, + this.props.playback.clockInfo.durationSeconds), + }); + } + + public left() { + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds - ARROW_SKIP_SECONDS); + } + + public right() { + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds + ARROW_SKIP_SECONDS); + } + + private onChange = (ev: ChangeEvent) => { + // Thankfully, onChange is only called when the user changes the value, not when we + // change the value on the component. We can use this as a reliable "skip to X" function. + // + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(Number(ev.target.value) * this.props.playback.clockInfo.durationSeconds); + }; + + public render(): ReactNode { + // We use a range input to avoid having to re-invent accessibility handling on + // a custom set of divs. + return ; + } +} diff --git a/src/components/views/audio_messages/Waveform.tsx b/src/components/views/audio_messages/Waveform.tsx new file mode 100644 index 0000000000..3b7a881754 --- /dev/null +++ b/src/components/views/audio_messages/Waveform.tsx @@ -0,0 +1,63 @@ +/* +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 from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import classNames from "classnames"; +import { CSSProperties } from "react"; + +interface WaveformCSSProperties extends CSSProperties { + '--barHeight': number; +} + +interface IProps { + relHeights: number[]; // relative heights (0-1) + progress: number; // percent complete, 0-1, default 100% +} + +interface IState { +} + +/** + * A simple waveform component. This renders bars (centered vertically) for each + * height provided in the component properties. Updating the properties will update + * the rendered waveform. + * + * For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be + * "filled", as a demonstration of the progress property. + */ +@replaceableComponent("views.audio_messages.Waveform") +export default class Waveform extends React.PureComponent { + public static defaultProps = { + progress: 1, + }; + + public render() { + return
      + {this.props.relHeights.map((h, i) => { + const progress = this.props.progress; + const isCompleteBar = (i / this.props.relHeights.length) <= progress && progress > 0; + const classes = classNames({ + 'mx_Waveform_bar': true, + 'mx_Waveform_bar_100pct': isCompleteBar, + }); + return ; + })} +
      ; + } +} diff --git a/src/components/views/auth/AuthBody.js b/src/components/views/auth/AuthBody.js index 9a078efb52..abe7fd2fd3 100644 --- a/src/components/views/auth/AuthBody.js +++ b/src/components/views/auth/AuthBody.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import React from 'react'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +@replaceableComponent("views.auth.AuthBody") export default class AuthBody extends React.PureComponent { render() { return
      diff --git a/src/components/views/auth/AuthFooter.js b/src/components/views/auth/AuthFooter.js index 3de5a19350..e81d2cd969 100644 --- a/src/components/views/auth/AuthFooter.js +++ b/src/components/views/auth/AuthFooter.js @@ -18,7 +18,9 @@ limitations under the License. import { _t } from '../../../languageHandler'; import React from 'react'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +@replaceableComponent("views.auth.AuthFooter") export default class AuthFooter extends React.Component { render() { return ( diff --git a/src/components/views/auth/AuthHeader.js b/src/components/views/auth/AuthHeader.js index 57499e397c..d9bd81adcb 100644 --- a/src/components/views/auth/AuthHeader.js +++ b/src/components/views/auth/AuthHeader.js @@ -18,7 +18,9 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +@replaceableComponent("views.auth.AuthHeader") export default class AuthHeader extends React.Component { static propTypes = { disableLanguageSelector: PropTypes.bool, diff --git a/src/components/views/auth/AuthHeaderLogo.js b/src/components/views/auth/AuthHeaderLogo.js index 9edf149a83..0adf18dc1c 100644 --- a/src/components/views/auth/AuthHeaderLogo.js +++ b/src/components/views/auth/AuthHeaderLogo.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import React from 'react'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +@replaceableComponent("views.auth.AuthHeaderLogo") export default class AuthHeaderLogo extends React.PureComponent { render() { return
      diff --git a/src/components/views/auth/AuthPage.js b/src/components/views/auth/AuthPage.js index 82f7270121..6ba47e5288 100644 --- a/src/components/views/auth/AuthPage.js +++ b/src/components/views/auth/AuthPage.js @@ -18,7 +18,7 @@ limitations under the License. import React from 'react'; import * as sdk from '../../../index'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthPage") export default class AuthPage extends React.PureComponent { diff --git a/src/components/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.js index 5cce93f0b8..bea4f89f53 100644 --- a/src/components/views/auth/CaptchaForm.js +++ b/src/components/views/auth/CaptchaForm.js @@ -14,16 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import CountlyAnalytics from "../../../CountlyAnalytics"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; const DIV_ID = 'mx_recaptcha'; /** * A pure UI component which displays a captcha form. */ +@replaceableComponent("views.auth.CaptchaForm") export default class CaptchaForm extends React.Component { static propTypes = { sitePublicKey: PropTypes.string, @@ -102,6 +104,10 @@ export default class CaptchaForm extends React.Component { console.log("Loaded recaptcha script."); try { this._renderRecaptcha(DIV_ID); + // clear error if re-rendered + this.setState({ + errorText: null, + }); CountlyAnalytics.instance.track("onboarding_grecaptcha_loaded"); } catch (e) { this.setState({ diff --git a/src/components/views/auth/CompleteSecurityBody.js b/src/components/views/auth/CompleteSecurityBody.js index d757de9fe0..745d7abbf2 100644 --- a/src/components/views/auth/CompleteSecurityBody.js +++ b/src/components/views/auth/CompleteSecurityBody.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import React from 'react'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +@replaceableComponent("views.auth.CompleteSecurityBody") export default class CompleteSecurityBody extends React.PureComponent { render() { return
      diff --git a/src/components/views/auth/CountryDropdown.js b/src/components/views/auth/CountryDropdown.js index 37b1967c48..cbc19e0f8d 100644 --- a/src/components/views/auth/CountryDropdown.js +++ b/src/components/views/auth/CountryDropdown.js @@ -19,9 +19,10 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; -import {COUNTRIES, getEmojiFlag} from '../../../phonenumber'; +import { COUNTRIES, getEmojiFlag } from '../../../phonenumber'; import SdkConfig from "../../../SdkConfig"; import { _t } from "../../../languageHandler"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; const COUNTRIES_BY_ISO2 = {}; for (const c of COUNTRIES) { @@ -40,6 +41,7 @@ function countryMatchesSearchQuery(query, country) { return false; } +@replaceableComponent("views.auth.CountryDropdown") export default class CountryDropdown extends React.Component { constructor(props) { super(props); @@ -123,7 +125,7 @@ export default class CountryDropdown extends React.Component { const options = displayedCountries.map((country) => { return
      { this._flagImgForIso2(country.iso2) } - { country.name } (+{ country.prefix }) + { _t(country.name) } (+{ country.prefix })
      ; }); diff --git a/src/components/views/auth/CustomServerDialog.js b/src/components/views/auth/CustomServerDialog.js deleted file mode 100644 index 138f8c4689..0000000000 --- a/src/components/views/auth/CustomServerDialog.js +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import { _t } from '../../../languageHandler'; -import SdkConfig from '../../../SdkConfig'; - -export default class CustomServerDialog extends React.Component { - render() { - const brand = SdkConfig.get().brand; - return ( -
      -
      - { _t("Custom Server Options") } -
      -
      -

      {_t( - "You can use the custom server options to sign into other " + - "Matrix servers by specifying a different homeserver URL. This " + - "allows you to use %(brand)s with an existing Matrix account on a " + - "different homeserver.", - { brand }, - )}

      -
      -
      - -
      -
      - ); - } -} diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.tsx similarity index 63% rename from src/components/views/auth/InteractiveAuthEntryComponents.js rename to src/components/views/auth/InteractiveAuthEntryComponents.tsx index f49e6959fb..d9af2c2b77 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2016-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. @@ -16,17 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; -import PropTypes from 'prop-types'; -import url from 'url'; -import classnames from 'classnames'; +import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react'; +import classNames from 'classnames'; +import { MatrixClient } from "matrix-js-sdk/src/client"; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; import Spinner from "../elements/Spinner"; import CountlyAnalytics from "../../../CountlyAnalytics"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { LocalisedPolicy, Policies } from '../../../Terms'; +import Field from '../elements/Field'; +import CaptchaForm from "./CaptchaForm"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -41,7 +41,7 @@ import CountlyAnalytics from "../../../CountlyAnalytics"; * one HS whilst beign a guest on another). * loginType: the login type of the auth stage being attempted * authSessionId: session id from the server - * clientSecret: The client secret in use for ID server auth sessions + * clientSecret: The client secret in use for identity server auth sessions * stageParams: params from the server for the stage being attempted * errorText: error message from a previous attempt to authenticate * submitAuthDict: a function which will be called with the new auth dict @@ -54,8 +54,8 @@ import CountlyAnalytics from "../../../CountlyAnalytics"; * Defined keys for stages are: * m.login.email.identity: * * emailSid: string representing the sid of the active - * verification session from the ID server, or - * null if no session is active. + * verification session from the identity server, + * or null if no session is active. * fail: a function which should be called with an error object if an * error occurred during the auth stage. This will cause the auth * session to be failed and the process to go back to the start. @@ -74,35 +74,72 @@ import CountlyAnalytics from "../../../CountlyAnalytics"; * focus: set the input focus appropriately in the form. */ +enum AuthType { + Password = "m.login.password", + Recaptcha = "m.login.recaptcha", + Terms = "m.login.terms", + Email = "m.login.email.identity", + Msisdn = "m.login.msisdn", + Sso = "m.login.sso", + SsoUnstable = "org.matrix.login.sso", +} + +/* eslint-disable camelcase */ +interface IAuthDict { + type?: AuthType; + // TODO: Remove `user` once servers support proper UIA + // See https://github.com/vector-im/element-web/issues/10312 + user?: string; + identifier?: any; + password?: string; + response?: string; + // TODO: Remove `threepid_creds` once servers support proper UIA + // See https://github.com/vector-im/element-web/issues/10312 + // See https://github.com/matrix-org/matrix-doc/issues/2220 + threepid_creds?: any; + threepidCreds?: any; +} +/* eslint-enable camelcase */ + export const DEFAULT_PHASE = 0; -export class PasswordAuthEntry extends React.Component { - static LOGIN_TYPE = "m.login.password"; +interface IAuthEntryProps { + matrixClient: MatrixClient; + loginType: string; + authSessionId: string; + errorText?: string; + // Is the auth logic currently waiting for something to happen? + busy?: boolean; + onPhaseChange: (phase: number) => void; + submitAuthDict: (auth: IAuthDict) => void; +} - static propTypes = { - matrixClient: PropTypes.object.isRequired, - submitAuthDict: PropTypes.func.isRequired, - errorText: PropTypes.string, - // is the auth logic currently waiting for something to - // happen? - busy: PropTypes.bool, - onPhaseChange: PropTypes.func.isRequired, - }; +interface IPasswordAuthEntryState { + password: string; +} + +@replaceableComponent("views.auth.PasswordAuthEntry") +export class PasswordAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Password; + + constructor(props) { + super(props); + + this.state = { + password: "", + }; + } componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); } - state = { - password: "", - }; - - _onSubmit = e => { + private onSubmit = (e: FormEvent) => { e.preventDefault(); if (this.props.busy) return; this.props.submitAuthDict({ - type: PasswordAuthEntry.LOGIN_TYPE, + type: AuthType.Password, // TODO: Remove `user` once servers support proper UIA // See https://github.com/vector-im/element-web/issues/10312 user: this.props.matrixClient.credentials.userId, @@ -114,7 +151,7 @@ export class PasswordAuthEntry extends React.Component { }); }; - _onPasswordFieldChange = ev => { + private onPasswordFieldChange = (ev: ChangeEvent) => { // enable the submit button iff the password is non-empty this.setState({ password: ev.target.value, @@ -122,14 +159,13 @@ export class PasswordAuthEntry extends React.Component { }; render() { - const passwordBoxClass = classnames({ + const passwordBoxClass = classNames({ "error": this.props.errorText, }); let submitButtonOrSpinner; if (this.props.busy) { - const Loader = sdk.getComponent("elements.Spinner"); - submitButtonOrSpinner = ; + submitButtonOrSpinner = ; } else { submitButtonOrSpinner = (

      { _t("Confirm your identity by entering your account password below.") }

      - +
      { submitButtonOrSpinner }
      - { errorSection } + { errorSection }
      ); } } -export class RecaptchaAuthEntry extends React.Component { - static LOGIN_TYPE = "m.login.recaptcha"; - - static propTypes = { - submitAuthDict: PropTypes.func.isRequired, - stageParams: PropTypes.object.isRequired, - errorText: PropTypes.string, - busy: PropTypes.bool, - onPhaseChange: PropTypes.func.isRequired, +/* eslint-disable camelcase */ +interface IRecaptchaAuthEntryProps extends IAuthEntryProps { + stageParams?: { + public_key?: string; }; +} +/* eslint-enable camelcase */ + +@replaceableComponent("views.auth.RecaptchaAuthEntry") +export class RecaptchaAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Recaptcha; componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); } - _onCaptchaResponse = response => { + private onCaptchaResponse = (response: string) => { CountlyAnalytics.instance.track("onboarding_grecaptcha_submit"); this.props.submitAuthDict({ - type: RecaptchaAuthEntry.LOGIN_TYPE, + type: AuthType.Recaptcha, response: response, }); }; render() { if (this.props.busy) { - const Loader = sdk.getComponent("elements.Spinner"); - return ; + return ; } let errorText = this.props.errorText; - const CaptchaForm = sdk.getComponent("views.auth.CaptchaForm"); let sitePublicKey; if (!this.props.stageParams || !this.props.stageParams.public_key) { errorText = _t( @@ -228,7 +261,7 @@ export class RecaptchaAuthEntry extends React.Component { return (
      { errorSection }
      @@ -236,17 +269,28 @@ export class RecaptchaAuthEntry extends React.Component { } } -export class TermsAuthEntry extends React.Component { - static LOGIN_TYPE = "m.login.terms"; - - static propTypes = { - submitAuthDict: PropTypes.func.isRequired, - stageParams: PropTypes.object.isRequired, - errorText: PropTypes.string, - busy: PropTypes.bool, - showContinue: PropTypes.bool, - onPhaseChange: PropTypes.func.isRequired, +interface ITermsAuthEntryProps extends IAuthEntryProps { + stageParams?: { + policies?: Policies; }; + showContinue: boolean; +} + +interface LocalisedPolicyWithId extends LocalisedPolicy { + id: string; +} + +interface ITermsAuthEntryState { + policies: LocalisedPolicyWithId[]; + toggledPolicies: { + [policy: string]: boolean; + }; + errorText?: string; +} + +@replaceableComponent("views.auth.TermsAuthEntry") +export class TermsAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Terms; constructor(props) { super(props); @@ -291,8 +335,11 @@ export class TermsAuthEntry extends React.Component { initToggles[policyId] = false; - langPolicy.id = policyId; - pickedPolicies.push(langPolicy); + pickedPolicies.push({ + id: policyId, + name: langPolicy.name, + url: langPolicy.url, + }); } this.state = { @@ -303,16 +350,15 @@ export class TermsAuthEntry extends React.Component { CountlyAnalytics.instance.track("onboarding_terms_begin"); } - componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); } - tryContinue = () => { - this._trySubmit(); + public tryContinue = () => { + this.trySubmit(); }; - _togglePolicy(policyId) { + private togglePolicy(policyId: string) { const newToggles = {}; for (const policy of this.state.policies) { let checked = this.state.toggledPolicies[policy.id]; @@ -320,10 +366,10 @@ export class TermsAuthEntry extends React.Component { newToggles[policy.id] = checked; } - this.setState({"toggledPolicies": newToggles}); + this.setState({ "toggledPolicies": newToggles }); } - _trySubmit = () => { + private trySubmit = () => { let allChecked = true; for (const policy of this.state.policies) { const checked = this.state.toggledPolicies[policy.id]; @@ -331,17 +377,16 @@ export class TermsAuthEntry extends React.Component { } if (allChecked) { - this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE}); + this.props.submitAuthDict({ type: AuthType.Terms }); CountlyAnalytics.instance.track("onboarding_terms_complete"); } else { - this.setState({errorText: _t("Please review and accept all of the homeserver's policies")}); + this.setState({ errorText: _t("Please review and accept all of the homeserver's policies") }); } }; render() { if (this.props.busy) { - const Loader = sdk.getComponent("elements.Spinner"); - return ; + return ; } const checkboxes = []; @@ -353,7 +398,7 @@ export class TermsAuthEntry extends React.Component { checkboxes.push( // XXX: replace with StyledCheckbox , ); @@ -372,7 +417,7 @@ export class TermsAuthEntry extends React.Component { if (this.props.showContinue !== false) { // XXX: button classes submitButton = ; + onClick={this.trySubmit} disabled={!allChecked}>{_t("Accept")}; } return ( @@ -386,20 +431,18 @@ export class TermsAuthEntry extends React.Component { } } -export class EmailIdentityAuthEntry extends React.Component { - static LOGIN_TYPE = "m.login.email.identity"; - - static propTypes = { - matrixClient: PropTypes.object.isRequired, - submitAuthDict: PropTypes.func.isRequired, - authSessionId: PropTypes.string.isRequired, - clientSecret: PropTypes.string.isRequired, - inputs: PropTypes.object.isRequired, - stageState: PropTypes.object.isRequired, - fail: PropTypes.func.isRequired, - setEmailSid: PropTypes.func.isRequired, - onPhaseChange: PropTypes.func.isRequired, +interface IEmailIdentityAuthEntryProps extends IAuthEntryProps { + inputs?: { + emailAddress?: string; }; + stageState?: { + emailSid: string; + }; +} + +@replaceableComponent("views.auth.EmailIdentityAuthEntry") +export class EmailIdentityAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Email; componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); @@ -421,77 +464,85 @@ export class EmailIdentityAuthEntry extends React.Component { return ; } else { return ( -
      -

      { _t("An email has been sent to %(emailAddress)s", - { emailAddress: (sub) => { this.props.inputs.emailAddress } }, +

      +

      { _t("A confirmation email has been sent to %(emailAddress)s", + { emailAddress: { this.props.inputs.emailAddress } }, ) }

      -

      { _t("Please check your email to continue registration.") }

      +

      { _t("Open the link in the email to continue registration.") }

      ); } } } -export class MsisdnAuthEntry extends React.Component { - static LOGIN_TYPE = "m.login.msisdn"; - - static propTypes = { - inputs: PropTypes.shape({ - phoneCountry: PropTypes.string, - phoneNumber: PropTypes.string, - }), - fail: PropTypes.func, - clientSecret: PropTypes.func, - submitAuthDict: PropTypes.func.isRequired, - matrixClient: PropTypes.object, - onPhaseChange: PropTypes.func.isRequired, +interface IMsisdnAuthEntryProps extends IAuthEntryProps { + inputs: { + phoneCountry: string; + phoneNumber: string; }; + clientSecret: string; + fail: (error: Error) => void; +} - state = { - token: '', - requestingToken: false, - }; +interface IMsisdnAuthEntryState { + token: string; + requestingToken: boolean; + errorText: string; +} + +@replaceableComponent("views.auth.MsisdnAuthEntry") +export class MsisdnAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Msisdn; + + private submitUrl: string; + private sid: string; + private msisdn: string; + + constructor(props) { + super(props); + + this.state = { + token: '', + requestingToken: false, + errorText: '', + }; + } componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); - this._submitUrl = null; - this._sid = null; - this._msisdn = null; - this._tokenBox = null; - - this.setState({requestingToken: true}); - this._requestMsisdnToken().catch((e) => { + this.setState({ requestingToken: true }); + this.requestMsisdnToken().catch((e) => { this.props.fail(e); }).finally(() => { - this.setState({requestingToken: false}); + this.setState({ requestingToken: false }); }); } /* * Requests a verification token by SMS. */ - _requestMsisdnToken() { + private requestMsisdnToken(): Promise { return this.props.matrixClient.requestRegisterMsisdnToken( this.props.inputs.phoneCountry, this.props.inputs.phoneNumber, this.props.clientSecret, 1, // TODO: Multiple send attempts? ).then((result) => { - this._submitUrl = result.submit_url; - this._sid = result.sid; - this._msisdn = result.msisdn; + this.submitUrl = result.submit_url; + this.sid = result.sid; + this.msisdn = result.msisdn; }); } - _onTokenChange = e => { + private onTokenChange = (e: ChangeEvent) => { this.setState({ token: e.target.value, }); }; - _onFormSubmit = async e => { + private onFormSubmit = async (e: FormEvent) => { e.preventDefault(); if (this.state.token == '') return; @@ -500,33 +551,21 @@ export class MsisdnAuthEntry extends React.Component { }); try { - const requiresIdServerParam = - await this.props.matrixClient.doesServerRequireIdServerParam(); let result; - if (this._submitUrl) { + if (this.submitUrl) { result = await this.props.matrixClient.submitMsisdnTokenOtherUrl( - this._submitUrl, this._sid, this.props.clientSecret, this.state.token, - ); - } else if (requiresIdServerParam) { - result = await this.props.matrixClient.submitMsisdnToken( - this._sid, this.props.clientSecret, this.state.token, + this.submitUrl, this.sid, this.props.clientSecret, this.state.token, ); } else { throw new Error("The registration with MSISDN flow is misconfigured"); } if (result.success) { const creds = { - sid: this._sid, + sid: this.sid, client_secret: this.props.clientSecret, }; - if (requiresIdServerParam) { - const idServerParsedUrl = url.parse( - this.props.matrixClient.getIdentityServerUrl(), - ); - creds.id_server = idServerParsedUrl.host; - } this.props.submitAuthDict({ - type: MsisdnAuthEntry.LOGIN_TYPE, + type: AuthType.Msisdn, // TODO: Remove `threepid_creds` once servers support proper UIA // See https://github.com/vector-im/element-web/issues/10312 // See https://github.com/matrix-org/matrix-doc/issues/2220 @@ -546,11 +585,10 @@ export class MsisdnAuthEntry extends React.Component { render() { if (this.state.requestingToken) { - const Loader = sdk.getComponent("elements.Spinner"); - return ; + return ; } else { const enableSubmit = Boolean(this.state.token); - const submitClasses = classnames({ + const submitClasses = classNames({ mx_InteractiveAuthEntryComponents_msisdnSubmit: true, mx_GeneralButton: true, }); @@ -565,16 +603,16 @@ export class MsisdnAuthEntry extends React.Component { return (

      { _t("A text message has been sent to %(msisdn)s", - { msisdn: { this._msisdn } }, + { msisdn: { this.msisdn } }, ) }

      { _t("Please enter the code it contains:") }

      -
      +
      @@ -591,57 +629,85 @@ export class MsisdnAuthEntry extends React.Component { } } -export class SSOAuthEntry extends React.Component { - static propTypes = { - matrixClient: PropTypes.object.isRequired, - authSessionId: PropTypes.string.isRequired, - loginType: PropTypes.string.isRequired, - submitAuthDict: PropTypes.func.isRequired, - errorText: PropTypes.string, - onPhaseChange: PropTypes.func.isRequired, - continueText: PropTypes.string, - continueKind: PropTypes.string, - onCancel: PropTypes.func, - }; +interface ISSOAuthEntryProps extends IAuthEntryProps { + continueText?: string; + continueKind?: string; + onCancel?: () => void; +} - static LOGIN_TYPE = "m.login.sso"; - static UNSTABLE_LOGIN_TYPE = "org.matrix.login.sso"; +interface ISSOAuthEntryState { + phase: number; + attemptFailed: boolean; +} + +@replaceableComponent("views.auth.SSOAuthEntry") +export class SSOAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Sso; + static UNSTABLE_LOGIN_TYPE = AuthType.SsoUnstable; static PHASE_PREAUTH = 1; // button to start SSO static PHASE_POSTAUTH = 2; // button to confirm SSO completed - _ssoUrl: string; + private ssoUrl: string; + private popupWindow: Window; constructor(props) { super(props); // We actually send the user through fallback auth so we don't have to // deal with a redirect back to us, losing application context. - this._ssoUrl = props.matrixClient.getFallbackAuthUrl( + this.ssoUrl = props.matrixClient.getFallbackAuthUrl( this.props.loginType, this.props.authSessionId, ); + this.popupWindow = null; + window.addEventListener("message", this.onReceiveMessage); + this.state = { phase: SSOAuthEntry.PHASE_PREAUTH, + attemptFailed: false, }; } - componentDidMount(): void { + componentDidMount() { this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH); } - onStartAuthClick = () => { + componentWillUnmount() { + window.removeEventListener("message", this.onReceiveMessage); + if (this.popupWindow) { + this.popupWindow.close(); + this.popupWindow = null; + } + } + + public attemptFailed = () => { + this.setState({ + attemptFailed: true, + }); + }; + + private onReceiveMessage = (event: MessageEvent) => { + if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) { + if (this.popupWindow) { + this.popupWindow.close(); + this.popupWindow = null; + } + } + }; + + private onStartAuthClick = () => { // Note: We don't use PlatformPeg's startSsoAuth functions because we almost // certainly will need to open the thing in a new tab to avoid losing application // context. - window.open(this._ssoUrl, '_blank'); - this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH}); + this.popupWindow = window.open(this.ssoUrl, "_blank"); + this.setState({ phase: SSOAuthEntry.PHASE_POSTAUTH }); this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH); }; - onConfirmClick = () => { + private onConfirmClick = () => { this.props.submitAuthDict({}); }; @@ -669,53 +735,63 @@ export class SSOAuthEntry extends React.Component { ); } - return
      - {cancelButton} - {continueButton} -
      ; + let errorSection; + if (this.props.errorText) { + errorSection = ( +
      + { this.props.errorText } +
      + ); + } else if (this.state.attemptFailed) { + errorSection = ( +
      + { _t("Something went wrong in confirming your identity. Cancel and try again.") } +
      + ); + } + + return + { errorSection } +
      + {cancelButton} + {continueButton} +
      +
      ; } } -export class FallbackAuthEntry extends React.Component { - static propTypes = { - matrixClient: PropTypes.object.isRequired, - authSessionId: PropTypes.string.isRequired, - loginType: PropTypes.string.isRequired, - submitAuthDict: PropTypes.func.isRequired, - errorText: PropTypes.string, - onPhaseChange: PropTypes.func.isRequired, - }; +@replaceableComponent("views.auth.FallbackAuthEntry") +export class FallbackAuthEntry extends React.Component { + private popupWindow: Window; + private fallbackButton = createRef(); constructor(props) { super(props); // we have to make the user click a button, as browsers will block // the popup if we open it immediately. - this._popupWindow = null; - window.addEventListener("message", this._onReceiveMessage); - - this._fallbackButton = createRef(); + this.popupWindow = null; + window.addEventListener("message", this.onReceiveMessage); } - componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); } componentWillUnmount() { - window.removeEventListener("message", this._onReceiveMessage); - if (this._popupWindow) { - this._popupWindow.close(); + window.removeEventListener("message", this.onReceiveMessage); + if (this.popupWindow) { + this.popupWindow.close(); } } - focus = () => { - if (this._fallbackButton.current) { - this._fallbackButton.current.focus(); + public focus = () => { + if (this.fallbackButton.current) { + this.fallbackButton.current.focus(); } }; - _onShowFallbackClick = e => { + private onShowFallbackClick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -723,11 +799,10 @@ export class FallbackAuthEntry extends React.Component { this.props.loginType, this.props.authSessionId, ); - this._popupWindow = window.open(url); - this._popupWindow.opener = null; + this.popupWindow = window.open(url, "_blank"); }; - _onReceiveMessage = event => { + private onReceiveMessage = (event: MessageEvent) => { if ( event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl() @@ -747,27 +822,31 @@ export class FallbackAuthEntry extends React.Component { } return ( ); } } -const AuthEntryComponents = [ - PasswordAuthEntry, - RecaptchaAuthEntry, - EmailIdentityAuthEntry, - MsisdnAuthEntry, - TermsAuthEntry, - SSOAuthEntry, -]; - -export default function getEntryComponentForLoginType(loginType) { - for (const c of AuthEntryComponents) { - if (c.LOGIN_TYPE === loginType || c.UNSTABLE_LOGIN_TYPE === loginType) { - return c; - } +export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component { + switch (loginType) { + case AuthType.Password: + return PasswordAuthEntry; + case AuthType.Recaptcha: + return RecaptchaAuthEntry; + case AuthType.Email: + return EmailIdentityAuthEntry; + case AuthType.Msisdn: + return MsisdnAuthEntry; + case AuthType.Terms: + return TermsAuthEntry; + case AuthType.Sso: + case AuthType.SsoUnstable: + return SSOAuthEntry; + default: + return FallbackAuthEntry; } - return FallbackAuthEntry; } diff --git a/src/components/views/auth/LanguageSelector.js b/src/components/views/auth/LanguageSelector.js index 0738ee43e4..88293310e7 100644 --- a/src/components/views/auth/LanguageSelector.js +++ b/src/components/views/auth/LanguageSelector.js @@ -15,12 +15,12 @@ limitations under the License. */ import SdkConfig from "../../../SdkConfig"; -import {getCurrentLanguage} from "../../../languageHandler"; +import { getCurrentLanguage } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import PlatformPeg from "../../../PlatformPeg"; import * as sdk from '../../../index'; import React from 'react'; -import {SettingLevel} from "../../../settings/SettingLevel"; +import { SettingLevel } from "../../../settings/SettingLevel"; function onChange(newLang) { if (getCurrentLanguage() !== newLang) { @@ -29,7 +29,7 @@ function onChange(newLang) { } } -export default function LanguageSelector({disabled}) { +export default function LanguageSelector({ disabled }) { if (SdkConfig.get()['disable_login_language_selector']) return
      ; const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown'); diff --git a/src/components/views/auth/ModularServerConfig.js b/src/components/views/auth/ModularServerConfig.js deleted file mode 100644 index 28fd16379d..0000000000 --- a/src/components/views/auth/ModularServerConfig.js +++ /dev/null @@ -1,124 +0,0 @@ -/* -Copyright 2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import SdkConfig from "../../../SdkConfig"; -import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; -import * as ServerType from '../../views/auth/ServerTypeSelector'; -import ServerConfig from "./ServerConfig"; - -const MODULAR_URL = 'https://element.io/matrix-services' + - '?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication'; - -// TODO: TravisR - Can this extend ServerConfig for most things? - -/* - * Configure the Modular server name. - * - * This is a variant of ServerConfig with only the HS field and different body - * text that is specific to the Modular case. - */ -export default class ModularServerConfig extends ServerConfig { - static propTypes = ServerConfig.propTypes; - - async validateAndApplyServer(hsUrl, isUrl) { - // Always try and use the defaults first - const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; - if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) { - this.setState({busy: false, errorText: ""}); - this.props.onServerConfigChange(defaultConfig); - return defaultConfig; - } - - this.setState({ - hsUrl, - isUrl, - busy: true, - errorText: "", - }); - - try { - const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); - this.setState({busy: false, errorText: ""}); - this.props.onServerConfigChange(result); - return result; - } catch (e) { - console.error(e); - let message = _t("Unable to validate homeserver/identity server"); - if (e.translatedMessage) { - message = e.translatedMessage; - } - this.setState({ - busy: false, - errorText: message, - }); - - return null; - } - } - - async validateServer() { - // TODO: Do we want to support .well-known lookups here? - // If for some reason someone enters "matrix.org" for a URL, we could do a lookup to - // find their homeserver without demanding they use "https://matrix.org" - return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl); - } - - render() { - const Field = sdk.getComponent('elements.Field'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - - const submitButton = this.props.submitText - ? {this.props.submitText} - : null; - - return ( -
      -

      {_t("Your server")}

      - {_t( - "Enter the location of your Element Matrix Services homeserver. It may use your own " + - "domain name or be a subdomain of element.io.", - {}, { - a: sub => - {sub} - , - }, - )} - -
      - -
      - {submitButton} - -
      - ); - } -} diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index b420ed0872..bab7e59d2a 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -14,16 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {PureComponent, RefCallback, RefObject} from "react"; +import React, { PureComponent, RefCallback, RefObject } from "react"; import classNames from "classnames"; import zxcvbn from "zxcvbn"; import SdkConfig from "../../../SdkConfig"; -import withValidation, {IFieldState, IValidationResult} from "../elements/Validation"; -import {_t, _td} from "../../../languageHandler"; -import Field from "../elements/Field"; +import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; +import { _t, _td } from "../../../languageHandler"; +import Field, { IInputProps } from "../elements/Field"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; -interface IProps { +interface IProps extends Omit { autoFocus?: boolean; id?: string; className?: string; @@ -40,6 +41,7 @@ interface IProps { onValidate(result: IValidationResult); } +@replaceableComponent("views.auth.PassphraseField") class PassphraseField extends PureComponent { static defaultProps = { label: _td("Password"), diff --git a/src/components/views/auth/PasswordLogin.js b/src/components/views/auth/PasswordLogin.js deleted file mode 100644 index 405f9051b9..0000000000 --- a/src/components/views/auth/PasswordLogin.js +++ /dev/null @@ -1,377 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2019 New Vector Ltd. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import SdkConfig from '../../../SdkConfig'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import AccessibleButton from "../elements/AccessibleButton"; -import CountlyAnalytics from "../../../CountlyAnalytics"; - -/** - * A pure UI component which displays a username/password form. - */ -export default class PasswordLogin extends React.Component { - static propTypes = { - onSubmit: PropTypes.func.isRequired, // fn(username, password) - onError: PropTypes.func, - onEditServerDetailsClick: PropTypes.func, - onForgotPasswordClick: PropTypes.func, // fn() - initialUsername: PropTypes.string, - initialPhoneCountry: PropTypes.string, - initialPhoneNumber: PropTypes.string, - initialPassword: PropTypes.string, - onUsernameChanged: PropTypes.func, - onPhoneCountryChanged: PropTypes.func, - onPhoneNumberChanged: PropTypes.func, - onPasswordChanged: PropTypes.func, - loginIncorrect: PropTypes.bool, - disableSubmit: PropTypes.bool, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - busy: PropTypes.bool, - }; - - static defaultProps = { - onError: function() {}, - onEditServerDetailsClick: null, - onUsernameChanged: function() {}, - onUsernameBlur: function() {}, - onPasswordChanged: function() {}, - onPhoneCountryChanged: function() {}, - onPhoneNumberChanged: function() {}, - onPhoneNumberBlur: function() {}, - initialUsername: "", - initialPhoneCountry: "", - initialPhoneNumber: "", - initialPassword: "", - loginIncorrect: false, - disableSubmit: false, - }; - - static LOGIN_FIELD_EMAIL = "login_field_email"; - static LOGIN_FIELD_MXID = "login_field_mxid"; - static LOGIN_FIELD_PHONE = "login_field_phone"; - - constructor(props) { - super(props); - this.state = { - username: this.props.initialUsername, - password: this.props.initialPassword, - phoneCountry: this.props.initialPhoneCountry, - phoneNumber: this.props.initialPhoneNumber, - loginType: PasswordLogin.LOGIN_FIELD_MXID, - }; - - this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this); - this.onSubmitForm = this.onSubmitForm.bind(this); - this.onUsernameChanged = this.onUsernameChanged.bind(this); - this.onUsernameBlur = this.onUsernameBlur.bind(this); - this.onLoginTypeChange = this.onLoginTypeChange.bind(this); - this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this); - this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this); - this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this); - this.onPasswordChanged = this.onPasswordChanged.bind(this); - this.isLoginEmpty = this.isLoginEmpty.bind(this); - } - - onForgotPasswordClick(ev) { - ev.preventDefault(); - ev.stopPropagation(); - this.props.onForgotPasswordClick(); - } - - onSubmitForm(ev) { - ev.preventDefault(); - - let username = ''; // XXX: Synapse breaks if you send null here: - let phoneCountry = null; - let phoneNumber = null; - let error; - - switch (this.state.loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - username = this.state.username; - if (!username) { - error = _t('The email field must not be blank.'); - } - break; - case PasswordLogin.LOGIN_FIELD_MXID: - username = this.state.username; - if (!username) { - error = _t('The username field must not be blank.'); - } - break; - case PasswordLogin.LOGIN_FIELD_PHONE: - phoneCountry = this.state.phoneCountry; - phoneNumber = this.state.phoneNumber; - if (!phoneNumber) { - error = _t('The phone number field must not be blank.'); - } - break; - } - - if (error) { - this.props.onError(error); - return; - } - - if (!this.state.password) { - this.props.onError(_t('The password field must not be blank.')); - return; - } - - this.props.onSubmit( - username, - phoneCountry, - phoneNumber, - this.state.password, - ); - } - - onUsernameChanged(ev) { - this.setState({username: ev.target.value}); - this.props.onUsernameChanged(ev.target.value); - } - - onUsernameFocus() { - if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) { - CountlyAnalytics.instance.track("onboarding_login_mxid_focus"); - } else { - CountlyAnalytics.instance.track("onboarding_login_email_focus"); - } - } - - onUsernameBlur(ev) { - if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) { - CountlyAnalytics.instance.track("onboarding_login_mxid_blur"); - } else { - CountlyAnalytics.instance.track("onboarding_login_email_blur"); - } - this.props.onUsernameBlur(ev.target.value); - } - - onLoginTypeChange(ev) { - const loginType = ev.target.value; - this.props.onError(null); // send a null error to clear any error messages - this.setState({ - loginType: loginType, - username: "", // Reset because email and username use the same state - }); - CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType }); - } - - onPhoneCountryChanged(country) { - this.setState({ - phoneCountry: country.iso2, - phonePrefix: country.prefix, - }); - this.props.onPhoneCountryChanged(country.iso2); - } - - onPhoneNumberChanged(ev) { - this.setState({phoneNumber: ev.target.value}); - this.props.onPhoneNumberChanged(ev.target.value); - } - - onPhoneNumberFocus() { - CountlyAnalytics.instance.track("onboarding_login_phone_number_focus"); - } - - onPhoneNumberBlur(ev) { - this.props.onPhoneNumberBlur(ev.target.value); - CountlyAnalytics.instance.track("onboarding_login_phone_number_blur"); - } - - onPasswordChanged(ev) { - this.setState({password: ev.target.value}); - this.props.onPasswordChanged(ev.target.value); - } - - renderLoginField(loginType, autoFocus) { - const Field = sdk.getComponent('elements.Field'); - - const classes = {}; - - switch (loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - classes.error = this.props.loginIncorrect && !this.state.username; - return ; - case PasswordLogin.LOGIN_FIELD_MXID: - classes.error = this.props.loginIncorrect && !this.state.username; - return ; - case PasswordLogin.LOGIN_FIELD_PHONE: { - const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); - classes.error = this.props.loginIncorrect && !this.state.phoneNumber; - - const phoneCountry = ; - - return ; - } - } - } - - isLoginEmpty() { - switch (this.state.loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - case PasswordLogin.LOGIN_FIELD_MXID: - return !this.state.username; - case PasswordLogin.LOGIN_FIELD_PHONE: - return !this.state.phoneCountry || !this.state.phoneNumber; - } - } - - render() { - const Field = sdk.getComponent('elements.Field'); - const SignInToText = sdk.getComponent('views.auth.SignInToText'); - - let forgotPasswordJsx; - - if (this.props.onForgotPasswordClick) { - forgotPasswordJsx = - {_t('Not sure of your password? Set a new one', {}, { - a: sub => ( - - {sub} - - ), - })} - ; - } - - const pwFieldClass = classNames({ - error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field - }); - - // If login is empty, autoFocus login, otherwise autoFocus password. - // this is for when auto server discovery remounts us when the user tries to tab from username to password - const autoFocusPassword = !this.isLoginEmpty(); - const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword); - - let loginType; - if (!SdkConfig.get().disable_3pid_login) { - loginType = ( -
      - - - - - - -
      - ); - } - - return ( -
      - -
      - {loginType} - {loginField} - - {forgotPasswordJsx} - { !this.props.busy && } - -
      - ); - } -} diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx new file mode 100644 index 0000000000..a77dd0b683 --- /dev/null +++ b/src/components/views/auth/PasswordLogin.tsx @@ -0,0 +1,487 @@ +/* +Copyright 2015, 2016, 2017, 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import classNames from 'classnames'; + +import { _t } from '../../../languageHandler'; +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 Field from "../elements/Field"; +import CountryDropdown from "./CountryDropdown"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +// For validating phone numbers without country codes +const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; + +interface IProps { + username: string; // also used for email address + phoneCountry: string; + phoneNumber: string; + + serverConfig: ValidatedServerConfig; + loginIncorrect?: boolean; + disableSubmit?: boolean; + busy?: boolean; + + onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void; + onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void; + onUsernameChanged?(username: string): void; + onUsernameBlur?(username: string): void; + onPhoneCountryChanged?(phoneCountry: string): void; + onPhoneNumberChanged?(phoneNumber: string): void; + onForgotPasswordClick?(): void; +} + +interface IState { + fieldValid: Partial>; + loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone; + password: ""; +} + +enum LoginField { + Email = "login_field_email", + MatrixId = "login_field_mxid", + Phone = "login_field_phone", + Password = "login_field_phone", +} + +/* + * A pure UI component which displays a username/password form. + * The email/username/phone fields are fully-controlled, the password field is not. + */ +@replaceableComponent("views.auth.PasswordLogin") +export default class PasswordLogin extends React.PureComponent { + static defaultProps = { + onUsernameChanged: function() {}, + onUsernameBlur: function() {}, + onPhoneCountryChanged: function() {}, + onPhoneNumberChanged: function() {}, + loginIncorrect: false, + disableSubmit: false, + }; + + constructor(props) { + super(props); + this.state = { + // Field error codes by field ID + fieldValid: {}, + loginType: LoginField.MatrixId, + password: "", + }; + } + + private onForgotPasswordClick = ev => { + ev.preventDefault(); + ev.stopPropagation(); + this.props.onForgotPasswordClick(); + }; + + private onSubmitForm = async ev => { + ev.preventDefault(); + + const allFieldsValid = await this.verifyFieldsBeforeSubmit(); + if (!allFieldsValid) { + CountlyAnalytics.instance.track("onboarding_registration_submit_failed"); + return; + } + + let username = ''; // XXX: Synapse breaks if you send null here: + let phoneCountry = null; + let phoneNumber = null; + + switch (this.state.loginType) { + case LoginField.Email: + case LoginField.MatrixId: + username = this.props.username; + break; + case LoginField.Phone: + phoneCountry = this.props.phoneCountry; + phoneNumber = this.props.phoneNumber; + break; + } + + this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password); + }; + + private onUsernameChanged = ev => { + this.props.onUsernameChanged(ev.target.value); + }; + + private onUsernameFocus = () => { + if (this.state.loginType === LoginField.MatrixId) { + CountlyAnalytics.instance.track("onboarding_login_mxid_focus"); + } else { + CountlyAnalytics.instance.track("onboarding_login_email_focus"); + } + }; + + private onUsernameBlur = ev => { + if (this.state.loginType === LoginField.MatrixId) { + CountlyAnalytics.instance.track("onboarding_login_mxid_blur"); + } else { + CountlyAnalytics.instance.track("onboarding_login_email_blur"); + } + this.props.onUsernameBlur(ev.target.value); + }; + + private onLoginTypeChange = ev => { + const loginType = ev.target.value; + this.setState({ loginType }); + this.props.onUsernameChanged(""); // Reset because email and username use the same state + CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType }); + }; + + private onPhoneCountryChanged = country => { + this.props.onPhoneCountryChanged(country.iso2); + }; + + private onPhoneNumberChanged = ev => { + this.props.onPhoneNumberChanged(ev.target.value); + }; + + private onPhoneNumberFocus = () => { + CountlyAnalytics.instance.track("onboarding_login_phone_number_focus"); + }; + + private onPhoneNumberBlur = ev => { + CountlyAnalytics.instance.track("onboarding_login_phone_number_blur"); + }; + + private onPasswordChanged = ev => { + this.setState({ password: ev.target.value }); + }; + + private async verifyFieldsBeforeSubmit() { + // Blur the active element if any, so we first run its blur validation, + // which is less strict than the pass we're about to do below for all fields. + const activeElement = document.activeElement as HTMLElement; + if (activeElement) { + activeElement.blur(); + } + + const fieldIDsInDisplayOrder = [ + this.state.loginType, + LoginField.Password, + ]; + + // Run all fields with stricter validation that no longer allows empty + // values for required fields. + for (const fieldID of fieldIDsInDisplayOrder) { + const field = this[fieldID]; + if (!field) { + continue; + } + // We must wait for these validations to finish before queueing + // up the setState below so our setState goes in the queue after + // all the setStates from these validate calls (that's how we + // know they've finished). + await field.validate({ allowEmpty: false }); + } + + // Validation and state updates are async, so we need to wait for them to complete + // first. Queue a `setState` callback and wait for it to resolve. + await new Promise(resolve => this.setState({}, resolve)); + + if (this.allFieldsValid()) { + return true; + } + + const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder); + + if (!invalidField) { + return true; + } + + // Focus the first invalid field and show feedback in the stricter mode + // that no longer allows empty values for required fields. + invalidField.focus(); + invalidField.validate({ allowEmpty: false, focused: true }); + return false; + } + + private allFieldsValid() { + const keys = Object.keys(this.state.fieldValid); + for (let i = 0; i < keys.length; ++i) { + if (!this.state.fieldValid[keys[i]]) { + return false; + } + } + return true; + } + + private findFirstInvalidField(fieldIDs: LoginField[]) { + for (const fieldID of fieldIDs) { + if (!this.state.fieldValid[fieldID] && this[fieldID]) { + return this[fieldID]; + } + } + return null; + } + + private markFieldValid(fieldID: LoginField, valid: boolean) { + const { fieldValid } = this.state; + fieldValid[fieldID] = valid; + this.setState({ + fieldValid, + }); + } + + private validateUsernameRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter username"), + }, + ], + }); + + private onUsernameValidate = async (fieldState) => { + const result = await this.validateUsernameRules(fieldState); + this.markFieldValid(LoginField.MatrixId, result.valid); + 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); + this.markFieldValid(LoginField.Email, result.valid); + return result; + }; + + private validatePhoneNumberRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter phone number"), + }, { + key: "number", + test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value), + invalid: () => _t("That phone number doesn't look quite right, please check and try again"), + }, + ], + }); + + private onPhoneNumberValidate = async (fieldState) => { + const result = await this.validatePhoneNumberRules(fieldState); + this.markFieldValid(LoginField.Password, result.valid); + return result; + }; + + private validatePasswordRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter password"), + }, + ], + }); + + private onPasswordValidate = async (fieldState) => { + const result = await this.validatePasswordRules(fieldState); + this.markFieldValid(LoginField.Password, result.valid); + return result; + }; + + private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) { + const classes = { + error: false, + }; + + switch (loginType) { + case LoginField.Email: + classes.error = this.props.loginIncorrect && !this.props.username; + return this[LoginField.Email] = field} + />; + case LoginField.MatrixId: + classes.error = this.props.loginIncorrect && !this.props.username; + return this[LoginField.MatrixId] = field} + />; + case LoginField.Phone: { + classes.error = this.props.loginIncorrect && !this.props.phoneNumber; + + const phoneCountry = ; + + return this[LoginField.Password] = field} + />; + } + } + } + + private isLoginEmpty() { + switch (this.state.loginType) { + case LoginField.Email: + case LoginField.MatrixId: + return !this.props.username; + case LoginField.Phone: + return !this.props.phoneCountry || !this.props.phoneNumber; + } + } + + render() { + let forgotPasswordJsx; + + if (this.props.onForgotPasswordClick) { + forgotPasswordJsx = + {_t("Forgot password?")} + ; + } + + const pwFieldClass = classNames({ + error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field + }); + + // If login is empty, autoFocus login, otherwise autoFocus password. + // this is for when auto server discovery remounts us when the user tries to tab from username to password + const autoFocusPassword = !this.isLoginEmpty(); + const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword); + + let loginType; + if (!SdkConfig.get().disable_3pid_login) { + loginType = ( +
      + + + + + + +
      + ); + } + + return ( +
      +
      + {loginType} + {loginField} + this[LoginField.Password] = field} + /> + {forgotPasswordJsx} + { !this.props.busy && } + +
      + ); + } +} diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.tsx similarity index 60% rename from src/components/views/auth/RegistrationForm.js rename to src/components/views/auth/RegistrationForm.tsx index db7d1df994..25ea347d24 100644 --- a/src/components/views/auth/RegistrationForm.js +++ b/src/components/views/auth/RegistrationForm.tsx @@ -1,8 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,8 +16,7 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; + import * as Email from '../../../email'; import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; import Modal from '../../../Modal'; @@ -27,37 +24,65 @@ import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import { SAFE_LOCALPART_REGEX } from '../../../Registration'; import withValidation from '../elements/Validation'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import PassphraseField from "./PassphraseField"; import CountlyAnalytics from "../../../CountlyAnalytics"; +import Field from '../elements/Field'; +import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import CountryDropdown from "./CountryDropdown"; -const FIELD_EMAIL = 'field_email'; -const FIELD_PHONE_NUMBER = 'field_phone_number'; -const FIELD_USERNAME = 'field_username'; -const FIELD_PASSWORD = 'field_password'; -const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; +enum RegistrationField { + Email = "field_email", + PhoneNumber = "field_phone_number", + Username = "field_username", + Password = "field_password", + PasswordConfirm = "field_password_confirm", +} -const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. +export const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. + +interface IProps { + // Values pre-filled in the input boxes when the component loads + defaultEmail?: string; + defaultPhoneCountry?: string; + defaultPhoneNumber?: string; + defaultUsername?: string; + defaultPassword?: string; + flows: { + stages: string[]; + }[]; + serverConfig: ValidatedServerConfig; + canSubmit?: boolean; + + onRegisterClick(params: { + username: string; + password: string; + email?: string; + phoneCountry?: string; + phoneNumber?: string; + }): Promise; + onEditServerDetailsClick?(): void; +} + +interface IState { + // Field error codes by field ID + fieldValid: Partial>; + // The ISO2 country code selected in the phone number entry + phoneCountry: string; + username: string; + email: string; + phoneNumber: string; + password: string; + passwordConfirm: string; + passwordComplexity?: number; +} /* * A pure UI component which displays a registration form. */ -export default class RegistrationForm extends React.Component { - static propTypes = { - // Values pre-filled in the input boxes when the component loads - defaultEmail: PropTypes.string, - defaultPhoneCountry: PropTypes.string, - defaultPhoneNumber: PropTypes.string, - defaultUsername: PropTypes.string, - defaultPassword: PropTypes.string, - onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise - onEditServerDetailsClick: PropTypes.func, - flows: PropTypes.arrayOf(PropTypes.object).isRequired, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - canSubmit: PropTypes.bool, - serverRequiresIdServer: PropTypes.bool, - }; - +@replaceableComponent("views.auth.RegistrationForm") +export default class RegistrationForm extends React.PureComponent { static defaultProps = { onValidationChange: console.error, canSubmit: true, @@ -67,9 +92,7 @@ export default class RegistrationForm extends React.Component { super(props); this.state = { - // Field error codes by field ID fieldValid: {}, - // The ISO2 country code selected in the phone number entry phoneCountry: this.props.defaultPhoneCountry, username: this.props.defaultUsername || "", email: this.props.defaultEmail || "", @@ -82,8 +105,9 @@ export default class RegistrationForm extends React.Component { CountlyAnalytics.instance.track("onboarding_registration_begin"); } - onSubmit = async ev => { + private onSubmit = async ev => { ev.preventDefault(); + ev.persist(); if (!this.props.canSubmit) return; @@ -93,46 +117,31 @@ export default class RegistrationForm extends React.Component { return; } - const self = this; if (this.state.email === '') { - const haveIs = Boolean(this.props.serverConfig.isUrl); - - let desc; - if (this.props.serverRequiresIdServer && !haveIs) { - desc = _t( - "No identity server is configured so you cannot add an email address in order to " + - "reset your password in the future.", - ); - } else if (this._showEmail()) { - desc = _t( - "If you don't specify an email address, you won't be able to reset your password. " + - "Are you sure?", - ); + if (this.showEmail()) { + CountlyAnalytics.instance.track("onboarding_registration_submit_warn"); + Modal.createTrackedDialog("Email prompt dialog", '', RegistrationEmailPromptDialog, { + onFinished: async (confirmed: boolean, email?: string) => { + if (confirmed) { + this.setState({ + email, + }, () => { + this.doSubmit(ev); + }); + } + }, + }); } else { // user can't set an e-mail so don't prompt them to - self._doSubmit(ev); + this.doSubmit(ev); return; } - - CountlyAnalytics.instance.track("onboarding_registration_submit_warn"); - - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, { - title: _t("Warning!"), - description: desc, - button: _t("Continue"), - onFinished(confirmed) { - if (confirmed) { - self._doSubmit(ev); - } - }, - }); } else { - self._doSubmit(ev); + this.doSubmit(ev); } }; - _doSubmit(ev) { + private doSubmit(ev) { const email = this.state.email.trim(); CountlyAnalytics.instance.track("onboarding_registration_submit_ok", { @@ -155,20 +164,20 @@ export default class RegistrationForm extends React.Component { } } - async verifyFieldsBeforeSubmit() { + private async verifyFieldsBeforeSubmit() { // Blur the active element if any, so we first run its blur validation, // which is less strict than the pass we're about to do below for all fields. - const activeElement = document.activeElement; + const activeElement = document.activeElement as HTMLElement; if (activeElement) { activeElement.blur(); } const fieldIDsInDisplayOrder = [ - FIELD_USERNAME, - FIELD_PASSWORD, - FIELD_PASSWORD_CONFIRM, - FIELD_EMAIL, - FIELD_PHONE_NUMBER, + RegistrationField.Username, + RegistrationField.Password, + RegistrationField.PasswordConfirm, + RegistrationField.Email, + RegistrationField.PhoneNumber, ]; // Run all fields with stricter validation that no longer allows empty @@ -187,7 +196,7 @@ export default class RegistrationForm extends React.Component { // Validation and state updates are async, so we need to wait for them to complete // first. Queue a `setState` callback and wait for it to resolve. - await new Promise(resolve => this.setState({}, resolve)); + await new Promise(resolve => this.setState({}, resolve)); if (this.allFieldsValid()) { return true; @@ -209,7 +218,7 @@ export default class RegistrationForm extends React.Component { /** * @returns {boolean} true if all fields were valid last time they were validated. */ - allFieldsValid() { + private allFieldsValid() { const keys = Object.keys(this.state.fieldValid); for (let i = 0; i < keys.length; ++i) { if (!this.state.fieldValid[keys[i]]) { @@ -219,7 +228,7 @@ export default class RegistrationForm extends React.Component { return true; } - findFirstInvalidField(fieldIDs) { + private findFirstInvalidField(fieldIDs: RegistrationField[]) { for (const fieldID of fieldIDs) { if (!this.state.fieldValid[fieldID] && this[fieldID]) { return this[fieldID]; @@ -228,7 +237,7 @@ export default class RegistrationForm extends React.Component { return null; } - markFieldValid(fieldID, valid) { + private markFieldValid(fieldID: RegistrationField, valid: boolean) { const { fieldValid } = this.state; fieldValid[fieldID] = valid; this.setState({ @@ -236,25 +245,26 @@ export default class RegistrationForm extends React.Component { }); } - onEmailChange = ev => { + private onEmailChange = ev => { this.setState({ email: ev.target.value, }); }; - onEmailValidate = async fieldState => { + private onEmailValidate = async fieldState => { const result = await this.validateEmailRules(fieldState); - this.markFieldValid(FIELD_EMAIL, result.valid); + this.markFieldValid(RegistrationField.Email, result.valid); return result; }; - validateEmailRules = withValidation({ + private validateEmailRules = withValidation({ description: () => _t("Use an email address to recover your account"), + hideDescriptionIfValid: true, rules: [ { key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value; + test(this: RegistrationForm, { value, allowEmpty }) { + return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value; }, invalid: () => _t("Enter email address (required on this homeserver)"), }, @@ -266,29 +276,29 @@ export default class RegistrationForm extends React.Component { ], }); - onPasswordChange = ev => { + private onPasswordChange = ev => { this.setState({ password: ev.target.value, }); }; - onPasswordValidate = result => { - this.markFieldValid(FIELD_PASSWORD, result.valid); + private onPasswordValidate = result => { + this.markFieldValid(RegistrationField.Password, result.valid); }; - onPasswordConfirmChange = ev => { + private onPasswordConfirmChange = ev => { this.setState({ passwordConfirm: ev.target.value, }); }; - onPasswordConfirmValidate = async fieldState => { + private onPasswordConfirmValidate = async fieldState => { const result = await this.validatePasswordConfirmRules(fieldState); - this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); + this.markFieldValid(RegistrationField.PasswordConfirm, result.valid); return result; }; - validatePasswordConfirmRules = withValidation({ + private validatePasswordConfirmRules = withValidation({ rules: [ { key: "required", @@ -297,65 +307,66 @@ export default class RegistrationForm extends React.Component { }, { key: "match", - test({ value }) { + test(this: RegistrationForm, { value }) { return !value || value === this.state.password; }, invalid: () => _t("Passwords don't match"), }, - ], + ], }); - onPhoneCountryChange = newVal => { + private onPhoneCountryChange = newVal => { this.setState({ phoneCountry: newVal.iso2, - phonePrefix: newVal.prefix, }); }; - onPhoneNumberChange = ev => { + private onPhoneNumberChange = ev => { this.setState({ phoneNumber: ev.target.value, }); }; - onPhoneNumberValidate = async fieldState => { + private onPhoneNumberValidate = async fieldState => { const result = await this.validatePhoneNumberRules(fieldState); - this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); + this.markFieldValid(RegistrationField.PhoneNumber, result.valid); return result; }; - validatePhoneNumberRules = withValidation({ + private validatePhoneNumberRules = withValidation({ description: () => _t("Other users can invite you to rooms using your contact details"), + hideDescriptionIfValid: true, rules: [ { key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value; + test(this: RegistrationForm, { value, allowEmpty }) { + return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value; }, invalid: () => _t("Enter phone number (required on this homeserver)"), }, { key: "email", test: ({ value }) => !value || phoneNumberLooksValid(value), - invalid: () => _t("Doesn't look like a valid phone number"), + invalid: () => _t("That phone number doesn't look quite right, please check and try again"), }, ], }); - onUsernameChange = ev => { + private onUsernameChange = ev => { this.setState({ username: ev.target.value, }); }; - onUsernameValidate = async fieldState => { + private onUsernameValidate = async fieldState => { const result = await this.validateUsernameRules(fieldState); - this.markFieldValid(FIELD_USERNAME, result.valid); + this.markFieldValid(RegistrationField.Username, result.valid); return result; }; - validateUsernameRules = withValidation({ + private validateUsernameRules = withValidation({ description: () => _t("Use lowercase letters, numbers, dashes and underscores only"), + hideDescriptionIfValid: true, rules: [ { key: "required", @@ -376,7 +387,7 @@ export default class RegistrationForm extends React.Component { * @param {string} step A stage name to check * @returns {boolean} Whether it is required */ - _authStepIsRequired(step) { + private authStepIsRequired(step: string) { return this.props.flows.every((flow) => { return flow.stages.includes(step); }); @@ -388,46 +399,36 @@ export default class RegistrationForm extends React.Component { * @param {string} step A stage name to check * @returns {boolean} Whether it is used */ - _authStepIsUsed(step) { + private authStepIsUsed(step: string) { return this.props.flows.some((flow) => { return flow.stages.includes(step); }); } - _showEmail() { - const haveIs = Boolean(this.props.serverConfig.isUrl); - if ( - (this.props.serverRequiresIdServer && !haveIs) || - !this._authStepIsUsed('m.login.email.identity') - ) { + private showEmail() { + if (!this.authStepIsUsed('m.login.email.identity')) { return false; } return true; } - _showPhoneNumber() { + private showPhoneNumber() { const threePidLogin = !SdkConfig.get().disable_3pid_login; - const haveIs = Boolean(this.props.serverConfig.isUrl); - if ( - !threePidLogin || - (this.props.serverRequiresIdServer && !haveIs) || - !this._authStepIsUsed('m.login.msisdn') - ) { + if (!threePidLogin || !this.authStepIsUsed('m.login.msisdn')) { return false; } return true; } - renderEmail() { - if (!this._showEmail()) { + private renderEmail() { + if (!this.showEmail()) { return null; } - const Field = sdk.getComponent('elements.Field'); - const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ? + const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ? _t("Email") : _t("Email (optional)"); return this[FIELD_EMAIL] = field} + ref={field => this[RegistrationField.Email] = field} type="text" label={emailPlaceholder} value={this.state.email} @@ -438,10 +439,10 @@ export default class RegistrationForm extends React.Component { />; } - renderPassword() { + private renderPassword() { return this[FIELD_PASSWORD] = field} + fieldRef={field => this[RegistrationField.Password] = field} minScore={PASSWORD_MIN_SCORE} value={this.state.password} onChange={this.onPasswordChange} @@ -452,13 +453,12 @@ export default class RegistrationForm extends React.Component { } renderPasswordConfirm() { - const Field = sdk.getComponent('elements.Field'); return this[FIELD_PASSWORD_CONFIRM] = field} + ref={field => this[RegistrationField.PasswordConfirm] = field} type="password" autoComplete="new-password" - label={_t("Confirm")} + label={_t("Confirm password")} value={this.state.passwordConfirm} onChange={this.onPasswordConfirmChange} onValidate={this.onPasswordConfirmValidate} @@ -468,12 +468,10 @@ export default class RegistrationForm extends React.Component { } renderPhoneNumber() { - if (!this._showPhoneNumber()) { + if (!this.showPhoneNumber()) { return null; } - const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); - const Field = sdk.getComponent('elements.Field'); - const phoneLabel = this._authStepIsRequired('m.login.msisdn') ? + const phoneLabel = this.authStepIsRequired('m.login.msisdn') ? _t("Phone") : _t("Phone (optional)"); const phoneCountry = ; return this[FIELD_PHONE_NUMBER] = field} + ref={field => this[RegistrationField.PhoneNumber] = field} type="text" label={phoneLabel} value={this.state.phoneNumber} @@ -494,13 +492,13 @@ export default class RegistrationForm extends React.Component { } renderUsername() { - const Field = sdk.getComponent('elements.Field'); return this[FIELD_USERNAME] = field} + ref={field => this[RegistrationField.Username] = field} type="text" autoFocus={true} label={_t("Username")} + placeholder={_t("Username").toLocaleLowerCase()} value={this.state.username} onChange={this.onUsernameChange} onValidate={this.onUsernameValidate} @@ -510,72 +508,33 @@ export default class RegistrationForm extends React.Component { } render() { - let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { - serverName: this.props.serverConfig.hsName, - }); - if (this.props.serverConfig.hsNameIsDifferent) { - const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); - - yourMatrixAccountText = _t('Create your Matrix account on ', {}, { - 'underlinedServerName': () => { - return ; - }, - }); - } - - let editLink = null; - if (this.props.onEditServerDetailsClick) { - editLink = - {_t('Change')} - ; - } - const registerButton = ( ); let emailHelperText = null; - if (this._showEmail()) { - if (this._showPhoneNumber()) { + if (this.showEmail()) { + if (this.showPhoneNumber()) { emailHelperText =
      - {_t( - "Set an email for account recovery. " + - "Use email or phone to optionally be discoverable by existing contacts.", - )} + { + _t("Add an email to be able to reset your password.") + } { + _t("Use email or phone to optionally be discoverable by existing contacts.") + }
      ; } else { emailHelperText =
      - {_t( - "Set an email for account recovery. " + - "Use email to optionally be discoverable by existing contacts.", - )} + { + _t("Add an email to be able to reset your password.") + } { + _t("Use email to optionally be discoverable by existing contacts.") + }
      ; } } - const haveIs = Boolean(this.props.serverConfig.isUrl); - let noIsText = null; - if (this.props.serverRequiresIdServer && !haveIs) { - noIsText =
      - {_t( - "No identity server is configured so you cannot add an email address in order to " + - "reset your password in the future.", - )} -
      ; - } return (
      -

      - {yourMatrixAccountText} - {editLink} -

      {this.renderUsername()} @@ -589,7 +548,6 @@ export default class RegistrationForm extends React.Component { {this.renderPhoneNumber()}
      { emailHelperText } - { noIsText } { registerButton }
      diff --git a/src/components/views/auth/ServerConfig.js b/src/components/views/auth/ServerConfig.js deleted file mode 100644 index e04bf9e25a..0000000000 --- a/src/components/views/auth/ServerConfig.js +++ /dev/null @@ -1,291 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import Modal from '../../../Modal'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; -import SdkConfig from "../../../SdkConfig"; -import { createClient } from 'matrix-js-sdk/src/matrix'; -import classNames from 'classnames'; -import CountlyAnalytics from "../../../CountlyAnalytics"; - -/* - * A pure UI component which displays the HS and IS to use. - */ - -export default class ServerConfig extends React.PureComponent { - static propTypes = { - onServerConfigChange: PropTypes.func.isRequired, - - // The current configuration that the user is expecting to change. - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - - delayTimeMs: PropTypes.number, // time to wait before invoking onChanged - - // Called after the component calls onServerConfigChange - onAfterSubmit: PropTypes.func, - - // Optional text for the submit button. If falsey, no button will be shown. - submitText: PropTypes.string, - - // Optional class for the submit button. Only applies if the submit button - // is to be rendered. - submitClass: PropTypes.string, - - // Whether the flow this component is embedded in requires an identity - // server when the homeserver says it will need one. Default false. - showIdentityServerIfRequiredByHomeserver: PropTypes.bool, - }; - - static defaultProps = { - onServerConfigChange: function() {}, - delayTimeMs: 0, - }; - - constructor(props) { - super(props); - - this.state = { - busy: false, - errorText: "", - hsUrl: props.serverConfig.hsUrl, - isUrl: props.serverConfig.isUrl, - showIdentityServer: false, - }; - - CountlyAnalytics.instance.track("onboarding_custom_server"); - } - - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase - if (newProps.serverConfig.hsUrl === this.state.hsUrl && - newProps.serverConfig.isUrl === this.state.isUrl) return; - - this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); - } - - async validateServer() { - // TODO: Do we want to support .well-known lookups here? - // If for some reason someone enters "matrix.org" for a URL, we could do a lookup to - // find their homeserver without demanding they use "https://matrix.org" - const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl); - if (!result) { - return result; - } - - // If the UI flow this component is embedded in requires an identity - // server when the homeserver says it will need one, check first and - // reveal this field if not already shown. - // XXX: This a backward compatibility path for homeservers that require - // an identity server to be passed during certain flows. - // See also https://github.com/matrix-org/synapse/pull/5868. - if ( - this.props.showIdentityServerIfRequiredByHomeserver && - !this.state.showIdentityServer && - await this.isIdentityServerRequiredByHomeserver() - ) { - this.setState({ - showIdentityServer: true, - }); - return null; - } - - return result; - } - - async validateAndApplyServer(hsUrl, isUrl) { - // Always try and use the defaults first - const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; - if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) { - this.setState({ - hsUrl: defaultConfig.hsUrl, - isUrl: defaultConfig.isUrl, - busy: false, - errorText: "", - }); - this.props.onServerConfigChange(defaultConfig); - return defaultConfig; - } - - this.setState({ - hsUrl, - isUrl, - busy: true, - errorText: "", - }); - - try { - const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); - this.setState({busy: false, errorText: ""}); - this.props.onServerConfigChange(result); - return result; - } catch (e) { - console.error(e); - - const stateForError = AutoDiscoveryUtils.authComponentStateForError(e); - if (!stateForError.isFatalError) { - this.setState({ - busy: false, - }); - // carry on anyway - const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true); - this.props.onServerConfigChange(result); - return result; - } else { - let message = _t("Unable to validate homeserver/identity server"); - if (e.translatedMessage) { - message = e.translatedMessage; - } - this.setState({ - busy: false, - errorText: message, - }); - - return null; - } - } - } - - async isIdentityServerRequiredByHomeserver() { - // XXX: We shouldn't have to create a whole new MatrixClient just to - // check if the homeserver requires an identity server... Should it be - // extracted to a static utils function...? - return createClient({ - baseUrl: this.state.hsUrl, - }).doesServerRequireIdServerParam(); - } - - onHomeserverBlur = (ev) => { - this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => { - this.validateServer(); - }); - }; - - onHomeserverChange = (ev) => { - const hsUrl = ev.target.value; - this.setState({ hsUrl }); - }; - - onIdentityServerBlur = (ev) => { - this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, () => { - this.validateServer(); - }); - }; - - onIdentityServerChange = (ev) => { - const isUrl = ev.target.value; - this.setState({ isUrl }); - }; - - onSubmit = async (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const result = await this.validateServer(); - if (!result) return; // Do not continue. - - if (this.props.onAfterSubmit) { - this.props.onAfterSubmit(); - } - }; - - _waitThenInvoke(existingTimeoutId, fn) { - if (existingTimeoutId) { - clearTimeout(existingTimeoutId); - } - return setTimeout(fn.bind(this), this.props.delayTimeMs); - } - - showHelpPopup = () => { - const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog'); - Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog); - }; - - _renderHomeserverSection() { - const Field = sdk.getComponent('elements.Field'); - return
      - {_t("Enter your custom homeserver URL What does this mean?", {}, { - a: sub => - {sub} - , - })} - -
      ; - } - - _renderIdentityServerSection() { - const Field = sdk.getComponent('elements.Field'); - const classes = classNames({ - "mx_ServerConfig_identityServer": true, - "mx_ServerConfig_identityServer_shown": this.state.showIdentityServer, - }); - return
      - {_t("Enter your custom identity server URL What does this mean?", {}, { - a: sub => - {sub} - , - })} - -
      ; - } - - render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - - const errorText = this.state.errorText - ? {this.state.errorText} - : null; - - const submitButton = this.props.submitText - ? {this.props.submitText} - : null; - - return ( -
      -

      {_t("Other servers")}

      - {errorText} - {this._renderHomeserverSection()} - {this._renderIdentityServerSection()} - {submitButton} -
      - ); - } -} diff --git a/src/components/views/auth/ServerTypeSelector.js b/src/components/views/auth/ServerTypeSelector.js deleted file mode 100644 index 71e7ac7f0e..0000000000 --- a/src/components/views/auth/ServerTypeSelector.js +++ /dev/null @@ -1,153 +0,0 @@ -/* -Copyright 2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; -import * as sdk from '../../../index'; -import classnames from 'classnames'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import {makeType} from "../../../utils/TypeUtils"; - -const MODULAR_URL = 'https://element.io/matrix-services' + - '?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication'; - -export const FREE = 'Free'; -export const PREMIUM = 'Premium'; -export const ADVANCED = 'Advanced'; - -export const TYPES = { - FREE: { - id: FREE, - label: () => _t('Free'), - logo: () => , - description: () => _t('Join millions for free on the largest public server'), - serverConfig: makeType(ValidatedServerConfig, { - hsUrl: "https://matrix-client.matrix.org", - hsName: "matrix.org", - hsNameIsDifferent: false, - isUrl: "https://vector.im", - }), - }, - PREMIUM: { - id: PREMIUM, - label: () => _t('Premium'), - logo: () => , - description: () => _t('Premium hosting for organisations Learn more', {}, { - a: sub => - {sub} - , - }), - identityServerUrl: "https://vector.im", - }, - ADVANCED: { - id: ADVANCED, - label: () => _t('Advanced'), - logo: () =>
      - - {_t('Other')} -
      , - description: () => _t('Find other public servers or use a custom server'), - }, -}; - -export function getTypeFromServerConfig(config) { - const {hsUrl} = config; - if (!hsUrl) { - return null; - } else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) { - return FREE; - } else if (new URL(hsUrl).hostname.endsWith('.modular.im')) { - // This is an unlikely case to reach, as Modular defaults to hiding the - // server type selector. - return PREMIUM; - } else { - return ADVANCED; - } -} - -export default class ServerTypeSelector extends React.PureComponent { - static propTypes = { - // The default selected type. - selected: PropTypes.string, - // Handler called when the selected type changes. - onChange: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - - const { - selected, - } = props; - - this.state = { - selected, - }; - } - - updateSelectedType(type) { - if (this.state.selected === type) { - return; - } - this.setState({ - selected: type, - }); - if (this.props.onChange) { - this.props.onChange(type); - } - } - - onClick = (e) => { - e.stopPropagation(); - const type = e.currentTarget.dataset.id; - this.updateSelectedType(type); - }; - - render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - - const serverTypes = []; - for (const type of Object.values(TYPES)) { - const { id, label, logo, description } = type; - const classes = classnames( - "mx_ServerTypeSelector_type", - `mx_ServerTypeSelector_type_${id}`, - { - "mx_ServerTypeSelector_type_selected": id === this.state.selected, - }, - ); - - serverTypes.push(
      -
      - {label()} -
      - -
      - {logo()} -
      -
      - {description()} -
      -
      -
      ); - } - - return
      - {serverTypes} -
      ; - } -} diff --git a/src/components/views/auth/SignInToText.js b/src/components/views/auth/SignInToText.js deleted file mode 100644 index 7564096b7d..0000000000 --- a/src/components/views/auth/SignInToText.js +++ /dev/null @@ -1,62 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import {_t} from "../../../languageHandler"; -import * as sdk from "../../../index"; -import PropTypes from "prop-types"; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; - -export default class SignInToText extends React.PureComponent { - static propTypes = { - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - onEditServerDetailsClick: PropTypes.func, - }; - - render() { - let signInToText = _t('Sign in to your Matrix account on %(serverName)s', { - serverName: this.props.serverConfig.hsName, - }); - if (this.props.serverConfig.hsNameIsDifferent) { - const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); - - signInToText = _t('Sign in to your Matrix account on ', {}, { - 'underlinedServerName': () => { - return ; - }, - }); - } - - let editLink = null; - if (this.props.onEditServerDetailsClick) { - editLink = - {_t('Change')} - ; - } - - return

      - {signInToText} - {editLink} -

      ; - } -} diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index 0205f4e0b9..e3f7a601f2 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -20,14 +20,16 @@ import classNames from "classnames"; import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import AuthPage from "./AuthPage"; -import {_td} from "../../../languageHandler"; +import { _td } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; -import {UIFeature} from "../../../settings/UIFeature"; +import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; // translatable strings for Welcome pages _td("Sign in with SSO"); +@replaceableComponent("views.auth.Welcome") export default class Welcome extends React.PureComponent { constructor(props) { super(props); diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 245c50576a..87cdbe7512 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -17,14 +17,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useCallback, useContext, useEffect, useState} from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import classNames from 'classnames'; +import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials'; + import * as AvatarLogic from '../../../Avatar'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from '../elements/AccessibleButton'; +import RoomContext from "../../../contexts/RoomContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {useEventEmitter} from "../../../hooks/useEventEmitter"; -import {toPx} from "../../../utils/units"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; +import { toPx } from "../../../utils/units"; +import { _t } from '../../../languageHandler'; interface IProps { name: string; // The name (first initial used as default) @@ -35,23 +39,24 @@ interface IProps { width?: number; height?: number; // XXX: resizeMethod not actually used. - resizeMethod?: string; + resizeMethod?: ResizeMethod; defaultToInitialLetter?: boolean; // true to add default url onClick?: React.MouseEventHandler; inputRef?: React.RefObject; className?: string; } -const calculateUrls = (url, urls) => { +const calculateUrls = (url, urls, lowBandwidth) => { // work out the full set of urls to try to load. This is formed like so: // imageUrls: [ props.url, ...props.urls ] let _urls = []; - if (!SettingsStore.getValue("lowBandwidth")) { + if (!lowBandwidth) { _urls = urls || []; if (url) { - _urls.unshift(url); // put in urls[0] + // copy urls and put url first + _urls = [url, ..._urls]; } } @@ -59,8 +64,14 @@ const calculateUrls = (url, urls) => { return Array.from(new Set(_urls)); }; -const useImageUrl = ({url, urls}): [string, () => void] => { - const [imageUrls, setUrls] = useState(calculateUrls(url, urls)); +const useImageUrl = ({ url, urls }): [string, () => void] => { + // Since this is a hot code path and the settings store can be slow, we + // use the cached lowBandwidth value from the room context if it exists + const roomContext = useContext(RoomContext); + const lowBandwidth = roomContext ? + roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth"); + + const [imageUrls, setUrls] = useState(calculateUrls(url, urls, lowBandwidth)); const [urlsIndex, setIndex] = useState(0); const onError = useCallback(() => { @@ -68,7 +79,7 @@ const useImageUrl = ({url, urls}): [string, () => void] => { }, []); useEffect(() => { - setUrls(calculateUrls(url, urls)); + setUrls(calculateUrls(url, urls, lowBandwidth)); setIndex(0); }, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps @@ -104,7 +115,7 @@ const BaseAvatar = (props: IProps) => { ...otherProps } = props; - const [imageUrl, onError] = useImageUrl({url, urls}); + const [imageUrl, onError] = useImageUrl({ url, urls }); if (!imageUrl && defaultToInitialLetter) { const initialLetter = AvatarLogic.getInitialLetter(name); @@ -138,6 +149,7 @@ const BaseAvatar = (props: IProps) => { if (onClick) { return ( { width: toPx(width), height: toPx(height), }} - title={title} alt="" + title={title} alt={_t("Avatar")} inputRef={inputRef} {...otherProps} /> ); diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index d7e012467b..5e6bf45f07 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -20,24 +20,24 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { User } from "matrix-js-sdk/src/models/user"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { TagID } from '../../../stores/room-list/models'; import RoomAvatar from "./RoomAvatar"; import NotificationBadge from '../rooms/NotificationBadge'; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { NotificationState } from "../../../stores/notifications/NotificationState"; -import {isPresenceEnabled} from "../../../utils/presence"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {_t} from "../../../languageHandler"; +import { isPresenceEnabled } from "../../../utils/presence"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { _t } from "../../../languageHandler"; import TextWithTooltip from "../elements/TextWithTooltip"; import DMRoomMap from "../../../utils/DMRoomMap"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { IOOBData } from "../../../stores/ThreepidInviteStore"; interface IProps { room: Room; avatarSize: number; - tag: TagID; displayBadge?: boolean; forceCount?: boolean; - oobData?: object; + oobData?: IOOBData; viewAvatarOnClick?: boolean; } @@ -68,6 +68,7 @@ function tooltipText(variant: Icon) { } } +@replaceableComponent("views.avatars.DecoratedRoomAvatar") export default class DecoratedRoomAvatar extends React.PureComponent { private _dmUser: User; private isUnmounted = false; @@ -119,7 +120,10 @@ export default class DecoratedRoomAvatar extends React.PureComponent { public static defaultProps = { width: 36, @@ -36,8 +40,8 @@ export default class GroupAvatar extends React.Component { }; getGroupAvatarUrl() { - return MatrixClientPeg.get().mxcUrlToHttp( - this.props.groupAvatarUrl, + if (!this.props.groupAvatarUrl) return null; + return mediaFromMxc(this.props.groupAvatarUrl).getThumbnailOfSourceHttp( this.props.width, this.props.height, this.props.resizeMethod, @@ -48,7 +52,7 @@ export default class GroupAvatar extends React.Component { // extract the props we use from props so we can pass any others through // should consider adding this as a global rule in js-sdk? /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ - const {groupId, groupAvatarUrl, groupName, ...otherProps} = this.props; + const { groupId, groupAvatarUrl, groupName, ...otherProps } = this.props; return ( , "name" | "idName" | "url"> { member: RoomMember; fallbackUserId?: string; width: number; height: number; - resizeMethod?: string; + resizeMethod?: ResizeMethod; // The onClick to give the avatar onClick?: React.MouseEventHandler; // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` @@ -42,6 +44,7 @@ interface IState { imageUrl?: string; } +@replaceableComponent("views.avatars.MemberAvatar") export default class MemberAvatar extends React.Component { public static defaultProps = { width: 40, @@ -61,18 +64,19 @@ export default class MemberAvatar extends React.Component { } private static getState(props: IProps): IState { - if (props.member && props.member.name) { + if (props.member?.name) { + let imageUrl = null; + if (props.member.getMxcAvatarUrl()) { + imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( + props.width, + props.height, + props.resizeMethod, + ); + } return { name: props.member.name, title: props.title || props.member.userId, - imageUrl: props.member.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), - props.resizeMethod, - false, - false, - ), + imageUrl: imageUrl, }; } else if (props.fallbackUserId) { return { @@ -85,7 +89,7 @@ export default class MemberAvatar extends React.Component { } render() { - let {member, fallbackUserId, onClick, viewUserOnClick, ...otherProps} = this.props; + let { member, fallbackUserId, onClick, viewUserOnClick, ...otherProps } = this.props; const userId = member ? member.userId : fallbackUserId; if (viewUserOnClick) { diff --git a/src/components/views/avatars/MemberStatusMessageAvatar.js b/src/components/views/avatars/MemberStatusMessageAvatar.js index d5d927106c..b8b23dc33e 100644 --- a/src/components/views/avatars/MemberStatusMessageAvatar.js +++ b/src/components/views/avatars/MemberStatusMessageAvatar.js @@ -14,16 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {_t} from "../../../languageHandler"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { _t } from "../../../languageHandler"; import MemberAvatar from '../avatars/MemberAvatar'; import classNames from 'classnames'; import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu"; import SettingsStore from "../../../settings/SettingsStore"; -import {ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; +import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +@replaceableComponent("views.avatars.MemberStatusMessageAvatar") export default class MemberStatusMessageAvatar extends React.Component { static propTypes = { member: PropTypes.object.isRequired, diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index cbdae765f7..8ac8de8233 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -13,34 +13,37 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import Room from 'matrix-js-sdk/src/models/room'; -import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo'; +import React, { ComponentProps } from 'react'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials'; import BaseAvatar from './BaseAvatar'; import ImageView from '../elements/ImageView'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import * as Avatar from '../../../Avatar'; -import {ResizeMethod} from "../../../Avatar"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; +import { IOOBData } from '../../../stores/ThreepidInviteStore'; -interface IProps { +interface IProps extends Omit, "name" | "idName" | "url" | "onClick"> { // Room may be left unset here, but if it is, // oobData.avatarUrl should be set (else there // would be nowhere to get the avatar from) room?: Room; - // TODO: type when js-sdk has types - oobData?: any; + oobData?: IOOBData; width?: number; height?: number; resizeMethod?: ResizeMethod; viewAvatarOnClick?: boolean; + onClick?(): void; } interface IState { urls: string[]; } +@replaceableComponent("views.avatars.RoomAvatar") export default class RoomAvatar extends React.Component { public static defaultProps = { width: 36, @@ -87,16 +90,16 @@ export default class RoomAvatar extends React.Component { }; private static getImageUrls(props: IProps): string[] { - return [ - getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), - // Default props don't play nicely with getDerivedStateFromProps - //props.oobData !== undefined ? props.oobData.avatarUrl : {}, - props.oobData.avatarUrl, - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), + let oobAvatar = null; + if (props.oobData.avatarUrl) { + oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp( + props.width, + props.height, props.resizeMethod, - ), // highest priority + ); + } + return [ + oobAvatar, // highest priority RoomAvatar.getRoomAvatarUrl(props), ].filter(function(url) { return (url !== null && url !== ""); @@ -106,12 +109,7 @@ export default class RoomAvatar extends React.Component { private static getRoomAvatarUrl(props: IProps): string { if (!props.room) return null; - return Avatar.avatarUrlForRoom( - props.room, - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), - props.resizeMethod, - ); + return Avatar.avatarUrlForRoom(props.room, props.width, props.height, props.resizeMethod); } private onRoomAvatarClick = () => { @@ -126,11 +124,11 @@ export default class RoomAvatar extends React.Component { name: this.props.room.name, }; - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); }; public render() { - const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props; + const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props; const roomName = room ? room.name : oobData.name; @@ -139,7 +137,7 @@ export default class RoomAvatar extends React.Component { name={roomName} idName={room ? room.roomId : null} urls={this.state.urls} - onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : null} + onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick} /> ); } diff --git a/src/components/views/avatars/WidgetAvatar.tsx b/src/components/views/avatars/WidgetAvatar.tsx index 04cfce7670..264f1f3956 100644 --- a/src/components/views/avatars/WidgetAvatar.tsx +++ b/src/components/views/avatars/WidgetAvatar.tsx @@ -14,21 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ComponentProps, useContext} from 'react'; +import React, { ComponentProps } from 'react'; import classNames from 'classnames'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {IApp} from "../../../stores/WidgetStore"; -import BaseAvatar, {BaseAvatarType} from "./BaseAvatar"; +import { IApp } from "../../../stores/WidgetStore"; +import BaseAvatar, { BaseAvatarType } from "./BaseAvatar"; +import { mediaFromMxc } from "../../../customisations/Media"; interface IProps extends Omit, "name" | "url" | "urls"> { app: IApp; } const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 20, ...props }) => { - const cli = useContext(MatrixClientContext); - let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")]; // heuristics for some better icons until Widgets support their own icons if (app.type.includes("jitsi")) { @@ -47,12 +44,12 @@ const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 2 name={app.id} className={classNames("mx_WidgetAvatar", className)} // MSC2765 - url={app.avatar_url ? getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop") : undefined} + url={app.avatar_url ? mediaFromMxc(app.avatar_url).getSquareThumbnailHttp(20) : undefined} urls={iconUrls} width={width} height={height} /> - ) + ); }; export default WidgetAvatar; diff --git a/src/components/views/beta/BetaCard.tsx b/src/components/views/beta/BetaCard.tsx new file mode 100644 index 0000000000..ec662d831b --- /dev/null +++ b/src/components/views/beta/BetaCard.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 from "react"; +import classNames from "classnames"; + +import { _t } from "../../../languageHandler"; +import AccessibleButton from "../elements/AccessibleButton"; +import SettingsStore from "../../../settings/SettingsStore"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import TextWithTooltip from "../elements/TextWithTooltip"; +import Modal from "../../../Modal"; +import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog"; +import SdkConfig from "../../../SdkConfig"; +import SettingsFlag from "../elements/SettingsFlag"; + +interface IProps { + title?: string; + featureId: string; +} + +export const BetaPill = ({ onClick }: { onClick?: () => void }) => { + if (onClick) { + return +
      + { _t("Spaces is a beta feature") } +
      +
      + { _t("Tap for more info") } +
      +
      } + onClick={onClick} + tooltipProps={{ yOffset: -10 }} + > + { _t("Beta") } + ; + } + + return + { _t("Beta") } + ; +}; + +const BetaCard = ({ title: titleOverride, featureId }: IProps) => { + const info = SettingsStore.getBetaInfo(featureId); + if (!info) return null; // Beta is invalid/disabled + + const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading, extraSettings } = info; + const value = SettingsStore.getValue(featureId); + + let feedbackButton; + if (value && feedbackLabel && feedbackSubheading && SdkConfig.get().bug_report_endpoint_url) { + feedbackButton = { + Modal.createTrackedDialog("Beta Feedback", featureId, BetaFeedbackDialog, { featureId }); + }} + kind="primary" + > + { _t("Feedback") } + ; + } + + return
      +
      +
      +

      + { titleOverride || _t(title) } + +

      + { _t(caption) } +
      + { feedbackButton } + SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)} + kind={feedbackButton ? "primary_outline" : "primary"} + > + { value ? _t("Leave the beta") : _t("Join the beta") } + +
      + { disclaimer &&
      + { disclaimer(value) } +
      } +
      + +
      + { extraSettings && value &&
      + { extraSettings.map(key => ( + + )) } +
      } +
      ; +}; + +export default BetaCard; diff --git a/src/components/views/context_menus/CallContextMenu.tsx b/src/components/views/context_menus/CallContextMenu.tsx new file mode 100644 index 0000000000..76e1670669 --- /dev/null +++ b/src/components/views/context_menus/CallContextMenu.tsx @@ -0,0 +1,79 @@ +/* +Copyright 2020 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; +import { ContextMenu, IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu'; +import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; +import CallHandler from '../../../CallHandler'; +import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog'; +import Modal from '../../../Modal'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps extends IContextMenuProps { + call: MatrixCall; +} + +@replaceableComponent("views.context_menus.CallContextMenu") +export default class CallContextMenu extends React.Component { + static propTypes = { + // js-sdk User object. Not required because it might not exist. + user: PropTypes.object, + }; + + constructor(props) { + super(props); + } + + onHoldClick = () => { + this.props.call.setRemoteOnHold(true); + this.props.onFinished(); + }; + + onUnholdClick = () => { + CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId); + + this.props.onFinished(); + }; + + onTransferClick = () => { + Modal.createTrackedDialog( + 'Transfer Call', '', InviteDialog, { kind: KIND_CALL_TRANSFER, call: this.props.call }, + /*className=*/"mx_InviteDialog_transferWrapper", /*isPriority=*/false, /*isStatic=*/true, + ); + this.props.onFinished(); + }; + + render() { + const holdUnholdCaption = this.props.call.isRemoteOnHold() ? _t("Resume") : _t("Hold"); + const handler = this.props.call.isRemoteOnHold() ? this.onUnholdClick : this.onHoldClick; + + let transferItem; + if (this.props.call.opponentCanBeTransferred()) { + transferItem = + {_t("Transfer")} + ; + } + + return + + {holdUnholdCaption} + + {transferItem} + ; + } +} diff --git a/src/components/views/context_menus/DialpadContextMenu.tsx b/src/components/views/context_menus/DialpadContextMenu.tsx new file mode 100644 index 0000000000..39dfd50795 --- /dev/null +++ b/src/components/views/context_menus/DialpadContextMenu.tsx @@ -0,0 +1,74 @@ +/* +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 from 'react'; +import AccessibleButton from "../elements/AccessibleButton"; +import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu'; +import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; +import Field from "../elements/Field"; +import DialPad from '../voip/DialPad'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps extends IContextMenuProps { + call: MatrixCall; +} + +interface IState { + value: string; +} + +@replaceableComponent("views.context_menus.DialpadContextMenu") +export default class DialpadContextMenu extends React.Component { + constructor(props) { + super(props); + + this.state = { + value: '', + }; + } + + onDigitPress = (digit) => { + this.props.call.sendDtmfDigit(digit); + this.setState({ value: this.state.value + digit }); + }; + + onCancelClick = () => { + this.props.onFinished(); + }; + + onChange = (ev) => { + this.setState({ value: ev.target.value }); + }; + + render() { + return +
      +
      + +
      +
      + +
      +
      + +
      +
      +
      ; + } +} diff --git a/src/components/views/context_menus/GenericElementContextMenu.js b/src/components/views/context_menus/GenericElementContextMenu.js index cea684b663..87d44ef0d3 100644 --- a/src/components/views/context_menus/GenericElementContextMenu.js +++ b/src/components/views/context_menus/GenericElementContextMenu.js @@ -16,13 +16,14 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; /* * This component can be used to display generic HTML content in a contextual * menu. */ - +@replaceableComponent("views.context_menus.GenericElementContextMenu") export default class GenericElementContextMenu extends React.Component { static propTypes = { element: PropTypes.element.isRequired, diff --git a/src/components/views/context_menus/GenericTextContextMenu.js b/src/components/views/context_menus/GenericTextContextMenu.js index 068f83be5f..474732e88b 100644 --- a/src/components/views/context_menus/GenericTextContextMenu.js +++ b/src/components/views/context_menus/GenericTextContextMenu.js @@ -16,7 +16,9 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +@replaceableComponent("views.context_menus.GenericTextContextMenu") export default class GenericTextContextMenu extends React.Component { static propTypes = { message: PropTypes.string.isRequired, diff --git a/src/components/views/context_menus/GroupInviteTileContextMenu.js b/src/components/views/context_menus/GroupInviteTileContextMenu.js index 27ef76452f..1529723ac8 100644 --- a/src/components/views/context_menus/GroupInviteTileContextMenu.js +++ b/src/components/views/context_menus/GroupInviteTileContextMenu.js @@ -20,10 +20,12 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; -import {Group} from 'matrix-js-sdk'; +import { Group } from 'matrix-js-sdk/src/models/group'; import GroupStore from "../../../stores/GroupStore"; -import {MenuItem} from "../../structures/ContextMenu"; +import { MenuItem } from "../../structures/ContextMenu"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +@replaceableComponent("views.context_menus.GroupInviteTileContextMenu") export default class GroupInviteTileContextMenu extends React.Component { static propTypes = { group: PropTypes.instanceOf(Group).isRequired, diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index a3fb00a9f4..a9c75bf3ba 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -90,14 +90,14 @@ export const IconizedContextMenuCheckbox: React.FC = ({ ; }; -export const IconizedContextMenuOption: React.FC = ({label, iconClassName, ...props}) => { +export const IconizedContextMenuOption: React.FC = ({ label, iconClassName, ...props }) => { return { iconClassName && } {label} ; }; -export const IconizedContextMenuOptionList: React.FC = ({first, red, className, children}) => { +export const IconizedContextMenuOptionList: React.FC = ({ first, red, className, children }) => { const classes = classNames("mx_IconizedContextMenu_optionList", className, { mx_IconizedContextMenu_optionList_notFirst: !first, mx_IconizedContextMenu_optionList_red: red, @@ -108,7 +108,7 @@ export const IconizedContextMenuOptionList: React.FC = ({first
      ; }; -const IconizedContextMenu: React.FC = ({className, children, compact, ...props}) => { +const IconizedContextMenu: React.FC = ({ className, children, compact, ...props }) => { const classes = classNames("mx_IconizedContextMenu", className, { mx_IconizedContextMenu_compact: compact, }); diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js deleted file mode 100644 index bc4514f8a6..0000000000 --- a/src/components/views/context_menus/MessageContextMenu.js +++ /dev/null @@ -1,492 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import {EventStatus} from 'matrix-js-sdk'; - -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import dis from '../../../dispatcher/dispatcher'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import Modal from '../../../Modal'; -import Resend from '../../../Resend'; -import SettingsStore from '../../../settings/SettingsStore'; -import { isUrlPermitted } from '../../../HtmlUtils'; -import { isContentActionable } from '../../../utils/EventUtils'; -import {MenuItem} from "../../structures/ContextMenu"; -import {EventType} from "matrix-js-sdk/src/@types/event"; - -function canCancel(eventStatus) { - return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; -} - -export default class MessageContextMenu extends React.Component { - static propTypes = { - /* the MatrixEvent associated with the context menu */ - mxEvent: PropTypes.object.isRequired, - - /* an optional EventTileOps implementation that can be used to unhide preview widgets */ - eventTileOps: PropTypes.object, - - /* an optional function to be called when the user clicks collapse thread, if not provided hide button */ - collapseReplyThread: PropTypes.func, - - /* callback called when the menu is dismissed */ - onFinished: PropTypes.func, - }; - - state = { - canRedact: false, - canPin: false, - }; - - componentDidMount() { - MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions); - this._checkPermissions(); - } - - componentWillUnmount() { - const cli = MatrixClientPeg.get(); - if (cli) { - cli.removeListener('RoomMember.powerLevel', this._checkPermissions); - } - } - - _checkPermissions = () => { - const cli = MatrixClientPeg.get(); - const room = cli.getRoom(this.props.mxEvent.getRoomId()); - - // We explicitly decline to show the redact option on ACL events as it has a potential - // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 - const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) - && this.props.mxEvent.getType() !== EventType.RoomServerAcl; - let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli); - - // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality - if (!SettingsStore.getValue("feature_pinning")) canPin = false; - - this.setState({canRedact, canPin}); - }; - - _isPinned() { - const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); - const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', ''); - if (!pinnedEvent) return false; - const content = pinnedEvent.getContent(); - return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); - } - - onResendClick = () => { - Resend.resend(this.props.mxEvent); - this.closeMenu(); - }; - - onResendEditClick = () => { - Resend.resend(this.props.mxEvent.replacingEvent()); - this.closeMenu(); - }; - - onResendRedactionClick = () => { - Resend.resend(this.props.mxEvent.localRedactionEvent()); - this.closeMenu(); - }; - - onResendReactionsClick = () => { - for (const reaction of this._getUnsentReactions()) { - Resend.resend(reaction); - } - this.closeMenu(); - }; - - onReportEventClick = () => { - const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog"); - Modal.createTrackedDialog('Report Event', '', ReportEventDialog, { - mxEvent: this.props.mxEvent, - }, 'mx_Dialog_reportEvent'); - this.closeMenu(); - }; - - onViewSourceClick = () => { - const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent; - const ViewSource = sdk.getComponent('structures.ViewSource'); - Modal.createTrackedDialog('View Event Source', '', ViewSource, { - roomId: ev.getRoomId(), - eventId: ev.getId(), - content: ev.event, - }, 'mx_Dialog_viewsource'); - this.closeMenu(); - }; - - onViewClearSourceClick = () => { - const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent; - const ViewSource = sdk.getComponent('structures.ViewSource'); - Modal.createTrackedDialog('View Clear Event Source', '', ViewSource, { - roomId: ev.getRoomId(), - eventId: ev.getId(), - // FIXME: _clearEvent is private - content: ev._clearEvent, - }, 'mx_Dialog_viewsource'); - this.closeMenu(); - }; - - onRedactClick = () => { - const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); - Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { - onFinished: async (proceed) => { - if (!proceed) return; - - const cli = MatrixClientPeg.get(); - try { - await cli.redactEvent( - this.props.mxEvent.getRoomId(), - this.props.mxEvent.getId(), - ); - } catch (e) { - const code = e.errcode || e.statusCode; - // only show the dialog if failing for something other than a network error - // (e.g. no errcode or statusCode) as in that case the redactions end up in the - // detached queue and we show the room status bar to allow retry - if (typeof code !== "undefined") { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - // display error message stating you couldn't delete this. - Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { - title: _t('Error'), - description: _t('You cannot delete this message. (%(code)s)', {code}), - }); - } - } - }, - }, 'mx_Dialog_confirmredact'); - this.closeMenu(); - }; - - onCancelSendClick = () => { - const mxEvent = this.props.mxEvent; - const editEvent = mxEvent.replacingEvent(); - const redactEvent = mxEvent.localRedactionEvent(); - const pendingReactions = this._getPendingReactions(); - - if (editEvent && canCancel(editEvent.status)) { - Resend.removeFromQueue(editEvent); - } - if (redactEvent && canCancel(redactEvent.status)) { - Resend.removeFromQueue(redactEvent); - } - if (pendingReactions.length) { - for (const reaction of pendingReactions) { - Resend.removeFromQueue(reaction); - } - } - if (canCancel(mxEvent.status)) { - Resend.removeFromQueue(this.props.mxEvent); - } - this.closeMenu(); - }; - - onForwardClick = () => { - dis.dispatch({ - action: 'forward_event', - event: this.props.mxEvent, - }); - this.closeMenu(); - }; - - onPinClick = () => { - MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '') - .catch((e) => { - // Intercept the Event Not Found error and fall through the promise chain with no event. - if (e.errcode === "M_NOT_FOUND") return null; - throw e; - }) - .then((event) => { - const eventIds = (event ? event.pinned : []) || []; - if (!eventIds.includes(this.props.mxEvent.getId())) { - // Not pinned - add - eventIds.push(this.props.mxEvent.getId()); - } else { - // Pinned - remove - eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1); - } - - const cli = MatrixClientPeg.get(); - cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, ''); - }); - this.closeMenu(); - }; - - closeMenu = () => { - if (this.props.onFinished) this.props.onFinished(); - }; - - onUnhidePreviewClick = () => { - if (this.props.eventTileOps) { - this.props.eventTileOps.unhideWidget(); - } - this.closeMenu(); - }; - - onQuoteClick = () => { - dis.dispatch({ - action: 'quote', - event: this.props.mxEvent, - }); - this.closeMenu(); - }; - - onPermalinkClick = (e: Event) => { - e.preventDefault(); - const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); - Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { - target: this.props.mxEvent, - permalinkCreator: this.props.permalinkCreator, - }); - this.closeMenu(); - }; - - onCollapseReplyThreadClick = () => { - this.props.collapseReplyThread(); - this.closeMenu(); - }; - - _getReactions(filter) { - const cli = MatrixClientPeg.get(); - const room = cli.getRoom(this.props.mxEvent.getRoomId()); - const eventId = this.props.mxEvent.getId(); - return room.getPendingEvents().filter(e => { - const relation = e.getRelation(); - return relation && - relation.rel_type === "m.annotation" && - relation.event_id === eventId && - filter(e); - }); - } - - _getPendingReactions() { - return this._getReactions(e => canCancel(e.status)); - } - - _getUnsentReactions() { - return this._getReactions(e => e.status === EventStatus.NOT_SENT); - } - - render() { - const cli = MatrixClientPeg.get(); - const me = cli.getUserId(); - const mxEvent = this.props.mxEvent; - const eventStatus = mxEvent.status; - const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status; - const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status; - const unsentReactionsCount = this._getUnsentReactions().length; - const pendingReactionsCount = this._getPendingReactions().length; - const allowCancel = canCancel(mxEvent.status) || - canCancel(editStatus) || - canCancel(redactStatus) || - pendingReactionsCount !== 0; - let resendButton; - let resendEditButton; - let resendReactionsButton; - let resendRedactionButton; - let redactButton; - let cancelButton; - let forwardButton; - let pinButton; - let viewClearSourceButton; - let unhidePreviewButton; - let externalURLButton; - let quoteButton; - let collapseReplyThread; - - // status is SENT before remote-echo, null after - const isSent = !eventStatus || eventStatus === EventStatus.SENT; - if (!mxEvent.isRedacted()) { - if (eventStatus === EventStatus.NOT_SENT) { - resendButton = ( - - { _t('Resend') } - - ); - } - - if (editStatus === EventStatus.NOT_SENT) { - resendEditButton = ( - - { _t('Resend edit') } - - ); - } - - if (unsentReactionsCount !== 0) { - resendReactionsButton = ( - - { _t('Resend %(unsentCount)s reaction(s)', {unsentCount: unsentReactionsCount}) } - - ); - } - } - - if (redactStatus === EventStatus.NOT_SENT) { - resendRedactionButton = ( - - { _t('Resend removal') } - - ); - } - - if (isSent && this.state.canRedact) { - redactButton = ( - - { _t('Remove') } - - ); - } - - if (allowCancel) { - cancelButton = ( - - { _t('Cancel Sending') } - - ); - } - - if (isContentActionable(mxEvent)) { - forwardButton = ( - - { _t('Forward Message') } - - ); - - if (this.state.canPin) { - pinButton = ( - - { this._isPinned() ? _t('Unpin Message') : _t('Pin Message') } - - ); - } - } - - const viewSourceButton = ( - - { _t('View Source') } - - ); - - if (mxEvent.getType() !== mxEvent.getWireType()) { - viewClearSourceButton = ( - - { _t('View Decrypted Source') } - - ); - } - - if (this.props.eventTileOps) { - if (this.props.eventTileOps.isWidgetHidden()) { - unhidePreviewButton = ( - - { _t('Unhide Preview') } - - ); - } - } - - let permalink; - if (this.props.permalinkCreator) { - permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); - } - // XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID) - const permalinkButton = ( - - { mxEvent.isRedacted() || mxEvent.getType() !== 'm.room.message' - ? _t('Share Permalink') : _t('Share Message') } - - ); - - if (this.props.eventTileOps) { // this event is rendered using TextualBody - quoteButton = ( - - { _t('Quote') } - - ); - } - - // Bridges can provide a 'external_url' to link back to the source. - if ( - typeof(mxEvent.event.content.external_url) === "string" && - isUrlPermitted(mxEvent.event.content.external_url) - ) { - externalURLButton = ( - - { _t('Source URL') } - - ); - } - - if (this.props.collapseReplyThread) { - collapseReplyThread = ( - - { _t('Collapse Reply Thread') } - - ); - } - - let reportEventButton; - if (mxEvent.getSender() !== me) { - reportEventButton = ( - - { _t('Report Content') } - - ); - } - - return ( -
      - { resendButton } - { resendEditButton } - { resendReactionsButton } - { resendRedactionButton } - { redactButton } - { cancelButton } - { forwardButton } - { pinButton } - { viewSourceButton } - { viewClearSourceButton } - { unhidePreviewButton } - { permalinkButton } - { quoteButton } - { externalURLButton } - { collapseReplyThread } - { reportEventButton } -
      - ); - } -} diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx new file mode 100644 index 0000000000..999e98f4ad --- /dev/null +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -0,0 +1,436 @@ +/* +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2015 - 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 from 'react'; +import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; + +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import dis from '../../../dispatcher/dispatcher'; +import { _t } from '../../../languageHandler'; +import Modal from '../../../Modal'; +import Resend from '../../../Resend'; +import SettingsStore from '../../../settings/SettingsStore'; +import { isUrlPermitted } from '../../../HtmlUtils'; +import { isContentActionable } from '../../../utils/EventUtils'; +import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard"; +import ForwardDialog from "../dialogs/ForwardDialog"; +import { Action } from "../../../dispatcher/actions"; +import ReportEventDialog from '../dialogs/ReportEventDialog'; +import ViewSource from '../../structures/ViewSource'; +import ConfirmRedactDialog from '../dialogs/ConfirmRedactDialog'; +import ErrorDialog from '../dialogs/ErrorDialog'; +import ShareDialog from '../dialogs/ShareDialog'; +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; + +export function canCancel(eventStatus: EventStatus): boolean { + return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; +} + +interface IEventTileOps { + isWidgetHidden(): boolean; + unhideWidget(): void; +} + +interface IProps { + /* the MatrixEvent associated with the context menu */ + mxEvent: MatrixEvent; + /* an optional EventTileOps implementation that can be used to unhide preview widgets */ + eventTileOps?: IEventTileOps; + permalinkCreator?: RoomPermalinkCreator; + /* an optional function to be called when the user clicks collapse thread, if not provided hide button */ + collapseReplyThread?(): void; + /* callback called when the menu is dismissed */ + onFinished(): void; + /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */ + onCloseDialog?(): void; +} + +interface IState { + canRedact: boolean; + canPin: boolean; +} + +@replaceableComponent("views.context_menus.MessageContextMenu") +export default class MessageContextMenu extends React.Component { + state = { + canRedact: false, + canPin: false, + }; + + componentDidMount() { + MatrixClientPeg.get().on('RoomMember.powerLevel', this.checkPermissions); + this.checkPermissions(); + } + + componentWillUnmount() { + const cli = MatrixClientPeg.get(); + if (cli) { + cli.removeListener('RoomMember.powerLevel', this.checkPermissions); + } + } + + private checkPermissions = (): void => { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.mxEvent.getRoomId()); + + // We explicitly decline to show the redact option on ACL events as it has a potential + // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 + // Similarly for encryption events, since redacting them "breaks everything" + const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) + && this.props.mxEvent.getType() !== EventType.RoomServerAcl + && this.props.mxEvent.getType() !== EventType.RoomEncryption; + let canPin = room.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli); + + // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality + if (!SettingsStore.getValue("feature_pinning")) canPin = false; + + this.setState({ canRedact, canPin }); + }; + + private isPinned(): boolean { + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, ''); + if (!pinnedEvent) return false; + const content = pinnedEvent.getContent(); + return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); + } + + private onResendReactionsClick = (): void => { + for (const reaction of this.getUnsentReactions()) { + Resend.resend(reaction); + } + this.closeMenu(); + }; + + private onReportEventClick = (): void => { + Modal.createTrackedDialog('Report Event', '', ReportEventDialog, { + mxEvent: this.props.mxEvent, + }, 'mx_Dialog_reportEvent'); + this.closeMenu(); + }; + + private onViewSourceClick = (): void => { + Modal.createTrackedDialog('View Event Source', '', ViewSource, { + mxEvent: this.props.mxEvent, + }, 'mx_Dialog_viewsource'); + this.closeMenu(); + }; + + private onRedactClick = (): void => { + Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { + onFinished: async (proceed: boolean, reason?: string) => { + if (!proceed) return; + + const cli = MatrixClientPeg.get(); + try { + this.props.onCloseDialog?.(); + await cli.redactEvent( + this.props.mxEvent.getRoomId(), + this.props.mxEvent.getId(), + undefined, + reason ? { reason } : {}, + ); + } catch (e) { + const code = e.errcode || e.statusCode; + // only show the dialog if failing for something other than a network error + // (e.g. no errcode or statusCode) as in that case the redactions end up in the + // detached queue and we show the room status bar to allow retry + if (typeof code !== "undefined") { + // display error message stating you couldn't delete this. + Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { + title: _t('Error'), + description: _t('You cannot delete this message. (%(code)s)', { code }), + }); + } + } + }, + }, 'mx_Dialog_confirmredact'); + this.closeMenu(); + }; + + private onForwardClick = (): void => { + Modal.createTrackedDialog('Forward Message', '', ForwardDialog, { + matrixClient: MatrixClientPeg.get(), + event: this.props.mxEvent, + permalinkCreator: this.props.permalinkCreator, + }); + this.closeMenu(); + }; + + private onPinClick = (): void => { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.mxEvent.getRoomId()); + const eventId = this.props.mxEvent.getId(); + + const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || []; + if (pinnedIds.includes(eventId)) { + pinnedIds.splice(pinnedIds.indexOf(eventId), 1); + } else { + pinnedIds.push(eventId); + cli.setRoomAccountData(room.roomId, ReadPinsEventId, { + event_ids: [ + ...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), + eventId, + ], + }); + } + cli.sendStateEvent(this.props.mxEvent.getRoomId(), EventType.RoomPinnedEvents, { pinned: pinnedIds }, ""); + this.closeMenu(); + }; + + private closeMenu = (): void => { + this.props.onFinished(); + }; + + private onUnhidePreviewClick = (): void => { + this.props.eventTileOps?.unhideWidget(); + this.closeMenu(); + }; + + private onQuoteClick = (): void => { + dis.dispatch({ + action: Action.ComposerInsert, + event: this.props.mxEvent, + }); + this.closeMenu(); + }; + + private onPermalinkClick = (e: React.MouseEvent): void => { + e.preventDefault(); + Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { + target: this.props.mxEvent, + permalinkCreator: this.props.permalinkCreator, + }); + this.closeMenu(); + }; + + private onCollapseReplyThreadClick = (): void => { + this.props.collapseReplyThread(); + this.closeMenu(); + }; + + private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.mxEvent.getRoomId()); + const eventId = this.props.mxEvent.getId(); + return room.getPendingEvents().filter(e => { + const relation = e.getRelation(); + return relation?.rel_type === RelationType.Annotation && relation.event_id === eventId && filter(e); + }); + } + + private getPendingReactions(): MatrixEvent[] { + return this.getReactions(e => canCancel(e.status)); + } + + private getUnsentReactions(): MatrixEvent[] { + return this.getReactions(e => e.status === EventStatus.NOT_SENT); + } + + render() { + const cli = MatrixClientPeg.get(); + const me = cli.getUserId(); + const mxEvent = this.props.mxEvent; + const eventStatus = mxEvent.status; + const unsentReactionsCount = this.getUnsentReactions().length; + + let resendReactionsButton: JSX.Element; + let redactButton: JSX.Element; + let forwardButton: JSX.Element; + let pinButton: JSX.Element; + let unhidePreviewButton: JSX.Element; + let externalURLButton: JSX.Element; + let quoteButton: JSX.Element; + let collapseReplyThread: JSX.Element; + let redactItemList: JSX.Element; + + // status is SENT before remote-echo, null after + const isSent = !eventStatus || eventStatus === EventStatus.SENT; + if (!mxEvent.isRedacted()) { + if (unsentReactionsCount !== 0) { + resendReactionsButton = ( + + ); + } + } + + if (isSent && this.state.canRedact) { + redactButton = ( + + ); + } + + if (isContentActionable(mxEvent)) { + forwardButton = ( + + ); + + if (this.state.canPin) { + pinButton = ( + + ); + } + } + + const viewSourceButton = ( + + ); + + if (this.props.eventTileOps) { + if (this.props.eventTileOps.isWidgetHidden()) { + unhidePreviewButton = ( + + ); + } + } + + let permalink; + if (this.props.permalinkCreator) { + permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + } + const permalinkButton = ( + + ); + + if (this.props.eventTileOps) { // this event is rendered using TextualBody + quoteButton = ( + + ); + } + + // Bridges can provide a 'external_url' to link back to the source. + if (typeof (mxEvent.getContent().external_url) === "string" && + isUrlPermitted(mxEvent.getContent().external_url) + ) { + externalURLButton = ( + + ); + } + + if (this.props.collapseReplyThread) { + collapseReplyThread = ( + + ); + } + + let reportEventButton: JSX.Element; + if (mxEvent.getSender() !== me) { + reportEventButton = ( + + ); + } + + const commonItemsList = ( + + { quoteButton } + { forwardButton } + { pinButton } + { permalinkButton } + { reportEventButton } + { externalURLButton } + { unhidePreviewButton } + { viewSourceButton } + { resendReactionsButton } + { collapseReplyThread } + + ); + + if (redactButton) { + redactItemList = ( + + { redactButton } + + ); + } + + return ( + + { commonItemsList } + { redactItemList } + + ); + } +} diff --git a/src/components/views/context_menus/StatusMessageContextMenu.js b/src/components/views/context_menus/StatusMessageContextMenu.js index 5e6f06dd5d..23b91fe68f 100644 --- a/src/components/views/context_menus/StatusMessageContextMenu.js +++ b/src/components/views/context_menus/StatusMessageContextMenu.js @@ -17,10 +17,12 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import * as sdk from '../../../index'; import AccessibleButton from '../elements/AccessibleButton'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +@replaceableComponent("views.context_menus.StatusMessageContextMenu") export default class StatusMessageContextMenu extends React.Component { static propTypes = { // js-sdk User object. Not required because it might not exist. diff --git a/src/components/views/context_menus/TagTileContextMenu.js b/src/components/views/context_menus/TagTileContextMenu.js index 8d690483a8..c40ff4207b 100644 --- a/src/components/views/context_menus/TagTileContextMenu.js +++ b/src/components/views/context_menus/TagTileContextMenu.js @@ -20,46 +20,73 @@ import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import TagOrderActions from '../../../actions/TagOrderActions'; -import {MenuItem} from "../../structures/ContextMenu"; +import { MenuItem } from "../../structures/ContextMenu"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore"; +@replaceableComponent("views.context_menus.TagTileContextMenu") export default class TagTileContextMenu extends React.Component { static propTypes = { tag: PropTypes.string.isRequired, + index: PropTypes.number.isRequired, /* callback called when the menu is dismissed */ onFinished: PropTypes.func.isRequired, }; static contextType = MatrixClientContext; - constructor() { - super(); - - this._onViewCommunityClick = this._onViewCommunityClick.bind(this); - this._onRemoveClick = this._onRemoveClick.bind(this); - } - - _onViewCommunityClick() { + _onViewCommunityClick = () => { dis.dispatch({ action: 'view_group', group_id: this.props.tag, }); this.props.onFinished(); - } + }; - _onRemoveClick() { + _onRemoveClick = () => { dis.dispatch(TagOrderActions.removeTag(this.context, this.props.tag)); this.props.onFinished(); - } + }; + + _onMoveUp = () => { + dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1)); + this.props.onFinished(); + }; + + _onMoveDown = () => { + dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index + 1)); + this.props.onFinished(); + }; render() { + let moveUp; + let moveDown; + if (this.props.index > 0) { + moveUp = ( + + { _t("Move up") } + + ); + } + if (this.props.index < (GroupFilterOrderStore.getOrderedTags() || []).length - 1) { + moveDown = ( + + { _t("Move down") } + + ); + } + return
      { _t('View Community') } + { (moveUp || moveDown) ?
      : null } + { moveUp } + { moveDown }
      - { _t('Hide') } + { _t("Unpin") }
      ; } diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 7656e70341..b21efdceb9 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -14,23 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useContext} from "react"; -import {MatrixCapabilities} from "matrix-widget-api"; +import React, { useContext } from "react"; +import { MatrixCapabilities } from "matrix-widget-api"; -import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu"; -import {ChevronFace} from "../../structures/ContextMenu"; -import {_t} from "../../../languageHandler"; -import WidgetStore, {IApp} from "../../../stores/WidgetStore"; +import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; +import { ChevronFace } from "../../structures/ContextMenu"; +import { _t } from "../../../languageHandler"; +import { IApp } from "../../../stores/WidgetStore"; import WidgetUtils from "../../../utils/WidgetUtils"; -import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore"; +import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; import RoomContext from "../../../contexts/RoomContext"; import dis from "../../../dispatcher/dispatcher"; import SettingsStore from "../../../settings/SettingsStore"; -import {SettingLevel} from "../../../settings/SettingLevel"; import Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; -import {WidgetType} from "../../../widgets/WidgetType"; +import ErrorDialog from "../dialogs/ErrorDialog"; +import { WidgetType } from "../../../widgets/WidgetType"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; +import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream"; interface IProps extends React.ComponentProps { app: IApp; @@ -38,6 +40,8 @@ interface IProps extends React.ComponentProps { showUnpin?: boolean; // override delete handler onDeleteClick?(): void; + // override edit handler + onEditClick?(): void; } const WidgetContextMenu: React.FC = ({ @@ -45,19 +49,41 @@ const WidgetContextMenu: React.FC = ({ app, userWidget, onDeleteClick, + onEditClick, showUnpin, ...props }) => { const cli = useContext(MatrixClientContext); - const {room, roomId} = useContext(RoomContext); + const { room, roomId } = useContext(RoomContext); const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id); const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId); + let streamAudioStreamButton; + if (getConfigLivestreamUrl() && WidgetType.JITSI.matches(app.type)) { + const onStreamAudioClick = async () => { + try { + await startJitsiAudioLivestream(widgetMessaging, roomId); + } catch (err) { + console.error("Failed to start livestream", err); + // XXX: won't i18n well, but looks like widget api only support 'message'? + const message = err.message || _t("Unable to start audio streaming."); + Modal.createTrackedDialog('WidgetContext Menu', 'Livestream failed', ErrorDialog, { + title: _t('Failed to start livestream'), + description: message, + }); + } + onFinished(); + }; + streamAudioStreamButton = ; + } + let unpinButton; if (showUnpin) { const onUnpinClick = () => { - WidgetStore.instance.unpinWidget(app.id); + WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); onFinished(); }; @@ -66,12 +92,16 @@ const WidgetContextMenu: React.FC = ({ let editButton; if (canModify && WidgetUtils.isManagedByManager(app)) { - const onEditClick = () => { - WidgetUtils.editWidget(room, app); + const _onEditClick = () => { + if (onEditClick) { + onEditClick(); + } else { + WidgetUtils.editWidget(room, app); + } onFinished(); }; - editButton = ; + editButton = ; } let snapshotButton; @@ -93,24 +123,29 @@ const WidgetContextMenu: React.FC = ({ let deleteButton; if (onDeleteClick || canModify) { - const onDeleteClickDefault = () => { - // Show delete confirmation dialog - Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { - title: _t("Delete Widget"), - description: _t( - "Deleting a widget removes it for all users in this room." + - " Are you sure you want to delete this widget?"), - button: _t("Delete widget"), - onFinished: (confirmed) => { - if (!confirmed) return; - WidgetUtils.setRoomWidget(roomId, app.id); - }, - }); + const _onDeleteClick = () => { + if (onDeleteClick) { + onDeleteClick(); + } else { + // Show delete confirmation dialog + Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { + title: _t("Delete Widget"), + description: _t( + "Deleting a widget removes it for all users in this room." + + " Are you sure you want to delete this widget?"), + button: _t("Delete widget"), + onFinished: (confirmed) => { + if (!confirmed) return; + WidgetUtils.setRoomWidget(roomId, app.id); + }, + }); + } + onFinished(); }; deleteButton = ; } @@ -127,7 +162,8 @@ const WidgetContextMenu: React.FC = ({ console.info("Revoking permission for widget to load: " + app.eventId); const current = SettingsStore.getValue("allowedWidgets", roomId); current[app.eventId] = false; - SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).catch(err => { + const level = SettingsStore.firstSupportedLevel("allowedWidgets"); + SettingsStore.setValue("allowedWidgets", roomId, level, current).catch(err => { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. }); @@ -137,13 +173,13 @@ const WidgetContextMenu: React.FC = ({ revokeButton = ; } - const pinnedWidgets = WidgetStore.instance.getPinnedApps(roomId); + const pinnedWidgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top); const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id); let moveLeftButton; if (showUnpin && widgetIndex > 0) { const onClick = () => { - WidgetStore.instance.movePinnedWidget(app.id, -1); + WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1); onFinished(); }; @@ -153,7 +189,7 @@ const WidgetContextMenu: React.FC = ({ let moveRightButton; if (showUnpin && widgetIndex < pinnedWidgets.length - 1) { const onClick = () => { - WidgetStore.instance.movePinnedWidget(app.id, 1); + WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, 1); onFinished(); }; @@ -162,6 +198,7 @@ const WidgetContextMenu: React.FC = ({ return + { streamAudioStreamButton } { editButton } { revokeButton } { deleteButton } @@ -174,4 +211,3 @@ const WidgetContextMenu: React.FC = ({ }; export default WidgetContextMenu; - diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx new file mode 100644 index 0000000000..5024b98def --- /dev/null +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -0,0 +1,365 @@ +/* +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, { ReactNode, useContext, useMemo, useState } from "react"; +import classNames from "classnames"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { sleep } from "matrix-js-sdk/src/utils"; + +import { _t } from '../../../languageHandler'; +import { IDialogProps } from "./IDialogProps"; +import BaseDialog from "./BaseDialog"; +import Dropdown from "../elements/Dropdown"; +import SearchBox from "../../structures/SearchBox"; +import SpaceStore from "../../../stores/SpaceStore"; +import RoomAvatar from "../avatars/RoomAvatar"; +import { getDisplayAliasForRoom } from "../../../Rooms"; +import AccessibleButton from "../elements/AccessibleButton"; +import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import { calculateRoomVia } from "../../../utils/permalinks/Permalinks"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import ProgressBar from "../elements/ProgressBar"; +import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import QueryMatcher from "../../../autocomplete/QueryMatcher"; +import TruncatedList from "../elements/TruncatedList"; +import EntityTile from "../rooms/EntityTile"; +import BaseAvatar from "../avatars/BaseAvatar"; + +interface IProps extends IDialogProps { + matrixClient: MatrixClient; + space: Room; + onCreateRoomClick(cli: MatrixClient, space: Room): void; +} + +const Entry = ({ room, checked, onChange }) => { + return ; +}; + +interface IAddExistingToSpaceProps { + space: Room; + footerPrompt?: ReactNode; + emptySelectionButton?: ReactNode; + onFinished(added: boolean): void; +} + +export const AddExistingToSpace: React.FC = ({ + space, + footerPrompt, + emptySelectionButton, + onFinished, +}) => { + const cli = useContext(MatrixClientContext); + const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]); + + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + const lcQuery = query.toLowerCase().trim(); + + const existingSubspacesSet = useMemo(() => new Set(SpaceStore.instance.getChildSpaces(space.roomId)), [space]); + const existingRoomsSet = useMemo(() => new Set(SpaceStore.instance.getChildRooms(space.roomId)), [space]); + + const [spaces, rooms, dms] = useMemo(() => { + let rooms = visibleRooms; + + if (lcQuery) { + const matcher = new QueryMatcher(visibleRooms, { + keys: ["name"], + funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)], + shouldMatchWordsOnly: false, + }); + + rooms = matcher.match(lcQuery); + } + + const joinRule = space.getJoinRule(); + return sortRooms(rooms).reduce((arr, room) => { + if (room.isSpaceRoom()) { + if (room !== space && !existingSubspacesSet.has(room)) { + arr[0].push(room); + } + } else if (!existingRoomsSet.has(room)) { + if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { + arr[1].push(room); + } else if (joinRule !== "public") { + // Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones. + arr[2].push(room); + } + } + return arr; + }, [[], [], []]); + }, [visibleRooms, space, lcQuery, existingRoomsSet, existingSubspacesSet]); + + const addRooms = async () => { + setError(null); + setProgress(0); + + let error; + + for (const room of selectedToAdd) { + const via = calculateRoomVia(room); + try { + await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => { + if (e.errcode === "M_LIMIT_EXCEEDED") { + await sleep(e.data.retry_after_ms); + return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry + } + + throw e; + }); + setProgress(i => i + 1); + } catch (e) { + console.error("Failed to add rooms to space", e); + setError(error = e); + break; + } + } + + if (!error) { + onFinished(true); + } + }; + + const busy = progress !== null; + + let footer; + if (error) { + footer = <> + + + +
      { _t("Not all selected were added") }
      +
      { _t("Try again") }
      +
      + + + { _t("Retry") } + + ; + } else if (busy) { + footer = + +
      + { _t("Adding rooms... (%(progress)s out of %(count)s)", { + count: selectedToAdd.size, + progress, + }) } +
      +
      ; + } else { + let button = emptySelectionButton; + if (!button || selectedToAdd.size > 0) { + button = + { _t("Add") } + ; + } + + footer = <> + + { footerPrompt } + + + { button } + ; + } + + const onChange = !busy && !error ? (checked, room) => { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + } : null; + + const [truncateAt, setTruncateAt] = useState(20); + function overflowTile(overflowCount, totalCount) { + const text = _t("and %(count)s others...", { count: overflowCount }); + return ( + + } name={text} presenceState="online" suppressOnHover={true} + onClick={() => setTruncateAt(totalCount)} /> + ); + } + + return
      + + + { rooms.length > 0 ? ( +
      +

      { _t("Rooms") }

      + rooms.slice(start, end).map(room => + { + onChange(checked, room); + } : null} + />, + )} + getChildCount={() => rooms.length} + /> +
      + ) : undefined } + + { spaces.length > 0 ? ( +
      +

      { _t("Spaces") }

      +
      +
      { _t("Feeling experimental?") }
      +
      { _t("You can add existing spaces to a space.") }
      +
      + { spaces.map(space => { + return { + onChange(checked, space); + } : null} + />; + }) } +
      + ) : null } + + { dms.length > 0 ? ( +
      +

      { _t("Direct Messages") }

      + { dms.map(room => { + return { + onChange(checked, room); + } : null} + />; + }) } +
      + ) : null } + + { spaces.length + rooms.length + dms.length < 1 ? + { _t("No results") } + : undefined } +
      + +
      + { footer } +
      +
      ; +}; + +const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { + const [selectedSpace, setSelectedSpace] = useState(space); + const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); + + let spaceOptionSection; + if (existingSubspaces.length > 0) { + const options = [space, ...existingSubspaces].map((space) => { + const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", { + mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace, + }); + return
      + + { space.name || getDisplayAliasForRoom(space) || space.roomId } +
      ; + }); + + spaceOptionSection = ( + { + setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space); + }} + value={selectedSpace.roomId} + label={_t("Space selection")} + > + { options } + + ); + } else { + spaceOptionSection =
      + { space.name || getDisplayAliasForRoom(space) || space.roomId } +
      ; + } + + const title = + +
      +

      { _t("Add existing rooms") }

      + { spaceOptionSection } +
      +
      ; + + return + + +
      { _t("Want to add a new room instead?") }
      + onCreateRoomClick(cli, space)} kind="link"> + { _t("Create a new room") } + + } + /> +
      + + onFinished(false)} /> +
      ; +}; + +export default AddExistingToSpaceDialog; + diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 2cd09874b2..09714e24e3 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -17,22 +17,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; +import { sleep } from "matrix-js-sdk/src/utils"; import { _t, _td } from '../../../languageHandler'; import * as sdk from '../../../index'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import dis from '../../../dispatcher/dispatcher'; -import { addressTypes, getAddressType } from '../../../UserAddress.js'; +import { addressTypes, getAddressType } from '../../../UserAddress'; import GroupStore from '../../../stores/GroupStore'; import * as Email from '../../../email'; import IdentityAuthClient from '../../../IdentityAuthClient'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils'; import { abbreviateUrl } from '../../../utils/UrlUtils'; -import {sleep} from "../../../utils/promise"; -import {Key} from "../../../Keyboard"; -import {Action} from "../../../dispatcher/actions"; +import { Key } from "../../../Keyboard"; +import { Action } from "../../../dispatcher/actions"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; const TRUNCATE_QUERY_LIST = 40; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; @@ -43,7 +44,7 @@ const addressTypeName = { 'email': _td("email address"), }; - +@replaceableComponent("views.dialogs.AddressPickerDialog") export default class AddressPickerDialog extends React.Component { static propTypes = { title: PropTypes.string.isRequired, @@ -456,7 +457,7 @@ export default class AddressPickerDialog extends React.Component { const addrType = getAddressType(query); if (this.state.validAddressTypes.includes(addrType)) { if (addrType === 'email' && !Email.looksValid(query)) { - this.setState({searchError: _t("That doesn't look like a valid email address")}); + this.setState({ searchError: _t("That doesn't look like a valid email address") }); return; } suggestedList.unshift({ @@ -572,13 +573,13 @@ export default class AddressPickerDialog extends React.Component { _getFilteredSuggestions() { // map addressType => set of addresses to avoid O(n*m) operation const selectedAddresses = {}; - this.state.selectedList.forEach(({address, addressType}) => { + this.state.selectedList.forEach(({ address, addressType }) => { if (!selectedAddresses[addressType]) selectedAddresses[addressType] = new Set(); selectedAddresses[addressType].add(address); }); // Filter out any addresses in the above already selected addresses (matching both type and address) - return this.state.suggestedList.filter(({address, addressType}) => { + return this.state.suggestedList.filter(({ address, addressType }) => { return !(selectedAddresses[addressType] && selectedAddresses[addressType].has(address)); }); } diff --git a/src/components/views/dialogs/AskInviteAnywayDialog.js b/src/components/views/dialogs/AskInviteAnywayDialog.tsx similarity index 69% rename from src/components/views/dialogs/AskInviteAnywayDialog.js rename to src/components/views/dialogs/AskInviteAnywayDialog.tsx index c69400977a..26fad0c724 100644 --- a/src/components/views/dialogs/AskInviteAnywayDialog.js +++ b/src/components/views/dialogs/AskInviteAnywayDialog.tsx @@ -15,49 +15,52 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; -import {SettingLevel} from "../../../settings/SettingLevel"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; -export default class AskInviteAnywayDialog extends React.Component { - static propTypes = { - unknownProfileUsers: PropTypes.array.isRequired, // [ {userId, errorText}... ] - onInviteAnyways: PropTypes.func.isRequired, - onGiveUp: PropTypes.func.isRequired, - onFinished: PropTypes.func.isRequired, - }; +interface IProps { + unknownProfileUsers: Array<{ + userId: string; + errorText: string; + }>; + onInviteAnyways: () => void; + onGiveUp: () => void; + onFinished: (success: boolean) => void; +} - _onInviteClicked = () => { +@replaceableComponent("views.dialogs.AskInviteAnywayDialog") +export default class AskInviteAnywayDialog extends React.Component { + private onInviteClicked = (): void => { this.props.onInviteAnyways(); this.props.onFinished(true); }; - _onInviteNeverWarnClicked = () => { + private onInviteNeverWarnClicked = (): void => { SettingsStore.setValue("promptBeforeInviteUnknownUsers", null, SettingLevel.ACCOUNT, false); this.props.onInviteAnyways(); this.props.onFinished(true); }; - _onGiveUpClicked = () => { + private onGiveUpClicked = (): void => { this.props.onGiveUp(); this.props.onFinished(false); }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - + public render() { const errorList = this.props.unknownProfileUsers .map(address =>
    1. {address.userId}: {address.errorText}
    2. ); return (
      + {/* eslint-disable-next-line */}

      {_t("Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?")}

        { errorList } @@ -65,13 +68,13 @@ export default class AskInviteAnywayDialog extends React.Component {
      - - -
      diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index 9ba5368ee5..e92bd6315e 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -23,9 +23,10 @@ import classNames from 'classnames'; import { Key } from '../../../Keyboard'; import AccessibleButton from '../elements/AccessibleButton'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from "../../../languageHandler"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; /* * Basic container for modal dialogs. @@ -33,6 +34,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; * Includes a div for the title, and a keypress handler which cancels the * dialog on escape. */ +@replaceableComponent("views.dialogs.BaseDialog") export default class BaseDialog extends React.Component { static propTypes = { // onFinished callback to call when Escape is pressed diff --git a/src/components/views/dialogs/BetaFeedbackDialog.tsx b/src/components/views/dialogs/BetaFeedbackDialog.tsx new file mode 100644 index 0000000000..5a2f16f169 --- /dev/null +++ b/src/components/views/dialogs/BetaFeedbackDialog.tsx @@ -0,0 +1,111 @@ +/* +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, { useState } from "react"; + +import QuestionDialog from './QuestionDialog'; +import { _t } from '../../../languageHandler'; +import Field from "../elements/Field"; +import SdkConfig from "../../../SdkConfig"; +import { IDialogProps } from "./IDialogProps"; +import SettingsStore from "../../../settings/SettingsStore"; +import { submitFeedback } from "../../../rageshake/submit-rageshake"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; +import AccessibleButton from "../elements/AccessibleButton"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { UserTab } from "./UserSettingsDialog"; + +interface IProps extends IDialogProps { + featureId: string; +} + +const BetaFeedbackDialog: React.FC = ({ featureId, onFinished }) => { + const info = SettingsStore.getBetaInfo(featureId); + + const [comment, setComment] = useState(""); + const [canContact, setCanContact] = useState(false); + + const sendFeedback = async (ok: boolean) => { + if (!ok) return onFinished(false); + + const extraData = SettingsStore.getBetaInfo(featureId)?.extraSettings.reduce((o, k) => { + o[k] = SettingsStore.getValue(k); + return o; + }, {}); + + submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact, extraData); + onFinished(true); + + Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, { + title: _t("Beta feedback"), + description: _t("Thank you for your feedback, we really appreciate it."), + button: _t("Done"), + hasCloseButton: false, + fixedWidth: false, + }); + }; + + return ( +
      + { _t(info.feedbackSubheading) } +   + { _t("Your platform and username will be noted to help us use your feedback as much as we can.")} + + { + onFinished(false); + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Labs, + }); + }}> + { _t("To leave the beta, visit your settings.") } + +
      + + { + setComment(ev.target.value); + }} + autoFocus={true} + /> + + setCanContact((e.target as HTMLInputElement).checked)} + > + { _t("You may contact me if you have any follow up questions") } + + } + button={_t("Send feedback")} + buttonDisabled={!comment} + onFinished={sendFeedback} + />); +}; + +export default BetaFeedbackDialog; diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.tsx similarity index 72% rename from src/components/views/dialogs/BugReportDialog.js rename to src/components/views/dialogs/BugReportDialog.tsx index c4dd0a1430..6baf24f797 100644 --- a/src/components/views/dialogs/BugReportDialog.js +++ b/src/components/views/dialogs/BugReportDialog.tsx @@ -18,15 +18,39 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -import sendBugReport, {downloadBugReport} from '../../../rageshake/submit-rageshake'; +import sendBugReport, { downloadBugReport } from '../../../rageshake/submit-rageshake'; import AccessibleButton from "../elements/AccessibleButton"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import QuestionDialog from "./QuestionDialog"; +import BaseDialog from "./BaseDialog"; +import Field from '../elements/Field'; +import Spinner from "../elements/Spinner"; +import DialogButtons from "../elements/DialogButtons"; + +interface IProps { + onFinished: (success: boolean) => void; + initialText?: string; + label?: string; +} + +interface IState { + sendLogs: boolean; + busy: boolean; + err: string; + issueUrl: string; + text: string; + progress: string; + downloadBusy: boolean; + downloadProgress: string; +} + +@replaceableComponent("views.dialogs.BugReportDialog") +export default class BugReportDialog extends React.Component { + private unmounted: boolean; -export default class BugReportDialog extends React.Component { constructor(props) { super(props); this.state = { @@ -39,25 +63,18 @@ export default class BugReportDialog extends React.Component { downloadBusy: false, downloadProgress: null, }; - this._unmounted = false; - this._onSubmit = this._onSubmit.bind(this); - this._onCancel = this._onCancel.bind(this); - this._onTextChange = this._onTextChange.bind(this); - this._onIssueUrlChange = this._onIssueUrlChange.bind(this); - this._onSendLogsChange = this._onSendLogsChange.bind(this); - this._sendProgressCallback = this._sendProgressCallback.bind(this); - this._downloadProgressCallback = this._downloadProgressCallback.bind(this); + this.unmounted = false; } - componentWillUnmount() { - this._unmounted = true; + public componentWillUnmount() { + this.unmounted = true; } - _onCancel(ev) { + private onCancel = (): void => { this.props.onFinished(false); - } + }; - _onSubmit(ev) { + private onSubmit = (): void => { if ((!this.state.text || !this.state.text.trim()) && (!this.state.issueUrl || !this.state.issueUrl.trim())) { this.setState({ err: _t("Please tell us what went wrong or, better, create a GitHub issue that describes the problem."), @@ -70,17 +87,16 @@ export default class BugReportDialog extends React.Component { (this.state.issueUrl.length > 0 ? this.state.issueUrl : 'No issue link given'); this.setState({ busy: true, progress: null, err: null }); - this._sendProgressCallback(_t("Preparing to send logs")); + this.sendProgressCallback(_t("Preparing to send logs")); sendBugReport(SdkConfig.get().bug_report_endpoint_url, { userText, sendLogs: true, - progressCallback: this._sendProgressCallback, + progressCallback: this.sendProgressCallback, label: this.props.label, }).then(() => { - if (!this._unmounted) { + if (!this.unmounted) { this.props.onFinished(false); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); // N.B. first param is passed to piwik and so doesn't want i18n Modal.createTrackedDialog('Bug report sent', '', QuestionDialog, { title: _t('Logs sent'), @@ -89,7 +105,7 @@ export default class BugReportDialog extends React.Component { }); } }, (err) => { - if (!this._unmounted) { + if (!this.unmounted) { this.setState({ busy: false, progress: null, @@ -97,16 +113,16 @@ export default class BugReportDialog extends React.Component { }); } }); - } + }; - _onDownload = async (ev) => { + private onDownload = async (): Promise => { this.setState({ downloadBusy: true }); - this._downloadProgressCallback(_t("Preparing to download logs")); + this.downloadProgressCallback(_t("Preparing to download logs")); try { await downloadBugReport({ sendLogs: true, - progressCallback: this._downloadProgressCallback, + progressCallback: this.downloadProgressCallback, label: this.props.label, }); @@ -115,7 +131,7 @@ export default class BugReportDialog extends React.Component { downloadProgress: null, }); } catch (err) { - if (!this._unmounted) { + if (!this.unmounted) { this.setState({ downloadBusy: false, downloadProgress: _t("Failed to send logs: ") + `${err.message}`, @@ -124,38 +140,29 @@ export default class BugReportDialog extends React.Component { } }; - _onTextChange(ev) { - this.setState({ text: ev.target.value }); - } + private onTextChange = (ev: React.FormEvent): void => { + this.setState({ text: ev.currentTarget.value }); + }; - _onIssueUrlChange(ev) { - this.setState({ issueUrl: ev.target.value }); - } + private onIssueUrlChange = (ev: React.FormEvent): void => { + this.setState({ issueUrl: ev.currentTarget.value }); + }; - _onSendLogsChange(ev) { - this.setState({ sendLogs: ev.target.checked }); - } - - _sendProgressCallback(progress) { - if (this._unmounted) { + private sendProgressCallback = (progress: string): void => { + if (this.unmounted) { return; } - this.setState({progress: progress}); - } + this.setState({ progress }); + }; - _downloadProgressCallback(downloadProgress) { - if (this._unmounted) { + private downloadProgressCallback = (downloadProgress: string): void => { + if (this.unmounted) { return; } this.setState({ downloadProgress }); - } - - render() { - const Loader = sdk.getComponent("elements.Spinner"); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const Field = sdk.getComponent('elements.Field'); + }; + public render() { let error = null; if (this.state.err) { error =
      @@ -167,7 +174,7 @@ export default class BugReportDialog extends React.Component { if (this.state.busy) { progress = (
      - + {this.state.progress} ...
      ); @@ -181,8 +188,8 @@ export default class BugReportDialog extends React.Component { } return ( -
      @@ -211,7 +218,7 @@ export default class BugReportDialog extends React.Component {

      - + { _t("Download logs") } {this.state.downloadProgress && {this.state.downloadProgress} ...} @@ -221,7 +228,7 @@ export default class BugReportDialog extends React.Component { type="text" className="mx_BugReportDialog_field_input" label={_t("GitHub issue")} - onChange={this._onIssueUrlChange} + onChange={this.onIssueUrlChange} value={this.state.issueUrl} placeholder="https://github.com/vector-im/element-web/issues/..." /> @@ -230,7 +237,7 @@ export default class BugReportDialog extends React.Component { element="textarea" label={_t("Notes")} rows={5} - onChange={this._onTextChange} + onChange={this.onTextChange} value={this.state.text} placeholder={_t( "If there is additional context that would help in " + @@ -243,17 +250,12 @@ export default class BugReportDialog extends React.Component { {error}
      ); } } - -BugReportDialog.propTypes = { - onFinished: PropTypes.func.isRequired, - initialText: PropTypes.string, -}; diff --git a/src/components/views/dialogs/ChangelogDialog.js b/src/components/views/dialogs/ChangelogDialog.tsx similarity index 81% rename from src/components/views/dialogs/ChangelogDialog.js rename to src/components/views/dialogs/ChangelogDialog.tsx index 50bc13cff5..d484d94249 100644 --- a/src/components/views/dialogs/ChangelogDialog.js +++ b/src/components/views/dialogs/ChangelogDialog.tsx @@ -16,21 +16,27 @@ Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import request from 'browser-request'; import { _t } from '../../../languageHandler'; +import QuestionDialog from "./QuestionDialog"; +import Spinner from "../elements/Spinner"; + +interface IProps { + newVersion: string; + version: string; + onFinished: (success: boolean) => void; +} const REPOS = ['vector-im/element-web', 'matrix-org/matrix-react-sdk', 'matrix-org/matrix-js-sdk']; -export default class ChangelogDialog extends React.Component { +export default class ChangelogDialog extends React.Component { constructor(props) { super(props); this.state = {}; } - componentDidMount() { + public componentDidMount() { const version = this.props.newVersion.split('-'); const version2 = this.props.version.split('-'); if (version == null || version2 == null) return; @@ -44,12 +50,12 @@ export default class ChangelogDialog extends React.Component { this.setState({ [REPOS[i]]: response.statusText }); return; } - this.setState({[REPOS[i]]: JSON.parse(body).commits}); + this.setState({ [REPOS[i]]: JSON.parse(body).commits }); }); } } - _elementsForCommit(commit) { + private elementsForCommit(commit): JSX.Element { return (
    3. @@ -59,10 +65,7 @@ export default class ChangelogDialog extends React.Component { ); } - render() { - const Spinner = sdk.getComponent('views.elements.Spinner'); - const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); - + public render() { const logs = REPOS.map(repo => { let content; if (this.state[repo] == null) { @@ -72,7 +75,7 @@ export default class ChangelogDialog extends React.Component { msg: this.state[repo], }); } else { - content = this.state[repo].map(this._elementsForCommit); + content = this.state[repo].map(this.elementsForCommit); } return (
      @@ -88,20 +91,13 @@ export default class ChangelogDialog extends React.Component {
      ); - return ( + /> ); } } - -ChangelogDialog.propTypes = { - version: PropTypes.string.isRequired, - newVersion: PropTypes.string.isRequired, - onFinished: PropTypes.func.isRequired, -}; diff --git a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx index 1c8a4ad6f6..7627489deb 100644 --- a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx +++ b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx @@ -26,11 +26,12 @@ import SdkConfig from "../../../SdkConfig"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import InviteDialog from "./InviteDialog"; import BaseAvatar from "../avatars/BaseAvatar"; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; -import {inviteMultipleToRoom, showAnyInviteErrors} from "../../../RoomInvite"; +import { inviteMultipleToRoom, showAnyInviteErrors } from "../../../RoomInvite"; import StyledCheckbox from "../elements/StyledCheckbox"; import Modal from "../../../Modal"; import ErrorDialog from "./ErrorDialog"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; interface IProps extends IDialogProps { roomId: string; @@ -52,6 +53,7 @@ interface IState { busy: boolean; } +@replaceableComponent("views.dialogs.CommunityPrototypeInviteDialog") export default class CommunityPrototypeInviteDialog extends React.PureComponent { constructor(props: IProps) { super(props); @@ -84,7 +86,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< ev.preventDefault(); ev.stopPropagation(); - this.setState({busy: true}); + this.setState({ busy: true }); try { const targets = [...this.state.emailTargets, ...this.state.userTargets]; const result = await inviteMultipleToRoom(this.props.roomId, targets); @@ -93,10 +95,10 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< if (success) { this.props.onFinished(true); } else { - this.setState({busy: false}); + this.setState({ busy: false }); } } catch (e) { - this.setState({busy: false}); + this.setState({ busy: false }); console.error(e); Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { title: _t("Failed to invite"), @@ -112,7 +114,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< } else { targets[index] = ev.target.value; } - this.setState({emailTargets: targets}); + this.setState({ emailTargets: targets }); }; private onAddressBlur = (index: number) => { @@ -120,12 +122,12 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< if (index >= targets.length) return; // not important if (targets[index].trim() === "") { targets.splice(index, 1); - this.setState({emailTargets: targets}); + this.setState({ emailTargets: targets }); } }; private onShowPeopleClick = () => { - this.setState({showPeople: !this.state.showPeople}); + this.setState({ showPeople: !this.state.showPeople }); }; private setPersonToggle = (person: IPerson, selected: boolean) => { @@ -135,17 +137,19 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< } else if (!selected && targets.includes(person.userId)) { targets.splice(targets.indexOf(person.userId), 1); } - this.setState({userTargets: targets}); + this.setState({ userTargets: targets }); }; private renderPerson(person: IPerson, key: any) { const avatarSize = 36; + let avatarUrl = null; + if (person.user.getMxcAvatarUrl()) { + avatarUrl = mediaFromMxc(person.user.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize); + } return (
      { - this.setState({numPeople: this.state.numPeople + 5}); // arbitrary increase + this.setState({ numPeople: this.state.numPeople + 5 }); // arbitrary increase }; public render() { @@ -210,7 +214,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< if (this.state.people.length > 0) { peopleIntro = (
      - {_t("People you know on %(brand)s", {brand: SdkConfig.get().brand})} + {_t("People you know on %(brand)s", { brand: SdkConfig.get().brand })} {this.state.showPeople ? _t("Hide") : _t("Show")} @@ -221,14 +225,14 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< let buttonText = _t("Skip"); const targetCount = this.state.userTargets.length + this.state.emailTargets.length; if (targetCount > 0) { - buttonText = _t("Send %(count)s invites", {count: targetCount}); + buttonText = _t("Send %(count)s invites", { count: targetCount }); } return (
      diff --git a/src/components/views/dialogs/ConfirmAndWaitRedactDialog.js b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx similarity index 77% rename from src/components/views/dialogs/ConfirmAndWaitRedactDialog.js rename to src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx index 0622dd7dfb..d21fde329c 100644 --- a/src/components/views/dialogs/ConfirmAndWaitRedactDialog.js +++ b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx @@ -15,8 +15,22 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ConfirmRedactDialog from './ConfirmRedactDialog'; +import ErrorDialog from './ErrorDialog'; +import BaseDialog from "./BaseDialog"; +import Spinner from "../elements/Spinner"; + +interface IProps { + redact: () => Promise; + onFinished: (success: boolean) => void; +} + +interface IState { + isRedacting: boolean; + redactionErrorCode: string | number; +} /* * A dialog for confirming a redaction. @@ -30,7 +44,8 @@ import { _t } from '../../../languageHandler'; * * To avoid this, we keep the dialog open as long as /redact is in progress. */ -export default class ConfirmAndWaitRedactDialog extends React.PureComponent { +@replaceableComponent("views.dialogs.ConfirmAndWaitRedactDialog") +export default class ConfirmAndWaitRedactDialog extends React.PureComponent { constructor(props) { super(props); this.state = { @@ -39,16 +54,16 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent { }; } - onParentFinished = async (proceed) => { + public onParentFinished = async (proceed: boolean): Promise => { if (proceed) { - this.setState({isRedacting: true}); + this.setState({ isRedacting: true }); try { await this.props.redact(); this.props.onFinished(true); } catch (error) { const code = error.errcode || error.statusCode; if (typeof code !== "undefined") { - this.setState({redactionErrorCode: code}); + this.setState({ redactionErrorCode: code }); } else { this.props.onFinished(true); } @@ -58,21 +73,18 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent { } }; - render() { + public render() { if (this.state.isRedacting) { if (this.state.redactionErrorCode) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const code = this.state.redactionErrorCode; return ( ); } else { - const BaseDialog = sdk.getComponent("dialogs.BaseDialog"); - const Spinner = sdk.getComponent('elements.Spinner'); return ( ; } } diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.tsx similarity index 71% rename from src/components/views/dialogs/ConfirmRedactDialog.js rename to src/components/views/dialogs/ConfirmRedactDialog.tsx index 3106df1d5b..a2f2b10144 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.js +++ b/src/components/views/dialogs/ConfirmRedactDialog.tsx @@ -15,23 +15,30 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import TextInputDialog from "./TextInputDialog"; + +interface IProps { + onFinished: (success: boolean) => void; +} /* * A dialog for confirming a redaction. */ -export default class ConfirmRedactDialog extends React.Component { +@replaceableComponent("views.dialogs.ConfirmRedactDialog") +export default class ConfirmRedactDialog extends React.Component { render() { - const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog'); return ( - - + ); } } diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.tsx similarity index 66% rename from src/components/views/dialogs/ConfirmUserActionDialog.js rename to src/components/views/dialogs/ConfirmUserActionDialog.tsx index 44f57f047e..cbef474c69 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.tsx @@ -15,11 +15,34 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import { MatrixClient } from 'matrix-js-sdk'; -import * as sdk from '../../../index'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { _t } from '../../../languageHandler'; import { GroupMemberType } from '../../../groups'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; +import MemberAvatar from '../avatars/MemberAvatar'; +import BaseAvatar from '../avatars/BaseAvatar'; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; + +interface IProps { + // matrix-js-sdk (room) member object. Supply either this or 'groupMember' + member: RoomMember; + // group member object. Supply either this or 'member' + groupMember: GroupMemberType; + // needed if a group member is specified + matrixClient?: MatrixClient; + action: string; // eg. 'Ban' + title: string; // eg. 'Ban this user?' + + // Whether to display a text field for a reason + // If true, the second argument to onFinished will + // be the string entered. + askReason?: boolean; + danger?: boolean; + onFinished: (success: boolean, reason?: string) => void; +} /* * A dialog for confirming an operation on another user. @@ -29,58 +52,24 @@ import { GroupMemberType } from '../../../groups'; * to make it obvious what is going to happen. * Also tweaks the style for 'dangerous' actions (albeit only with colour) */ -export default class ConfirmUserActionDialog extends React.Component { - static propTypes = { - // matrix-js-sdk (room) member object. Supply either this or 'groupMember' - member: PropTypes.object, - // group member object. Supply either this or 'member' - groupMember: GroupMemberType, - // needed if a group member is specified - matrixClient: PropTypes.instanceOf(MatrixClient), - action: PropTypes.string.isRequired, // eg. 'Ban' - title: PropTypes.string.isRequired, // eg. 'Ban this user?' - - // Whether to display a text field for a reason - // If true, the second argument to onFinished will - // be the string entered. - askReason: PropTypes.bool, - danger: PropTypes.bool, - onFinished: PropTypes.func.isRequired, - }; +@replaceableComponent("views.dialogs.ConfirmUserActionDialog") +export default class ConfirmUserActionDialog extends React.Component { + private reasonField: React.RefObject = React.createRef(); static defaultProps = { danger: false, askReason: false, }; - constructor(props) { - super(props); - - this._reasonField = null; - } - - onOk = () => { - let reason; - if (this._reasonField) { - reason = this._reasonField.value; - } - this.props.onFinished(true, reason); + public onOk = (): void => { + this.props.onFinished(true, this.reasonField.current?.value); }; - onCancel = () => { + public onCancel = (): void => { this.props.onFinished(false); }; - _collectReasonField = e => { - this._reasonField = e; - }; - - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); - const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); - + public render() { const confirmButtonClass = this.props.danger ? 'danger' : ''; let reasonBox; @@ -89,7 +78,7 @@ export default class ConfirmUserActionDialog extends React.Component {
      @@ -106,8 +95,9 @@ export default class ConfirmUserActionDialog extends React.Component { name = this.props.member.name; userId = this.props.member.userId; } else { - const httpAvatarUrl = this.props.groupMember.avatarUrl ? - this.props.matrixClient.mxcUrlToHttp(this.props.groupMember.avatarUrl, 48, 48) : null; + const httpAvatarUrl = this.props.groupMember.avatarUrl + ? mediaFromMxc(this.props.groupMember.avatarUrl).getSquareThumbnailHttp(48) + : null; name = this.props.groupMember.displayname || this.props.groupMember.userId; userId = this.props.groupMember.userId; avatar = ; diff --git a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js b/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx similarity index 64% rename from src/components/views/dialogs/ConfirmWipeDeviceDialog.js rename to src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx index 41ef9131fa..544d0df1c9 100644 --- a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js +++ b/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx @@ -15,31 +15,33 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {_t} from "../../../languageHandler"; -import * as sdk from "../../../index"; +import { _t } from "../../../languageHandler"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; -export default class ConfirmWipeDeviceDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; +interface IProps { + onFinished: (success: boolean) => void; +} - _onConfirm = () => { +@replaceableComponent("views.dialogs.ConfirmWipeDeviceDialog") +export default class ConfirmWipeDeviceDialog extends React.Component { + private onConfirm = (): void => { this.props.onFinished(true); }; - _onDecline = () => { + private onDecline = (): void => { this.props.onFinished(false); }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return ( - +

      {_t( @@ -50,10 +52,10 @@ export default class ConfirmWipeDeviceDialog extends React.Component {

      ); diff --git a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx index 1d9d92b9c9..29e9a2ad39 100644 --- a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx +++ b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx @@ -23,8 +23,9 @@ import AccessibleButton from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import InfoTooltip from "../elements/InfoTooltip"; import dis from "../../../dispatcher/dispatcher"; -import {showCommunityRoomInviteDialog} from "../../../RoomInvite"; +import { showCommunityRoomInviteDialog } from "../../../RoomInvite"; import GroupStore from "../../../stores/GroupStore"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends IDialogProps { } @@ -38,6 +39,7 @@ interface IState { avatarPreview: string; } +@replaceableComponent("views.dialogs.CreateCommunityPrototypeDialog") export default class CreateCommunityPrototypeDialog extends React.PureComponent { private avatarUploadRef: React.RefObject = React.createRef(); @@ -56,7 +58,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< private onNameChange = (ev: ChangeEvent) => { const localpart = (ev.target.value || "").toLowerCase().replace(/[^a-z0-9.\-_]/g, '-'); - this.setState({name: ev.target.value, localpart}); + this.setState({ name: ev.target.value, localpart }); }; private onSubmit = async (ev) => { @@ -67,7 +69,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< // We'll create the community now to see if it's taken, leaving it active in // the background for the user to look at while they invite people. - this.setState({busy: true}); + this.setState({ busy: true }); try { let avatarUrl = ''; // must be a string for synapse to accept it if (this.state.avatarFile) { @@ -83,7 +85,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< }); // Ensure the tag gets selected now that we've created it - dis.dispatch({action: 'deselect_tags'}, true); + dis.dispatch({ action: 'deselect_tags' }, true); dis.dispatch({ action: 'select_tag', tag: result.group_id, @@ -121,13 +123,13 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< private onAvatarChanged = (e: ChangeEvent) => { if (!e.target.files || !e.target.files.length) { - this.setState({avatarFile: null}); + this.setState({ avatarFile: null }); } else { - this.setState({busy: true}); + this.setState({ busy: true }); const file = e.target.files[0]; const reader = new FileReader(); reader.onload = (ev: ProgressEvent) => { - this.setState({avatarFile: file, busy: false, avatarPreview: ev.target.result as string}); + this.setState({ avatarFile: file, busy: false, avatarPreview: ev.target.result as string }); }; reader.readAsDataURL(file); } @@ -173,7 +175,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< let preview = ; if (!this.state.avatarPreview) { - preview =
      + preview =
      ; } return ( @@ -202,7 +204,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent<
      diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.tsx similarity index 76% rename from src/components/views/dialogs/CreateGroupDialog.js rename to src/components/views/dialogs/CreateGroupDialog.tsx index 6636153c98..d6bb582079 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.tsx @@ -15,42 +15,52 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; +import Spinner from "../elements/Spinner"; -export default class CreateGroupDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; +interface IProps { + onFinished: (success: boolean) => void; +} - state = { +interface IState { + groupName: string; + groupId: string; + groupIdError: string; + creating: boolean; + createError: Error; +} + +@replaceableComponent("views.dialogs.CreateGroupDialog") +export default class CreateGroupDialog extends React.Component { + public state = { groupName: '', groupId: '', - groupError: null, + groupIdError: '', creating: false, createError: null, }; - _onGroupNameChange = e => { + private onGroupNameChange = (e: React.FormEvent): void => { this.setState({ - groupName: e.target.value, + groupName: e.currentTarget.value, }); }; - _onGroupIdChange = e => { + private onGroupIdChange = (e: React.FormEvent): void => { this.setState({ - groupId: e.target.value, + groupId: e.currentTarget.value, }); }; - _onGroupIdBlur = e => { - this._checkGroupId(); + private onGroupIdBlur = (): void => { + this.checkGroupId(); }; - _checkGroupId(e) { + private checkGroupId() { let error = null; if (!this.state.groupId) { error = _t("Community IDs cannot be empty."); @@ -65,16 +75,16 @@ export default class CreateGroupDialog extends React.Component { return error; } - _onFormSubmit = e => { + private onFormSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (this._checkGroupId()) return; + if (this.checkGroupId()) return; - const profile = {}; + const profile: any = {}; if (this.state.groupName !== '') { profile.name = this.state.groupName; } - this.setState({creating: true}); + this.setState({ creating: true }); MatrixClientPeg.get().createGroup({ localpart: this.state.groupId, profile: profile, @@ -86,9 +96,9 @@ export default class CreateGroupDialog extends React.Component { }); this.props.onFinished(true); }).catch((e) => { - this.setState({createError: e}); + this.setState({ createError: e }); }).finally(() => { - this.setState({creating: false}); + this.setState({ creating: false }); }); }; @@ -97,9 +107,6 @@ export default class CreateGroupDialog extends React.Component { }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const Spinner = sdk.getComponent('elements.Spinner'); - if (this.state.creating) { return ; } @@ -119,7 +126,7 @@ export default class CreateGroupDialog extends React.Component { - +
      @@ -127,9 +134,9 @@ export default class CreateGroupDialog extends React.Component {
      @@ -142,10 +149,10 @@ export default class CreateGroupDialog extends React.Component { + diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.tsx similarity index 55% rename from src/components/views/dialogs/CreateRoomDialog.js rename to src/components/views/dialogs/CreateRoomDialog.tsx index 2b6bb5e187..b5c0096771 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -1,6 +1,6 @@ /* Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 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. @@ -15,22 +15,47 @@ 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 SdkConfig from '../../../SdkConfig'; -import withValidation from '../elements/Validation'; -import { _t } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {Key} from "../../../Keyboard"; -import {privateShouldBeEncrypted} from "../../../createRoom"; -import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; +import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; -export default class CreateRoomDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - defaultPublic: PropTypes.bool, - }; +import SdkConfig from '../../../SdkConfig'; +import withValidation, { IFieldState } from '../elements/Validation'; +import { _t } from '../../../languageHandler'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { Key } from "../../../Keyboard"; +import { IOpts, privateShouldBeEncrypted } from "../../../createRoom"; +import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Field from "../elements/Field"; +import RoomAliasField from "../elements/RoomAliasField"; +import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; +import DialogButtons from "../elements/DialogButtons"; +import BaseDialog from "../dialogs/BaseDialog"; +import { Preset, Visibility } from "matrix-js-sdk/src/@types/partials"; + +interface IProps { + defaultPublic?: boolean; + defaultName?: string; + parentSpace?: Room; + onFinished(proceed: boolean, opts?: IOpts): void; +} + +interface IState { + isPublic: boolean; + isEncrypted: boolean; + name: string; + topic: string; + alias: string; + detailsOpen: boolean; + noFederate: boolean; + nameIsValid: boolean; + canChangeEncryption: boolean; +} + +@replaceableComponent("views.dialogs.CreateRoomDialog") +export default class CreateRoomDialog extends React.Component { + private nameField = createRef(); + private aliasField = createRef(); constructor(props) { super(props); @@ -39,7 +64,7 @@ export default class CreateRoomDialog extends React.Component { this.state = { isPublic: this.props.defaultPublic || false, isEncrypted: privateShouldBeEncrypted(), - name: "", + name: this.props.defaultName || "", topic: "", alias: "", detailsOpen: false, @@ -48,27 +73,26 @@ export default class CreateRoomDialog extends React.Component { canChangeEncryption: true, }; - MatrixClientPeg.get().doesServerForceEncryptionForPreset("private") - .then(isForced => this.setState({canChangeEncryption: !isForced})); + MatrixClientPeg.get().doesServerForceEncryptionForPreset(Preset.PrivateChat) + .then(isForced => this.setState({ canChangeEncryption: !isForced })); } - _roomCreateOptions() { - const opts = {}; - const createOpts = opts.createOpts = {}; + private roomCreateOptions() { + const opts: IOpts = {}; + const createOpts: IOpts["createOpts"] = opts.createOpts = {}; createOpts.name = this.state.name; if (this.state.isPublic) { - createOpts.visibility = "public"; - createOpts.preset = "public_chat"; + createOpts.visibility = Visibility.Public; + createOpts.preset = Preset.PublicChat; opts.guestAccess = false; - const {alias} = this.state; - const localPart = alias.substr(1, alias.indexOf(":") - 1); - createOpts['room_alias_name'] = localPart; + const { alias } = this.state; + createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1); } if (this.state.topic) { createOpts.topic = this.state.topic; } if (this.state.noFederate) { - createOpts.creation_content = {'m.federate': false}; + createOpts.creation_content = { 'm.federate': false }; } if (!this.state.isPublic) { @@ -85,20 +109,22 @@ export default class CreateRoomDialog extends React.Component { opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId(); } + if (this.props.parentSpace) { + opts.parentSpace = this.props.parentSpace; + } + return opts; } componentDidMount() { - this._detailsRef.addEventListener("toggle", this.onDetailsToggled); // move focus to first field when showing dialog - this._nameFieldRef.focus(); + this.nameField.current.focus(); } componentWillUnmount() { - this._detailsRef.removeEventListener("toggle", this.onDetailsToggled); } - _onKeyDown = event => { + private onKeyDown = (event: KeyboardEvent) => { if (event.key === Key.ENTER) { this.onOk(); event.preventDefault(); @@ -106,26 +132,26 @@ export default class CreateRoomDialog extends React.Component { } }; - onOk = async () => { - const activeElement = document.activeElement; + private onOk = async () => { + const activeElement = document.activeElement as HTMLElement; if (activeElement) { activeElement.blur(); } - await this._nameFieldRef.validate({allowEmpty: false}); - if (this._aliasFieldRef) { - await this._aliasFieldRef.validate({allowEmpty: false}); + await this.nameField.current.validate({ allowEmpty: false }); + if (this.aliasField.current) { + await this.aliasField.current.validate({ allowEmpty: false }); } // Validation and state updates are async, so we need to wait for them to complete // first. Queue a `setState` callback and wait for it to resolve. - await new Promise(resolve => this.setState({}, resolve)); - if (this.state.nameIsValid && (!this._aliasFieldRef || this._aliasFieldRef.isValid)) { - this.props.onFinished(true, this._roomCreateOptions()); + await new Promise(resolve => this.setState({}, resolve)); + if (this.state.nameIsValid && (!this.aliasField.current || this.aliasField.current.isValid)) { + this.props.onFinished(true, this.roomCreateOptions()); } else { let field; if (!this.state.nameIsValid) { - field = this._nameFieldRef; - } else if (this._aliasFieldRef && !this._aliasFieldRef.isValid) { - field = this._aliasFieldRef; + field = this.nameField.current; + } else if (this.aliasField.current && !this.aliasField.current.isValid) { + field = this.aliasField.current; } if (field) { field.focus(); @@ -134,49 +160,45 @@ export default class CreateRoomDialog extends React.Component { } }; - onCancel = () => { + private onCancel = () => { this.props.onFinished(false); }; - onNameChange = ev => { - this.setState({name: ev.target.value}); + private onNameChange = (ev: ChangeEvent) => { + this.setState({ name: ev.target.value }); }; - onTopicChange = ev => { - this.setState({topic: ev.target.value}); + private onTopicChange = (ev: ChangeEvent) => { + this.setState({ topic: ev.target.value }); }; - onPublicChange = isPublic => { - this.setState({isPublic}); + private onPublicChange = (isPublic: boolean) => { + this.setState({ isPublic }); }; - onEncryptedChange = isEncrypted => { - this.setState({isEncrypted}); + private onEncryptedChange = (isEncrypted: boolean) => { + this.setState({ isEncrypted }); }; - onAliasChange = alias => { - this.setState({alias}); + private onAliasChange = (alias: string) => { + this.setState({ alias }); }; - onDetailsToggled = ev => { - this.setState({detailsOpen: ev.target.open}); + private onDetailsToggled = (ev: SyntheticEvent) => { + this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open }); }; - onNoFederateChange = noFederate => { - this.setState({noFederate}); + private onNoFederateChange = (noFederate: boolean) => { + this.setState({ noFederate }); }; - collectDetailsRef = ref => { - this._detailsRef = ref; - }; - - onNameValidate = async fieldState => { - const result = await CreateRoomDialog._validateRoomName(fieldState); - this.setState({nameIsValid: result.valid}); + private onNameValidate = async (fieldState: IFieldState) => { + const result = await CreateRoomDialog.validateRoomName(fieldState); + this.setState({ nameIsValid: result.valid }); return result; }; - static _validateRoomName = withValidation({ + private static validateRoomName = withValidation({ rules: [ { key: "required", @@ -187,18 +209,17 @@ export default class CreateRoomDialog extends React.Component { }); render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const Field = sdk.getComponent('views.elements.Field'); - const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch'); - const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField'); - let aliasField; if (this.state.isPublic) { const domain = MatrixClientPeg.get().getDomain(); aliasField = (
      - this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} value={this.state.alias} /> +
      ); } @@ -255,26 +276,44 @@ export default class CreateRoomDialog extends React.Component { let title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room'); if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { const name = CommunityPrototypeStore.instance.getSelectedCommunityName(); - title = _t("Create a room in %(communityName)s", {communityName: name}); + title = _t("Create a room in %(communityName)s", { communityName: name }); } return ( - +
      - this._nameFieldRef = ref} label={ _t('Name') } onChange={this.onNameChange} onValidate={this.onNameValidate} value={this.state.name} className="mx_CreateRoomDialog_name" /> - - + + + { publicPrivateLabel } { e2eeSection } { aliasField } -
      - { this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') } +
      + + { this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') } + { +interface IProps { + onFinished: (success: boolean) => void; +} + +const CryptoStoreTooNewDialog: React.FC = (props: IProps) => { const brand = SdkConfig.get().brand; const _onLogoutClicked = () => { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('Logout e2e db too new', '', QuestionDialog, { title: _t("Sign out"), description: _t( @@ -39,8 +44,8 @@ export default (props) => { focus: false, onFinished: (doLogout) => { if (doLogout) { - dis.dispatch({action: 'logout'}); - props.onFinished(); + dis.dispatch({ action: 'logout' }); + props.onFinished(true); } }, }); @@ -54,8 +59,6 @@ export default (props) => { { brand }, ); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( { ); }; + +export default CryptoStoreTooNewDialog; diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.tsx similarity index 77% rename from src/components/views/dialogs/DeactivateAccountDialog.js rename to src/components/views/dialogs/DeactivateAccountDialog.tsx index fca8c42546..b2ac849314 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -16,18 +16,36 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import Analytics from '../../../Analytics'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import * as Lifecycle from '../../../Lifecycle'; import { _t } from '../../../languageHandler'; -import InteractiveAuth, {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth"; -import {DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry} from "../auth/InteractiveAuthEntryComponents"; +import InteractiveAuth, { ERROR_USER_CANCELLED } from "../../structures/InteractiveAuth"; +import { DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import StyledCheckbox from "../elements/StyledCheckbox"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; -export default class DeactivateAccountDialog extends React.Component { +interface IProps { + onFinished: (success: boolean) => void; +} + +interface IState { + shouldErase: boolean; + errStr: string; + authData: any; // for UIA + authEnabled: boolean; // see usages for information + + // A few strings that are passed to InteractiveAuth for design or are displayed + // next to the InteractiveAuth component. + bodyText: string; + continueText: string; + continueKind: string; +} + +@replaceableComponent("views.dialogs.DeactivateAccountDialog") +export default class DeactivateAccountDialog extends React.Component { constructor(props) { super(props); @@ -44,10 +62,10 @@ export default class DeactivateAccountDialog extends React.Component { continueKind: null, }; - this._initAuth(/* shouldErase= */false); + this.initAuth(/* shouldErase= */false); } - _onStagePhaseChange = (stage, phase) => { + private onStagePhaseChange = (stage: string, phase: string): void => { const dialogAesthetics = { [SSOAuthEntry.PHASE_PREAUTH]: { body: _t("Confirm your account deactivation by using Single Sign On to prove your identity."), @@ -82,22 +100,22 @@ export default class DeactivateAccountDialog extends React.Component { if (phaseAesthetics && phaseAesthetics.continueText) continueText = phaseAesthetics.continueText; if (phaseAesthetics && phaseAesthetics.continueKind) continueKind = phaseAesthetics.continueKind; } - this.setState({bodyText, continueText, continueKind}); + this.setState({ bodyText, continueText, continueKind }); }; - _onUIAuthFinished = (success, result, extra) => { + private onUIAuthFinished = (success: boolean, result: Error) => { if (success) return; // great! makeRequest() will be called too. if (result === ERROR_USER_CANCELLED) { - this._onCancel(); + this.onCancel(); return; } - console.error("Error during UI Auth:", {result, extra}); - this.setState({errStr: _t("There was a problem communicating with the server. Please try again.")}); + console.error("Error during UI Auth:", { result }); + this.setState({ errStr: _t("There was a problem communicating with the server. Please try again.") }); }; - _onUIAuthComplete = (auth) => { + private onUIAuthComplete = (auth: any): void => { MatrixClientPeg.get().deactivateAccount(auth, this.state.shouldErase).then(r => { // Deactivation worked - logout & close this dialog Analytics.trackEvent('Account', 'Deactivate Account'); @@ -105,13 +123,13 @@ export default class DeactivateAccountDialog extends React.Component { this.props.onFinished(true); }).catch(e => { console.error(e); - this.setState({errStr: _t("There was a problem communicating with the server. Please try again.")}); + this.setState({ errStr: _t("There was a problem communicating with the server. Please try again.") }); }); }; - _onEraseFieldChange = (ev) => { + private onEraseFieldChange = (ev: React.FormEvent): void => { this.setState({ - shouldErase: ev.target.checked, + shouldErase: ev.currentTarget.checked, // Disable the auth form because we're going to have to reinitialize the auth // information. We do this because we can't modify the parameters in the UIA @@ -121,34 +139,32 @@ export default class DeactivateAccountDialog extends React.Component { }); // As mentioned above, set up for auth again to get updated UIA session info - this._initAuth(/* shouldErase= */ev.target.checked); + this.initAuth(/* shouldErase= */ev.currentTarget.checked); }; - _onCancel() { + private onCancel(): void { this.props.onFinished(false); } - _initAuth(shouldErase) { + private initAuth(shouldErase: boolean): void { MatrixClientPeg.get().deactivateAccount(null, shouldErase).then(r => { // If we got here, oops. The server didn't require any auth. // Our application lifecycle will catch the error and do the logout bits. // We'll try to log something in an vain attempt to record what happened (storage // is also obliterated on logout). console.warn("User's account got deactivated without confirmation: Server had no auth"); - this.setState({errStr: _t("Server did not require any authentication")}); + this.setState({ errStr: _t("Server did not require any authentication") }); }).catch(e => { if (e && e.httpStatus === 401 && e.data) { // Valid UIA response - this.setState({authData: e.data, authEnabled: true}); + this.setState({ authData: e.data, authEnabled: true }); } else { - this.setState({errStr: _t("Server did not return valid authentication information.")}); + this.setState({ errStr: _t("Server did not return valid authentication information.") }); } }); } - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - + public render() { let error = null; if (this.state.errStr) { error =
      @@ -164,9 +180,9 @@ export default class DeactivateAccountDialog extends React.Component { @@ -212,7 +228,7 @@ export default class DeactivateAccountDialog extends React.Component {

      {_t( "Please forget all messages I have sent when my account is deactivated " + @@ -233,7 +249,3 @@ export default class DeactivateAccountDialog extends React.Component { ); } } - -DeactivateAccountDialog.propTypes = { - onFinished: PropTypes.func.isRequired, -}; diff --git a/src/components/views/dialogs/DevtoolsDialog.js b/src/components/views/dialogs/DevtoolsDialog.js deleted file mode 100644 index a0c5375843..0000000000 --- a/src/components/views/dialogs/DevtoolsDialog.js +++ /dev/null @@ -1,793 +0,0 @@ -/* -Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, {useState, useEffect} from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; -import SyntaxHighlight from '../elements/SyntaxHighlight'; -import { _t } from '../../../languageHandler'; -import { Room, MatrixEvent } from "matrix-js-sdk"; -import Field from "../elements/Field"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {useEventEmitter} from "../../../hooks/useEventEmitter"; - -import { - PHASE_UNSENT, - PHASE_REQUESTED, - PHASE_READY, - PHASE_DONE, - PHASE_STARTED, - PHASE_CANCELLED, -} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; - -class GenericEditor extends React.PureComponent { - // static propTypes = {onBack: PropTypes.func.isRequired}; - - constructor(props) { - super(props); - this._onChange = this._onChange.bind(this); - this.onBack = this.onBack.bind(this); - } - - onBack() { - if (this.state.message) { - this.setState({ message: null }); - } else { - this.props.onBack(); - } - } - - _onChange(e) { - this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); - } - - _buttons() { - return

      - - { !this.state.message && } -
      ; - } - - textInput(id, label) { - return ; - } -} - -class SendCustomEvent extends GenericEditor { - static getLabel() { return _t('Send Custom Event'); } - - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - forceStateEvent: PropTypes.bool, - inputs: PropTypes.object, - }; - - static contextType = MatrixClientContext; - - constructor(props) { - super(props); - this._send = this._send.bind(this); - - const {eventType, stateKey, evContent} = Object.assign({ - eventType: '', - stateKey: '', - evContent: '{\n\n}', - }, this.props.inputs); - - this.state = { - isStateEvent: Boolean(this.props.forceStateEvent), - - eventType, - stateKey, - evContent, - }; - } - - send(content) { - const cli = this.context; - if (this.state.isStateEvent) { - return cli.sendStateEvent(this.props.room.roomId, this.state.eventType, content, this.state.stateKey); - } else { - return cli.sendEvent(this.props.room.roomId, this.state.eventType, content); - } - } - - async _send() { - if (this.state.eventType === '') { - this.setState({ message: _t('You must specify an event type!') }); - return; - } - - let message; - try { - const content = JSON.parse(this.state.evContent); - await this.send(content); - message = _t('Event sent!'); - } catch (e) { - message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; - } - this.setState({ message }); - } - - render() { - if (this.state.message) { - return
      -
      - { this.state.message } -
      - { this._buttons() } -
      ; - } - - return
      -
      -
      - { this.textInput('eventType', _t('Event Type')) } - { this.state.isStateEvent && this.textInput('stateKey', _t('State Key')) } -
      - -
      - - -
      -
      - - { !this.state.message && } - { !this.state.message && !this.props.forceStateEvent &&
      - -
      } -
      -
      ; - } -} - -class SendAccountData extends GenericEditor { - static getLabel() { return _t('Send Account Data'); } - - static propTypes = { - room: PropTypes.instanceOf(Room).isRequired, - isRoomAccountData: PropTypes.bool, - forceMode: PropTypes.bool, - inputs: PropTypes.object, - }; - - static contextType = MatrixClientContext; - - constructor(props) { - super(props); - this._send = this._send.bind(this); - - const {eventType, evContent} = Object.assign({ - eventType: '', - evContent: '{\n\n}', - }, this.props.inputs); - - this.state = { - isRoomAccountData: Boolean(this.props.isRoomAccountData), - - eventType, - evContent, - }; - } - - send(content) { - const cli = this.context; - if (this.state.isRoomAccountData) { - return cli.setRoomAccountData(this.props.room.roomId, this.state.eventType, content); - } - return cli.setAccountData(this.state.eventType, content); - } - - async _send() { - if (this.state.eventType === '') { - this.setState({ message: _t('You must specify an event type!') }); - return; - } - - let message; - try { - const content = JSON.parse(this.state.evContent); - await this.send(content); - message = _t('Event sent!'); - } catch (e) { - message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; - } - this.setState({ message }); - } - - render() { - if (this.state.message) { - return
      -
      - { this.state.message } -
      - { this._buttons() } -
      ; - } - - return
      -
      - { this.textInput('eventType', _t('Event Type')) } -
      - - -
      -
      - - { !this.state.message && } - { !this.state.message &&
      - -
      } -
      -
      ; - } -} - -const INITIAL_LOAD_TILES = 20; -const LOAD_TILES_STEP_SIZE = 50; - -class FilteredList extends React.PureComponent { - static propTypes = { - children: PropTypes.any, - query: PropTypes.string, - onChange: PropTypes.func, - }; - - static filterChildren(children, query) { - if (!query) return children; - const lcQuery = query.toLowerCase(); - return children.filter((child) => child.key.toLowerCase().includes(lcQuery)); - } - - constructor(props) { - super(props); - - this.state = { - filteredChildren: FilteredList.filterChildren(this.props.children, this.props.query), - truncateAt: INITIAL_LOAD_TILES, - }; - } - - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase - if (this.props.children === nextProps.children && this.props.query === nextProps.query) return; - this.setState({ - filteredChildren: FilteredList.filterChildren(nextProps.children, nextProps.query), - truncateAt: INITIAL_LOAD_TILES, - }); - } - - showAll = () => { - this.setState({ - truncateAt: this.state.truncateAt + LOAD_TILES_STEP_SIZE, - }); - }; - - createOverflowElement = (overflowCount: number, totalCount: number) => { - return ; - }; - - onQuery = (ev) => { - if (this.props.onChange) this.props.onChange(ev.target.value); - }; - - getChildren = (start: number, end: number) => { - return this.state.filteredChildren.slice(start, end); - }; - - getChildCount = (): number => { - return this.state.filteredChildren.length; - }; - - render() { - const TruncatedList = sdk.getComponent("elements.TruncatedList"); - return
      - - - -
      ; - } -} - -class RoomStateExplorer extends React.PureComponent { - static getLabel() { return _t('Explore Room State'); } - - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - }; - - static contextType = MatrixClientContext; - - roomStateEvents: Map>; - - constructor(props) { - super(props); - - this.roomStateEvents = this.props.room.currentState.events; - - this.onBack = this.onBack.bind(this); - this.editEv = this.editEv.bind(this); - this.onQueryEventType = this.onQueryEventType.bind(this); - this.onQueryStateKey = this.onQueryStateKey.bind(this); - - this.state = { - eventType: null, - event: null, - editing: false, - - queryEventType: '', - queryStateKey: '', - }; - } - - browseEventType(eventType) { - return () => { - this.setState({ eventType }); - }; - } - - onViewSourceClick(event) { - return () => { - this.setState({ event }); - }; - } - - onBack() { - if (this.state.editing) { - this.setState({ editing: false }); - } else if (this.state.event) { - this.setState({ event: null }); - } else if (this.state.eventType) { - this.setState({ eventType: null }); - } else { - this.props.onBack(); - } - } - - editEv() { - this.setState({ editing: true }); - } - - onQueryEventType(filterEventType) { - this.setState({ queryEventType: filterEventType }); - } - - onQueryStateKey(filterStateKey) { - this.setState({ queryStateKey: filterStateKey }); - } - - render() { - if (this.state.event) { - if (this.state.editing) { - return ; - } - - return
      -
      - - { JSON.stringify(this.state.event.event, null, 2) } - -
      -
      - - -
      -
      ; - } - - let list = null; - - const classes = 'mx_DevTools_RoomStateExplorer_button'; - if (this.state.eventType === null) { - list = - { - Array.from(this.roomStateEvents.entries()).map(([eventType, allStateKeys]) => { - let onClickFn; - if (allStateKeys.size === 1 && allStateKeys.has("")) { - onClickFn = this.onViewSourceClick(allStateKeys.get("")); - } else { - onClickFn = this.browseEventType(eventType); - } - - return ; - }) - } - ; - } else { - const stateGroup = this.roomStateEvents.get(this.state.eventType); - - list = - { - Array.from(stateGroup.entries()).map(([stateKey, ev]) => { - return ; - }) - } - ; - } - - return
      -
      - { list } -
      -
      - -
      -
      ; - } -} - -class AccountDataExplorer extends React.PureComponent { - static getLabel() { return _t('Explore Account Data'); } - - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - }; - - static contextType = MatrixClientContext; - - constructor(props) { - super(props); - - this.onBack = this.onBack.bind(this); - this.editEv = this.editEv.bind(this); - this._onChange = this._onChange.bind(this); - this.onQueryEventType = this.onQueryEventType.bind(this); - - this.state = { - isRoomAccountData: false, - event: null, - editing: false, - - queryEventType: '', - }; - } - - getData() { - if (this.state.isRoomAccountData) { - return this.props.room.accountData; - } - return this.context.store.accountData; - } - - onViewSourceClick(event) { - return () => { - this.setState({ event }); - }; - } - - onBack() { - if (this.state.editing) { - this.setState({ editing: false }); - } else if (this.state.event) { - this.setState({ event: null }); - } else { - this.props.onBack(); - } - } - - _onChange(e) { - this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); - } - - editEv() { - this.setState({ editing: true }); - } - - onQueryEventType(queryEventType) { - this.setState({ queryEventType }); - } - - render() { - if (this.state.event) { - if (this.state.editing) { - return ; - } - - return
      -
      - - { JSON.stringify(this.state.event.event, null, 2) } - -
      -
      - - -
      -
      ; - } - - const rows = []; - - const classes = 'mx_DevTools_RoomStateExplorer_button'; - - const data = this.getData(); - Object.keys(data).forEach((evType) => { - const ev = data[evType]; - rows.push(); - }); - - return
      -
      - - { rows } - -
      -
      - - { !this.state.message &&
      - -
      } -
      -
      ; - } -} - -class ServersInRoomList extends React.PureComponent { - static getLabel() { return _t('View Servers in Room'); } - - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - }; - - static contextType = MatrixClientContext; - - constructor(props) { - super(props); - - const room = this.props.room; - const servers = new Set(); - room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1])); - this.servers = Array.from(servers).map(s => - ); - - this.state = { - query: '', - }; - } - - onQuery = (query) => { - this.setState({ query }); - } - - render() { - return
      -
      - - { this.servers } - -
      -
      - -
      -
      ; - } -} - -const PHASE_MAP = { - [PHASE_UNSENT]: "unsent", - [PHASE_REQUESTED]: "requested", - [PHASE_READY]: "ready", - [PHASE_DONE]: "done", - [PHASE_STARTED]: "started", - [PHASE_CANCELLED]: "cancelled", -}; - -function VerificationRequest({txnId, request}) { - const [, updateState] = useState(); - const [timeout, setRequestTimeout] = useState(request.timeout); - - /* Re-render if something changes state */ - useEventEmitter(request, "change", updateState); - - /* Keep re-rendering if there's a timeout */ - useEffect(() => { - if (request.timeout == 0) return; - - /* Note that request.timeout is a getter, so its value changes */ - const id = setInterval(() => { - setRequestTimeout(request.timeout); - }, 500); - - return () => { clearInterval(id); }; - }, [request]); - - return (
      -
      -
      Transaction
      -
      {txnId}
      -
      Phase
      -
      {PHASE_MAP[request.phase] || request.phase}
      -
      Timeout
      -
      {Math.floor(timeout / 1000)}
      -
      Methods
      -
      {request.methods && request.methods.join(", ")}
      -
      requestingUserId
      -
      {request.requestingUserId}
      -
      observeOnly
      -
      {JSON.stringify(request.observeOnly)}
      -
      -
      ); -} - -class VerificationExplorer extends React.Component { - static getLabel() { - return _t("Verification Requests"); - } - - /* Ensure this.context is the cli */ - static contextType = MatrixClientContext; - - onNewRequest = () => { - this.forceUpdate(); - } - - componentDidMount() { - const cli = this.context; - cli.on("crypto.verification.request", this.onNewRequest); - } - - componentWillUnmount() { - const cli = this.context; - cli.off("crypto.verification.request", this.onNewRequest); - } - - render() { - const cli = this.context; - const room = this.props.room; - const inRoomChannel = cli._crypto._inRoomVerificationRequests; - const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map(); - - return (
      -
      - {Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) => - , - )} -
      -
      - -
      -
      ); - } -} - -const Entries = [ - SendCustomEvent, - RoomStateExplorer, - SendAccountData, - AccountDataExplorer, - ServersInRoomList, - VerificationExplorer, -]; - -export default class DevtoolsDialog extends React.PureComponent { - static propTypes = { - roomId: PropTypes.string.isRequired, - onFinished: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - this.onBack = this.onBack.bind(this); - this.onCancel = this.onCancel.bind(this); - - this.state = { - mode: null, - }; - } - - componentWillUnmount() { - this._unmounted = true; - } - - _setMode(mode) { - return () => { - this.setState({ mode }); - }; - } - - onBack() { - if (this.prevMode) { - this.setState({ mode: this.prevMode }); - this.prevMode = null; - } else { - this.setState({ mode: null }); - } - } - - onCancel() { - this.props.onFinished(false); - } - - render() { - let body; - - if (this.state.mode) { - body = - {(cli) => -
      { this.state.mode.getLabel() }
      -
      Room ID: { this.props.roomId }
      -
      - - } - ; - } else { - const classes = "mx_DevTools_RoomStateExplorer_button"; - body = -
      -
      { _t('Toolbox') }
      -
      Room ID: { this.props.roomId }
      -
      - -
      - { Entries.map((Entry) => { - const label = Entry.getLabel(); - const onClick = this._setMode(Entry); - return ; - }) } -
      -
      -
      - -
      - ; - } - - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - return ( - - { body } - - ); - } -} diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx new file mode 100644 index 0000000000..86b8f93d7b --- /dev/null +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -0,0 +1,1270 @@ +/* +Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2018-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, { useState, useEffect, ChangeEvent, MouseEvent } from 'react'; +import SyntaxHighlight from '../elements/SyntaxHighlight'; +import { _t } from '../../../languageHandler'; +import Field from "../elements/Field"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; + +import { + PHASE_UNSENT, + PHASE_REQUESTED, + PHASE_READY, + PHASE_DONE, + PHASE_STARTED, + PHASE_CANCELLED, + VerificationRequest, +} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; +import WidgetStore, { IApp } from "../../../stores/WidgetStore"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import { SETTINGS } from "../../../settings/Settings"; +import SettingsStore, { LEVEL_ORDER } from "../../../settings/SettingsStore"; +import Modal from "../../../Modal"; +import ErrorDialog from "./ErrorDialog"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { SettingLevel } from '../../../settings/SettingLevel'; +import BaseDialog from "./BaseDialog"; +import TruncatedList from "../elements/TruncatedList"; + +interface IGenericEditorProps { + onBack: () => void; +} + +interface IGenericEditorState { + message?: string; + [inputId: string]: boolean | string; +} + +abstract class GenericEditor< + P extends IGenericEditorProps = IGenericEditorProps, + S extends IGenericEditorState = IGenericEditorState, +> extends React.PureComponent { + protected onBack = () => { + if (this.state.message) { + this.setState({ message: null }); + } else { + this.props.onBack(); + } + }; + + protected onChange = (e: ChangeEvent) => { + // @ts-ignore: Unsure how to convince TS this is okay when the state + // type can be extended. + this.setState({ [e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }); + }; + + protected abstract send(); + + protected buttons(): React.ReactNode { + return
      + + { !this.state.message && } +
      ; + } + + protected textInput(id: string, label: string): React.ReactNode { + return ; + } +} + +interface ISendCustomEventProps extends IGenericEditorProps { + room: Room; + forceStateEvent?: boolean; + forceGeneralEvent?: boolean; + inputs?: { + eventType?: string; + stateKey?: string; + evContent?: string; + }; +} + +interface ISendCustomEventState extends IGenericEditorState { + isStateEvent: boolean; + eventType: string; + stateKey: string; + evContent: string; +} + +export class SendCustomEvent extends GenericEditor { + static getLabel() { return _t('Send Custom Event'); } + + static contextType = MatrixClientContext; + + constructor(props) { + super(props); + + const { eventType, stateKey, evContent } = Object.assign({ + eventType: '', + stateKey: '', + evContent: '{\n\n}', + }, this.props.inputs); + + this.state = { + isStateEvent: Boolean(this.props.forceStateEvent), + + eventType, + stateKey, + evContent, + }; + } + + private doSend(content: object): Promise { + const cli = this.context; + if (this.state.isStateEvent) { + return cli.sendStateEvent(this.props.room.roomId, this.state.eventType, content, this.state.stateKey); + } else { + return cli.sendEvent(this.props.room.roomId, this.state.eventType, content); + } + } + + protected send = async () => { + if (this.state.eventType === '') { + this.setState({ message: _t('You must specify an event type!') }); + return; + } + + let message; + try { + const content = JSON.parse(this.state.evContent); + await this.doSend(content); + message = _t('Event sent!'); + } catch (e) { + message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; + } + this.setState({ message }); + }; + + render() { + if (this.state.message) { + return
      +
      + { this.state.message } +
      + { this.buttons() } +
      ; + } + + const showTglFlip = !this.state.message && !this.props.forceStateEvent && !this.props.forceGeneralEvent; + + return
      +
      +
      + { this.textInput('eventType', _t('Event Type')) } + { this.state.isStateEvent && this.textInput('stateKey', _t('State Key')) } +
      + +
      + + +
      +
      + + { !this.state.message && } + { showTglFlip &&
      + +
      } +
      +
      ; + } +} + +interface ISendAccountDataProps extends IGenericEditorProps { + room: Room; + isRoomAccountData: boolean; + forceMode: boolean; + inputs?: { + eventType?: string; + evContent?: string; + }; +} + +interface ISendAccountDataState extends IGenericEditorState { + isRoomAccountData: boolean; + eventType: string; + evContent: string; +} + +class SendAccountData extends GenericEditor { + static getLabel() { return _t('Send Account Data'); } + + static contextType = MatrixClientContext; + + constructor(props) { + super(props); + + const { eventType, evContent } = Object.assign({ + eventType: '', + evContent: '{\n\n}', + }, this.props.inputs); + + this.state = { + isRoomAccountData: Boolean(this.props.isRoomAccountData), + + eventType, + evContent, + }; + } + + private doSend(content: object): Promise { + const cli = this.context; + if (this.state.isRoomAccountData) { + return cli.setRoomAccountData(this.props.room.roomId, this.state.eventType, content); + } + return cli.setAccountData(this.state.eventType, content); + } + + protected send = async () => { + if (this.state.eventType === '') { + this.setState({ message: _t('You must specify an event type!') }); + return; + } + + let message; + try { + const content = JSON.parse(this.state.evContent); + await this.doSend(content); + message = _t('Event sent!'); + } catch (e) { + message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; + } + this.setState({ message }); + }; + + render() { + if (this.state.message) { + return
      +
      + { this.state.message } +
      + { this.buttons() } +
      ; + } + + return
      +
      + { this.textInput('eventType', _t('Event Type')) } +
      + + +
      +
      + + { !this.state.message && } + { !this.state.message &&
      + +
      } +
      +
      ; + } +} + +const INITIAL_LOAD_TILES = 20; +const LOAD_TILES_STEP_SIZE = 50; + +interface IFilteredListProps { + children: React.ReactElement[]; + query: string; + onChange: (value: string) => void; +} + +interface IFilteredListState { + filteredChildren: React.ReactElement[]; + truncateAt: number; +} + +class FilteredList extends React.PureComponent { + static filterChildren(children: React.ReactElement[], query: string): React.ReactElement[] { + if (!query) return children; + const lcQuery = query.toLowerCase(); + return children.filter((child) => child.key.toString().toLowerCase().includes(lcQuery)); + } + + constructor(props) { + super(props); + + this.state = { + filteredChildren: FilteredList.filterChildren(this.props.children, this.props.query), + truncateAt: INITIAL_LOAD_TILES, + }; + } + + // TODO: [REACT-WARNING] Replace with appropriate lifecycle event + UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase + if (this.props.children === nextProps.children && this.props.query === nextProps.query) return; + this.setState({ + filteredChildren: FilteredList.filterChildren(nextProps.children, nextProps.query), + truncateAt: INITIAL_LOAD_TILES, + }); + } + + private showAll = () => { + this.setState({ + truncateAt: this.state.truncateAt + LOAD_TILES_STEP_SIZE, + }); + }; + + private createOverflowElement = (overflowCount: number, totalCount: number) => { + return ; + }; + + private onQuery = (ev: ChangeEvent) => { + if (this.props.onChange) this.props.onChange(ev.target.value); + }; + + private getChildren = (start: number, end: number): React.ReactElement[] => { + return this.state.filteredChildren.slice(start, end); + }; + + private getChildCount = (): number => { + return this.state.filteredChildren.length; + }; + + render() { + return
      + + + +
      ; + } +} + +interface IExplorerProps { + room: Room; + onBack: () => void; +} + +interface IRoomStateExplorerState { + eventType?: string; + event?: MatrixEvent; + editing: boolean; + queryEventType: string; + queryStateKey: string; +} + +class RoomStateExplorer extends React.PureComponent { + static getLabel() { return _t('Explore Room State'); } + + static contextType = MatrixClientContext; + + private roomStateEvents: Map>; + + constructor(props) { + super(props); + + this.roomStateEvents = this.props.room.currentState.events; + + this.state = { + eventType: null, + event: null, + editing: false, + + queryEventType: '', + queryStateKey: '', + }; + } + + private browseEventType(eventType: string) { + return () => { + this.setState({ eventType }); + }; + } + + private onViewSourceClick(event: MatrixEvent) { + return () => { + this.setState({ event }); + }; + } + + private onBack = () => { + if (this.state.editing) { + this.setState({ editing: false }); + } else if (this.state.event) { + this.setState({ event: null }); + } else if (this.state.eventType) { + this.setState({ eventType: null }); + } else { + this.props.onBack(); + } + }; + + private editEv = () => { + this.setState({ editing: true }); + }; + + private onQueryEventType = (filterEventType: string) => { + this.setState({ queryEventType: filterEventType }); + }; + + private onQueryStateKey = (filterStateKey: string) => { + this.setState({ queryStateKey: filterStateKey }); + }; + + render() { + if (this.state.event) { + if (this.state.editing) { + return ; + } + + return
      +
      + + { JSON.stringify(this.state.event.event, null, 2) } + +
      +
      + + +
      +
      ; + } + + let list = null; + + const classes = 'mx_DevTools_RoomStateExplorer_button'; + if (this.state.eventType === null) { + list = + { + Array.from(this.roomStateEvents.entries()).map(([eventType, allStateKeys]) => { + let onClickFn; + if (allStateKeys.size === 1 && allStateKeys.has("")) { + onClickFn = this.onViewSourceClick(allStateKeys.get("")); + } else { + onClickFn = this.browseEventType(eventType); + } + + return ; + }) + } + ; + } else { + const stateGroup = this.roomStateEvents.get(this.state.eventType); + + list = + { + Array.from(stateGroup.entries()).map(([stateKey, ev]) => { + return ; + }) + } + ; + } + + return
      +
      + { list } +
      +
      + +
      +
      ; + } +} + +interface IAccountDataExplorerState { + [inputId: string]: boolean | string | any; + isRoomAccountData: boolean; + event?: MatrixEvent; + editing: boolean; + queryEventType: string; +} + +class AccountDataExplorer extends React.PureComponent { + static getLabel() { return _t('Explore Account Data'); } + + static contextType = MatrixClientContext; + + constructor(props) { + super(props); + + this.state = { + isRoomAccountData: false, + event: null, + editing: false, + + queryEventType: '', + }; + } + + private getData(): Record { + if (this.state.isRoomAccountData) { + return this.props.room.accountData; + } + return this.context.store.accountData; + } + + private onViewSourceClick(event: MatrixEvent) { + return () => { + this.setState({ event }); + }; + } + + private onBack = () => { + if (this.state.editing) { + this.setState({ editing: false }); + } else if (this.state.event) { + this.setState({ event: null }); + } else { + this.props.onBack(); + } + }; + + private onChange = (e: ChangeEvent) => { + this.setState({ [e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }); + }; + + private editEv = () => { + this.setState({ editing: true }); + }; + + private onQueryEventType = (queryEventType: string) => { + this.setState({ queryEventType }); + }; + + render() { + if (this.state.event) { + if (this.state.editing) { + return ; + } + + return
      +
      + + { JSON.stringify(this.state.event.event, null, 2) } + +
      +
      + + +
      +
      ; + } + + const rows = []; + + const classes = 'mx_DevTools_RoomStateExplorer_button'; + + const data = this.getData(); + Object.keys(data).forEach((evType) => { + const ev = data[evType]; + rows.push(); + }); + + return
      +
      + + { rows } + +
      +
      + +
      + +
      +
      +
      ; + } +} + +interface IServersInRoomListState { + query: string; +} + +class ServersInRoomList extends React.PureComponent { + static getLabel() { return _t('View Servers in Room'); } + + static contextType = MatrixClientContext; + + private servers: React.ReactElement[]; + + constructor(props) { + super(props); + + const room = this.props.room; + const servers = new Set(); + room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1])); + this.servers = Array.from(servers).map(s => + ); + + this.state = { + query: '', + }; + } + + private onQuery = (query: string) => { + this.setState({ query }); + }; + + render() { + return
      +
      + + { this.servers } + +
      +
      + +
      +
      ; + } +} + +const PHASE_MAP = { + [PHASE_UNSENT]: "unsent", + [PHASE_REQUESTED]: "requested", + [PHASE_READY]: "ready", + [PHASE_DONE]: "done", + [PHASE_STARTED]: "started", + [PHASE_CANCELLED]: "cancelled", +}; + +const VerificationRequestExplorer: React.FC<{ + txnId: string; + request: VerificationRequest; +}> = ({ txnId, request }) => { + const [, updateState] = useState(); + const [timeout, setRequestTimeout] = useState(request.timeout); + + /* Re-render if something changes state */ + useEventEmitter(request, "change", updateState); + + /* Keep re-rendering if there's a timeout */ + useEffect(() => { + if (request.timeout == 0) return; + + /* Note that request.timeout is a getter, so its value changes */ + const id = setInterval(() => { + setRequestTimeout(request.timeout); + }, 500); + + return () => { clearInterval(id); }; + }, [request]); + + return (
      +
      +
      Transaction
      +
      {txnId}
      +
      Phase
      +
      {PHASE_MAP[request.phase] || request.phase}
      +
      Timeout
      +
      {Math.floor(timeout / 1000)}
      +
      Methods
      +
      {request.methods && request.methods.join(", ")}
      +
      requestingUserId
      +
      {request.requestingUserId}
      +
      observeOnly
      +
      {JSON.stringify(request.observeOnly)}
      +
      +
      ); +}; + +class VerificationExplorer extends React.PureComponent { + static getLabel() { + return _t("Verification Requests"); + } + + /* Ensure this.context is the cli */ + static contextType = MatrixClientContext; + + private onNewRequest = () => { + this.forceUpdate(); + }; + + componentDidMount() { + const cli = this.context; + cli.on("crypto.verification.request", this.onNewRequest); + } + + componentWillUnmount() { + const cli = this.context; + cli.off("crypto.verification.request", this.onNewRequest); + } + + render() { + const cli = this.context; + const room = this.props.room; + const inRoomChannel = cli.crypto.inRoomVerificationRequests; + const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map(); + + return (
      +
      + {Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) => + , + )} +
      +
      + +
      +
      ); + } +} + +interface IWidgetExplorerState { + query: string; + editWidget?: IApp; +} + +class WidgetExplorer extends React.Component { + static getLabel() { + return _t("Active Widgets"); + } + + constructor(props) { + super(props); + + this.state = { + query: '', + editWidget: null, // set to an IApp when editing + }; + } + + private onWidgetStoreUpdate = () => { + this.forceUpdate(); + }; + + private onQueryChange = (query: string) => { + this.setState({ query }); + }; + + private onEditWidget = (widget: IApp) => { + this.setState({ editWidget: widget }); + }; + + private onBack = () => { + const widgets = WidgetStore.instance.getApps(this.props.room.roomId); + if (this.state.editWidget && widgets.includes(this.state.editWidget)) { + this.setState({ editWidget: null }); + } else { + this.props.onBack(); + } + }; + + componentDidMount() { + WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + } + + componentWillUnmount() { + WidgetStore.instance.off(UPDATE_EVENT, this.onWidgetStoreUpdate); + } + + render() { + const room = this.props.room; + + const editWidget = this.state.editWidget; + const widgets = WidgetStore.instance.getApps(room.roomId); + if (editWidget && widgets.includes(editWidget)) { + const allState = Array.from( + Array.from(room.currentState.events.values()).map((e: Map) => { + return e.values(); + }), + ).reduce((p, c) => { p.push(...c); return p; }, []); + const stateEv = allState.find(ev => ev.getId() === editWidget.eventId); + if (!stateEv) { // "should never happen" + return
      + {_t("There was an error finding this widget.")} +
      + +
      +
      ; + } + return ; + } + + return (
      +
      + + {widgets.map(w => { + return ; + })} + +
      +
      + +
      +
      ); + } +} + +interface ISettingsExplorerState { + query: string; + editSetting?: string; + viewSetting?: string; + explicitValues?: string; + explicitRoomValues?: string; + } + +class SettingsExplorer extends React.PureComponent { + static getLabel() { + return _t("Settings Explorer"); + } + + constructor(props) { + super(props); + + this.state = { + query: '', + editSetting: null, // set to a setting ID when editing + viewSetting: null, // set to a setting ID when exploring in detail + + explicitValues: null, // stringified JSON for edit view + explicitRoomValues: null, // stringified JSON for edit view + }; + } + + private onQueryChange = (ev: ChangeEvent) => { + this.setState({ query: ev.target.value }); + }; + + private onExplValuesEdit = (ev: ChangeEvent) => { + this.setState({ explicitValues: ev.target.value }); + }; + + private onExplRoomValuesEdit = (ev: ChangeEvent) => { + this.setState({ explicitRoomValues: ev.target.value }); + }; + + private onBack = () => { + if (this.state.editSetting) { + this.setState({ editSetting: null }); + } else if (this.state.viewSetting) { + this.setState({ viewSetting: null }); + } else { + this.props.onBack(); + } + }; + + private onViewClick = (ev: MouseEvent, settingId: string) => { + ev.preventDefault(); + this.setState({ viewSetting: settingId }); + }; + + private onEditClick = (ev: MouseEvent, settingId: string) => { + ev.preventDefault(); + this.setState({ + editSetting: settingId, + explicitValues: this.renderExplicitSettingValues(settingId, null), + explicitRoomValues: this.renderExplicitSettingValues(settingId, this.props.room.roomId), + }); + }; + + private onSaveClick = async () => { + try { + const settingId = this.state.editSetting; + const parsedExplicit = JSON.parse(this.state.explicitValues); + const parsedExplicitRoom = JSON.parse(this.state.explicitRoomValues); + for (const level of Object.keys(parsedExplicit)) { + console.log(`[Devtools] Setting value of ${settingId} at ${level} from user input`); + try { + const val = parsedExplicit[level]; + await SettingsStore.setValue(settingId, null, level as SettingLevel, val); + } catch (e) { + console.warn(e); + } + } + const roomId = this.props.room.roomId; + for (const level of Object.keys(parsedExplicit)) { + console.log(`[Devtools] Setting value of ${settingId} at ${level} in ${roomId} from user input`); + try { + const val = parsedExplicitRoom[level]; + await SettingsStore.setValue(settingId, roomId, level as SettingLevel, val); + } catch (e) { + console.warn(e); + } + } + this.setState({ + viewSetting: settingId, + editSetting: null, + }); + } catch (e) { + Modal.createTrackedDialog('Devtools - Failed to save settings', '', ErrorDialog, { + title: _t("Failed to save settings"), + description: e.message, + }); + } + }; + + private renderSettingValue(val: any): string { + // Note: we don't .toString() a string because we want JSON.stringify to inject quotes for us + const toStringTypes = ['boolean', 'number']; + if (toStringTypes.includes(typeof(val))) { + return val.toString(); + } else { + return JSON.stringify(val); + } + } + + private renderExplicitSettingValues(setting: string, roomId: string): string { + const vals = {}; + for (const level of LEVEL_ORDER) { + try { + vals[level] = SettingsStore.getValueAt(level, setting, roomId, true, true); + if (vals[level] === undefined) { + vals[level] = null; + } + } catch (e) { + console.warn(e); + } + } + return JSON.stringify(vals, null, 4); + } + + private renderCanEditLevel(roomId: string, level: SettingLevel): React.ReactNode { + const canEdit = SettingsStore.canSetValue(this.state.editSetting, roomId, level); + const className = canEdit ? 'mx_DevTools_SettingsExplorer_mutable' : 'mx_DevTools_SettingsExplorer_immutable'; + return {canEdit.toString()}; + } + + render() { + const room = this.props.room; + + if (!this.state.viewSetting && !this.state.editSetting) { + // view all settings + const allSettings = Object.keys(SETTINGS) + .filter(n => this.state.query ? n.toLowerCase().includes(this.state.query.toLowerCase()) : true); + return ( +
      +
      + + + + + + + + + + + {allSettings.map(i => ( + + + + + + ))} + +
      {_t("Setting ID")}{_t("Value")}{_t("Value in this room")}
      + this.onViewClick(e, i)}> + {i} + + this.onEditClick(e, i)} + className='mx_DevTools_SettingsExplorer_edit' + > + ✏ + + + {this.renderSettingValue(SettingsStore.getValue(i))} + + + {this.renderSettingValue(SettingsStore.getValue(i, room.roomId))} + +
      +
      +
      + +
      +
      + ); + } else if (this.state.editSetting) { + return ( +
      +
      +

      {_t("Setting:")} {this.state.editSetting}

      + +
      + {_t("Caution:")} {_t( + "This UI does NOT check the types of the values. Use at your own risk.", + )} +
      + +
      + {_t("Setting definition:")} +
      {JSON.stringify(SETTINGS[this.state.editSetting], null, 4)}
      +
      + +
      + + + + + + + + + + {LEVEL_ORDER.map(lvl => ( + + + {this.renderCanEditLevel(null, lvl)} + {this.renderCanEditLevel(room.roomId, lvl)} + + ))} + +
      {_t("Level")}{_t("Settable at global")}{_t("Settable at room")}
      {lvl}
      +
      + +
      + +
      + +
      + +
      + +
      +
      + + +
      +
      + ); + } else if (this.state.viewSetting) { + return ( +
      +
      +

      {_t("Setting:")} {this.state.viewSetting}

      + +
      + {_t("Setting definition:")} +
      {JSON.stringify(SETTINGS[this.state.viewSetting], null, 4)}
      +
      + +
      + {_t("Value:")}  + {this.renderSettingValue( + SettingsStore.getValue(this.state.viewSetting), + )} +
      + +
      + {_t("Value in this room:")}  + {this.renderSettingValue( + SettingsStore.getValue(this.state.viewSetting, room.roomId), + )} +
      + +
      + {_t("Values at explicit levels:")} +
      {this.renderExplicitSettingValues(
      +                                this.state.viewSetting, null,
      +                            )}
      +
      + +
      + {_t("Values at explicit levels in this room:")} +
      {this.renderExplicitSettingValues(
      +                                this.state.viewSetting, room.roomId,
      +                            )}
      +
      + +
      +
      + + +
      +
      + ); + } + } +} + +type DevtoolsDialogEntry = React.JSXElementConstructor & { + getLabel: () => string; +}; + +const Entries: DevtoolsDialogEntry[] = [ + SendCustomEvent, + RoomStateExplorer, + SendAccountData, + AccountDataExplorer, + ServersInRoomList, + VerificationExplorer, + WidgetExplorer, + SettingsExplorer, +]; + +interface IProps { + roomId: string; + onFinished: (finished: boolean) => void; +} + +interface IState { + mode?: DevtoolsDialogEntry; +} + +@replaceableComponent("views.dialogs.DevtoolsDialog") +export default class DevtoolsDialog extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + mode: null, + }; + } + + private setMode(mode: DevtoolsDialogEntry) { + return () => { + this.setState({ mode }); + }; + } + + private onBack = () => { + this.setState({ mode: null }); + }; + + private onCancel = () => { + this.props.onFinished(false); + }; + + render() { + let body; + + if (this.state.mode) { + body = + {(cli) => +
      { this.state.mode.getLabel() }
      +
      Room ID: { this.props.roomId }
      +
      + + } + ; + } else { + const classes = "mx_DevTools_RoomStateExplorer_button"; + body = +
      +
      { _t('Toolbox') }
      +
      Room ID: { this.props.roomId }
      +
      + +
      + { Entries.map((Entry) => { + const label = Entry.getLabel(); + const onClick = this.setMode(Entry); + return ; + }) } +
      +
      +
      + +
      + ; + } + + return ( + + { body } + + ); + } +} diff --git a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx index 3071854b3e..217e4f2d37 100644 --- a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx +++ b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx @@ -23,6 +23,8 @@ import AccessibleButton from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import FlairStore from "../../../stores/FlairStore"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; interface IProps extends IDialogProps { communityId: string; @@ -38,6 +40,7 @@ interface IState { } // XXX: This is a lot of duplication from the create dialog, just in a different shape +@replaceableComponent("views.dialogs.EditCommunityPrototypeDialog") export default class EditCommunityPrototypeDialog extends React.PureComponent { private avatarUploadRef: React.RefObject = React.createRef(); @@ -57,7 +60,7 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent) => { - this.setState({name: ev.target.value}); + this.setState({ name: ev.target.value }); }; private onSubmit = async (ev) => { @@ -68,7 +71,7 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent) => { if (!e.target.files || !e.target.files.length) { - this.setState({avatarFile: null}); + this.setState({ avatarFile: null }); } else { - this.setState({busy: true}); + this.setState({ busy: true }); const file = e.target.files[0]; const reader = new FileReader(); reader.onload = (ev: ProgressEvent) => { - this.setState({avatarFile: file, busy: false, avatarPreview: ev.target.result as string}); + this.setState({ avatarFile: file, busy: false, avatarPreview: ev.target.result as string }); }; reader.readAsDataURL(file); } @@ -116,10 +119,10 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent; if (!this.state.avatarPreview) { if (this.state.currentAvatarUrl) { - const url = MatrixClientPeg.get().mxcUrlToHttp(this.state.currentAvatarUrl); + const url = mediaFromMxc(this.state.currentAvatarUrl).srcHttp; preview = ; } else { - preview =
      + preview =
      ; } } @@ -141,7 +144,7 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent
      diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.tsx similarity index 72% rename from src/components/views/dialogs/ErrorDialog.js rename to src/components/views/dialogs/ErrorDialog.tsx index acebdcd854..56cd76237f 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.tsx @@ -26,32 +26,37 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; -export default class ErrorDialog extends React.Component { - static propTypes = { - title: PropTypes.string, - description: PropTypes.oneOfType([ - PropTypes.element, - PropTypes.string, - ]), - button: PropTypes.string, - focus: PropTypes.bool, - onFinished: PropTypes.func.isRequired, - headerImage: PropTypes.string, - }; +interface IProps { + onFinished: (success: boolean) => void; + title?: string; + description?: React.ReactNode; + button?: string; + focus?: boolean; + headerImage?: string; +} - static defaultProps = { +interface IState { + onFinished: (success: boolean) => void; +} + +@replaceableComponent("views.dialogs.ErrorDialog") +export default class ErrorDialog extends React.Component { + public static defaultProps = { focus: true, title: null, description: null, button: null, }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + private onClick = () => { + this.props.onFinished(true); + }; + + public render() { return (
      -
      diff --git a/src/components/views/dialogs/FeedbackDialog.js b/src/components/views/dialogs/FeedbackDialog.js index 2515377709..88a57cf8cb 100644 --- a/src/components/views/dialogs/FeedbackDialog.js +++ b/src/components/views/dialogs/FeedbackDialog.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useState} from 'react'; +import React, { useState } from 'react'; import QuestionDialog from './QuestionDialog'; import { _t } from '../../../languageHandler'; import Field from "../elements/Field"; @@ -30,7 +30,6 @@ const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" + "?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc"; const newIssueUrl = "https://github.com/vector-im/element-web/issues/new"; - export default (props) => { const [rating, setRating] = useState(""); const [comment, setComment] = useState(""); @@ -48,8 +47,8 @@ export default (props) => { title: _t('Feedback sent'), description: _t('Thank you!'), }); - props.onFinished(); } + props.onFinished(); }; const brand = SdkConfig.get().brand; @@ -100,6 +99,20 @@ export default (props) => { ); } + let bugReports = null; + if (SdkConfig.get().bug_report_endpoint_url) { + bugReports = ( +

      { + _t("PRO TIP: If you start a bug, please submit debug logs " + + "to help us track down the problem.", {}, { + debugLogsLink: sub => ( + {sub} + ), + }) + }

      + ); + } + return ( { }, }) }

      -

      { - _t("PRO TIP: If you start a bug, please submit debug logs " + - "to help us track down the problem.", {}, { - debugLogsLink: sub => ( - {sub} - ), - }) - }

      + {bugReports}
      { countlyFeedbackSection } } diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx new file mode 100644 index 0000000000..839ca6da2f --- /dev/null +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -0,0 +1,269 @@ +/* +Copyright 2021 Robin Townsend + +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, { useMemo, useState, useEffect } from "react"; +import classnames from "classnames"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; + +import { _t } from "../../../languageHandler"; +import dis from "../../../dispatcher/dispatcher"; +import { useSettingValue, useFeatureEnabled } from "../../../hooks/useSettings"; +import { UIFeature } from "../../../settings/UIFeature"; +import { Layout } from "../../../settings/Layout"; +import { IDialogProps } from "./IDialogProps"; +import BaseDialog from "./BaseDialog"; +import { avatarUrlForUser } from "../../../Avatar"; +import EventTile from "../rooms/EventTile"; +import SearchBox from "../../structures/SearchBox"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import { Alignment } from '../elements/Tooltip'; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; +import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; +import NotificationBadge from "../rooms/NotificationBadge"; +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import QueryMatcher from "../../../autocomplete/QueryMatcher"; +import TruncatedList from "../elements/TruncatedList"; +import EntityTile from "../rooms/EntityTile"; +import BaseAvatar from "../avatars/BaseAvatar"; +import SpaceStore from "../../../stores/SpaceStore"; + +const AVATAR_SIZE = 30; + +interface IProps extends IDialogProps { + matrixClient: MatrixClient; + // The event to forward + event: MatrixEvent; + // We need a permalink creator for the source room to pass through to EventTile + // in case the event is a reply (even though the user can't get at the link) + permalinkCreator: RoomPermalinkCreator; +} + +interface IEntryProps { + room: Room; + event: MatrixEvent; + matrixClient: MatrixClient; + onFinished(success: boolean): void; +} + +enum SendState { + CanSend, + Sending, + Sent, + Failed, +} + +const Entry: React.FC = ({ room, event, matrixClient: cli, onFinished }) => { + const [sendState, setSendState] = useState(SendState.CanSend); + + const jumpToRoom = () => { + dis.dispatch({ + action: "view_room", + room_id: room.roomId, + }); + onFinished(true); + }; + const send = async () => { + setSendState(SendState.Sending); + try { + await cli.sendEvent(room.roomId, event.getType(), event.getContent()); + setSendState(SendState.Sent); + } catch (e) { + setSendState(SendState.Failed); + } + }; + + let className; + let disabled = false; + let title; + let icon; + if (sendState === SendState.CanSend) { + className = "mx_ForwardList_canSend"; + if (room.maySendMessage()) { + title = _t("Send"); + } else { + disabled = true; + title = _t("You don't have permission to do this"); + } + } else if (sendState === SendState.Sending) { + className = "mx_ForwardList_sending"; + disabled = true; + title = _t("Sending"); + icon =
      ; + } else if (sendState === SendState.Sent) { + className = "mx_ForwardList_sent"; + disabled = true; + title = _t("Sent"); + icon =
      ; + } else { + className = "mx_ForwardList_sendFailed"; + disabled = true; + title = _t("Failed to send"); + icon = ; + } + + return
      + + + { room.name } + + +
      { _t("Send") }
      + { icon } +
      +
      ; +}; + +const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCreator, onFinished }) => { + const userId = cli.getUserId(); + const [profileInfo, setProfileInfo] = useState({}); + useEffect(() => { + cli.getProfileInfo(userId).then(info => setProfileInfo(info)); + }, [cli, userId]); + + // For the message preview we fake the sender as ourselves + const mockEvent = new MatrixEvent({ + type: "m.room.message", + sender: userId, + content: event.getContent(), + unsigned: { + age: 97, + }, + event_id: "$9999999999999999999999999999999999999999999", + room_id: event.getRoomId(), + }); + mockEvent.sender = { + name: profileInfo.displayname || userId, + rawDisplayName: profileInfo.displayname, + userId, + getAvatarUrl: (..._) => { + return avatarUrlForUser( + { avatarUrl: profileInfo.avatar_url }, + AVATAR_SIZE, AVATAR_SIZE, "crop", + ); + }, + getMxcAvatarUrl: () => profileInfo.avatar_url, + } as RoomMember; + + const [query, setQuery] = useState(""); + const lcQuery = query.toLowerCase(); + + const spacesEnabled = SpaceStore.spacesEnabled; + const flairEnabled = useFeatureEnabled(UIFeature.Flair); + const previewLayout = useSettingValue("layout"); + + let rooms = useMemo(() => sortRooms( + cli.getVisibleRooms().filter( + room => room.getMyMembership() === "join" && + !(spacesEnabled && room.isSpaceRoom()), + ), + ), [cli, spacesEnabled]); + + if (lcQuery) { + rooms = new QueryMatcher(rooms, { + keys: ["name"], + funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)], + shouldMatchWordsOnly: false, + }).match(lcQuery); + } + + const [truncateAt, setTruncateAt] = useState(20); + function overflowTile(overflowCount, totalCount) { + const text = _t("and %(count)s others...", { count: overflowCount }); + return ( + + } name={text} presenceState="online" suppressOnHover={true} + onClick={() => setTruncateAt(totalCount)} /> + ); + } + + return +

      { _t("Message preview") }

      +
      + +
      +
      +
      + + + { rooms.length > 0 ? ( +
      + rooms.slice(start, end).map(room => + , + )} + getChildCount={() => rooms.length} + /> +
      + ) : + { _t("No results") } + } +
      +
      +
      ; +}; + +export default ForwardDialog; diff --git a/src/components/views/dialogs/HostSignupDialog.tsx b/src/components/views/dialogs/HostSignupDialog.tsx new file mode 100644 index 0000000000..64c080bf01 --- /dev/null +++ b/src/components/views/dialogs/HostSignupDialog.tsx @@ -0,0 +1,293 @@ +/* +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 from "react"; +import AccessibleButton from "../elements/AccessibleButton"; +import Modal from "../../../Modal"; +import PersistedElement from "../elements/PersistedElement"; +import QuestionDialog from './QuestionDialog'; +import SdkConfig from "../../../SdkConfig"; +import classNames from "classnames"; +import { _t } from "../../../languageHandler"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { HostSignupStore } from "../../../stores/HostSignupStore"; +import { OwnProfileStore } from "../../../stores/OwnProfileStore"; +import { + IHostSignupConfig, + IPostmessage, + IPostmessageResponseData, + PostmessageAction, +} from "./HostSignupDialogTypes"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +const HOST_SIGNUP_KEY = "host_signup"; + +interface IProps {} + +interface IState { + completed: boolean; + error: string; + minimized: boolean; +} + +@replaceableComponent("views.dialogs.HostSignupDialog") +export default class HostSignupDialog extends React.PureComponent { + private iframeRef: React.RefObject = React.createRef(); + private readonly config: IHostSignupConfig; + + constructor(props: IProps) { + super(props); + + this.state = { + completed: false, + error: null, + minimized: false, + }; + + this.config = SdkConfig.get().hostSignup; + } + + private messageHandler = async (message: IPostmessage) => { + if (!this.config.url.startsWith(message.origin)) { + return; + } + switch (message.data.action) { + case PostmessageAction.HostSignupAccountDetailsRequest: + this.onAccountDetailsRequest(); + break; + case PostmessageAction.Maximize: + this.setState({ + minimized: false, + }); + break; + case PostmessageAction.Minimize: + this.setState({ + minimized: true, + }); + break; + case PostmessageAction.SetupComplete: + this.setState({ + completed: true, + }); + break; + case PostmessageAction.CloseDialog: + return this.closeDialog(); + } + }; + + private maximizeDialog = () => { + this.setState({ + minimized: false, + }); + // Send this action to the iframe so it can act accordingly + this.sendMessage({ + action: PostmessageAction.Maximize, + }); + }; + + private minimizeDialog = () => { + this.setState({ + minimized: true, + }); + // Send this action to the iframe so it can act accordingly + this.sendMessage({ + action: PostmessageAction.Minimize, + }); + }; + + private closeDialog = async () => { + window.removeEventListener("message", this.messageHandler); + // Ensure we destroy the host signup persisted element + PersistedElement.destroyElement("host_signup"); + // Finally clear the flag in + return HostSignupStore.instance.setHostSignupActive(false); + }; + + private onCloseClick = async () => { + if (this.state.completed) { + // We're done, close + return this.closeDialog(); + } else { + Modal.createDialog( + QuestionDialog, + { + title: _t("Confirm abort of host creation"), + description: _t( + "Are you sure you wish to abort creation of the host? The process cannot be continued.", + ), + button: _t("Abort"), + onFinished: result => { + if (result) { + return this.closeDialog(); + } + }, + }, + ); + } + }; + + private sendMessage = (message: IPostmessageResponseData) => { + this.iframeRef.current.contentWindow.postMessage(message, this.config.url); + }; + + private async sendAccountDetails() { + const openIdToken = await MatrixClientPeg.get().getOpenIdToken(); + if (!openIdToken || !openIdToken.access_token) { + console.warn("Failed to connect to homeserver for OpenID token."); + this.setState({ + completed: true, + error: _t("Failed to connect to your homeserver. Please close this dialog and try again."), + }); + return; + } + this.sendMessage({ + action: PostmessageAction.HostSignupAccountDetails, + account: { + accessToken: await MatrixClientPeg.get().getAccessToken(), + name: OwnProfileStore.instance.displayName, + openIdToken: openIdToken.access_token, + serverName: await MatrixClientPeg.get().getDomain(), + userLocalpart: await MatrixClientPeg.get().getUserIdLocalpart(), + termsAccepted: true, + }, + }); + } + + private onAccountDetailsDialogFinished = async (result) => { + if (result) { + return this.sendAccountDetails(); + } + return this.closeDialog(); + }; + + private onAccountDetailsRequest = () => { + const textComponent = ( + <> +

      + {_t("Continuing temporarily allows the %(hostSignupBrand)s setup process to access your " + + "account to fetch verified email addresses. This data is not stored.", { + hostSignupBrand: this.config.brand, + })} +

      +

      + {_t("Learn more in our , and .", + {}, + { + cookiePolicyLink: () => ( + + {_t("Cookie Policy")} + + ), + privacyPolicyLink: () => ( + + {_t("Privacy Policy")} + + ), + termsOfServiceLink: () => ( + + {_t("Terms of Service")} + + ), + }, + )} +

      + + ); + Modal.createDialog( + QuestionDialog, + { + title: _t("You should know"), + description: textComponent, + button: _t("Continue"), + onFinished: this.onAccountDetailsDialogFinished, + }, + ); + }; + + public componentDidMount() { + window.addEventListener("message", this.messageHandler); + } + + public componentWillUnmount() { + if (HostSignupStore.instance.isHostSignupActive) { + // Run the close dialog actions if we're still active, otherwise good to go + return this.closeDialog(); + } + } + + public render(): React.ReactNode { + return ( +
      + +
      +
      + {this.state.minimized && +
      +
      + {_t("%(hostSignupBrand)s Setup", { + hostSignupBrand: this.config.brand, + })} +
      + +
      + } + {!this.state.minimized && +
      + + +
      + } + {this.state.error && +
      + {this.state.error} +
      + } + {!this.state.error && +