diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles deleted file mode 100644 index d9177bebb5..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/NodeAnimator.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/element/ -test/end-to-end-tests/synapse/ diff --git a/.eslintrc.js b/.eslintrc.js index c759eae9e3..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,35 +17,60 @@ module.exports = { "prefer-promise-reject-errors": "off", "no-async-promise-executor": "off", "quotes": "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}", "test/**/*.{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", - "no-restricted-properties": [ - "error", - ...buildRestrictedPropertiesOptions( - ["window.innerHeight", "window.innerWidth", "window.visualViewport"], - "Use UIStore to access window dimensions instead", - ), - ], }, }], }; function buildRestrictedPropertiesOptions(properties, message) { return properties.map(prop => { - const [object, property] = prop.split("."); + let [object, property] = prop.split("."); + if (object === "*") { + object = undefined; + } return { object, property, 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..c9d11f02c8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ + + + diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 3f82e61280..3c3807e33b 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -1,4 +1,4 @@ -name: Develop jobs +name: Develop on: push: branches: [develop] @@ -11,17 +11,31 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v2 - - name: End-to-End tests - run: ./scripts/ci/end-to-end-tests.sh + - 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: Archive performance benchmark - uses: actions/upload-artifact@v2 + - name: Download previous benchmark data + uses: actions/cache@v1 with: - name: performance-entries.json - path: test/end-to-end-tests/performance-entries.json + 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/CHANGELOG.md b/CHANGELOG.md index 94c9530941..22b35b7c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,304 @@ +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) diff --git a/babel.config.js b/babel.config.js index 0a3a34a391..f00e83652c 100644 --- a/babel.config.js +++ b/babel.config.js @@ -10,7 +10,6 @@ module.exports = { ], }], "@babel/preset-typescript", - "@babel/preset-flow", "@babel/preset-react", ], "plugins": [ @@ -19,7 +18,6 @@ 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", ], diff --git a/package.json b/package.json index 4dc4bda3b2..610404ba0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.23.0", + "version": "3.25.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -45,7 +45,7 @@ "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", @@ -54,7 +54,9 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", + "@types/commonmark": "^0.27.4", "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", @@ -78,8 +80,8 @@ "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "^0.1.0-beta.14", + "matrix-js-sdk": "12.0.1", + "matrix-widget-api": "^0.1.0-beta.15", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", @@ -89,7 +91,7 @@ "qrcode": "^1.4.4", "re-resizable": "^6.9.0", "react": "^17.0.2", - "react-beautiful-dnd": "^4.0.1", + "react-beautiful-dnd": "^13.1.0", "react-dom": "^17.0.2", "react-focus-lock": "^2.5.0", "react-transition-group": "^4.4.1", @@ -104,16 +106,16 @@ "devDependencies": { "@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-flow-comments": "^7.12.1", "@babel/plugin-transform-runtime": "^7.12.10", "@babel/preset-env": "^7.12.11", - "@babel/preset-flow": "^7.12.1", "@babel/preset-react": "^7.12.10", "@babel/preset-typescript": "^7.12.7", "@babel/register": "^7.12.10", @@ -123,6 +125,7 @@ "@sinonjs/fake-timers": "^7.0.2", "@types/classnames": "^2.2.11", "@types/counterpart": "^0.18.1", + "@types/diff-match-patch": "^1.0.32", "@types/flux": "^3.1.9", "@types/jest": "^26.0.20", "@types/linkifyjs": "^2.1.3", @@ -132,23 +135,22 @@ "@types/pako": "^1.0.1", "@types/parse5": "^6.0.0", "@types/qrcode": "^1.3.5", - "@types/react": "^16.9", - "@types/react-dom": "^16.9.10", + "@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": "^2.3.1", "@types/zxcvbn": "^4.4.0", - "@typescript-eslint/eslint-plugin": "^4.14.0", - "@typescript-eslint/parser": "^4.14.0", - "babel-eslint": "^10.1.0", + "@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", - "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", "eslint": "7.18.0", - "eslint-config-matrix-org": "^0.2.0", - "eslint-plugin-babel": "^5.3.1", - "eslint-plugin-flowtype": "^5.2.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", @@ -157,7 +159,7 @@ "jest-environment-jsdom-sixteen": "^1.0.3", "jest-fetch-mock": "^3.0.3", "matrix-mock-request": "^1.2.3", - "matrix-react-test-utils": "^0.2.2", + "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", @@ -167,13 +169,10 @@ "typescript": "^4.1.3", "walk": "^2.3.14" }, - "resolutions": { - "**/@types/react": "^16.14" - }, "jest": { "testEnvironment": "./__test-utils__/environment.js", "testMatch": [ - "/test/**/*-test.[jt]s" + "/test/**/*-test.[jt]s?(x)" ], "setupFiles": [ "jest-canvas-mock" diff --git a/res/css/_components.scss b/res/css/_components.scss index cd156d8b8b..a1a2d7ede3 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -37,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"; @@ -52,7 +57,6 @@ @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"; @@ -123,7 +127,6 @@ @import "./views/elements/_EventListSummary.scss"; @import "./views/elements/_FacePile.scss"; @import "./views/elements/_Field.scss"; -@import "./views/elements/_FormButton.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InlineSpinner.scss"; @@ -166,6 +169,7 @@ @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"; @@ -254,9 +258,6 @@ @import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; -@import "./views/voice_messages/_PlayPauseButton.scss"; -@import "./views/voice_messages/_PlaybackContainer.scss"; -@import "./views/voice_messages/_Waveform.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; @import "./views/voip/_CallViewForRoom.scss"; 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/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index c7dd678c07..f254ca3226 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -111,6 +111,29 @@ $roomListCollapsedWidth: 68px; } } + .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; @@ -185,6 +208,12 @@ $roomListCollapsedWidth: 68px; 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/_RightPanel.scss b/res/css/structures/_RightPanel.scss index 52a2a68b6a..3222fe936c 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -121,23 +121,51 @@ $pulse-color: $pinned-unread-color; 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); - box-shadow: 0 0 0 0 rgba($pulse-color, 0.7); } 70% { transform: scale(1); - box-shadow: 0 0 0 10px rgba($pulse-color, 0); } 100% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0); + } +} + +@keyframes mx_RightPanel_indicator_pulse_shadow { + 0% { + opacity: 0.7; + } + + 70% { + transform: scale(2.2); + opacity: 0; + } + + 100% { + opacity: 0; } } diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index 8cc00aba0f..de9e049165 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -112,7 +112,7 @@ limitations under the License. .mx_AccessibleButton { padding: 5px 10px; - padding-left: 28px; // 16px for the icon, 2px margin to text, 10px regular padding + padding-left: 30px; // 18px for the icon, 2px margin to text, 10px regular padding display: inline-block; position: relative; @@ -128,13 +128,14 @@ limitations under the License. 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'); - width: 12px; - height: 16px; - top: calc(50% - 8px); // text sizes are dynamic } &.mx_RoomStatusBar_unsentResendAllBtn { @@ -142,9 +143,6 @@ limitations under the License. &::before { mask-image: url('$(res)/img/element-icons/retry.svg'); - width: 18px; - height: 18px; - top: calc(50% - 9px); // text sizes are dynamic } } } diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 0efa2d01a1..831f186ed4 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -57,14 +57,15 @@ limitations under the License. @keyframes mx_RoomView_fileDropTarget_image_animation { from { - width: 0px; + transform: scaleX(0); } to { - width: 32px; + transform: scaleX(1); } } .mx_RoomView_fileDropTarget_image { + width: 32px; animation: mx_RoomView_fileDropTarget_image_animation; animation-duration: 0.5s; margin-bottom: 16px; diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index c433ccf275..e64057d16c 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -31,7 +31,6 @@ $activeBorderColor: $secondary-fg-color; // Create another flexbox so the Panel fills the container display: flex; flex-direction: column; - overflow-y: auto; .mx_SpacePanel_spaceTreeWrapper { flex: 1; @@ -69,6 +68,12 @@ $activeBorderColor: $secondary-fg-color; cursor: pointer; } + .mx_SpaceItem_dragging { + .mx_SpaceButton_toggleCollapse { + visibility: hidden; + } + } + .mx_SpaceTreeLevel { display: flex; flex-direction: column; diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 12762cbc9d..48b565be7f 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -328,7 +328,8 @@ $SpaceRoomViewInnerWidth: 428px; font-size: $font-15px; margin-top: 12px; margin-bottom: 16px; - white-space: pre; + white-space: pre-wrap; + word-wrap: break-word; } > hr { diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index 09f834a6e3..d248568740 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -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 { 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/voice_messages/_PlayPauseButton.scss b/res/css/views/audio_messages/_PlayPauseButton.scss similarity index 91% rename from res/css/views/voice_messages/_PlayPauseButton.scss rename to res/css/views/audio_messages/_PlayPauseButton.scss index 6caedafa29..714da3e605 100644 --- a/res/css/views/voice_messages/_PlayPauseButton.scss +++ b/res/css/views/audio_messages/_PlayPauseButton.scss @@ -18,6 +18,8 @@ limitations under the License. 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; diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/audio_messages/_PlaybackContainer.scss similarity index 87% rename from res/css/views/voice_messages/_PlaybackContainer.scss rename to res/css/views/audio_messages/_PlaybackContainer.scss index 20def16d6a..fd01864bba 100644 --- a/res/css/views/voice_messages/_PlaybackContainer.scss +++ b/res/css/views/audio_messages/_PlaybackContainer.scss @@ -22,25 +22,24 @@ limitations under the License. // 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; - background-color: $voice-record-waveform-bg-color; - border-radius: 12px; // Cheat at alignment a bit display: flex; align-items: center; - color: $voice-record-waveform-fg-color; - font-size: $font-14px; - line-height: $font-24px; + 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: $voice-record-waveform-fg-color; + background-color: $message-body-panel-fg-color; } } } 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/voice_messages/_Waveform.scss b/res/css/views/audio_messages/_Waveform.scss similarity index 100% rename from res/css/views/voice_messages/_Waveform.scss rename to res/css/views/audio_messages/_Waveform.scss diff --git a/res/css/views/beta/_BetaCard.scss b/res/css/views/beta/_BetaCard.scss index 3463a653fc..2af4e79ecd 100644 --- a/res/css/views/beta/_BetaCard.scss +++ b/res/css/views/beta/_BetaCard.scss @@ -19,49 +19,68 @@ limitations under the License. padding: 24px; background-color: $settings-profile-placeholder-bg-color; border-radius: 8px; - display: flex; box-sizing: border-box; - > 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_columns { + display: flex; - .mx_BetaCard_betaPill { - margin-left: 12px; + > 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; } } - .mx_BetaCard_caption { - font-size: $font-15px; - line-height: $font-20px; - color: $secondary-fg-color; - margin-bottom: 20px; - } - - .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%; } } - > 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; + } + } } } @@ -91,24 +110,52 @@ $dot-size: 12px; width: $dot-size; transform: scale(1); background: rgba($pulse-color, 1); - box-shadow: 0 0 0 0 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); - box-shadow: 0 0 0 0 rgba($pulse-color, 0.7); } 70% { transform: scale(1); - box-shadow: 0 0 0 10px rgba($pulse-color, 0); } 100% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0); + } +} + +@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/_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/_ForwardDialog.scss b/res/css/views/dialogs/_ForwardDialog.scss index 1976a43ab9..95d7ce74c4 100644 --- a/res/css/views/dialogs/_ForwardDialog.scss +++ b/res/css/views/dialogs/_ForwardDialog.scss @@ -34,7 +34,7 @@ limitations under the License. > .mx_ForwardDialog_preview { max-height: 30%; flex-shrink: 0; - overflow: scroll; + overflow-y: auto; div { pointer-events: none; diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss index d8ff56663a..c01b43c1c4 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -17,6 +17,9 @@ limitations under the License. .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; @@ -73,7 +76,7 @@ limitations under the License. } .mx_InviteDialog_section { - padding-bottom: 10px; + padding-bottom: 4px; h3 { font-size: $font-12px; @@ -82,6 +85,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 @@ -90,6 +101,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; @@ -142,6 +210,7 @@ limitations under the License. .mx_InviteDialog_roomTile_nameStack { display: inline-block; + overflow: hidden; } .mx_InviteDialog_roomTile_name { @@ -157,6 +226,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; @@ -212,24 +288,71 @@ limitations under the License. .mx_InviteDialog { // 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 + display: flex; + flex-direction: column; + + .mx_InviteDialog_content { + overflow: hidden; + height: 100%; + } } .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; + height: calc(100% - 115px); // mx_InviteDialog's height minus some for the upper and lower elements } -// 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_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/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/_SpaceSettingsDialog.scss b/res/css/views/dialogs/_SpaceSettingsDialog.scss index 6e5fd9c8c8..fa074fdbe8 100644 --- a/res/css/views/dialogs/_SpaceSettingsDialog.scss +++ b/res/css/views/dialogs/_SpaceSettingsDialog.scss @@ -15,7 +15,6 @@ limitations under the License. */ .mx_SpaceSettingsDialog { - width: 480px; color: $primary-fg-color; .mx_SpaceSettings_errorText { @@ -32,8 +31,44 @@ limitations under the License. margin-left: 16px; } - .mx_AccessibleButton_kind_danger { - margin-top: 28px; + .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 { @@ -52,4 +87,14 @@ limitations under the License. .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/security/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss index 30b79c1a9a..ec3bea0ef7 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss @@ -28,6 +28,7 @@ limitations under the License. left: 0; top: 2px; // alignment background-image: url("$(res)/img/element-icons/warning-badge.svg"); + background-size: contain; } .mx_AccessSecretStorageDialog_reset_link { diff --git a/res/css/views/elements/_FormButton.scss b/res/css/views/elements/_FormButton.scss deleted file mode 100644 index eda201ff03..0000000000 --- a/res/css/views/elements/_FormButton.scss +++ /dev/null @@ -1,42 +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_FormButton { - line-height: $font-16px; - padding: 5px 15px; - font-size: $font-12px; - height: min-content; - - &:not(:last-child) { - margin-right: 8px; - } - - &.mx_AccessibleButton_kind_primary { - color: $accent-color; - background-color: $accent-bg-color; - } - - &.mx_AccessibleButton_kind_danger { - color: $notice-primary-color; - background-color: $notice-primary-bg-color; - } - - &.mx_AccessibleButton_kind_secondary { - color: $secondary-fg-color; - border: 1px solid $secondary-fg-color; - background-color: unset; - } -} 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/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/_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/_common_CryptoEvent.scss b/res/css/views/messages/_common_CryptoEvent.scss index 4faa4b594f..afaed50fa4 100644 --- a/res/css/views/messages/_common_CryptoEvent.scss +++ b/res/css/views/messages/_common_CryptoEvent.scss @@ -21,7 +21,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%; } &.mx_cryptoEvent_icon::after { @@ -48,6 +48,7 @@ limitations under the License. .mx_cryptoEvent_buttons { align-items: center; display: flex; + gap: 5px; } .mx_cryptoEvent_state { diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index 87420ae4e7..6632ccddf9 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -259,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/_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 3af266caee..27a83e58f8 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -345,7 +345,7 @@ $hover-select-border: 4px; mask-image: url('$(res)/img/e2e/normal.svg'); mask-repeat: no-repeat; mask-position: center; - mask-size: 90%; + mask-size: 80%; } } diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index cf61ce569d..5e61c3b8a3 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; @@ -173,27 +162,37 @@ $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; - > .mx_SenderProfile_name { + > .mx_SenderProfile_displayName { + width: 100%; + text-align: end; overflow: hidden; text-overflow: ellipsis; - min-width: var(--name-width); - text-align: end; + } + + > .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 { @@ -201,16 +200,7 @@ $irc-line-height: $font-18px; .mx_SenderProfile { width: unset; max-width: var(--name-width); - } - - .mx_SenderProfile_hover { background: transparent; - - > span { - > .mx_SenderProfile_name { - min-width: inherit; - } - } } .mx_EventTile_emote { diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss index a3ee104bd8..5501ab343e 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.scss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -36,10 +36,10 @@ limitations under the License. } .mx_VoiceRecordComposerTile_delete { - width: 14px; // w&h are size of icon - height: 18px; + width: 24px; + height: 24px; vertical-align: middle; - margin-right: 11px; // distance from left edge of waveform container (container has some margin too) + 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; diff --git a/res/css/views/spaces/_SpaceBasicSettings.scss b/res/css/views/spaces/_SpaceBasicSettings.scss index 204ccab2b7..c73e0715dd 100644 --- a/res/css/views/spaces/_SpaceBasicSettings.scss +++ b/res/css/views/spaces/_SpaceBasicSettings.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_SpaceBasicSettings { .mx_Field { - margin: 32px 0; + margin: 24px 0; } .mx_SpaceBasicSettings_avatarContainer { @@ -73,7 +73,7 @@ limitations under the License. } } - .mx_FormButton { + .mx_AccessibleButton_hasKind { padding: 8px 22px; margin-left: auto; display: block; diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss index 8262075559..168a8bb74b 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_CallContainer.scss @@ -98,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/_DialPad.scss b/res/css/views/voip/_DialPad.scss index 0c7bff0ce8..483b131bfe 100644 --- a/res/css/views/voip/_DialPad.scss +++ b/res/css/views/voip/_DialPad.scss @@ -23,7 +23,7 @@ limitations under the License. .mx_DialPad_button { width: 40px; height: 40px; - background-color: $theme-button-bg-color; + background-color: $dialpad-button-bg-color; border-radius: 40px; font-size: 18px; font-weight: 600; diff --git a/res/css/views/voip/_DialPadContextMenu.scss b/res/css/views/voip/_DialPadContextMenu.scss index 520f51cf93..31327113cf 100644 --- a/res/css/views/voip/_DialPadContextMenu.scss +++ b/res/css/views/voip/_DialPadContextMenu.scss @@ -27,9 +27,22 @@ limitations under the License. } .mx_DialPadContextMenu_dialled { - height: 1em; + height: 1.5em; font-size: 18px; font-weight: 600; + max-width: 150px; + border: none; + margin: 0px; +} +.mx_DialPadContextMenu_dialled input { + font-size: 18px; + font-weight: 600; + overflow: hidden; + max-width: 150px; + text-align: left; + direction: rtl; + padding: 8px 0px; + background-color: rgb(0, 0, 0, 0); } .mx_DialPadContextMenu_dialPad { 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/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/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/trashcan.svg b/res/img/element-icons/trashcan.svg index f8fb8b5c46..4106f0bd60 100644 --- a/res/img/element-icons/trashcan.svg +++ b/res/img/element-icons/trashcan.svg @@ -1,3 +1,3 @@ - - + + diff --git a/res/img/element-icons/warning-badge.svg b/res/img/element-icons/warning-badge.svg index 1ae4e40ffe..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/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/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/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 2d0e3d2a8b..81fd3c892a 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -118,6 +118,9 @@ $voipcall-plinth-color: #394049; // ******************** $theme-button-bg-color: #e3e8f0; +$dialpad-button-bg-color: #6F7882; +; + $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; @@ -212,8 +215,6 @@ $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-bg-color: $message-body-panel-bg-color; -$voice-record-waveform-fg-color: $message-body-panel-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; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index a852ad94e9..df01efbe1e 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; @@ -114,6 +117,8 @@ $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; @@ -207,8 +212,6 @@ $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-bg-color: $message-body-panel-bg-color; -$voice-record-waveform-fg-color: $message-body-panel-fg-color; $voice-record-waveform-incomplete-fg-color: #6F7882; $voice-record-icon-color: #6F7882; $voice-playback-button-bg-color: $tertiary-fg-color; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 84666bc662..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; @@ -181,6 +184,8 @@ $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; @@ -332,8 +337,6 @@ $message-body-panel-icon-bg-color: $primary-bg-color; $voice-record-stop-symbol-color: #ff4b55; $voice-record-live-circle-color: #ff4b55; $voice-record-stop-border-color: #E3E8F0; -$voice-record-waveform-bg-color: $message-body-panel-bg-color; -$voice-record-waveform-fg-color: $message-body-panel-fg-color; $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; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index c889f43d0b..7e958c2af6 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -173,6 +173,8 @@ $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; @@ -333,8 +335,6 @@ $voice-record-stop-symbol-color: #ff4b55; $voice-record-live-circle-color: #ff4b55; $voice-record-stop-border-color: #E3E8F0; // "Separator" -$voice-record-waveform-bg-color: $message-body-panel-bg-color; -$voice-record-waveform-fg-color: $message-body-panel-fg-color; $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; diff --git a/scripts/ci/Dockerfile b/scripts/ci/Dockerfile index 3fdd0d7bf6..6d33987d8c 100644 --- a/scripts/ci/Dockerfile +++ b/scripts/ci/Dockerfile @@ -3,6 +3,6 @@ # 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/end-to-end-tests.sh b/scripts/ci/prepare-end-to-end-tests.sh similarity index 65% rename from scripts/ci/end-to-end-tests.sh rename to scripts/ci/prepare-end-to-end-tests.sh index edb8870d8e..147e1f6445 100755 --- a/scripts/ci/end-to-end-tests.sh +++ b/scripts/ci/prepare-end-to-end-tests.sh @@ -1,8 +1,4 @@ #!/bin/bash -# -# script which is run by the CI build (after `yarn test`). -# -# clones element-web develop and runs the tests against our version of react-sdk. set -ev @@ -19,7 +15,7 @@ cd element-web element_web_dir=`pwd` CI_PACKAGE=true yarn build cd .. -# run end to end tests +# 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 @@ -28,9 +24,4 @@ echo "--- Install synapse & other dependencies" ./install.sh # install static webserver to server symlinked local copy of element ./element/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/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/fetchdep.sh b/scripts/fetchdep.sh index fe1f49c361..0990af70ce 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -22,29 +22,51 @@ clone() { } # Try the PR author's branch in case it exists on the deps as well. -# First we check if BUILDKITE_BRANCH is defined, -# if it isn't we can assume this is a Netlify build -if [ -z ${BUILDKITE_BRANCH+x} ]; then - # 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') -else +# 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 either: +# 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 - clone $deforg $defrepo $BUILDKITE_BRANCH + + 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 +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. 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/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 22280b8a28..d257ee4c5c 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -16,6 +16,7 @@ limitations under the License. import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first import * as ModernizrStatic from "modernizr"; + import ContentMessages from "../ContentMessages"; import { IMatrixClientPeg } from "../MatrixClientPeg"; import ToastStore from "../stores/ToastStore"; @@ -23,27 +24,29 @@ 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 { SpaceStoreClass } from "../stores/SpaceStore"; import TypingStore from "../stores/TypingStore"; import { EventIndexPeg } from "../indexing/EventIndexPeg"; -import {VoiceRecordingStore} from "../stores/VoiceRecordingStore"; +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 { @@ -84,6 +87,8 @@ declare global { mxPerformanceMonitor: PerformanceMonitor; mxPerformanceEntryNames: any; mxUIStore: UIStore; + mxSetupEncryptionStore?: SetupEncryptionStore; + mxRoomScrollStateStore?: RoomScrollStateStore; } interface Document { @@ -111,19 +116,6 @@ 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 @@ -138,11 +130,24 @@ declare global { 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 { diff --git a/src/AddThreepid.js b/src/AddThreepid.js index f06f7c187d..eb822c6d75 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"), diff --git a/src/Analytics.tsx b/src/Analytics.tsx index 212bfd3757..8c82639b5f 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'; diff --git a/src/Avatar.ts b/src/Avatar.ts index a6499c688e..4c4bd1c265 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -14,18 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -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 DMRoomMap from './utils/DMRoomMap'; -import {mediaFromMxc} from "./customisations/Media"; +import { mediaFromMxc } from "./customisations/Media"; import SettingsStore from "./settings/SettingsStore"; -export type ResizeMethod = "crop" | "scale"; - // 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?.getMxcAvatarUrl()) { url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); @@ -39,7 +43,12 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu return url; } -export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) { +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); } diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 5483ea6874..5b4b15cc67 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -17,16 +17,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClient} from "matrix-js-sdk/src/client"; -import {encodeUnpaddedBase64} from "matrix-js-sdk/src/crypto/olmlib"; +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 {MatrixClientPeg} from "./MatrixClientPeg"; -import {idbLoad, idbSave, idbDelete} from "./utils/StorageManager"; +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"; @@ -335,7 +335,7 @@ export default abstract class BasePlatform { try { const key = await crypto.subtle.decrypt( - {name: "AES-GCM", iv: data.iv, additionalData}, data.cryptoKey, + { name: "AES-GCM", iv: data.iv, additionalData }, data.cryptoKey, data.encrypted, ); return encodeUnpaddedBase64(key); @@ -348,7 +348,7 @@ export default abstract class BasePlatform { /** * 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. */ @@ -360,7 +360,7 @@ export default abstract class BasePlatform { const randomArray = new Uint8Array(32); crypto.getRandomValues(randomArray); const cryptoKey = await crypto.subtle.generateKey( - {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"], + { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); const iv = new Uint8Array(32); crypto.getRandomValues(iv); @@ -375,11 +375,11 @@ export default abstract class BasePlatform { } const encrypted = await crypto.subtle.encrypt( - {name: "AES-GCM", iv, additionalData}, cryptoKey, randomArray, + { name: "AES-GCM", iv, additionalData }, cryptoKey, randomArray, ); try { - await idbSave("pickleKey", [userId, deviceId], {encrypted, iv, cryptoKey}); + await idbSave("pickleKey", [userId, deviceId], { encrypted, iv, cryptoKey }); } catch (e) { return null; } diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index fbadab92a5..88ef72e9d9 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -55,18 +55,19 @@ 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'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import WidgetEchoStore from './stores/WidgetEchoStore'; import SettingsStore from './settings/SettingsStore'; -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"; @@ -76,7 +77,7 @@ import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; 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 { UIFeature } from "./settings/UIFeature"; import { CallError } from "matrix-js-sdk/src/webrtc/call"; import { logger } from 'matrix-js-sdk/src/logger'; import { Action } from './dispatcher/actions'; @@ -97,7 +98,7 @@ const CHECK_PROTOCOLS_ATTEMPTS = 3; // (and store the ID of their native room) export const VIRTUAL_ROOM_EVENT_TYPE = 'im.vector.is_virtual_room'; -enum AudioID { +export enum AudioID { Ring = 'ringAudio', Ringback = 'ringbackAudio', CallEnd = 'callendAudio', @@ -122,9 +123,9 @@ interface ThirdpartyLookupResponseFields { } interface ThirdpartyLookupResponse { - userid: string, - protocol: string, - fields: ThirdpartyLookupResponseFields, + userid: string; + protocol: string; + fields: ThirdpartyLookupResponseFields; } export enum PlaceCallType { @@ -159,7 +160,7 @@ export default class CallHandler extends EventEmitter { static sharedInstance() { if (!window.mxCallHandler) { - window.mxCallHandler = new CallHandler() + window.mxCallHandler = new CallHandler(); } return window.mxCallHandler; @@ -178,7 +179,7 @@ export default class CallHandler extends EventEmitter { const nativeUser = this.assertedIdentityNativeUsers[call.callId]; if (nativeUser) { const room = findDMForUser(MatrixClientPeg.get(), nativeUser); - if (room) return room.roomId + if (room) return room.roomId; } } @@ -231,7 +232,7 @@ export default class CallHandler extends EventEmitter { this.supportsPstnProtocol = null; } - dis.dispatch({action: Action.PstnSupportUpdated}); + dis.dispatch({ action: Action.PstnSupportUpdated }); if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) { this.supportsSipNativeVirtual = Boolean( @@ -239,7 +240,7 @@ export default class CallHandler extends EventEmitter { ); } - dis.dispatch({action: Action.VirtualRoomSupportUpdated}); + 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); @@ -292,7 +293,7 @@ export default class CallHandler extends EventEmitter { action: 'incoming_call', call: call, }, true); - } + }; getCallForRoom(roomId: string): MatrixCall { return this.calls.get(roomId) || null; @@ -790,7 +791,7 @@ export default class CallHandler extends EventEmitter { Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); console.log("Adding call for room ", mappedRoomId); - this.calls.set(mappedRoomId, call) + this.calls.set(mappedRoomId, call); this.emit(CallHandlerEvent.CallsChanged, this.calls); this.setCallListeners(call); @@ -846,7 +847,7 @@ export default class CallHandler extends EventEmitter { this.dialNumber(payload.number); break; } - } + }; private async dialNumber(number: string) { const results = await this.pstnLookup(number); @@ -940,7 +941,7 @@ export default class CallHandler extends EventEmitter { 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); diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js deleted file mode 100644 index 634f0bb336..0000000000 --- a/src/CallMediaHandler.js +++ /dev/null @@ -1,85 +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 SettingsStore from "./settings/SettingsStore"; -import {SettingLevel} from "./settings/SettingLevel"; -import {setMatrixCallAudioInput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix"; - -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 audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); - const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); - - setMatrixCallAudioInput(audioDeviceId); - setMatrixCallVideoInput(videoDeviceId); - }, - - setAudioOutput: function(deviceId) { - SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); - }, - - setAudioInput: function(deviceId) { - SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); - setMatrixCallAudioInput(deviceId); - }, - - setVideoInput: function(deviceId) { - SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); - 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 9042d47243..1b8a3be37e 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -17,9 +17,10 @@ limitations under the License. */ import React from "react"; +import { encode } from "blurhash"; +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'; @@ -37,7 +38,7 @@ import { UploadProgressPayload, UploadStartedPayload, } from "./dispatcher/payloads/UploadPayload"; -import {IUpload} from "./models/IUpload"; +import { IUpload } from "./models/IUpload"; import { IImageInfo } from "matrix-js-sdk/src/@types/partials"; const MAX_WIDTH = 800; @@ -47,6 +48,10 @@ 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 +const BLURHASH_X_COMPONENTS = 6; +const BLURHASH_Y_COMPONENTS = 6; + export class UploadCanceledError extends Error {} type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; @@ -77,6 +82,7 @@ interface IThumbnail { }; w: number; h: number; + [BLURHASH_FIELD]: string; }; thumbnail: Blob; } @@ -124,7 +130,16 @@ function createThumbnail( const canvas = document.createElement("canvas"); canvas.width = targetWidth; canvas.height = targetHeight; - canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); + const context = canvas.getContext("2d"); + context.drawImage(element, 0, 0, targetWidth, targetHeight); + const imageData = context.getImageData(0, 0, targetWidth, targetHeight); + const blurhash = encode( + imageData.data, + imageData.width, + imageData.height, + BLURHASH_X_COMPONENTS, + BLURHASH_Y_COMPONENTS, + ); canvas.toBlob(function(thumbnail) { resolve({ info: { @@ -136,8 +151,9 @@ function createThumbnail( }, w: inputWidth, h: inputHeight, + [BLURHASH_FIELD]: blurhash, }, - thumbnail: thumbnail, + thumbnail, }); }, mimeType); }); @@ -189,7 +205,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 }; } /** @@ -220,7 +236,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. @@ -229,20 +246,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); @@ -307,7 +329,7 @@ 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( +export function uploadFile( matrixClient: MatrixClient, roomId: string, file: File | Blob, @@ -343,11 +365,11 @@ function uploadFile( if (file.type) { encryptInfo.mimetype = file.type; } - return {"file": encryptInfo}; + return { "file": encryptInfo }; }); (prom as IAbortablePromise).abort = () => { canceled = true; - if (uploadPromise) MatrixClientPeg.get().cancelUpload(uploadPromise); + if (uploadPromise) matrixClient.cancelUpload(uploadPromise); }; return prom; } else { @@ -357,11 +379,11 @@ function uploadFile( 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 }; }); (promise1 as any).abort = () => { canceled = true; - MatrixClientPeg.get().cancelUpload(basePromise); + matrixClient.cancelUpload(basePromise); }; return promise1; } @@ -373,11 +395,11 @@ export default class ContentMessages { 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; } @@ -391,14 +413,14 @@ 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) { 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( @@ -415,7 +437,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(); } @@ -432,7 +454,7 @@ export default class ContentMessages { if (tooBigFiles.length > 0) { 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, @@ -449,7 +471,7 @@ export default class ContentMessages { for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; if (!uploadAll) { - const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', + const { finished } = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', '', UploadConfirmDialog, { file, currentIndex: i, @@ -470,7 +492,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) { @@ -480,8 +502,8 @@ export default class ContentMessages { } if (upload) { upload.canceled = true; - MatrixClientPeg.get().cancelUpload(upload.promise); - dis.dispatch({action: Action.UploadCanceled, upload}); + matrixClient.cancelUpload(upload.promise); + dis.dispatch({ action: Action.UploadCanceled, upload }); } } @@ -542,7 +564,7 @@ export default class ContentMessages { promise: prom, }; this.inprogress.push(upload); - dis.dispatch({action: Action.UploadStarted, upload}); + dis.dispatch({ action: Action.UploadStarted, upload }); // Focus the composer view dis.fire(Action.FocusComposer); @@ -550,7 +572,7 @@ export default class ContentMessages { function onProgress(ev) { upload.total = ev.total; upload.loaded = ev.loaded; - dis.dispatch({action: Action.UploadProgress, upload}); + dis.dispatch({ action: Action.UploadProgress, upload }); } let error; @@ -577,11 +599,11 @@ 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 }, ); } const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -604,10 +626,10 @@ export default class ContentMessages { if (error && error.http_status === 413) { this.mediaConfig = null; } - dis.dispatch({action: Action.UploadFailed, upload, error}); + dis.dispatch({ action: Action.UploadFailed, upload, error }); } else { - dis.dispatch({action: Action.UploadFinished, upload}); - dis.dispatch({action: 'message_sent'}); + dis.dispatch({ action: Action.UploadFinished, upload }); + dis.dispatch({ action: 'message_sent' }); } }); } @@ -621,11 +643,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 f494c1eb22..a75c578536 100644 --- a/src/CountlyAnalytics.ts +++ b/src/CountlyAnalytics.ts @@ -14,13 +14,14 @@ 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"; import { Action } from "./dispatcher/actions"; @@ -255,7 +256,7 @@ interface ICreateRoomEvent extends IEvent { num_users: number; is_encrypted: boolean; is_public: boolean; - } + }; } interface IJoinRoomEvent extends IEvent { @@ -338,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) => { @@ -414,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); } @@ -438,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); @@ -662,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) { @@ -680,7 +681,7 @@ export default class CountlyAnalytics { private getOrientation = (): Orientation => { return window.matchMedia("(orientation: landscape)").matches ? Orientation.Landscape - : Orientation.Portrait + : Orientation.Portrait; }; private reportOrientation = () => { @@ -749,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) { @@ -773,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({ @@ -868,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(); 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..d40574a6db 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: NodeJS.Timeout = null; + public trackInterval: NodeJS.Timeout = 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..d70585e5ec 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, diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index 9497d9de4c..ea1813876c 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -19,7 +19,7 @@ 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 StyledCheckbox from './components/views/elements/StyledCheckbox'; @@ -103,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(", "), }); } @@ -111,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 }), }); }); } @@ -137,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(() => { @@ -152,7 +152,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { { title: _t( "Failed to add the following rooms to %(groupId)s:", - {groupId}, + { groupId }, ), description: errorList.join(", "), }, diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index ef5ac383e3..016b557477 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -17,11 +17,10 @@ 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'; @@ -29,13 +28,15 @@ import EMOJIBASE_REGEX from 'emojibase-regex'; import url from 'url'; import katex from 'katex'; import { AllHtmlEntities } from 'html-entities'; -import SettingsStore from './settings/SettingsStore'; -import cheerio from 'cheerio'; +import { IContent } from 'matrix-js-sdk/src/models/event'; -import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; -import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; +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 ReplyThread from "./components/views/elements/ReplyThread"; -import {mediaFromMxc} from "./customisations/Media"; +import { mediaFromMxc } from "./customisations/Media"; linkifyMatrix(linkify); @@ -66,7 +67,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); } @@ -76,7 +77,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]}:` : ''); } @@ -87,7 +88,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; @@ -124,20 +125,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 getHtmlText(insaneHtml: string) { +export function getHtmlText(insaneHtml: string): string { return sanitizeHtml(insaneHtml, { allowedTags: [], allowedAttributes: {}, selfClosing: [], allowedSchemes: [], disallowedTagsMode: 'discard', - }) + }); } /** @@ -148,7 +149,7 @@ export function getHtmlText(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; @@ -182,7 +183,7 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to // 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: {}}; + return { tagName, attribs: {} }; } const width = Number(attribs.width) || 800; const height = Number(attribs.height) || 600; @@ -351,20 +352,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 @@ -380,6 +382,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; @@ -399,9 +403,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(''); @@ -501,7 +510,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); } @@ -512,7 +521,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); } @@ -523,7 +532,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); } @@ -534,7 +543,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": diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index 9239c1bc75..31a5021317 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -17,7 +17,7 @@ limitations under the License. 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'; diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 63c4ac0f86..b2f70abff7 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -156,7 +156,7 @@ const messageComposerBindings = (): KeyBinding[] => { } } return bindings; -} +}; const autocompleteBindings = (): KeyBinding[] => { return [ @@ -207,7 +207,7 @@ const autocompleteBindings = (): KeyBinding[] => { }, }, ]; -} +}; const roomListBindings = (): KeyBinding[] => { return [ @@ -248,7 +248,7 @@ const roomListBindings = (): KeyBinding[] => { }, }, ]; -} +}; const roomBindings = (): KeyBinding[] => { const bindings: KeyBinding[] = [ @@ -312,7 +312,7 @@ const roomBindings = (): KeyBinding[] => { } return bindings; -} +}; const navigationBindings = (): KeyBinding[] => { return [ @@ -396,7 +396,7 @@ const navigationBindings = (): KeyBinding[] => { }, }, ]; -} +}; export const defaultBindingsProvider: IKeyBindingsProvider = { getMessageComposerBindings: messageComposerBindings, @@ -404,4 +404,4 @@ export const defaultBindingsProvider: IKeyBindingsProvider = { getRoomListBindings: roomListBindings, getRoomBindings: roomBindings, getNavigationBindings: navigationBindings, -} +}; diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index aac14bde20..4225d2f449 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -140,12 +140,12 @@ export type KeyCombo = { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; -} +}; export type KeyBinding = { action: T; keyCombo: KeyCombo; -} +}; /** * Helper method to check if a KeyboardEvent matches a KeyCombo diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index b0a1292ba1..76dee5ab55 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -20,9 +20,9 @@ limitations under the License. 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} from "matrix-js-sdk/src/crypto/aes"; +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'; @@ -41,17 +41,17 @@ 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, SSO_IDP_ID_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 { _t } from "./languageHandler"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -154,7 +154,7 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise * return [null, null]. */ export async function getStoredSessionOwner(): Promise<[string, boolean]> { - const {hsUrl, userId, hasAccessToken, isGuest} = await getStoredSessionVars(); + const { hsUrl, userId, hasAccessToken, isGuest } = await getStoredSessionVars(); return hsUrl && userId && hasAccessToken ? [userId, isGuest] : [null, null]; } @@ -303,7 +303,7 @@ export interface IStoredSession { hsUrl: string; isUrl: string; hasAccessToken: boolean; - accessToken: string | object; + accessToken: string | IEncryptedPayload; userId: string; deviceId: string; isGuest: boolean; @@ -346,11 +346,11 @@ export async function getStoredSessionVars(): Promise { isGuest = localStorage.getItem("matrix-is-guest") === "true"; } - return {hsUrl, isUrl, hasAccessToken, 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 +// 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); @@ -402,7 +402,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): return false; } - const {hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest} = await getStoredSessionVars(); + const { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest } = await getStoredSessionVars(); if (hasAccessToken && !accessToken) { abortLogin(); @@ -495,7 +495,7 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): 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(); @@ -814,7 +814,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(); @@ -830,9 +830,9 @@ 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?.(); } diff --git a/src/Login.ts b/src/Login.ts index 7caab22d88..a8848210be 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -16,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 {createClient} from "matrix-js-sdk/src/matrix"; +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"; @@ -190,7 +190,6 @@ export default class Login { } } - /** * Send a login request to the given server, and format the response * as a MatrixClientCreds diff --git a/src/Markdown.js b/src/Markdown.ts similarity index 74% rename from src/Markdown.js rename to src/Markdown.ts index f670bded12..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. @@ -15,16 +16,24 @@ limitations under the License. */ import * as commonmark from 'commonmark'; -import {escape} from "lodash"; +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) { + node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) { return true; } @@ -39,21 +48,12 @@ function is_allowed_html_tag(node) { 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)); - } -} - /* * Returns true if the parse output containing the 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; @@ -67,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; @@ -74,7 +77,7 @@ export default class Markdown { this.parsed = parser.parse(this.input); } - isPlainText() { + isPlainText(): boolean { const walker = this.parsed.walker(); let ev; @@ -87,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 { @@ -97,7 +100,7 @@ export default class Markdown { return true; } - toHTML({ externalLinks = false } = {}) { + toHTML({ externalLinks = false } = {}): string { const renderer = new commonmark.HtmlRenderer({ safe: false, @@ -107,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 @@ -118,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); } }; @@ -150,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); @@ -177,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 7db5ed1a4e..cb6cb5c65c 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -18,22 +18,22 @@ 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 { 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 { diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts new file mode 100644 index 0000000000..49ef123def --- /dev/null +++ b/src/MediaDeviceHandler.ts @@ -0,0 +1,120 @@ +/* +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'; + +interface IMediaDevices { + audioOutput: Array; + audioInput: Array; + videoInput: Array; +} + +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 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; + } + }); + + return { audioOutput, audioInput, videoInput }; + } 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 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 ce11c571b6..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"; @@ -193,7 +192,7 @@ export class ModalManager { modal.elem = ; modal.close = closeDialog; - return {modal, closeDialog, onFinishedProm}; + return { modal, closeDialog, onFinishedProm }; } private getCloseFn( @@ -282,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; @@ -305,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(); @@ -385,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/Notifier.ts b/src/Notifier.ts index 4f55046e72..2335dc59ac 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -32,11 +32,11 @@ 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 { mediaFromMxc } from "./customisations/Media"; /* * Dispatches: @@ -68,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); } diff --git a/src/Presence.ts b/src/Presence.ts index 8f2e127cb4..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,7 +98,7 @@ class Presence { } try { - await MatrixClientPeg.get().setPresence({presence: this.state}); + await MatrixClientPeg.get().setPresence({ presence: this.state }); console.info("Presence:", newState); } catch (err) { console.error("Failed to set presence:", err); 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/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/RoomInvite.js b/src/RoomInvite.tsx similarity index 52% rename from src/RoomInvite.js rename to src/RoomInvite.tsx index aa758ecbdc..c7f377b6e8 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.tsx @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2017, 2018 New Vector Ltd -Copyright 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,15 +14,26 @@ 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 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 * as sdk from './'; import { _t } from './languageHandler'; -import InviteDialog, {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; +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 { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore"; +import BaseAvatar from "./components/views/avatars/BaseAvatar"; +import { mediaFromMxc } from "./customisations/Media"; + +export interface IInviteResult { + states: CompletionStates; + inviter: MultiInviter; +} /** * Invites multiple addresses to a room @@ -32,24 +41,24 @@ import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; * 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. + * @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids. * @returns {Promise} Promise */ -export function inviteMultipleToRoom(roomId, addrs) { +export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise { const inviter = new MultiInviter(roomId); - return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); + return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter })); } -export function showStartChatInviteDialog(initialText) { +export function showStartChatInviteDialog(initialText = ""): void { // 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, initialText}, + 'Start DM', '', InviteDialog, { kind: KIND_DM, initialText }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); } -export function showRoomInviteDialog(roomId, initialText = "") { +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, { @@ -61,14 +70,14 @@ export function showRoomInviteDialog(roomId, initialText = "") { ); } -export function showCommunityRoomInviteDialog(roomId, communityName) { +export function showCommunityRoomInviteDialog(roomId: string, communityName: string): void { Modal.createTrackedDialog( - 'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId}, + 'Invite Users to Community', '', CommunityPrototypeInviteDialog, { communityName, roomId }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); } -export function showCommunityInviteDialog(communityId) { +export function showCommunityInviteDialog(communityId: string): void { const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId); if (chat) { const name = CommunityPrototypeStore.instance.getCommunityName(communityId); @@ -83,7 +92,7 @@ export function showCommunityInviteDialog(communityId) { * @param {MatrixEvent} event The event to check * @returns {boolean} True if valid, false otherwise */ -export function isValid3pidInvite(event) { +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 @@ -96,7 +105,7 @@ export function isValid3pidInvite(event) { return true; } -export function inviteUsersToRoom(roomId, userIds) { +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); @@ -110,35 +119,68 @@ export function inviteUsersToRoom(roomId, userIds) { }); } -export function showAnyInviteErrors(addrs, room, inviter) { +export function showAnyInviteErrors( + states: CompletionStates, + room: Room, + inviter: MultiInviter, + userMap?: Map, +): boolean { // Show user any errors - const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error'); + 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. 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}), + 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") { + 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 =
{errorList.map(e =>
{e}
)}
; + 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) } +
+
; + }) } +
+
; 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}), + Modal.createTrackedDialog("Some invites could not be sent", "", ErrorDialog, { + title: _t("Some invites couldn't be sent"), description, }); return false; diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index 600655f635..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) { diff --git a/src/Rooms.js b/src/Rooms.ts similarity index 89% rename from src/Rooms.js rename to src/Rooms.ts index 955498faaa..4d1682660b 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,9 @@ 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'; /** * Given a room object, return the alias we should use for it, @@ -25,11 +27,11 @@ import {MatrixClientPeg} from './MatrixClientPeg'; * @param {Object} room The room object * @returns {string} A display alias for the given room */ -export function getDisplayAliasForRoom(room) { +export function getDisplayAliasForRoom(room: Room): string { return room.getCanonicalAlias() || room.getAltAliases()[0]; } -export function looksLikeDirectMessageRoom(room, myUserId) { +export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean { const myMembership = room.getMyMembership(); const me = room.getMember(myUserId); @@ -48,7 +50,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,7 +72,7 @@ export function guessAndSetDMRoom(room, isDirect) { this room as a DM room * @returns {object} A promise */ -export function setDMRoom(roomId, userId) { +export function setDMRoom(roomId: string, userId: string): Promise { if (MatrixClientPeg.get().isGuest()) { return Promise.resolve(); } @@ -102,7 +104,6 @@ export function setDMRoom(roomId, userId) { dmRoomMap[userId] = roomList; } - return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap); } @@ -114,7 +115,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.ts b/src/ScalarAuthClient.ts index a09c3494a8..86dd4b7a0f 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -17,12 +17,12 @@ limitations under the License. import url from 'url'; import SettingsStore from "./settings/SettingsStore"; import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms'; -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { MatrixClientPeg } from "./MatrixClientPeg"; import request from "browser-request"; import SdkConfig from "./SdkConfig"; -import {WidgetType} from "./widgets/WidgetType"; -import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types"; +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 @@ -109,7 +109,7 @@ export default class ScalarAuthClient { request({ method: "GET", uri: url, - qs: {scalar_token: token, v: imApiVersion}, + qs: { scalar_token: token, v: imApiVersion }, json: true, }, (err, response, body) => { if (err) { @@ -189,7 +189,7 @@ export default class ScalarAuthClient { request({ method: 'POST', uri: scalarRestUrl + '/register', - qs: {v: imApiVersion}, + qs: { v: imApiVersion }, body: openidTokenObject, json: true, }, (err, response, body) => { diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 3f75b3788c..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 { 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.js index 2b17aee054..d0666b1760 100644 --- a/src/Searching.js +++ b/src/Searching.js @@ -15,7 +15,7 @@ limitations under the License. */ import EventIndexPeg from "./indexing/EventIndexPeg"; -import {MatrixClientPeg} from "./MatrixClientPeg"; +import { MatrixClientPeg } from "./MatrixClientPeg"; const SEARCH_LIMIT = 10; @@ -43,7 +43,7 @@ async function serverSideSearch(term, roomId = undefined) { }, }; - const response = await client.search({body: body}); + const response = await client.search({ body: body }); const result = { response: response, @@ -468,7 +468,7 @@ function restoreEncryptionInfo(searchResultSlice = []) { ev.event.curve25519Key, ev.event.ed25519Key, ); - ev._forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain; + ev.forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain; delete ev.event.curve25519Key; delete ev.event.ed25519Key; @@ -498,7 +498,7 @@ 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); } diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 09c8d30614..214047c4fa 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -14,11 +14,11 @@ 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, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import Modal from './Modal'; import * as sdk from './index'; -import {MatrixClientPeg} from './MatrixClientPeg'; +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 +28,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 +42,8 @@ let secretStorageBeingAccessed = false; let nonInteractive = false; let dehydrationCache: { - key?: Uint8Array, - keyInfo?: ISecretStorageKeyInfo, + key?: Uint8Array; + keyInfo?: ISecretStorageKeyInfo; } = {}; function isCachingAllowed(): boolean { @@ -134,7 +135,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]; } @@ -184,7 +185,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; } @@ -223,7 +224,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; } @@ -244,7 +245,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(); 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/SlashCommands.tsx b/src/SlashCommands.tsx index 4a7b37b5e5..128ca9e5e2 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -17,25 +17,25 @@ 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 * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; -import {MatrixClientPeg} from './MatrixClientPeg'; +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, Element as ChildElement } from "parse5"; @@ -46,10 +46,10 @@ 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 {CHAT_EFFECTS} from "./effects" +import { UIFeature } from "./settings/UIFeature"; +import { CHAT_EFFECTS } from "./effects"; import CallHandler from "./CallHandler"; -import {guessAndSetDMRoom} from "./Rooms"; +import { guessAndSetDMRoom } from "./Rooms"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -143,11 +143,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 @@ -160,7 +164,7 @@ export const Commands = [ args: '', description: _td('Sends the given message as a spoiler'), runFn: function(roomId, message) { - return success(ContentHelpers.makeHtmlMessage( + return successSync(ContentHelpers.makeHtmlMessage( message, `${message}`, )); @@ -176,7 +180,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -189,7 +193,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -202,7 +206,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -215,7 +219,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -224,7 +228,7 @@ export const Commands = [ args: '', description: _td('Sends a message as plain text, without interpreting it as markdown'), runFn: function(roomId, messages) { - return success(ContentHelpers.makeTextMessage(messages)); + return successSync(ContentHelpers.makeTextMessage(messages)); }, category: CommandCategories.messages, }), @@ -233,7 +237,7 @@ export const Commands = [ args: '', description: _td('Sends a message as html, without interpreting it as markdown'), runFn: function(roomId, messages) { - return success(ContentHelpers.makeHtmlMessage(messages, messages)); + return successSync(ContentHelpers.makeHtmlMessage(messages, messages)); }, category: CommandCategories.messages, }), @@ -267,8 +271,8 @@ export const Commands = [ 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]) => { @@ -284,7 +288,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 = [ @@ -366,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, @@ -737,7 +741,7 @@ export const Commands = [ 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 }) }

, }); }), @@ -768,7 +772,7 @@ export const Commands = [ 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 }) }

, }); }), @@ -835,7 +839,7 @@ export const Commands = [ 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, @@ -947,7 +951,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 }) }

, @@ -978,7 +982,7 @@ export const Commands = [ args: '', runFn: function(roomId, args) { if (!args) return reject(this.getUserId()); - return success(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args))); + return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args))); }, category: CommandCategories.messages, }), @@ -988,7 +992,7 @@ export const Commands = [ args: '', runFn: function(roomId, args) { if (!args) return reject(this.getUserId()); - return success(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args))); + return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args))); }, category: CommandCategories.messages, }), @@ -1015,9 +1019,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(); }, @@ -1169,16 +1172,16 @@ export const Commands = [ }; MatrixClientPeg.get().sendMessage(roomId, content); } - dis.dispatch({action: `effects.${effect.command}`}); + 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 => { @@ -1186,15 +1189,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]+((.|\n)*))?$/); - let cmd; - let args; + let cmd: string; + let args: string; if (bits) { cmd = bits[1].substring(1).toLowerCase(); args = bits[2]; @@ -1202,7 +1205,12 @@ export function parseCommandString(input: string) { cmd = input; } - return {cmd, args}; + return { cmd, args }; +} + +interface ICmd { + cmd?: Command; + args?: string; } /** @@ -1213,8 +1221,8 @@ 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(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 { diff --git a/src/Terms.ts b/src/Terms.ts index a6ea40a6e8..e9ba5ff8be 100644 --- a/src/Terms.ts +++ b/src/Terms.ts @@ -15,8 +15,9 @@ limitations under the License. */ import classNames from 'classnames'; +import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import * as sdk from '.'; import Modal from './Modal'; @@ -32,7 +33,7 @@ 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(public serviceType: string, public baseUrl: string, public accessToken: string) { + constructor(public serviceType: SERVICE_TYPES, public baseUrl: string, public accessToken: string) { } } @@ -48,13 +49,13 @@ export interface Policy { } export type Policies = { - [policy: string]: Policy, + [policy: string]: Policy; }; export type TermsInteractionCallback = ( policiesAndServicePairs: { - service: Service, - policies: Policies, + service: Service; + policies: Policies; }[], agreedUrls: string[], extraClassNames?: string, @@ -117,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; @@ -131,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 }); } } @@ -148,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); } @@ -180,8 +181,8 @@ export async function startTermsFlow( export function dialogTermsInteractionCallback( policiesAndServicePairs: { - service: Service, - policies: { [policy: string]: Policy }, + service: Service; + policies: { [policy: string]: Policy }; }[], agreedUrls: string[], extraClassNames?: string, diff --git a/src/TextForEvent.ts b/src/TextForEvent.tsx similarity index 81% rename from src/TextForEvent.ts rename to src/TextForEvent.tsx index 649c53664e..844c79fbae 100644 --- a/src/TextForEvent.ts +++ b/src/TextForEvent.tsx @@ -13,13 +13,20 @@ 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 React from 'react'; +import { MatrixClientPeg } from './MatrixClientPeg'; import { _t } from './languageHandler'; import * as Roles from './Roles'; -import {isValid3pidInvite} from "./RoomInvite"; +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 { 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 @@ -31,76 +38,89 @@ function textForMemberEvent(ev): () => string | null { const targetName = ev.target ? ev.target.name : ev.getStateKey(); const prevContent = ev.getPrevContent(); const content = ev.getContent(); + const reason = content.reason; - const getReason = () => 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.', { + return () => _t('%(targetName)s accepted the invitation for %(displayName)s', { targetName, displayName: threePidContent.display_name, }); } else { - return () => _t('%(targetName)s accepted an invitation.', {targetName}); + return () => _t('%(targetName)s accepted an invitation', { targetName }); } } else { - return () => _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); + return () => _t('%(senderName)s invited %(targetName)s', { senderName, targetName }); } } case 'ban': - return () => _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + getReason(); + 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.', { + 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.', { + 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).', { + 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}); + 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}); + 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}); + 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}); + // 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}); + 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}); + return () => _t('%(targetName)s rejected the invitation', { targetName }); } else { - return () => _t('%(targetName)s left the room.', {targetName}); + 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}); + 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, - }) + ' ' + getReason(); + 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 () => _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + getReason(); + return () => reason + ? _t('%(senderName)s kicked %(targetName)s: %(reason)s', { + senderName, + targetName, + reason, + }) + : _t('%(senderName)s kicked %(targetName)s', { senderName, targetName }); } else { return null; } @@ -119,7 +139,7 @@ function textForRoomNameEvent(ev): () => 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}); + 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.', { @@ -136,7 +156,7 @@ function textForRoomNameEvent(ev): () => string | null { function textForTombstoneEvent(ev): () => string | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - return () => _t('%(senderDisplayName)s upgraded this room.', {senderDisplayName}); + return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName }); } function textForJoinRulesEvent(ev): () => string | null { @@ -163,9 +183,9 @@ function textForGuestAccessEvent(ev): () => 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}); + 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}); + 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', { @@ -217,9 +237,9 @@ function textForServerACLEvent(ev): () => string | null { let getText = null; if (prev.deny.length === 0 && prev.allow.length === 0) { - getText = () => _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName}); + 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}); + getText = () => _t("%(senderDisplayName)s changed the server ACLs for this room.", { senderDisplayName }); } if (!Array.isArray(current.allow)) { @@ -242,7 +262,7 @@ function textForMessageEvent(ev): () => string | null { if (ev.getContent().msgtype === "m.emote") { message = "* " + senderDisplayName + " " + message; } else if (ev.getContent().msgtype === "m.image") { - message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); + message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName }); } return message; }; @@ -303,7 +323,7 @@ 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; + return _t('%(senderName)s answered the call.', { senderName }) + ' ' + supported; }; } @@ -338,16 +358,16 @@ function textForCallHangupEvent(event): () => string | null { // Also the correct hangup code as of VoIP v1 (with underscore) getReason = () => ''; } else { - getReason = () => _t('(unknown failure: %(reason)s)', {reason: eventContent.reason}); + getReason = () => _t('(unknown failure: %(reason)s)', { reason: eventContent.reason }); } } - return () => _t('%(senderName)s ended the call.', {senderName: getSenderName()}) + ' ' + getReason(); + 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}); + return _t('%(senderName)s declined the call.', { senderName }); }; } @@ -404,14 +424,14 @@ function textForHistoryVisibilityEvent(event): () => string | null { 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}); + + '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}); + + 'from the point they joined.', { senderName }); case 'shared': - return () => _t('%(senderName)s made future room history visible to all room members.', {senderName}); + 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}); + 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, @@ -466,15 +486,39 @@ function textForPowerEvent(event): () => string | null { }); } -function textForPinnedEvent(event): () => string | null { +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(); - return () => _t("%(senderName)s changed the pinned messages for the room.", {senderName}); + + 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() || {}; + 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 @@ -503,68 +547,68 @@ function textForWidgetEvent(event): () => string | null { function textForWidgetLayoutEvent(event): () => string | null { const senderName = event.sender?.name || event.getSender(); - return () => _t("%(senderName)s has updated the widget layout", {senderName}); + 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(); + 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}); + { 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}); + { 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}); + { 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}); + 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}); + 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}); + { 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}); + { 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}); + { 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}); + { 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}); + { 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}); + { 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}); + { 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}); + { senderName, glob: entity, reason }); } // else the entity !== prevEntity - count as a removal & add @@ -572,29 +616,29 @@ function textForMjolnirEvent(event): () => string | null { 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}, + { 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}, + { 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}, + { 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}); + "for %(reason)s", { senderName, oldGlob: prevEntity, newGlob: entity, reason }); } interface IHandlers { - [type: string]: (ev: any) => (() => string | null); + [type: string]: (ev: MatrixEvent, allowJSX?: boolean) => (() => string | JSX.Element | null); } const handlers: IHandlers = { @@ -635,7 +679,9 @@ export function hasText(ev): boolean { return Boolean(handler?.(ev)); } -export function textForEvent(ev): string { +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)?.() || ''; + 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 78% rename from src/Unread.js rename to src/Unread.ts index 25c425aa9a..72f0bb4642 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,28 +29,27 @@ 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) { +export function eventTriggersUnreadCount(ev: MatrixEvent): boolean { 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') { - return false; - } else if (ev.isRedacted()) { - 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) { +export function doesRoomHaveUnreadMessages(room: Room): boolean { const myUserId = MatrixClientPeg.get().getUserId(); // get the most recent read receipt sent by our account. 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/VoipUserMapper.ts b/src/VoipUserMapper.ts index d576a5434c..dacb4262bd 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -24,7 +24,9 @@ import { Room } from 'matrix-js-sdk/src/models/room'; // is sip virtual: there could be others in the future. export default class VoipUserMapper { - private virtualRoomIdCache = new Set(); + // 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(); @@ -49,10 +51,20 @@ export default class VoipUserMapper { 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); @@ -67,7 +79,7 @@ export default class VoipUserMapper { public isVirtualRoom(room: Room): boolean { if (this.nativeRoomForVirtualRoom(room.roomId)) return true; - if (this.virtualRoomIdCache.has(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 @@ -110,7 +122,7 @@ export default class VoipUserMapper { // 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.virtualRoomIdCache.add(invitedRoom.roomId); + 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 2a3e576e31..25c41f9db5 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -20,7 +20,7 @@ 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"; // 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[]; @@ -319,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]: "↑", @@ -329,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), }); diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 4cb537f318..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,13 +156,13 @@ 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; @@ -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 9a7c1d1f0a..9c0b248274 100644 --- a/src/accessibility/context_menu/MenuItem.tsx +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -27,7 +27,7 @@ interface IProps extends React.ComponentProps { } // Semantic component for representing a role=menuitem -export const MenuItem: React.FC = ({children, label, tooltip, ...props}) => { +export const MenuItem: React.FC = ({ children, label, tooltip, ...props }) => { const ariaLabel = props["aria-label"] || label; if (tooltip) { 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.ts b/src/actions/MatrixActionCreators.ts index 33e72becd2..70b86f8ee5 100644 --- a/src/actions/MatrixActionCreators.ts +++ b/src/actions/MatrixActionCreators.ts @@ -20,7 +20,7 @@ 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"; +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. diff --git a/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index 88946ee26f..81e05f8678 100644 --- a/src/actions/RoomListActions.ts +++ b/src/actions/RoomListActions.ts @@ -112,7 +112,7 @@ export default class RoomListActions { 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')), }); }); @@ -132,7 +132,7 @@ export default class RoomListActions { 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.tsx b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx index 0710c513da..76c3373ba4 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx @@ -21,9 +21,9 @@ 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"; interface IProps { onFinished: (confirmed: boolean) => void; @@ -139,7 +139,7 @@ export default class ManageEventIndexDialog extends React.Component { - this.setState({crawlerSleepTime: e.target.value}); + this.setState({ crawlerSleepTime: e.target.value }); SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value); }; diff --git a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js index 549494b5cb..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; @@ -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) => { @@ -370,21 +370,21 @@ export default class CreateKeyBackupDialog extends React.PureComponent { if (this.state.copied) { introText = _t( "Your Security Key has been copied to your clipboard, paste it to:", - {}, {b: s => {s}}, + {}, { b: s => {s} }, ); } else if (this.state.downloaded) { introText = _t( "Your Security Key is in your Downloads folder.", - {}, {b: s => {s}}, + {}, { 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} })}
{ - 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) => { diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js index 60f2ca9168..0435d81968 100644 --- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js @@ -15,7 +15,7 @@ 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'; @@ -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; } diff --git a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js index 70fc997230..6017d07047 100644 --- a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js index 8c09cc6d16..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 = { diff --git a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js index b60e6fd3cb..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 = { diff --git a/src/autocomplete/AutocompleteProvider.tsx b/src/autocomplete/AutocompleteProvider.tsx index 2242fec914..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; diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 5409825f45..7ab2ae70ea 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,8 @@ 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 SettingsStore from "../settings/SettingsStore"; import SpaceProvider from "./SpaceProvider"; @@ -54,13 +55,14 @@ const PROVIDERS = [ EmojiProvider, NotifProvider, CommandProvider, - CommunityProvider, DuckDuckGoProvider, ]; // as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here if (SettingsStore.getValue("feature_spaces")) { PROVIDERS.push(SpaceProvider); +} else { + PROVIDERS.push(CommunityProvider); } // Providers will get rejected if they take longer than this. diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index 9de25c0d84..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,7 +34,7 @@ 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 }); } @@ -44,7 +44,7 @@ export default class CommandProvider extends AutocompleteProvider { force?: boolean, limit = -1, ): Promise { - const {command, range} = this.getCurrentCommand(query, selection); + const { command, range } = this.getCurrentCommand(query, selection); if (!command) return []; let matches = []; @@ -68,7 +68,6 @@ export default class CommandProvider extends AutocompleteProvider { } } - 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 c9358b0c61..48c1921539 100644 --- a/src/autocomplete/CommunityProvider.tsx +++ b/src/autocomplete/CommunityProvider.tsx @@ -19,15 +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 { PillCompletion } from './Components'; import * as sdk from '../index'; -import {sortBy} from "lodash"; -import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; -import {ICompletion, ISelectionRange} from "./Autocompleter"; +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 { mediaFromMxc } from "../customisations/Media"; const COMMUNITY_REGEX = /\B\+\S*/g; @@ -66,11 +66,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 @@ -90,7 +90,7 @@ export default class CommunityProvider extends AutocompleteProvider { 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", 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); + const { command, range } = this.getCurrentCommand(query, selection); if (!query || !command) { return []; } diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index b7c4a5120a..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'; @@ -95,7 +95,7 @@ export default class EmojiProvider extends AutocompleteProvider { } 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, limit); @@ -121,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 0bc7ead097..1d42915ec9 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 { MatrixClientPeg } from '../MatrixClientPeg'; +import { PillCompletion } from './Components'; import * as sdk from '../index'; -import {ICompletion, ISelectionRange} from "./Autocompleter"; +import { ICompletion, ISelectionRange } from "./Autocompleter"; const AT_ROOM_REGEX = /@\S*/g; @@ -45,7 +46,7 @@ export default class NotifProvider extends AutocompleteProvider { 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 73bb37ff0f..3948be301c 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -16,8 +16,8 @@ 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; @@ -112,7 +112,7 @@ export default class QueryMatcher { const index = resultKey.indexOf(query); if (index !== -1) { matches.push( - ...candidates.map((candidate) => ({index, ...candidate})), + ...candidates.map((candidate) => ({ index, ...candidate })), ); } } diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index ad55b19101..7865a76daa 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -17,28 +17,24 @@ limitations under the License. */ import React from "react"; -import {uniqBy, sortBy} from "lodash"; -import Room from "matrix-js-sdk/src/models/room"; +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 {makeRoomPermalink} from "../utils/permalinks/Permalinks"; -import {ICompletion, ISelectionRange} from "./Autocompleter"; +import { PillCompletion } from './Components'; +import { makeRoomPermalink } from "../utils/permalinks/Permalinks"; +import { ICompletion, ISelectionRange } from "./Autocompleter"; import RoomAvatar from '../components/views/avatars/RoomAvatar'; import SettingsStore from "../settings/SettingsStore"; 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 = "") { @@ -77,7 +73,7 @@ export default class RoomProvider extends AutocompleteProvider { 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 = this.getRooms().reduce((aliases, room) => { @@ -106,7 +102,7 @@ export default class RoomProvider extends AutocompleteProvider { const matchedString = command[0]; 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); diff --git a/src/autocomplete/SpaceProvider.tsx b/src/autocomplete/SpaceProvider.tsx index 0361a2c91e..1c99aee5ac 100644 --- a/src/autocomplete/SpaceProvider.tsx +++ b/src/autocomplete/SpaceProvider.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from "react"; import { _t } from '../languageHandler'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { MatrixClientPeg } from '../MatrixClientPeg'; import RoomProvider from "./RoomProvider"; export default class SpaceProvider extends RoomProvider { diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 3cf43d0b84..470e018e22 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 { PillCompletion } from './Components'; import * as sdk from '../index'; 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"; const USER_REGEX = /\B@\S*/g; @@ -114,7 +114,7 @@ export default class UserProvider extends AutocompleteProvider { 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; @@ -158,7 +158,7 @@ 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.tsx b/src/components/structures/AutoHideScrollbar.tsx index 66f998b616..184d883dda 100644 --- a/src/components/structures/AutoHideScrollbar.tsx +++ b/src/components/structures/AutoHideScrollbar.tsx @@ -15,14 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { HTMLAttributes, WheelEvent } from "react"; -interface IProps { +interface IProps extends Omit, "onScroll"> { className?: string; - onScroll?: () => void; - onWheel?: () => void; - style?: React.CSSProperties - tabIndex?: number, + onScroll?: (event: Event) => void; + onWheel?: (event: WheelEvent) => void; + style?: React.CSSProperties; + tabIndex?: number; wrappedRef?: (ref: HTMLDivElement) => void; } @@ -52,14 +52,18 @@ export default class AutoHideScrollbar extends React.Component { } public render() { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { className, onScroll, onWheel, style, tabIndex, wrappedRef, children, ...otherProps } = this.props; + return (
- { this.props.children } + { children }
); } } diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 9d8665c176..407dc6f04c 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -16,13 +16,13 @@ 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 {replaceableComponent} from "../../utils/replaceableComponent"; +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 @@ -371,7 +371,7 @@ export class ContextMenu extends React.PureComponent { return (
@@ -399,7 +399,7 @@ export const toRightOf = (elementRect: Pick 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, @@ -498,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 73359f17a5..037d7c251c 100644 --- a/src/components/structures/CustomRoomTagPanel.js +++ b/src/components/structures/CustomRoomTagPanel.js @@ -21,7 +21,7 @@ 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"; +import { replaceableComponent } from "../../utils/replaceableComponent"; @replaceableComponent("structures.CustomRoomTagPanel") class CustomRoomTagPanel extends React.Component { @@ -34,7 +34,7 @@ class CustomRoomTagPanel extends React.Component { componentDidMount() { this._tagStoreToken = CustomRoomTagStore.addListener(() => { - this.setState({tags: CustomRoomTagStore.getSortedTags()}); + this.setState({ tags: CustomRoomTagStore.getSortedTags() }); }); } @@ -64,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 c37ab3df48..628c16f322 100644 --- a/src/components/structures/EmbeddedPage.js +++ b/src/components/structures/EmbeddedPage.js @@ -22,7 +22,7 @@ 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 84% rename from src/components/structures/FilePanel.js rename to src/components/structures/FilePanel.tsx index bb7c1f9642..5865201069 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.tsx @@ -16,37 +16,50 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {Filter} from 'matrix-js-sdk/src/filter'; +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 * as sdk from '../../index'; -import {MatrixClientPeg} from '../../MatrixClientPeg'; +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 {replaceableComponent} from "../../utils/replaceableComponent"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import DesktopBuildsNotice, { WarningKind } from "../views/elements/DesktopBuildsNotice"; +import { replaceableComponent } from "../../utils/replaceableComponent"; + +import ResizeNotifier from '../../utils/ResizeNotifier'; + +interface IProps { + roomId: string; + onClose: () => void; + resizeNotifier: ResizeNotifier; +} + +interface IState { + timelineSet: EventTimelineSet; +} /* * Component which shows the filtered file using a TimelinePanel */ @replaceableComponent("structures.FilePanel") -class FilePanel extends React.Component { - static propTypes = { - roomId: PropTypes.string.isRequired, - onClose: PropTypes.func.isRequired, - }; - +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) => { + 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; @@ -60,7 +73,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(); @@ -70,7 +83,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(); @@ -84,7 +97,7 @@ class FilePanel extends React.Component { } } - async componentDidMount() { + public async componentDidMount(): Promise { const client = MatrixClientPeg.get(); await this.updateTimelineSet(this.props.roomId); @@ -105,7 +118,7 @@ class FilePanel extends React.Component { } } - componentWillUnmount() { + public componentWillUnmount(): void { const client = MatrixClientPeg.get(); if (client === null) return; @@ -117,7 +130,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); @@ -141,7 +154,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; @@ -159,7 +176,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(); @@ -195,7 +212,7 @@ class FilePanel extends React.Component { } } - render() { + public render() { if (MatrixClientPeg.get().isGuest()) { return { + 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() { @@ -151,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 3a2c611cc9..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,13 +34,13 @@ 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/src/models/group"; -import {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"; +import { mediaFromMxc } from "../../customisations/Media"; +import { replaceableComponent } from "../../utils/replaceableComponent"; const LONG_DESC_PLACEHOLDER = _td( `

HTML for your community's page

@@ -115,7 +115,7 @@ class CategoryRoomList extends React.Component { { title: _t( "Failed to add the following rooms to the summary of %(groupId)s:", - {groupId: this.props.groupId}, + { groupId: this.props.groupId }, ), description: errorList.join(", "), }, @@ -126,12 +126,11 @@ class CategoryRoomList extends React.Component { }; render() { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); const addButton = this.props.editing ? ( - +
{ _t('Add a Room') }
@@ -195,9 +194,9 @@ class FeaturedRoom extends React.Component { { title: _t( "Failed to remove the room from the summary of %(groupId)s", - {groupId: this.props.groupId}, + { groupId: this.props.groupId }, ), - description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}), + description: _t("The room '%(roomName)s' could not be removed from the summary.", { roomName }), }, ); }); @@ -289,7 +288,7 @@ class RoleUserList extends React.Component { { title: _t( "Failed to add the following users to the summary of %(groupId)s:", - {groupId: this.props.groupId}, + { groupId: this.props.groupId }, ), description: errorList.join(", "), }, @@ -300,10 +299,9 @@ class RoleUserList extends React.Component { }; render() { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); const addButton = this.props.editing ? ( - +
{ _t('Add a User') }
@@ -361,9 +359,12 @@ class FeaturedUser extends React.Component { { title: _t( "Failed to remove a user from the summary of %(groupId)s", - {groupId: this.props.groupId}, + { groupId: this.props.groupId }, + ), + description: _t( + "The user '%(displayName)s' could not be removed from the summary.", + { displayName }, ), - description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}), }, ); }); @@ -470,7 +471,7 @@ export default class GroupView extends React.Component { // Leave settings - the user might have clicked the "Leave" button this._closeSettings(); } - this.setState({membershipBusy: false}); + this.setState({ membershipBusy: false }); }; _initGroupStore(groupId, firstInit) { @@ -491,7 +492,7 @@ export default class GroupView extends React.Component { group_id: groupId, }, }); - dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${groupId}`}}); + dis.dispatch({ action: 'require_registration', screen_after: { screen: `group/${groupId}` } }); willDoOnboarding = true; } if (stateKey === GroupStore.STATE_KEY.Summary) { @@ -592,7 +593,7 @@ export default class GroupView extends React.Component { }; _closeSettings = () => { - dis.dispatch({action: 'close_settings'}); + dis.dispatch({ action: 'close_settings' }); }; _onNameChange = (value) => { @@ -620,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({ @@ -632,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, { @@ -649,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({ @@ -688,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. @@ -697,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"), @@ -707,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. @@ -716,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"), @@ -727,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. @@ -740,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"), @@ -773,7 +774,7 @@ export default class GroupView extends React.Component { title: _t("Leave Community"), description: ( - { _t("Leave %(groupName)s?", {groupName: this.props.groupId}) } + { _t("Leave %(groupName)s?", { groupName: this.props.groupId }) } { warnings } ), @@ -782,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. @@ -791,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"), @@ -855,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'); @@ -871,7 +871,7 @@ export default class GroupView extends React.Component { onClick={this._onAddRoomsClick} >
- +
{ _t('Add rooms to this community') } @@ -1336,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 { @@ -1346,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 68bb4322e6..046e07f455 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -15,29 +15,29 @@ limitations under the License. */ import * as React from "react"; -import {useContext, useState} from "react"; +import { useContext, useState } from "react"; import AutoHideScrollbar from './AutoHideScrollbar'; -import {getHomePageUrl} from "../../utils/pages"; -import {_t} from "../../languageHandler"; +import { getHomePageUrl } from "../../utils/pages"; +import { _t } from "../../languageHandler"; import SdkConfig from "../../SdkConfig"; import * as sdk from "../../index"; import dis from "../../dispatcher/dispatcher"; -import {Action} from "../../dispatcher/actions"; +import { Action } from "../../dispatcher/actions"; import BaseAvatar from "../views/avatars/BaseAvatar"; -import {OwnProfileStore} from "../../stores/OwnProfileStore"; +import { OwnProfileStore } from "../../stores/OwnProfileStore"; import AccessibleButton from "../views/elements/AccessibleButton"; -import {UPDATE_EVENT} from "../../stores/AsyncStore"; -import {useEventEmitter} from "../../hooks/useEventEmitter"; +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 MiniAvatarUploader, { AVATAR_SIZE } from "../views/elements/MiniAvatarUploader"; import Analytics from "../../Analytics"; import CountlyAnalytics from "../../CountlyAnalytics"; const onClickSendDm = () => { Analytics.trackEvent('home_page', 'button', 'dm'); CountlyAnalytics.instance.track("home_page_button", { button: "dm" }); - dis.dispatch({action: 'view_create_chat'}); + dis.dispatch({ action: 'view_create_chat' }); }; const onClickExplore = () => { @@ -49,7 +49,7 @@ const onClickExplore = () => { const onClickNewRoom = () => { Analytics.trackEvent('home_page', 'button', 'create_room'); CountlyAnalytics.instance.track("home_page_button", { button: "create_room" }); - dis.dispatch({action: 'view_create_room'}); + dis.dispatch({ action: 'view_create_room' }); }; interface IProps { @@ -117,7 +117,6 @@ const HomePage: React.FC = ({ justRegistered = false }) => { ; } - return
{ introSection } diff --git a/src/components/structures/HostSignupAction.tsx b/src/components/structures/HostSignupAction.tsx index 46e158d6c8..41dc8a6da8 100644 --- a/src/components/structures/HostSignupAction.tsx +++ b/src/components/structures/HostSignupAction.tsx @@ -22,7 +22,7 @@ import { import { _t } from "../../languageHandler"; import { HostSignupStore } from "../../stores/HostSignupStore"; import SdkConfig from "../../SdkConfig"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; interface IProps { onClick?(): void; @@ -35,7 +35,7 @@ export default class HostSignupAction extends React.PureComponent { this.props.onClick?.(); await HostSignupStore.instance.setHostSignupActive(true); - } + }; public render(): React.ReactNode { const hostSignupConfig = SdkConfig.get().hostSignup; diff --git a/src/components/structures/IndicatorScrollbar.js b/src/components/structures/IndicatorScrollbar.js index 51a3b287f0..3e1940955b 100644 --- a/src/components/structures/IndicatorScrollbar.js +++ b/src/components/structures/IndicatorScrollbar.js @@ -17,7 +17,7 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; import AutoHideScrollbar from "./AutoHideScrollbar"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; @replaceableComponent("structures.IndicatorScrollbar") export default class IndicatorScrollbar extends React.Component { @@ -70,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; @@ -185,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 d419c9de6e..9ff830f66a 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -15,14 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {InteractiveAuth} from "matrix-js-sdk/src/interactive-auth"; -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"; +import { replaceableComponent } from "../../utils/replaceableComponent"; export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index af22db1350..3d5e386b00 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -24,6 +24,7 @@ import CustomRoomTagPanel from "./CustomRoomTagPanel"; 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"; @@ -39,9 +40,9 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; 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 { 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"; @@ -90,7 +91,7 @@ export default class LeftPanel extends React.Component { 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") }); }); } @@ -124,6 +125,10 @@ export default class LeftPanel extends React.Component { this.setState({ activeSpace }); }; + private onDialPad = () => { + dis.fire(Action.OpenDialPad); + }; + private onExplore = () => { dis.fire(Action.ViewRoomDirectory); }; @@ -131,12 +136,12 @@ export default class LeftPanel extends React.Component { 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 @@ -397,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 (
{ onKeyDown={this.onKeyDown} onSelectRoom={this.selectRoom} /> + + {dialPadButton} + { {leftLeftPanel}
{/*
- +
diff --git a/src/components/structures/NonUrgentToastContainer.tsx b/src/components/structures/NonUrgentToastContainer.tsx index 7c193ec9d7..a2d419b4ba 100644 --- a/src/components/structures/NonUrgentToastContainer.tsx +++ b/src/components/structures/NonUrgentToastContainer.tsx @@ -18,13 +18,13 @@ 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"; +import { replaceableComponent } from "../../utils/replaceableComponent"; interface IProps { } interface IState { - toasts: ComponentClass[], + toasts: ComponentClass[]; } @replaceableComponent("structures.NonUrgentToastContainer") @@ -44,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.tsx b/src/components/structures/NotificationPanel.tsx index 6c22835447..8c8fab7ece 100644 --- a/src/components/structures/NotificationPanel.tsx +++ b/src/components/structures/NotificationPanel.tsx @@ -22,6 +22,7 @@ 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; @@ -48,7 +49,7 @@ export default class NotificationPanel extends React.PureComponent { manageReadMarkers={false} timelineSet={timelineSet} showUrlPreview={false} - tileShape="notif" + tileShape={TileShape.Notif} empty={emptyState} alwaysShowTimestamps={true} /> diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 294865fe08..63027ab627 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -23,7 +23,6 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import dis from '../../dispatcher/dispatcher'; -import RateLimitedFunc from '../../ratelimitedfunc'; import GroupStore from '../../stores/GroupStore'; import { RIGHT_PANEL_PHASES_NO_ARGS, @@ -48,6 +47,7 @@ import FilePanel from "./FilePanel"; import NotificationPanel from "./NotificationPanel"; import ResizeNotifier from "../../utils/ResizeNotifier"; import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard"; +import { throttle } from 'lodash'; interface IProps { room?: Room; // if showing panels for a given room, this is set @@ -73,7 +73,6 @@ interface IState { export default class RightPanel extends React.Component { static contextType = MatrixClientContext; - private readonly delayedUpdate: RateLimitedFunc; private dispatcherRef: string; constructor(props, context) { @@ -84,12 +83,12 @@ export default class RightPanel extends React.Component { isUserPrivilegedInGroup: null, member: this.getUserForPanel(), }; - - this.delayedUpdate = new RateLimitedFunc(() => { - this.forceUpdate(); - }, 500); } + 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. private getUserForPanel() { @@ -104,7 +103,7 @@ export default class RightPanel extends React.Component { 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; diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx index 1e0605f263..3acd9f1a2e 100644 --- a/src/components/structures/RoomDirectory.tsx +++ b/src/components/structures/RoomDirectory.tsx @@ -25,7 +25,7 @@ import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; -import {ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols} from "../views/directory/NetworkDropdown"; +import { ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols } from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupStore from "../../stores/GroupStore"; @@ -34,7 +34,7 @@ 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 AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import BaseAvatar from "../views/avatars/BaseAvatar"; import ErrorDialog from "../views/dialogs/ErrorDialog"; import QuestionDialog from "../views/dialogs/QuestionDialog"; @@ -45,7 +45,6 @@ import ScrollPanel from "./ScrollPanel"; import Spinner from "../views/elements/Spinner"; import { ActionPayload } from "../../dispatcher/payloads"; - const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 800; @@ -95,7 +94,7 @@ interface IPublicRoomsRequest { @replaceableComponent("structures.RoomDirectory") export default class RoomDirectory extends React.Component { private readonly startTime: number; - private unmounted = false + private unmounted = false; private nextBatch: string = null; private filterTimeout: NodeJS.Timeout; private protocols: Protocols; @@ -207,9 +206,9 @@ export default class RoomDirectory extends React.Component { this.getMoreRooms(); }; - private 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, @@ -239,12 +238,12 @@ export default class RoomDirectory extends React.Component { // 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 we've been unmounted, we don't care either. - return; + return false; } if (this.state.filterString) { @@ -264,14 +263,13 @@ export default class RoomDirectory extends React.Component { filterString != this.state.filterString || roomServer != this.state.roomServer || nextBatch != this.nextBatch) { - // as above: we don't care about errors for old - // requests either - return; + // as above: we don't care about errors for old requests either + return false; } 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)); @@ -300,9 +298,9 @@ export default class RoomDirectory extends React.Component { 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, { @@ -312,7 +310,7 @@ export default class RoomDirectory extends React.Component { if (!shouldDelete) return; const modal = Modal.createDialog(Spinner); - let step = _t('remove %(name)s from the directory.', {name: name}); + let step = _t('remove %(name)s from the directory.', { name: name }); MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => { if (!alias) return; @@ -337,11 +335,10 @@ export default class RoomDirectory extends React.Component { } private onRoomClicked = (room: IRoom, 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); } }; @@ -373,7 +370,7 @@ export default class RoomDirectory extends React.Component { private onFilterChange = (alias: string) => { this.setState({ - filterString: alias || null, + filterString: alias || "", }); // don't send the request for a little bit, @@ -392,7 +389,7 @@ export default class RoomDirectory extends React.Component { private onFilterClear = () => { // update immediately this.setState({ - filterString: null, + filterString: "", }, this.refreshRoomList); if (this.filterTimeout) { @@ -484,7 +481,7 @@ 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; } } @@ -568,11 +565,11 @@ export default class RoomDirectory extends React.Component { 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)} - // cancel onMouseDown otherwise shift-clicking highlights text - onMouseDown={(ev) => {ev.preventDefault();}} +
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_roomAvatar" > { url={avatarUrl} />
, -
this.onRoomClicked(room, ev)} - // cancel onMouseDown otherwise shift-clicking highlights text - onMouseDown={(ev) => {ev.preventDefault();}} +
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_roomDescription" > -
{ name }
  -
{ ev.stopPropagation(); } } +
this.onRoomClicked(room, ev)} + > + { name } +
  +
this.onRoomClicked(room, ev)} dangerouslySetInnerHTML={{ __html: topic }} /> -
{ getDisplayAliasForRoom(room) }
+
this.onRoomClicked(room, ev)} + > + { getDisplayAliasForRoom(room) } +
, -
this.onRoomClicked(room, ev)} - // cancel onMouseDown otherwise shift-clicking highlights text - onMouseDown={(ev) => {ev.preventDefault();}} +
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_roomMemberCount" > { room.num_joined_members }
, -
this.onRoomClicked(room, ev)} +
this.onRoomClicked(room, ev)} // cancel onMouseDown otherwise shift-clicking highlights text - onMouseDown={(ev) => {ev.preventDefault();}} className="mx_RoomDirectory_preview" > - {previewButton} + { previewButton }
, -
this.onRoomClicked(room, ev)} - // cancel onMouseDown otherwise shift-clicking highlights text - onMouseDown={(ev) => {ev.preventDefault();}} +
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_join" > - {joinOrViewButton} + { joinOrViewButton }
, ]; } @@ -775,11 +780,11 @@ export default class RoomDirectory extends React.Component { } 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 => ( + { a: sub => ( { sub } - )}, + ) }, ); const title = this.state.selectedCommunityId diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index bda46aef07..9cdd1efe7e 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -108,22 +108,22 @@ 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) => { diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index b2f0c70bd7..f6e42a4f9c 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -17,15 +17,15 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import { _t, _td } from '../../languageHandler'; -import {MatrixClientPeg} from '../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../MatrixClientPeg'; import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; -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 { 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 { StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; import AccessibleButton from "../views/elements/AccessibleButton"; import InlineSpinner from "../views/elements/InlineSpinner"; @@ -41,7 +41,7 @@ export function getUnsentMessages(room) { } @replaceableComponent("structures.RoomStatusBar") -export default class RoomStatusBar extends React.Component { +export default class RoomStatusBar extends React.PureComponent { static propTypes = { // the room this statusbar is representing. room: PropTypes.object.isRequired, @@ -115,9 +115,9 @@ export default class RoomStatusBar extends React.Component { _onResendAllClick = () => { Resend.resendUnsentEvents(this.props.room).then(() => { - this.setState({isResending: false}); + this.setState({ isResending: false }); }); - this.setState({isResending: true}); + this.setState({ isResending: true }); dis.fire(Action.FocusComposer); }; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index fe90d2f873..0985719302 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -23,8 +23,9 @@ limitations under the License. import React, { createRef } from 'react'; import classNames from 'classnames'; -import { Room } from "matrix-js-sdk/src/models/room"; +import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { SearchResult } from "matrix-js-sdk/src/models/search-result"; import { EventSubscription } from "fbemitter"; import shouldHideEvent from '../../shouldHideEvent'; @@ -36,14 +37,12 @@ import Modal from '../../Modal'; import * as sdk from '../../index'; import CallHandler, { PlaceCallType } from '../../CallHandler'; import dis from '../../dispatcher/dispatcher'; -import Tinter from '../../Tinter'; -import rateLimitedFunc from '../../ratelimitedfunc'; import * as Rooms from '../../Rooms'; 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"; @@ -59,12 +58,12 @@ import ScrollPanel from "./ScrollPanel"; import TimelinePanel from "./TimelinePanel"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; -import SearchBar from "../views/rooms/SearchBar"; +import SearchBar, { SearchScope } from "../views/rooms/SearchBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import AuxPanel from "../views/rooms/AuxPanel"; import RoomHeader from "../views/rooms/RoomHeader"; import { XOR } from "../../@types/common"; -import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from '../../effects/utils'; import { CHAT_EFFECTS } from '../../effects'; @@ -80,8 +79,9 @@ import { objectHasDiff } from "../../utils/objects"; import SpaceRoomView from "./SpaceRoomView"; import { IOpts } from "../../createRoom"; import { replaceableComponent } from "../../utils/replaceableComponent"; -import { omit } from 'lodash'; import UIStore from "../../stores/UIStore"; +import EditorStateTransfer from "../../utils/EditorStateTransfer"; +import { throttle } from "lodash"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -94,22 +94,8 @@ if (DEBUG) { } interface IProps { - threepidInvite: IThreepidInvite, - - // 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; - }; + threepidInvite: IThreepidInvite; + oobData?: IOOBData; resizeNotifier: ResizeNotifier; justCreatedOpts?: IOpts; @@ -139,11 +125,11 @@ export interface IState { draggingFile: boolean; searching: boolean; searchTerm?: string; - searchScope?: "All" | "Room"; + searchScope?: SearchScope; searchResults?: XOR<{}, { count: number; highlights: string[]; - results: MatrixEvent[]; + results: SearchResult[]; next_batch: string; // eslint-disable-line camelcase }>; searchHighlights?: string[]; @@ -172,11 +158,7 @@ export interface IState { // 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; layout: Layout; @@ -196,6 +178,7 @@ export interface IState { // 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") @@ -289,7 +272,7 @@ export default class RoomView extends React.Component { if (this.state.room) { this.checkWidgets(this.state.room); } - } + }; private checkWidgets = (room) => { this.setState({ @@ -532,7 +515,7 @@ 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 }); } } } @@ -572,16 +555,12 @@ export default class RoomView extends React.Component { shouldComponentUpdate(nextProps, nextState) { const hasPropsDiff = objectHasDiff(this.props, nextProps); - // React only shallow comparison and we only want to trigger - // a component re-render if a room requires an upgrade - const newUpgradeRecommendation = nextState.upgradeRecommendation || {} - - const state = omit(this.state, ['upgradeRecommendation']); - const newState = omit(nextState, ['upgradeRecommendation']) + const { upgradeRecommendation, ...state } = this.state; + const { upgradeRecommendation: newUpgradeRecommendation, ...newState } = nextState; const hasStateDiff = - objectHasDiff(state, newState) || - (newUpgradeRecommendation.needsUpgrade === true) + newUpgradeRecommendation?.needsUpgrade !== upgradeRecommendation?.needsUpgrade || + objectHasDiff(state, newState); return hasPropsDiff || hasStateDiff; } @@ -682,12 +661,8 @@ export default class RoomView extends React.Component { ); } - // cancel any pending calls to the rate_limited_funcs - this.updateRoomMembers.cancelPendingCall(); - - // 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 + // cancel any pending calls to the throttled updated + this.updateRoomMembers.cancel(); for (const watcher of this.settingWatchers) { SettingsStore.unwatchSetting(watcher); @@ -701,14 +676,9 @@ export default class RoomView extends React.Component { room_id: this.state.room.roomId, event_id: this.state.initialEventId, highlighted: false, + replyingToEvent: this.state.replyToEvent, }); } - } - - private onLayoutChange = () => { - this.setState({ - layout: SettingsStore.getValue("layout"), - }); }; private onRightPanelStoreUpdate = () => { @@ -828,6 +798,36 @@ export default class RoomView extends React.Component { 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 + if (this.state.editState) { + dis.dispatch({ + ...payload, + action: "edit_composer_insert", + }); + } else { + dis.dispatch({ + ...payload, + action: "send_composer_insert", + }); + } + break; + } + + case "scroll_to_bottom": + this.messagePanel?.jumpToLiveTimeline(); + break; } }; @@ -863,7 +863,7 @@ export default class RoomView extends React.Component { // no change } else if (!shouldHideEvent(ev, this.state)) { this.setState((state, props) => { - return {numUnreadMessages: state.numUnreadMessages + 1}; + return { numUnreadMessages: state.numUnreadMessages + 1 }; }); } } @@ -888,7 +888,7 @@ export default class RoomView extends React.Component { CHAT_EFFECTS.forEach(effect => { if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) { - dis.dispatch({action: `effects.${effect.command}`}); + dis.dispatch({ action: `effects.${effect.command}` }); } }); }; @@ -941,7 +941,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.` + @@ -969,7 +969,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({ @@ -1040,15 +1040,6 @@ export default class RoomView extends React.Component { }); } - 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); - } - private onAccountData = (event: MatrixEvent) => { const type = event.getType(); if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) { @@ -1060,12 +1051,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); } @@ -1092,7 +1078,7 @@ export default class RoomView extends React.Component { return; } - this.updateRoomMembers(member); + this.updateRoomMembers(); }; private onMyMembership = (room: Room, membership: string, oldMembership: string) => { @@ -1109,15 +1095,15 @@ 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(() => { + private updateRoomMembers = throttle(() => { this.updateDMState(); this.updateE2EStatus(this.state.room); - }, 500); + }, 500, { leading: true, trailing: true }); private checkDesktopNotifications() { const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount(); @@ -1138,7 +1124,7 @@ export default class RoomView extends React.Component { } } - private onSearchResultsFillRequest = (backwards: boolean) => { + private onSearchResultsFillRequest = (backwards: boolean): Promise => { if (!backwards) { return Promise.resolve(false); } @@ -1173,7 +1159,7 @@ 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; @@ -1208,13 +1194,13 @@ export default class RoomView extends React.Component { // 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}); + 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}); + this.setState({ draggingFile: true }); } }; @@ -1261,9 +1247,9 @@ export default class RoomView extends React.Component { }); }; - 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; } @@ -1276,7 +1262,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, @@ -1297,14 +1283,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; @@ -1317,7 +1303,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 @@ -1349,6 +1335,7 @@ export default class RoomView extends React.Component { description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or search timed out :(")), }); + return false; }).finally(() => { this.setState({ searchInProgress: false, @@ -1459,13 +1446,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', @@ -1590,14 +1570,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; @@ -1644,29 +1624,27 @@ export default class RoomView extends React.Component { 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}); + if (this.state.auxPanelMaxHeight !== auxPanelMaxHeight) { + this.setState({ auxPanelMaxHeight }); + } }; 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 }); }; /** @@ -1701,10 +1679,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() { @@ -1723,7 +1697,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() { @@ -1931,7 +1905,7 @@ 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 }, )} ); @@ -2054,6 +2028,7 @@ export default class RoomView extends React.Component { resizeNotifier={this.props.resizeNotifier} showReactions={true} layout={this.state.layout} + editState={this.state.editState} />); let topUnreadMessagesBar = null; @@ -2069,7 +2044,7 @@ export default class RoomView extends React.Component { 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} @@ -2110,7 +2085,6 @@ export default class RoomView extends React.Component { onSearchClick={this.onSearchClick} onSettingsClick={this.onSettingsClick} 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} diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.tsx similarity index 72% rename from src/components/structures/ScrollPanel.js rename to src/components/structures/ScrollPanel.tsx index f6e1530537..df885575df 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,17 +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 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 { 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. @@ -43,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 @@ -84,97 +153,54 @@ if (DEBUG_SCROLL) { * offset as normal. */ +export interface IScrollState { + stuckAtBottom: boolean; + trackedNode?: HTMLElement; + trackedScrollToken?: string; + bottomOffset?: number; + pixelOffset?: number; +} + +interface IPreventShrinkingState { + offsetFromBottom: number; + offsetNode: HTMLElement; +} + @replaceableComponent("structures.ScrollPanel") -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, - - /* 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, - - /* onUserScroll: callback which is called when the user interacts with the room timeline - */ - onUserScroll: 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, - }; - +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: NodeJS.Timeout; + 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() { @@ -203,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 @@ -225,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(); }; @@ -238,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. @@ -278,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; @@ -293,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. @@ -330,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 = []; @@ -348,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) { @@ -365,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; @@ -413,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); @@ -425,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; } @@ -436,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 @@ -445,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) { @@ -477,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. * @@ -491,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(); }; /** @@ -527,18 +553,18 @@ export default class ScrollPanel extends React.Component { * * @param {number} mult: -1 to page up, +1 to page down */ - scrollRelative = mult => { - const scrollNode = this._getScrollNode(); + 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 => { + public handleScrollKey = (ev: KeyboardEvent) => { let isScrolling = false; const roomAction = getKeyBindingsManager().getRoomAction(ev); switch (roomAction) { @@ -575,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 @@ -593,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; } @@ -634,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, @@ -644,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"); @@ -680,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."); } @@ -694,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) { @@ -713,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 @@ -730,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 && @@ -768,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; }; /** @@ -814,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; @@ -841,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; @@ -857,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 @@ -898,13 +924,15 @@ export default class ScrollPanel extends React.Component { // list-style-type: none; is no longer a list return ( + 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 abeb858274..5c966d2d3a 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -15,14 +15,14 @@ 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"; +import { replaceableComponent } from "../../utils/replaceableComponent"; @replaceableComponent("structures.SearchBox") export default class SearchBox extends React.Component { @@ -89,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) { @@ -101,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); @@ -109,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); } @@ -147,7 +147,7 @@ export default class SearchBox extends React.Component { this.props.placeholder; const className = this.props.className || ""; return ( -
    +
    = ({ ev.preventDefault(); ev.stopPropagation(); onViewRoomClick(false); - } + }; const onJoinClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); onViewRoomClick(true); - } + }; let button; if (joinedRoom) { @@ -137,7 +137,7 @@ const Tile: React.FC = ({ } else { checkbox = { ev.stopPropagation() }} + onClick={ev => { ev.stopPropagation(); }} > ; @@ -286,7 +286,7 @@ export const HierarchyLevel = ({ 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 getOrder(ev.content.order, null, ev.state_key); + return getChildOrder(ev.content.order, null, ev.state_key); }); const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { const roomId = ev.state_key; @@ -340,7 +340,7 @@ export const HierarchyLevel = ({ )) } - + ; }; // mutate argument refreshToken to force a reload @@ -520,6 +520,7 @@ export const SpaceHierarchy: React.FC = ({ setError("Failed to update some suggestions. Try again later"); } setSaving(false); + setSelected(new Map()); }} kind="primary_outline" disabled={disabled} @@ -634,9 +635,9 @@ const SpaceRoomDirectory: React.FC = ({ space, onFinished, initialText }
    { _t("If you can't find the room you're looking for, ask for an invite or create a new room.", null, - {a: sub => { + { a: sub => { return {sub}; - }}, + } }, ) } { ) : null} } -
    +
    ; }; const onBetaClick = () => { defaultDispatcher.dispatch({ action: Action.ViewUserSettings, - initialTabId: USER_LABS_TAB, + initialTabId: UserTab.Labs, }); }; @@ -177,6 +180,9 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => const spacesEnabled = SettingsStore.getValue("feature_spaces"); + const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave + && space.getJoinRule() !== JoinRule.Public; + let inviterSection; let joinButtons; if (myMembership === "join") { @@ -243,7 +249,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => setBusy(true); onJoinButtonClicked(); }} - disabled={!spacesEnabled} + disabled={!spacesEnabled || cannotJoin} > { _t("Join") } @@ -254,6 +260,30 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => 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 } @@ -273,20 +303,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
    { joinButtons }
    - { !spacesEnabled &&
    - { 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 }, - }) - } -
    } + { footer }
    ; }; @@ -575,14 +592,14 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => { { onFinished(false) }} + onClick={() => { onFinished(false); }} >

    { _t("Just me") }

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

    { _t("Me and my teammates") }

    { _t("A private space for you and your teammates") }
    @@ -669,7 +686,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { let buttonLabel = _t("Skip for now"); if (emailAddresses.some(name => name.trim())) { onClick = onNextClick; - buttonLabel = busy ? _t("Inviting...") : _t("Continue") + buttonLabel = busy ? _t("Inviting...") : _t("Continue"); } return
    diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index 0097d55cf5..3d77eaeac1 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -17,10 +17,10 @@ limitations under the License. */ import * as React from "react"; -import {_t} from '../../languageHandler'; +import { _t } from '../../languageHandler'; import * as sdk from "../../index"; import AutoHideScrollbar from './AutoHideScrollbar'; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; /** * Represents a tab for the TabbedView. @@ -75,7 +75,7 @@ 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}); + this.setState({ activeTabIndex: idx }); } else { console.error("Could not find tab " + tab.label + " in tabs"); } diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.tsx similarity index 73% rename from src/components/structures/TimelinePanel.js rename to src/components/structures/TimelinePanel.tsx index bb62745d98..0b6d33b409 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,15 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import SettingsStore from "../../settings/SettingsStore"; -import {LayoutPropType} from "../../settings/Layout"; -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/src/models/event-timeline"; -import {TimelineWindow} from "matrix-js-sdk/src/timeline-window"; +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 { MatrixClientPeg } from "../../MatrixClientPeg"; import RoomContext from "../../contexts/RoomContext"; import UserActivity from "../../UserActivity"; import Modal from "../../Modal"; @@ -34,11 +36,17 @@ import * as sdk from "../../index"; import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; import shouldHideEvent from '../../shouldHideEvent'; -import EditorStateTransfer from '../../utils/EditorStateTransfer'; -import {haveTileForEvent} from "../views/rooms/EventTile"; -import {UIFeature} from "../../settings/UIFeature"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +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'; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -46,90 +54,159 @@ 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. */ @replaceableComponent("structures.TimelinePanel") -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, - sendReadReceiptOnLoad: 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 user interacts with the room timeline - onUserScroll: 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, - - // which layout to use - layout: LayoutPropType, - - // whether to always show timestamps for an event - alwaysShowTimestamps: PropTypes.bool, - } - +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 @@ -139,16 +216,21 @@ class TimelinePanel extends React.Component { 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; @@ -157,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 @@ -245,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 @@ -271,7 +312,7 @@ class TimelinePanel extends React.Component { if (differentEventId || differentHighlightedEventId) { console.log("TimelinePanel switching to eventId " + newProps.eventId + " (was " + this.props.eventId + ")"); - return this._initTimeline(newProps); + return this.initTimeline(newProps); } } @@ -281,13 +322,13 @@ class TimelinePanel extends React.Component { // // (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); @@ -307,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); @@ -326,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 { @@ -349,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'; @@ -361,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); } @@ -373,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, @@ -394,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; } @@ -405,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)); @@ -416,7 +466,7 @@ class TimelinePanel extends React.Component { }); }; - onMessageListScroll = e => { + private onMessageListScroll = e => { if (this.props.onScroll) { this.props.onScroll(e); } @@ -427,37 +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(), - ); - } - }); - } - if (payload.action === "scroll_to_bottom") { - this.jumpToLiveTimeline(); + 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; @@ -465,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; } @@ -484,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, @@ -515,15 +563,15 @@ class TimelinePanel extends React.Component { // 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(); } @@ -531,17 +579,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 @@ -552,7 +600,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 @@ -563,7 +611,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 @@ -572,22 +620,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 @@ -597,7 +645,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; @@ -612,46 +660,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 @@ -662,8 +710,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. // @@ -678,11 +726,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) { @@ -744,8 +792,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, @@ -756,7 +804,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, @@ -766,7 +814,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, }); @@ -774,7 +822,7 @@ class TimelinePanel extends React.Component { return; } const lastDisplayedEvent = this.state.events[lastDisplayedIndex]; - this._setReadMarker( + this.setReadMarker( lastDisplayedEvent.getId(), lastDisplayedEvent.getTs(), ); @@ -791,15 +839,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; @@ -824,45 +871,47 @@ 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, + this.messagePanel.current.scrollToEvent(this.state.readMarkerEventId, 0, 1/3); return; } @@ -870,15 +919,15 @@ class TimelinePanel extends React.Component { // 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); @@ -890,28 +939,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: @@ -920,11 +967,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; } @@ -943,7 +990,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 @@ -958,19 +1005,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; @@ -981,7 +1028,7 @@ class TimelinePanel extends React.Component { offsetBase = 0.5; } - return this._loadTimeline(initialEvent, pixelOffset, offsetBase); + return this.loadTimeline(initialEvent, pixelOffset, offsetBase); } /** @@ -997,34 +1044,32 @@ 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 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 = () => { // 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 @@ -1034,10 +1079,10 @@ class TimelinePanel extends React.Component { return; } if (eventId) { - this._messagePanel.current.scrollToEvent(eventId, pixelOffset, + this.messagePanel.current.scrollToEvent(eventId, pixelOffset, offsetBase); } else { - this._messagePanel.current.scrollToBottom(); + this.messagePanel.current.scrollToBottom(); } if (this.props.sendReadReceiptOnLoad) { @@ -1099,10 +1144,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: [], @@ -1117,17 +1162,17 @@ 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(); + 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 @@ -1139,14 +1184,14 @@ class TimelinePanel extends React.Component { client.decryptEventIfNeeded(event); }); - const firstVisibleEventIndex = this._checkForPreJoinUISI(events); + 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()); } @@ -1167,7 +1212,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 || @@ -1231,7 +1276,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; @@ -1240,15 +1285,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; @@ -1325,7 +1369,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) { @@ -1336,7 +1380,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 @@ -1361,7 +1405,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. @@ -1372,12 +1416,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 @@ -1392,7 +1437,7 @@ class TimelinePanel extends React.Component { if (this.state.timelineLoading) { return (
    - +
    ); } @@ -1413,7 +1458,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. @@ -1426,7 +1471,7 @@ class TimelinePanel extends React.Component { : this.state.events; return (
    - ) + ); } else if (hostSignupConfig) { if (hostSignupConfig && hostSignupConfig.url) { // If hostSignup.domains is set to a non-empty array, only show @@ -408,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 = ( diff --git a/src/components/structures/UserView.js b/src/components/structures/UserView.js index 6b472783bb..eb839be7be 100644 --- a/src/components/structures/UserView.js +++ b/src/components/structures/UserView.js @@ -17,14 +17,14 @@ 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 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"; +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 { @@ -56,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); @@ -66,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 MatrixEvent({type: "m.room.member", content: profileInfo}); + 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 6fe99dd464..b69a92dd61 100644 --- a/src/components/structures/ViewSource.js +++ b/src/components/structures/ViewSource.js @@ -55,7 +55,7 @@ export default class ViewSource extends React.Component { 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 decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private const originalEventSource = mxEvent.event; if (isEncrypted) { diff --git a/src/components/structures/auth/CompleteSecurity.js b/src/components/structures/auth/CompleteSecurity.tsx similarity index 70% rename from src/components/structures/auth/CompleteSecurity.js rename to src/components/structures/auth/CompleteSecurity.tsx index 49fcf20415..2f37e60450 100644 --- a/src/components/structures/auth/CompleteSecurity.js +++ b/src/components/structures/auth/CompleteSecurity.tsx @@ -15,64 +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_LOADING, - 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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + onFinished: () => void; +} + +interface IState { + phase: Phase; +} @replaceableComponent("structures.auth.CompleteSecurity") -export default class CompleteSecurity extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; - - constructor() { - super(); +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_LOADING) { + if (phase === Phase.Loading) { return null; - } else if (phase === PHASE_INTRO) { + } 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 80% rename from src/components/structures/auth/E2eSetup.js rename to src/components/structures/auth/E2eSetup.tsx index 4e51ae828c..93cb92664f 100644 --- a/src/components/structures/auth/E2eSetup.js +++ b/src/components/structures/auth/E2eSetup.tsx @@ -15,20 +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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + onFinished: () => void; + accountPassword?: string; + tokenLogin?: boolean; +} @replaceableComponent("structures.auth.E2eSetup") -export default class E2eSetup extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - accountPassword: PropTypes.string, - tokenLogin: PropTypes.bool, - }; - +export default class E2eSetup extends React.Component { render() { return ( diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.tsx similarity index 80% rename from src/components/structures/auth/ForgotPassword.js rename to src/components/structures/auth/ForgotPassword.tsx index 6188fdb5e4..432f69fd1b 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -17,41 +17,63 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { _t, _td } from '../../../languageHandler'; import * as sdk from '../../../index'; import Modal from "../../../Modal"; 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 { replaceableComponent } from "../../../utils/replaceableComponent"; import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm'; -// Phases -// 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"; + +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: () => 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 { - static propTypes = { - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - onServerConfigChange: PropTypes.func.isRequired, - onLoginClick: PropTypes.func, - onComplete: PropTypes.func.isRequired, - }; +export default class ForgotPassword extends React.Component { + private reset: PasswordReset; state = { - phase: PHASE_FORGOT, + phase: Phase.Forgot, email: "", password: "", password2: "", @@ -64,30 +86,31 @@ export default class ForgotPassword extends React.Component { serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", + 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, @@ -98,28 +121,28 @@ export default class ForgotPassword extends React.Component { serverIsAlive: true, }); } 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!"); @@ -127,17 +150,17 @@ 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 }); @@ -172,27 +195,27 @@ 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); }; - 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, }); } - onPasswordValidate(result) { + private onPasswordValidate(result: IValidationResult) { this.setState({ passwordFieldValid: result.valid, }); @@ -316,16 +339,16 @@ export default class ForgotPassword extends React.Component { let resetPasswordJsx; switch (this.state.phase) { - 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.tsx b/src/components/structures/auth/Login.tsx index d34582b0c3..61d3759dee 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -14,28 +14,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ReactNode} from 'react'; -import {MatrixError} from "matrix-js-sdk/src/http-api"; +import React, { ReactNode } from 'react'; +import { MatrixError } from "matrix-js-sdk/src/http-api"; -import {_t, _td} from '../../../languageHandler'; +import { _t, _td } from '../../../languageHandler'; import * as sdk from '../../../index'; -import Login, {ISSOFlow, LoginFlow} from '../../../Login'; +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 PlatformPeg from '../../../PlatformPeg'; import SettingsStore from "../../../settings/SettingsStore"; -import {UIFeature} from "../../../settings/UIFeature"; +import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; -import {IMatrixClientCreds} from "../../../MatrixClientPeg"; +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 { replaceableComponent } from "../../../utils/replaceableComponent"; // 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. @@ -166,7 +166,7 @@ export default class LoginComponent extends React.PureComponent 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 { @@ -174,7 +174,7 @@ export default class LoginComponent extends React.PureComponent 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({ @@ -201,7 +201,7 @@ export default class LoginComponent extends React.PureComponent 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) { @@ -252,7 +252,7 @@ export default class LoginComponent extends React.PureComponent
    {_t( 'Please note you are logging into the %(hs)s server, not matrix.org.', - {hs: this.props.serverConfig.hsName}, + { hs: this.props.serverConfig.hsName }, )}
    @@ -363,7 +363,7 @@ export default class LoginComponent extends React.PureComponent } }; - private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) { + private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig) { let isDefaultServer = false; if (this.props.serverConfig.isDefault && hsUrl === this.props.serverConfig.hsUrl @@ -501,9 +501,9 @@ export default class LoginComponent extends React.PureComponent return { flows.map(flow => { const stepRenderer = this.stepRendererMap[flow.type]; - return { stepRenderer() } + return { stepRenderer() }; }) } - + ; } private renderPasswordStep = () => { diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index 6feb1e34f7..57b758091a 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -14,23 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {createClient} from 'matrix-js-sdk/src/matrix'; -import React, {ReactNode} from 'react'; -import {MatrixClient} from "matrix-js-sdk/src/client"; +import { createClient } from 'matrix-js-sdk/src/matrix'; +import React, { ReactNode } from 'react'; +import { MatrixClient } from "matrix-js-sdk/src/client"; import * as sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; -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 { MatrixClientPeg } from "../../../MatrixClientPeg"; import AuthPage from "../../views/auth/AuthPage"; -import Login, {ISSOFlow} 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 { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { serverConfig: ValidatedServerConfig; @@ -49,7 +49,7 @@ interface IProps { // for operations like uploading cross-signing keys). onLoggedIn(params: { userId: string; - deviceId: string + deviceId: string; homeserverUrl: string; identityServerUrl?: string; accessToken: string; @@ -131,7 +131,7 @@ export default class Registration extends React.Component { serverDeadError: "", }; - const {hsUrl, isUrl} = this.props.serverConfig; + const { hsUrl, isUrl } = this.props.serverConfig; this.loginLogic = new Login(hsUrl, isUrl, null, { defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used }); @@ -180,7 +180,7 @@ export default class Registration extends React.Component { } } - const {hsUrl, isUrl} = serverConfig; + const { hsUrl, isUrl } = serverConfig; const cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, @@ -230,7 +230,7 @@ export default class Registration extends React.Component { // the user off to the login page to figure their account out. if (ssoFlow) { // Redirect to login page - server probably expects SSO only - dis.dispatch({action: 'start_login'}); + dis.dispatch({ action: 'start_login' }); } else { this.setState({ serverErrorIsFatal: true, // fatal because user cannot continue on this server @@ -267,9 +267,9 @@ export default class Registration extends React.Component { session_id: sessionId, }), ); - } + }; - private 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? @@ -432,7 +432,7 @@ export default class Registration extends React.Component { 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(); @@ -487,7 +487,13 @@ export default class Registration extends React.Component { fragmentAfterLogin={this.props.fragmentAfterLogin} />

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

    ; } @@ -563,7 +569,7 @@ export default class Registration extends React.Component {

    { const sessionLoaded = await this.onLoginClickWithCheck(event); if (sessionLoaded) { - dis.dispatch({action: "view_welcome_page"}); + dis.dispatch({ action: "view_welcome_page" }); } }}> {_t("Continue with previous account")} diff --git a/src/components/structures/auth/SetupEncryptionBody.js b/src/components/structures/auth/SetupEncryptionBody.tsx similarity index 76% rename from src/components/structures/auth/SetupEncryptionBody.js rename to src/components/structures/auth/SetupEncryptionBody.tsx index 803df19d00..13790c2e47 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,41 +15,43 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog'; -import * as sdk from '../../../index'; -import { - SetupEncryptionStore, - PHASE_LOADING, - PHASE_INTRO, - PHASE_BUSY, - PHASE_DONE, - PHASE_CONFIRM_SKIP, - PHASE_FINISHED, -} from '../../../stores/SetupEncryptionStore'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { ISecretStorageKeyInfo } from 'matrix-js-sdk'; +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, ); } -@replaceableComponent("structures.auth.SetupEncryptionBody") -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, @@ -61,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({ @@ -74,18 +76,18 @@ 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(); - } + }; - _onVerifyClick = () => { + private onVerifyClick = () => { const cli = MatrixClientPeg.get(); const userId = cli.getUserId(); const requestPromise = cli.requestVerification(userId); @@ -99,44 +101,46 @@ export default class SetupEncryptionBody extends React.Component { request.cancel(); }, }); - } + }; - onSkipClick = () => { + 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)) { @@ -147,14 +151,14 @@ export default class SetupEncryptionBody extends React.Component { let useRecoveryKeyButton; if (recoveryKeyPrompt) { - useRecoveryKeyButton = + useRecoveryKeyButton = {recoveryKeyPrompt} ; } let verifyButton; if (store.hasDevicesToVerifyAgainst) { - verifyButton = + verifyButton = { _t("Use another login") } ; } @@ -174,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( @@ -200,7 +204,7 @@ export default class SetupEncryptionBody extends React.Component {

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

    {_t( @@ -224,8 +228,7 @@ export default class SetupEncryptionBody extends React.Component {

    ); - } else if (phase === PHASE_BUSY || phase === PHASE_LOADING) { - 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.tsx b/src/components/structures/auth/SoftLogout.tsx index fa9207efdd..3790028fea 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -15,17 +15,17 @@ limitations under the License. */ import React from 'react'; -import {_t} from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import * as Lifecycle from '../../../Lifecycle'; import Modal from '../../../Modal'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {ISSOFlow, LoginFlow, sendLoginRequest} from "../../../Login"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { ISSOFlow, LoginFlow, sendLoginRequest } from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; -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 { replaceableComponent } from "../../../utils/replaceableComponent"; const LOGIN_VIEW = { LOADING: 1, @@ -49,7 +49,7 @@ interface IProps { fragmentAfterLogin?: string; // Called when the SSO login completes - onTokenLoginCompleted: () => void, + onTokenLoginCompleted: () => void; } interface IState { @@ -79,7 +79,7 @@ export default class SoftLogout extends React.Component { 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; } @@ -109,7 +109,7 @@ export default class SoftLogout extends React.Component { 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; } @@ -125,18 +125,18 @@ export default class SoftLogout extends React.Component { } 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(); @@ -168,12 +168,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(); @@ -188,7 +188,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; } @@ -196,7 +196,7 @@ 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 }); }); } 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/voice_messages/Clock.tsx b/src/components/views/audio_messages/Clock.tsx similarity index 90% rename from src/components/views/voice_messages/Clock.tsx rename to src/components/views/audio_messages/Clock.tsx index 23e6762c52..7f387715f8 100644 --- a/src/components/views/voice_messages/Clock.tsx +++ b/src/components/views/audio_messages/Clock.tsx @@ -15,9 +15,9 @@ limitations under the License. */ import React from "react"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; -interface IProps { +export interface IProps { seconds: number; } @@ -28,7 +28,7 @@ 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.voice_messages.Clock") +@replaceableComponent("views.audio_messages.Clock") export default class Clock extends React.Component { public constructor(props) { super(props); 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/voice_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx similarity index 52% rename from src/components/views/voice_messages/LiveRecordingClock.tsx rename to src/components/views/audio_messages/LiveRecordingClock.tsx index b82539eb16..a9dbd3c52f 100644 --- a/src/components/views/voice_messages/LiveRecordingClock.tsx +++ b/src/components/views/audio_messages/LiveRecordingClock.tsx @@ -15,9 +15,10 @@ limitations under the License. */ import React from "react"; -import {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import Clock from "./Clock"; +import { MarkedExecution } from "../../../utils/MarkedExecution"; interface IProps { recorder: VoiceRecording; @@ -30,18 +31,33 @@ interface IState { /** * A clock for a live recording. */ -@replaceableComponent("views.voice_messages.LiveRecordingClock") +@replaceableComponent("views.audio_messages.LiveRecordingClock") export default class LiveRecordingClock extends React.PureComponent { - public constructor(props) { - super(props); + private seconds = 0; + private scheduledUpdate = new MarkedExecution( + () => this.updateClock(), + () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), + ); - this.state = {seconds: 0}; - this.props.recorder.liveData.onUpdate(this.onRecordingUpdate); + constructor(props) { + super(props); + this.state = { + seconds: 0, + }; } - private onRecordingUpdate = (update: IRecordingUpdate) => { - this.setState({seconds: update.timeSeconds}); - }; + 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/voice_messages/PlayPauseButton.tsx b/src/components/views/audio_messages/PlayPauseButton.tsx similarity index 67% rename from src/components/views/voice_messages/PlayPauseButton.tsx rename to src/components/views/audio_messages/PlayPauseButton.tsx index 1f87eb012d..a4f1e770f2 100644 --- a/src/components/views/voice_messages/PlayPauseButton.tsx +++ b/src/components/views/audio_messages/PlayPauseButton.tsx @@ -14,14 +14,15 @@ 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 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 { _t } from "../../../languageHandler"; +import { Playback, PlaybackState } from "../../../voice/Playback"; import classNames from "classnames"; -interface IProps { +// 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; @@ -33,19 +34,25 @@ interface IProps { * Displays a play/pause button (activating the play/pause function of the recorder) * to be displayed in reference to a recording. */ -@replaceableComponent("views.voice_messages.PlayPauseButton") +@replaceableComponent("views.audio_messages.PlayPauseButton") export default class PlayPauseButton extends React.PureComponent { public constructor(props) { super(props); } - private onClick = async () => { - await this.props.playback.toggle(); + private onClick = () => { + // noinspection JSIgnoredPromiseFromCall + this.toggleState(); }; + public async toggleState() { + await this.props.playback.toggle(); + } + public render(): ReactNode { - const isPlaying = this.props.playback.isPlaying; - const isDisabled = this.props.playbackPhase === PlaybackState.Decoding; + 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, @@ -56,6 +63,7 @@ export default class PlayPauseButton extends React.PureComponent { title={isPlaying ? _t("Pause") : _t("Play")} onClick={this.onClick} disabled={isDisabled} + {...restProps} />; } } diff --git a/src/components/views/voice_messages/PlaybackClock.tsx b/src/components/views/audio_messages/PlaybackClock.tsx similarity index 73% rename from src/components/views/voice_messages/PlaybackClock.tsx rename to src/components/views/audio_messages/PlaybackClock.tsx index 2e8ec9a3e7..374d47c31d 100644 --- a/src/components/views/voice_messages/PlaybackClock.tsx +++ b/src/components/views/audio_messages/PlaybackClock.tsx @@ -15,13 +15,18 @@ limitations under the License. */ import React from "react"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import Clock from "./Clock"; -import {Playback, PlaybackState} from "../../../voice/Playback"; -import {UPDATE_EVENT} from "../../../stores/AsyncStore"; +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 { @@ -33,7 +38,7 @@ interface IState { /** * A clock for a playback of a recording. */ -@replaceableComponent("views.voice_messages.PlaybackClock") +@replaceableComponent("views.audio_messages.PlaybackClock") export default class PlaybackClock extends React.PureComponent { public constructor(props) { super(props); @@ -54,17 +59,21 @@ export default class PlaybackClock extends React.PureComponent { 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}); + this.setState({ playbackPhase: ev }); }; private onTimeUpdate = (time: number[]) => { - this.setState({seconds: time[0], durationSeconds: time[1]}); + this.setState({ seconds: time[0], durationSeconds: time[1] }); }; public render() { let seconds = this.state.seconds; if (this.state.playbackPhase === PlaybackState.Stopped) { - seconds = this.state.durationSeconds; + if (Number.isFinite(this.props.defaultDisplaySeconds)) { + seconds = this.props.defaultDisplaySeconds; + } else { + seconds = this.state.durationSeconds; + } } return ; } diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/audio_messages/PlaybackWaveform.tsx similarity index 81% rename from src/components/views/voice_messages/PlaybackWaveform.tsx rename to src/components/views/audio_messages/PlaybackWaveform.tsx index 2e9f163f5e..ea1b846c01 100644 --- a/src/components/views/voice_messages/PlaybackWaveform.tsx +++ b/src/components/views/audio_messages/PlaybackWaveform.tsx @@ -15,11 +15,11 @@ limitations under the License. */ import React from "react"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {arraySeed, arrayTrimFill} from "../../../utils/arrays"; +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"; +import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../voice/Playback"; +import { percentageOf } from "../../../utils/numbers"; interface IProps { playback: Playback; @@ -33,7 +33,7 @@ interface IState { /** * A waveform which shows the waveform of a previously recorded recording */ -@replaceableComponent("views.voice_messages.PlaybackWaveform") +@replaceableComponent("views.audio_messages.PlaybackWaveform") export default class PlaybackWaveform extends React.PureComponent { public constructor(props) { super(props); @@ -53,13 +53,13 @@ export default class PlaybackWaveform extends React.PureComponent { - this.setState({heights: this.toHeights(waveform)}); + 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}); + this.setState({ progress }); }; public render() { diff --git a/src/components/views/voice_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx similarity index 81% rename from src/components/views/voice_messages/RecordingPlayback.tsx rename to src/components/views/audio_messages/RecordingPlayback.tsx index 776997cec2..a0dea1c6db 100644 --- a/src/components/views/voice_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -14,12 +14,13 @@ 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 { Playback, PlaybackState } from "../../../voice/Playback"; +import React, { ReactNode } from "react"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import PlaybackWaveform from "./PlaybackWaveform"; import PlayPauseButton from "./PlayPauseButton"; import PlaybackClock from "./PlaybackClock"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { // Playback instance to render. Cannot change during component lifecycle: create @@ -31,6 +32,7 @@ interface IState { playbackPhase: PlaybackState; } +@replaceableComponent("views.audio_messages.RecordingPlayback") export default class RecordingPlayback extends React.PureComponent { constructor(props: IProps) { super(props); @@ -49,14 +51,14 @@ export default class RecordingPlayback extends React.PureComponent { - this.setState({playbackPhase: ev}); + this.setState({ playbackPhase: ev }); }; public render(): ReactNode { - return
    + return
    -
    +
    ; } } 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/voice_messages/Waveform.tsx b/src/components/views/audio_messages/Waveform.tsx similarity index 81% rename from src/components/views/voice_messages/Waveform.tsx rename to src/components/views/audio_messages/Waveform.tsx index 840a5a12b3..3b7a881754 100644 --- a/src/components/views/voice_messages/Waveform.tsx +++ b/src/components/views/audio_messages/Waveform.tsx @@ -15,8 +15,13 @@ limitations under the License. */ import React from "react"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +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) @@ -34,16 +39,12 @@ interface IState { * 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.voice_messages.Waveform") +@replaceableComponent("views.audio_messages.Waveform") export default class Waveform extends React.PureComponent { public static defaultProps = { progress: 1, }; - public constructor(props) { - super(props); - } - public render() { return
    {this.props.relHeights.map((h, i) => { @@ -53,7 +54,9 @@ export default class Waveform extends React.PureComponent { 'mx_Waveform_bar': true, 'mx_Waveform_bar_100pct': isCompleteBar, }); - return ; + return ; })}
    ; } diff --git a/src/components/views/auth/AuthBody.js b/src/components/views/auth/AuthBody.js index 2cb72b5e1d..abe7fd2fd3 100644 --- a/src/components/views/auth/AuthBody.js +++ b/src/components/views/auth/AuthBody.js @@ -15,7 +15,7 @@ limitations under the License. */ import React from 'react'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthBody") export default class AuthBody extends React.PureComponent { diff --git a/src/components/views/auth/AuthFooter.js b/src/components/views/auth/AuthFooter.js index f167e16283..e81d2cd969 100644 --- a/src/components/views/auth/AuthFooter.js +++ b/src/components/views/auth/AuthFooter.js @@ -18,7 +18,7 @@ limitations under the License. import { _t } from '../../../languageHandler'; import React from 'react'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthFooter") export default class AuthFooter extends React.Component { diff --git a/src/components/views/auth/AuthHeader.js b/src/components/views/auth/AuthHeader.js index 323299b3a8..d9bd81adcb 100644 --- a/src/components/views/auth/AuthHeader.js +++ b/src/components/views/auth/AuthHeader.js @@ -18,7 +18,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthHeader") export default class AuthHeader extends React.Component { diff --git a/src/components/views/auth/AuthHeaderLogo.js b/src/components/views/auth/AuthHeaderLogo.js index b4e04799bb..0adf18dc1c 100644 --- a/src/components/views/auth/AuthHeaderLogo.js +++ b/src/components/views/auth/AuthHeaderLogo.js @@ -15,7 +15,7 @@ limitations under the License. */ import React from 'react'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthHeaderLogo") export default class AuthHeaderLogo extends React.PureComponent { 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 50de24d403..bea4f89f53 100644 --- a/src/components/views/auth/CaptchaForm.js +++ b/src/components/views/auth/CaptchaForm.js @@ -14,11 +14,11 @@ 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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; const DIV_ID = 'mx_recaptcha'; diff --git a/src/components/views/auth/CompleteSecurityBody.js b/src/components/views/auth/CompleteSecurityBody.js index 6647bb1200..745d7abbf2 100644 --- a/src/components/views/auth/CompleteSecurityBody.js +++ b/src/components/views/auth/CompleteSecurityBody.js @@ -15,7 +15,7 @@ limitations under the License. */ import React from 'react'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.CompleteSecurityBody") export default class CompleteSecurityBody extends React.PureComponent { diff --git a/src/components/views/auth/CountryDropdown.js b/src/components/views/auth/CountryDropdown.js index e21f112865..cbc19e0f8d 100644 --- a/src/components/views/auth/CountryDropdown.js +++ b/src/components/views/auth/CountryDropdown.js @@ -19,10 +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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; const COUNTRIES_BY_ISO2 = {}; for (const c of COUNTRIES) { diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index e819e1e59c..e002eb5717 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -24,7 +24,7 @@ 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 { replaceableComponent } from "../../../utils/replaceableComponent"; import { LocalisedPolicy, Policies } from '../../../Terms'; /* This file contains a collection of components which are used by the @@ -354,7 +354,6 @@ export class TermsAuthEntry extends React.Component { @@ -382,10 +381,10 @@ export class TermsAuthEntry extends React.Component { this.props.fail(e); }).finally(() => { - this.setState({requestingToken: false}); + this.setState({ requestingToken: false }); }); } @@ -710,7 +709,7 @@ export class SSOAuthEntry extends React.Component; const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown'); diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index 274c244b2a..bab7e59d2a 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -14,15 +14,15 @@ 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, {IInputProps} from "../elements/Field"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +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 extends Omit { autoFocus?: boolean; diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index 2a42804a61..a77dd0b683 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -19,14 +19,14 @@ import classNames from 'classnames'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; @@ -52,8 +52,8 @@ interface IProps { interface IState { fieldValid: Partial>; - loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone, - password: "", + loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone; + password: ""; } enum LoginField { @@ -166,7 +166,7 @@ export default class PasswordLogin extends React.PureComponent { }; private onPasswordChanged = ev => { - this.setState({password: ev.target.value}); + this.setState({ password: ev.target.value }); }; private async verifyFieldsBeforeSubmit() { @@ -322,7 +322,7 @@ export default class PasswordLogin extends React.PureComponent { const result = await this.validatePasswordRules(fieldState); this.markFieldValid(LoginField.Password, result.valid); return result; - } + }; private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) { const classes = { diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index 8f0a293a3c..be6e29a493 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -25,12 +25,12 @@ 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 { replaceableComponent } from "../../../utils/replaceableComponent"; enum RegistrationField { Email = "field_email", diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index fca66fcf9b..e3f7a601f2 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -20,11 +20,11 @@ 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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; // translatable strings for Welcome pages _td("Sign in with SSO"); diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 6949c14636..87cdbe7512 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -17,16 +17,17 @@ 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 {ResizeMethod} from "../../../Avatar"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; +import { toPx } from "../../../utils/units"; import { _t } from '../../../languageHandler'; interface IProps { @@ -63,7 +64,7 @@ const calculateUrls = (url, urls, lowBandwidth) => { return Array.from(new Set(_urls)); }; -const useImageUrl = ({url, urls}): [string, () => void] => { +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); @@ -114,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); diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index 42aef24086..5e6bf45f07 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -24,19 +24,20 @@ 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 { replaceableComponent } from "../../../utils/replaceableComponent"; +import { IOOBData } from "../../../stores/ThreepidInviteStore"; interface IProps { room: Room; avatarSize: number; displayBadge?: boolean; forceCount?: boolean; - oobData?: object; + oobData?: IOOBData; viewAvatarOnClick?: boolean; } @@ -121,7 +122,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent { // 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; @@ -89,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 acf190f17f..b8b23dc33e 100644 --- a/src/components/views/avatars/MemberStatusMessageAvatar.js +++ b/src/components/views/avatars/MemberStatusMessageAvatar.js @@ -14,16 +14,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 {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 {replaceableComponent} from "../../../utils/replaceableComponent"; +import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.avatars.MemberStatusMessageAvatar") export default class MemberStatusMessageAvatar extends React.Component { diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 4693d907ba..8ac8de8233 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -13,25 +13,25 @@ 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, {ComponentProps} from 'react'; -import Room from 'matrix-js-sdk/src/models/room'; +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 { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; +import { IOOBData } from '../../../stores/ThreepidInviteStore'; 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; @@ -128,7 +128,7 @@ export default class RoomAvatar extends React.Component { }; public render() { - const {room, oobData, viewAvatarOnClick, onClick, ...otherProps} = this.props; + const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props; const roomName = room ? room.name : oobData.name; diff --git a/src/components/views/avatars/WidgetAvatar.tsx b/src/components/views/avatars/WidgetAvatar.tsx index cca158269e..264f1f3956 100644 --- a/src/components/views/avatars/WidgetAvatar.tsx +++ b/src/components/views/avatars/WidgetAvatar.tsx @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ComponentProps} from 'react'; +import React, { ComponentProps } from 'react'; import classNames from 'classnames'; -import {IApp} from "../../../stores/WidgetStore"; -import BaseAvatar, {BaseAvatarType} from "./BaseAvatar"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { IApp } from "../../../stores/WidgetStore"; +import BaseAvatar, { BaseAvatarType } from "./BaseAvatar"; +import { mediaFromMxc } from "../../../customisations/Media"; interface IProps extends Omit, "name" | "url" | "urls"> { app: IApp; @@ -49,7 +49,7 @@ const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 2 width={width} height={height} /> - ) + ); }; export default WidgetAvatar; diff --git a/src/components/views/beta/BetaCard.tsx b/src/components/views/beta/BetaCard.tsx index 821c448f4f..3127e1a915 100644 --- a/src/components/views/beta/BetaCard.tsx +++ b/src/components/views/beta/BetaCard.tsx @@ -17,14 +17,15 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import {_t} from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; import SettingsStore from "../../../settings/SettingsStore"; -import {SettingLevel} from "../../../settings/SettingLevel"; +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; @@ -66,7 +67,7 @@ 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 } = info; + const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading, extraSettings } = info; const value = SettingsStore.getValue(featureId); let feedbackButton; @@ -82,26 +83,33 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => { } 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") } - +

    + { 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) } +
    }
    - { disclaimer &&
    - { disclaimer(value) } -
    } +
    - + { extraSettings &&
    + { extraSettings.map(key => ( + + )) } +
    }
    ; }; diff --git a/src/components/views/context_menus/CallContextMenu.tsx b/src/components/views/context_menus/CallContextMenu.tsx index 97473059a6..428e18ed30 100644 --- a/src/components/views/context_menus/CallContextMenu.tsx +++ b/src/components/views/context_menus/CallContextMenu.tsx @@ -22,7 +22,7 @@ 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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends IContextMenuProps { call: MatrixCall; @@ -42,21 +42,21 @@ export default class CallContextMenu extends React.Component { 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}, + 'Transfer Call', '', InviteDialog, { kind: KIND_CALL_TRANSFER, call: this.props.call }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); this.props.onFinished(); - } + }; render() { const holdUnholdCaption = this.props.call.isRemoteOnHold() ? _t("Resume") : _t("Hold"); diff --git a/src/components/views/context_menus/DialpadContextMenu.tsx b/src/components/views/context_menus/DialpadContextMenu.tsx index 17abce0c61..28a73ba8d4 100644 --- a/src/components/views/context_menus/DialpadContextMenu.tsx +++ b/src/components/views/context_menus/DialpadContextMenu.tsx @@ -18,8 +18,9 @@ import React from 'react'; import { _t } from '../../../languageHandler'; 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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends IContextMenuProps { call: MatrixCall; @@ -36,13 +37,17 @@ export default class DialpadContextMenu extends React.Component this.state = { value: '', - } + }; } onDigitPress = (digit) => { this.props.call.sendDtmfDigit(digit); - this.setState({value: this.state.value + digit}); - } + this.setState({ value: this.state.value + digit }); + }; + + onChange = (ev) => { + this.setState({ value: ev.target.value }); + }; render() { return @@ -50,7 +55,10 @@ export default class DialpadContextMenu extends React.Component
    {_t("Dial pad")}
    -
    {this.state.value}
    +
    diff --git a/src/components/views/context_menus/GenericElementContextMenu.js b/src/components/views/context_menus/GenericElementContextMenu.js index e04e3f7695..87d44ef0d3 100644 --- a/src/components/views/context_menus/GenericElementContextMenu.js +++ b/src/components/views/context_menus/GenericElementContextMenu.js @@ -16,14 +16,13 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +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 = { diff --git a/src/components/views/context_menus/GenericTextContextMenu.js b/src/components/views/context_menus/GenericTextContextMenu.js index 3d3add006f..474732e88b 100644 --- a/src/components/views/context_menus/GenericTextContextMenu.js +++ b/src/components/views/context_menus/GenericTextContextMenu.js @@ -16,7 +16,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.context_menus.GenericTextContextMenu") export default class GenericTextContextMenu extends React.Component { diff --git a/src/components/views/context_menus/GroupInviteTileContextMenu.js b/src/components/views/context_menus/GroupInviteTileContextMenu.js index 15078326b3..1529723ac8 100644 --- a/src/components/views/context_menus/GroupInviteTileContextMenu.js +++ b/src/components/views/context_menus/GroupInviteTileContextMenu.js @@ -20,10 +20,10 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; -import {Group} from 'matrix-js-sdk/src/models/group'; +import { Group } from 'matrix-js-sdk/src/models/group'; import GroupStore from "../../../stores/GroupStore"; -import {MenuItem} from "../../structures/ContextMenu"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MenuItem } from "../../structures/ContextMenu"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.context_menus.GroupInviteTileContextMenu") export default class GroupInviteTileContextMenu extends React.Component { 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 index adfeeb0968..a2086451cd 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -28,11 +28,12 @@ import Resend from '../../../Resend'; import SettingsStore from '../../../settings/SettingsStore'; import { isUrlPermitted } from '../../../HtmlUtils'; import { isContentActionable } from '../../../utils/EventUtils'; -import { MenuItem } from "../../structures/ContextMenu"; +import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu'; import { EventType } from "matrix-js-sdk/src/@types/event"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard"; import ForwardDialog from "../dialogs/ForwardDialog"; +import { Action } from "../../../dispatcher/actions"; export function canCancel(eventStatus) { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; @@ -89,7 +90,7 @@ export default class MessageContextMenu extends React.Component { // 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}); + this.setState({ canRedact, canPin }); }; _isPinned() { @@ -148,7 +149,7 @@ export default class MessageContextMenu extends React.Component { // 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}), + description: _t('You cannot delete this message. (%(code)s)', { code }), }); } } @@ -178,7 +179,7 @@ export default class MessageContextMenu extends React.Component { pinnedIds.push(eventId); cli.setRoomAccountData(room.roomId, ReadPinsEventId, { event_ids: [ - ...room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids, + ...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId, ], }); @@ -200,13 +201,13 @@ export default class MessageContextMenu extends React.Component { onQuoteClick = () => { dis.dispatch({ - action: 'quote', + action: Action.ComposerInsert, event: this.props.mxEvent, }); this.closeMenu(); }; - onPermalinkClick = (e: Event) => { + onPermalinkClick = (e) => { e.preventDefault(); const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { @@ -256,55 +257,68 @@ export default class MessageContextMenu extends React.Component { let externalURLButton; let quoteButton; let collapseReplyThread; + let redactItemList; // status is SENT before remote-echo, null after const isSent = !eventStatus || eventStatus === EventStatus.SENT; if (!mxEvent.isRedacted()) { if (unsentReactionsCount !== 0) { resendReactionsButton = ( - - { _t('Resend %(unsentCount)s reaction(s)', {unsentCount: unsentReactionsCount}) } - + ); } } if (isSent && this.state.canRedact) { redactButton = ( - - { _t('Remove') } - + ); } 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 (this.props.eventTileOps) { if (this.props.eventTileOps.isWidgetHidden()) { unhidePreviewButton = ( - - { _t('Unhide Preview') } - + ); } } @@ -315,77 +329,97 @@ export default class MessageContextMenu extends React.Component { } // 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" && + 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') } - + + ); + } + + const commonItemsList = ( + + { quoteButton } + { forwardButton } + { pinButton } + { permalinkButton } + { reportEventButton } + { externalURLButton } + { unhidePreviewButton } + { viewSourceButton } + { resendReactionsButton } + { collapseReplyThread } + + ); + + if (redactButton) { + redactItemList = ( + + { redactButton } + ); } return ( -
    - { resendReactionsButton } - { redactButton } - { forwardButton } - { pinButton } - { viewSourceButton } - { unhidePreviewButton } - { permalinkButton } - { quoteButton } - { externalURLButton } - { collapseReplyThread } - { reportEventButton } -
    + + { commonItemsList } + { redactItemList } + ); } } diff --git a/src/components/views/context_menus/StatusMessageContextMenu.js b/src/components/views/context_menus/StatusMessageContextMenu.js index 41f0e0ba61..23b91fe68f 100644 --- a/src/components/views/context_menus/StatusMessageContextMenu.js +++ b/src/components/views/context_menus/StatusMessageContextMenu.js @@ -17,10 +17,10 @@ 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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.context_menus.StatusMessageContextMenu") export default class StatusMessageContextMenu extends React.Component { diff --git a/src/components/views/context_menus/TagTileContextMenu.js b/src/components/views/context_menus/TagTileContextMenu.js index 8dea62690c..c40ff4207b 100644 --- a/src/components/views/context_menus/TagTileContextMenu.js +++ b/src/components/views/context_menus/TagTileContextMenu.js @@ -20,48 +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 { 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 068bfd6497..b21efdceb9 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -14,22 +14,22 @@ 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 {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 Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; import ErrorDialog from "../dialogs/ErrorDialog"; -import {WidgetType} from "../../../widgets/WidgetType"; +import { WidgetType } from "../../../widgets/WidgetType"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream"; @@ -54,7 +54,7 @@ const WidgetContextMenu: React.FC = ({ ...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); diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 822ffc2827..5024b98def 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -14,31 +14,34 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ReactNode, useContext, useMemo, useState} from "react"; +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 { 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 { _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 { getDisplayAliasForRoom } from "../../../Rooms"; import AccessibleButton from "../elements/AccessibleButton"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {sleep} from "../../../utils/promise"; import DMRoomMap from "../../../utils/DMRoomMap"; -import {calculateRoomVia} from "../../../utils/permalinks/Permalinks"; +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 { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; import ProgressBar from "../elements/ProgressBar"; -import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView"; +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; @@ -204,6 +207,17 @@ export const AddExistingToSpace: React.FC = ({ 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.map(room => { - return { - onChange(checked, room); - } : null} - />; - }) } + rooms.slice(start, end).map(room => + { + onChange(checked, room); + } : null} + />, + )} + getChildCount={() => rooms.length} + />
    ) : undefined } diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 929d688e47..09714e24e3 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -17,23 +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 {replaceableComponent} from "../../../utils/replaceableComponent"; +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; @@ -457,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({ @@ -573,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 73% rename from src/components/views/dialogs/AskInviteAnywayDialog.js rename to src/components/views/dialogs/AskInviteAnywayDialog.tsx index e6cd45ba6b..970883aca2 100644 --- a/src/components/views/dialogs/AskInviteAnywayDialog.js +++ b/src/components/views/dialogs/AskInviteAnywayDialog.tsx @@ -15,39 +15,41 @@ 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 {replaceableComponent} from "../../../utils/replaceableComponent"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + unknownProfileUsers: Array<{ + userId: string; + errorText: string; + }>; + onInviteAnyways: () => void; + onGiveUp: () => void; + onFinished: (success: boolean) => void; +} @replaceableComponent("views.dialogs.AskInviteAnywayDialog") -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, - }; - - _onInviteClicked = () => { +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() { + public render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const errorList = this.props.unknownProfileUsers @@ -55,11 +57,12 @@ export default class AskInviteAnywayDialog extends React.Component { return (
    + {/* eslint-disable-next-line */}

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

      { errorList } @@ -67,13 +70,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 0858e53e50..e92bd6315e 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -23,10 +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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; /* * Basic container for modal dialogs. diff --git a/src/components/views/dialogs/BetaFeedbackDialog.tsx b/src/components/views/dialogs/BetaFeedbackDialog.tsx index 1ae50dd66f..5a2f16f169 100644 --- a/src/components/views/dialogs/BetaFeedbackDialog.tsx +++ b/src/components/views/dialogs/BetaFeedbackDialog.tsx @@ -14,28 +14,28 @@ 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"; import SdkConfig from "../../../SdkConfig"; -import {IDialogProps} from "./IDialogProps"; +import { IDialogProps } from "./IDialogProps"; import SettingsStore from "../../../settings/SettingsStore"; -import {submitFeedback} from "../../../rageshake/submit-rageshake"; +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 {USER_LABS_TAB} from "./UserSettingsDialog"; +import { Action } from "../../../dispatcher/actions"; +import { UserTab } from "./UserSettingsDialog"; interface IProps extends IDialogProps { featureId: string; } -const BetaFeedbackDialog: React.FC = ({featureId, onFinished}) => { +const BetaFeedbackDialog: React.FC = ({ featureId, onFinished }) => { const info = SettingsStore.getBetaInfo(featureId); const [comment, setComment] = useState(""); @@ -44,7 +44,12 @@ const BetaFeedbackDialog: React.FC = ({featureId, onFinished}) => { const sendFeedback = async (ok: boolean) => { if (!ok) return onFinished(false); - submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact); + 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, { @@ -70,7 +75,7 @@ const BetaFeedbackDialog: React.FC = ({featureId, onFinished}) => { onFinished(false); defaultDispatcher.dispatch({ action: Action.ViewUserSettings, - initialTabId: USER_LABS_TAB, + initialTabId: UserTab.Labs, }); }}> { _t("To leave the beta, visit your settings.") } diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.tsx similarity index 77% rename from src/components/views/dialogs/BugReportDialog.js rename to src/components/views/dialogs/BugReportDialog.tsx index cbe0130649..eeb3769bf9 100644 --- a/src/components/views/dialogs/BugReportDialog.js +++ b/src/components/views/dialogs/BugReportDialog.tsx @@ -18,17 +18,35 @@ 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 { replaceableComponent } from "../../../utils/replaceableComponent"; + +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 { +export default class BugReportDialog extends React.Component { + private unmounted: boolean; + constructor(props) { super(props); this.state = { @@ -41,25 +59,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."), @@ -72,15 +83,15 @@ 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 @@ -91,7 +102,7 @@ export default class BugReportDialog extends React.Component { }); } }, (err) => { - if (!this._unmounted) { + if (!this.unmounted) { this.setState({ busy: false, progress: null, @@ -99,16 +110,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, }); @@ -117,7 +128,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}`, @@ -126,33 +137,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() { + public render() { const Loader = sdk.getComponent("elements.Spinner"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); @@ -183,7 +190,7 @@ export default class BugReportDialog extends React.Component { } return ( - @@ -213,7 +220,7 @@ export default class BugReportDialog extends React.Component {

    - + { _t("Download logs") } {this.state.downloadProgress && {this.state.downloadProgress} ...} @@ -223,7 +230,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/..." /> @@ -232,7 +239,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 " + @@ -245,17 +252,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 87% rename from src/components/views/dialogs/ChangelogDialog.js rename to src/components/views/dialogs/ChangelogDialog.tsx index efbeba3977..8acacd8e73 100644 --- a/src/components/views/dialogs/ChangelogDialog.js +++ b/src/components/views/dialogs/ChangelogDialog.tsx @@ -16,21 +16,26 @@ 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'; +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 +49,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 (
  • @@ -59,7 +64,7 @@ export default class ChangelogDialog extends React.Component { ); } - render() { + public render() { const Spinner = sdk.getComponent('views.elements.Spinner'); const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); @@ -72,7 +77,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,7 +93,6 @@ export default class ChangelogDialog extends React.Component {
    ); - return ( { @@ -122,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) => { @@ -137,7 +137,7 @@ 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) { @@ -165,7 +165,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< } private onShowMorePeople = () => { - this.setState({numPeople: this.state.numPeople + 5}); // arbitrary increase + this.setState({ numPeople: this.state.numPeople + 5 }); // arbitrary increase }; public render() { @@ -214,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")} @@ -225,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 85% rename from src/components/views/dialogs/ConfirmAndWaitRedactDialog.js rename to src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx index 37d5510756..90b749b959 100644 --- a/src/components/views/dialogs/ConfirmAndWaitRedactDialog.js +++ b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx @@ -17,7 +17,17 @@ limitations under the License. import React from 'react'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + redact: () => Promise; + onFinished: (success: boolean) => void; +} + +interface IState { + isRedacting: boolean; + redactionErrorCode: string | number; +} /* * A dialog for confirming a redaction. @@ -32,7 +42,7 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; * To avoid this, we keep the dialog open as long as /redact is in progress. */ @replaceableComponent("views.dialogs.ConfirmAndWaitRedactDialog") -export default class ConfirmAndWaitRedactDialog extends React.PureComponent { +export default class ConfirmAndWaitRedactDialog extends React.PureComponent { constructor(props) { super(props); this.state = { @@ -41,16 +51,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); } @@ -60,7 +70,7 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent { } }; - render() { + public render() { if (this.state.isRedacting) { if (this.state.redactionErrorCode) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -69,7 +79,7 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent { ); } else { diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.tsx similarity index 90% rename from src/components/views/dialogs/ConfirmRedactDialog.js rename to src/components/views/dialogs/ConfirmRedactDialog.tsx index bd63d3acc1..94f29a71fc 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.js +++ b/src/components/views/dialogs/ConfirmRedactDialog.tsx @@ -17,13 +17,17 @@ limitations under the License. import React from 'react'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + onFinished: (success: boolean) => void; +} /* * A dialog for confirming a redaction. */ @replaceableComponent("views.dialogs.ConfirmRedactDialog") -export default class ConfirmRedactDialog extends React.Component { +export default class ConfirmRedactDialog extends React.Component { render() { const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog'); return ( diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.tsx similarity index 74% rename from src/components/views/dialogs/ConfirmUserActionDialog.js rename to src/components/views/dialogs/ConfirmUserActionDialog.tsx index 8059b9172a..78fae390b5 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.tsx @@ -15,13 +15,31 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { GroupMemberType } from '../../../groups'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; + +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. @@ -32,53 +50,23 @@ import {mediaFromMxc} from "../../../customisations/Media"; * Also tweaks the style for 'dangerous' actions (albeit only with colour) */ @replaceableComponent("views.dialogs.ConfirmUserActionDialog") -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, - }; +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() { + public render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); @@ -92,7 +80,7 @@ export default class ConfirmUserActionDialog extends React.Component {
    diff --git a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js b/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx similarity index 82% rename from src/components/views/dialogs/ConfirmWipeDeviceDialog.js rename to src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx index 333e1522f1..2978179817 100644 --- a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js +++ b/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx @@ -15,22 +15,21 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {_t} from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import * as sdk from "../../../index"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + onFinished: (success: boolean) => void; +} @replaceableComponent("views.dialogs.ConfirmWipeDeviceDialog") -export default class ConfirmWipeDeviceDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; - - _onConfirm = () => { +export default class ConfirmWipeDeviceDialog extends React.Component { + private onConfirm = (): void => { this.props.onFinished(true); }; - _onDecline = () => { + private onDecline = (): void => { this.props.onFinished(false); }; @@ -55,10 +54,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 9b4484d661..29e9a2ad39 100644 --- a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx +++ b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx @@ -23,9 +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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends IDialogProps { } @@ -58,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) => { @@ -69,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) { @@ -85,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, @@ -123,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); } @@ -175,7 +175,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< let preview = ; if (!this.state.avatarPreview) { - preview =
    + preview =
    ; } return ( @@ -204,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 78% rename from src/components/views/dialogs/CreateGroupDialog.js rename to src/components/views/dialogs/CreateGroupDialog.tsx index e6c7a67aca..f03a40ddc5 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.tsx @@ -15,44 +15,51 @@ 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 {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + onFinished: (success: boolean) => void; +} + +interface IState { + groupName: string; + groupId: string; + groupIdError: string; + creating: boolean; + createError: Error; +} @replaceableComponent("views.dialogs.CreateGroupDialog") -export default class CreateGroupDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; - - state = { +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."); @@ -67,16 +74,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, @@ -88,9 +95,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 }); }); }; @@ -121,7 +128,7 @@ export default class CreateGroupDialog extends React.Component { - +
    @@ -129,9 +136,9 @@ export default class CreateGroupDialog extends React.Component {
    @@ -144,10 +151,10 @@ export default class CreateGroupDialog extends React.Component { + diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index cce6b6c34c..b5c0096771 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -15,22 +15,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ChangeEvent, createRef, KeyboardEvent, SyntheticEvent} from "react"; -import {Room} from "matrix-js-sdk/src/models/room"; +import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; import SdkConfig from '../../../SdkConfig'; -import withValidation, {IFieldState} from '../elements/Validation'; -import {_t} from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {Key} from "../../../Keyboard"; -import {IOpts, Preset, privateShouldBeEncrypted, Visibility} from "../../../createRoom"; -import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +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; @@ -72,7 +73,7 @@ export default class CreateRoomDialog extends React.Component { canChangeEncryption: true, }; - MatrixClientPeg.get().doesServerForceEncryptionForPreset("private") + MatrixClientPeg.get().doesServerForceEncryptionForPreset(Preset.PrivateChat) .then(isForced => this.setState({ canChangeEncryption: !isForced })); } @@ -136,9 +137,9 @@ export default class CreateRoomDialog extends React.Component { if (activeElement) { activeElement.blur(); } - await this.nameField.current.validate({allowEmpty: false}); + await this.nameField.current.validate({ allowEmpty: false }); if (this.aliasField.current) { - await this.aliasField.current.validate({allowEmpty: false}); + 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. @@ -193,7 +194,7 @@ export default class CreateRoomDialog extends React.Component { private onNameValidate = async (fieldState: IFieldState) => { const result = await CreateRoomDialog.validateRoomName(fieldState); - this.setState({nameIsValid: result.valid}); + this.setState({ nameIsValid: result.valid }); return result; }; @@ -275,7 +276,7 @@ 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 ( { { +interface IProps { + onFinished: (success: boolean) => void; +} + +export default (props: IProps) => { const brand = SdkConfig.get().brand; const _onLogoutClicked = () => { @@ -39,8 +43,8 @@ export default (props) => { focus: false, onFinished: (doLogout) => { if (doLogout) { - dis.dispatch({action: 'logout'}); - props.onFinished(); + dis.dispatch({ action: 'logout' }); + props.onFinished(true); } }, }); diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.tsx similarity index 79% rename from src/components/views/dialogs/DeactivateAccountDialog.js rename to src/components/views/dialogs/DeactivateAccountDialog.tsx index 4e52549d51..6df6056670 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -16,20 +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 { replaceableComponent } from "../../../utils/replaceableComponent"; + +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 { +export default class DeactivateAccountDialog extends React.Component { constructor(props) { super(props); @@ -46,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."), @@ -84,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'); @@ -107,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 @@ -123,32 +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() { + public render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); let error = null; @@ -166,9 +182,9 @@ export default class DeactivateAccountDialog extends React.Component { @@ -214,7 +230,7 @@ export default class DeactivateAccountDialog extends React.Component {

    {_t( "Please forget all messages I have sent when my account is deactivated " + @@ -235,7 +251,3 @@ export default class DeactivateAccountDialog extends React.Component { ); } } - -DeactivateAccountDialog.propTypes = { - onFinished: PropTypes.func.isRequired, -}; diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index fdbf6a36fc..de958f8e9a 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -62,13 +62,13 @@ abstract class GenericEditor< } 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}); - } + this.setState({ [e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }); + }; protected abstract send(); @@ -119,7 +119,7 @@ export class SendCustomEvent extends GenericEditor { !this.state.message && } - { showTglFlip &&

    + { showTglFlip &&
    { !this.state.message && } - { !this.state.message &&
    + { !this.state.message &&
    { 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) { @@ -525,11 +525,11 @@ class RoomStateExplorer extends React.PureComponent { @@ -570,19 +570,19 @@ class AccountDataExplorer extends React.PureComponent) => { - this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); - } + 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) { @@ -630,7 +630,7 @@ class AccountDataExplorer extends React.PureComponent
    -
    +
    { this.setState({ query }); - } + }; render() { return
    @@ -704,7 +704,7 @@ const PHASE_MAP = { const VerificationRequestExplorer: React.FC<{ txnId: string; request: VerificationRequest; -}> = ({txnId, request}) => { +}> = ({ txnId, request }) => { const [, updateState] = useState(); const [timeout, setRequestTimeout] = useState(request.timeout); @@ -739,7 +739,7 @@ const VerificationRequestExplorer: React.FC<{
    {JSON.stringify(request.observeOnly)}
    ); -} +}; class VerificationExplorer extends React.PureComponent { static getLabel() { @@ -751,7 +751,7 @@ class VerificationExplorer extends React.PureComponent { private onNewRequest = () => { this.forceUpdate(); - } + }; componentDidMount() { const cli = this.context; @@ -766,7 +766,7 @@ class VerificationExplorer extends React.PureComponent { render() { const cli = this.context; const room = this.props.room; - const inRoomChannel = cli.crypto._inRoomVerificationRequests; + const inRoomChannel = cli.crypto.inRoomVerificationRequests; const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map(); return (
    @@ -806,17 +806,17 @@ class WidgetExplorer extends React.Component { - this.setState({query}); + this.setState({ query }); }; private onEditWidget = (widget: IApp) => { - this.setState({editWidget: widget}); + 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}); + this.setState({ editWidget: null }); } else { this.props.onBack(); } @@ -908,22 +908,22 @@ class SettingsExplorer extends React.PureComponent) => { - this.setState({query: ev.target.value}); + this.setState({ query: ev.target.value }); }; private onExplValuesEdit = (ev: ChangeEvent) => { - this.setState({explicitValues: ev.target.value}); + this.setState({ explicitValues: ev.target.value }); }; private onExplRoomValuesEdit = (ev: ChangeEvent) => { - this.setState({explicitRoomValues: ev.target.value}); + this.setState({ explicitRoomValues: ev.target.value }); }; private onBack = () => { if (this.state.editSetting) { - this.setState({editSetting: null}); + this.setState({ editSetting: null }); } else if (this.state.viewSetting) { - this.setState({viewSetting: null}); + this.setState({ viewSetting: null }); } else { this.props.onBack(); } @@ -931,7 +931,7 @@ class SettingsExplorer extends React.PureComponent { ev.preventDefault(); - this.setState({viewSetting: settingId}); + this.setState({ viewSetting: settingId }); }; private onEditClick = (ev: MouseEvent, settingId: string) => { @@ -1221,11 +1221,11 @@ export default class DevtoolsDialog extends React.PureComponent private onBack = () => { this.setState({ mode: null }); - } + }; private onCancel = () => { this.props.onFinished(false); - } + }; render() { let body; diff --git a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx index ee3696b427..217e4f2d37 100644 --- a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx +++ b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx @@ -23,8 +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"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; interface IProps extends IDialogProps { communityId: string; @@ -60,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) => { @@ -71,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); } @@ -122,7 +122,7 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent; } else { - preview =
    + preview =
    ; } } @@ -144,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 78% rename from src/components/views/dialogs/ErrorDialog.js rename to src/components/views/dialogs/ErrorDialog.tsx index 5197c68b5a..0f675f0df7 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.tsx @@ -26,37 +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 { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + onFinished: (success: boolean) => void; + title?: string; + description?: React.ReactNode; + button?: string; + focus?: boolean; + headerImage?: string; +} + +interface IState { + onFinished: (success: boolean) => void; +} @replaceableComponent("views.dialogs.ErrorDialog") -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, - }; - - static defaultProps = { +export default class ErrorDialog extends React.Component { + public static defaultProps = { focus: true, title: null, description: null, button: null, }; - onClick = () => { + private onClick = () => { this.props.onFinished(true); }; - render() { + public render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( { const [rating, setRating] = useState(""); const [comment, setComment] = useState(""); diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index 1c90dca432..ba06436ae2 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -14,31 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useMemo, useState, useEffect} from "react"; +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 { 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 { _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 { 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 { avatarUrlForUser } from "../../../Avatar"; import EventTile from "../rooms/EventTile"; import SearchBox from "../../structures/SearchBox"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; -import {Alignment} from '../elements/Tooltip'; +import { Alignment } from '../elements/Tooltip'; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState"; +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 { 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"; const AVATAR_SIZE = 30; @@ -162,6 +166,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr }); mockEvent.sender = { name: profileInfo.displayname || userId, + rawDisplayName: profileInfo.displayname, userId, getAvatarUrl: (..._) => { return avatarUrlForUser( @@ -170,7 +175,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr ); }, getMxcAvatarUrl: () => profileInfo.avatar_url, - }; + } as RoomMember; const [query, setQuery] = useState(""); const lcQuery = query.toLowerCase(); @@ -194,6 +199,17 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr }).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 = ({ matrixClient: cli, event, permalinkCr { rooms.length > 0 ? (
    - { rooms.map(room => - , - ) } + rooms.slice(start, end).map(room => + , + )} + getChildCount={() => rooms.length} + />
    ) : { _t("No results") } diff --git a/src/components/views/dialogs/HostSignupDialog.tsx b/src/components/views/dialogs/HostSignupDialog.tsx index c8bc907136..64c080bf01 100644 --- a/src/components/views/dialogs/HostSignupDialog.tsx +++ b/src/components/views/dialogs/HostSignupDialog.tsx @@ -31,7 +31,7 @@ import { IPostmessageResponseData, PostmessageAction, } from "./HostSignupDialogTypes"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; const HOST_SIGNUP_KEY = "host_signup"; @@ -86,7 +86,7 @@ export default class HostSignupDialog extends React.PureComponent { this.setState({ @@ -96,7 +96,7 @@ export default class HostSignupDialog extends React.PureComponent { this.setState({ @@ -106,7 +106,7 @@ export default class HostSignupDialog extends React.PureComponent { window.removeEventListener("message", this.messageHandler); @@ -114,7 +114,7 @@ export default class HostSignupDialog extends React.PureComponent { if (this.state.completed) { @@ -137,16 +137,16 @@ export default class HostSignupDialog extends React.PureComponent { 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.") + 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."), @@ -171,7 +171,7 @@ export default class HostSignupDialog extends React.PureComponent { const textComponent = ( @@ -215,7 +215,7 @@ export default class HostSignupDialog extends React.PureComponent { - this.setState({phase: PHASE_WAIT_FOR_PARTNER_TO_CONFIRM}); + this.setState({ phase: PHASE_WAIT_FOR_PARTNER_TO_CONFIRM }); this.props.verifier.verify().then(() => { - this.setState({phase: PHASE_VERIFIED}); + this.setState({ phase: PHASE_VERIFIED }); }).catch((e) => { console.log("Verification failed", e); }); diff --git a/src/components/views/dialogs/IntegrationsDisabledDialog.js b/src/components/views/dialogs/IntegrationsDisabledDialog.js index dd7a51420e..1e2ff09196 100644 --- a/src/components/views/dialogs/IntegrationsDisabledDialog.js +++ b/src/components/views/dialogs/IntegrationsDisabledDialog.js @@ -16,11 +16,11 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {_t} from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import * as sdk from "../../../index"; import dis from '../../../dispatcher/dispatcher'; -import {Action} from "../../../dispatcher/actions"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Action } from "../../../dispatcher/actions"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.dialogs.IntegrationsDisabledDialog") export default class IntegrationsDisabledDialog extends React.Component { diff --git a/src/components/views/dialogs/IntegrationsImpossibleDialog.js b/src/components/views/dialogs/IntegrationsImpossibleDialog.js index e14d40aaef..2cf9daa7ea 100644 --- a/src/components/views/dialogs/IntegrationsImpossibleDialog.js +++ b/src/components/views/dialogs/IntegrationsImpossibleDialog.js @@ -16,10 +16,10 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {_t} from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; import * as sdk from "../../../index"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.dialogs.IntegrationsImpossibleDialog") export default class IntegrationsImpossibleDialog extends React.Component { diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index 28a9bf673a..09da72cab0 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -23,9 +23,9 @@ import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; -import {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth"; -import {SSOAuthEntry} from "../auth/InteractiveAuthEntryComponents"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { ERROR_USER_CANCELLED } from "../../structures/InteractiveAuth"; +import { SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.dialogs.InteractiveAuthDialog") export default class InteractiveAuthDialog extends React.Component { @@ -117,7 +117,7 @@ export default class InteractiveAuthDialog extends React.Component { _onUpdateStagePhase = (newStage, newPhase) => { // We copy the stage and stage phase params into state for title selection in render() - this.setState({uiaStage: newStage, uiaStagePhase: newPhase}); + this.setState({ uiaStage: newStage, uiaStagePhase: newPhase }); }; _onDismissClick = () => { diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index b006205f11..bbb5f24162 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -14,50 +14,65 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; -import {_t, _td} from "../../../languageHandler"; +import React, { createRef } from 'react'; +import classNames from 'classnames'; + +import { _t, _td } from "../../../languageHandler"; import * as sdk from "../../../index"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { makeRoomPermalink, makeUserPermalink } from "../../../utils/permalinks/Permalinks"; import DMRoomMap from "../../../utils/DMRoomMap"; -import {RoomMember} from "matrix-js-sdk/src/models/room-member"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import SdkConfig from "../../../SdkConfig"; import * as Email from "../../../email"; -import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils"; -import {abbreviateUrl} from "../../../utils/UrlUtils"; +import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from "../../../utils/IdentityServerUtils"; +import { abbreviateUrl } from "../../../utils/UrlUtils"; import dis from "../../../dispatcher/dispatcher"; import IdentityAuthClient from "../../../IdentityAuthClient"; import Modal from "../../../Modal"; -import {humanizeTime} from "../../../utils/humanize"; +import { humanizeTime } from "../../../utils/humanize"; import createRoom, { - canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted, - IInvite3PID, + canEncryptToAllUsers, + ensureDMExists, + findDMForUser, + privateShouldBeEncrypted, } from "../../../createRoom"; -import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite"; -import {Key} from "../../../Keyboard"; -import {Action} from "../../../dispatcher/actions"; -import {DefaultTagID} from "../../../stores/room-list/models"; +import { + IInviteResult, + inviteMultipleToRoom, + showAnyInviteErrors, + showCommunityInviteDialog, +} from "../../../RoomInvite"; +import { Key } from "../../../Keyboard"; +import { Action } from "../../../dispatcher/actions"; +import { DefaultTagID } from "../../../stores/room-list/models"; import RoomListStore from "../../../stores/room-list/RoomListStore"; -import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; +import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import SettingsStore from "../../../settings/SettingsStore"; -import {UIFeature} from "../../../settings/UIFeature"; +import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; -import {Room} from "matrix-js-sdk/src/models/room"; +import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; -import {getAddressType} from "../../../UserAddress"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; +import { getAddressType } from "../../../UserAddress"; import BaseAvatar from '../avatars/BaseAvatar'; import AccessibleButton from '../elements/AccessibleButton'; import { compare } from '../../../utils/strings'; +import { IInvite3PID } from "matrix-js-sdk/src/@types/requests"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import { copyPlaintext, selectText } from "../../../utils/strings"; +import * as ContextMenu from "../../structures/ContextMenu"; +import { toRightOf } from "../../structures/ContextMenu"; +import GenericTextContextMenu from "../context_menus/GenericTextContextMenu"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ interface IRecentUser { - userId: string, - user: RoomMember, - lastActive: number, + userId: string; + user: RoomMember; + lastActive: number; } export const KIND_DM = "dm"; @@ -67,10 +82,10 @@ export const KIND_CALL_TRANSFER = "call_transfer"; const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked -// This is the interface that is expected by various components in this file. It is a bit -// awkward because it also matches the RoomMember class from the js-sdk with some extra support +// This is the interface that is expected by various components in the Invite Dialog and RoomInvite. +// It is a bit awkward because it also matches the RoomMember class from the js-sdk with some extra support // for 3PIDs/email addresses. -abstract class Member { +export abstract class Member { /** * The display name of this Member. For users this should be their profile's display * name or user ID if none set. For 3PIDs this should be the 3PID address (email). @@ -95,7 +110,8 @@ class DirectoryMember extends Member { private readonly displayName: string; private readonly avatarUrl: string; - constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) { + // eslint-disable-next-line camelcase + constructor(userDirResult: { user_id: string, display_name: string, avatar_url: string }) { super(); this._userId = userDirResult.user_id; this.displayName = userDirResult.display_name; @@ -146,8 +162,8 @@ class ThreepidMember extends Member { } interface IDMUserTileProps { - member: RoomMember; - onRemove(member: RoomMember): void; + member: Member; + onRemove(member: Member): void; } class DMUserTile extends React.PureComponent { @@ -161,7 +177,7 @@ class DMUserTile extends React.PureComponent { render() { const avatarSize = 20; - const avatar = this.props.member.isEmail + const avatar = (this.props.member as ThreepidMember).isEmail ? { } interface IDMRoomTileProps { - member: RoomMember; + member: Member; lastActiveTs: number; - onToggle(member: RoomMember): void; + onToggle(member: Member): void; highlightWord: string; isSelected: boolean; } @@ -263,7 +279,7 @@ class DMRoomTile extends React.PureComponent { } const avatarSize = 36; - const avatar = this.props.member.isEmail + const avatar = (this.props.member as ThreepidMember).isEmail ? @@ -291,7 +307,7 @@ class DMRoomTile extends React.PureComponent { ); - const caption = this.props.member.isEmail + const caption = (this.props.member as ThreepidMember).isEmail ? _t("Invite by email") : this.highlightName(this.props.member.userId); @@ -314,20 +330,20 @@ interface IInviteDialogProps { // The kind of invite being performed. Assumed to be KIND_DM if // not provided. - kind: string, + kind: string; // The room ID this dialog is for. Only required for KIND_INVITE. - roomId: string, + roomId: string; // The call to transfer. Only required for KIND_CALL_TRANSFER. - call: MatrixCall, + call: MatrixCall; // Initial value to populate the filter with - initialText: string, + initialText: string; } interface IInviteDialogState { - targets: RoomMember[]; // array of Member objects (see interface above) + targets: Member[]; // array of Member objects (see interface above) filterText: string; recents: { user: Member, userId: string }[]; numRecentsShown: number; @@ -340,8 +356,8 @@ interface IInviteDialogState { consultFirst: boolean; // These two flags are used for the 'Go' button to communicate what is going on. - busy: boolean, - errorText: string, + busy: boolean; + errorText: string; } @replaceableComponent("views.dialogs.InviteDialog") @@ -351,6 +367,7 @@ export default class InviteDialog extends React.PureComponent void; private debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser private editorRef = createRef(); private unmounted = false; @@ -403,11 +420,14 @@ export default class InviteDialog extends React.PureComponent { - this.setState({consultFirst: ev.target.checked}); - } + this.setState({ consultFirst: ev.target.checked }); + }; public static buildRecents(excludedTargetIds: Set): IRecentUser[] { const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room @@ -462,7 +482,7 @@ export default class InviteDialog extends React.PureComponent ({userId: m.member.userId, user: m.member})); + return members.map(m => ({ userId: m.member.userId, user: m.member })); } - private shouldAbortAfterInviteError(result): boolean { - const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error'); - if (failedUsers.length > 0) { - console.log("Failed to invite users: ", result); - this.setState({ - busy: false, - errorText: _t("Failed to invite the following users to chat: %(csvUsers)s", { - csvUsers: failedUsers.join(", "), - }), - }); - return true; // abort - } - return false; + private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean { + this.setState({ busy: false }); + const userMap = new Map(this.state.targets.map(member => [member.userId, member])); + return !showAnyInviteErrors(result.states, room, result.inviter, userMap); } private convertFilter(): Member[] { @@ -612,18 +623,18 @@ export default class InviteDialog extends React.PureComponent { - this.setState({busy: true}); + this.setState({ busy: true }); const client = MatrixClientPeg.get(); const targets = this.convertFilter(); const targetIds = targets.map(t => t.userId); @@ -646,7 +657,7 @@ export default class InviteDialog extends React.PureComponent { const startTime = CountlyAnalytics.getTimestamp(); - this.setState({busy: true}); + this.setState({ busy: true }); this.convertFilter(); const targets = this.convertFilter(); const targetIds = targets.map(t => t.userId); @@ -718,9 +729,9 @@ export default class InviteDialog extends React.PureComponent { - MatrixClientPeg.get().searchUserDirectory({term}).then(async r => { + MatrixClientPeg.get().searchUserDirectory({ term }).then(async r => { if (term !== this.state.filterText) { // Discard the results - we were probably too slow on the server-side to make // these results useful. This is a race we want to avoid because we could overwrite @@ -863,14 +874,14 @@ export default class InviteDialog extends React.PureComponent { console.error("Error searching user directory:"); console.error(e); - this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal + this.setState({ serverResultsMixin: [] }); // clear results because it's moderately fatal }); // Whenever we search the directory, also try to search the identity server. It's // all debounced the same anyways. if (!this.state.canUseIdentityServer) { // The user doesn't have an identity server set - warn them of that. - this.setState({tryingIdentityServer: true}); + this.setState({ tryingIdentityServer: true }); return; } if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) { @@ -878,7 +889,7 @@ export default class InviteDialog extends React.PureComponent { const term = e.target.value; - this.setState({filterText: term}); + this.setState({ filterText: term }); // Debounce server lookups to reduce spam. We don't clear the existing server // results because they might still be vaguely accurate, likewise for races which @@ -939,11 +950,11 @@ export default class InviteDialog extends React.PureComponent { - this.setState({numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN}); + this.setState({ numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN }); }; private showMoreSuggestions = () => { - this.setState({numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN}); + this.setState({ numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN }); }; private toggleMember = (member: Member) => { @@ -957,7 +968,7 @@ export default class InviteDialog extends React.PureComponent= 0) { targets.splice(idx, 1); - this.setState({targets}); + this.setState({ targets }); } if (this.editorRef && this.editorRef.current) { @@ -1040,13 +1051,13 @@ export default class InviteDialog extends React.PureComponent { @@ -1065,7 +1076,7 @@ export default class InviteDialog extends React.PureComponent { @@ -1089,7 +1100,7 @@ export default class InviteDialog extends React.PureComponent { + e.preventDefault(); + const target = e.target; // copy target before we go async and React throws it away + + const successful = await copyPlaintext(makeUserPermalink(MatrixClientPeg.get().getUserId())); + const buttonRect = target.getBoundingClientRect(); + const { close } = ContextMenu.createMenu(GenericTextContextMenu, { + ...toRightOf(buttonRect, 2), + message: successful ? _t("Copied!") : _t("Failed to copy"), + }); + // Drop a reference to this close handler for componentWillUnmount + this.closeCopiedTooltip = target.onmouseleave = close; + }; + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); @@ -1248,12 +1278,12 @@ export default class InviteDialog extends React.PureComponent; } - let title; let helpText; let buttonText; let goButtonFn; - let consultSection; + let extraSection; + let footer; let keySharingWarning = ; const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); @@ -1267,21 +1297,21 @@ export default class InviteDialog extends React.PureComponent).", {}, - {userId: () => { + { userId: () => { return ( {userId} ); - }}, + } }, ); } else { helpText = _t( "Start a conversation with someone using their name or username (like ).", {}, - {userId: () => { + { userId: () => { return ( {userId} ); - }}, + } }, ); } @@ -1290,7 +1320,7 @@ export default class InviteDialog extends React.PureComponenthere
    ", - {communityName}, { + { communityName }, { userId: () => { return ( + { _t("Some suggestions may be hidden for privacy.") } +

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

    +
    ; + const link = makeUserPermalink(MatrixClientPeg.get().getUserId()); + footer =
    +

    { _t("Or send invite link") }

    +
    ; } else if (this.props.kind === KIND_INVITE) { const room = MatrixClientPeg.get()?.getRoom(this.props.roomId); const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom(); @@ -1377,7 +1427,7 @@ export default class InviteDialog extends React.PureComponent + footer =
    - {consultSection} + {footer}
    ); diff --git a/src/components/views/dialogs/KeySignatureUploadFailedDialog.js b/src/components/views/dialogs/KeySignatureUploadFailedDialog.js index bcb4d4f9b9..22487af17c 100644 --- a/src/components/views/dialogs/KeySignatureUploadFailedDialog.js +++ b/src/components/views/dialogs/KeySignatureUploadFailedDialog.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useState, useCallback, useRef} from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; diff --git a/src/components/views/dialogs/LogoutDialog.js b/src/components/views/dialogs/LogoutDialog.js index 7bced46d43..bd9411358b 100644 --- a/src/components/views/dialogs/LogoutDialog.js +++ b/src/components/views/dialogs/LogoutDialog.js @@ -22,7 +22,7 @@ import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.dialogs.LogoutDialog") export default class LogoutDialog extends React.Component { @@ -85,7 +85,7 @@ export default class LogoutDialog extends React.Component { _onFinished(confirmed) { if (confirmed) { - dis.dispatch({action: 'logout'}); + dis.dispatch({ action: 'logout' }); } // close dialog this.props.onFinished(); @@ -112,7 +112,7 @@ export default class LogoutDialog extends React.Component { } _onLogoutConfirm() { - dis.dispatch({action: 'logout'}); + dis.dispatch({ action: 'logout' }); // close dialog this.props.onFinished(); diff --git a/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.js b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.js index 3151edd796..4387108fac 100644 --- a/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.js +++ b/src/components/views/dialogs/ManualDeviceKeyVerificationDialog.js @@ -20,11 +20,11 @@ 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 * as FormattingUtils from '../../../utils/FormattingUtils'; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.dialogs.ManualDeviceKeyVerificationDialog") export default class ManualDeviceKeyVerificationDialog extends React.Component { diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.js b/src/components/views/dialogs/MessageEditHistoryDialog.js index 64d66fe833..b9225f5932 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.js +++ b/src/components/views/dialogs/MessageEditHistoryDialog.js @@ -16,12 +16,12 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { _t } from '../../../languageHandler'; import * as sdk from "../../../index"; -import {wantsDateSeparator} from '../../../DateUtils'; +import { wantsDateSeparator } from '../../../DateUtils'; import SettingsStore from '../../../settings/SettingsStore'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.dialogs.MessageEditHistoryDialog") export default class MessageEditHistoryDialog extends React.PureComponent { @@ -46,7 +46,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent { // bail out on backwards as we only paginate in one direction return false; } - const opts = {from: this.state.nextBatch}; + const opts = { from: this.state.nextBatch }; const roomId = this.props.mxEvent.getRoomId(); const eventId = this.props.mxEvent.getId(); const client = MatrixClientPeg.get(); @@ -62,7 +62,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent { if (error.errcode) { console.error("fetching /relations failed with error", error); } - this.setState({error}, () => reject(error)); + this.setState({ error }, () => reject(error)); return promise; } @@ -131,7 +131,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent { render() { let content; if (this.state.error) { - const {error} = this.state; + const { error } = this.state; if (error.errcode === "M_UNRECOGNIZED") { content = (

    {_t("Your homeserver doesn't seem to support this feature.")} diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 0c474b160c..6bc84b66b4 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -33,13 +33,13 @@ import { WidgetApiFromWidgetAction, WidgetKind, } from "matrix-widget-api"; -import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {OwnProfileStore} from "../../../stores/OwnProfileStore"; +import { StopGapWidgetDriver } from "../../../stores/widgets/StopGapWidgetDriver"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { arrayFastClone } from "../../../utils/arrays"; import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {ELEMENT_CLIENT_ID} from "../../../identifiers"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { ELEMENT_CLIENT_ID } from "../../../identifiers"; import SettingsStore from "../../../settings/SettingsStore"; interface IProps { @@ -63,7 +63,8 @@ export default class ModalWidgetDialog extends React.PureComponent = React.createRef(); state: IState = { - disabledButtonIds: [], + disabledButtonIds: (this.props.widgetDefinition.buttons || []).filter(b => b.disabled) + .map(b => b.id), }; constructor(props) { @@ -80,7 +81,7 @@ export default class ModalWidgetDialog extends React.PureComponent) => { this.props.onFinished(true, ev.detail.data); - } + }; private onButtonEnableToggle = (ev: CustomEvent) => { ev.preventDefault(); const isClose = ev.detail.data.button === BuiltInModalButtonID.Close; if (isClose || !this.possibleButtons.includes(ev.detail.data.button)) { return this.state.messaging.transport.reply(ev.detail, { - error: {message: "Invalid button"}, + error: { message: "Invalid button" }, } as IWidgetApiErrorResponseData); } @@ -121,7 +122,7 @@ export default class ModalWidgetDialog extends React.PureComponent { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog("Verification failed", "insecure", ErrorDialog, { - headerImage: require("../../../../res/img/e2e/warning.svg"), - title: _t("Your account is not secure"), - description:

    - {_t("One of the following may be compromised:")} -
      -
    • {_t("Your password")}
    • -
    • {_t("Your homeserver")}
    • -
    • {_t("This session, or the other session")}
    • -
    • {_t("The internet connection either session is using")}
    • -
    -
    - {_t("We recommend you change your password and Security Key in Settings immediately")} -
    -
    , - onFinished: () => this.props.onFinished(false), - }); - } - - onContinueClick = () => { - const { userId, device } = this.props; - const cli = MatrixClientPeg.get(); - const requestPromise = cli.requestVerification( - userId, - [device.deviceId], - ); - - 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(); - }, - }); - } - - render() { - const { device } = this.props; - - const icon = ; - const titleText = _t("New session"); - - const title =

    - {icon} - {titleText} -

    ; - - return ( - -
    -

    {_t( - "Use this session to verify your new one, " + - "granting it access to encrypted messages:", - )}

    -
    -
    - - {device.getDisplayName()} - - ({device.deviceId}) - -
    -
    -

    {_t( - "If you didn’t sign in to this session, " + - "your account may be compromised.", - )}

    - -
    -
    - ); - } -} diff --git a/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx b/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx index 3f3ad215a5..98f1a79b4f 100644 --- a/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx +++ b/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx @@ -18,7 +18,7 @@ import * as React from "react"; import { _t } from '../../../languageHandler'; import { IDialogProps } from "./IDialogProps"; -import {useRef, useState} from "react"; +import { useRef, useState } from "react"; import Field from "../elements/Field"; import CountlyAnalytics from "../../../CountlyAnalytics"; import withValidation from "../elements/Validation"; @@ -40,7 +40,7 @@ const validation = withValidation({ ], }); -const RegistrationEmailPromptDialog: React.FC = ({onFinished}) => { +const RegistrationEmailPromptDialog: React.FC = ({ onFinished }) => { const [email, setEmail] = useState(""); const fieldRef = useRef(); diff --git a/src/components/views/dialogs/ReportEventDialog.js b/src/components/views/dialogs/ReportEventDialog.js deleted file mode 100644 index 5454b97287..0000000000 --- a/src/components/views/dialogs/ReportEventDialog.js +++ /dev/null @@ -1,149 +0,0 @@ -/* -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, {PureComponent} from 'react'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import PropTypes from "prop-types"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import SdkConfig from '../../../SdkConfig'; -import Markdown from '../../../Markdown'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; - -/* - * A dialog for reporting an event. - */ -@replaceableComponent("views.dialogs.ReportEventDialog") -export default class ReportEventDialog extends PureComponent { - static propTypes = { - mxEvent: PropTypes.instanceOf(MatrixEvent).isRequired, - onFinished: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - - this.state = { - reason: "", - busy: false, - err: null, - }; - } - - _onReasonChange = ({target: {value: reason}}) => { - this.setState({ reason }); - }; - - _onCancel = () => { - this.props.onFinished(false); - }; - - _onSubmit = async () => { - if (!this.state.reason || !this.state.reason.trim()) { - this.setState({ - err: _t("Please fill why you're reporting."), - }); - return; - } - - this.setState({ - busy: true, - err: null, - }); - - try { - const ev = this.props.mxEvent; - await MatrixClientPeg.get().reportEvent(ev.getRoomId(), ev.getId(), -100, this.state.reason.trim()); - this.props.onFinished(true); - } catch (e) { - this.setState({ - busy: false, - err: e.message, - }); - } - }; - - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const Loader = sdk.getComponent('elements.Spinner'); - const Field = sdk.getComponent('elements.Field'); - - let error = null; - if (this.state.err) { - error =
    - {this.state.err} -
    ; - } - - let progress = null; - if (this.state.busy) { - progress = ( -
    - -
    - ); - } - - const adminMessageMD = - SdkConfig.get().reportEvent && - SdkConfig.get().reportEvent.adminMessageMD; - let adminMessage; - if (adminMessageMD) { - const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true }); - adminMessage =

    ; - } - - return ( - -

    -

    - { - _t("Reporting this message will send its unique 'event ID' to the administrator of " + - "your homeserver. If messages in this room are encrypted, your homeserver " + - "administrator will not be able to read the message text or view any files or images.") - } -

    - {adminMessage} - - {progress} - {error} -
    - - - ); - } -} diff --git a/src/components/views/dialogs/ReportEventDialog.tsx b/src/components/views/dialogs/ReportEventDialog.tsx new file mode 100644 index 0000000000..81a7d569fe --- /dev/null +++ b/src/components/views/dialogs/ReportEventDialog.tsx @@ -0,0 +1,444 @@ +/* +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import * as sdk from '../../../index'; +import { _t } from '../../../languageHandler'; +import { ensureDMExists } from "../../../createRoom"; +import { IDialogProps } from "./IDialogProps"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import SdkConfig from '../../../SdkConfig'; +import Markdown from '../../../Markdown'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import SettingsStore from "../../../settings/SettingsStore"; +import StyledRadioButton from "../elements/StyledRadioButton"; + +interface IProps extends IDialogProps { + mxEvent: MatrixEvent; +} + +interface IState { + // A free-form text describing the abuse. + reason: string; + busy: boolean; + err?: string; + // If we know it, the nature of the abuse, as specified by MSC3215. + nature?: EXTENDED_NATURE; +} + +const MODERATED_BY_STATE_EVENT_TYPE = [ + "org.matrix.msc3215.room.moderation.moderated_by", + /** + * Unprefixed state event. Not ready for prime time. + * + * "m.room.moderation.moderated_by" + */ +]; + +const ABUSE_EVENT_TYPE = "org.matrix.msc3215.abuse.report"; + +// Standard abuse natures. +enum NATURE { + DISAGREEMENT = "org.matrix.msc3215.abuse.nature.disagreement", + TOXIC = "org.matrix.msc3215.abuse.nature.toxic", + ILLEGAL = "org.matrix.msc3215.abuse.nature.illegal", + SPAM = "org.matrix.msc3215.abuse.nature.spam", + OTHER = "org.matrix.msc3215.abuse.nature.other", +} + +enum NON_STANDARD_NATURE { + // Non-standard abuse nature. + // It should never leave the client - we use it to fallback to + // server-wide abuse reporting. + ADMIN = "non-standard.abuse.nature.admin" +} + +type EXTENDED_NATURE = NATURE | NON_STANDARD_NATURE; + +type Moderation = { + // The id of the moderation room. + moderationRoomId: string; + // The id of the bot in charge of forwarding abuse reports to the moderation room. + moderationBotUserId: string; +}; +/* + * A dialog for reporting an event. + * + * The actual content of the dialog will depend on two things: + * + * 1. Is `feature_report_to_moderators` enabled? + * 2. Does the room support moderation as per MSC3215, i.e. is there + * a well-formed state event `m.room.moderation.moderated_by` + * /`org.matrix.msc3215.room.moderation.moderated_by`? + */ +@replaceableComponent("views.dialogs.ReportEventDialog") +export default class ReportEventDialog extends React.Component { + // If the room supports moderation, the moderation information. + private moderation?: Moderation; + + constructor(props: IProps) { + super(props); + + let moderatedByRoomId = null; + let moderatedByUserId = null; + + if (SettingsStore.getValue("feature_report_to_moderators")) { + // The client supports reporting to moderators. + // Does the room support it, too? + + // Extract state events to determine whether we should display + const client = MatrixClientPeg.get(); + const room = client.getRoom(props.mxEvent.getRoomId()); + + for (const stateEventType of MODERATED_BY_STATE_EVENT_TYPE) { + const stateEvent = room.currentState.getStateEvents(stateEventType, stateEventType); + if (!stateEvent) { + continue; + } + if (Array.isArray(stateEvent)) { + // Internal error. + throw new TypeError(`getStateEvents(${stateEventType}, ${stateEventType}) ` + + "should return at most one state event"); + } + const event = stateEvent.event; + if (!("content" in event) || typeof event["content"] != "object") { + // The room is improperly configured. + // Display this debug message for the sake of moderators. + console.debug("Moderation error", "state event", stateEventType, + "should have an object field `content`, got", event); + continue; + } + const content = event["content"]; + if (!("room_id" in content) || typeof content["room_id"] != "string") { + // The room is improperly configured. + // Display this debug message for the sake of moderators. + console.debug("Moderation error", "state event", stateEventType, + "should have a string field `content.room_id`, got", event); + continue; + } + if (!("user_id" in content) || typeof content["user_id"] != "string") { + // The room is improperly configured. + // Display this debug message for the sake of moderators. + console.debug("Moderation error", "state event", stateEventType, + "should have a string field `content.user_id`, got", event); + continue; + } + moderatedByRoomId = content["room_id"]; + moderatedByUserId = content["user_id"]; + } + + if (moderatedByRoomId && moderatedByUserId) { + // The room supports moderation. + this.moderation = { + moderationRoomId: moderatedByRoomId, + moderationBotUserId: moderatedByUserId, + }; + } + } + + this.state = { + // A free-form text describing the abuse. + reason: "", + busy: false, + err: null, + // If specified, the nature of the abuse, as specified by MSC3215. + nature: null, + }; + } + + // The user has written down a freeform description of the abuse. + private onReasonChange = ({ target: { value: reason } }): void => { + this.setState({ reason }); + }; + + // The user has clicked on a nature. + private onNatureChosen = (e: React.FormEvent): void => { + this.setState({ nature: e.currentTarget.value as EXTENDED_NATURE }); + }; + + // The user has clicked "cancel". + private onCancel = (): void => { + this.props.onFinished(false); + }; + + // The user has clicked "submit". + private onSubmit = async () => { + let reason = this.state.reason || ""; + reason = reason.trim(); + if (this.moderation) { + // This room supports moderation. + // We need a nature. + // If the nature is `NATURE.OTHER` or `NON_STANDARD_NATURE.ADMIN`, we also need a `reason`. + if (!this.state.nature || + ((this.state.nature == NATURE.OTHER || this.state.nature == NON_STANDARD_NATURE.ADMIN) + && !reason) + ) { + this.setState({ + err: _t("Please fill why you're reporting."), + }); + return; + } + } else { + // This room does not support moderation. + // We need a `reason`. + if (!reason) { + this.setState({ + err: _t("Please fill why you're reporting."), + }); + return; + } + } + + this.setState({ + busy: true, + err: null, + }); + + try { + const client = MatrixClientPeg.get(); + const ev = this.props.mxEvent; + if (this.moderation && this.state.nature != NON_STANDARD_NATURE.ADMIN) { + const nature: NATURE = this.state.nature; + + // Report to moderators through to the dedicated bot, + // as configured in the room's state events. + const dmRoomId = await ensureDMExists(client, this.moderation.moderationBotUserId); + await client.sendEvent(dmRoomId, ABUSE_EVENT_TYPE, { + event_id: ev.getId(), + room_id: ev.getRoomId(), + moderated_by_id: this.moderation.moderationRoomId, + nature, + reporter: client.getUserId(), + comment: this.state.reason.trim(), + }); + } else { + // Report to homeserver admin through the dedicated Matrix API. + await client.reportEvent(ev.getRoomId(), ev.getId(), -100, this.state.reason.trim()); + } + this.props.onFinished(true); + } catch (e) { + this.setState({ + busy: false, + err: e.message, + }); + } + }; + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const Loader = sdk.getComponent('elements.Spinner'); + const Field = sdk.getComponent('elements.Field'); + + let error = null; + if (this.state.err) { + error =
    + {this.state.err} +
    ; + } + + let progress = null; + if (this.state.busy) { + progress = ( +
    + +
    + ); + } + + const adminMessageMD = + SdkConfig.get().reportEvent && + SdkConfig.get().reportEvent.adminMessageMD; + let adminMessage; + if (adminMessageMD) { + const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true }); + adminMessage =

    ; + } + + if (this.moderation) { + // Display report-to-moderator dialog. + // We let the user pick a nature. + const client = MatrixClientPeg.get(); + const homeServerName = SdkConfig.get()["validated_server_config"].hsName; + let subtitle; + switch (this.state.nature) { + case NATURE.DISAGREEMENT: + subtitle = _t("What this user is writing is wrong.\n" + + "This will be reported to the room moderators."); + break; + case NATURE.TOXIC: + subtitle = _t("This user is displaying toxic behaviour, " + + "for instance by insulting other users or sharing " + + " adult-only content in a family-friendly room " + + " or otherwise violating the rules of this room.\n" + + "This will be reported to the room moderators."); + break; + case NATURE.ILLEGAL: + subtitle = _t("This user is displaying illegal behaviour, " + + "for instance by doxing people or threatening violence.\n" + + "This will be reported to the room moderators who may escalate this to legal authorities."); + break; + case NATURE.SPAM: + subtitle = _t("This user is spamming the room with ads, links to ads or to propaganda.\n" + + "This will be reported to the room moderators."); + break; + case NON_STANDARD_NATURE.ADMIN: + if (client.isRoomEncrypted(this.props.mxEvent.getRoomId())) { + subtitle = _t("This room is dedicated to illegal or toxic content " + + "or the moderators fail to moderate illegal or toxic content.\n" + + "This will be reported to the administrators of %(homeserver)s. " + + "The administrators will NOT be able to read the encrypted content of this room.", + { homeserver: homeServerName }); + } else { + subtitle = _t("This room is dedicated to illegal or toxic content " + + "or the moderators fail to moderate illegal or toxic content.\n" + + " This will be reported to the administrators of %(homeserver)s.", + { homeserver: homeServerName }); + } + break; + case NATURE.OTHER: + subtitle = _t("Any other reason. Please describe the problem.\n" + + "This will be reported to the room moderators."); + break; + default: + subtitle = _t("Please pick a nature and describe what makes this message abusive."); + break; + } + + return ( + +

    + + {_t('Disagree')} + + + {_t('Toxic Behaviour')} + + + {_t('Illegal Content')} + + + {_t('Spam or propaganda')} + + + {_t('Report the entire room')} + + + {_t('Other')} + +

    + {subtitle} +

    + + {progress} + {error} +
    + + + ); + } + // Report to homeserver admin. + // Currently, the API does not support natures. + return ( + +
    +

    + { + _t("Reporting this message will send its unique 'event ID' to the administrator of " + + "your homeserver. If messages in this room are encrypted, your homeserver " + + "administrator will not be able to read the message text or view any files " + + "or images.") + } +

    + {adminMessage} + + {progress} + {error} +
    + +
    + ); + } +} diff --git a/src/components/views/dialogs/RoomSettingsDialog.tsx b/src/components/views/dialogs/RoomSettingsDialog.tsx index 1a664951c5..005222a94e 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.tsx +++ b/src/components/views/dialogs/RoomSettingsDialog.tsx @@ -16,8 +16,8 @@ limitations under the License. */ import React from 'react'; -import TabbedView, {Tab} from "../../structures/TabbedView"; -import {_t, _td} from "../../../languageHandler"; +import TabbedView, { Tab } from "../../structures/TabbedView"; +import { _t, _td } from "../../../languageHandler"; import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab"; import RolesRoomSettingsTab from "../settings/tabs/room/RolesRoomSettingsTab"; import GeneralRoomSettingsTab from "../settings/tabs/room/GeneralRoomSettingsTab"; @@ -25,11 +25,11 @@ import SecurityRoomSettingsTab from "../settings/tabs/room/SecurityRoomSettingsT import NotificationSettingsTab from "../settings/tabs/room/NotificationSettingsTab"; import BridgeSettingsTab from "../settings/tabs/room/BridgeSettingsTab"; import * as sdk from "../../../index"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; import SettingsStore from "../../../settings/SettingsStore"; -import {UIFeature} from "../../../settings/UIFeature"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { UIFeature } from "../../../settings/UIFeature"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB"; export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB"; @@ -108,7 +108,10 @@ export default class RoomSettingsDialog extends React.Component { ROOM_ADVANCED_TAB, _td("Advanced"), "mx_RoomSettingsDialog_warningIcon", - , + this.props.onFinished(true)} + />, )); } @@ -124,7 +127,7 @@ export default class RoomSettingsDialog extends React.Component { className='mx_RoomSettingsDialog' hasCancel={true} onFinished={this.props.onFinished} - title={_t("Room Settings - %(roomName)s", {roomName})} + title={_t("Room Settings - %(roomName)s", { roomName })} >
    { @@ -44,7 +44,7 @@ export default class RoomUpgradeDialog extends React.Component { }; _onUpgradeClick = () => { - this.setState({busy: true}); + this.setState({ busy: true }); MatrixClientPeg.get().upgradeRoom(this.props.room.roomId, this._targetVersion).then(() => { this.props.onFinished(true); }).catch((err) => { @@ -54,7 +54,7 @@ export default class RoomUpgradeDialog extends React.Component { description: ((err && err.message) ? err.message : _t("The room upgrade could not be completed")), }); }).finally(() => { - this.setState({busy: false}); + this.setState({ busy: false }); }); }; @@ -70,7 +70,7 @@ export default class RoomUpgradeDialog extends React.Component { buttons = { - this.props.onFinished({continue: true, invite: this.state.isPrivate && this.state.inviteUsersToNewRoom}); + this.props.onFinished({ continue: true, invite: this.state.isPrivate && this.state.inviteUsersToNewRoom }); }; _onCancel = () => { - this.props.onFinished({continue: false, invite: false}); + this.props.onFinished({ continue: false, invite: false }); }; _onInviteUsersToggle = (newVal) => { - this.setState({inviteUsersToNewRoom: newVal}); + this.setState({ inviteUsersToNewRoom: newVal }); }; _openBugReportDialog = (e) => { @@ -86,7 +86,7 @@ export default class RoomUpgradeWarningDialog extends React.Component {

    {_t( "This usually only affects how the room is processed on the server. If you're " + - "having problems with your %(brand)s, please report a bug.", {brand}, + "having problems with your %(brand)s, please report a bug.", { brand }, )}

    ); diff --git a/src/components/views/dialogs/ServerOfflineDialog.tsx b/src/components/views/dialogs/ServerOfflineDialog.tsx index 52ff056907..ebf32e9131 100644 --- a/src/components/views/dialogs/ServerOfflineDialog.tsx +++ b/src/components/views/dialogs/ServerOfflineDialog.tsx @@ -28,7 +28,7 @@ import AccessibleButton from "../elements/AccessibleButton"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { IDialogProps } from "./IDialogProps"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends IDialogProps { } @@ -85,7 +85,7 @@ export default class ServerOfflineDialog extends React.PureComponent { {entries}
    - ) + ); }); } @@ -108,7 +108,7 @@ export default class ServerOfflineDialog extends React.PureComponent { "Below are some of the most likely reasons.", )}

      -
    • {_t("The server (%(serverName)s) took too long to respond.", {serverName})}
    • +
    • {_t("The server (%(serverName)s) took too long to respond.", { serverName })}
    • {_t("Your firewall or anti-virus is blocking the request.")}
    • {_t("A browser extension is preventing the request.")}
    • {_t("The server is offline.")}
    • diff --git a/src/components/views/dialogs/ServerPickerDialog.tsx b/src/components/views/dialogs/ServerPickerDialog.tsx index 11fef9e75d..8dafd8a2bc 100644 --- a/src/components/views/dialogs/ServerPickerDialog.tsx +++ b/src/components/views/dialogs/ServerPickerDialog.tsx @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from "react"; -import {AutoDiscovery} from "matrix-js-sdk/src/autodiscovery"; +import React, { createRef } from "react"; +import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery"; -import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import BaseDialog from './BaseDialog'; import { _t } from '../../../languageHandler'; import AccessibleButton from "../elements/AccessibleButton"; @@ -25,8 +25,8 @@ import SdkConfig from "../../../SdkConfig"; import Field from "../elements/Field"; import StyledRadioButton from "../elements/StyledRadioButton"; import TextWithTooltip from "../elements/TextWithTooltip"; -import withValidation, {IFieldState} from "../elements/Validation"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import withValidation, { IFieldState } from "../elements/Validation"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { title?: string; diff --git a/src/components/views/dialogs/SeshatResetDialog.tsx b/src/components/views/dialogs/SeshatResetDialog.tsx index 63654ca949..863157ec08 100644 --- a/src/components/views/dialogs/SeshatResetDialog.tsx +++ b/src/components/views/dialogs/SeshatResetDialog.tsx @@ -15,13 +15,13 @@ limitations under the License. */ import React from 'react'; -import {_t} from "../../../languageHandler"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { _t } from "../../../languageHandler"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import BaseDialog from "./BaseDialog"; import DialogButtons from "../elements/DialogButtons"; -import {IDialogProps} from "./IDialogProps"; +import { IDialogProps } from "./IDialogProps"; @replaceableComponent("views.dialogs.SeshatResetDialog") export default class SeshatResetDialog extends React.PureComponent { diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js index 43e73a2f83..b7037d1f4f 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.js +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -22,7 +22,7 @@ import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.dialogs.SessionRestoreErrorDialog") export default class SessionRestoreErrorDialog extends React.Component { diff --git a/src/components/views/dialogs/SetEmailDialog.js b/src/components/views/dialogs/SetEmailDialog.js index 0f8f410a6a..3dad3821fb 100644 --- a/src/components/views/dialogs/SetEmailDialog.js +++ b/src/components/views/dialogs/SetEmailDialog.js @@ -22,8 +22,7 @@ import * as Email from '../../../email'; import AddThreepid from '../../../AddThreepid'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; - +import { replaceableComponent } from "../../../utils/replaceableComponent"; /* * Prompt the user to set an email address. @@ -71,14 +70,14 @@ export default class SetEmailDialog extends React.Component { onFinished: this.onEmailDialogFinished, }); }, (err) => { - this.setState({emailBusy: false}); + this.setState({ emailBusy: false }); console.error("Unable to add email address " + emailAddress + " " + err); Modal.createTrackedDialog('Unable to add email address', '', ErrorDialog, { title: _t("Unable to add email address"), description: ((err && err.message) ? err.message : _t("Operation failed")), }); }); - this.setState({emailBusy: true}); + this.setState({ emailBusy: true }); }; onCancelled = () => { @@ -89,7 +88,7 @@ export default class SetEmailDialog extends React.Component { if (ok) { this.verifyEmailAddress(); } else { - this.setState({emailBusy: false}); + this.setState({ emailBusy: false }); } }; @@ -97,7 +96,7 @@ export default class SetEmailDialog extends React.Component { this._addThreepid.checkEmailLinkClicked().then(() => { this.props.onFinished(true); }, (err) => { - this.setState({emailBusy: false}); + this.setState({ emailBusy: false }); if (err.errcode == 'M_THREEPID_AUTH_FAILED') { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const message = _t("Unable to verify email address.") + " " + diff --git a/src/components/views/dialogs/ShareDialog.tsx b/src/components/views/dialogs/ShareDialog.tsx index df1206a4f0..fb43db1a25 100644 --- a/src/components/views/dialogs/ShareDialog.tsx +++ b/src/components/views/dialogs/ShareDialog.tsx @@ -17,24 +17,24 @@ limitations under the License. import * as React from 'react'; import * as PropTypes from 'prop-types'; -import {Room} from "matrix-js-sdk/src/models/room"; -import {User} from "matrix-js-sdk/src/models/user"; -import {Group} from "matrix-js-sdk/src/models/group"; -import {RoomMember} from "matrix-js-sdk/src/models/room-member"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { User } from "matrix-js-sdk/src/models/user"; +import { Group } from "matrix-js-sdk/src/models/group"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import QRCode from "../elements/QRCode"; -import {RoomPermalinkCreator, makeGroupPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks"; +import { RoomPermalinkCreator, makeGroupPermalink, makeUserPermalink } from "../../../utils/permalinks/Permalinks"; import * as ContextMenu from "../../structures/ContextMenu"; -import {toRightOf} from "../../structures/ContextMenu"; -import {copyPlaintext, selectText} from "../../../utils/strings"; +import { toRightOf } from "../../structures/ContextMenu"; +import { copyPlaintext, selectText } from "../../../utils/strings"; import StyledCheckbox from '../elements/StyledCheckbox'; import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; import { IDialogProps } from "./IDialogProps"; import SettingsStore from "../../../settings/SettingsStore"; -import {UIFeature} from "../../../settings/UIFeature"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { UIFeature } from "../../../settings/UIFeature"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; const socials = [ { @@ -120,7 +120,7 @@ export default class ShareDialog extends React.PureComponent { const successful = await copyPlaintext(this.getUrl()); const buttonRect = target.getBoundingClientRect(); const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); - const {close} = ContextMenu.createMenu(GenericTextContextMenu, { + const { close } = ContextMenu.createMenu(GenericTextContextMenu, { ...toRightOf(buttonRect, 2), message: successful ? _t('Copied!') : _t('Failed to copy'), }); diff --git a/src/components/views/dialogs/SlashCommandHelpDialog.js b/src/components/views/dialogs/SlashCommandHelpDialog.js index 5b4148e939..608f81a612 100644 --- a/src/components/views/dialogs/SlashCommandHelpDialog.js +++ b/src/components/views/dialogs/SlashCommandHelpDialog.js @@ -15,11 +15,11 @@ limitations under the License. */ import React from 'react'; -import {_t} from "../../../languageHandler"; -import {CommandCategories, Commands} from "../../../SlashCommands"; +import { _t } from "../../../languageHandler"; +import { CommandCategories, Commands } from "../../../SlashCommands"; import * as sdk from "../../../index"; -export default ({onFinished}) => { +export default ({ onFinished }) => { const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); const categories = {}; diff --git a/src/components/views/dialogs/SpaceSettingsDialog.tsx b/src/components/views/dialogs/SpaceSettingsDialog.tsx index a135b6bc16..fe836ebc5c 100644 --- a/src/components/views/dialogs/SpaceSettingsDialog.tsx +++ b/src/components/views/dialogs/SpaceSettingsDialog.tsx @@ -14,24 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useState} from 'react'; -import {Room} from "matrix-js-sdk/src/models/room"; -import {MatrixClient} from "matrix-js-sdk/src/client"; -import {EventType} from "matrix-js-sdk/src/@types/event"; +import React, { useMemo } from 'react'; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixClient } from "matrix-js-sdk/src/client"; -import {_t} from '../../../languageHandler'; -import {IDialogProps} from "./IDialogProps"; +import { _t, _td } from '../../../languageHandler'; +import { IDialogProps } from "./IDialogProps"; import BaseDialog from "./BaseDialog"; -import DevtoolsDialog from "./DevtoolsDialog"; -import SpaceBasicSettings from '../spaces/SpaceBasicSettings'; -import {getTopic} from "../elements/RoomTopic"; -import {avatarUrlForRoom} from "../../../Avatar"; -import ToggleSwitch from "../elements/ToggleSwitch"; -import AccessibleButton from "../elements/AccessibleButton"; -import Modal from "../../../Modal"; import defaultDispatcher from "../../../dispatcher/dispatcher"; -import {useDispatcher} from "../../../hooks/useDispatcher"; -import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView"; +import { useDispatcher } from "../../../hooks/useDispatcher"; +import TabbedView, { Tab } from "../../structures/TabbedView"; +import SpaceSettingsGeneralTab from '../spaces/SpaceSettingsGeneralTab'; +import SpaceSettingsVisibilityTab from "../spaces/SpaceSettingsVisibilityTab"; +import SettingsStore from "../../../settings/SettingsStore"; +import { UIFeature } from "../../../settings/UIFeature"; +import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab"; + +export enum SpaceSettingsTab { + General = "SPACE_GENERAL_TAB", + Visibility = "SPACE_VISIBILITY_TAB", + Advanced = "SPACE_ADVANCED_TAB", +} interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -39,69 +42,36 @@ interface IProps extends IDialogProps { } const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFinished }) => { - useDispatcher(defaultDispatcher, ({action, ...params}) => { + useDispatcher(defaultDispatcher, ({ action, ...params }) => { if (action === "after_leave_room" && params.room_id === space.roomId) { onFinished(false); } }); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - const userId = cli.getUserId(); - - const [newAvatar, setNewAvatar] = useState(null); // undefined means to remove avatar - const canSetAvatar = space.currentState.maySendStateEvent(EventType.RoomAvatar, userId); - const avatarChanged = newAvatar !== null; - - const [name, setName] = useState(space.name); - const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId); - const nameChanged = name !== space.name; - - const currentTopic = getTopic(space); - const [topic, setTopic] = useState(currentTopic); - const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId); - const topicChanged = topic !== currentTopic; - - const currentJoinRule = space.getJoinRule(); - const [joinRule, setJoinRule] = useState(currentJoinRule); - const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId); - const joinRuleChanged = joinRule !== currentJoinRule; - - const onSave = async () => { - setBusy(true); - const promises = []; - - if (avatarChanged) { - if (newAvatar) { - promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, { - url: await cli.uploadContent(newAvatar), - }, "")); - } else { - promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {}, "")); - } - } - - if (nameChanged) { - promises.push(cli.setRoomName(space.roomId, name)); - } - - if (topicChanged) { - promises.push(cli.setRoomTopic(space.roomId, topic)); - } - - if (joinRuleChanged) { - promises.push(cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, { join_rule: joinRule }, "")); - } - - const results = await Promise.allSettled(promises); - setBusy(false); - const failures = results.filter(r => r.status === "rejected"); - if (failures.length > 0) { - console.error("Failed to save space settings: ", failures); - setError(_t("Failed to save space settings.")); - } - }; + const tabs = useMemo(() => { + return [ + new Tab( + SpaceSettingsTab.General, + _td("General"), + "mx_SpaceSettingsDialog_generalIcon", + , + ), + new Tab( + SpaceSettingsTab.Visibility, + _td("Visibility"), + "mx_SpaceSettingsDialog_visibilityIcon", + , + ), + SettingsStore.getValue(UIFeature.AdvancedSettings) + ? new Tab( + SpaceSettingsTab.Advanced, + _td("Advanced"), + "mx_RoomSettingsDialog_warningIcon", + , + ) + : null, + ].filter(Boolean); + }, [cli, space, onFinished]); return = ({ matrixClient: cli, space, onFin onFinished={onFinished} fixedWidth={false} > -
      -
      { _t("Edit settings relating to your space.") }
      - - { error &&
      { error }
      } - - onFinished(false)} /> - - - -
      - { _t("Make this space private") } - setJoinRule(checked ? "invite" : "public")} - disabled={!canSetJoinRule} - aria-label={_t("Make this space private")} - /> -
      - - { - defaultDispatcher.dispatch({ - action: "leave_room", - room_id: space.roomId, - }); - }} - > - { _t("Leave Space") } - - -
      - Modal.createDialog(DevtoolsDialog, {roomId: space.roomId})}> - { _t("View dev tools") } - - - { _t("Cancel") } - - - { busy ? _t("Saving...") : _t("Save Changes") } - -
      +
      +
      ; }; export default SpaceSettingsDialog; - diff --git a/src/components/views/dialogs/StorageEvictedDialog.js b/src/components/views/dialogs/StorageEvictedDialog.js index 1e17ab1738..c25866b64d 100644 --- a/src/components/views/dialogs/StorageEvictedDialog.js +++ b/src/components/views/dialogs/StorageEvictedDialog.js @@ -20,7 +20,7 @@ import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.dialogs.StorageEvictedDialog") export default class StorageEvictedDialog extends React.Component { diff --git a/src/components/views/dialogs/TabbedIntegrationManagerDialog.js b/src/components/views/dialogs/TabbedIntegrationManagerDialog.js index 618b0b4347..3a3cc31cf8 100644 --- a/src/components/views/dialogs/TabbedIntegrationManagerDialog.js +++ b/src/components/views/dialogs/TabbedIntegrationManagerDialog.js @@ -16,13 +16,13 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; -import {Room} from "matrix-js-sdk/src/models/room"; +import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; +import { Room } from "matrix-js-sdk/src/models/room"; import * as sdk from '../../../index'; -import {dialogTermsInteractionCallback, TermsNotSignedError} from "../../../Terms"; +import { dialogTermsInteractionCallback, TermsNotSignedError } from "../../../Terms"; import classNames from 'classnames'; import * as ScalarMessaging from "../../../ScalarMessaging"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.dialogs.TabbedIntegrationManagerDialog") export default class TabbedIntegrationManagerDialog extends React.Component { @@ -63,11 +63,11 @@ export default class TabbedIntegrationManagerDialog extends React.Component { }; } - componentDidMount(): void { + componentDidMount() { this.openManager(0, true); } - openManager = async (i: number, force = false) => { + openManager = async (i, force = false) => { if (i === this.state.currentIndex && !force) return; const manager = this.state.managers[i]; diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.tsx similarity index 70% rename from src/components/views/dialogs/TermsDialog.js rename to src/components/views/dialogs/TermsDialog.tsx index e8625ec6cb..02a779743b 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.tsx @@ -16,23 +16,22 @@ limitations under the License. import url from 'url'; import React from 'react'; -import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t, pickBestLanguage } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; -class TermsCheckbox extends React.PureComponent { - static propTypes = { - onChange: PropTypes.func.isRequired, - url: PropTypes.string.isRequired, - checked: PropTypes.bool.isRequired, - } +interface ITermsCheckboxProps { + onChange: (url: string, checked: boolean) => void; + url: string; + checked: boolean; +} - onChange = (ev) => { - this.props.onChange(this.props.url, ev.target.checked); - } +class TermsCheckbox extends React.PureComponent { + private onChange = (ev: React.FormEvent): void => { + this.props.onChange(this.props.url, ev.currentTarget.checked); + }; render() { return void; +} + +interface IState { + agreedUrls: any; +} + @replaceableComponent("views.dialogs.TermsDialog") -export default class TermsDialog extends React.PureComponent { - static propTypes = { - /** - * Array of [Service, policies] pairs, where policies is the response from the - * /terms endpoint for that service - */ - policiesAndServicePairs: PropTypes.array.isRequired, - - /** - * urls that the user has already agreed to - */ - agreedUrls: PropTypes.arrayOf(PropTypes.string), - - /** - * Called with: - * * success {bool} True if the user accepted any douments, false if cancelled - * * agreedUrls {string[]} List of agreed URLs - */ - onFinished: PropTypes.func.isRequired, - } - +export default class TermsDialog extends React.PureComponent { constructor(props) { - super(); + super(props); this.state = { // url -> boolean agreedUrls: {}, @@ -75,15 +78,15 @@ export default class TermsDialog extends React.PureComponent { } } - _onCancelClick = () => { + private onCancelClick = (): void => { this.props.onFinished(false); - } + }; - _onNextClick = () => { + private onNextClick = (): void => { this.props.onFinished(true, Object.keys(this.state.agreedUrls).filter((url) => this.state.agreedUrls[url])); - } + }; - _nameForServiceType(serviceType, host) { + private nameForServiceType(serviceType: SERVICE_TYPES, host: string): JSX.Element { switch (serviceType) { case SERVICE_TYPES.IS: return
      {_t("Identity Server")}
      ({host})
      ; @@ -92,7 +95,7 @@ export default class TermsDialog extends React.PureComponent { } } - _summaryForServiceType(serviceType) { + private summaryForServiceType(serviceType: SERVICE_TYPES): JSX.Element { switch (serviceType) { case SERVICE_TYPES.IS: return
      @@ -107,13 +110,13 @@ export default class TermsDialog extends React.PureComponent { } } - _onTermsCheckboxChange = (url, checked) => { + private onTermsCheckboxChange = (url: string, checked: boolean) => { this.setState({ agreedUrls: Object.assign({}, this.state.agreedUrls, { [url]: checked }), }); - } + }; - render() { + public render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); @@ -128,8 +131,8 @@ export default class TermsDialog extends React.PureComponent { let serviceName; let summary; if (i === 0) { - serviceName = this._nameForServiceType(policiesAndService.service.serviceType, parsedBaseUrl.host); - summary = this._summaryForServiceType( + serviceName = this.nameForServiceType(policiesAndService.service.serviceType, parsedBaseUrl.host); + summary = this.summaryForServiceType( policiesAndService.service.serviceType, ); } @@ -137,12 +140,15 @@ export default class TermsDialog extends React.PureComponent { rows.push( {serviceName} {summary} - {termDoc[termsLang].name} - - + + {termDoc[termsLang].name} + + + + ); @@ -176,7 +182,7 @@ export default class TermsDialog extends React.PureComponent { return ( diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index 97abd209c0..3d37c89424 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -14,12 +14,12 @@ 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 Field from "../elements/Field"; import { _t, _td } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.dialogs.TextInputDialog") export default class TextInputDialog extends React.Component { diff --git a/src/components/views/dialogs/UntrustedDeviceDialog.tsx b/src/components/views/dialogs/UntrustedDeviceDialog.tsx index da1492e30d..b89293b386 100644 --- a/src/components/views/dialogs/UntrustedDeviceDialog.tsx +++ b/src/components/views/dialogs/UntrustedDeviceDialog.tsx @@ -30,7 +30,7 @@ interface IProps extends IDialogProps { device: IDevice; } -const UntrustedDeviceDialog: React.FC = ({device, user, onFinished}) => { +const UntrustedDeviceDialog: React.FC = ({ device, user, onFinished }) => { let askToVerifyText; let newSessionText; @@ -39,7 +39,7 @@ const UntrustedDeviceDialog: React.FC = ({device, user, onFinished}) => askToVerifyText = _t("Verify your other session using one of the options below."); } else { newSessionText = _t("%(name)s (%(userId)s) signed in to a new session without verifying it:", - {name: user.displayName, userId: user.userId}); + { name: user.displayName, userId: user.userId }); askToVerifyText = _t("Ask this user to verify their session, or manually verify it below."); } diff --git a/src/components/views/dialogs/UploadConfirmDialog.tsx b/src/components/views/dialogs/UploadConfirmDialog.tsx index 7f6bcd27d1..5024880c1c 100644 --- a/src/components/views/dialogs/UploadConfirmDialog.tsx +++ b/src/components/views/dialogs/UploadConfirmDialog.tsx @@ -36,7 +36,7 @@ export default class UploadConfirmDialog extends React.Component { static defaultProps = { totalFiles: 1, - } + }; constructor(props) { super(props); @@ -56,15 +56,15 @@ export default class UploadConfirmDialog extends React.Component { private onCancelClick = () => { this.props.onFinished(false); - } + }; private onUploadClick = () => { this.props.onFinished(true); - } + }; private onUploadAllClick = () => { this.props.onFinished(true, true); - } + }; render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); diff --git a/src/components/views/dialogs/UploadFailureDialog.js b/src/components/views/dialogs/UploadFailureDialog.js index d220d6c684..d26b83d0d6 100644 --- a/src/components/views/dialogs/UploadFailureDialog.js +++ b/src/components/views/dialogs/UploadFailureDialog.js @@ -21,7 +21,7 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import ContentMessages from '../../../ContentMessages'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; /* * Tells the user about files we know cannot be uploaded before we even try uploading diff --git a/src/components/views/dialogs/UserSettingsDialog.js b/src/components/views/dialogs/UserSettingsDialog.tsx similarity index 71% rename from src/components/views/dialogs/UserSettingsDialog.js rename to src/components/views/dialogs/UserSettingsDialog.tsx index fe29b85aea..2878a07d35 100644 --- a/src/components/views/dialogs/UserSettingsDialog.js +++ b/src/components/views/dialogs/UserSettingsDialog.tsx @@ -16,11 +16,10 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import TabbedView, {Tab} from "../../structures/TabbedView"; -import {_t, _td} from "../../../languageHandler"; +import TabbedView, { Tab } from "../../structures/TabbedView"; +import { _t, _td } from "../../../languageHandler"; import GeneralUserSettingsTab from "../settings/tabs/user/GeneralUserSettingsTab"; -import SettingsStore from "../../../settings/SettingsStore"; +import SettingsStore, { CallbackFn } from "../../../settings/SettingsStore"; import LabsUserSettingsTab from "../settings/tabs/user/LabsUserSettingsTab"; import AppearanceUserSettingsTab from "../settings/tabs/user/AppearanceUserSettingsTab"; import SecurityUserSettingsTab from "../settings/tabs/user/SecurityUserSettingsTab"; @@ -32,79 +31,87 @@ import FlairUserSettingsTab from "../settings/tabs/user/FlairUserSettingsTab"; import * as sdk from "../../../index"; import SdkConfig from "../../../SdkConfig"; import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab"; -import {UIFeature} from "../../../settings/UIFeature"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { UIFeature } from "../../../settings/UIFeature"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; -export const USER_GENERAL_TAB = "USER_GENERAL_TAB"; -export const USER_APPEARANCE_TAB = "USER_APPEARANCE_TAB"; -export const USER_FLAIR_TAB = "USER_FLAIR_TAB"; -export const USER_NOTIFICATIONS_TAB = "USER_NOTIFICATIONS_TAB"; -export const USER_PREFERENCES_TAB = "USER_PREFERENCES_TAB"; -export const USER_VOICE_TAB = "USER_VOICE_TAB"; -export const USER_SECURITY_TAB = "USER_SECURITY_TAB"; -export const USER_LABS_TAB = "USER_LABS_TAB"; -export const USER_MJOLNIR_TAB = "USER_MJOLNIR_TAB"; -export const USER_HELP_TAB = "USER_HELP_TAB"; +export enum UserTab { + General = "USER_GENERAL_TAB", + Appearance = "USER_APPEARANCE_TAB", + Flair = "USER_FLAIR_TAB", + Notifications = "USER_NOTIFICATIONS_TAB", + Preferences = "USER_PREFERENCES_TAB", + Voice = "USER_VOICE_TAB", + Security = "USER_SECURITY_TAB", + Labs = "USER_LABS_TAB", + Mjolnir = "USER_MJOLNIR_TAB", + Help = "USER_HELP_TAB", +} + +interface IProps { + onFinished: (success: boolean) => void; + initialTabId?: string; +} + +interface IState { + mjolnirEnabled: boolean; +} @replaceableComponent("views.dialogs.UserSettingsDialog") -export default class UserSettingsDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - initialTabId: PropTypes.string, - }; +export default class UserSettingsDialog extends React.Component { + private mjolnirWatcher: string; - constructor() { - super(); + constructor(props) { + super(props); this.state = { mjolnirEnabled: SettingsStore.getValue("feature_mjolnir"), }; } - componentDidMount(): void { - this._mjolnirWatcher = SettingsStore.watchSetting("feature_mjolnir", null, this._mjolnirChanged.bind(this)); + public componentDidMount(): void { + this.mjolnirWatcher = SettingsStore.watchSetting("feature_mjolnir", null, this.mjolnirChanged); } - componentWillUnmount(): void { - SettingsStore.unwatchSetting(this._mjolnirWatcher); + public componentWillUnmount(): void { + SettingsStore.unwatchSetting(this.mjolnirWatcher); } - _mjolnirChanged(settingName, roomId, atLevel, newValue) { + private mjolnirChanged: CallbackFn = (settingName, roomId, atLevel, newValue) => { // We can cheat because we know what levels a feature is tracked at, and how it is tracked - this.setState({mjolnirEnabled: newValue}); - } + this.setState({ mjolnirEnabled: newValue }); + }; _getTabs() { const tabs = []; tabs.push(new Tab( - USER_GENERAL_TAB, + UserTab.General, _td("General"), "mx_UserSettingsDialog_settingsIcon", , )); tabs.push(new Tab( - USER_APPEARANCE_TAB, + UserTab.Appearance, _td("Appearance"), "mx_UserSettingsDialog_appearanceIcon", , )); if (SettingsStore.getValue(UIFeature.Flair)) { tabs.push(new Tab( - USER_FLAIR_TAB, + UserTab.Flair, _td("Flair"), "mx_UserSettingsDialog_flairIcon", , )); } tabs.push(new Tab( - USER_NOTIFICATIONS_TAB, + UserTab.Notifications, _td("Notifications"), "mx_UserSettingsDialog_bellIcon", , )); tabs.push(new Tab( - USER_PREFERENCES_TAB, + UserTab.Preferences, _td("Preferences"), "mx_UserSettingsDialog_preferencesIcon", , @@ -112,7 +119,7 @@ export default class UserSettingsDialog extends React.Component { if (SettingsStore.getValue(UIFeature.Voip)) { tabs.push(new Tab( - USER_VOICE_TAB, + UserTab.Voice, _td("Voice & Video"), "mx_UserSettingsDialog_voiceIcon", , @@ -120,7 +127,7 @@ export default class UserSettingsDialog extends React.Component { } tabs.push(new Tab( - USER_SECURITY_TAB, + UserTab.Security, _td("Security & Privacy"), "mx_UserSettingsDialog_securityIcon", , @@ -130,7 +137,7 @@ export default class UserSettingsDialog extends React.Component { || SettingsStore.getFeatureSettingNames().some(k => SettingsStore.getBetaInfo(k)) ) { tabs.push(new Tab( - USER_LABS_TAB, + UserTab.Labs, _td("Labs"), "mx_UserSettingsDialog_labsIcon", , @@ -138,17 +145,17 @@ export default class UserSettingsDialog extends React.Component { } if (this.state.mjolnirEnabled) { tabs.push(new Tab( - USER_MJOLNIR_TAB, + UserTab.Mjolnir, _td("Ignored users"), "mx_UserSettingsDialog_mjolnirIcon", , )); } tabs.push(new Tab( - USER_HELP_TAB, + UserTab.Help, _td("Help & About"), "mx_UserSettingsDialog_helpIcon", - , + this.props.onFinished(true)} />, )); return tabs; diff --git a/src/components/views/dialogs/VerificationRequestDialog.js b/src/components/views/dialogs/VerificationRequestDialog.tsx similarity index 63% rename from src/components/views/dialogs/VerificationRequestDialog.js rename to src/components/views/dialogs/VerificationRequestDialog.tsx index 9281275e6a..4d3123c274 100644 --- a/src/components/views/dialogs/VerificationRequestDialog.js +++ b/src/components/views/dialogs/VerificationRequestDialog.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,36 +15,40 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import * as sdk from '../../../index'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; +import BaseDialog from "./BaseDialog"; +import EncryptionPanel from "../right_panel/EncryptionPanel"; +import { User } from 'matrix-js-sdk'; + +interface IProps { + verificationRequest: VerificationRequest; + verificationRequestPromise: Promise; + onFinished: () => void; + member: User; +} + +interface IState { + verificationRequest: VerificationRequest; +} @replaceableComponent("views.dialogs.VerificationRequestDialog") -export default class VerificationRequestDialog extends React.Component { - static propTypes = { - verificationRequest: PropTypes.object, - verificationRequestPromise: PropTypes.object, - onFinished: PropTypes.func.isRequired, - member: PropTypes.string, - }; - - constructor(...args) { - super(...args); - this.state = {}; - if (this.props.verificationRequest) { - this.state.verificationRequest = this.props.verificationRequest; - } else if (this.props.verificationRequestPromise) { +export default class VerificationRequestDialog extends React.Component { + constructor(props) { + super(props); + this.state = { + verificationRequest: this.props.verificationRequest, + }; + if (this.props.verificationRequestPromise) { this.props.verificationRequestPromise.then(r => { - this.setState({verificationRequest: r}); + this.setState({ verificationRequest: r }); }); } } render() { - const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); - const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel"); const request = this.state.verificationRequest; const otherUserId = request && request.otherUserId; const member = this.props.member || @@ -65,6 +69,7 @@ export default class VerificationRequestDialog extends React.Component { verificationRequestPromise={this.props.verificationRequestPromise} onClose={this.props.onFinished} member={member} + isRoomEncrypted={false} /> ; } diff --git a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx index 70fe7fe5e3..638d5cde93 100644 --- a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx +++ b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx @@ -29,7 +29,7 @@ import StyledCheckbox from "../elements/StyledCheckbox"; import DialogButtons from "../elements/DialogButtons"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import { CapabilityText } from "../../../widgets/CapabilityText"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] { return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]"); @@ -77,11 +77,11 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent< private onToggle = (capability: Capability) => { const newStates = objectShallowClone(this.state.booleanStates); newStates[capability] = !newStates[capability]; - this.setState({booleanStates: newStates}); + this.setState({ booleanStates: newStates }); }; private onRememberSelectionChange = (newVal: boolean) => { - this.setState({rememberSelection: newVal}); + this.setState({ rememberSelection: newVal }); }; private onSubmit = async (ev) => { @@ -98,7 +98,7 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent< if (this.state.rememberSelection) { setRememberedCapabilitiesForWidget(this.props.widget, approved); } - this.props.onFinished({approved}); + this.props.onFinished({ approved }); } public render() { diff --git a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js index f77661c54e..2130d0e4ef 100644 --- a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js +++ b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js @@ -16,12 +16,12 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {_t} from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import * as sdk from "../../../index"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; -import {Widget} from "matrix-widget-api"; -import {OIDCState, WidgetPermissionStore} from "../../../stores/widgets/WidgetPermissionStore"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Widget } from "matrix-widget-api"; +import { OIDCState, WidgetPermissionStore } from "../../../stores/widgets/WidgetPermissionStore"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.dialogs.WidgetOpenIDPermissionsDialog") export default class WidgetOpenIDPermissionsDialog extends React.Component { @@ -62,7 +62,7 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component { } _onRememberSelectionChange = (newVal) => { - this.setState({rememberSelection: newVal}); + this.setState({ rememberSelection: newVal }); }; render() { diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index e09b39f4c7..d614cc0956 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -14,18 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {debounce} from "lodash"; +import { debounce } from "lodash"; import classNames from 'classnames'; -import React, {ChangeEvent, FormEvent} from 'react'; -import {ISecretStorageKeyInfo} from "matrix-js-sdk/src"; +import React, { ChangeEvent, FormEvent } from 'react'; +import { ISecretStorageKeyInfo } from "matrix-js-sdk/src"; import * as sdk from '../../../../index'; -import {MatrixClientPeg} from '../../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../../MatrixClientPeg'; import Field from '../../elements/Field'; import AccessibleButton from '../../elements/AccessibleButton'; -import {_t} from '../../../../languageHandler'; -import {IDialogProps} from "../IDialogProps"; -import {accessSecretStorage} from "../../../../SecurityManager"; +import { _t } from '../../../../languageHandler'; +import { IDialogProps } from "../IDialogProps"; +import { accessSecretStorage } from "../../../../SecurityManager"; import Modal from "../../../../Modal"; // Maximum acceptable size of a key file. It's 59 characters including the spaces we encode, @@ -75,7 +75,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { if (this.state.resetting) { - this.setState({resetting: false}); + this.setState({ resetting: false }); } this.props.onFinished(false); }; @@ -169,7 +169,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { this.fileUpload.current.click(); - } + }; private onPassPhraseNext = async (ev: FormEvent) => { ev.preventDefault(); @@ -210,7 +210,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent) => { ev.preventDefault(); - this.setState({resetting: true}); + this.setState({ resetting: true }); }; private onConfirmResetAllClick = async () => { @@ -231,7 +231,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { // XXX: Making this an import breaks the app. const InteractiveAuthDialog = sdk.getComponent("views.dialogs.InteractiveAuthDialog"); - const {finished} = Modal.createTrackedDialog( + const { finished } = Modal.createTrackedDialog( 'Cross-signing keys dialog', '', InteractiveAuthDialog, { title: _t("Setting up keys"), diff --git a/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.tsx similarity index 83% rename from src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js rename to src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.tsx index e71983b074..6272302a76 100644 --- a/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js +++ b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.tsx @@ -15,22 +15,21 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {_t} from "../../../../languageHandler"; +import { _t } from "../../../../languageHandler"; import * as sdk from "../../../../index"; -import {replaceableComponent} from "../../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../../utils/replaceableComponent"; + +interface IProps { + onFinished: (success: boolean) => void; +} @replaceableComponent("views.dialogs.security.ConfirmDestroyCrossSigningDialog") -export default class ConfirmDestroyCrossSigningDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; - - _onConfirm = () => { +export default class ConfirmDestroyCrossSigningDialog extends React.Component { + private onConfirm = (): void => { this.props.onFinished(true); }; - _onDecline = () => { + private onDecline = (): void => { this.props.onFinished(false); }; @@ -57,10 +56,10 @@ export default class ConfirmDestroyCrossSigningDialog extends React.Component {
      ); diff --git a/src/components/views/dialogs/security/CreateCrossSigningDialog.js b/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx similarity index 83% rename from src/components/views/dialogs/security/CreateCrossSigningDialog.js rename to src/components/views/dialogs/security/CreateCrossSigningDialog.tsx index fedcc02f89..84dcfede4a 100644 --- a/src/components/views/dialogs/security/CreateCrossSigningDialog.js +++ b/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx @@ -16,7 +16,8 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; +import { CrossSigningKeys } from 'matrix-js-sdk/src/client'; + import { MatrixClientPeg } from '../../../../MatrixClientPeg'; import { _t } from '../../../../languageHandler'; import Modal from '../../../../Modal'; @@ -25,7 +26,19 @@ import DialogButtons from '../../elements/DialogButtons'; import BaseDialog from '../BaseDialog'; import Spinner from '../../elements/Spinner'; import InteractiveAuthDialog from '../InteractiveAuthDialog'; -import {replaceableComponent} from "../../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../../utils/replaceableComponent"; + +interface IProps { + accountPassword?: string; + tokenLogin?: boolean; + onFinished?: (success: boolean) => void; +} + +interface IState { + error: Error | null; + canUploadKeysWithPasswordOnly?: boolean; + accountPassword: string; +} /* * Walks the user through the process of creating a cross-signing keys. In most @@ -33,41 +46,34 @@ import {replaceableComponent} from "../../../../utils/replaceableComponent"; * may need to complete some steps to proceed. */ @replaceableComponent("views.dialogs.security.CreateCrossSigningDialog") -export default class CreateCrossSigningDialog extends React.PureComponent { - static propTypes = { - accountPassword: PropTypes.string, - tokenLogin: PropTypes.bool, - }; - - constructor(props) { +export default class CreateCrossSigningDialog extends React.PureComponent { + constructor(props: IProps) { super(props); this.state = { error: null, // Does the server offer a UI auth flow with just m.login.password // for /keys/device_signing/upload? - canUploadKeysWithPasswordOnly: null, - accountPassword: props.accountPassword || "", - }; - - if (this.state.accountPassword) { // If we have an account password in memory, let's simplify and // assume it means password auth is also supported for device // signing key upload as well. This avoids hitting the server to // test auth flows, which may be slow under high load. - this.state.canUploadKeysWithPasswordOnly = true; - } else { - this._queryKeyUploadAuth(); + canUploadKeysWithPasswordOnly: props.accountPassword ? true : null, + accountPassword: props.accountPassword || "", + }; + + if (!this.state.accountPassword) { + this.queryKeyUploadAuth(); } } - componentDidMount() { - this._bootstrapCrossSigning(); + public componentDidMount(): void { + this.bootstrapCrossSigning(); } - async _queryKeyUploadAuth() { + private async queryKeyUploadAuth(): Promise { try { - await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {}); + await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {} as CrossSigningKeys); // We should never get here: the server should always require // UI auth to upload device signing keys. If we do, we upload // no keys which would be a no-op. @@ -86,7 +92,7 @@ export default class CreateCrossSigningDialog extends React.PureComponent { } } - _doBootstrapUIAuth = async (makeRequest) => { + private doBootstrapUIAuth = async (makeRequest: (authData: any) => void): Promise => { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { await makeRequest({ type: 'm.login.password', @@ -135,9 +141,9 @@ export default class CreateCrossSigningDialog extends React.PureComponent { throw new Error("Cross-signing key upload auth canceled"); } } - } + }; - _bootstrapCrossSigning = async () => { + private bootstrapCrossSigning = async (): Promise => { this.setState({ error: null, }); @@ -146,24 +152,24 @@ export default class CreateCrossSigningDialog extends React.PureComponent { try { await cli.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: this._doBootstrapUIAuth, + authUploadDeviceSigningKeys: this.doBootstrapUIAuth, }); this.props.onFinished(true); } catch (e) { if (this.props.tokenLogin) { // ignore any failures, we are relying on grace period here - this.props.onFinished(); + this.props.onFinished(false); return; } this.setState({ error: e }); console.error("Error bootstrapping cross-signing", e); } - } + }; - _onCancel = () => { + private onCancel = (): void => { this.props.onFinished(false); - } + }; render() { let content; @@ -172,8 +178,8 @@ export default class CreateCrossSigningDialog extends React.PureComponent {

      {_t("Unable to set up keys")}

      ; diff --git a/src/components/views/dialogs/security/RestoreKeyBackupDialog.js b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js index 4ac15ab5a3..5f21033d29 100644 --- a/src/components/views/dialogs/security/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js @@ -18,7 +18,7 @@ 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 { MatrixClient } from 'matrix-js-sdk/src/client'; import { _t } from '../../../../languageHandler'; import { accessSecretStorage } from '../../../../SecurityManager'; @@ -327,11 +327,11 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { if (this.state.recoverInfo.total > this.state.recoverInfo.imported) { failedToDecrypt =

      {_t( "Failed to decrypt %(failedCount)s sessions!", - {failedCount: this.state.recoverInfo.total - this.state.recoverInfo.imported}, + { failedCount: this.state.recoverInfo.total - this.state.recoverInfo.imported }, )}

      ; } content =
      -

      {_t("Successfully restored %(sessionCount)s keys", {sessionCount: this.state.recoverInfo.imported})}

      +

      {_t("Successfully restored %(sessionCount)s keys", { sessionCount: this.state.recoverInfo.imported })}

      {failedToDecrypt} void; +} - constructor() { - super(); +interface IState { + icon: Phase; +} + +@replaceableComponent("views.dialogs.security.SetupEncryptionDialog") +export default class SetupEncryptionDialog extends React.Component { + private store: SetupEncryptionStore; + + constructor(props: IProps) { + super(props); this.store = SetupEncryptionStore.sharedInstance(); - this.state = {icon: iconFromPhase(this.store.phase)}; + this.state = { icon: iconFromPhase(this.store.phase) }; } - componentDidMount() { - this.store.on("update", this._onStoreUpdate); + public componentDidMount() { + this.store.on("update", this.onStoreUpdate); } - componentWillUnmount() { - this.store.removeListener("update", this._onStoreUpdate); + public componentWillUnmount() { + this.store.removeListener("update", this.onStoreUpdate); } - _onStoreUpdate = () => { - this.setState({icon: iconFromPhase(this.store.phase)}); + private onStoreUpdate = (): void => { + this.setState({ icon: iconFromPhase(this.store.phase) }); }; - render() { + public render() { return { + protocolsList.forEach(({ instances=[] }) => { [...instances].sort((b, a) => { return compare(a.desc, b.desc); - }).forEach(({desc, instance_id: instanceId}) => { + }).forEach(({ desc, instance_id: instanceId }) => { entries.push( { closeMenu(); - const {finished} = Modal.createTrackedDialog("Network Dropdown", "Remove server", QuestionDialog, { - title: _t("Are you sure?"), - description: _t("Are you sure you want to remove %(serverName)s", { - serverName: server, - }, { - b: serverName => { serverName }, - }), - button: _t("Remove"), - fixedWidth: false, - }, "mx_NetworkDropdown_dialog"); + const { finished } = Modal.createTrackedDialog( + "Network Dropdown", "Remove server", QuestionDialog, + { + title: _t("Are you sure?"), + description: _t("Are you sure you want to remove %(serverName)s", { + serverName: server, + }, { + b: serverName => { serverName }, + }), + button: _t("Remove"), + fixedWidth: false, + }, + "mx_NetworkDropdown_dialog", + ); const [ok] = await finished; if (!ok) return; diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index e634057a21..997bbcb9c2 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -16,7 +16,7 @@ import React from 'react'; -import {Key} from '../../../Keyboard'; +import { Key } from '../../../Keyboard'; import classnames from 'classnames'; export type ButtonEvent = React.MouseEvent | React.KeyboardEvent; @@ -62,6 +62,8 @@ export default function AccessibleButton({ disabled, inputRef, className, + onKeyDown, + onKeyUp, ...restProps }: IProps) { const newProps: IAccessibleButtonProps = restProps; @@ -83,6 +85,8 @@ export default function AccessibleButton({ if (e.key === Key.SPACE) { e.stopPropagation(); e.preventDefault(); + } else { + onKeyDown?.(e); } }; newProps.onKeyUp = (e) => { @@ -94,6 +98,8 @@ export default function AccessibleButton({ if (e.key === Key.ENTER) { e.stopPropagation(); e.preventDefault(); + } else { + onKeyUp?.(e); } }; } diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index a1743da475..8ac41ad1a2 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -19,8 +19,8 @@ import React from 'react'; import classNames from 'classnames'; import AccessibleButton from "./AccessibleButton"; -import Tooltip, {Alignment} from './Tooltip'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import Tooltip, { Alignment } from './Tooltip'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface ITooltipProps extends React.ComponentProps { title: string; @@ -67,7 +67,7 @@ export default class AccessibleTooltipButton extends React.PureComponent { ev.stopPropagation(); Analytics.trackEvent('Action Button', 'click', this.props.action); - dis.dispatch({action: this.props.action}); + dis.dispatch({ action: this.props.action }); }; _onMouseEnter = () => { - if (this.props.tooltip) this.setState({showTooltip: true}); + if (this.props.tooltip) this.setState({ showTooltip: true }); if (this.props.mouseOverAction) { - dis.dispatch({action: this.props.mouseOverAction}); + dis.dispatch({ action: this.props.mouseOverAction }); } }; _onMouseLeave = () => { - this.setState({showTooltip: false}); + this.setState({ showTooltip: false }); }; render() { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); - let tooltip; if (this.state.showTooltip) { const Tooltip = sdk.getComponent("elements.Tooltip"); @@ -71,7 +69,7 @@ export default class ActionButton extends React.Component { } const icon = this.props.iconPath ? - () : + () : undefined; const classNames = ["mx_RoleButton"]; diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js index 33b2906870..b7c9124438 100644 --- a/src/components/views/elements/AddressSelector.js +++ b/src/components/views/elements/AddressSelector.js @@ -20,7 +20,7 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import classNames from 'classnames'; import { UserAddressType } from '../../../UserAddress'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.AddressSelector") export default class AddressSelector extends React.Component { diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index df66d10a71..ca85d73a11 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -20,9 +20,9 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import * as sdk from "../../../index"; import { _t } from '../../../languageHandler'; -import { UserAddressType } from '../../../UserAddress.js'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { UserAddressType } from '../../../UserAddress'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; @replaceableComponent("views.elements.AddressTile") export default class AddressTile extends React.Component { @@ -53,7 +53,6 @@ export default class AddressTile extends React.Component { } const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - const TintableSvg = sdk.getComponent("elements.TintableSvg"); const nameClasses = classNames({ "mx_AddressTile_name": true, @@ -124,7 +123,7 @@ export default class AddressTile extends React.Component { if (this.props.canDismiss) { dismiss = (
      - +
      ); } diff --git a/src/components/views/elements/AppPermission.js b/src/components/views/elements/AppPermission.js index 65e40ef19a..152d3c6b95 100644 --- a/src/components/views/elements/AppPermission.js +++ b/src/components/views/elements/AppPermission.js @@ -23,8 +23,8 @@ import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import WidgetUtils from "../../../utils/WidgetUtils"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.AppPermission") export default class AppPermission extends React.Component { @@ -115,9 +115,9 @@ export default class AppPermission extends React.Component { // Due to i18n limitations, we can't dedupe the code for variables in these two messages. const warning = this.state.isWrapped ? _t("Using this widget may share data with %(widgetDomain)s & your Integration Manager.", - {widgetDomain: this.state.widgetDomain}, {helpIcon: () => warningTooltip}) + { widgetDomain: this.state.widgetDomain }, { helpIcon: () => warningTooltip }) : _t("Using this widget may share data with %(widgetDomain)s.", - {widgetDomain: this.state.widgetDomain}, {helpIcon: () => warningTooltip}); + { widgetDomain: this.state.widgetDomain }, { helpIcon: () => warningTooltip }); const encryptionWarning = this.props.isRoomEncrypted ? _t("Widgets do not use message encryption.") : null; diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 3fa43ee9c2..f5d3aaf9eb 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -18,9 +18,9 @@ limitations under the License. */ import url from 'url'; -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import AccessibleButton from './AccessibleButton'; import { _t } from '../../../languageHandler'; import AppPermission from './AppPermission'; @@ -30,15 +30,15 @@ import dis from '../../../dispatcher/dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import classNames from 'classnames'; import SettingsStore from "../../../settings/SettingsStore"; -import {aboveLeftOf, ContextMenuButton} from "../../structures/ContextMenu"; -import PersistedElement, {getPersistKey} from "./PersistedElement"; -import {WidgetType} from "../../../widgets/WidgetType"; -import {StopGapWidget} from "../../../stores/widgets/StopGapWidget"; -import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions"; -import {MatrixCapabilities} from "matrix-widget-api"; +import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu"; +import PersistedElement, { getPersistKey } from "./PersistedElement"; +import { WidgetType } from "../../../widgets/WidgetType"; +import { StopGapWidget } from "../../../stores/widgets/StopGapWidget"; +import { ElementWidgetActions } from "../../../stores/widgets/ElementWidgetActions"; +import { MatrixCapabilities } from "matrix-widget-api"; import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu"; import WidgetAvatar from "../avatars/WidgetAvatar"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.AppTile") export default class AppTile extends React.Component { @@ -164,7 +164,7 @@ export default class AppTile extends React.Component { _startWidget() { this._sgWidget.prepare().then(() => { - this.setState({initialising: false}); + this.setState({ initialising: false }); }); } @@ -213,17 +213,17 @@ export default class AppTile extends React.Component { } if (WidgetType.JITSI.matches(this.props.app.type)) { - dis.dispatch({action: 'hangup_conference'}); + dis.dispatch({ action: 'hangup_conference' }); } // Delete the widget from the persisted store for good measure. PersistedElement.destroyElement(this._persistKey); - if (this._sgWidget) this._sgWidget.stop({forceDestroy: true}); + if (this._sgWidget) this._sgWidget.stop({ forceDestroy: true }); } _onWidgetPrepared = () => { - this.setState({loading: false}); + this.setState({ loading: false }); }; _onWidgetReady = () => { @@ -237,7 +237,7 @@ export default class AppTile extends React.Component { switch (payload.action) { case 'm.sticker': if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) { - dis.dispatch({action: 'post_sticker_message', data: payload.data}); + dis.dispatch({ action: 'post_sticker_message', data: payload.data }); } else { console.warn('Ignoring sticker message. Invalid capability'); } @@ -253,7 +253,7 @@ export default class AppTile extends React.Component { current[this.props.app.eventId] = true; const level = SettingsStore.firstSupportedLevel("allowedWidgets"); SettingsStore.setValue("allowedWidgets", roomId, level, current).then(() => { - this.setState({hasPermissionToLoad: true}); + this.setState({ hasPermissionToLoad: true }); // Fetch a token for the integration manager, now that we're allowed to this._startWidget(); @@ -313,7 +313,7 @@ export default class AppTile extends React.Component { // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), - { target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click(); + { target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener' }).click(); }; _onContextMenuClick = () => { @@ -416,11 +416,11 @@ export default class AppTile extends React.Component { let appTileClasses; if (this.props.miniMode) { - appTileClasses = {mx_AppTile_mini: true}; + appTileClasses = { mx_AppTile_mini: true }; } else if (this.props.fullWidth) { - appTileClasses = {mx_AppTileFullWidth: true}; + appTileClasses = { mx_AppTileFullWidth: true }; } else { - appTileClasses = {mx_AppTile: true}; + appTileClasses = { mx_AppTile: true }; } appTileClasses = classNames(appTileClasses); @@ -443,7 +443,7 @@ export default class AppTile extends React.Component {
      { this.props.showMenubar &&
      - + { this.props.showTitle && this._getTileTitle() } diff --git a/src/components/views/elements/BlurhashPlaceholder.tsx b/src/components/views/elements/BlurhashPlaceholder.tsx new file mode 100644 index 0000000000..0e59253fe8 --- /dev/null +++ b/src/components/views/elements/BlurhashPlaceholder.tsx @@ -0,0 +1,56 @@ +/* + 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 { decode } from "blurhash"; + +interface IProps { + blurhash: string; + width: number; + height: number; +} + +export default class BlurhashPlaceholder extends React.PureComponent { + private canvas: React.RefObject = React.createRef(); + + public componentDidMount() { + this.draw(); + } + + public componentDidUpdate() { + this.draw(); + } + + private draw() { + if (!this.canvas.current) return; + + try { + const { width, height } = this.props; + + const pixels = decode(this.props.blurhash, Math.ceil(width), Math.ceil(height)); + const ctx = this.canvas.current.getContext("2d"); + const imgData = ctx.createImageData(width, height); + imgData.data.set(pixels); + ctx.putImageData(imgData, 0, 0); + } catch (e) { + console.error("Error rendering blurhash: ", e); + } + } + + public render() { + return ; + } +} diff --git a/src/components/views/elements/DNDTagTile.js b/src/components/views/elements/DNDTagTile.js index 67572d4508..2e88d37882 100644 --- a/src/components/views/elements/DNDTagTile.js +++ b/src/components/views/elements/DNDTagTile.js @@ -18,7 +18,6 @@ limitations under the License. import TagTile from './TagTile'; import React from 'react'; -import { Draggable } from 'react-beautiful-dnd'; import { ContextMenu, toRightOf, useContextMenu } from "../../structures/ContextMenu"; import * as sdk from '../../../index'; @@ -31,32 +30,17 @@ export default function DNDTagTile(props) { const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu'); contextMenu = ( - + ); } - return
      - - {(provided, snapshot) => ( -
      - -
      - )} -
      + return <> + {contextMenu} -
      ; + ; } diff --git a/src/components/views/elements/DesktopBuildsNotice.tsx b/src/components/views/elements/DesktopBuildsNotice.tsx index fd1c7848aa..c97a9b6cef 100644 --- a/src/components/views/elements/DesktopBuildsNotice.tsx +++ b/src/components/views/elements/DesktopBuildsNotice.tsx @@ -14,10 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +import React from "react"; import EventIndexPeg from "../../../indexing/EventIndexPeg"; import { _t } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; -import React from "react"; +import dis from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { UserTab } from "../dialogs/UserSettingsDialog"; export enum WarningKind { Files, @@ -29,11 +32,27 @@ interface IProps { kind: WarningKind; } -export default function DesktopBuildsNotice({isRoomEncrypted, kind}: IProps) { +export default function DesktopBuildsNotice({ isRoomEncrypted, kind }: IProps) { if (!isRoomEncrypted) return null; if (EventIndexPeg.get()) return null; - const {desktopBuilds, brand} = SdkConfig.get(); + if (EventIndexPeg.error) { + return <> + {_t("Message search initialisation failed, check your settings for more information", {}, { + a: sub => ( { + evt.preventDefault(); + dis.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Security, + }); + }}> + {sub} + ), + })} + ; + } + + const { desktopBuilds, brand } = SdkConfig.get(); let text = null; let logo = null; @@ -54,10 +73,10 @@ export default function DesktopBuildsNotice({isRoomEncrypted, kind}: IProps) { } else { switch (kind) { case WarningKind.Files: - text = _t("This version of %(brand)s does not support viewing some encrypted files", {brand}); + text = _t("This version of %(brand)s does not support viewing some encrypted files", { brand }); break; case WarningKind.Search: - text = _t("This version of %(brand)s does not support searching encrypted messages", {brand}); + text = _t("This version of %(brand)s does not support searching encrypted messages", { brand }); break; } } diff --git a/src/components/views/elements/DesktopCapturerSourcePicker.tsx b/src/components/views/elements/DesktopCapturerSourcePicker.tsx index cbe7cd25b0..8b7eaca7ea 100644 --- a/src/components/views/elements/DesktopCapturerSourcePicker.tsx +++ b/src/components/views/elements/DesktopCapturerSourcePicker.tsx @@ -16,12 +16,12 @@ limitations under the License. import React from 'react'; import { _t } from '../../../languageHandler'; -import BaseDialog from "..//dialogs/BaseDialog" -import DialogButtons from "./DialogButtons" +import BaseDialog from "..//dialogs/BaseDialog"; +import DialogButtons from "./DialogButtons"; +import classNames from 'classnames'; import AccessibleButton from './AccessibleButton'; import { getDesktopCapturerSources } from "matrix-js-sdk/src/webrtc/call"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import classNames from 'classnames'; export interface DesktopCapturerSource { id: string; @@ -47,7 +47,7 @@ export class ExistingSource extends React.Component { onClick = (ev) => { this.props.onSelect(this.props.source); - } + }; render() { const classes = classNames({ @@ -118,11 +118,11 @@ export default class DesktopCapturerSourcePicker extends React.Component< onSelect = (source) => { this.setState({ selectedSource: source }); - } + }; onShare = () => { this.props.onFinished(this.state.selectedSource); - } + }; onScreensClick = () => { if (this.state.selectedTab === Tabs.Screens) return; @@ -130,7 +130,7 @@ export default class DesktopCapturerSourcePicker extends React.Component< selectedTab: Tabs.Screens, selectedSource: null, }); - } + }; onWindowsClick = () => { if (this.state.selectedTab === Tabs.Windows) return; @@ -138,15 +138,15 @@ export default class DesktopCapturerSourcePicker extends React.Component< selectedTab: Tabs.Windows, selectedSource: null, }); - } + }; onCloseClick = () => { this.props.onFinished(null); - } + }; render() { const sources = this.state.sources.filter((source) => { - return source.id.startsWith(this.state.selectedTab) + return source.id.startsWith(this.state.selectedTab); }); const sourceElements = sources.map((source) => { return ( diff --git a/src/components/views/elements/DialogButtons.js b/src/components/views/elements/DialogButtons.js index dcb1cee077..af68260563 100644 --- a/src/components/views/elements/DialogButtons.js +++ b/src/components/views/elements/DialogButtons.js @@ -19,7 +19,7 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; /** * Basic container for buttons in modal dialogs. diff --git a/src/components/views/elements/DirectorySearchBox.js b/src/components/views/elements/DirectorySearchBox.js index 6447bb3cd8..45270ada64 100644 --- a/src/components/views/elements/DirectorySearchBox.js +++ b/src/components/views/elements/DirectorySearchBox.js @@ -18,7 +18,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.DirectorySearchBox") export default class DirectorySearchBox extends React.Component { @@ -42,7 +42,7 @@ export default class DirectorySearchBox extends React.Component { } _onClearClick() { - this.setState({value: ''}); + this.setState({ value: '' }); if (this.input) { this.input.focus(); @@ -55,7 +55,7 @@ export default class DirectorySearchBox extends React.Component { _onChange(ev) { if (!this.input) return; - this.setState({value: ev.target.value}); + this.setState({ value: ev.target.value }); if (this.props.onChange) { this.props.onChange(ev.target.value); diff --git a/src/components/views/elements/Draggable.tsx b/src/components/views/elements/Draggable.tsx index 6032721a48..03dccadcb9 100644 --- a/src/components/views/elements/Draggable.tsx +++ b/src/components/views/elements/Draggable.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from 'react'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { className: string; diff --git a/src/components/views/elements/Dropdown.js b/src/components/views/elements/Dropdown.js index 981c0becc0..f95247e9ae 100644 --- a/src/components/views/elements/Dropdown.js +++ b/src/components/views/elements/Dropdown.js @@ -16,13 +16,13 @@ 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 classnames from 'classnames'; import AccessibleButton from './AccessibleButton'; import { _t } from '../../../languageHandler'; -import {Key} from "../../../Keyboard"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Key } from "../../../Keyboard"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; class MenuOption extends React.Component { constructor(props) { diff --git a/src/components/views/elements/EditableItemList.js b/src/components/views/elements/EditableItemList.tsx similarity index 54% rename from src/components/views/elements/EditableItemList.js rename to src/components/views/elements/EditableItemList.tsx index d8ec5af278..89e2e1b8a0 100644 --- a/src/components/views/elements/EditableItemList.js +++ b/src/components/views/elements/EditableItemList.tsx @@ -1,5 +1,5 @@ /* -Copyright 2017, 2019 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,48 +14,48 @@ 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 React from "react"; + +import { _t } from '../../../languageHandler'; import Field from "./Field"; import AccessibleButton from "./AccessibleButton"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; -export class EditableItem extends React.Component { - static propTypes = { - index: PropTypes.number, - value: PropTypes.string, - onRemove: PropTypes.func, +interface IItemProps { + index?: number; + value?: string; + onRemove?(index: number): void; +} + +interface IItemState { + verifyRemove: boolean; +} + +export class EditableItem extends React.Component { + public state = { + verifyRemove: false, }; - constructor() { - super(); - - this.state = { - verifyRemove: false, - }; - } - - _onRemove = (e) => { + private onRemove = (e) => { e.stopPropagation(); e.preventDefault(); - this.setState({verifyRemove: true}); + this.setState({ verifyRemove: true }); }; - _onDontRemove = (e) => { + private onDontRemove = (e) => { e.stopPropagation(); e.preventDefault(); - this.setState({verifyRemove: false}); + this.setState({ verifyRemove: false }); }; - _onActuallyRemove = (e) => { + private onActuallyRemove = (e) => { e.stopPropagation(); e.preventDefault(); if (this.props.onRemove) this.props.onRemove(this.props.index); - this.setState({verifyRemove: false}); + this.setState({ verifyRemove: false }); }; render() { @@ -66,14 +66,14 @@ export class EditableItem extends React.Component { {_t("Are you sure?")}
      {_t("Yes")} @@ -85,59 +85,68 @@ export class EditableItem extends React.Component { return (
      -
      +
      {this.props.value}
      ); } } +interface IProps { + id: string; + items: string[]; + itemsLabel?: string; + noItemsLabel?: string; + placeholder?: string; + newItem?: string; + canEdit?: boolean; + canRemove?: boolean; + suggestionsListId?: string; + onItemAdded?(item: string): void; + onItemRemoved?(index: number): void; + onNewItemChanged?(item: string): void; +} + @replaceableComponent("views.elements.EditableItemList") -export default class EditableItemList extends React.Component { - static propTypes = { - id: PropTypes.string.isRequired, - items: PropTypes.arrayOf(PropTypes.string).isRequired, - itemsLabel: PropTypes.string, - noItemsLabel: PropTypes.string, - placeholder: PropTypes.string, - newItem: PropTypes.string, - - onItemAdded: PropTypes.func, - onItemRemoved: PropTypes.func, - onNewItemChanged: PropTypes.func, - - canEdit: PropTypes.bool, - canRemove: PropTypes.bool, - }; - - _onItemAdded = (e) => { +export default class EditableItemList

      extends React.PureComponent { + protected onItemAdded = (e) => { e.stopPropagation(); e.preventDefault(); if (this.props.onItemAdded) this.props.onItemAdded(this.props.newItem); }; - _onItemRemoved = (index) => { + protected onItemRemoved = (index) => { if (this.props.onItemRemoved) this.props.onItemRemoved(index); }; - _onNewItemChanged = (e) => { + protected onNewItemChanged = (e) => { if (this.props.onNewItemChanged) this.props.onNewItemChanged(e.target.value); }; - _renderNewItemField() { + protected renderNewItemField() { return ( - - - {_t("Add")} + + + { _t("Add") } ); @@ -153,19 +162,21 @@ export default class EditableItemList extends React.Component { key={item} index={index} value={item} - onRemove={this._onItemRemoved} + onRemove={this.onItemRemoved} />; }); const editableItemsSection = this.props.canRemove ? editableItems :

        {editableItems}
      ; const label = this.props.items.length > 0 ? this.props.itemsLabel : this.props.noItemsLabel; - return (
      -
      - { label } + return ( +
      +
      + { label } +
      + { editableItemsSection } + { this.props.canEdit ? this.renderNewItemField() :
      }
      - { editableItemsSection } - { this.props.canEdit ? this._renderNewItemField() :
      } -
      ); + ); } } diff --git a/src/components/views/elements/EditableText.js b/src/components/views/elements/EditableText.js index 7c38ac1777..6dbc8b8771 100644 --- a/src/components/views/elements/EditableText.js +++ b/src/components/views/elements/EditableText.js @@ -15,10 +15,10 @@ 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 {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Key } from "../../../Keyboard"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.EditableText") export default class EditableText extends React.Component { @@ -209,7 +209,7 @@ export default class EditableText extends React.Component { }; render() { - const {className, editable, initialValue, label, labelClassName} = this.props; + const { className, editable, initialValue, label, labelClassName } = this.props; let editableEl; if (!editable || (this.state.phase === EditableText.Phases.Display && diff --git a/src/components/views/elements/EditableTextContainer.js b/src/components/views/elements/EditableTextContainer.js index e925220089..5778446355 100644 --- a/src/components/views/elements/EditableTextContainer.js +++ b/src/components/views/elements/EditableTextContainer.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; /** * A component which wraps an EditableText, with a spinner while updates take @@ -50,7 +50,7 @@ export default class EditableTextContainer extends React.Component { return; } - this.setState({busy: true}); + this.setState({ busy: true }); this.props.getInitialValue().then( (result) => { @@ -144,7 +144,6 @@ EditableTextContainer.propTypes = { blurToSubmit: PropTypes.bool, }; - EditableTextContainer.defaultProps = { initialValue: "", placeholder: "", diff --git a/src/components/views/elements/EffectsOverlay.tsx b/src/components/views/elements/EffectsOverlay.tsx index 00d9d147f1..9e6833696f 100644 --- a/src/components/views/elements/EffectsOverlay.tsx +++ b/src/components/views/elements/EffectsOverlay.tsx @@ -17,7 +17,7 @@ import React, { FunctionComponent, useEffect, useRef } from 'react'; import dis from '../../../dispatcher/dispatcher'; import ICanvasEffect from '../../../effects/ICanvasEffect'; -import { CHAT_EFFECTS } from '../../../effects' +import { CHAT_EFFECTS } from '../../../effects'; import UIStore, { UI_EVENTS } from "../../../stores/UIStore"; interface IProps { @@ -32,7 +32,7 @@ const EffectsOverlay: FunctionComponent = ({ roomWidth }) => { if (!name) return null; let effect: ICanvasEffect | null = effectsRef.current[name] || null; if (effect === null) { - const options = CHAT_EFFECTS.find((e) => e.command === name)?.options + const options = CHAT_EFFECTS.find((e) => e.command === name)?.options; try { const { default: Effect } = await import(`../../../effects/${name}`); effect = new Effect(options); @@ -56,7 +56,7 @@ const EffectsOverlay: FunctionComponent = ({ roomWidth }) => { const effect = payload.action.substr(actionPrefix.length); lazyLoadEffectModule(effect).then((module) => module?.start(canvasRef.current)); } - } + }; const dispatcherRef = dis.register(onAction); const canvas = canvasRef.current; canvas.height = UIStore.instance.windowHeight; @@ -89,7 +89,7 @@ const EffectsOverlay: FunctionComponent = ({ roomWidth }) => { right: 0, }} /> - ) -} + ); +}; export default EffectsOverlay; diff --git a/src/components/views/elements/ErrorBoundary.js b/src/components/views/elements/ErrorBoundary.tsx similarity index 80% rename from src/components/views/elements/ErrorBoundary.js rename to src/components/views/elements/ErrorBoundary.tsx index 9037287f49..f967b8c594 100644 --- a/src/components/views/elements/ErrorBoundary.js +++ b/src/components/views/elements/ErrorBoundary.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. @@ -14,21 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import * as sdk from '../../../index'; +import React, { ErrorInfo } from 'react'; + import { _t } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import PlatformPeg from '../../../PlatformPeg'; import Modal from '../../../Modal'; import SdkConfig from "../../../SdkConfig"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BugReportDialog from '../dialogs/BugReportDialog'; +import AccessibleButton from './AccessibleButton'; + +interface IState { + error: Error; +} /** * This error boundary component can be used to wrap large content areas and * catch exceptions during rendering in the component tree below them. */ @replaceableComponent("views.elements.ErrorBoundary") -export default class ErrorBoundary extends React.PureComponent { +export default class ErrorBoundary extends React.PureComponent<{}, IState> { constructor(props) { super(props); @@ -37,13 +43,13 @@ export default class ErrorBoundary extends React.PureComponent { }; } - static getDerivedStateFromError(error) { + static getDerivedStateFromError(error: Error): Partial { // Side effects are not permitted here, so we only update the state so // that the next render shows an error message. return { error }; } - componentDidCatch(error, { componentStack }) { + componentDidCatch(error: Error, { componentStack }: ErrorInfo): void { // Browser consoles are better at formatting output when native errors are passed // in their own `console.error` invocation. console.error(error); @@ -53,7 +59,7 @@ export default class ErrorBoundary extends React.PureComponent { ); } - _onClearCacheAndReload = () => { + private onClearCacheAndReload = (): void => { if (!PlatformPeg.get()) return; MatrixClientPeg.get().stopClient(); @@ -62,11 +68,7 @@ export default class ErrorBoundary extends React.PureComponent { }); }; - _onBugReport = () => { - const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); - if (!BugReportDialog) { - return; - } + private onBugReport = (): void => { Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, { label: 'react-soft-crash', }); @@ -74,7 +76,6 @@ export default class ErrorBoundary extends React.PureComponent { render() { if (this.state.error) { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const newIssueUrl = "https://github.com/vector-im/element-web/issues/new"; let bugReportSection; @@ -95,7 +96,7 @@ export default class ErrorBoundary extends React.PureComponent { "the rooms or groups you have visited and the usernames of " + "other users. They do not contain messages.", )}

      - + {_t("Submit debug logs")} ; @@ -105,7 +106,7 @@ export default class ErrorBoundary extends React.PureComponent {

      {_t("Something went wrong!")}

      { bugReportSection } - + {_t("Clear cache and reload")}
      diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx index 86d3e082ad..681817ca86 100644 --- a/src/components/views/elements/EventListSummary.tsx +++ b/src/components/views/elements/EventListSummary.tsx @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ReactChildren, useEffect} from 'react'; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; -import {RoomMember} from "matrix-js-sdk/src/models/room-member"; +import React, { ReactNode, useEffect } from 'react'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import MemberAvatar from '../avatars/MemberAvatar'; import { _t } from '../../../languageHandler'; -import {useStateToggle} from "../../../hooks/useStateToggle"; +import { useStateToggle } from "../../../hooks/useStateToggle"; import AccessibleButton from "./AccessibleButton"; interface IProps { @@ -29,13 +29,13 @@ interface IProps { // The minimum number of events needed to trigger summarisation threshold?: number; // Whether or not to begin with state.expanded=true - startExpanded?: boolean, + startExpanded?: boolean; // The list of room members for which to show avatars next to the summary - summaryMembers?: RoomMember[], + summaryMembers?: RoomMember[]; // The text to show as the summary of this event list - summaryText?: string, + summaryText?: string; // An array of EventTiles to render when expanded - children: ReactChildren, + children: ReactNode[]; // Called when the event list expansion is toggled onToggle?(): void; } diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index b15fbbed2b..68a70133e6 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -17,13 +17,14 @@ limitations under the License. import React from 'react'; import classnames from 'classnames'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; import * as Avatar from '../../../Avatar'; import EventTile from '../rooms/EventTile'; import SettingsStore from "../../../settings/SettingsStore"; -import {Layout} from "../../../settings/Layout"; -import {UIFeature} from "../../../settings/UIFeature"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Layout } from "../../../settings/Layout"; +import { UIFeature } from "../../../settings/UIFeature"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { /** @@ -72,7 +73,7 @@ export default class EventTilePreview extends React.Component { }; } - private fakeEvent({message}: IState) { + private fakeEvent({ message }: IState) { // Fake it till we make it /* eslint-disable quote-props */ const rawEvent = { @@ -101,7 +102,8 @@ export default class EventTilePreview extends React.Component { // Fake it more event.sender = { - name: this.props.displayName, + name: this.props.displayName || this.props.userId, + rawDisplayName: this.props.displayName, userId: this.props.userId, getAvatarUrl: (..._) => { return Avatar.avatarUrlForUser( @@ -110,7 +112,7 @@ export default class EventTilePreview extends React.Component { ); }, getMxcAvatarUrl: () => this.props.avatarUrl, - }; + } as RoomMember; return event; } @@ -128,6 +130,7 @@ export default class EventTilePreview extends React.Component { mxEvent={event} layout={this.props.layout} enableFlair={SettingsStore.getValue(UIFeature.Flair)} + as="div" />
      ; } diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 59d9a11596..297044e422 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes} from 'react'; +import React, { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'; import classNames from 'classnames'; import * as sdk from '../../../index'; -import {debounce} from "lodash"; -import {IFieldState, IValidationResult} from "./Validation"; +import { debounce } from "lodash"; +import { IFieldState, IValidationResult } from "./Validation"; // Invoke validation from user input (when typing, etc.) at most once every N ms. const VALIDATION_THROTTLE_MS = 200; @@ -29,6 +29,11 @@ function getId() { return `${BASE_ID}_${count++}`; } +export interface IValidateOpts { + focused?: boolean; + allowEmpty?: boolean; +} + interface IProps { // The field's ID, which binds the input and label together. Immutable. id?: string; @@ -180,7 +185,7 @@ export default class Field extends React.PureComponent { } }; - public async validate({ focused, allowEmpty = true }: {focused?: boolean, allowEmpty?: boolean}) { + public async validate({ focused, allowEmpty = true }: IValidateOpts) { if (!this.props.onValidate) { return; } @@ -217,7 +222,7 @@ export default class Field extends React.PureComponent { /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ const { element, prefixComponent, postfixComponent, className, onValidate, children, tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus, - ...inputProps} = this.props; + ...inputProps } = this.props; // Set some defaults for the element const ref = input => this.input = input; @@ -229,7 +234,7 @@ export default class Field extends React.PureComponent { inputProps.onBlur = this.onBlur; // Appease typescript's inference - const inputProps_ = {...inputProps, ref, list}; + const inputProps_ = { ...inputProps, ref, list }; const fieldInput = React.createElement(this.props.element, inputProps_, children); diff --git a/src/components/views/elements/Flair.js b/src/components/views/elements/Flair.js index 23858b860d..873d65d5bd 100644 --- a/src/components/views/elements/Flair.js +++ b/src/components/views/elements/Flair.js @@ -19,9 +19,8 @@ import PropTypes from 'prop-types'; import FlairStore from '../../../stores/FlairStore'; import dis from '../../../dispatcher/dispatcher'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; - +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; class FlairAvatar extends React.Component { constructor() { diff --git a/src/components/views/elements/FormButton.js b/src/components/views/elements/FormButton.js deleted file mode 100644 index f6b4c986f5..0000000000 --- a/src/components/views/elements/FormButton.js +++ /dev/null @@ -1,28 +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 AccessibleButton from "./AccessibleButton"; - -export default function FormButton(props) { - const {className, label, kind, ...restProps} = props; - const newClassName = (className || "") + " mx_FormButton"; - const allProps = Object.assign({}, restProps, - {className: newClassName, kind: kind || "primary", children: [label]}); - return React.createElement(AccessibleButton, allProps); -} - -FormButton.propTypes = AccessibleButton.propTypes; diff --git a/src/components/views/elements/IRCTimelineProfileResizer.tsx b/src/components/views/elements/IRCTimelineProfileResizer.tsx index cd1ccf2fc4..13e8ae2ae2 100644 --- a/src/components/views/elements/IRCTimelineProfileResizer.tsx +++ b/src/components/views/elements/IRCTimelineProfileResizer.tsx @@ -16,9 +16,9 @@ limitations under the License. import React from 'react'; import SettingsStore from "../../../settings/SettingsStore"; -import Draggable, {ILocationState} from './Draggable'; +import Draggable, { ILocationState } from './Draggable'; import { SettingLevel } from "../../../settings/SettingLevel"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { // Current room diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index 4812817359..90f5d18be7 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -24,13 +24,13 @@ import FocusLock from "react-focus-lock"; import MemberAvatar from "../avatars/MemberAvatar"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import MessageContextMenu from "../context_menus/MessageContextMenu"; -import { aboveLeftOf, ContextMenu } from '../../structures/ContextMenu'; +import { aboveLeftOf } from '../../structures/ContextMenu'; import MessageTimestamp from "../messages/MessageTimestamp"; import SettingsStore from "../../../settings/SettingsStore"; import { formatFullDate } from "../../../DateUtils"; import dis from '../../../dispatcher/dispatcher'; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks" +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { normalizeWheelEvent } from "../../../utils/Mouse"; @@ -44,31 +44,31 @@ const ZOOM_COEFFICIENT = 0.0025; const ZOOM_DISTANCE = 10; interface IProps { - src: string, // the source of the image being displayed - name?: string, // the main title ('name') for the image - link?: string, // the link (if any) applied to the name of the image - width?: number, // width of the image src in pixels - height?: number, // height of the image src in pixels - fileSize?: number, // size of the image src in bytes - onFinished(): void, // callback when the lightbox is dismissed + src: string; // the source of the image being displayed + name?: string; // the main title ('name') for the image + link?: string; // the link (if any) applied to the name of the image + width?: number; // width of the image src in pixels + height?: number; // height of the image src in pixels + fileSize?: number; // size of the image src in bytes + onFinished(): void; // callback when the lightbox is dismissed // the event (if any) that the Image is displaying. Used for event-specific stuff like // redactions, senders, timestamps etc. Other descriptors are taken from the explicit // properties above, which let us use lightboxes to display images which aren't associated // with events. - mxEvent: MatrixEvent, - permalinkCreator: RoomPermalinkCreator, + mxEvent: MatrixEvent; + permalinkCreator: RoomPermalinkCreator; } interface IState { - zoom: number, - minZoom: number, - maxZoom: number, - rotation: number, - translationX: number, - translationY: number, - moving: boolean, - contextMenuDisplayed: boolean, + zoom: number; + minZoom: number; + maxZoom: number; + rotation: number; + translationX: number; + translationY: number; + moving: boolean; + contextMenuDisplayed: boolean; } @replaceableComponent("views.elements.ImageView") @@ -116,13 +116,13 @@ export default class ImageView extends React.Component { private recalculateZoom = () => { this.setZoomAndRotation(); - } + }; private setZoomAndRotation = (inputRotation?: number) => { const image = this.image.current; const imageWrapper = this.imageWrapper.current; - const rotation = inputRotation || this.state.rotation; + const rotation = inputRotation ?? this.state.rotation; const imageIsNotFlipped = rotation % 180 === 0; @@ -158,7 +158,7 @@ export default class ImageView extends React.Component { rotation: rotation, zoom: zoom, }); - } + }; private zoom(delta: number) { const newZoom = this.state.zoom + delta; @@ -304,17 +304,13 @@ export default class ImageView extends React.Component { let contextMenu = null; if (this.state.contextMenuDisplayed) { contextMenu = ( - - - + onCloseDialog={this.props.onFinished} + /> ); } diff --git a/src/components/views/elements/InfoTooltip.tsx b/src/components/views/elements/InfoTooltip.tsx index d49090dbae..de82c5daeb 100644 --- a/src/components/views/elements/InfoTooltip.tsx +++ b/src/components/views/elements/InfoTooltip.tsx @@ -18,9 +18,9 @@ limitations under the License. import React from 'react'; import classNames from 'classnames'; -import Tooltip, {Alignment} from './Tooltip'; -import {_t} from "../../../languageHandler"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import Tooltip, { Alignment } from './Tooltip'; +import { _t } from "../../../languageHandler"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface ITooltipProps { tooltip?: React.ReactNode; @@ -53,7 +53,7 @@ export default class InfoTooltip extends React.PureComponent { static defaultProps = { w: 16, h: 16, - } + }; render() { return (
      {this.props.children} diff --git a/src/components/views/elements/InviteReason.tsx b/src/components/views/elements/InviteReason.tsx index ddce7552ed..d684f61859 100644 --- a/src/components/views/elements/InviteReason.tsx +++ b/src/components/views/elements/InviteReason.tsx @@ -42,7 +42,7 @@ export default class InviteReason extends React.PureComponent { this.setState({ hidden: false, }); - } + }; render() { const classes = classNames({ diff --git a/src/components/views/elements/LabelledToggleSwitch.js b/src/components/views/elements/LabelledToggleSwitch.tsx similarity index 60% rename from src/components/views/elements/LabelledToggleSwitch.js rename to src/components/views/elements/LabelledToggleSwitch.tsx index ef60eeed7b..14853ea117 100644 --- a/src/components/views/elements/LabelledToggleSwitch.js +++ b/src/components/views/elements/LabelledToggleSwitch.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +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,38 +14,33 @@ 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 ToggleSwitch from "./ToggleSwitch"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + // The value for the toggle switch + value: boolean; + // The translated label for the switch + label: string; + // Whether or not to disable the toggle switch + disabled?: boolean; + // True to put the toggle in front of the label + // Default false. + toggleInFront?: boolean; + // Additional class names to append to the switch. Optional. + className?: string; + // The function to call when the value changes + onChange(checked: boolean): void; +} @replaceableComponent("views.elements.LabelledToggleSwitch") -export default class LabelledToggleSwitch extends React.Component { - static propTypes = { - // The value for the toggle switch - value: PropTypes.bool.isRequired, - - // The function to call when the value changes - onChange: PropTypes.func.isRequired, - - // The translated label for the switch - label: PropTypes.string.isRequired, - - // Whether or not to disable the toggle switch - disabled: PropTypes.bool, - - // True to put the toggle in front of the label - // Default false. - toggleInFront: PropTypes.bool, - - // Additional class names to append to the switch. Optional. - className: PropTypes.string, - }; - +export default class LabelledToggleSwitch extends React.PureComponent { render() { // This is a minimal version of a SettingsFlag - let firstPart = {this.props.label}; + let firstPart = { this.props.label }; let secondPart = b.label) return 1; return 0; }); - this.setState({langs}); + this.setState({ langs }); }).catch(() => { - this.setState({langs: ['en']}); + this.setState({ langs: ['en'] }); }); if (!this.props.value) { diff --git a/src/components/views/elements/LazyRenderList.js b/src/components/views/elements/LazyRenderList.js index f2c8148cd2..070d9bcc8d 100644 --- a/src/components/views/elements/LazyRenderList.js +++ b/src/components/views/elements/LazyRenderList.js @@ -16,7 +16,7 @@ limitations under the License. import React from "react"; import PropTypes from 'prop-types'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; class ItemRange { constructor(topCount, renderCount, bottomCount) { @@ -72,13 +72,13 @@ export default class LazyRenderList extends React.Component { // only update render Range if the list has shrunk/grown and we need to adjust padding OR // if the new range + overflowMargin isn't contained by the old anymore if (listHasChangedSize || !state.renderRange || !state.renderRange.contains(intersectRange)) { - return {renderRange}; + return { renderRange }; } return null; } static getVisibleRangeFromProps(props) { - const {items, itemHeight, scrollTop, height} = props; + const { items, itemHeight, scrollTop, height } = props; const length = items ? items.length : 0; const topCount = Math.min(Math.max(0, Math.floor(scrollTop / itemHeight)), length); const itemsAfterTop = length - topCount; @@ -89,9 +89,9 @@ export default class LazyRenderList extends React.Component { } render() { - const {itemHeight, items, renderItem} = this.props; - const {renderRange} = this.state; - const {topCount, renderCount, bottomCount} = renderRange; + const { itemHeight, items, renderItem } = this.props; + const { renderRange } = this.state; + const { topCount, renderCount, bottomCount } = renderRange; const paddingTop = topCount * itemHeight; const paddingBottom = bottomCount * itemHeight; @@ -102,7 +102,7 @@ export default class LazyRenderList extends React.Component { const element = this.props.element || "div"; const elementProps = { - "style": {paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px`}, + "style": { paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px` }, "className": this.props.className, }; return React.createElement(element, elementProps, renderedItems.map(renderItem)); diff --git a/src/components/views/elements/MemberEventListSummary.tsx b/src/components/views/elements/MemberEventListSummary.tsx index 0290ef6d83..d52462f629 100644 --- a/src/components/views/elements/MemberEventListSummary.tsx +++ b/src/components/views/elements/MemberEventListSummary.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactChildren } from 'react'; +import React, { ComponentProps } from 'react'; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; @@ -24,23 +24,13 @@ import { _t } from '../../../languageHandler'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import { isValid3pidInvite } from "../../../RoomInvite"; import EventListSummary from "./EventListSummary"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; -interface IProps { - // An array of member events to summarise - events: MatrixEvent[]; +interface IProps extends Omit, "summaryText" | "summaryMembers"> { // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" summaryLength?: number; // The maximum number of avatars to display in the summary avatarsMaxLength?: number; - // The minimum number of events needed to trigger summarisation - threshold?: number, - // Whether or not to begin with state.expanded=true - startExpanded?: boolean, - // An array of EventTiles to render when expanded - children: ReactChildren; - // Called when the MELS expansion is toggled - onToggle?(): void, } interface IUserEvents { @@ -66,6 +56,7 @@ enum TransitionType { ChangedName = "changed_name", ChangedAvatar = "changed_avatar", NoChange = "no_change", + ServerAcl = "server_acl", } const SEP = ","; @@ -298,12 +289,18 @@ export default class MemberEventListSummary extends React.Component { ? _t("%(severalUsers)smade no changes %(count)s times", { severalUsers: "", count: repeats }) : _t("%(oneUser)smade no changes %(count)s times", { oneUser: "", count: repeats }); break; + case "server_acl": + res = (userCount > 1) + ? _t("%(severalUsers)schanged the server ACLs %(count)s times", + { severalUsers: "", count: repeats }) + : _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count: repeats }); + break; } return res; } - private static getTransitionSequence(events: MatrixEvent[]) { + private static getTransitionSequence(events: IUserEvents[]) { return events.map(MemberEventListSummary.getTransition); } @@ -315,7 +312,7 @@ export default class MemberEventListSummary extends React.Component { * @returns {string?} the transition type given to this event. This defaults to `null` * if a transition is not recognised. */ - private static getTransition(e: MatrixEvent): TransitionType { + private static getTransition(e: IUserEvents): TransitionType { if (e.mxEvent.getType() === 'm.room.third_party_invite') { // Handle 3pid invites the same as invites so they get bundled together if (!isValid3pidInvite(e.mxEvent)) { @@ -324,6 +321,10 @@ export default class MemberEventListSummary extends React.Component { return TransitionType.Invited; } + if (e.mxEvent.getType() === 'm.room.server_acl') { + return TransitionType.ServerAcl; + } + switch (e.mxEvent.getContent().membership) { case 'invite': return TransitionType.Invited; case 'ban': return TransitionType.Banned; @@ -410,19 +411,23 @@ export default class MemberEventListSummary extends React.Component { // Object mapping user IDs to an array of IUserEvents const userEvents: Record = {}; eventsToRender.forEach((e, index) => { - const userId = e.getStateKey(); + const userId = e.getType() === 'm.room.server_acl' ? e.getSender() : e.getStateKey(); // Initialise a user's events if (!userEvents[userId]) { userEvents[userId] = []; } - if (e.target) { + if (e.getType() === 'm.room.server_acl') { + latestUserAvatarMember.set(userId, e.sender); + } else if (e.target) { latestUserAvatarMember.set(userId, e.target); } let displayName = userId; if (e.getType() === 'm.room.third_party_invite') { displayName = e.getContent().display_name; + } else if (e.getType() === 'm.room.server_acl') { + displayName = e.sender.name; } else if (e.target) { displayName = e.target.name; } diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index 32ef0d4da2..83fc1ebefd 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useContext, useRef, useState} from 'react'; -import {EventType} from 'matrix-js-sdk/src/@types/event'; +import React, { useContext, useRef, useState } from 'react'; +import { EventType } from 'matrix-js-sdk/src/@types/event'; import classNames from 'classnames'; import AccessibleButton from "./AccessibleButton"; import Spinner from "./Spinner"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {useTimeout} from "../../../hooks/useTimeout"; +import { useTimeout } from "../../../hooks/useTimeout"; import Analytics from "../../../Analytics"; import CountlyAnalytics from '../../../CountlyAnalytics'; import RoomContext from "../../../contexts/RoomContext"; @@ -52,7 +52,7 @@ const MiniAvatarUploader: React.FC = ({ hasAvatar, hasAvatarLabel, noAva const label = (hasAvatar || busy) ? hasAvatarLabel : noAvatarLabel; - const {room} = useContext(RoomContext); + const { room } = useContext(RoomContext); const canSetAvatar = room?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId()); if (!canSetAvatar) return { children }; diff --git a/src/components/views/elements/PersistedElement.js b/src/components/views/elements/PersistedElement.js index 701c140a19..22d4bfdd68 100644 --- a/src/components/views/elements/PersistedElement.js +++ b/src/components/views/elements/PersistedElement.js @@ -17,14 +17,14 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; -import {throttle} from "lodash"; +import { throttle } from "lodash"; import ResizeObserver from 'resize-observer-polyfill'; import dis from '../../../dispatcher/dispatcher'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {isNullOrUndefined} from "matrix-js-sdk/src/utils"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -180,11 +180,11 @@ export default class PersistedElement extends React.Component { width: parentRect.width + 'px', height: parentRect.height + 'px', }); - }, 100, {trailing: true, leading: true}); + }, 100, { trailing: true, leading: true }); render() { return
      ; } } -export const getPersistKey = (appId: string) => 'widget_' + appId; +export const getPersistKey = (appId) => 'widget_' + appId; diff --git a/src/components/views/elements/PersistentApp.js b/src/components/views/elements/PersistentApp.js index 5df373e4fe..763ab63487 100644 --- a/src/components/views/elements/PersistentApp.js +++ b/src/components/views/elements/PersistentApp.js @@ -20,8 +20,8 @@ import RoomViewStore from '../../../stores/RoomViewStore'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import WidgetUtils from '../../../utils/WidgetUtils'; import * as sdk from '../../../index'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.PersistentApp") export default class PersistentApp extends React.Component { diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.js index ace41db39d..ba166ccfc6 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.js @@ -20,14 +20,14 @@ import classNames from 'classnames'; import { Room } from 'matrix-js-sdk/src/models/room'; import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; import PropTypes from 'prop-types'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import FlairStore from "../../../stores/FlairStore"; -import {getPrimaryPermalinkEntity, parseAppLocalLink} from "../../../utils/permalinks/Permalinks"; +import { getPrimaryPermalinkEntity, parseAppLocalLink } from "../../../utils/permalinks/Permalinks"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {Action} from "../../../dispatcher/actions"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { Action } from "../../../dispatcher/actions"; +import { mediaFromMxc } from "../../../customisations/Media"; import Tooltip from './Tooltip'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.Pill") class Pill extends React.Component { @@ -144,7 +144,7 @@ class Pill extends React.Component { } } } - this.setState({resourceId, pillType, member, group, room}); + this.setState({ resourceId, pillType, member, group, room }); } componentDidMount() { @@ -180,13 +180,13 @@ class Pill extends React.Component { member.rawDisplayName = resp.displayname; member.events.member = { getContent: () => { - return {avatar_url: resp.avatar_url}; + return { avatar_url: resp.avatar_url }; }, getDirectionalContent: function() { return this.getContent(); }, }; - this.setState({member}); + this.setState({ member }); }).catch((err) => { console.error('Could not retrieve profile data for ' + userId + ':', err); }); @@ -253,7 +253,7 @@ class Pill extends React.Component { break; case Pill.TYPE_GROUP_MENTION: { if (this.state.group) { - const {avatarUrl, groupId, name} = this.state.group; + const { avatarUrl, groupId, name } = this.state.group; linkText = groupId; if (this.props.shouldShowPillAvatar) { @@ -273,7 +273,7 @@ class Pill extends React.Component { }); if (this.state.pillType) { - const {yOffset} = this.props; + const { yOffset } = this.props; let tip; if (this.state.hover && resource) { diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js index a7a65425a3..ef449df295 100644 --- a/src/components/views/elements/PowerSelector.js +++ b/src/components/views/elements/PowerSelector.js @@ -19,8 +19,8 @@ import PropTypes from 'prop-types'; import * as Roles from '../../../Roles'; import { _t } from '../../../languageHandler'; import Field from "./Field"; -import {Key} from "../../../Keyboard"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Key } from "../../../Keyboard"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.PowerSelector") export default class PowerSelector extends React.Component { @@ -97,15 +97,15 @@ export default class PowerSelector extends React.Component { onSelectChange = event => { const isCustom = event.target.value === "SELECT_VALUE_CUSTOM"; if (isCustom) { - this.setState({custom: true}); + this.setState({ custom: true }); } else { this.props.onChange(event.target.value, this.props.powerLevelKey); - this.setState({selectValue: event.target.value}); + this.setState({ selectValue: event.target.value }); } }; onCustomChange = event => { - this.setState({customValue: event.target.value}); + this.setState({ customValue: event.target.value }); }; onCustomBlur = event => { diff --git a/src/components/views/elements/ProgressBar.tsx b/src/components/views/elements/ProgressBar.tsx index 90832e5006..c4ff06e04e 100644 --- a/src/components/views/elements/ProgressBar.tsx +++ b/src/components/views/elements/ProgressBar.tsx @@ -21,7 +21,7 @@ interface IProps { max: number; } -const ProgressBar: React.FC = ({value, max}) => { +const ProgressBar: React.FC = ({ value, max }) => { return ; }; diff --git a/src/components/views/elements/QRCode.tsx b/src/components/views/elements/QRCode.tsx index 9ce3dc7202..50b12ae23d 100644 --- a/src/components/views/elements/QRCode.tsx +++ b/src/components/views/elements/QRCode.tsx @@ -15,10 +15,10 @@ limitations under the License. */ import * as React from "react"; -import {toDataURL, QRCodeSegment, QRCodeToDataURLOptions} from "qrcode"; +import { toDataURL, QRCodeSegment, QRCodeToDataURLOptions } from "qrcode"; import classNames from "classnames"; -import {_t} from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import Spinner from "./Spinner"; interface IProps extends QRCodeToDataURLOptions { @@ -30,11 +30,11 @@ const defaultOptions: QRCodeToDataURLOptions = { errorCorrectionLevel: 'L', // we want it as trivial-looking as possible }; -const QRCode: React.FC = ({data, className, ...options}) => { +const QRCode: React.FC = ({ data, className, ...options }) => { const [dataUri, setUri] = React.useState(null); React.useEffect(() => { let cancelled = false; - toDataURL(data, {...defaultOptions, ...options}).then(uri => { + toDataURL(data, { ...defaultOptions, ...options }).then(uri => { if (cancelled) return; setUri(uri); }); diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js index 81ed360b17..aea447c9b1 100644 --- a/src/components/views/elements/ReplyThread.js +++ b/src/components/views/elements/ReplyThread.js @@ -17,21 +17,21 @@ limitations under the License. */ import React from 'react'; import * as sdk from '../../../index'; -import {_t} from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher/dispatcher'; -import {wantsDateSeparator} from '../../../DateUtils'; -import {MatrixEvent} from 'matrix-js-sdk/src/models/event'; -import {makeUserPermalink, RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"; +import { wantsDateSeparator } from '../../../DateUtils'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import SettingsStore from "../../../settings/SettingsStore"; -import {LayoutPropType} from "../../../settings/Layout"; +import { LayoutPropType } from "../../../settings/Layout"; import escapeHtml from "escape-html"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {Action} from "../../../dispatcher/actions"; +import { Action } from "../../../dispatcher/actions"; import sanitizeHtml from "sanitize-html"; -import {UIFeature} from "../../../settings/UIFeature"; -import {PERMITTED_URL_SCHEMES} from "../../../HtmlUtils"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { UIFeature } from "../../../settings/UIFeature"; +import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; // This component does no cycle detection, simply because the only way to make such a cycle would be to // craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would @@ -130,7 +130,7 @@ export default class ReplyThread extends React.Component { static getNestedReplyText(ev, permalinkCreator) { if (!ev) return null; - let {body, formatted_body: html} = ev.getContent(); + let { body, formatted_body: html } = ev.getContent(); if (this.getParentEventId(ev)) { if (body) body = this.stripPlainReply(body); } @@ -200,7 +200,7 @@ export default class ReplyThread extends React.Component { return null; } - return {body, html}; + return { body, html }; } static makeReplyMixIn(ev) { @@ -269,7 +269,7 @@ export default class ReplyThread extends React.Component { }; async initialize() { - const {parentEv} = this.props; + const { parentEv } = this.props; // at time of making this component we checked that props.parentEv has a parentEventId const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv)); @@ -283,7 +283,7 @@ export default class ReplyThread extends React.Component { loading: false, }); } else { - this.setState({err: true}); + this.setState({ err: true }); } } @@ -297,6 +297,7 @@ export default class ReplyThread extends React.Component { } async getEvent(eventId) { + if (!eventId) return null; const event = this.room.findEventById(eventId); if (event) return event; @@ -392,6 +393,7 @@ export default class ReplyThread extends React.Component { alwaysShowTimestamps={this.props.alwaysShowTimestamps} enableFlair={SettingsStore.getValue(UIFeature.Flair)} replacingEventId={ev.replacingEventId()} + as="div" /> ; }); diff --git a/src/components/views/elements/RoomAliasField.js b/src/components/views/elements/RoomAliasField.tsx similarity index 63% rename from src/components/views/elements/RoomAliasField.js rename to src/components/views/elements/RoomAliasField.tsx index 813dd8b5cc..d9e081341b 100644 --- a/src/components/views/elements/RoomAliasField.js +++ b/src/components/views/elements/RoomAliasField.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +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. @@ -13,67 +13,78 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ + +import React, { createRef } from "react"; + import { _t } from '../../../languageHandler'; -import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import withValidation from './Validation'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Field, { IValidateOpts } from "./Field"; + +interface IProps { + domain: string; + value: string; + label?: string; + placeholder?: string; + onChange?(value: string): void; +} + +interface IState { + isValid: boolean; +} // Controlled form component wrapping Field for inputting a room alias scoped to a given domain @replaceableComponent("views.elements.RoomAliasField") -export default class RoomAliasField extends React.PureComponent { - static propTypes = { - domain: PropTypes.string.isRequired, - onChange: PropTypes.func, - value: PropTypes.string.isRequired, - }; +export default class RoomAliasField extends React.PureComponent { + private fieldRef = createRef(); - constructor(props) { - super(props); - this.state = {isValid: true}; + constructor(props, context) { + super(props, context); + + this.state = { + isValid: true, + }; } - _asFullAlias(localpart) { + private asFullAlias(localpart: string): string { return `#${localpart}:${this.props.domain}`; } render() { - const Field = sdk.getComponent('views.elements.Field'); const poundSign = (#); const aliasPostfix = ":" + this.props.domain; const domain = ({aliasPostfix}); const maxlength = 255 - this.props.domain.length - 2; // 2 for # and : return ( this._fieldRef = ref} - onValidate={this._onValidate} - placeholder={_t("e.g. my-room")} - onChange={this._onChange} + ref={this.fieldRef} + onValidate={this.onValidate} + placeholder={this.props.placeholder || _t("e.g. my-room")} + onChange={this.onChange} value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)} maxLength={maxlength} /> ); } - _onChange = (ev) => { + private onChange = (ev) => { if (this.props.onChange) { - this.props.onChange(this._asFullAlias(ev.target.value)); + this.props.onChange(this.asFullAlias(ev.target.value)); } }; - _onValidate = async (fieldState) => { - const result = await this._validationRules(fieldState); - this.setState({isValid: result.valid}); + private onValidate = async (fieldState) => { + const result = await this.validationRules(fieldState); + this.setState({ isValid: result.valid }); return result; }; - _validationRules = withValidation({ + private validationRules = withValidation({ rules: [ { key: "safeLocalpart", @@ -81,7 +92,7 @@ export default class RoomAliasField extends React.PureComponent { if (!value) { return true; } - const fullAlias = this._asFullAlias(value); + const fullAlias = this.asFullAlias(value); // XXX: FIXME https://github.com/matrix-org/matrix-doc/issues/668 return !value.includes("#") && !value.includes(":") && !value.includes(",") && encodeURI(fullAlias) === fullAlias; @@ -90,17 +101,17 @@ export default class RoomAliasField extends React.PureComponent { }, { key: "required", test: async ({ value, allowEmpty }) => allowEmpty || !!value, - invalid: () => _t("Please provide a room address"), + invalid: () => _t("Please provide an address"), }, { key: "taken", final: true, - test: async ({value}) => { + test: async ({ value }) => { if (!value) { return true; } const client = MatrixClientPeg.get(); try { - await client.getRoomIdForAlias(this._asFullAlias(value)); + await client.getRoomIdForAlias(this.asFullAlias(value)); // we got a room id, so the alias is taken return false; } catch (err) { @@ -116,15 +127,15 @@ export default class RoomAliasField extends React.PureComponent { ], }); - get isValid() { + public get isValid() { return this.state.isValid; } - validate(options) { - return this._fieldRef.validate(options); + public validate(options: IValidateOpts) { + return this.fieldRef.current?.validate(options); } - focus() { - this._fieldRef.focus(); + public focus() { + this.fieldRef.current?.focus(); } } diff --git a/src/components/views/elements/RoomName.tsx b/src/components/views/elements/RoomName.tsx index 9178155d19..cdd83aedc2 100644 --- a/src/components/views/elements/RoomName.tsx +++ b/src/components/views/elements/RoomName.tsx @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {useEffect, useState} from "react"; -import {Room} from "matrix-js-sdk/src/models/room"; +import React, { useEffect, useState } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; -import {useEventEmitter} from "../../../hooks/useEventEmitter"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; interface IProps { room: Room; @@ -34,7 +34,7 @@ const RoomName = ({ room, children }: IProps): JSX.Element => { }, [room]); if (children) return children(name); - return name || ""; + return <>{ name || "" }; }; export default RoomName; diff --git a/src/components/views/elements/RoomTopic.tsx b/src/components/views/elements/RoomTopic.tsx index fe8aa5a83d..0526c2676c 100644 --- a/src/components/views/elements/RoomTopic.tsx +++ b/src/components/views/elements/RoomTopic.tsx @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useEffect, useState} from "react"; -import {EventType} from "matrix-js-sdk/src/@types/event"; -import {Room} from "matrix-js-sdk/src/models/room"; +import React, { useEffect, useState } from "react"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { Room } from "matrix-js-sdk/src/models/room"; -import {useEventEmitter} from "../../../hooks/useEventEmitter"; -import {linkifyElement} from "../../../HtmlUtils"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; +import { linkifyElement } from "../../../HtmlUtils"; interface IProps { room?: Room; diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index a531abdf83..68c58a3e56 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -17,14 +17,14 @@ limitations under the License. import React from "react"; import { chunk } from "lodash"; import classNames from "classnames"; -import {MatrixClient} from "matrix-js-sdk/src/client"; +import { MatrixClient } from "matrix-js-sdk/src/client"; import PlatformPeg from "../../../PlatformPeg"; import AccessibleButton from "./AccessibleButton"; -import {_t} from "../../../languageHandler"; -import {IdentityProviderBrand, IIdentityProvider, ISSOFlow} from "../../../Login"; +import { _t } from "../../../languageHandler"; +import { IdentityProviderBrand, IIdentityProvider, ISSOFlow } from "../../../Login"; import AccessibleTooltipButton from "./AccessibleTooltipButton"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { mediaFromMxc } from "../../../customisations/Media"; interface ISSOButtonProps extends Omit { idp: IIdentityProvider; @@ -48,7 +48,7 @@ const getIcon = (brand: IdentityProviderBrand | string) => { default: return null; } -} +}; const SSOButton: React.FC = ({ matrixClient, @@ -111,7 +111,7 @@ interface IProps { const MAX_PER_ROW = 6; -const SSOButtons: React.FC = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => { +const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary }) => { const providers = flow.identity_providers || []; if (providers.length < 2) { return
      diff --git a/src/components/views/elements/ServerPicker.tsx b/src/components/views/elements/ServerPicker.tsx index 9f06e2618c..c2d1fcb275 100644 --- a/src/components/views/elements/ServerPicker.tsx +++ b/src/components/views/elements/ServerPicker.tsx @@ -17,8 +17,8 @@ limitations under the License. import React from 'react'; import AccessibleButton from "./AccessibleButton"; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import {_t} from "../../../languageHandler"; +import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; +import { _t } from "../../../languageHandler"; import TextWithTooltip from "./TextWithTooltip"; import SdkConfig from "../../../SdkConfig"; import Modal from "../../../Modal"; @@ -87,7 +87,7 @@ const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange } {serverName} { editBtn } { desc } -
      -} +
      ; +}; export default ServerPicker; diff --git a/src/components/views/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx index 4f885ab47d..ccde80ff00 100644 --- a/src/components/views/elements/SettingsFlag.tsx +++ b/src/components/views/elements/SettingsFlag.tsx @@ -21,7 +21,7 @@ import { _t } from '../../../languageHandler'; import ToggleSwitch from "./ToggleSwitch"; import StyledCheckbox from "./StyledCheckbox"; import { SettingLevel } from "../../../settings/SettingLevel"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { // The setting must be a boolean @@ -77,9 +77,10 @@ export default class SettingsFlag extends React.Component { public render() { const canChange = SettingsStore.canSetValue(this.props.name, this.props.roomId, this.props.level); - let label = this.props.label; - if (!label) label = SettingsStore.getDisplayName(this.props.name, this.props.level); - else label = _t(label); + const label = this.props.label + ? _t(this.props.label) + : SettingsStore.getDisplayName(this.props.name, this.props.level); + const description = SettingsStore.getDescription(this.props.name); if (this.props.useCheckbox) { return { disabled={this.props.disabled || !canChange} aria-label={label} /> + { description &&
      + { description } +
      }
      ); } diff --git a/src/components/views/elements/Slider.tsx b/src/components/views/elements/Slider.tsx index b513f90460..b9234d0550 100644 --- a/src/components/views/elements/Slider.tsx +++ b/src/components/views/elements/Slider.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import * as React from 'react'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { // A callback for the selected value @@ -86,8 +86,8 @@ export default class Slider extends React.Component { if (!this.props.disabled) { const offset = this.offset(this.props.values, this.props.value); selection =
      -
      -
      +
      +
      ; } diff --git a/src/components/views/elements/SpellCheckLanguagesDropdown.tsx b/src/components/views/elements/SpellCheckLanguagesDropdown.tsx index 06e1efe415..1678bdb33a 100644 --- a/src/components/views/elements/SpellCheckLanguagesDropdown.tsx +++ b/src/components/views/elements/SpellCheckLanguagesDropdown.tsx @@ -16,12 +16,12 @@ limitations under the License. import React from 'react'; -import Dropdown from "../../views/elements/Dropdown" +import Dropdown from "../../views/elements/Dropdown"; import * as sdk from '../../../index'; import PlatformPeg from "../../../PlatformPeg"; import SettingsStore from "../../../settings/SettingsStore"; import { _t } from "../../../languageHandler"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; function languageMatchesSearchQuery(query, language) { if (language.label.toUpperCase().includes(query.toUpperCase())) return true; @@ -30,14 +30,14 @@ function languageMatchesSearchQuery(query, language) { } interface SpellCheckLanguagesDropdownIProps { - className: string, - value: string, - onOptionChange(language: string), + className: string; + value: string; + onOptionChange(language: string); } interface SpellCheckLanguagesDropdownIState { - searchQuery: string, - languages: any, + searchQuery: string; + languages: any; } @replaceableComponent("views.elements.SpellCheckLanguagesDropdown") @@ -67,11 +67,11 @@ export default class SpellCheckLanguagesDropdown extends React.Component { - this.setState({languages: ['en']}); + this.setState({ languages: ['en'] }); }); } } diff --git a/src/components/views/elements/Spinner.js b/src/components/views/elements/Spinner.js index 3ad8444bd6..75f85d0441 100644 --- a/src/components/views/elements/Spinner.js +++ b/src/components/views/elements/Spinner.js @@ -17,14 +17,14 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; -import {_t} from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; -const Spinner = ({w = 32, h = 32, message}) => ( +const Spinner = ({ w = 32, h = 32, message }) => (
      { message &&
      { message }
       
      }
      diff --git a/src/components/views/elements/Spoiler.js b/src/components/views/elements/Spoiler.js index 33b4382a2c..56c18c6e33 100644 --- a/src/components/views/elements/Spoiler.js +++ b/src/components/views/elements/Spoiler.js @@ -15,7 +15,7 @@ */ import React from 'react'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.Spoiler") export default class Spoiler extends React.Component { diff --git a/src/components/views/elements/StyledCheckbox.tsx b/src/components/views/elements/StyledCheckbox.tsx index 2454d1336b..366cc2f1f7 100644 --- a/src/components/views/elements/StyledCheckbox.tsx +++ b/src/components/views/elements/StyledCheckbox.tsx @@ -16,7 +16,7 @@ limitations under the License. import React from "react"; import { randomString } from "matrix-js-sdk/src/randomstring"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends React.InputHTMLAttributes { } diff --git a/src/components/views/elements/StyledRadioButton.tsx b/src/components/views/elements/StyledRadioButton.tsx index 835394e055..7ec472b639 100644 --- a/src/components/views/elements/StyledRadioButton.tsx +++ b/src/components/views/elements/StyledRadioButton.tsx @@ -16,7 +16,7 @@ limitations under the License. import React from 'react'; import classnames from 'classnames'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends React.InputHTMLAttributes { outlined?: boolean; diff --git a/src/components/views/elements/StyledRadioGroup.tsx b/src/components/views/elements/StyledRadioGroup.tsx index 6b9e992f92..744b6f2059 100644 --- a/src/components/views/elements/StyledRadioGroup.tsx +++ b/src/components/views/elements/StyledRadioGroup.tsx @@ -34,10 +34,19 @@ interface IProps { definitions: IDefinition[]; value?: T; // if not provided no options will be selected outlined?: boolean; + disabled?: boolean; onChange(newValue: T): void; } -function StyledRadioGroup({name, definitions, value, className, outlined, onChange}: IProps) { +function StyledRadioGroup({ + name, + definitions, + value, + className, + outlined, + disabled, + onChange, +}: IProps) { const _onChange = e => { onChange(e.target.value); }; @@ -50,12 +59,12 @@ function StyledRadioGroup({name, definitions, value, className checked={d.checked !== undefined ? d.checked : d.value === value} name={name} value={d.value} - disabled={d.disabled} + disabled={disabled || d.disabled} outlined={outlined} > - {d.label} + { d.label } - {d.description} + { d.description ? { d.description } : null } )} ; } diff --git a/src/components/views/elements/SyntaxHighlight.js b/src/components/views/elements/SyntaxHighlight.js index f9874c5367..2c29f7c989 100644 --- a/src/components/views/elements/SyntaxHighlight.js +++ b/src/components/views/elements/SyntaxHighlight.js @@ -16,8 +16,8 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {highlightBlock} from 'highlight.js'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { highlightBlock } from 'highlight.js'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.SyntaxHighlight") export default class SyntaxHighlight extends React.Component { diff --git a/src/components/views/elements/TagTile.js b/src/components/views/elements/TagTile.js index 03d853babc..12c3718274 100644 --- a/src/components/views/elements/TagTile.js +++ b/src/components/views/elements/TagTile.js @@ -30,8 +30,8 @@ import GroupFilterOrderStore from '../../../stores/GroupFilterOrderStore'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AccessibleButton from "./AccessibleButton"; import SettingsStore from "../../../settings/SettingsStore"; -import {mediaFromMxc} from "../../../customisations/Media"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; // A class for a child of GroupFilterPanel (possibly wrapped in a DNDTagTile) that represents // a thing to click on for the user to filter the visible rooms in the RoomList to: diff --git a/src/components/views/elements/TextWithTooltip.js b/src/components/views/elements/TextWithTooltip.js index a6fc00fc2e..633d182fcf 100644 --- a/src/components/views/elements/TextWithTooltip.js +++ b/src/components/views/elements/TextWithTooltip.js @@ -17,7 +17,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.TextWithTooltip") export default class TextWithTooltip extends React.Component { @@ -37,17 +37,17 @@ export default class TextWithTooltip extends React.Component { } onMouseOver = () => { - this.setState({hover: true}); + this.setState({ hover: true }); }; onMouseLeave = () => { - this.setState({hover: false}); + this.setState({ hover: false }); }; render() { const Tooltip = sdk.getComponent("elements.Tooltip"); - const {class: className, children, tooltip, tooltipClass, tooltipProps, ...props} = this.props; + const { class: className, children, tooltip, tooltipClass, tooltipProps, ...props } = this.props; return ( diff --git a/src/components/views/elements/TintableSvg.js b/src/components/views/elements/TintableSvg.js deleted file mode 100644 index 670f3e9bb9..0000000000 --- a/src/components/views/elements/TintableSvg.js +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright 2015 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 Tinter from "../../../Tinter"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; - -@replaceableComponent("views.elements.TintableSvg") -class TintableSvg extends React.Component { - static propTypes = { - src: PropTypes.string.isRequired, - width: PropTypes.string.isRequired, - height: PropTypes.string.isRequired, - className: PropTypes.string, - }; - - // list of currently mounted TintableSvgs - static mounts = {}; - static idSequence = 0; - - componentDidMount() { - this.fixups = []; - - this.id = TintableSvg.idSequence++; - TintableSvg.mounts[this.id] = this; - } - - componentWillUnmount() { - delete TintableSvg.mounts[this.id]; - } - - tint = () => { - // TODO: only bother running this if the global tint settings have changed - // since we loaded! - Tinter.applySvgFixups(this.fixups); - }; - - onLoad = event => { - // console.log("TintableSvg.onLoad for " + this.props.src); - this.fixups = Tinter.calcSvgFixups([event.target]); - Tinter.applySvgFixups(this.fixups); - }; - - render() { - return ( - - ); - } -} - -// Register with the Tinter so that we will be told if the tint changes -Tinter.registerTintable(function() { - if (TintableSvg.mounts) { - Object.keys(TintableSvg.mounts).forEach((id) => { - TintableSvg.mounts[id].tint(); - }); - } -}); - -export default TintableSvg; diff --git a/src/components/views/elements/ToggleSwitch.tsx b/src/components/views/elements/ToggleSwitch.tsx index f05c45b3db..7315cc6383 100644 --- a/src/components/views/elements/ToggleSwitch.tsx +++ b/src/components/views/elements/ToggleSwitch.tsx @@ -31,7 +31,7 @@ interface IProps { } // Controlled Toggle Switch element, written with Accessibility in mind -export default ({checked, disabled = false, onChange, ...props}: IProps) => { +export default ({ checked, disabled = false, onChange, ...props }: IProps) => { const _onClick = () => { if (disabled) return; onChange(!checked); diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index 0202c6b02f..e64819f441 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -17,11 +17,10 @@ See the License for the specific language governing permissions and limitations under the License. */ - -import React, {Component, CSSProperties} from 'react'; +import React, { Component, CSSProperties } from 'react'; import ReactDOM from 'react-dom'; import classNames from 'classnames'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import UIStore from "../../../stores/UIStore"; const MIN_TOOLTIP_HEIGHT = 25; diff --git a/src/components/views/elements/TooltipButton.tsx b/src/components/views/elements/TooltipButton.tsx index 191018cc19..87bf04c2ea 100644 --- a/src/components/views/elements/TooltipButton.tsx +++ b/src/components/views/elements/TooltipButton.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import * as sdk from '../../../index'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { helpText: string; diff --git a/src/components/views/elements/TruncatedList.js b/src/components/views/elements/TruncatedList.tsx similarity index 61% rename from src/components/views/elements/TruncatedList.js rename to src/components/views/elements/TruncatedList.tsx index 0509775545..403df4111e 100644 --- a/src/components/views/elements/TruncatedList.js +++ b/src/components/views/elements/TruncatedList.tsx @@ -16,41 +16,39 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + // The number of elements to show before truncating. If negative, no truncation is done. + truncateAt?: number; + // The className to apply to the wrapping div + className?: string; + // A function that returns the children to be rendered into the element. + // The start element is included, the end is not (as in `slice`). + // If omitted, the React child elements will be used. This parameter can be used + // to avoid creating unnecessary React elements. + getChildren?: (start: number, end: number) => Array; + // A function that should return the total number of child element available. + // Required if getChildren is supplied. + getChildCount?: () => number; + // A function which will be invoked when an overflow element is required. + // This will be inserted after the children. + createOverflowElement?: (overflowCount: number, totalCount: number) => React.ReactNode; +} @replaceableComponent("views.elements.TruncatedList") -export default class TruncatedList extends React.Component { - static propTypes = { - // The number of elements to show before truncating. If negative, no truncation is done. - truncateAt: PropTypes.number, - // The className to apply to the wrapping div - className: PropTypes.string, - // A function that returns the children to be rendered into the element. - // function getChildren(start: number, end: number): Array - // The start element is included, the end is not (as in `slice`). - // If omitted, the React child elements will be used. This parameter can be used - // to avoid creating unnecessary React elements. - getChildren: PropTypes.func, - // A function that should return the total number of child element available. - // Required if getChildren is supplied. - getChildCount: PropTypes.func, - // A function which will be invoked when an overflow element is required. - // This will be inserted after the children. - createOverflowElement: PropTypes.func, - }; - +export default class TruncatedList extends React.Component { static defaultProps ={ truncateAt: 2, createOverflowElement(overflowCount, totalCount) { return ( -
      { _t("And %(count)s more...", {count: overflowCount}) }
      +
      { _t("And %(count)s more...", { count: overflowCount }) }
      ); }, }; - _getChildren(start, end) { + private getChildren(start: number, end: number): Array { if (this.props.getChildren && this.props.getChildCount) { return this.props.getChildren(start, end); } else { @@ -63,7 +61,7 @@ export default class TruncatedList extends React.Component { } } - _getChildCount() { + private getChildCount(): number { if (this.props.getChildren && this.props.getChildCount) { return this.props.getChildCount(); } else { @@ -73,10 +71,10 @@ export default class TruncatedList extends React.Component { } } - render() { + public render() { let overflowNode = null; - const totalChildren = this._getChildCount(); + const totalChildren = this.getChildCount(); let upperBound = totalChildren; if (this.props.truncateAt >= 0) { const overflowCount = totalChildren - this.props.truncateAt; @@ -87,7 +85,7 @@ export default class TruncatedList extends React.Component { upperBound = this.props.truncateAt; } } - const childNodes = this._getChildren(0, upperBound); + const childNodes = this.getChildren(0, upperBound); return (
      diff --git a/src/components/views/elements/UserTagTile.tsx b/src/components/views/elements/UserTagTile.tsx index d3e07a0a34..4414ff31fd 100644 --- a/src/components/views/elements/UserTagTile.tsx +++ b/src/components/views/elements/UserTagTile.tsx @@ -21,7 +21,7 @@ import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore"; import AccessibleTooltipButton from "./AccessibleTooltipButton"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { } @@ -52,7 +52,7 @@ export default class UserTagTile extends React.PureComponent { private onTagStoreUpdate = () => { const selected = GroupFilterOrderStore.getSelectedTags().length === 0; - this.setState({selected}); + this.setState({ selected }); }; private onTileClick = (ev) => { @@ -60,7 +60,7 @@ export default class UserTagTile extends React.PureComponent { ev.stopPropagation(); // Deselect all tags - defaultDispatcher.dispatch({action: "deselect_tags"}); + defaultDispatcher.dispatch({ action: "deselect_tags" }); }; public render() { diff --git a/src/components/views/elements/Validation.tsx b/src/components/views/elements/Validation.tsx index 1b0659ab9b..ad3571513c 100644 --- a/src/components/views/elements/Validation.tsx +++ b/src/components/views/elements/Validation.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* eslint-disable babel/no-invalid-this */ +/* eslint-disable @typescript-eslint/no-invalid-this */ import React from "react"; import classNames from "classnames"; diff --git a/src/components/views/elements/crypto/VerificationQRCode.js b/src/components/views/elements/crypto/VerificationQRCode.js index 3bbfb004c6..76cfb82d35 100644 --- a/src/components/views/elements/crypto/VerificationQRCode.js +++ b/src/components/views/elements/crypto/VerificationQRCode.js @@ -16,7 +16,7 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; -import {replaceableComponent} from "../../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../../utils/replaceableComponent"; import QRCode from "../QRCode"; @replaceableComponent("views.elements.crypto.VerificationQRCode") @@ -28,7 +28,7 @@ export default class VerificationQRCode extends React.PureComponent { render() { return ( ); diff --git a/src/components/views/emojipicker/Category.tsx b/src/components/views/emojipicker/Category.tsx index 4c7852def3..24b4fbe3ed 100644 --- a/src/components/views/emojipicker/Category.tsx +++ b/src/components/views/emojipicker/Category.tsx @@ -15,13 +15,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {RefObject} from 'react'; +import React, { RefObject } from 'react'; import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPicker"; import LazyRenderList from "../elements/LazyRenderList"; -import {DATA_BY_CATEGORY, IEmoji} from "../../../emoji"; +import { DATA_BY_CATEGORY, IEmoji } from "../../../emoji"; import Emoji from './Emoji'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; const OVERFLOW_ROWS = 3; diff --git a/src/components/views/emojipicker/Emoji.tsx b/src/components/views/emojipicker/Emoji.tsx index 5d7665ce98..73e24f46fb 100644 --- a/src/components/views/emojipicker/Emoji.tsx +++ b/src/components/views/emojipicker/Emoji.tsx @@ -17,9 +17,9 @@ limitations under the License. import React from 'react'; -import {MenuItem} from "../../structures/ContextMenu"; -import {IEmoji} from "../../../emoji"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MenuItem } from "../../structures/ContextMenu"; +import { IEmoji } from "../../../emoji"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { emoji: IEmoji; diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index 6d7b90c8a6..47e3823116 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -19,14 +19,14 @@ import React from 'react'; import { _t } from '../../../languageHandler'; import * as recent from '../../../emojipicker/recent'; -import {DATA_BY_CATEGORY, getEmojiFromUnicode, IEmoji} from "../../../emoji"; +import { DATA_BY_CATEGORY, getEmojiFromUnicode, IEmoji } from "../../../emoji"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import Header from "./Header"; import Search from "./Search"; import Preview from "./Preview"; import QuickReactions from "./QuickReactions"; -import Category, {ICategory, CategoryKey} from "./Category"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import Category, { ICategory, CategoryKey } from "./Category"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; export const CATEGORY_HEADER_HEIGHT = 22; export const EMOJI_HEIGHT = 37; @@ -234,7 +234,7 @@ class EmojiPicker extends React.Component { className="mx_EmojiPicker_body" wrappedRef={ref => { // @ts-ignore - AutoHideScrollbar should accept a RefObject or fall back to its own instead - this.bodyRef.current = ref + this.bodyRef.current = ref; }} onScroll={this.onScroll} > diff --git a/src/components/views/emojipicker/Header.tsx b/src/components/views/emojipicker/Header.tsx index 693f86ad73..ac39affdd9 100644 --- a/src/components/views/emojipicker/Header.tsx +++ b/src/components/views/emojipicker/Header.tsx @@ -18,14 +18,14 @@ limitations under the License. import React from 'react'; import classNames from "classnames"; -import {_t} from "../../../languageHandler"; -import {Key} from "../../../Keyboard"; -import {CategoryKey, ICategory} from "./Category"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { _t } from "../../../languageHandler"; +import { Key } from "../../../Keyboard"; +import { CategoryKey, ICategory } from "./Category"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { categories: ICategory[]; - onAnchorClick(id: CategoryKey): void + onAnchorClick(id: CategoryKey): void; } @replaceableComponent("views.emojipicker.Header") diff --git a/src/components/views/emojipicker/Preview.tsx b/src/components/views/emojipicker/Preview.tsx index e0952ec73e..9c2dbb9cbd 100644 --- a/src/components/views/emojipicker/Preview.tsx +++ b/src/components/views/emojipicker/Preview.tsx @@ -17,8 +17,8 @@ limitations under the License. import React from 'react'; -import {IEmoji} from "../../../emoji"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { IEmoji } from "../../../emoji"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { emoji: IEmoji; diff --git a/src/components/views/emojipicker/QuickReactions.tsx b/src/components/views/emojipicker/QuickReactions.tsx index a250aca458..ffd3ce9760 100644 --- a/src/components/views/emojipicker/QuickReactions.tsx +++ b/src/components/views/emojipicker/QuickReactions.tsx @@ -18,9 +18,9 @@ limitations under the License. import React from 'react'; import { _t } from '../../../languageHandler'; -import {getEmojiFromUnicode, IEmoji} from "../../../emoji"; +import { getEmojiFromUnicode, IEmoji } from "../../../emoji"; import Emoji from "./Emoji"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; // We use the variation-selector Heart in Quick Reactions for some reason const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => { diff --git a/src/components/views/emojipicker/ReactionPicker.tsx b/src/components/views/emojipicker/ReactionPicker.tsx index e86d183aba..d8f8b7f2ff 100644 --- a/src/components/views/emojipicker/ReactionPicker.tsx +++ b/src/components/views/emojipicker/ReactionPicker.tsx @@ -16,12 +16,12 @@ limitations under the License. */ import React from 'react'; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import EmojiPicker from "./EmojiPicker"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { mxEvent: MatrixEvent; @@ -103,7 +103,7 @@ class ReactionPicker extends React.Component { "key": reaction, }, }); - dis.dispatch({action: "message_sent"}); + dis.dispatch({ action: "message_sent" }); return true; } }; diff --git a/src/components/views/emojipicker/Search.tsx b/src/components/views/emojipicker/Search.tsx index abe3e026be..c88bf8d84d 100644 --- a/src/components/views/emojipicker/Search.tsx +++ b/src/components/views/emojipicker/Search.tsx @@ -18,8 +18,8 @@ limitations under the License. import React from 'react'; import { _t } from '../../../languageHandler'; -import {Key} from "../../../Keyboard"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Key } from "../../../Keyboard"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { query: string; diff --git a/src/components/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js index bc0bf966f9..c12e14e024 100644 --- a/src/components/views/groups/GroupInviteTile.js +++ b/src/components/views/groups/GroupInviteTile.js @@ -20,19 +20,19 @@ import React from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; -import {_t} from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; import classNames from 'classnames'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { ContextMenu, ContextMenuButton, toRightOf } from "../../structures/ContextMenu"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; // XXX this class copies a lot from RoomTile.js @replaceableComponent("views.groups.GroupInviteTile") export default class GroupInviteTile extends React.Component { - static propTypes: { + static propTypes = { group: PropTypes.object.isRequired, }; @@ -57,7 +57,7 @@ export default class GroupInviteTile extends React.Component { }; onMouseEnter = () => { - const state = {hover: true}; + const state = { hover: true }; // Only allow non-guests to access the context menu if (!this.context.isGuest()) { state.badgeHover = true; @@ -165,7 +165,7 @@ export default class GroupInviteTile extends React.Component { return - {({onFocus, isActive, ref}) => + {({ onFocus, isActive, ref }) => - + diff --git a/src/components/views/groups/GroupRoomInfo.js b/src/components/views/groups/GroupRoomInfo.js index c1d1adb0b3..d8f086700d 100644 --- a/src/components/views/groups/GroupRoomInfo.js +++ b/src/components/views/groups/GroupRoomInfo.js @@ -24,8 +24,8 @@ import { _t } from '../../../languageHandler'; import GroupStore from '../../../stores/GroupStore'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; @replaceableComponent("views.groups.GroupRoomInfo") export default class GroupRoomInfo extends React.Component { @@ -90,12 +90,12 @@ export default class GroupRoomInfo extends React.Component { e.stopPropagation(); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('Confirm removal of group from room', '', QuestionDialog, { - title: _t("Are you sure you want to remove '%(roomName)s' from %(groupId)s?", {roomName, groupId}), + title: _t("Are you sure you want to remove '%(roomName)s' from %(groupId)s?", { roomName, groupId }), description: _t("Removing a room from the community will also remove it from the community page."), button: _t("Remove"), onFinished: (proceed) => { if (!proceed) return; - this.setState({groupRoomRemoveLoading: true}); + this.setState({ groupRoomRemoveLoading: true }); const groupId = this.props.groupId; const roomId = this.props.groupRoomId; GroupStore.removeRoomFromGroup(this.props.groupId, roomId).then(() => { @@ -108,11 +108,11 @@ export default class GroupRoomInfo extends React.Component { Modal.createTrackedDialog('Failed to remove room from group', '', ErrorDialog, { title: _t("Failed to remove room from community"), description: _t( - "Failed to remove '%(roomName)s' from %(groupId)s", {groupId, roomName}, + "Failed to remove '%(roomName)s' from %(groupId)s", { groupId, roomName }, ), }); }).finally(() => { - this.setState({groupRoomRemoveLoading: false}); + this.setState({ groupRoomRemoveLoading: false }); }); }, }); @@ -139,7 +139,7 @@ export default class GroupRoomInfo extends React.Component { title: _t("Something went wrong!"), description: _t( "The visibility of '%(roomName)s' in %(groupId)s could not be updated.", - {roomName, groupId}, + { roomName, groupId }, ), }); }).finally(() => { diff --git a/src/components/views/groups/GroupRoomList.js b/src/components/views/groups/GroupRoomList.js index 2921ac79ee..00220441e7 100644 --- a/src/components/views/groups/GroupRoomList.js +++ b/src/components/views/groups/GroupRoomList.js @@ -21,7 +21,7 @@ import PropTypes from 'prop-types'; import { showGroupAddRoomDialog } from '../../../GroupAddressPicker'; import AccessibleButton from '../elements/AccessibleButton'; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; const INITIAL_LOAD_NUM_ROOMS = 30; diff --git a/src/components/views/groups/GroupRoomTile.js b/src/components/views/groups/GroupRoomTile.js index 7edfc1a376..662359669d 100644 --- a/src/components/views/groups/GroupRoomTile.js +++ b/src/components/views/groups/GroupRoomTile.js @@ -20,8 +20,8 @@ import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { GroupRoomType } from '../../../groups'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; @replaceableComponent("views.groups.GroupRoomTile") class GroupRoomTile extends React.Component { diff --git a/src/components/views/groups/GroupTile.js b/src/components/views/groups/GroupTile.js index 42a977fb79..21308ac056 100644 --- a/src/components/views/groups/GroupTile.js +++ b/src/components/views/groups/GroupTile.js @@ -16,15 +16,15 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import { Draggable, Droppable } from 'react-beautiful-dnd'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import FlairStore from '../../../stores/FlairStore'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; - -function nop() {} +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; +import { _t } from "../../../languageHandler"; +import TagOrderActions from "../../../actions/TagOrderActions"; +import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore"; @replaceableComponent("views.groups.GroupTile") class GroupTile extends React.Component { @@ -34,7 +34,6 @@ class GroupTile extends React.Component { showDescription: PropTypes.bool, // Height of the group avatar in pixels avatarHeight: PropTypes.number, - draggable: PropTypes.bool, }; static contextType = MatrixClientContext; @@ -42,7 +41,6 @@ class GroupTile extends React.Component { static defaultProps = { showDescription: true, avatarHeight: 50, - draggable: true, }; state = { @@ -51,13 +49,13 @@ class GroupTile extends React.Component { componentDidMount() { FlairStore.getGroupProfileCached(this.context, this.props.groupId).then((profile) => { - this.setState({profile}); + this.setState({ profile }); }).catch((err) => { console.error('Error whilst getting cached profile for GroupTile', err); }); } - onMouseDown = e => { + onClick = e => { e.preventDefault(); dis.dispatch({ action: 'view_group', @@ -65,6 +63,18 @@ class GroupTile extends React.Component { }); }; + onPinClick = e => { + e.preventDefault(); + e.stopPropagation(); + dis.dispatch(TagOrderActions.moveTag(this.context, this.props.groupId, 0)); + }; + + onUnpinClick = e => { + e.preventDefault(); + e.stopPropagation(); + dis.dispatch(TagOrderActions.removeTag(this.context, this.props.groupId)); + }; + render() { const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -78,7 +88,7 @@ class GroupTile extends React.Component { ? mediaFromMxc(profile.avatarUrl).getSquareThumbnailHttp(avatarHeight) : null; - let avatarElement = ( + const avatarElement = (
      ); - if (this.props.draggable) { - const avatarClone = avatarElement; - avatarElement = ( - - { (droppableProvided, droppableSnapshot) => ( -
      - - { (provided, snapshot) => ( -
      -
      - {avatarClone} -
      - { /* Instead of a blank placeholder, use a copy of the avatar itself. */ } - { provided.placeholder ? avatarClone :
      } -
      - ) } - -
      - ) } - - ); - } - // XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273 - // instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6156 - return + return { avatarElement }
      { name }
      { descElement }
      { this.props.groupId }
      + { !(GroupFilterOrderStore.getOrderedTags() || []).includes(this.props.groupId) + ? + { _t("Pin") } + + : + { _t("Unpin") } + + }
      ; } diff --git a/src/components/views/groups/GroupUserSettings.js b/src/components/views/groups/GroupUserSettings.js index 5b537d7377..efb392c54f 100644 --- a/src/components/views/groups/GroupUserSettings.js +++ b/src/components/views/groups/GroupUserSettings.js @@ -18,7 +18,7 @@ import React from 'react'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.groups.GroupUserSettings") export default class GroupUserSettings extends React.Component { @@ -31,10 +31,10 @@ export default class GroupUserSettings extends React.Component { componentDidMount() { this.context.getJoinedGroups().then((result) => { - this.setState({groups: result.groups || [], error: null}); + this.setState({ groups: result.groups || [], error: null }); }, (err) => { console.error(err); - this.setState({groups: null, error: err}); + this.setState({ groups: null, error: err }); }); } diff --git a/src/components/views/host_signup/HostSignupContainer.tsx b/src/components/views/host_signup/HostSignupContainer.tsx index 6445454994..fc1506bf61 100644 --- a/src/components/views/host_signup/HostSignupContainer.tsx +++ b/src/components/views/host_signup/HostSignupContainer.tsx @@ -33,4 +33,4 @@ const HostSignupContainer = () => {
      ; }; -export default HostSignupContainer +export default HostSignupContainer; diff --git a/src/components/views/messages/DateSeparator.js b/src/components/views/messages/DateSeparator.tsx similarity index 82% rename from src/components/views/messages/DateSeparator.js rename to src/components/views/messages/DateSeparator.tsx index 82ce8dc4ae..5d43e2182d 100644 --- a/src/components/views/messages/DateSeparator.js +++ b/src/components/views/messages/DateSeparator.tsx @@ -1,6 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd Copyright 2018 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. @@ -16,12 +16,12 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; -import {formatFullDateNoTime} from '../../../DateUtils'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -function getdaysArray() { +import { _t } from '../../../languageHandler'; +import { formatFullDateNoTime } from '../../../DateUtils'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +function getDaysArray(): string[] { return [ _t('Sunday'), _t('Monday'), @@ -33,17 +33,17 @@ function getdaysArray() { ]; } -@replaceableComponent("views.messages.DateSeparator") -export default class DateSeparator extends React.Component { - static propTypes = { - ts: PropTypes.number.isRequired, - }; +interface IProps { + ts: number; +} - getLabel() { +@replaceableComponent("views.messages.DateSeparator") +export default class DateSeparator extends React.Component { + private getLabel() { const date = new Date(this.props.ts); const today = new Date(); const yesterday = new Date(); - const days = getdaysArray(); + const days = getDaysArray(); yesterday.setDate(today.getDate() - 1); if (date.toDateString() === today.toDateString()) { diff --git a/src/components/views/messages/EditHistoryMessage.js b/src/components/views/messages/EditHistoryMessage.js index dc4e0187d3..1416cfff53 100644 --- a/src/components/views/messages/EditHistoryMessage.js +++ b/src/components/views/messages/EditHistoryMessage.js @@ -14,20 +14,20 @@ 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 HtmlUtils from '../../../HtmlUtils'; import { editBodyDiffToHtml } from '../../../utils/MessageDiffUtils'; -import {formatTime} from '../../../DateUtils'; -import {MatrixEvent} from 'matrix-js-sdk/src/models/event'; -import {pillifyLinks, unmountPills} from '../../../utils/pillify'; +import { formatTime } from '../../../DateUtils'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { pillifyLinks, unmountPills } from '../../../utils/pillify'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import classNames from 'classnames'; import RedactedBody from "./RedactedBody"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; function getReplacedContent(event) { const originalContent = event.getOriginalContent(); @@ -46,21 +46,21 @@ export default class EditHistoryMessage extends React.PureComponent { constructor(props) { super(props); const cli = MatrixClientPeg.get(); - const {userId} = cli.credentials; + const { userId } = cli.credentials; const event = this.props.mxEvent; const room = cli.getRoom(event.getRoomId()); if (event.localRedactionEvent()) { event.localRedactionEvent().on("status", this._onAssociatedStatusChanged); } const canRedact = room.currentState.maySendRedactionForEvent(event, userId); - this.state = {canRedact, sendStatus: event.getAssociatedStatus()}; + this.state = { canRedact, sendStatus: event.getAssociatedStatus() }; this._content = createRef(); this._pills = []; } _onAssociatedStatusChanged = () => { - this.setState({sendStatus: this.props.mxEvent.getAssociatedStatus()}); + this.setState({ sendStatus: this.props.mxEvent.getAssociatedStatus() }); }; _onRedactClick = async () => { @@ -129,7 +129,7 @@ export default class EditHistoryMessage extends React.PureComponent { } render() { - const {mxEvent} = this.props; + const { mxEvent } = this.props; const content = getReplacedContent(mxEvent); let contentContainer; if (mxEvent.isRedacted()) { @@ -139,7 +139,7 @@ export default class EditHistoryMessage extends React.PureComponent { if (this.props.previousEdit) { contentElements = editBodyDiffToHtml(getReplacedContent(this.props.previousEdit), content); } else { - contentElements = HtmlUtils.bodyToHtml(content, null, {stripReplyFallback: true}); + contentElements = HtmlUtils.bodyToHtml(content, null, { stripReplyFallback: true }); } if (mxEvent.getContent().msgtype === "m.emote") { const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx index 3af9c463c9..0f716ed010 100644 --- a/src/components/views/messages/EncryptionEvent.tsx +++ b/src/components/views/messages/EncryptionEvent.tsx @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {forwardRef, useContext} from 'react'; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import React, { forwardRef, useContext } from 'react'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -27,7 +27,7 @@ interface IProps { mxEvent: MatrixEvent; } -const EncryptionEvent = forwardRef(({mxEvent}, ref) => { +const EncryptionEvent = forwardRef(({ mxEvent }, ref) => { const cli = useContext(MatrixClientContext); const roomId = mxEvent.getRoomId(); const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId); diff --git a/src/components/views/messages/EventTileBubble.tsx b/src/components/views/messages/EventTileBubble.tsx index 88913ac2d4..9fa3586ea5 100644 --- a/src/components/views/messages/EventTileBubble.tsx +++ b/src/components/views/messages/EventTileBubble.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {forwardRef, ReactNode, ReactChildren} from "react"; +import React, { forwardRef, ReactNode, ReactChildren } from "react"; import classNames from "classnames"; interface IProps { diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js deleted file mode 100644 index 0d5e449fc0..0000000000 --- a/src/components/views/messages/MAudioBody.js +++ /dev/null @@ -1,112 +0,0 @@ -/* - Copyright 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 MFileBody from './MFileBody'; - -import { decryptFile } from '../../../utils/DecryptFile'; -import { _t } from '../../../languageHandler'; -import InlineSpinner from '../elements/InlineSpinner'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromContent} from "../../../customisations/Media"; - -@replaceableComponent("views.messages.MAudioBody") -export default class MAudioBody extends React.Component { - constructor(props) { - super(props); - this.state = { - playing: false, - decryptedUrl: null, - decryptedBlob: null, - error: null, - }; - } - onPlayToggle() { - this.setState({ - playing: !this.state.playing, - }); - } - - _getContentUrl() { - const media = mediaFromContent(this.props.mxEvent.getContent()); - if (media.isEncrypted) { - return this.state.decryptedUrl; - } else { - return media.srcHttp; - } - } - - componentDidMount() { - const content = this.props.mxEvent.getContent(); - if (content.file !== undefined && this.state.decryptedUrl === null) { - let decryptedBlob; - decryptFile(content.file).then(function(blob) { - decryptedBlob = blob; - return URL.createObjectURL(decryptedBlob); - }).then((url) => { - this.setState({ - decryptedUrl: url, - decryptedBlob: decryptedBlob, - }); - }, (err) => { - console.warn("Unable to decrypt attachment: ", err); - this.setState({ - error: err, - }); - }); - } - } - - componentWillUnmount() { - if (this.state.decryptedUrl) { - URL.revokeObjectURL(this.state.decryptedUrl); - } - } - - render() { - const content = this.props.mxEvent.getContent(); - - if (this.state.error !== null) { - return ( - - - { _t("Error decrypting audio") } - - ); - } - - if (content.file !== undefined && this.state.decryptedUrl === null) { - // Need to decrypt the attachment - // The attachment is decrypted in componentDidMount. - // For now add an img tag with a 16x16 spinner. - // Not sure how tall the audio player is so not sure how tall it should actually be. - return ( - - - - ); - } - - const contentUrl = this._getContentUrl(); - - return ( - - - ); - } -} diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx new file mode 100644 index 0000000000..bc7216f42c --- /dev/null +++ b/src/components/views/messages/MAudioBody.tsx @@ -0,0 +1,110 @@ +/* +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 { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { Playback } from "../../../voice/Playback"; +import MFileBody from "./MFileBody"; +import InlineSpinner from '../elements/InlineSpinner'; +import { _t } from "../../../languageHandler"; +import { mediaFromContent } from "../../../customisations/Media"; +import { decryptFile } from "../../../utils/DecryptFile"; +import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; +import AudioPlayer from "../audio_messages/AudioPlayer"; + +interface IProps { + mxEvent: MatrixEvent; +} + +interface IState { + error?: Error; + playback?: Playback; + decryptedBlob?: Blob; +} + +@replaceableComponent("views.messages.MAudioBody") +export default class MAudioBody extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = {}; + } + + public async componentDidMount() { + let buffer: ArrayBuffer; + const content: IMediaEventContent = this.props.mxEvent.getContent(); + const media = mediaFromContent(content); + if (media.isEncrypted) { + try { + const blob = await decryptFile(content.file); + buffer = await blob.arrayBuffer(); + this.setState({ decryptedBlob: blob }); + } catch (e) { + this.setState({ error: e }); + console.warn("Unable to decrypt audio message", e); + return; // stop processing the audio file + } + } else { + try { + buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer()); + } catch (e) { + this.setState({ error: e }); + console.warn("Unable to download audio message", e); + return; // stop processing the audio file + } + } + + // We should have a buffer to work with now: let's set it up + const playback = new Playback(buffer); + playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent); + this.setState({ playback }); + // Note: the RecordingPlayback component will handle preparing the Playback class for us. + } + + public componentWillUnmount() { + this.state.playback?.destroy(); + } + + public render() { + if (this.state.error) { + // TODO: @@TR: Verify error state + return ( + + + { _t("Error processing audio message") } + + ); + } + + if (!this.state.playback) { + // TODO: @@TR: Verify loading/decrypting state + return ( + + + + ); + } + + // At this point we should have a playable state + return ( + + + + + ); + } +} diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index 8f464e08bd..d8d832d15d 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -14,15 +14,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 PropTypes from 'prop-types'; import filesize from 'filesize'; import { _t } from '../../../languageHandler'; -import {decryptFile} from '../../../utils/DecryptFile'; +import { decryptFile } from '../../../utils/DecryptFile'; import Modal from '../../../Modal'; import AccessibleButton from "../elements/AccessibleButton"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromContent} from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromContent } from "../../../customisations/Media"; import ErrorDialog from "../dialogs/ErrorDialog"; let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on @@ -242,7 +242,7 @@ export default class MFileBody extends React.Component { {placeholder}
      -
      +
      { /* * Add dummy copy of the "a" tag * We'll use it to learn how the download link diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 6505b1d66a..5566f5aec0 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -16,7 +16,7 @@ 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 MFileBody from './MFileBody'; @@ -27,8 +27,10 @@ import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import InlineSpinner from '../elements/InlineSpinner'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromContent} from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromContent } from "../../../customisations/Media"; +import BlurhashPlaceholder from "../elements/BlurhashPlaceholder"; +import { BLURHASH_FIELD } from "../../../ContentMessages"; @replaceableComponent("views.messages.MImageBody") export default class MImageBody extends React.Component { @@ -90,7 +92,7 @@ export default class MImageBody extends React.Component { showImage() { localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true"); - this.setState({showImage: true}); + this.setState({ showImage: true }); this._downloadImage(); } @@ -296,7 +298,7 @@ export default class MImageBody extends React.Component { if (showImage) { // Don't download anything becaue we don't want to display anything. this._downloadImage(); - this.setState({showImage: true}); + this.setState({ showImage: true }); } this._afterComponentDidMount(); @@ -333,7 +335,8 @@ export default class MImageBody extends React.Component { infoWidth = content.info.w; infoHeight = content.info.h; } else { - // Whilst the image loads, display nothing. + // Whilst the image loads, display nothing. We also don't display a blurhash image + // because we don't really know what size of image we'll end up with. // // Once loaded, use the loaded image dimensions stored in `loadedImageDimensions`. // @@ -345,7 +348,7 @@ export default class MImageBody extends React.Component { imageElement = ; } else { imageElement = ( - {content.body}; - } else if (!this.state.imgLoaded) { - // Deliberately, getSpinner is left unimplemented here, MStickerBody overides - placeholder = this.getPlaceholder(); + if (!this.state.imgLoaded) { + placeholder = this.getPlaceholder(maxWidth, maxHeight); } let showPlaceholder = Boolean(placeholder); @@ -395,7 +394,7 @@ export default class MImageBody extends React.Component { if (!this.state.showImage) { img = ; - showPlaceholder = false; // because we're hiding the image, so don't show the sticker icon. + showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. } if (this._isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) { @@ -411,13 +410,11 @@ export default class MImageBody extends React.Component { // Constrain width here so that spinner appears central to the loaded thumbnail maxWidth: infoWidth + "px", }}> -
      - { placeholder } -
      + { placeholder }
      } -
      +
      { img } { gifLabel }
      @@ -437,9 +434,12 @@ export default class MImageBody extends React.Component { } // Overidden by MStickerBody - getPlaceholder() { - // MImageBody doesn't show a placeholder whilst the image loads, (but it could do) - return null; + getPlaceholder(width, height) { + const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD]; + if (blurhash) return ; + return
      + +
      ; } // Overidden by MStickerBody diff --git a/src/components/views/messages/MJitsiWidgetEvent.tsx b/src/components/views/messages/MJitsiWidgetEvent.tsx index 626efe1f36..aaf659d6d9 100644 --- a/src/components/views/messages/MJitsiWidgetEvent.tsx +++ b/src/components/views/messages/MJitsiWidgetEvent.tsx @@ -21,7 +21,7 @@ import WidgetStore from "../../../stores/WidgetStore"; import EventTileBubble from "./EventTileBubble"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { mxEvent: MatrixEvent; @@ -52,20 +52,20 @@ export default class MJitsiWidgetEvent extends React.PureComponent { // removed return ; } else if (prevUrl) { // modified return ; } else { // assume added return ; } diff --git a/src/components/views/messages/MKeyVerificationConclusion.js b/src/components/views/messages/MKeyVerificationConclusion.js index c9489711aa..a5f12df47d 100644 --- a/src/components/views/messages/MKeyVerificationConclusion.js +++ b/src/components/views/messages/MKeyVerificationConclusion.js @@ -17,12 +17,12 @@ limitations under the License. import React from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; -import {getNameForEventRoom, userLabelForEventRoom} +import { getNameForEventRoom, userLabelForEventRoom } from '../../../utils/KeyVerificationStateObserver'; import EventTileBubble from "./EventTileBubble"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.messages.MKeyVerificationConclusion") export default class MKeyVerificationConclusion extends React.Component { @@ -90,7 +90,7 @@ export default class MKeyVerificationConclusion extends React.Component { } render() { - const {mxEvent} = this.props; + const { mxEvent } = this.props; const request = mxEvent.verificationRequest; if (!this._shouldRender(mxEvent, request)) { @@ -103,15 +103,15 @@ export default class MKeyVerificationConclusion extends React.Component { let title; if (request.done) { - title = _t("You verified %(name)s", {name: getNameForEventRoom(request.otherUserId, mxEvent)}); + title = _t("You verified %(name)s", { name: getNameForEventRoom(request.otherUserId, mxEvent) }); } else if (request.cancelled) { const userId = request.cancellingUserId; if (userId === myUserId) { title = _t("You cancelled verifying %(name)s", - {name: getNameForEventRoom(request.otherUserId, mxEvent)}); + { name: getNameForEventRoom(request.otherUserId, mxEvent) }); } else { title = _t("%(name)s cancelled verifying", - {name: getNameForEventRoom(userId, mxEvent)}); + { name: getNameForEventRoom(userId, mxEvent) }); } } diff --git a/src/components/views/messages/MKeyVerificationRequest.js b/src/components/views/messages/MKeyVerificationRequest.tsx similarity index 66% rename from src/components/views/messages/MKeyVerificationRequest.js rename to src/components/views/messages/MKeyVerificationRequest.tsx index 988606a766..fc550845e2 100644 --- a/src/components/views/messages/MKeyVerificationRequest.js +++ b/src/components/views/messages/MKeyVerificationRequest.tsx @@ -15,58 +15,57 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixEvent } from 'matrix-js-sdk/src'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import {getNameForEventRoom, userLabelForEventRoom} +import { getNameForEventRoom, userLabelForEventRoom } from '../../../utils/KeyVerificationStateObserver'; import dis from "../../../dispatcher/dispatcher"; -import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; -import {Action} from "../../../dispatcher/actions"; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; +import { Action } from "../../../dispatcher/actions"; import EventTileBubble from "./EventTileBubble"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + mxEvent: MatrixEvent; +} @replaceableComponent("views.messages.MKeyVerificationRequest") -export default class MKeyVerificationRequest extends React.Component { - constructor(props) { - super(props); - this.state = {}; - } - - componentDidMount() { +export default class MKeyVerificationRequest extends React.Component { + public componentDidMount() { const request = this.props.mxEvent.verificationRequest; if (request) { - request.on("change", this._onRequestChanged); + request.on("change", this.onRequestChanged); } } - componentWillUnmount() { + public componentWillUnmount() { const request = this.props.mxEvent.verificationRequest; if (request) { - request.off("change", this._onRequestChanged); + request.off("change", this.onRequestChanged); } } - _openRequest = () => { - const {verificationRequest} = this.props.mxEvent; + private openRequest = () => { + const { verificationRequest } = this.props.mxEvent; const member = MatrixClientPeg.get().getUser(verificationRequest.otherUserId); dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.EncryptionPanel, - refireParams: {verificationRequest, member}, + refireParams: { verificationRequest, member }, }); }; - _onRequestChanged = () => { + private onRequestChanged = () => { this.forceUpdate(); }; - _onAcceptClicked = async () => { + private onAcceptClicked = async () => { const request = this.props.mxEvent.verificationRequest; if (request) { try { - this._openRequest(); + this.openRequest(); await request.accept(); } catch (err) { console.error(err.message); @@ -74,7 +73,7 @@ export default class MKeyVerificationRequest extends React.Component { } }; - _onRejectClicked = async () => { + private onRejectClicked = async () => { const request = this.props.mxEvent.verificationRequest; if (request) { try { @@ -85,20 +84,20 @@ export default class MKeyVerificationRequest extends React.Component { } }; - _acceptedLabel(userId) { + private acceptedLabel(userId: string) { const client = MatrixClientPeg.get(); const myUserId = client.getUserId(); if (userId === myUserId) { return _t("You accepted"); } else { - return _t("%(name)s accepted", {name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId())}); + return _t("%(name)s accepted", { name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId()) }); } } - _cancelledLabel(userId) { + private cancelledLabel(userId: string) { const client = MatrixClientPeg.get(); const myUserId = client.getUserId(); - const {cancellationCode} = this.props.mxEvent.verificationRequest; + const { cancellationCode } = this.props.mxEvent.verificationRequest; const declined = cancellationCode === "m.user"; if (userId === myUserId) { if (declined) { @@ -108,18 +107,17 @@ export default class MKeyVerificationRequest extends React.Component { } } else { if (declined) { - return _t("%(name)s declined", {name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId())}); + return _t("%(name)s declined", { name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId()) }); } else { - return _t("%(name)s cancelled", {name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId())}); + return _t("%(name)s cancelled", { name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId()) }); } } } - render() { + public render() { const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); - const FormButton = sdk.getComponent("elements.FormButton"); - const {mxEvent} = this.props; + const { mxEvent } = this.props; const request = mxEvent.verificationRequest; if (!request || request.invalid) { @@ -134,11 +132,11 @@ export default class MKeyVerificationRequest extends React.Component { let stateLabel; const accepted = request.ready || request.started || request.done; if (accepted) { - stateLabel = ( - {this._acceptedLabel(request.receivingUserId)} + stateLabel = ( + {this.acceptedLabel(request.receivingUserId)} ); } else if (request.cancelled) { - stateLabel = this._cancelledLabel(request.cancellingUserId); + stateLabel = this.cancelledLabel(request.cancellingUserId); } else if (request.accepting) { stateLabel = _t("Accepting …"); } else if (request.declining) { @@ -149,12 +147,16 @@ export default class MKeyVerificationRequest extends React.Component { if (!request.initiatedByMe) { const name = getNameForEventRoom(request.requestingUserId, mxEvent.getRoomId()); - title = _t("%(name)s wants to verify", {name}); + title = _t("%(name)s wants to verify", { name }); subtitle = userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId()); if (request.canAccept) { stateNode = (
      - - + + {_t("Decline")} + + + {_t("Accept")} +
      ); } } else { // request sent by us @@ -174,8 +176,3 @@ export default class MKeyVerificationRequest extends React.Component { return null; } } - -MKeyVerificationRequest.propTypes = { - /* the MatrixEvent to show */ - mxEvent: PropTypes.object.isRequired, -}; diff --git a/src/components/views/messages/MStickerBody.js b/src/components/views/messages/MStickerBody.js index 54eb7649b4..31af66baf5 100644 --- a/src/components/views/messages/MStickerBody.js +++ b/src/components/views/messages/MStickerBody.js @@ -17,7 +17,8 @@ limitations under the License. import React from 'react'; import MImageBody from './MImageBody'; import * as sdk from '../../../index'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { BLURHASH_FIELD } from "../../../ContentMessages"; @replaceableComponent("views.messages.MStickerBody") export default class MStickerBody extends MImageBody { @@ -41,9 +42,9 @@ export default class MStickerBody extends MImageBody { // Placeholder to show in place of the sticker image if // img onLoad hasn't fired yet. - getPlaceholder() { - const TintableSVG = sdk.getComponent('elements.TintableSvg'); - return ; + getPlaceholder(width, height) { + if (this.props.mxEvent.getContent().info[BLURHASH_FIELD]) return super.getPlaceholder(width, height); + return ; } // Tooltip to show on mouse over @@ -53,7 +54,7 @@ export default class MStickerBody extends MImageBody { if (!content || !content.body || !content.info || !content.info.w) return null; const Tooltip = sdk.getComponent('elements.Tooltip'); - return
      + return
      ; } diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index 2efdce506e..d882bb1eb0 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -16,13 +16,16 @@ limitations under the License. */ import React from 'react'; +import { decode } from "blurhash"; + import MFileBody from './MFileBody'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import InlineSpinner from '../elements/InlineSpinner'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromContent} from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromContent } from "../../../customisations/Media"; +import { BLURHASH_FIELD } from "../../../ContentMessages"; interface IProps { /* the MatrixEvent to show */ @@ -32,11 +35,13 @@ interface IProps { } interface IState { - decryptedUrl: string|null, - decryptedThumbnailUrl: string|null, - decryptedBlob: Blob|null, - error: any|null, - fetchingData: boolean, + decryptedUrl?: string; + decryptedThumbnailUrl?: string; + decryptedBlob?: Blob; + error?: any; + fetchingData: boolean; + posterLoading: boolean; + blurhashUrl: string; } @replaceableComponent("views.messages.MVideoBody") @@ -51,10 +56,12 @@ export default class MVideoBody extends React.PureComponent { decryptedThumbnailUrl: null, decryptedBlob: null, error: null, - } + posterLoading: false, + blurhashUrl: null, + }; } - thumbScale(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) { + thumbScale(fullWidth: number, fullHeight: number, thumbWidth = 480, thumbHeight = 360) { if (!fullWidth || !fullHeight) { // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even // log this because it's spammy @@ -92,8 +99,11 @@ export default class MVideoBody extends React.PureComponent { private getThumbUrl(): string|null { const content = this.props.mxEvent.getContent(); const media = mediaFromContent(content); - if (media.isEncrypted) { + + if (media.isEncrypted && this.state.decryptedThumbnailUrl) { return this.state.decryptedThumbnailUrl; + } else if (this.state.posterLoading) { + return this.state.blurhashUrl; } else if (media.hasThumbnail) { return media.thumbnailHttp; } else { @@ -101,18 +111,57 @@ export default class MVideoBody extends React.PureComponent { } } + private loadBlurhash() { + const info = this.props.mxEvent.getContent()?.info; + if (!info[BLURHASH_FIELD]) return; + + const canvas = document.createElement("canvas"); + + let width = info.w; + let height = info.h; + const scale = this.thumbScale(info.w, info.h); + if (scale) { + width = Math.floor(info.w * scale); + height = Math.floor(info.h * scale); + } + + canvas.width = width; + canvas.height = height; + + const pixels = decode(info[BLURHASH_FIELD], width, height); + const ctx = canvas.getContext("2d"); + const imgData = ctx.createImageData(width, height); + imgData.data.set(pixels); + ctx.putImageData(imgData, 0, 0); + + this.setState({ + blurhashUrl: canvas.toDataURL(), + posterLoading: true, + }); + + const content = this.props.mxEvent.getContent(); + const media = mediaFromContent(content); + if (media.hasThumbnail) { + const image = new Image(); + image.onload = () => { + this.setState({ posterLoading: false }); + }; + image.src = media.thumbnailHttp; + } + } + async componentDidMount() { const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean; const content = this.props.mxEvent.getContent(); + this.loadBlurhash(); + if (content.file !== undefined && this.state.decryptedUrl === null) { let thumbnailPromise = Promise.resolve(null); - if (content.info && content.info.thumbnail_file) { - thumbnailPromise = decryptFile( - content.info.thumbnail_file, - ).then(function(blob) { - return URL.createObjectURL(blob); - }); + if (content?.info?.thumbnail_file) { + thumbnailPromise = decryptFile(content.info.thumbnail_file) + .then(blob => URL.createObjectURL(blob)); } + try { const thumbnailUrl = await thumbnailPromise; if (autoplay) { @@ -182,7 +231,7 @@ export default class MVideoBody extends React.PureComponent { this.videoRef.current.play(); }); this.props.onHeightChanged(); - } + }; render() { const content = this.props.mxEvent.getContent(); @@ -218,7 +267,7 @@ export default class MVideoBody extends React.PureComponent { let poster = null; let preload = "metadata"; if (content.info) { - const scale = this.thumbScale(content.info.w, content.info.h, 480, 360); + const scale = this.thumbScale(content.info.w, content.info.h); if (scale) { width = Math.floor(content.info.w * scale); height = Math.floor(content.info.h * scale); diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx index d65de7697a..2edd42f2e4 100644 --- a/src/components/views/messages/MVoiceMessageBody.tsx +++ b/src/components/views/messages/MVoiceMessageBody.tsx @@ -15,15 +15,16 @@ limitations under the License. */ import React from "react"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {Playback} from "../../../voice/Playback"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { Playback } from "../../../voice/Playback"; import MFileBody from "./MFileBody"; import InlineSpinner from '../elements/InlineSpinner'; -import {_t} from "../../../languageHandler"; -import {mediaFromContent} from "../../../customisations/Media"; -import {decryptFile} from "../../../utils/DecryptFile"; -import RecordingPlayback from "../voice_messages/RecordingPlayback"; +import { _t } from "../../../languageHandler"; +import { mediaFromContent } from "../../../customisations/Media"; +import { decryptFile } from "../../../utils/DecryptFile"; +import RecordingPlayback from "../audio_messages/RecordingPlayback"; +import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; interface IProps { mxEvent: MatrixEvent; @@ -45,15 +46,15 @@ export default class MVoiceMessageBody extends React.PureComponent r.blob()).then(r => r.arrayBuffer()); } catch (e) { - this.setState({error: e}); + this.setState({ error: e }); console.warn("Unable to download voice message", e); return; // stop processing the audio file } @@ -105,6 +106,6 @@ export default class MVoiceMessageBody extends React.PureComponent - ) + ); } } diff --git a/src/components/views/messages/MVoiceOrAudioBody.tsx b/src/components/views/messages/MVoiceOrAudioBody.tsx index 0cebcf3440..676b5a2c47 100644 --- a/src/components/views/messages/MVoiceOrAudioBody.tsx +++ b/src/components/views/messages/MVoiceOrAudioBody.tsx @@ -15,9 +15,9 @@ limitations under the License. */ import React from "react"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import MAudioBody from "./MAudioBody"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import SettingsStore from "../../../settings/SettingsStore"; import MVoiceMessageBody from "./MVoiceMessageBody"; @@ -28,7 +28,9 @@ interface IProps { @replaceableComponent("views.messages.MVoiceOrAudioBody") export default class MVoiceOrAudioBody extends React.PureComponent { public render() { - const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']; + // MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245 + const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice'] + || !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice']; const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages"); if (isVoiceMessage && voiceMessagesEnabled) { return ; diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 37737519ce..7532554666 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -16,24 +16,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useEffect} from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { EventStatus } from 'matrix-js-sdk/src/models/event'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; -import {aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu} from '../../structures/ContextMenu'; +import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; import RoomContext from "../../../contexts/RoomContext"; import Toolbar from "../../../accessibility/Toolbar"; -import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {canCancel} from "../context_menus/MessageContextMenu"; +import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { canCancel } from "../context_menus/MessageContextMenu"; import Resend from "../../../Resend"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => { +const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const [onFocus, isActive, ref] = useRovingTabIndex(button); useEffect(() => { @@ -48,15 +48,14 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo const replyThread = getReplyThread && getReplyThread(); const buttonRect = button.current.getBoundingClientRect(); - contextMenu = - - ; + contextMenu = ; } return @@ -74,7 +73,7 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo ; }; -const ReactButton = ({mxEvent, reactions, onFocusChange}) => { +const ReactButton = ({ mxEvent, reactions, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const [onFocus, isActive, ref] = useRovingTabIndex(button); useEffect(() => { diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 78e0dc422d..52a0b9ad08 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -14,14 +14,14 @@ 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 SettingsStore from "../../../settings/SettingsStore"; -import {Mjolnir} from "../../../mjolnir/Mjolnir"; +import { Mjolnir } from "../../../mjolnir/Mjolnir"; import RedactedBody from "./RedactedBody"; import UnknownBody from "./UnknownBody"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.messages.MessageEvent") export default class MessageEvent extends React.Component { diff --git a/src/components/views/messages/MessageTimestamp.js b/src/components/views/messages/MessageTimestamp.tsx similarity index 67% rename from src/components/views/messages/MessageTimestamp.js rename to src/components/views/messages/MessageTimestamp.tsx index a7f350adcd..8b02f6b38e 100644 --- a/src/components/views/messages/MessageTimestamp.js +++ b/src/components/views/messages/MessageTimestamp.tsx @@ -16,20 +16,19 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {formatFullDate, formatTime, formatFullTime} from '../../../DateUtils'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { formatFullDate, formatTime, formatFullTime } from '../../../DateUtils'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + ts: number; + showTwelveHour?: boolean; + showFullDate?: boolean; + showSeconds?: boolean; +} @replaceableComponent("views.messages.MessageTimestamp") -export default class MessageTimestamp extends React.Component { - static propTypes = { - ts: PropTypes.number.isRequired, - showTwelveHour: PropTypes.bool, - showFullDate: PropTypes.bool, - showSeconds: PropTypes.bool, - }; - - render() { +export default class MessageTimestamp extends React.Component { + public render() { const date = new Date(this.props.ts); let timestamp; if (this.props.showFullDate) { @@ -41,7 +40,11 @@ export default class MessageTimestamp extends React.Component { } return ( - + {timestamp} ); diff --git a/src/components/views/messages/MjolnirBody.js b/src/components/views/messages/MjolnirBody.js index 4368fd936c..67484a6d9c 100644 --- a/src/components/views/messages/MjolnirBody.js +++ b/src/components/views/messages/MjolnirBody.js @@ -16,8 +16,8 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {_t} from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { _t } from '../../../languageHandler'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.messages.MjolnirBody") export default class MjolnirBody extends React.Component { @@ -43,7 +43,7 @@ export default class MjolnirBody extends React.Component { return (
      {_t( "You have ignored this user, so their message is hidden. Show anyways.", - {}, {a: (sub) => {sub}}, + {}, { a: (sub) => {sub} }, )}
      ); } diff --git a/src/components/views/messages/ReactionsRow.tsx b/src/components/views/messages/ReactionsRow.tsx index 0180baa466..55ffb8deac 100644 --- a/src/components/views/messages/ReactionsRow.tsx +++ b/src/components/views/messages/ReactionsRow.tsx @@ -125,7 +125,7 @@ export default class ReactionsRow extends React.PureComponent { private onDecrypted = () => { // Decryption changes whether the event is actionable this.forceUpdate(); - } + }; private onReactionsChange = () => { // TODO: Call `onHeightChanged` as needed @@ -136,7 +136,7 @@ export default class ReactionsRow extends React.PureComponent { // has changed (this is triggered by events for that purpose only) and // `PureComponent`s shallow state / props compare would otherwise filter this out. this.forceUpdate(); - } + }; private getMyReactions() { const reactions = this.props.reactions; @@ -155,7 +155,7 @@ export default class ReactionsRow extends React.PureComponent { this.setState({ showAll: true, }); - } + }; render() { const { mxEvent, reactions } = this.props; diff --git a/src/components/views/messages/ReactionsRowButton.tsx b/src/components/views/messages/ReactionsRowButton.tsx index d163f1ad30..53e27b882e 100644 --- a/src/components/views/messages/ReactionsRowButton.tsx +++ b/src/components/views/messages/ReactionsRowButton.tsx @@ -68,7 +68,7 @@ export default class ReactionsRowButton extends React.PureComponent { this.setState({ tooltipVisible: false, }); - } + }; render() { const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props; diff --git a/src/components/views/messages/RedactedBody.tsx b/src/components/views/messages/RedactedBody.tsx index 5f80460d03..3e5da1dd43 100644 --- a/src/components/views/messages/RedactedBody.tsx +++ b/src/components/views/messages/RedactedBody.tsx @@ -14,19 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useContext} from "react"; -import {MatrixClient} from "matrix-js-sdk/src/client"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import React, { useContext } from "react"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t } from "../../../languageHandler"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {formatFullDate} from "../../../DateUtils"; +import { formatFullDate } from "../../../DateUtils"; import SettingsStore from "../../../settings/SettingsStore"; interface IProps { mxEvent: MatrixEvent; } -const RedactedBody = React.forwardRef(({mxEvent}, ref) => { +const RedactedBody = React.forwardRef(({ mxEvent }, ref) => { const cli: MatrixClient = useContext(MatrixClientContext); let text = _t("Message deleted"); diff --git a/src/components/views/messages/RoomAvatarEvent.js b/src/components/views/messages/RoomAvatarEvent.js index 41eada3193..d68d794ee6 100644 --- a/src/components/views/messages/RoomAvatarEvent.js +++ b/src/components/views/messages/RoomAvatarEvent.js @@ -18,13 +18,13 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import Modal from '../../../Modal'; import AccessibleButton from '../elements/AccessibleButton'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; @replaceableComponent("views.messages.RoomAvatarEvent") export default class RoomAvatarEvent extends React.Component { @@ -60,7 +60,7 @@ export default class RoomAvatarEvent extends React.Component { if (!ev.getContent().url || ev.getContent().url.trim().length === 0) { return (
      - { _t('%(senderDisplayName)s removed the room avatar.', {senderDisplayName}) } + { _t('%(senderDisplayName)s removed the room avatar.', { senderDisplayName }) }
      ); } diff --git a/src/components/views/messages/RoomCreate.js b/src/components/views/messages/RoomCreate.js index 3e02884c02..56bd25cbac 100644 --- a/src/components/views/messages/RoomCreate.js +++ b/src/components/views/messages/RoomCreate.js @@ -21,9 +21,9 @@ import PropTypes from 'prop-types'; import dis from '../../../dispatcher/dispatcher'; import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { _t } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; import EventTileBubble from "./EventTileBubble"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.messages.RoomCreate") export default class RoomCreate extends React.Component { diff --git a/src/components/views/messages/SenderProfile.js b/src/components/views/messages/SenderProfile.tsx similarity index 71% rename from src/components/views/messages/SenderProfile.js rename to src/components/views/messages/SenderProfile.tsx index 8f10954370..11c3ca4e3c 100644 --- a/src/components/views/messages/SenderProfile.js +++ b/src/components/views/messages/SenderProfile.tsx @@ -15,23 +15,30 @@ */ import React from 'react'; -import PropTypes from 'prop-types'; -import Flair from '../elements/Flair.js'; +import Flair from '../elements/Flair'; import FlairStore from '../../../stores/FlairStore'; -import {getUserNameColorClass} from '../../../utils/FormattingUtils'; +import { getUserNameColorClass } from '../../../utils/FormattingUtils'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +interface IProps { + mxEvent: MatrixEvent; + onClick(): void; + enableFlair: boolean; +} + +interface IState { + userGroups; + relatedGroups; +} @replaceableComponent("views.messages.SenderProfile") -export default class SenderProfile extends React.Component { - static propTypes = { - mxEvent: PropTypes.object.isRequired, // event whose sender we're showing - onClick: PropTypes.func, - }; - +export default class SenderProfile extends React.Component { static contextType = MatrixClientContext; + private unmounted: boolean; - constructor(props) { + constructor(props: IProps) { super(props); const senderId = this.props.mxEvent.getSender(); @@ -40,6 +47,7 @@ export default class SenderProfile extends React.Component { relatedGroups: [], }; } + componentDidMount() { this.unmounted = false; this._updateRelatedGroups(); @@ -48,7 +56,6 @@ export default class SenderProfile extends React.Component { this.getPublicisedGroups(); } - this.context.on('RoomState.events', this.onRoomStateEvents); } @@ -62,7 +69,7 @@ export default class SenderProfile extends React.Component { const userGroups = await FlairStore.getPublicisedGroupsCached( this.context, this.props.mxEvent.getSender(), ); - this.setState({userGroups}); + this.setState({ userGroups }); } } @@ -98,16 +105,28 @@ export default class SenderProfile extends React.Component { } render() { - const {mxEvent} = this.props; + const { mxEvent } = this.props; const colorClass = getUserNameColorClass(mxEvent.getSender()); - const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); - const {msgtype} = mxEvent.getContent(); + const { msgtype } = mxEvent.getContent(); + + const disambiguate = mxEvent.sender?.disambiguate; + const displayName = mxEvent.sender?.rawDisplayName || mxEvent.getSender() || ""; + const mxid = mxEvent.sender?.userId || mxEvent.getSender() || ""; if (msgtype === 'm.emote') { return null; // emote message must include the name so don't duplicate it } - let flair = null; + let mxidElement; + if (disambiguate) { + mxidElement = ( + + { mxid } + + ); + } + + let flair; if (this.props.enableFlair) { const displayedGroups = this._getDisplayedGroups( this.state.userGroups, this.state.relatedGroups, @@ -119,13 +138,12 @@ export default class SenderProfile extends React.Component { />; } - const nameElem = name || ''; - return ( -
      - - { nameElem } +
      + + { displayName } + { mxidElement } { flair }
      ); diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.tsx similarity index 76% rename from src/components/views/messages/TextualBody.js rename to src/components/views/messages/TextualBody.tsx index 3adfea6ee6..6ba018c512 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.tsx @@ -1,7 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector 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. @@ -16,132 +14,151 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef, SyntheticEvent } from 'react'; import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; import highlight from 'highlight.js'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { MsgType } from "matrix-js-sdk/src/@types/event"; + import * as HtmlUtils from '../../../HtmlUtils'; -import {formatDate} from '../../../DateUtils'; -import * as sdk from '../../../index'; +import { formatDate } from '../../../DateUtils'; import Modal from '../../../Modal'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import * as ContextMenu from '../../structures/ContextMenu'; +import { toRightOf } from '../../structures/ContextMenu'; import SettingsStore from "../../../settings/SettingsStore"; import ReplyThread from "../elements/ReplyThread"; -import {pillifyLinks, unmountPills} from '../../../utils/pillify'; -import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; -import {isPermalinkHost} from "../../../utils/permalinks/Permalinks"; -import {toRightOf} from "../../structures/ContextMenu"; -import {copyPlaintext} from "../../../utils/strings"; +import { pillifyLinks, unmountPills } from '../../../utils/pillify'; +import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; +import { isPermalinkHost } from "../../../utils/permalinks/Permalinks"; +import { copyPlaintext } from "../../../utils/strings"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import UIStore from "../../../stores/UIStore"; +import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; +import { Action } from "../../../dispatcher/actions"; +import { TileShape } from '../rooms/EventTile'; +import EditorStateTransfer from "../../../utils/EditorStateTransfer"; +import GenericTextContextMenu from "../context_menus/GenericTextContextMenu"; +import Spoiler from "../elements/Spoiler"; +import QuestionDialog from "../dialogs/QuestionDialog"; +import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; +import EditMessageComposer from '../rooms/EditMessageComposer'; +import LinkPreviewWidget from '../rooms/LinkPreviewWidget'; + +interface IProps { + /* the MatrixEvent to show */ + mxEvent: MatrixEvent; + + /* a list of words to highlight */ + highlights?: string[]; + + /* link URL for the highlights */ + highlightLink?: string; + + /* should show URL previews for this event */ + showUrlPreview?: boolean; + + /* the shape of the tile, used */ + tileShape?: TileShape; + + editState?: EditorStateTransfer; + replacingEventId?: string; + + /* callback for when our widget has loaded */ + onHeightChanged(): void; +} + +interface IState { + // the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody. + links: string[]; + + // track whether the preview widget is hidden + widgetHidden: boolean; +} @replaceableComponent("views.messages.TextualBody") -export default class TextualBody extends React.Component { - static propTypes = { - /* the MatrixEvent to show */ - mxEvent: PropTypes.object.isRequired, +export default class TextualBody extends React.Component { + private readonly contentRef = createRef(); - /* a list of words to highlight */ - highlights: PropTypes.array, - - /* link URL for the highlights */ - highlightLink: PropTypes.string, - - /* should show URL previews for this event */ - showUrlPreview: PropTypes.bool, - - /* callback for when our widget has loaded */ - onHeightChanged: PropTypes.func, - - /* the shape of the tile, used */ - tileShape: PropTypes.string, - }; + private unmounted = false; + private pills: Element[] = []; constructor(props) { super(props); - this._content = createRef(); - this.state = { - // the URLs (if any) to be previewed with a LinkPreviewWidget - // inside this TextualBody. links: [], - - // track whether the preview widget is hidden widgetHidden: false, }; } componentDidMount() { - this._unmounted = false; - this._pills = []; if (!this.props.editState) { - this._applyFormatting(); + this.applyFormatting(); } } - _applyFormatting() { + private applyFormatting(): void { const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers"); - this.activateSpoilers([this._content.current]); + this.activateSpoilers([this.contentRef.current]); // pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer // are still sent as plaintext URLs. If these are ever pillified in the composer, // we should be pillify them here by doing the linkifying BEFORE the pillifying. - pillifyLinks([this._content.current], this.props.mxEvent, this._pills); - HtmlUtils.linkifyElement(this._content.current); + pillifyLinks([this.contentRef.current], this.props.mxEvent, this.pills); + HtmlUtils.linkifyElement(this.contentRef.current); this.calculateUrlPreview(); if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") { // Handle expansion and add buttons - const pres = ReactDOM.findDOMNode(this).getElementsByTagName("pre"); + const pres = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("pre"); if (pres.length > 0) { for (let i = 0; i < pres.length; i++) { // If there already is a div wrapping the codeblock we want to skip this. // This happens after the codeblock was edited. - if (pres[i].parentNode.className == "mx_EventTile_pre_container") continue; + if (pres[i].parentElement.className == "mx_EventTile_pre_container") continue; // Add code element if it's missing since we depend on it if (pres[i].getElementsByTagName("code").length == 0) { - this._addCodeElement(pres[i]); + this.addCodeElement(pres[i]); } // Wrap a div around
       so that the copy button can be correctly positioned
                           // when the 
       overflows and is scrolled horizontally.
      -                    const div = this._wrapInDiv(pres[i]);
      -                    this._handleCodeBlockExpansion(pres[i]);
      -                    this._addCodeExpansionButton(div, pres[i]);
      -                    this._addCodeCopyButton(div);
      +                    const div = this.wrapInDiv(pres[i]);
      +                    this.handleCodeBlockExpansion(pres[i]);
      +                    this.addCodeExpansionButton(div, pres[i]);
      +                    this.addCodeCopyButton(div);
                           if (showLineNumbers) {
      -                        this._addLineNumbers(pres[i]);
      +                        this.addLineNumbers(pres[i]);
                           }
                       }
                   }
                   // Highlight code
      -            const codes = ReactDOM.findDOMNode(this).getElementsByTagName("code");
      +            const codes = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("code");
                   if (codes.length > 0) {
                       // Do this asynchronously: parsing code takes time and we don't
                       // need to block the DOM update on it.
                       setTimeout(() => {
      -                    if (this._unmounted) return;
      +                    if (this.unmounted) return;
                           for (let i = 0; i < codes.length; i++) {
                               // If the code already has the hljs class we want to skip this.
                               // This happens after the codeblock was edited.
                               if (codes[i].className.includes("hljs")) continue;
      -                        this._highlightCode(codes[i]);
      +                        this.highlightCode(codes[i]);
                           }
                       }, 10);
                   }
               }
           }
       
      -    _addCodeElement(pre) {
      +    private addCodeElement(pre: HTMLPreElement): void {
               const code = document.createElement("code");
               code.append(...pre.childNodes);
               pre.appendChild(code);
           }
       
      -    _addCodeExpansionButton(div, pre) {
      +    private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
               // Calculate how many percent does the pre element take up.
               // If it's less than 30% we don't add the expansion button.
               const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
      @@ -173,7 +190,7 @@ export default class TextualBody extends React.Component {
               div.appendChild(button);
           }
       
      -    _addCodeCopyButton(div) {
      +    private addCodeCopyButton(div: HTMLDivElement): void {
               const button = document.createElement("span");
               button.className = "mx_EventTile_button mx_EventTile_copyButton ";
       
      @@ -183,12 +200,11 @@ export default class TextualBody extends React.Component {
               if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom";
       
               button.onclick = async () => {
      -            const copyCode = button.parentNode.getElementsByTagName("code")[0];
      +            const copyCode = button.parentElement.getElementsByTagName("code")[0];
                   const successful = await copyPlaintext(copyCode.textContent);
       
                   const buttonRect = button.getBoundingClientRect();
      -            const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
      -            const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
      +            const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
                       ...toRightOf(buttonRect, 2),
                       message: successful ? _t('Copied!') : _t('Failed to copy'),
                   });
      @@ -198,7 +214,7 @@ export default class TextualBody extends React.Component {
               div.appendChild(button);
           }
       
      -    _wrapInDiv(pre) {
      +    private wrapInDiv(pre: HTMLPreElement): HTMLDivElement {
               const div = document.createElement("div");
               div.className = "mx_EventTile_pre_container";
       
      @@ -210,13 +226,13 @@ export default class TextualBody extends React.Component {
               return div;
           }
       
      -    _handleCodeBlockExpansion(pre) {
      +    private handleCodeBlockExpansion(pre: HTMLPreElement): void {
               if (!SettingsStore.getValue("expandCodeByDefault")) {
                   pre.className = "mx_EventTile_collapsedCodeBlock";
               }
           }
       
      -    _addLineNumbers(pre) {
      +    private addLineNumbers(pre: HTMLPreElement): void {
               // Calculate number of lines in pre
               const number = pre.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
               pre.innerHTML = '' + pre.innerHTML + '';
      @@ -227,7 +243,7 @@ export default class TextualBody extends React.Component {
               }
           }
       
      -    _highlightCode(code) {
      +    private highlightCode(code: HTMLElement): void {
               if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
                   highlight.highlightBlock(code);
               } else {
      @@ -247,14 +263,14 @@ export default class TextualBody extends React.Component {
                   const stoppedEditing = prevProps.editState && !this.props.editState;
                   const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
                   if (messageWasEdited || stoppedEditing) {
      -                this._applyFormatting();
      +                this.applyFormatting();
                   }
               }
           }
       
           componentWillUnmount() {
      -        this._unmounted = true;
      -        unmountPills(this._pills);
      +        this.unmounted = true;
      +        unmountPills(this.pills);
           }
       
           shouldComponentUpdate(nextProps, nextState) {
      @@ -271,12 +287,12 @@ export default class TextualBody extends React.Component {
                       nextState.widgetHidden !== this.state.widgetHidden);
           }
       
      -    calculateUrlPreview() {
      +    private calculateUrlPreview(): void {
               //console.info("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
       
               if (this.props.showUrlPreview) {
                   // pass only the first child which is the event tile otherwise this recurses on edited events
      -            let links = this.findLinks([this._content.current]);
      +            let links = this.findLinks([this.contentRef.current]);
                   if (links.length) {
                       // de-duplicate the links after stripping hashes as they don't affect the preview
                       // using a set here maintains the order
      @@ -289,8 +305,8 @@ export default class TextualBody extends React.Component {
                       this.setState({ links });
       
                       // lazy-load the hidden state of the preview widget from localstorage
      -                if (global.localStorage) {
      -                    const hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
      +                if (window.localStorage) {
      +                    const hidden = !!window.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
                           this.setState({ widgetHidden: hidden });
                       }
                   } else if (this.state.links.length) {
      @@ -299,19 +315,15 @@ export default class TextualBody extends React.Component {
               }
           }
       
      -    activateSpoilers(nodes) {
      +    private activateSpoilers(nodes: ArrayLike): void {
               let node = nodes[0];
               while (node) {
                   if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
                       const spoilerContainer = document.createElement('span');
       
                       const reason = node.getAttribute("data-mx-spoiler");
      -                const Spoiler = sdk.getComponent('elements.Spoiler');
                       node.removeAttribute("data-mx-spoiler"); // we don't want to recurse
      -                const spoiler = ;
      +                const spoiler = ;
       
                       ReactDOM.render(spoiler, spoilerContainer);
                       node.parentNode.replaceChild(spoilerContainer, node);
      @@ -320,15 +332,15 @@ export default class TextualBody extends React.Component {
                   }
       
                   if (node.childNodes && node.childNodes.length) {
      -                this.activateSpoilers(node.childNodes);
      +                this.activateSpoilers(node.childNodes as NodeListOf);
                   }
       
      -            node = node.nextSibling;
      +            node = node.nextSibling as Element;
               }
           }
       
      -    findLinks(nodes) {
      -        let links = [];
      +    private findLinks(nodes: ArrayLike): string[] {
      +        let links: string[] = [];
       
               for (let i = 0; i < nodes.length; i++) {
                   const node = nodes[i];
      @@ -346,7 +358,7 @@ export default class TextualBody extends React.Component {
               return links;
           }
       
      -    isLinkPreviewable(node) {
      +    private isLinkPreviewable(node: Element): boolean {
               // don't try to preview relative links
               if (!node.getAttribute("href").startsWith("http://") &&
                   !node.getAttribute("href").startsWith("https://")) {
      @@ -379,7 +391,7 @@ export default class TextualBody extends React.Component {
               }
           }
       
      -    onCancelClick = event => {
      +    private onCancelClick = (): void => {
               this.setState({ widgetHidden: true });
               // FIXME: persist this somewhere smarter than local storage
               if (global.localStorage) {
      @@ -388,28 +400,28 @@ export default class TextualBody extends React.Component {
               this.forceUpdate();
           };
       
      -    onEmoteSenderClick = event => {
      +    private onEmoteSenderClick = (): void => {
               const mxEvent = this.props.mxEvent;
      -        dis.dispatch({
      -            action: 'insert_mention',
      -            user_id: mxEvent.getSender(),
      +        dis.dispatch({
      +            action: Action.ComposerInsert,
      +            userId: mxEvent.getSender(),
               });
           };
       
      -    getEventTileOps = () => ({
      +    public getEventTileOps = () => ({
               isWidgetHidden: () => {
                   return this.state.widgetHidden;
               },
       
               unhideWidget: () => {
      -            this.setState({widgetHidden: false});
      +            this.setState({ widgetHidden: false });
                   if (global.localStorage) {
                       global.localStorage.removeItem("hide_preview_" + this.props.mxEvent.getId());
                   }
               },
           });
       
      -    onStarterLinkClick = (starterLink, ev) => {
      +    private onStarterLinkClick = (starterLink: string, ev: SyntheticEvent): void => {
               ev.preventDefault();
               // We need to add on our scalar token to the starter link, but we may not have one!
               // In addition, we can't fetch one on click and then go to it immediately as that
      @@ -429,7 +441,6 @@ export default class TextualBody extends React.Component {
               const scalarClient = integrationManager.getScalarClient();
               scalarClient.connect().then(() => {
                   const completeUrl = scalarClient.getStarterLink(starterLink);
      -            const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
                   const integrationsUrl = integrationManager.uiUrl;
                   Modal.createTrackedDialog('Add an integration', '', QuestionDialog, {
                       title: _t("Add an Integration"),
      @@ -456,18 +467,17 @@ export default class TextualBody extends React.Component {
               });
           };
       
      -    _openHistoryDialog = async () => {
      -        const MessageEditHistoryDialog = sdk.getComponent("views.dialogs.MessageEditHistoryDialog");
      -        Modal.createDialog(MessageEditHistoryDialog, {mxEvent: this.props.mxEvent});
      +    private openHistoryDialog = async (): Promise => {
      +        Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent });
           };
       
      -    _renderEditedMarker() {
      +    private renderEditedMarker() {
               const date = this.props.mxEvent.replacingEventDate();
               const dateString = date && formatDate(date);
       
               const tooltip = 
      - {_t("Edited at %(date)s", {date: dateString})} + {_t("Edited at %(date)s", { date: dateString })}
      {_t("Click to view edits")} @@ -477,8 +487,8 @@ export default class TextualBody extends React.Component { return ( {`(${_t("edited")})`} @@ -488,24 +498,25 @@ export default class TextualBody extends React.Component { render() { if (this.props.editState) { - const EditMessageComposer = sdk.getComponent('rooms.EditMessageComposer'); return ; } const mxEvent = this.props.mxEvent; const content = mxEvent.getContent(); // only strip reply if this is the original replying event, edits thereafter do not have the fallback - const stripReply = !mxEvent.replacingEvent() && ReplyThread.getParentEventId(mxEvent); + const stripReply = !mxEvent.replacingEvent() && !!ReplyThread.getParentEventId(mxEvent); let body = HtmlUtils.bodyToHtml(content, this.props.highlights, { - disableBigEmoji: content.msgtype === "m.emote" || !SettingsStore.getValue('TextualBody.enableBigEmoji'), + disableBigEmoji: content.msgtype === MsgType.Emote + || !SettingsStore.getValue('TextualBody.enableBigEmoji'), // Part of Replies fallback support stripReplyFallback: stripReply, - ref: this._content, + ref: this.contentRef, + returnString: false, }); if (this.props.replacingEventId) { body = <> {body} - {this._renderEditedMarker()} + {this.renderEditedMarker()} ; } @@ -519,7 +530,6 @@ export default class TextualBody extends React.Component { let widgets; if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) { - const LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget'); widgets = this.state.links.map((link)=>{ return *  @@ -547,7 +557,7 @@ export default class TextualBody extends React.Component { { widgets } ); - case "m.notice": + case MsgType.Notice: return ( { body } diff --git a/src/components/views/messages/TextualEvent.js b/src/components/views/messages/TextualEvent.tsx similarity index 66% rename from src/components/views/messages/TextualEvent.js rename to src/components/views/messages/TextualEvent.tsx index a020cc6c52..70f90a33e4 100644 --- a/src/components/views/messages/TextualEvent.js +++ b/src/components/views/messages/TextualEvent.tsx @@ -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. @@ -16,20 +15,20 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; + import * as TextForEvent from "../../../TextForEvent"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + mxEvent: MatrixEvent; +} @replaceableComponent("views.messages.TextualEvent") -export default class TextualEvent extends React.Component { - static propTypes = { - /* the MatrixEvent to show */ - mxEvent: PropTypes.object.isRequired, - }; - +export default class TextualEvent extends React.Component { render() { - const text = TextForEvent.textForEvent(this.props.mxEvent); - if (text == null || text.length === 0) return null; + const text = TextForEvent.textForEvent(this.props.mxEvent, true); + if (!text || (text as string).length === 0) return null; return (
      { text }
      ); diff --git a/src/components/views/messages/TileErrorBoundary.js b/src/components/views/messages/TileErrorBoundary.tsx similarity index 77% rename from src/components/views/messages/TileErrorBoundary.js rename to src/components/views/messages/TileErrorBoundary.tsx index 0e9a7b6128..967127d275 100644 --- a/src/components/views/messages/TileErrorBoundary.js +++ b/src/components/views/messages/TileErrorBoundary.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. @@ -16,14 +16,24 @@ limitations under the License. import React from 'react'; import classNames from 'classnames'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + import { _t } from '../../../languageHandler'; -import * as sdk from '../../../index'; import Modal from '../../../Modal'; import SdkConfig from "../../../SdkConfig"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BugReportDialog from '../dialogs/BugReportDialog'; + +interface IProps { + mxEvent: MatrixEvent; +} + +interface IState { + error: Error; +} @replaceableComponent("views.messages.TileErrorBoundary") -export default class TileErrorBoundary extends React.Component { +export default class TileErrorBoundary extends React.Component { constructor(props) { super(props); @@ -32,17 +42,13 @@ export default class TileErrorBoundary extends React.Component { }; } - static getDerivedStateFromError(error) { + static getDerivedStateFromError(error: Error): Partial { // Side effects are not permitted here, so we only update the state so // that the next render shows an error message. return { error }; } - _onBugReport = () => { - const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); - if (!BugReportDialog) { - return; - } + private onBugReport = (): void => { Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, { label: 'react-soft-crash-tile', }); @@ -60,7 +66,7 @@ export default class TileErrorBoundary extends React.Component { let submitLogsButton; if (SdkConfig.get().bug_report_endpoint_url) { - submitLogsButton = + submitLogsButton = {_t("Submit logs")} ; } diff --git a/src/components/views/messages/UnknownBody.js b/src/components/views/messages/UnknownBody.js index 786facc340..0f866216fc 100644 --- a/src/components/views/messages/UnknownBody.js +++ b/src/components/views/messages/UnknownBody.js @@ -15,9 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {forwardRef} from "react"; +import React, { forwardRef } from "react"; -export default forwardRef(({mxEvent}, ref) => { +export default forwardRef(({ mxEvent }, ref) => { const text = mxEvent.getContent().body; return ( diff --git a/src/components/views/messages/ViewSourceEvent.js b/src/components/views/messages/ViewSourceEvent.js index 2ec567c5ad..62454fef1a 100644 --- a/src/components/views/messages/ViewSourceEvent.js +++ b/src/components/views/messages/ViewSourceEvent.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @replaceableComponent("views.messages.ViewSourceEvent") @@ -36,7 +36,7 @@ export default class ViewSourceEvent extends React.PureComponent { } componentDidMount() { - const {mxEvent} = this.props; + const { mxEvent } = this.props; const client = MatrixClientPeg.get(); client.decryptEventIfNeeded(mxEvent); diff --git a/src/components/views/right_panel/BaseCard.tsx b/src/components/views/right_panel/BaseCard.tsx index 09973809de..2528139a2b 100644 --- a/src/components/views/right_panel/BaseCard.tsx +++ b/src/components/views/right_panel/BaseCard.tsx @@ -14,16 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ReactNode} from 'react'; +import React, { ReactNode } from 'react'; import classNames from 'classnames'; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {_t} from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; -import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; -import {Action} from "../../../dispatcher/actions"; -import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; +import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; +import { Action } from "../../../dispatcher/actions"; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; interface IProps { header?: ReactNode; diff --git a/src/components/views/right_panel/EncryptionInfo.tsx b/src/components/views/right_panel/EncryptionInfo.tsx index aa51965ac6..c34cf18710 100644 --- a/src/components/views/right_panel/EncryptionInfo.tsx +++ b/src/components/views/right_panel/EncryptionInfo.tsx @@ -17,10 +17,11 @@ limitations under the License. import React from "react"; import * as sdk from "../../../index"; -import {_t} from "../../../languageHandler"; -import {RoomMember} from "matrix-js-sdk/src/models/room-member"; +import { _t } from "../../../languageHandler"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { User } from "matrix-js-sdk/src/models/user"; -export const PendingActionSpinner = ({text}) => { +export const PendingActionSpinner = ({ text }) => { const Spinner = sdk.getComponent('elements.Spinner'); return
      @@ -31,7 +32,7 @@ export const PendingActionSpinner = ({text}) => { interface IProps { waitingForOtherParty: boolean; waitingForNetwork: boolean; - member: RoomMember; + member: RoomMember | User; onStartVerification: () => Promise; isRoomEncrypted: boolean; inDialog: boolean; @@ -55,7 +56,7 @@ const EncryptionInfo: React.FC = ({ text = _t("Accept on your other login…"); } else { text = _t("Waiting for %(displayName)s to accept…", { - displayName: member.displayName || member.name || member.userId, + displayName: (member as User).displayName || (member as RoomMember).name || member.userId, }); } } else { diff --git a/src/components/views/right_panel/EncryptionPanel.tsx b/src/components/views/right_panel/EncryptionPanel.tsx index c237a4ade6..251c04d3cc 100644 --- a/src/components/views/right_panel/EncryptionPanel.tsx +++ b/src/components/views/right_panel/EncryptionPanel.tsx @@ -14,33 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useCallback, useEffect, useState} from "react"; +import React, { useCallback, useEffect, useState } from "react"; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { User } from "matrix-js-sdk/src/models/user"; +import { PHASE_REQUESTED, PHASE_UNSENT } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import EncryptionInfo from "./EncryptionInfo"; import VerificationPanel from "./VerificationPanel"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {ensureDMExists} from "../../../createRoom"; -import {useEventEmitter} from "../../../hooks/useEventEmitter"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { ensureDMExists } from "../../../createRoom"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; import Modal from "../../../Modal"; -import {PHASE_REQUESTED, PHASE_UNSENT} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import * as sdk from "../../../index"; -import {_t} from "../../../languageHandler"; -import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import {RoomMember} from "matrix-js-sdk/src/models/room-member"; +import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; -import {Action} from "../../../dispatcher/actions"; -import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; +import { Action } from "../../../dispatcher/actions"; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; // cancellation codes which constitute a key mismatch const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"]; interface IProps { - member: RoomMember; + member: RoomMember | User; onClose: () => void; verificationRequest: VerificationRequest; - verificationRequestPromise: Promise; + verificationRequestPromise?: Promise; layout: string; - inDialog: boolean; isRoomEncrypted: boolean; } diff --git a/src/components/views/right_panel/GroupHeaderButtons.tsx b/src/components/views/right_panel/GroupHeaderButtons.tsx index 3c93cf6470..132a76e891 100644 --- a/src/components/views/right_panel/GroupHeaderButtons.tsx +++ b/src/components/views/right_panel/GroupHeaderButtons.tsx @@ -48,7 +48,7 @@ export default class GroupHeaderButtons extends HeaderButtons { protected onAction(payload: ActionPayload) { if (payload.action === Action.ViewUser) { if ((payload as ViewUserPayload).member) { - this.setPhase(RightPanelPhases.RoomMemberInfo, {member: payload.member}); + this.setPhase(RightPanelPhases.RoomMemberInfo, { member: payload.member }); } else { this.setPhase(RightPanelPhases.GroupMemberList); } @@ -57,14 +57,14 @@ export default class GroupHeaderButtons extends HeaderButtons { } else if (payload.action === "view_group_room") { this.setPhase( RightPanelPhases.GroupRoomInfo, - {groupRoomId: payload.groupRoomId, groupId: payload.groupId}, + { groupRoomId: payload.groupRoomId, groupId: payload.groupId }, ); } else if (payload.action === "view_group_room_list") { this.setPhase(RightPanelPhases.GroupRoomList); } else if (payload.action === "view_group_member_list") { this.setPhase(RightPanelPhases.GroupMemberList); } else if (payload.action === "view_group_user") { - this.setPhase(RightPanelPhases.GroupMemberInfo, {member: payload.member}); + this.setPhase(RightPanelPhases.GroupMemberInfo, { member: payload.member }); } } diff --git a/src/components/views/right_panel/HeaderButton.tsx b/src/components/views/right_panel/HeaderButton.tsx index cdf4f44e06..1625db3f55 100644 --- a/src/components/views/right_panel/HeaderButton.tsx +++ b/src/components/views/right_panel/HeaderButton.tsx @@ -48,7 +48,7 @@ export default class HeaderButton extends React.Component { public render() { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const {isHighlighted, onClick, analytics, name, title, ...props} = this.props; + const { isHighlighted, onClick, analytics, name, title, ...props } = this.props; const classes = classNames({ mx_RightPanel_headerButton: true, diff --git a/src/components/views/right_panel/HeaderButtons.tsx b/src/components/views/right_panel/HeaderButtons.tsx index 6d44a081d9..c30ad60771 100644 --- a/src/components/views/right_panel/HeaderButtons.tsx +++ b/src/components/views/right_panel/HeaderButtons.tsx @@ -88,9 +88,9 @@ export default abstract class HeaderButtons

      extends React.Component => { return readPinnedEvents; }; -const useRoomState = (room: Room, mapper: (state: RoomState) => T): T => { - const [value, setValue] = useState(room ? mapper(room.currentState) : undefined); - - const update = useCallback(() => { - if (!room) return; - setValue(mapper(room.currentState)); - }, [room, mapper]); - - useEventEmitter(room?.currentState, "RoomState.events", update); - useEffect(() => { - update(); - return () => { - setValue(undefined); - }; - }, [update]); - return value; -}; - const PinnedMessagesCard = ({ room, onClose }: IProps) => { const cli = useContext(MatrixClientContext); const canUnpin = useRoomState(room, state => state.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli)); diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index 54e18e4529..4d256bf4f4 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -77,13 +77,13 @@ export default class RoomHeaderButtons extends HeaderButtons { protected onAction(payload: ActionPayload) { if (payload.action === Action.ViewUser) { if (payload.member) { - this.setPhase(RightPanelPhases.RoomMemberInfo, {member: payload.member}); + this.setPhase(RightPanelPhases.RoomMemberInfo, { member: payload.member }); } else { this.setPhase(RightPanelPhases.RoomMemberList); } } else if (payload.action === "view_3pid_invite") { if (payload.event) { - this.setPhase(RightPanelPhases.Room3pidMemberInfo, {event: payload.event}); + this.setPhase(RightPanelPhases.Room3pidMemberInfo, { event: payload.event }); } else { this.setPhase(RightPanelPhases.RoomMemberList); } diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index 937037f644..21c1c39827 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useCallback, useState, useEffect, useContext} from "react"; +import React, { useCallback, useState, useEffect, useContext } from "react"; import classNames from "classnames"; -import {Room} from "matrix-js-sdk/src/models/room"; +import { Room } from "matrix-js-sdk/src/models/room"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { useIsEncrypted } from '../../../hooks/useIsEncrypted'; @@ -25,25 +25,25 @@ import { _t } from '../../../languageHandler'; import RoomAvatar from "../avatars/RoomAvatar"; import AccessibleButton from "../elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; -import {Action} from "../../../dispatcher/actions"; -import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; -import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; +import { Action } from "../../../dispatcher/actions"; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; +import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; import Modal from "../../../Modal"; import ShareDialog from '../dialogs/ShareDialog'; -import {useEventEmitter} from "../../../hooks/useEventEmitter"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; import WidgetUtils from "../../../utils/WidgetUtils"; -import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; +import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; import TextWithTooltip from "../elements/TextWithTooltip"; import WidgetAvatar from "../avatars/WidgetAvatar"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import WidgetStore, {IApp} from "../../../stores/WidgetStore"; +import WidgetStore, { IApp } from "../../../stores/WidgetStore"; import { E2EStatus } from "../../../utils/ShieldUtils"; import RoomContext from "../../../contexts/RoomContext"; -import {UIFeature} from "../../../settings/UIFeature"; -import {ChevronFace, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu"; +import { UIFeature } from "../../../settings/UIFeature"; +import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; import WidgetContextMenu from "../context_menus/WidgetContextMenu"; -import {useRoomMemberCount} from "../../../hooks/useRoomMembers"; +import { useRoomMemberCount } from "../../../hooks/useRoomMembers"; import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import RoomName from "../elements/RoomName"; import UIStore from "../../../stores/UIStore"; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index d6c97f9cf2..e9d80d49c5 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -38,7 +38,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import RoomViewStore from "../../../stores/RoomViewStore"; import MultiInviter from "../../../utils/MultiInviter"; import GroupStore from "../../../stores/GroupStore"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; import E2EIcon from "../rooms/E2EIcon"; import { useEventEmitter } from "../../../hooks/useEventEmitter"; import { textualPowerLevel } from '../../../Roles'; @@ -48,7 +48,7 @@ import EncryptionPanel from "./EncryptionPanel"; import { useAsyncMemo } from '../../../hooks/useAsyncMemo'; import { legacyVerifyUser, verifyDevice, verifyUser } from '../../../verification'; import { Action } from "../../../dispatcher/actions"; -import { USER_SECURITY_TAB } from "../dialogs/UserSettingsDialog"; +import { UserTab } from "../dialogs/UserSettingsDialog"; import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; import BaseCard from "./BaseCard"; import { E2EStatus } from "../../../utils/ShieldUtils"; @@ -68,6 +68,7 @@ import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; import { mediaFromMxc } from "../../../customisations/Media"; import UIStore from "../../../stores/UIStore"; +import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; export interface IDevice { deviceId: string; @@ -146,7 +147,7 @@ async function openDMForUser(matrixClient: MatrixClient, userId: string) { type SetUpdating = (updating: boolean) => void; -function useHasCrossSigningKeys(cli: MatrixClient, member: RoomMember, canVerify: boolean, setUpdating: SetUpdating) { +function useHasCrossSigningKeys(cli: MatrixClient, member: User, canVerify: boolean, setUpdating: SetUpdating) { return useAsyncMemo(async () => { if (!canVerify) { return undefined; @@ -163,7 +164,7 @@ function useHasCrossSigningKeys(cli: MatrixClient, member: RoomMember, canVerify }, [cli, member, canVerify], undefined); } -function DeviceItem({userId, device}: {userId: string, device: IDevice}) { +function DeviceItem({ userId, device }: {userId: string, device: IDevice}) { const cli = useContext(MatrixClientContext); const isMe = userId === cli.getUserId(); const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId); @@ -201,7 +202,6 @@ function DeviceItem({userId, device}: {userId: string, device: IDevice}) { let trustedLabel = null; if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted"); - if (isVerified) { return (

      @@ -225,7 +225,7 @@ function DeviceItem({userId, device}: {userId: string, device: IDevice}) { } } -function DevicesSection({devices, userId, loading}: {devices: IDevice[], userId: string, loading: boolean}) { +function DevicesSection({ devices, userId, loading }: {devices: IDevice[], userId: string, loading: boolean}) { const cli = useContext(MatrixClientContext); const userTrust = cli.checkUserTrust(userId); @@ -265,12 +265,12 @@ function DevicesSection({devices, userId, loading}: {devices: IDevice[], userId: unverifiedDevices.push(device); } } - expandCountCaption = _t("%(count)s verified sessions", {count: expandSectionDevices.length}); + expandCountCaption = _t("%(count)s verified sessions", { count: expandSectionDevices.length }); expandHideCaption = _t("Hide verified sessions"); expandIconClasses += " mx_E2EIcon_verified"; } else { expandSectionDevices = devices; - expandCountCaption = _t("%(count)s sessions", {count: devices.length}); + expandCountCaption = _t("%(count)s sessions", { count: devices.length }); expandHideCaption = _t("Hide sessions"); expandIconClasses += " mx_E2EIcon_normal"; } @@ -316,7 +316,7 @@ const UserOptionsSection: React.FC<{ isIgnored: boolean; canInvite: boolean; isSpace?: boolean; -}> = ({member, isIgnored, canInvite, isSpace}) => { +}> = ({ member, isIgnored, canInvite, isSpace }) => { const cli = useContext(MatrixClientContext); let ignoreButton = null; @@ -350,7 +350,7 @@ const UserOptionsSection: React.FC<{ ignoreButton = ( { isIgnored ? _t("Unignore") : _t("Ignore") } @@ -368,9 +368,9 @@ const UserOptionsSection: React.FC<{ }; const onInsertPillButton = function() { - dis.dispatch({ - action: 'insert_mention', - user_id: member.userId, + dis.dispatch({ + action: Action.ComposerInsert, + userId: member.userId, }); }; @@ -449,7 +449,7 @@ const UserOptionsSection: React.FC<{ }; const warnSelfDemote = async (isSpace: boolean) => { - const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, { + const { finished } = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, { title: _t("Demote yourself?"), description:
      @@ -468,7 +468,7 @@ const warnSelfDemote = async (isSpace: boolean) => { return confirmed; }; -const GenericAdminToolsContainer: React.FC<{}> = ({children}) => { +const GenericAdminToolsContainer: React.FC<{}> = ({ children }) => { return (

      { _t("Admin Tools") }

      @@ -502,19 +502,15 @@ const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) => return member.powerLevel < levelToSend; }; +const getPowerLevels = room => room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {}; + export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => { - const [powerLevels, setPowerLevels] = useState({}); + const [powerLevels, setPowerLevels] = useState(getPowerLevels(room)); const update = useCallback((ev?: MatrixEvent) => { if (!room) return; if (ev && ev.getType() !== EventType.RoomPowerLevels) return; - - const event = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); - if (event) { - setPowerLevels(event.getContent()); - } else { - setPowerLevels({}); - } + setPowerLevels(getPowerLevels(room)); }, [room]); useEventEmitter(cli, "RoomState.events", update); @@ -533,14 +529,14 @@ interface IBaseProps { stopUpdating(): void; } -const RoomKickButton: React.FC = ({member, startUpdating, stopUpdating}) => { +const RoomKickButton: React.FC = ({ member, startUpdating, stopUpdating }) => { const cli = useContext(MatrixClientContext); // check if user can be kicked/disinvited if (member.membership !== "invite" && member.membership !== "join") return null; const onKick = async () => { - const {finished} = Modal.createTrackedDialog( + const { finished } = Modal.createTrackedDialog( 'Confirm User Action Dialog', 'onKick', ConfirmUserActionDialog, @@ -578,11 +574,11 @@ const RoomKickButton: React.FC = ({member, startUpdating, stopUpdati ; }; -const RedactMessagesButton: React.FC = ({member}) => { +const RedactMessagesButton: React.FC = ({ member }) => { const cli = useContext(MatrixClientContext); const onRedactAllMessages = async () => { - const {roomId, userId} = member; + const { roomId, userId } = member; const room = cli.getRoom(roomId); if (!room) { return; @@ -610,23 +606,23 @@ const RedactMessagesButton: React.FC = ({member}) => { if (count === 0) { Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, { - title: _t("No recent messages by %(user)s found", {user}), + title: _t("No recent messages by %(user)s found", { user }), description:

      { _t("Try scrolling up in the timeline to see if there are any earlier ones.") }

      , }); } else { - const {finished} = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, { - title: _t("Remove recent messages by %(user)s", {user}), + const { finished } = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, { + title: _t("Remove recent messages by %(user)s", { user }), description:

      { _t("You are about to remove %(count)s messages by %(user)s. " + - "This cannot be undone. Do you wish to continue?", {count, user}) }

      + "This cannot be undone. Do you wish to continue?", { count, user }) }

      { _t("For a large amount of messages, this might take some time. " + "Please don't refresh your client in the meantime.") }

      , - button: _t("Remove %(count)s messages", {count}), + button: _t("Remove %(count)s messages", { count }), }); const [confirmed] = await finished; @@ -657,11 +653,11 @@ const RedactMessagesButton: React.FC = ({member}) => { ; }; -const BanToggleButton: React.FC = ({member, startUpdating, stopUpdating}) => { +const BanToggleButton: React.FC = ({ member, startUpdating, stopUpdating }) => { const cli = useContext(MatrixClientContext); const onBanOrUnban = async () => { - const {finished} = Modal.createTrackedDialog( + const { finished } = Modal.createTrackedDialog( 'Confirm User Action Dialog', 'onBanOrUnban', ConfirmUserActionDialog, @@ -718,7 +714,7 @@ interface IBaseRoomProps extends IBaseProps { powerLevels: IPowerLevelsContent; } -const MuteToggleButton: React.FC = ({member, room, powerLevels, startUpdating, stopUpdating}) => { +const MuteToggleButton: React.FC = ({ member, room, powerLevels, startUpdating, stopUpdating }) => { const cli = useContext(MatrixClientContext); // Don't show the mute/unmute option if the user is not in the room @@ -865,7 +861,7 @@ const GroupAdminToolsSection: React.FC<{ groupMember: GroupMember; startUpdating(): void; stopUpdating(): void; -}> = ({children, groupId, groupMember, startUpdating, stopUpdating}) => { +}> = ({ children, groupId, groupMember, startUpdating, stopUpdating }) => { const cli = useContext(MatrixClientContext); const [isPrivileged, setIsPrivileged] = useState(false); @@ -894,7 +890,7 @@ const GroupAdminToolsSection: React.FC<{ if (isPrivileged) { const onKick = async () => { - const {finished} = Modal.createDialog(ConfirmUserActionDialog, { + const { finished } = Modal.createDialog(ConfirmUserActionDialog, { matrixClient: cli, groupMember, action: isInvited ? _t('Disinvite') : _t('Remove from community'), @@ -971,7 +967,7 @@ interface IRoomPermissions { canInvite: boolean; } -function useRoomPermissions(cli: MatrixClient, room: Room, user: User): IRoomPermissions { +function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions { const [roomPermissions, setRoomPermissions] = useState({ // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL modifyLevelMax: -1, @@ -1028,16 +1024,16 @@ function useRoomPermissions(cli: MatrixClient, room: Room, user: User): IRoomPer } const PowerLevelSection: React.FC<{ - user: User; + user: RoomMember; room: Room; roomPermissions: IRoomPermissions; powerLevels: IPowerLevelsContent; -}> = ({user, room, roomPermissions, powerLevels}) => { +}> = ({ user, room, roomPermissions, powerLevels }) => { if (roomPermissions.canEdit) { return (); } else { const powerLevelUsersDefault = powerLevels.users_default || 0; - const powerLevel = parseInt(user.powerLevel, 10); + const powerLevel = user.powerLevel; const role = textualPowerLevel(powerLevel, powerLevelUsersDefault); return (
      @@ -1048,13 +1044,13 @@ const PowerLevelSection: React.FC<{ }; const PowerLevelEditor: React.FC<{ - user: User; + user: RoomMember; room: Room; roomPermissions: IRoomPermissions; -}> = ({user, room, roomPermissions}) => { +}> = ({ user, room, roomPermissions }) => { const cli = useContext(MatrixClientContext); - const [selectedPowerLevel, setSelectedPowerLevel] = useState(parseInt(user.powerLevel, 10)); + const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel); const onPowerChange = useCallback(async (powerLevelStr: string) => { const powerLevel = parseInt(powerLevelStr, 10); setSelectedPowerLevel(powerLevel); @@ -1084,7 +1080,7 @@ const PowerLevelEditor: React.FC<{ const myUserId = cli.getUserId(); const myPower = powerLevelEvent.getContent().users[myUserId]; if (myPower && parseInt(myPower) === powerLevel) { - const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, { + const { finished } = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, { title: _t("Warning!"), description:
      @@ -1201,7 +1197,7 @@ const BasicUserInfo: React.FC<{ groupId: string; devices: IDevice[]; isRoomEncrypted: boolean; -}> = ({room, member, groupId, devices, isRoomEncrypted}) => { +}> = ({ room, member, groupId, devices, isRoomEncrypted }) => { const cli = useContext(MatrixClientContext); const powerLevels = useRoomPowerLevels(cli, room); @@ -1231,10 +1227,10 @@ const BasicUserInfo: React.FC<{ setPendingUpdateCount(pendingUpdateCount - 1); }, [pendingUpdateCount]); - const roomPermissions = useRoomPermissions(cli, room, member); + const roomPermissions = useRoomPermissions(cli, room, member as RoomMember); const onSynapseDeactivate = useCallback(async () => { - const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, { + const { finished } = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, { title: _t("Deactivate user?"), description:
      { _t( @@ -1275,12 +1271,26 @@ const BasicUserInfo: React.FC<{ ); } + let memberDetails; let adminToolsContainer; - if (room && member.roomId) { + if (room && (member as RoomMember).roomId) { + // hide the Roles section for DMs as it doesn't make sense there + if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) { + memberDetails =
      +

      { _t("Role") }

      + +
      ; + } + adminToolsContainer = ( @@ -1309,20 +1319,6 @@ const BasicUserInfo: React.FC<{ spinner = ; } - let memberDetails; - // hide the Roles section for DMs as it doesn't make sense there - if (room && member.roomId && !DMRoomMap.shared().getUserIdForRoomId(member.roomId)) { - memberDetails =
      -

      { _t("Role") }

      - -
      ; - } - // only display the devices list if our client supports E2E const cryptoEnabled = cli.isCryptoEnabled(); @@ -1349,8 +1345,7 @@ const BasicUserInfo: React.FC<{ const setUpdating = (updating) => { setPendingUpdateCount(count => count + (updating ? 1 : -1)); }; - const hasCrossSigningKeys = - useHasCrossSigningKeys(cli, member, canVerify, setUpdating ); + const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify, setUpdating); const showDeviceListSpinner = devices === undefined; if (canVerify) { @@ -1359,9 +1354,9 @@ const BasicUserInfo: React.FC<{ verifyButton = ( { if (hasCrossSigningKeys) { - verifyUser(member); + verifyUser(member as User); } else { - legacyVerifyUser(member); + legacyVerifyUser(member as User); } }}> {_t("Verify")} @@ -1381,12 +1376,12 @@ const BasicUserInfo: React.FC<{ { dis.dispatch({ action: Action.ViewUserSettings, - initialTabId: USER_SECURITY_TAB, + initialTabId: UserTab.Security, }); }}> { _t("Edit devices") } -

      ) +

      ); } const securitySection = ( @@ -1409,7 +1404,7 @@ const BasicUserInfo: React.FC<{ @@ -1424,17 +1419,19 @@ type Member = User | RoomMember | GroupMember; const UserInfoHeader: React.FC<{ member: Member; e2eStatus: E2EStatus; -}> = ({member, e2eStatus}) => { +}> = ({ member, e2eStatus }) => { const cli = useContext(MatrixClientContext); const onMemberAvatarClick = useCallback(() => { - const avatarUrl = member.getMxcAvatarUrl ? member.getMxcAvatarUrl() : member.avatarUrl; + const avatarUrl = (member as RoomMember).getMxcAvatarUrl + ? (member as RoomMember).getMxcAvatarUrl() + : (member as User).avatarUrl; if (!avatarUrl) return; const httpUrl = mediaFromMxc(avatarUrl).srcHttp; const params = { src: httpUrl, - name: member.name, + name: (member as RoomMember).name || (member as User).displayName, }; Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); @@ -1446,13 +1443,13 @@ const UserInfoHeader: React.FC<{
      + urls={(member as User).avatarUrl ? [(member as User).avatarUrl] : undefined} />
      @@ -1469,7 +1466,11 @@ const UserInfoHeader: React.FC<{ presenceCurrentlyActive = member.user.currentlyActive; if (SettingsStore.getValue("feature_custom_status")) { - statusMessage = member.user._unstable_statusMessage; + if ((member as RoomMember).user) { + statusMessage = member.user.unstable_statusMessage; + } else { + statusMessage = (member as unknown as User).unstable_statusMessage; + } } } @@ -1500,7 +1501,7 @@ const UserInfoHeader: React.FC<{ e2eIcon = ; } - const displayName = member.rawDisplayName || member.displayname; + const displayName = (member as RoomMember).rawDisplayName || (member as GroupMember).displayname; return { avatarElement } @@ -1565,7 +1566,7 @@ const UserInfo: React.FC = ({ // We have no previousPhase for when viewing a UserInfo from a Group or without a Room at this time if (room && phase === RightPanelPhases.EncryptionPanel) { previousPhase = RightPanelPhases.RoomMemberInfo; - refireParams = {member: member}; + refireParams = { member: member }; } else if (room) { previousPhase = previousPhase = SettingsStore.getValue("feature_spaces") && room.isSpaceRoom() ? RightPanelPhases.SpaceMemberList @@ -1578,7 +1579,7 @@ const UserInfo: React.FC = ({ phase: previousPhase, refireParams: refireParams, }); - } + }; let content; switch (phase) { @@ -1588,7 +1589,7 @@ const UserInfo: React.FC = ({ content = ( @@ -1599,7 +1600,7 @@ const UserInfo: React.FC = ({ content = ( } - member={member} + member={member as User | RoomMember} onClose={onEncryptionPanelClose} isRoomEncrypted={isRoomEncrypted} /> diff --git a/src/components/views/right_panel/VerificationPanel.tsx b/src/components/views/right_panel/VerificationPanel.tsx index ac01c953b9..6087923057 100644 --- a/src/components/views/right_panel/VerificationPanel.tsx +++ b/src/components/views/right_panel/VerificationPanel.tsx @@ -16,17 +16,18 @@ limitations under the License. import React from "react"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; import * as sdk from '../../../index'; -import {verificationMethods} from 'matrix-js-sdk/src/crypto'; -import {SCAN_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; -import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import {RoomMember} from "matrix-js-sdk/src/models/room-member"; -import {ReciprocateQRCode} from "matrix-js-sdk/src/crypto/verification/QRCode"; -import {SAS} from "matrix-js-sdk/src/crypto/verification/SAS"; +import { verificationMethods } from 'matrix-js-sdk/src/crypto'; +import { SCAN_QR_CODE_METHOD } from "matrix-js-sdk/src/crypto/verification/QRCode"; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { User } from "matrix-js-sdk/src/models/user"; +import { ReciprocateQRCode } from "matrix-js-sdk/src/crypto/verification/QRCode"; +import { SAS } from "matrix-js-sdk/src/crypto/verification/SAS"; import VerificationQRCode from "../elements/crypto/VerificationQRCode"; -import {_t} from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; import E2EIcon from "../rooms/E2EIcon"; import { @@ -36,7 +37,7 @@ import { PHASE_CANCELLED, } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import Spinner from "../elements/Spinner"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; // XXX: Should be defined in matrix-js-sdk enum VerificationPhase { @@ -51,7 +52,7 @@ enum VerificationPhase { interface IProps { layout: string; request: VerificationRequest; - member: RoomMember; + member: RoomMember | User; phase: VerificationPhase; onClose: () => void; isRoomEncrypted: boolean; @@ -77,7 +78,7 @@ export default class VerificationPanel extends React.PureComponent

      {_t("Verify by scanning")}

      {_t("Ask %(displayName)s to scan your code:", { - displayName: member.displayName || member.name || member.userId, + displayName: (member as User).displayName || (member as RoomMember).name || member.userId, })}

      @@ -178,12 +179,12 @@ export default class VerificationPanel extends React.PureComponent { - this.setState({reciprocateButtonClicked: true}); + this.setState({ reciprocateButtonClicked: true }); this.state.reciprocateQREvent.confirm(); }; private onReciprocateNoClick = () => { - this.setState({reciprocateButtonClicked: true}); + this.setState({ reciprocateButtonClicked: true }); this.state.reciprocateQREvent.cancel(); }; @@ -193,38 +194,34 @@ export default class VerificationPanel extends React.PureComponent

      {description}

      - - + onClick={this.onReciprocateYesClick} + > + { _t("Yes") } +
      ; } else { @@ -237,7 +234,7 @@ export default class VerificationPanel extends React.PureComponent { - this.setState({emojiButtonClicked: true}); + this.setState({ emojiButtonClicked: true }); const verifier = this.props.request.beginKeyVerification(verificationMethods.SAS); try { await verifier.verify(); @@ -382,15 +379,15 @@ export default class VerificationPanel extends React.PureComponent { - const {request} = this.props; - const {sasEvent, reciprocateQREvent} = request.verifier; + const { request } = this.props; + const { sasEvent, reciprocateQREvent } = request.verifier; request.verifier.off('show_sas', this.updateVerifierState); request.verifier.off('show_reciprocate_qr', this.updateVerifierState); - this.setState({sasEvent, reciprocateQREvent}); + this.setState({ sasEvent, reciprocateQREvent }); }; private onRequestChange = async () => { - const {request} = this.props; + const { request } = this.props; const hadVerifier = this.hasVerifier; this.hasVerifier = !!request.verifier; if (!hadVerifier && this.hasVerifier) { @@ -407,17 +404,17 @@ export default class VerificationPanel extends React.PureComponent { + private aliasField = createRef(); - _onAliasAdded = async () => { - await this._aliasField.current.validate({ allowEmpty: false }); + private onAliasAdded = async () => { + await this.aliasField.current.validate({ allowEmpty: false }); - if (this._aliasField.current.isValid) { + if (this.aliasField.current.isValid) { if (this.props.onItemAdded) this.props.onItemAdded(this.props.newItem); return; } - this._aliasField.current.focus(); - this._aliasField.current.validate({ allowEmpty: false, focused: true }); + this.aliasField.current.focus(); + this.aliasField.current.validate({ allowEmpty: false, focused: true }); }; - _renderNewItemField() { + protected renderNewItemField() { // if we don't need the RoomAliasField, - // we don't need to overriden version of _renderNewItemField + // we don't need to overriden version of renderNewItemField if (!this.props.domain) { - return super._renderNewItemField(); + return super.renderNewItemField(); } - const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField'); - const onChange = (alias) => this._onNewItemChanged({target: {value: alias}}); + const onChange = (alias) => this.onNewItemChanged({ target: { value: alias } }); return (
      - + { _t("Add") } @@ -75,19 +75,30 @@ class EditableAliasesList extends EditableItemList { } } -@replaceableComponent("views.room_settings.AliasSettings") -export default class AliasSettings extends React.Component { - static propTypes = { - roomId: PropTypes.string.isRequired, - canSetCanonicalAlias: PropTypes.bool.isRequired, - canSetAliases: PropTypes.bool.isRequired, - canonicalAliasEvent: PropTypes.object, // MatrixEvent - }; +interface IProps { + roomId: string; + canSetCanonicalAlias: boolean; + canSetAliases: boolean; + canonicalAliasEvent?: MatrixEvent; + hidePublishSetting?: boolean; +} +interface IState { + altAliases: string[]; + localAliases: string[]; + canonicalAlias?: string; + updatingCanonicalAlias: boolean; + localAliasesLoading: boolean; + detailsOpen: boolean; + newAlias?: string; + newAltAlias?: string; +} + +@replaceableComponent("views.room_settings.AliasSettings") +export default class AliasSettings extends React.Component { static defaultProps = { canSetAliases: false, canSetCanonicalAlias: false, - aliasEvents: [], }; constructor(props) { @@ -122,7 +133,7 @@ export default class AliasSettings extends React.Component { } } - async loadLocalAliases() { + private async loadLocalAliases() { this.setState({ localAliasesLoading: true }); try { const cli = MatrixClientPeg.get(); @@ -134,12 +145,16 @@ export default class AliasSettings extends React.Component { } } this.setState({ localAliases }); + + if (localAliases.length === 0) { + this.setState({ detailsOpen: true }); + } } finally { this.setState({ localAliasesLoading: false }); } } - changeCanonicalAlias(alias) { + private changeCanonicalAlias(alias: string) { if (!this.props.canSetCanonicalAlias) return; const oldAlias = this.state.canonicalAlias; @@ -164,13 +179,13 @@ export default class AliasSettings extends React.Component { "or a temporary failure occurred.", ), }); - this.setState({canonicalAlias: oldAlias}); + this.setState({ canonicalAlias: oldAlias }); }).finally(() => { - this.setState({updatingCanonicalAlias: false}); + this.setState({ updatingCanonicalAlias: false }); }); } - changeAltAliases(altAliases) { + private changeAltAliases(altAliases: string[]) { if (!this.props.canSetCanonicalAlias) return; this.setState({ @@ -181,7 +196,7 @@ export default class AliasSettings extends React.Component { const eventContent = {}; if (this.state.canonicalAlias) { - eventContent.alias = this.state.canonicalAlias; + eventContent["alias"] = this.state.canonicalAlias; } if (altAliases) { eventContent["alt_aliases"] = altAliases; @@ -198,15 +213,15 @@ export default class AliasSettings extends React.Component { ), }); }).finally(() => { - this.setState({updatingCanonicalAlias: false}); + this.setState({ updatingCanonicalAlias: false }); }); } - onNewAliasChanged = (value) => { - this.setState({newAlias: value}); + private onNewAliasChanged = (value: string) => { + this.setState({ newAlias: value }); }; - onLocalAliasAdded = (alias) => { + private onLocalAliasAdded = (alias: string) => { if (!alias || alias.length === 0) return; // ignore attempts to create blank aliases const localDomain = MatrixClientPeg.get().getDomain(); @@ -232,13 +247,13 @@ export default class AliasSettings extends React.Component { }); }; - onLocalAliasDeleted = (index) => { + private onLocalAliasDeleted = (index: number) => { const alias = this.state.localAliases[index]; // TODO: In future, we should probably be making sure that the alias actually belongs // to this room. See https://github.com/vector-im/element-web/issues/7353 MatrixClientPeg.get().deleteAlias(alias).then(() => { const localAliases = this.state.localAliases.filter(a => a !== alias); - this.setState({localAliases}); + this.setState({ localAliases }); if (this.state.canonicalAlias === alias) { this.changeCanonicalAlias(null); @@ -261,7 +276,7 @@ export default class AliasSettings extends React.Component { }); }; - onLocalAliasesToggled = (event) => { + private onLocalAliasesToggled = (event: ChangeEvent) => { // expanded if (event.target.open) { // if local aliases haven't been preloaded yet at component mount @@ -269,43 +284,45 @@ export default class AliasSettings extends React.Component { this.loadLocalAliases(); } } - this.setState({detailsOpen: event.target.open}); + this.setState({ detailsOpen: event.currentTarget.open }); }; - onCanonicalAliasChange = (event) => { + private onCanonicalAliasChange = (event: ChangeEvent) => { this.changeCanonicalAlias(event.target.value); }; - onNewAltAliasChanged = (value) => { - this.setState({newAltAlias: value}); - } + private onNewAltAliasChanged = (value: string) => { + this.setState({ newAltAlias: value }); + }; - onAltAliasAdded = (alias) => { + private onAltAliasAdded = (alias: string) => { const altAliases = this.state.altAliases.slice(); if (!altAliases.some(a => a.trim() === alias.trim())) { altAliases.push(alias.trim()); this.changeAltAliases(altAliases); - this.setState({newAltAlias: ""}); + this.setState({ newAltAlias: "" }); } - } + }; - onAltAliasDeleted = (index) => { + private onAltAliasDeleted = (index: number) => { const altAliases = this.state.altAliases.slice(); altAliases.splice(index, 1); this.changeAltAliases(altAliases); + }; + + private getAliases() { + return this.state.altAliases.concat(this.getLocalNonAltAliases()); } - _getAliases() { - return this.state.altAliases.concat(this._getLocalNonAltAliases()); - } - - _getLocalNonAltAliases() { - const {altAliases} = this.state; + private getLocalNonAltAliases() { + const { altAliases } = this.state; return this.state.localAliases.filter(alias => !altAliases.includes(alias)); } render() { - const localDomain = MatrixClientPeg.get().getDomain(); + const cli = MatrixClientPeg.get(); + const localDomain = cli.getDomain(); + const isSpaceRoom = cli.getRoom(this.props.roomId)?.isSpaceRoom(); let found = false; const canonicalValue = this.state.canonicalAlias || ""; @@ -320,7 +337,7 @@ export default class AliasSettings extends React.Component { > { - this._getAliases().map((alias, i) => { + this.getAliases().map((alias, i) => { if (alias === this.state.canonicalAlias) found = true; return (
      ; diff --git a/src/components/views/room_settings/RoomProfileSettings.js b/src/components/views/room_settings/RoomProfileSettings.js index b6885887b1..ded186af9c 100644 --- a/src/components/views/room_settings/RoomProfileSettings.js +++ b/src/components/views/room_settings/RoomProfileSettings.js @@ -14,14 +14,14 @@ 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 {MatrixClientPeg} from "../../../MatrixClientPeg"; +import { _t } from "../../../languageHandler"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; import Field from "../elements/Field"; import * as sdk from "../../../index"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; // TODO: Merge with ProfileSettings? @replaceableComponent("views.room_settings.RoomProfileSettings") @@ -97,7 +97,7 @@ export default class RoomProfileSettings extends React.Component { e.preventDefault(); if (!this.state.enableProfileSave) return; - this.setState({enableProfileSave: false}); + this.setState({ enableProfileSave: false }); const client = MatrixClientPeg.get(); const newState = {}; @@ -112,7 +112,7 @@ export default class RoomProfileSettings extends React.Component { if (this.state.avatarFile) { const uri = await client.uploadContent(this.state.avatarFile); - await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {url: uri}, ''); + await client.sendStateEvent(this.props.roomId, 'm.room.avatar', { url: uri }, ''); newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); newState.originalAvatarUrl = newState.avatarUrl; newState.avatarFile = null; @@ -129,20 +129,20 @@ export default class RoomProfileSettings extends React.Component { }; _onDisplayNameChanged = (e) => { - this.setState({displayName: e.target.value}); + this.setState({ displayName: e.target.value }); if (this.state.originalDisplayName === e.target.value) { - this.setState({enableProfileSave: false}); + this.setState({ enableProfileSave: false }); } else { - this.setState({enableProfileSave: true}); + this.setState({ enableProfileSave: true }); } }; _onTopicChanged = (e) => { - this.setState({topic: e.target.value}); + this.setState({ topic: e.target.value }); if (this.state.originalTopic === e.target.value) { - this.setState({enableProfileSave: false}); + this.setState({ enableProfileSave: false }); } else { - this.setState({enableProfileSave: true}); + this.setState({ enableProfileSave: true }); } }; diff --git a/src/components/views/room_settings/RoomPublishSetting.js b/src/components/views/room_settings/RoomPublishSetting.tsx similarity index 52% rename from src/components/views/room_settings/RoomPublishSetting.js rename to src/components/views/room_settings/RoomPublishSetting.tsx index 6cc3ce26ba..bc1d6f9e2c 100644 --- a/src/components/views/room_settings/RoomPublishSetting.js +++ b/src/components/views/room_settings/RoomPublishSetting.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. @@ -14,23 +14,37 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React from "react"; + import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; -import {_t} from "../../../languageHandler"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { _t } from "../../../languageHandler"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + roomId: string; + label?: string; + canSetCanonicalAlias?: boolean; +} + +interface IState { + isRoomPublished: boolean; +} @replaceableComponent("views.room_settings.RoomPublishSetting") -export default class RoomPublishSetting extends React.PureComponent { - constructor(props) { - super(props); - this.state = {isRoomPublished: false}; +export default class RoomPublishSetting extends React.PureComponent { + constructor(props, context) { + super(props, context); + + this.state = { + isRoomPublished: false, + }; } - onRoomPublishChange = (e) => { + private onRoomPublishChange = (e) => { const valueBefore = this.state.isRoomPublished; const newValue = !valueBefore; - this.setState({isRoomPublished: newValue}); + this.setState({ isRoomPublished: newValue }); const client = MatrixClientPeg.get(); client.setRoomDirectoryVisibility( @@ -38,25 +52,28 @@ export default class RoomPublishSetting extends React.PureComponent { newValue ? 'public' : 'private', ).catch(() => { // Roll back the local echo on the change - this.setState({isRoomPublished: valueBefore}); + this.setState({ isRoomPublished: valueBefore }); }); }; componentDidMount() { const client = MatrixClientPeg.get(); client.getRoomDirectoryVisibility(this.props.roomId).then((result => { - this.setState({isRoomPublished: result.visibility === 'public'}); + this.setState({ isRoomPublished: result.visibility === 'public' }); })); } render() { const client = MatrixClientPeg.get(); - return (); + return ( + + ); } } diff --git a/src/components/views/room_settings/UrlPreviewSettings.js b/src/components/views/room_settings/UrlPreviewSettings.js index f797ffeb96..0ff3b051d6 100644 --- a/src/components/views/room_settings/UrlPreviewSettings.js +++ b/src/components/views/room_settings/UrlPreviewSettings.js @@ -23,10 +23,10 @@ import * as sdk from "../../../index"; import { _t, _td } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import dis from "../../../dispatcher/dispatcher"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {Action} from "../../../dispatcher/actions"; -import {SettingLevel} from "../../../settings/SettingLevel"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { Action } from "../../../dispatcher/actions"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.room_settings.UrlPreviewSettings") export default class UrlPreviewSettings extends React.Component { diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 693ec8bc80..2866780255 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -18,7 +18,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import {Resizable} from "re-resizable"; +import { Resizable } from "re-resizable"; import AppTile from '../elements/AppTile'; import dis from '../../../dispatcher/dispatcher'; @@ -26,16 +26,16 @@ import * as sdk from '../../../index'; import * as ScalarMessaging from '../../../ScalarMessaging'; import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetEchoStore from "../../../stores/WidgetEchoStore"; -import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; +import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import ResizeHandle from "../elements/ResizeHandle"; import Resizer from "../../../resizer/resizer"; import PercentageDistributor from "../../../resizer/distributors/percentage"; -import {Container, WidgetLayoutStore} from "../../../stores/widgets/WidgetLayoutStore"; -import {clamp, percentageOf, percentageWithin} from "../../../utils/numbers"; -import {useStateCallback} from "../../../hooks/useStateCallback"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; +import { clamp, percentageOf, percentageWithin } from "../../../utils/numbers"; +import { useStateCallback } from "../../../hooks/useStateCallback"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import UIStore from "../../../stores/UIStore"; @replaceableComponent("views.rooms.AppsDrawer") @@ -82,13 +82,6 @@ export default class AppsDrawer extends React.Component { this.props.resizeNotifier.off("isResizing", this.onIsResizing); } - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps(newProps) { - // Room has changed probably, update apps - this._updateApps(); - } - onIsResizing = (resizing) => { // This one is the vertical, ie. change height of apps drawer this.setState({ resizingVertical: resizing }); @@ -141,7 +134,10 @@ export default class AppsDrawer extends React.Component { _getAppsHash = (apps) => apps.map(app => app.id).join("~"); componentDidUpdate(prevProps, prevState) { - if (this._getAppsHash(this.state.apps) !== this._getAppsHash(prevState.apps)) { + if (prevProps.userId !== this.props.userId || prevProps.room !== this.props.room) { + // Room has changed, update apps + this._updateApps(); + } else if (this._getAppsHash(this.state.apps) !== this._getAppsHash(prevState.apps)) { this._loadResizerPreferences(); } } @@ -307,7 +303,7 @@ const PersistentVResizer = ({ }); return { @@ -321,9 +317,9 @@ const PersistentVResizer = ({ resizeNotifier.stopResizing(); }} handleWrapperClass={handleWrapperClass} - handleClasses={{bottom: handleClass}} + handleClasses={{ bottom: handleClass }} className={className} - enable={{bottom: true}} + enable={{ bottom: true }} > { children } ; diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index 7054741da4..8fbecbe722 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -15,15 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef, KeyboardEvent} from 'react'; +import React, { createRef, KeyboardEvent } from 'react'; import classNames from 'classnames'; -import {flatMap} from "lodash"; -import {ICompletion, ISelectionRange, IProviderCompletions} from '../../../autocomplete/Autocompleter'; -import {Room} from 'matrix-js-sdk/src/models/room'; +import { flatMap } from "lodash"; +import { ICompletion, ISelectionRange, IProviderCompletions } from '../../../autocomplete/Autocompleter'; +import { Room } from 'matrix-js-sdk/src/models/room'; import SettingsStore from "../../../settings/SettingsStore"; import Autocompleter from '../../../autocomplete/Autocompleter'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; const COMPOSER_SELECTED = 0; const MAX_PROVIDER_MATCHES = 20; @@ -247,7 +247,7 @@ export default class Autocomplete extends React.PureComponent { }; setSelection(selectionOffset: number) { - this.setState({selectionOffset, hide: false}); + this.setState({ selectionOffset, hide: false }); if (this.props.onSelectionChange) { this.props.onSelectionChange(this.state.completionList[selectionOffset - 1], selectionOffset - 1); } @@ -273,7 +273,7 @@ export default class Autocomplete extends React.PureComponent { const renderedCompletions = this.state.completions.map((completionResult, i) => { const completions = completionResult.completions.map((completion, j) => { const selected = position === this.state.selectionOffset; - const className = classNames('mx_Autocomplete_Completion', {selected}); + const className = classNames('mx_Autocomplete_Completion', { selected }); const componentPosition = position; position++; @@ -291,7 +291,6 @@ export default class Autocomplete extends React.PureComponent { }); }); - return completions.length > 0 ? (
      { completionResult.provider.getName() }
      diff --git a/src/components/views/rooms/AuxPanel.tsx b/src/components/views/rooms/AuxPanel.tsx index 6d2ae39059..f142328895 100644 --- a/src/components/views/rooms/AuxPanel.tsx +++ b/src/components/views/rooms/AuxPanel.tsx @@ -15,48 +15,49 @@ limitations under the License. */ import React from 'react'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import { Room } from 'matrix-js-sdk/src/models/room' -import dis from "../../../dispatcher/dispatcher"; -import AppsDrawer from './AppsDrawer'; import classNames from 'classnames'; -import RateLimitedFunc from '../../../ratelimitedfunc'; +import { lexicographicCompare } from 'matrix-js-sdk/src/utils'; +import { Room } from 'matrix-js-sdk/src/models/room'; + +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import AppsDrawer from './AppsDrawer'; import SettingsStore from "../../../settings/SettingsStore"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {UIFeature} from "../../../settings/UIFeature"; -import { ResizeNotifier } from "../../../utils/ResizeNotifier"; +import { UIFeature } from "../../../settings/UIFeature"; +import ResizeNotifier from "../../../utils/ResizeNotifier"; import CallViewForRoom from '../voip/CallViewForRoom'; -import {objectHasDiff} from "../../../utils/objects"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { objectHasDiff } from "../../../utils/objects"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { throttle } from 'lodash'; interface IProps { // js-sdk room object - room: Room, - userId: string, - showApps: boolean, // Render apps + room: Room; + userId: string; + showApps: boolean; // Render apps // maxHeight attribute for the aux panel and the video // therein - maxHeight: number, + maxHeight: number; // a callback which is called when the content of the aux panel changes // content in a way that is likely to make it change size. - onResize: () => void, - fullHeight: boolean, + onResize: () => void; + fullHeight: boolean; - resizeNotifier: ResizeNotifier, + resizeNotifier: ResizeNotifier; } interface Counter { - title: string, - value: number, - link: string, - severity: string, - stateKey: string, + title: string; + value: number; + link: string; + severity: string; + stateKey: string; } interface IState { - counters: Counter[], + counters: Counter[]; } @replaceableComponent("views.rooms.AuxPanel") @@ -69,19 +70,21 @@ export default class AuxPanel extends React.Component { super(props); this.state = { - counters: this._computeCounters(), + counters: this.computeCounters(), }; } componentDidMount() { const cli = MatrixClientPeg.get(); - cli.on("RoomState.events", this._rateLimitedUpdate); + if (SettingsStore.getValue("feature_state_counters")) { + cli.on("RoomState.events", this.rateLimitedUpdate); + } } componentWillUnmount() { const cli = MatrixClientPeg.get(); - if (cli) { - cli.removeListener("RoomState.events", this._rateLimitedUpdate); + if (cli && SettingsStore.getValue("feature_state_counters")) { + cli.removeListener("RoomState.events", this.rateLimitedUpdate); } } @@ -96,30 +99,16 @@ export default class AuxPanel extends React.Component { } } - onConferenceNotificationClick = (ev, type) => { - dis.dispatch({ - action: 'place_call', - type: type, - room_id: this.props.room.roomId, - }); - ev.stopPropagation(); - ev.preventDefault(); - }; + private rateLimitedUpdate = throttle(() => { + this.setState({ counters: this.computeCounters() }); + }, 500, { leading: true, trailing: true }); - _rateLimitedUpdate = new RateLimitedFunc(() => { - if (SettingsStore.getValue("feature_state_counters")) { - this.setState({counters: this._computeCounters()}); - } - }, 500); - - _computeCounters() { + private computeCounters() { const counters = []; if (this.props.room && SettingsStore.getValue("feature_state_counters")) { const stateEvs = this.props.room.currentState.getStateEvents('re.jki.counter'); - stateEvs.sort((a, b) => { - return a.getStateKey() < b.getStateKey(); - }); + stateEvs.sort((a, b) => lexicographicCompare(a.getStateKey(), b.getStateKey())); for (const ev of stateEvs) { const title = ev.getContent().title; @@ -137,7 +126,7 @@ export default class AuxPanel extends React.Component { link, severity, stateKey, - }) + }); } } } @@ -225,7 +214,7 @@ export default class AuxPanel extends React.Component { } return ( - + { stateViews } { appsDrawer } { callView } diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index e83f066bd0..d317aa409b 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -16,45 +16,46 @@ limitations under the License. */ import classNames from 'classnames'; -import React, {createRef, ClipboardEvent} from 'react'; -import {Room} from 'matrix-js-sdk/src/models/room'; +import React, { createRef, ClipboardEvent } from 'react'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import EMOTICON_REGEX from 'emojibase-regex/emoticon'; import EditorModel from '../../../editor/model'; import HistoryManager from '../../../editor/history'; -import {Caret, setSelection} from '../../../editor/caret'; +import { Caret, setSelection } from '../../../editor/caret'; import { formatRangeAsQuote, formatRangeAsCode, toggleInlineFormat, replaceRangeAndMoveCaret, } from '../../../editor/operations'; -import {getCaretOffsetAndText, getRangeForSelection} from '../../../editor/dom'; -import Autocomplete, {generateCompletionDomId} from '../rooms/Autocomplete'; -import {getAutoCompleteCreator} from '../../../editor/parts'; -import {parsePlainTextMessage} from '../../../editor/deserialize'; -import {renderModel} from '../../../editor/render'; +import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom'; +import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete'; +import { getAutoCompleteCreator } from '../../../editor/parts'; +import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize'; +import { renderModel } from '../../../editor/render'; import TypingStore from "../../../stores/TypingStore"; import SettingsStore from "../../../settings/SettingsStore"; -import {Key} from "../../../Keyboard"; -import {EMOTICON_TO_EMOJI} from "../../../emoji"; -import {CommandCategories, CommandMap, parseCommandString} from "../../../SlashCommands"; +import { Key } from "../../../Keyboard"; +import { EMOTICON_TO_EMOJI } from "../../../emoji"; +import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands"; import Range from "../../../editor/range"; -import MessageComposerFormatBar from "./MessageComposerFormatBar"; +import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar"; import DocumentOffset from "../../../editor/offset"; -import {IDiff} from "../../../editor/diff"; +import { IDiff } from "../../../editor/diff"; import AutocompleteWrapperModel from "../../../editor/autocomplete"; import DocumentPosition from "../../../editor/position"; -import {ICompletion} from "../../../autocomplete/Autocompleter"; +import { ICompletion } from "../../../autocomplete/Autocompleter"; import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; // matches emoticons which follow the start of a line or whitespace const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); const IS_MAC = navigator.platform.indexOf("Mac") !== -1; -function ctrlShortcutLabel(key) { +function ctrlShortcutLabel(key: string): string { return (IS_MAC ? "⌘" : "Ctrl") + "+" + key; } @@ -80,14 +81,6 @@ function selectionEquals(a: Partial, b: Selection): boolean { a.type === b.type; } -enum Formatting { - Bold = "bold", - Italics = "italics", - Strikethrough = "strikethrough", - Code = "code", - Quote = "quote", -} - interface IProps { model: EditorModel; room: Room; @@ -110,9 +103,9 @@ interface IState { @replaceableComponent("views.rooms.BasicMessageEditor") export default class BasicMessageEditor extends React.Component { - private editorRef = createRef(); + public readonly editorRef = createRef(); private autocompleteRef = createRef(); - private formatBarRef = createRef(); + private formatBarRef = createRef(); private modifiedFlag = false; private isIMEComposing = false; @@ -146,7 +139,7 @@ export default class BasicMessageEditor extends React.Component const enabledChange = this.props.disabled !== prevProps.disabled; const placeholderChanged = this.props.placeholder !== prevProps.placeholder; if (this.props.placeholder && (placeholderChanged || enabledChange)) { - const {isEmpty} = this.props.model; + const { isEmpty } = this.props.model; if (isEmpty) { this.showPlaceholder(); } else { @@ -155,8 +148,8 @@ export default class BasicMessageEditor extends React.Component } } - private replaceEmoticon = (caretPosition: DocumentPosition) => { - const {model} = this.props; + private replaceEmoticon = (caretPosition: DocumentPosition): number => { + const { model } = this.props; const range = model.startRange(caretPosition); // expand range max 8 characters backwards from caretPosition, // as a space to look for an emoticon @@ -173,7 +166,7 @@ export default class BasicMessageEditor extends React.Component const data = EMOTICON_TO_EMOJI.get(query) || EMOTICON_TO_EMOJI.get(query.toLowerCase()); if (data) { - const {partCreator} = model; + const { partCreator } = model; const hasPrecedingSpace = emoticonMatch[0][0] === " "; // we need the range to only comprise of the emoticon // because we'll replace the whole range with an emoji, @@ -187,7 +180,7 @@ export default class BasicMessageEditor extends React.Component } }; - private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff) => { + private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => { renderModel(this.editorRef.current, this.props.model); if (selection) { // set the caret/selection try { @@ -199,7 +192,7 @@ export default class BasicMessageEditor extends React.Component const position = selection instanceof Range ? selection.end : selection; this.setLastCaretFromPosition(position); } - const {isEmpty} = this.props.model; + const { isEmpty } = this.props.model; if (this.props.placeholder) { if (isEmpty) { this.showPlaceholder(); @@ -210,13 +203,13 @@ export default class BasicMessageEditor extends React.Component if (isEmpty) { this.formatBarRef.current.hide(); } - this.setState({autoComplete: this.props.model.autoComplete}); + this.setState({ autoComplete: this.props.model.autoComplete }); this.historyManager.tryPush(this.props.model, selection, inputType, diff); let isTyping = !this.props.model.isEmpty; // If the user is entering a command, only consider them typing if it is one which sends a message into the room if (isTyping && this.props.model.parts[0].type === "command") { - const {cmd} = parseCommandString(this.props.model.parts[0].text); + const { cmd } = parseCommandString(this.props.model.parts[0].text); const command = CommandMap.get(cmd); if (!command || !command.isEnabled() || command.category !== CommandCategories.messages) { isTyping = false; @@ -229,25 +222,25 @@ export default class BasicMessageEditor extends React.Component } }; - private showPlaceholder() { + private showPlaceholder(): void { // escape single quotes const placeholder = this.props.placeholder.replace(/'/g, '\\\''); this.editorRef.current.style.setProperty("--placeholder", `'${placeholder}'`); this.editorRef.current.classList.add("mx_BasicMessageComposer_inputEmpty"); } - private hidePlaceholder() { + private hidePlaceholder(): void { this.editorRef.current.classList.remove("mx_BasicMessageComposer_inputEmpty"); this.editorRef.current.style.removeProperty("--placeholder"); } - private onCompositionStart = () => { + private onCompositionStart = (): void => { this.isIMEComposing = true; // even if the model is empty, the composition text shouldn't be mixed with the placeholder this.hidePlaceholder(); }; - private onCompositionEnd = () => { + private onCompositionEnd = (): void => { this.isIMEComposing = false; // some browsers (Chrome) don't fire an input event after ending a composition, // so trigger a model update after the composition is done by calling the input handler. @@ -262,26 +255,26 @@ export default class BasicMessageEditor extends React.Component const isSafari = ua.includes('safari/') && !ua.includes('chrome/'); if (isSafari) { - this.onInput({inputType: "insertCompositionText"}); + this.onInput({ inputType: "insertCompositionText" }); } else { Promise.resolve().then(() => { - this.onInput({inputType: "insertCompositionText"}); + this.onInput({ inputType: "insertCompositionText" }); }); } }; - isComposing(event: React.KeyboardEvent) { + public isComposing(event: React.KeyboardEvent): boolean { // checking the event.isComposing flag just in case any browser out there // emits events related to the composition after compositionend // has been fired return !!(this.isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing)); } - private onCutCopy = (event: ClipboardEvent, type: string) => { + private onCutCopy = (event: ClipboardEvent, type: string): void => { const selection = document.getSelection(); const text = selection.toString(); if (text) { - const {model} = this.props; + const { model } = this.props; const range = getRangeForSelection(this.editorRef.current, model, selection); const selectedParts = range.parts.map(p => p.serialize()); event.clipboardData.setData("application/x-element-composer", JSON.stringify(selectedParts)); @@ -295,23 +288,23 @@ export default class BasicMessageEditor extends React.Component } }; - private onCopy = (event: ClipboardEvent) => { + private onCopy = (event: ClipboardEvent): void => { this.onCutCopy(event, "copy"); }; - private onCut = (event: ClipboardEvent) => { + private onCut = (event: ClipboardEvent): void => { this.onCutCopy(event, "cut"); }; - private onPaste = (event: ClipboardEvent) => { + private onPaste = (event: ClipboardEvent): boolean => { event.preventDefault(); // we always handle the paste ourselves if (this.props.onPaste && this.props.onPaste(event, this.props.model)) { // to prevent double handling, allow props.onPaste to skip internal onPaste return true; } - const {model} = this.props; - const {partCreator} = model; + const { model } = this.props; + const { partCreator } = model; const partsText = event.clipboardData.getData("application/x-element-composer"); let parts; if (partsText) { @@ -327,20 +320,20 @@ export default class BasicMessageEditor extends React.Component replaceRangeAndMoveCaret(range, parts); }; - private onInput = (event: Partial) => { + private onInput = (event: Partial): void => { // ignore any input while doing IME compositions if (this.isIMEComposing) { return; } this.modifiedFlag = true; const sel = document.getSelection(); - const {caret, text} = getCaretOffsetAndText(this.editorRef.current, sel); + const { caret, text } = getCaretOffsetAndText(this.editorRef.current, sel); this.props.model.update(text, event.inputType, caret); }; - private insertText(textToInsert: string, inputType = "insertText") { + private insertText(textToInsert: string, inputType = "insertText"): void { const sel = document.getSelection(); - const {caret, text} = getCaretOffsetAndText(this.editorRef.current, sel); + const { caret, text } = getCaretOffsetAndText(this.editorRef.current, sel); const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset); caret.offset += textToInsert.length; this.modifiedFlag = true; @@ -352,14 +345,14 @@ export default class BasicMessageEditor extends React.Component // we don't need to. But if the user is navigating the caret without input // we need to recalculate it, to be able to know where to insert content after // losing focus - private setLastCaretFromPosition(position: DocumentPosition) { - const {model} = this.props; + private setLastCaretFromPosition(position: DocumentPosition): void { + const { model } = this.props; this._isCaretAtEnd = position.isAtEnd(model); this.lastCaret = position.asOffset(model); this.lastSelection = cloneSelection(document.getSelection()); } - private refreshLastCaretIfNeeded() { + private refreshLastCaretIfNeeded(): DocumentOffset { // XXX: needed when going up and down in editing messages ... not sure why yet // because the editors should stop doing this when when blurred ... // maybe it's on focus and the _editorRef isn't available yet or something. @@ -369,46 +362,46 @@ export default class BasicMessageEditor extends React.Component const selection = document.getSelection(); if (!this.lastSelection || !selectionEquals(this.lastSelection, selection)) { this.lastSelection = cloneSelection(selection); - const {caret, text} = getCaretOffsetAndText(this.editorRef.current, selection); + const { caret, text } = getCaretOffsetAndText(this.editorRef.current, selection); this.lastCaret = caret; this._isCaretAtEnd = caret.offset === text.length; } return this.lastCaret; } - clearUndoHistory() { + public clearUndoHistory(): void { this.historyManager.clear(); } - getCaret() { + public getCaret(): DocumentOffset { return this.lastCaret; } - isSelectionCollapsed() { + public isSelectionCollapsed(): boolean { return !this.lastSelection || this.lastSelection.isCollapsed; } - isCaretAtStart() { + public isCaretAtStart(): boolean { return this.getCaret().offset === 0; } - isCaretAtEnd() { + public isCaretAtEnd(): boolean { return this._isCaretAtEnd; } - private onBlur = () => { + private onBlur = (): void => { document.removeEventListener("selectionchange", this.onSelectionChange); }; - private onFocus = () => { + private onFocus = (): void => { document.addEventListener("selectionchange", this.onSelectionChange); // force to recalculate this.lastSelection = null; this.refreshLastCaretIfNeeded(); }; - private onSelectionChange = () => { - const {isEmpty} = this.props.model; + private onSelectionChange = (): void => { + const { isEmpty } = this.props.model; this.refreshLastCaretIfNeeded(); const selection = document.getSelection(); @@ -426,7 +419,7 @@ export default class BasicMessageEditor extends React.Component } }; - private onKeyDown = (event: React.KeyboardEvent) => { + private onKeyDown = (event: React.KeyboardEvent): void => { const model = this.props.model; let handled = false; const action = getKeyBindingsManager().getMessageComposerAction(event); @@ -445,7 +438,7 @@ export default class BasicMessageEditor extends React.Component break; case MessageComposerAction.EditRedo: if (this.historyManager.canRedo()) { - const {parts, caret} = this.historyManager.redo(); + const { parts, caret } = this.historyManager.redo(); // pass matching inputType so historyManager doesn't push echo // when invoked from rerender callback. model.reset(parts, caret, "historyRedo"); @@ -454,7 +447,7 @@ export default class BasicMessageEditor extends React.Component break; case MessageComposerAction.EditUndo: if (this.historyManager.canUndo()) { - const {parts, caret} = this.historyManager.undo(this.props.model); + const { parts, caret } = this.historyManager.undo(this.props.model); // pass matching inputType so historyManager doesn't push echo // when invoked from rerender callback. model.reset(parts, caret, "historyUndo"); @@ -522,10 +515,10 @@ export default class BasicMessageEditor extends React.Component } }; - private async tabCompleteName() { + private async tabCompleteName(): Promise { try { - await new Promise(resolve => this.setState({showVisualBell: false}, resolve)); - const {model} = this.props; + await new Promise(resolve => this.setState({ showVisualBell: false }, resolve)); + const { model } = this.props; const caret = this.getCaret(); const position = model.positionForOffset(caret.offset, caret.atNodeEnd); const range = model.startRange(position); @@ -536,7 +529,7 @@ export default class BasicMessageEditor extends React.Component part.type === "command" ); }); - const {partCreator} = model; + const { partCreator } = model; // await for auto-complete to be open await model.transform(() => { const addedLen = range.replace([partCreator.pillCandidate(range.text)]); @@ -547,7 +540,7 @@ export default class BasicMessageEditor extends React.Component if (model.autoComplete) { await model.autoComplete.startSelection(); if (!model.autoComplete.hasSelection()) { - this.setState({showVisualBell: true}); + this.setState({ showVisualBell: true }); model.autoComplete.close(); } } @@ -556,27 +549,27 @@ export default class BasicMessageEditor extends React.Component } } - isModified() { + public isModified(): boolean { return this.modifiedFlag; } - private onAutoCompleteConfirm = (completion: ICompletion) => { + private onAutoCompleteConfirm = (completion: ICompletion): void => { this.modifiedFlag = true; this.props.model.autoComplete.onComponentConfirm(completion); }; - private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number) => { + private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number): void => { this.modifiedFlag = true; this.props.model.autoComplete.onComponentSelectionChange(completion); - this.setState({completionIndex}); + this.setState({ completionIndex }); }; - private configureEmoticonAutoReplace = () => { + private configureEmoticonAutoReplace = (): void => { const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji'); this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null); }; - private configureShouldShowPillAvatar = () => { + private configureShouldShowPillAvatar = (): void => { const showPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar"); this.setState({ showPillAvatar }); }; @@ -598,7 +591,7 @@ export default class BasicMessageEditor extends React.Component // not really, but we could not serialize the parts, and just change the autoCompleter partCreator.setAutoCompleteCreator(getAutoCompleteCreator( () => this.autocompleteRef.current, - query => new Promise(resolve => this.setState({query}, resolve)), + query => new Promise(resolve => this.setState({ query }, resolve)), )); // initial render of model this.updateEditorState(this.getInitialCaretPosition()); @@ -610,8 +603,8 @@ export default class BasicMessageEditor extends React.Component this.editorRef.current.focus(); } - private getInitialCaretPosition() { - let caretPosition; + private getInitialCaretPosition(): DocumentPosition { + let caretPosition: DocumentPosition; if (this.props.initialCaret) { // if restoring state from a previous editor, // restore caret position from the state @@ -624,7 +617,7 @@ export default class BasicMessageEditor extends React.Component return caretPosition; } - private onFormatAction = (action: Formatting) => { + private onFormatAction = (action: Formatting): void => { const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); // trim the range as we want it to exclude leading/trailing spaces range.trim(); @@ -665,7 +658,7 @@ export default class BasicMessageEditor extends React.Component query={query} onConfirm={this.onAutoCompleteConfirm} onSelectionChange={this.onAutoCompleteSelectionChange} - selection={{beginning: true, end: queryLen, start: queryLen}} + selection={{ beginning: true, end: queryLen, start: queryLen }} room={this.props.room} />
      ); @@ -679,12 +672,12 @@ export default class BasicMessageEditor extends React.Component }); const shortcuts = { - bold: ctrlShortcutLabel("B"), - italics: ctrlShortcutLabel("I"), - quote: ctrlShortcutLabel(">"), + [Formatting.Bold]: ctrlShortcutLabel("B"), + [Formatting.Italics]: ctrlShortcutLabel("I"), + [Formatting.Quote]: ctrlShortcutLabel(">"), }; - const {completionIndex} = this.state; + const { completionIndex } = this.state; return (
      { autoComplete } @@ -713,7 +706,51 @@ export default class BasicMessageEditor extends React.Component
      ); } - focus() { + public focus(): void { this.editorRef.current.focus(); } + + public insertMention(userId: string): void { + const { model } = this.props; + const { partCreator } = model; + const member = this.props.room.getMember(userId); + const displayName = member ? + member.rawDisplayName : userId; + const caret = this.getCaret(); + const position = model.positionForOffset(caret.offset, caret.atNodeEnd); + // Insert suffix only if the caret is at the start of the composer + const parts = partCreator.createMentionParts(caret.offset === 0, displayName, userId); + model.transform(() => { + const addedLen = model.insert(parts, position); + return model.positionForOffset(caret.offset + addedLen, true); + }); + // refocus on composer, as we just clicked "Mention" + this.focus(); + } + + public insertQuotedMessage(event: MatrixEvent): void { + const { model } = this.props; + const { partCreator } = model; + const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true }); + // add two newlines + quoteParts.push(partCreator.newline()); + quoteParts.push(partCreator.newline()); + model.transform(() => { + const addedLen = model.insert(quoteParts, model.positionForOffset(0)); + return model.positionForOffset(addedLen, true); + }); + // refocus on composer, as we just clicked "Quote" + this.focus(); + } + + public insertPlaintext(text: string): void { + const { model } = this.props; + const { partCreator } = model; + const caret = this.getCaret(); + const position = model.positionForOffset(caret.offset, caret.atNodeEnd); + model.transform(() => { + const addedLen = model.insert([partCreator.plain(text)], position); + return model.positionForOffset(caret.offset + addedLen, true); + }); + } } diff --git a/src/components/views/rooms/E2EIcon.js b/src/components/views/rooms/E2EIcon.js index d7316f198d..7425af6060 100644 --- a/src/components/views/rooms/E2EIcon.js +++ b/src/components/views/rooms/E2EIcon.js @@ -15,11 +15,11 @@ 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 PropTypes from "prop-types"; import classNames from 'classnames'; -import {_t, _td} from '../../../languageHandler'; +import { _t, _td } from '../../../languageHandler'; import AccessibleButton from "../elements/AccessibleButton"; import Tooltip from "../elements/Tooltip"; @@ -42,7 +42,7 @@ const crossSigningRoomTitles = { [E2E_STATE.VERIFIED]: _td("Everyone in this room is verified"), }; -const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip, bordered}) => { +const E2EIcon = ({ isUser, status, className, size, onClick, hideTooltip, bordered }) => { const [hover, setHover] = useState(false); const classes = classNames({ @@ -62,7 +62,7 @@ const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip, bordere let style; if (size) { - style = {width: `${size}px`, height: `${size}px`}; + style = { width: `${size}px`, height: `${size}px` }; } const onMouseOver = () => setHover(true); diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.tsx similarity index 61% rename from src/components/views/rooms/EditMessageComposer.js rename to src/components/views/rooms/EditMessageComposer.tsx index f0980af7ae..4e51a0105b 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -1,6 +1,5 @@ /* -Copyright 2019 New Vector Ltd -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,37 +13,42 @@ 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, _td} from '../../../languageHandler'; -import PropTypes from 'prop-types'; + +import React, { createRef, KeyboardEvent } from 'react'; +import classNames from 'classnames'; +import { EventStatus, IContent, MatrixEvent } from 'matrix-js-sdk/src/models/event'; + +import { _t, _td } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import EditorModel from '../../../editor/model'; -import {getCaretOffsetAndText} from '../../../editor/dom'; -import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize'; -import {findEditableEvent} from '../../../utils/EventUtils'; -import {parseEvent} from '../../../editor/deserialize'; -import {CommandPartCreator} from '../../../editor/parts'; +import { getCaretOffsetAndText } from '../../../editor/dom'; +import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize'; +import { findEditableEvent } from '../../../utils/EventUtils'; +import { parseEvent } from '../../../editor/deserialize'; +import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts'; import EditorStateTransfer from '../../../utils/EditorStateTransfer'; -import classNames from 'classnames'; -import {EventStatus} from 'matrix-js-sdk/src/models/event'; import BasicMessageComposer from "./BasicMessageComposer"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {CommandCategories, getCommand} from '../../../SlashCommands'; -import {Action} from "../../../dispatcher/actions"; +import { Command, CommandCategories, getCommand } from '../../../SlashCommands'; +import { Action } from "../../../dispatcher/actions"; import CountlyAnalytics from "../../../CountlyAnalytics"; -import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; import SendHistoryManager from '../../../SendHistoryManager'; import Modal from '../../../Modal'; +import { MsgType } from 'matrix-js-sdk/src/@types/event'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import ErrorDialog from "../dialogs/ErrorDialog"; +import QuestionDialog from "../dialogs/QuestionDialog"; +import { ActionPayload } from "../../../dispatcher/payloads"; +import AccessibleButton from '../elements/AccessibleButton'; -function _isReply(mxEvent) { +function eventIsReply(mxEvent: MatrixEvent): boolean { const relatesTo = mxEvent.getContent()["m.relates_to"]; - const isReply = !!(relatesTo && relatesTo["m.in_reply_to"]); - return isReply; + return !!(relatesTo && relatesTo["m.in_reply_to"]); } -function getHtmlReplyFallback(mxEvent) { +function getHtmlReplyFallback(mxEvent: MatrixEvent): string { const html = mxEvent.getContent().formatted_body; if (!html) { return ""; @@ -54,7 +58,7 @@ function getHtmlReplyFallback(mxEvent) { return (mxReply && mxReply.outerHTML) || ""; } -function getTextReplyFallback(mxEvent) { +function getTextReplyFallback(mxEvent: MatrixEvent): string { const body = mxEvent.getContent().body; const lines = body.split("\n").map(l => l.trim()); if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) { @@ -63,12 +67,12 @@ function getTextReplyFallback(mxEvent) { return ""; } -function createEditContent(model, editedEvent) { +function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IContent { const isEmote = containsEmote(model); if (isEmote) { model = stripEmoteCommand(model); } - const isReply = _isReply(editedEvent); + const isReply = eventIsReply(editedEvent); let plainPrefix = ""; let htmlPrefix = ""; @@ -79,16 +83,16 @@ function createEditContent(model, editedEvent) { const body = textSerialize(model); - const newContent = { - "msgtype": isEmote ? "m.emote" : "m.text", + const newContent: IContent = { + "msgtype": isEmote ? MsgType.Emote : MsgType.Text, "body": body, }; - const contentBody = { + const contentBody: IContent = { msgtype: newContent.msgtype, body: `${plainPrefix} * ${body}`, }; - const formattedBody = htmlSerializeIfNeeded(model, {forceHTML: isReply}); + const formattedBody = htmlSerializeIfNeeded(model, { forceHTML: isReply }); if (formattedBody) { newContent.format = "org.matrix.custom.html"; newContent.formatted_body = formattedBody; @@ -105,103 +109,109 @@ function createEditContent(model, editedEvent) { }, contentBody); } +interface IProps { + editState: EditorStateTransfer; + className?: string; +} + +interface IState { + saveDisabled: boolean; +} + @replaceableComponent("views.rooms.EditMessageComposer") -export default class EditMessageComposer extends React.Component { - static propTypes = { - // the message event being edited - editState: PropTypes.instanceOf(EditorStateTransfer).isRequired, - }; - +export default class EditMessageComposer extends React.Component { static contextType = MatrixClientContext; + context!: React.ContextType; - constructor(props, context) { - super(props, context); - this.model = null; - this._editorRef = null; + private readonly editorRef = createRef(); + private readonly dispatcherRef: string; + private model: EditorModel = null; + + constructor(props: IProps, context: React.ContextType) { + super(props); + this.context = context; // otherwise React will only set it prior to render due to type def above this.state = { saveDisabled: true, }; - this._createEditorModel(); - window.addEventListener("beforeunload", this._saveStoredEditorState); + + this.createEditorModel(); + window.addEventListener("beforeunload", this.saveStoredEditorState); + this.dispatcherRef = dis.register(this.onAction); } - _setEditorRef = ref => { - this._editorRef = ref; - }; - - _getRoom() { + private getRoom(): Room { return this.context.getRoom(this.props.editState.getEvent().getRoomId()); } - _onKeyDown = (event) => { + private onKeyDown = (event: KeyboardEvent): void => { // ignore any keypress while doing IME compositions - if (this._editorRef.isComposing(event)) { + if (this.editorRef.current?.isComposing(event)) { return; } const action = getKeyBindingsManager().getMessageComposerAction(event); switch (action) { case MessageComposerAction.Send: - this._sendEdit(); + this.sendEdit(); event.preventDefault(); break; case MessageComposerAction.CancelEditing: - this._cancelEdit(); + this.cancelEdit(); break; case MessageComposerAction.EditPrevMessage: { - if (this._editorRef.isModified() || !this._editorRef.isCaretAtStart()) { + if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtStart()) { return; } - const previousEvent = findEditableEvent(this._getRoom(), false, + const previousEvent = findEditableEvent(this.getRoom(), false, this.props.editState.getEvent().getId()); if (previousEvent) { - dis.dispatch({action: 'edit_event', event: previousEvent}); + dis.dispatch({ action: 'edit_event', event: previousEvent }); event.preventDefault(); } break; } case MessageComposerAction.EditNextMessage: { - if (this._editorRef.isModified() || !this._editorRef.isCaretAtEnd()) { + if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) { return; } - const nextEvent = findEditableEvent(this._getRoom(), true, this.props.editState.getEvent().getId()); + const nextEvent = findEditableEvent(this.getRoom(), true, this.props.editState.getEvent().getId()); if (nextEvent) { - dis.dispatch({action: 'edit_event', event: nextEvent}); + dis.dispatch({ action: 'edit_event', event: nextEvent }); } else { - this._clearStoredEditorState(); - dis.dispatch({action: 'edit_event', event: null}); + this.clearStoredEditorState(); + dis.dispatch({ action: 'edit_event', event: null }); dis.fire(Action.FocusComposer); } event.preventDefault(); break; } } + }; + + private get editorRoomKey(): string { + return `mx_edit_room_${this.getRoom().roomId}`; } - get _editorRoomKey() { - return `mx_edit_room_${this._getRoom().roomId}`; - } - - get _editorStateKey() { + private get editorStateKey(): string { return `mx_edit_state_${this.props.editState.getEvent().getId()}`; } - _cancelEdit = () => { - this._clearStoredEditorState(); - dis.dispatch({action: "edit_event", event: null}); + private cancelEdit = (): void => { + this.clearStoredEditorState(); + dis.dispatch({ action: "edit_event", event: null }); dis.fire(Action.FocusComposer); + }; + + private get shouldSaveStoredEditorState(): boolean { + return localStorage.getItem(this.editorRoomKey) !== null; } - get _shouldSaveStoredEditorState() { - return localStorage.getItem(this._editorRoomKey) !== null; - } - - _restoreStoredEditorState(partCreator) { - const json = localStorage.getItem(this._editorStateKey); + private restoreStoredEditorState(partCreator: PartCreator): Part[] { + const json = localStorage.getItem(this.editorStateKey); if (json) { try { - const {parts: serializedParts} = JSON.parse(json); - const parts = serializedParts.map(p => partCreator.deserializePart(p)); + const { parts: serializedParts } = JSON.parse(json); + const parts: Part[] = serializedParts.map(p => partCreator.deserializePart(p)); return parts; } catch (e) { console.error("Error parsing editing state: ", e); @@ -209,25 +219,25 @@ export default class EditMessageComposer extends React.Component { } } - _clearStoredEditorState() { - localStorage.removeItem(this._editorRoomKey); - localStorage.removeItem(this._editorStateKey); + private clearStoredEditorState(): void { + localStorage.removeItem(this.editorRoomKey); + localStorage.removeItem(this.editorStateKey); } - _clearPreviousEdit() { - if (localStorage.getItem(this._editorRoomKey)) { - localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this._editorRoomKey)}`); + private clearPreviousEdit(): void { + if (localStorage.getItem(this.editorRoomKey)) { + localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this.editorRoomKey)}`); } } - _saveStoredEditorState() { + private saveStoredEditorState(): void { const item = SendHistoryManager.createItem(this.model); - this._clearPreviousEdit(); - localStorage.setItem(this._editorRoomKey, this.props.editState.getEvent().getId()); - localStorage.setItem(this._editorStateKey, JSON.stringify(item)); + this.clearPreviousEdit(); + localStorage.setItem(this.editorRoomKey, this.props.editState.getEvent().getId()); + localStorage.setItem(this.editorStateKey, JSON.stringify(item)); } - _isSlashCommand() { + private isSlashCommand(): boolean { const parts = this.model.parts; const firstPart = parts[0]; if (firstPart) { @@ -243,10 +253,10 @@ export default class EditMessageComposer extends React.Component { return false; } - _isContentModified(newContent) { + private isContentModified(newContent: IContent): boolean { // if nothing has changed then bail const oldContent = this.props.editState.getEvent().getContent(); - if (!this._editorRef.isModified() || + if (!this.editorRef.current?.isModified() || (oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] && oldContent["format"] === newContent["format"] && oldContent["formatted_body"] === newContent["formatted_body"])) { @@ -255,7 +265,7 @@ export default class EditMessageComposer extends React.Component { return true; } - _getSlashCommand() { + private getSlashCommand(): [Command, string, string] { const commandText = this.model.parts.reduce((text, part) => { // use mxid to textify user pills in a command if (part.type === "user-pill") { @@ -263,11 +273,11 @@ export default class EditMessageComposer extends React.Component { } return text + part.text; }, ""); - const {cmd, args} = getCommand(commandText); + const { cmd, args } = getCommand(commandText); return [cmd, args, commandText]; } - async _runSlashCommand(cmd, args, roomId) { + private async runSlashCommand(cmd: Command, args: string, roomId: string): Promise { const result = cmd.run(roomId, args); let messageContent; let error = result.error; @@ -284,7 +294,6 @@ export default class EditMessageComposer extends React.Component { } if (error) { console.error("Command failure: %s", error); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); // assume the error is a server error when the command is async const isServerError = !!result.promise; const title = isServerError ? _td("Server error") : _td("Command error"); @@ -308,7 +317,7 @@ export default class EditMessageComposer extends React.Component { } } - _sendEdit = async () => { + private sendEdit = async (): Promise => { const startTime = CountlyAnalytics.getTimestamp(); const editedEvent = this.props.editState.getEvent(); const editContent = createEditContent(this.model, editedEvent); @@ -317,25 +326,24 @@ export default class EditMessageComposer extends React.Component { let shouldSend = true; // If content is modified then send an updated event into the room - if (this._isContentModified(newContent)) { + if (this.isContentModified(newContent)) { const roomId = editedEvent.getRoomId(); - if (!containsEmote(this.model) && this._isSlashCommand()) { - const [cmd, args, commandText] = this._getSlashCommand(); + if (!containsEmote(this.model) && this.isSlashCommand()) { + const [cmd, args, commandText] = this.getSlashCommand(); if (cmd) { if (cmd.category === CommandCategories.messages) { - editContent["m.new_content"] = await this._runSlashCommand(cmd, args, roomId); + editContent["m.new_content"] = await this.runSlashCommand(cmd, args, roomId); } else { - this._runSlashCommand(cmd, args, roomId); + this.runSlashCommand(cmd, args, roomId); shouldSend = false; } } else { // ask the user if their unknown command should be sent as a message - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const {finished} = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, { + const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, { title: _t("Unknown Command"), description:

      - { _t("Unrecognised command: %(commandText)s", {commandText}) } + { _t("Unrecognised command: %(commandText)s", { commandText }) }

      { _t("You can use /help to list available commands. " + @@ -357,20 +365,20 @@ export default class EditMessageComposer extends React.Component { } } if (shouldSend) { - this._cancelPreviousPendingEdit(); + this.cancelPreviousPendingEdit(); const prom = this.context.sendMessage(roomId, editContent); - this._clearStoredEditorState(); - dis.dispatch({action: "message_sent"}); + this.clearStoredEditorState(); + dis.dispatch({ action: "message_sent" }); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); } } // close the event editing and focus composer - dis.dispatch({action: "edit_event", event: null}); + dis.dispatch({ action: "edit_event", event: null }); dis.fire(Action.FocusComposer); }; - _cancelPreviousPendingEdit() { + private cancelPreviousPendingEdit(): void { const originalEvent = this.props.editState.getEvent(); const previousEdit = originalEvent.replacingEvent(); if (previousEdit && ( @@ -388,22 +396,23 @@ export default class EditMessageComposer extends React.Component { const sel = document.getSelection(); let caret; if (sel.focusNode) { - caret = getCaretOffsetAndText(this._editorRef, sel).caret; + caret = getCaretOffsetAndText(this.editorRef.current?.editorRef.current, sel).caret; } const parts = this.model.serializeParts(); // if caret is undefined because for some reason there isn't a valid selection, // then when mounting the editor again with the same editor state, // it will set the cursor at the end. this.props.editState.setEditorState(caret, parts); - window.removeEventListener("beforeunload", this._saveStoredEditorState); - if (this._shouldSaveStoredEditorState) { - this._saveStoredEditorState(); + window.removeEventListener("beforeunload", this.saveStoredEditorState); + if (this.shouldSaveStoredEditorState) { + this.saveStoredEditorState(); } + dis.unregister(this.dispatcherRef); } - _createEditorModel() { - const {editState} = this.props; - const room = this._getRoom(); + private createEditorModel(): void { + const { editState } = this.props; + const room = this.getRoom(); const partCreator = new CommandPartCreator(room, this.context); let parts; if (editState.hasEditorState()) { @@ -412,14 +421,14 @@ export default class EditMessageComposer extends React.Component { parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p)); } else { //otherwise, either restore serialized parts from localStorage or parse the body of the event - parts = this._restoreStoredEditorState(partCreator) || parseEvent(editState.getEvent(), partCreator); + parts = this.restoreStoredEditorState(partCreator) || parseEvent(editState.getEvent(), partCreator); } this.model = new EditorModel(parts, partCreator); - this._saveStoredEditorState(); + this.saveStoredEditorState(); } - _getInitialCaretPosition() { - const {editState} = this.props; + private getInitialCaretPosition(): CaretPosition { + const { editState } = this.props; let caretPosition; if (editState.hasEditorState() && editState.getCaret()) { // if restoring state from a previous editor, @@ -433,8 +442,8 @@ export default class EditMessageComposer extends React.Component { return caretPosition; } - _onChange = () => { - if (!this.state.saveDisabled || !this._editorRef || !this._editorRef.isModified()) { + private onChange = (): void => { + if (!this.state.saveDisabled || !this.editorRef.current?.isModified()) { return; } @@ -443,21 +452,34 @@ export default class EditMessageComposer extends React.Component { }); }; + private onAction = (payload: ActionPayload) => { + if (payload.action === "edit_composer_insert" && this.editorRef.current) { + if (payload.userId) { + this.editorRef.current?.insertMention(payload.userId); + } else if (payload.event) { + this.editorRef.current?.insertQuotedMessage(payload.event); + } else if (payload.text) { + this.editorRef.current?.insertPlaintext(payload.text); + } + } + }; + render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - return (

      + return (
      - {_t("Cancel")} - - {_t("Save")} + + { _t("Cancel") } + + + { _t("Save") }
      ); diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.tsx similarity index 73% rename from src/components/views/rooms/EntityTile.js rename to src/components/views/rooms/EntityTile.tsx index a92d87643c..74738c3683 100644 --- a/src/components/views/rooms/EntityTile.js +++ b/src/components/views/rooms/EntityTile.tsx @@ -17,13 +17,23 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import AccessibleButton from '../elements/AccessibleButton'; -import { _t } from '../../../languageHandler'; +import { _td } from '../../../languageHandler'; import classNames from "classnames"; import E2EIcon from './E2EIcon'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseAvatar from '../avatars/BaseAvatar'; +import PresenceLabel from "./PresenceLabel"; + +export enum PowerStatus { + Admin = "admin", + Moderator = "moderator", +} + +const PowerLabel: Record = { + [PowerStatus.Admin]: _td("Admin"), + [PowerStatus.Moderator]: _td("Mod"), +}; const PRESENCE_CLASS = { "offline": "mx_EntityTile_offline", @@ -31,14 +41,14 @@ const PRESENCE_CLASS = { "unavailable": "mx_EntityTile_unavailable", }; -function presenceClassForMember(presenceState, lastActiveAgo, showPresence) { +function presenceClassForMember(presenceState: string, lastActiveAgo: number, showPresence: boolean): string { if (showPresence === false) { return 'mx_EntityTile_online_beenactive'; } // offline is split into two categories depending on whether we have // a last_active_ago for them. - if (presenceState == 'offline') { + if (presenceState === 'offline') { if (lastActiveAgo) { return PRESENCE_CLASS['offline'] + '_beenactive'; } else { @@ -51,29 +61,32 @@ function presenceClassForMember(presenceState, lastActiveAgo, showPresence) { } } -@replaceableComponent("views.rooms.EntityTile") -class EntityTile extends React.Component { - static propTypes = { - name: PropTypes.string, - title: PropTypes.string, - avatarJsx: PropTypes.any, // - className: PropTypes.string, - presenceState: PropTypes.string, - presenceLastActiveAgo: PropTypes.number, - presenceLastTs: PropTypes.number, - presenceCurrentlyActive: PropTypes.bool, - showInviteButton: PropTypes.bool, - shouldComponentUpdate: PropTypes.func, - onClick: PropTypes.func, - suppressOnHover: PropTypes.bool, - showPresence: PropTypes.bool, - subtextLabel: PropTypes.string, - e2eStatus: PropTypes.string, - }; +interface IProps { + name?: string; + title?: string; + avatarJsx?: JSX.Element; // + className?: string; + presenceState?: string; + presenceLastActiveAgo?: number; + presenceLastTs?: number; + presenceCurrentlyActive?: boolean; + showInviteButton?: boolean; + onClick?(): void; + suppressOnHover?: boolean; + showPresence?: boolean; + subtextLabel?: string; + e2eStatus?: string; + powerStatus?: PowerStatus; +} +interface IState { + hover: boolean; +} + +@replaceableComponent("views.rooms.EntityTile") +export default class EntityTile extends React.PureComponent { static defaultProps = { - shouldComponentUpdate: function(nextProps, nextState) { return true; }, - onClick: function() {}, + onClick: () => {}, presenceState: "offline", presenceLastActiveAgo: 0, presenceLastTs: 0, @@ -82,13 +95,12 @@ class EntityTile extends React.Component { showPresence: true, }; - state = { - hover: false, - }; + constructor(props: IProps) { + super(props); - shouldComponentUpdate(nextProps, nextState) { - if (this.state.hover !== nextState.hover) return true; - return this.props.shouldComponentUpdate(nextProps, nextState); + this.state = { + hover: false, + }; } render() { @@ -104,13 +116,12 @@ class EntityTile extends React.Component { mainClassNames[presenceClass] = true; let nameEl; - const {name} = this.props; + const { name } = this.props; if (!this.props.suppressOnHover) { const activeAgo = this.props.presenceLastActiveAgo ? (Date.now() - (this.props.presenceLastTs - this.props.presenceLastActiveAgo)) : -1; - const PresenceLabel = sdk.getComponent("rooms.PresenceLabel"); let presenceLabel = null; if (this.props.showPresence) { presenceLabel = {powerText}
      ; } @@ -168,14 +176,12 @@ class EntityTile extends React.Component { e2eIcon = ; } - const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - const av = this.props.avatarJsx ||