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 99695b7a03..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,22 +17,64 @@ module.exports = { "prefer-promise-reject-errors": "off", "no-async-promise-executor": "off", "quotes": "off", - "indent": "off", - }, + "no-extra-boolean-cast": "off", + // Bind or arrow functions in props causes performance issues (but we + // currently use them in some places). + // It's disabled here, but we should using it sparingly. + "react/jsx-no-bind": "off", + "react/jsx-key": ["error"], + + "no-restricted-properties": [ + "error", + ...buildRestrictedPropertiesOptions( + ["window.innerHeight", "window.innerWidth", "window.visualViewport"], + "Use UIStore to access window dimensions instead.", + ), + ...buildRestrictedPropertiesOptions( + ["*.mxcUrlToHttp", "*.getHttpUriForMxc"], + "Use Media helper instead to centralise access for customisation.", + ), + ], + }, overrides: [{ - "files": ["src/**/*.{ts,tsx}"], - "extends": ["matrix-org/ts"], - "rules": { + files: [ + "src/**/*.{ts,tsx}", + "test/**/*.{ts,tsx}", + ], + extends: [ + "plugin:matrix-org/typescript", + "plugin:matrix-org/react", + ], + rules: { + // Things we do that break the ideal style + "prefer-promise-reject-errors": "off", + "quotes": "off", + "no-extra-boolean-cast": "off", + + // Remove Babel things manually due to override limitations + "@babel/no-invalid-this": ["off"], + // We're okay being explicit at the moment "@typescript-eslint/no-empty-interface": "off", // We disable this while we're transitioning "@typescript-eslint/no-explicit-any": "off", // We'd rather not do this but we do "@typescript-eslint/ban-ts-comment": "off", - - "quotes": "off", - "no-extra-boolean-cast": "off", }, }], }; + +function buildRestrictedPropertiesOptions(properties, message) { + return properties.map(prop => { + let [object, property] = prop.split("."); + if (object === "*") { + object = undefined; + } + return { + object, + property, + message, + }; + }); +} diff --git a/.flowconfig b/.flowconfig deleted file mode 100644 index 81770c6585..0000000000 --- a/.flowconfig +++ /dev/null @@ -1,6 +0,0 @@ -[include] -src/**/*.js -test/**/*.js - -[ignore] -node_modules/ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..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 new file mode 100644 index 0000000000..3c3807e33b --- /dev/null +++ b/.github/workflows/develop.yml @@ -0,0 +1,41 @@ +name: Develop +on: + push: + branches: [develop] + pull_request: + branches: [develop] +jobs: + end-to-end: + runs-on: ubuntu-latest + container: vectorim/element-web-ci-e2etests-env:latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Prepare End-to-End tests + run: ./scripts/ci/prepare-end-to-end-tests.sh + - name: Run End-to-End tests + run: ./scripts/ci/run-end-to-end-tests.sh + - name: Archive logs + uses: actions/upload-artifact@v2 + if: ${{ always() }} + with: + path: | + test/end-to-end-tests/logs/**/* + test/end-to-end-tests/synapse/installations/consent/homeserver.log + retention-days: 14 + - name: Download previous benchmark data + uses: actions/cache@v1 + with: + path: ./cache + key: ${{ runner.os }}-benchmark + - name: Store benchmark result + uses: matrix-org/github-action-benchmark@jsperfentry-1 + with: + tool: 'jsperformanceentry' + output-file-path: test/end-to-end-tests/performance-entries.json + fail-on-alert: false + comment-on-alert: false + # Only temporary to monitor where failures occur + alert-comment-cc-users: '@gsouquet' + github-token: ${{ secrets.DEPLOY_GH_PAGES }} + auto-push: ${{ github.ref == 'refs/heads/develop' }} diff --git a/.gitignore b/.gitignore index 50aa10fbfd..102f4b5ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ package-lock.json .DS_Store *.tmp + +.vscode +.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d459b4e94a..22b35b7c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,745 @@ +Changes in [3.25.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0) (2021-07-05) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0-rc.1...v3.25.0) + + * Remove reminescent references to the tinter + [\#6316](https://github.com/matrix-org/matrix-react-sdk/pull/6316) + * Update to released version of js-sdk + +Changes in [3.25.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0-rc.1) (2021-06-29) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0...v3.25.0-rc.1) + + * Update to js-sdk v12.0.1-rc.1 + * Translations update from Weblate + [\#6286](https://github.com/matrix-org/matrix-react-sdk/pull/6286) + * Fix back button on user info card after clicking a permalink + [\#6277](https://github.com/matrix-org/matrix-react-sdk/pull/6277) + * Group ACLs with MELS + [\#6280](https://github.com/matrix-org/matrix-react-sdk/pull/6280) + * Fix editState not getting passed through + [\#6282](https://github.com/matrix-org/matrix-react-sdk/pull/6282) + * Migrate message context menu to IconizedContextMenu + [\#5671](https://github.com/matrix-org/matrix-react-sdk/pull/5671) + * Improve audio recording performance + [\#6240](https://github.com/matrix-org/matrix-react-sdk/pull/6240) + * Fix multiple timeline panels handling composer and edit events + [\#6278](https://github.com/matrix-org/matrix-react-sdk/pull/6278) + * Let m.notice messages mark a room as unread + [\#6281](https://github.com/matrix-org/matrix-react-sdk/pull/6281) + * Removes the override on the Bubble Container + [\#5953](https://github.com/matrix-org/matrix-react-sdk/pull/5953) + * Fix IRC layout regressions + [\#6193](https://github.com/matrix-org/matrix-react-sdk/pull/6193) + * Fix trashcan.svg by exporting it with its viewbox + [\#6248](https://github.com/matrix-org/matrix-react-sdk/pull/6248) + * Fix tiny scrollbar dot on chrome/electron in Forward Dialog + [\#6276](https://github.com/matrix-org/matrix-react-sdk/pull/6276) + * Upgrade puppeteer to use newer version of Chrome + [\#6268](https://github.com/matrix-org/matrix-react-sdk/pull/6268) + * Make toast dismiss button less prominent + [\#6275](https://github.com/matrix-org/matrix-react-sdk/pull/6275) + * Encrypt the voice message file if needed + [\#6269](https://github.com/matrix-org/matrix-react-sdk/pull/6269) + * Fix hyper-precise presence + [\#6270](https://github.com/matrix-org/matrix-react-sdk/pull/6270) + * Fix issues around private spaces, including previewable + [\#6265](https://github.com/matrix-org/matrix-react-sdk/pull/6265) + * Make _pinned messages_ in `m.room.pinned_events` event clickable + [\#6257](https://github.com/matrix-org/matrix-react-sdk/pull/6257) + * Fix space avatar management layout being broken + [\#6266](https://github.com/matrix-org/matrix-react-sdk/pull/6266) + * Convert EntityTile, MemberTile and PresenceLabel to TS + [\#6251](https://github.com/matrix-org/matrix-react-sdk/pull/6251) + * Fix UserInfo not working when rendered without a room + [\#6260](https://github.com/matrix-org/matrix-react-sdk/pull/6260) + * Update membership reason handling, including leave reason displaying + [\#6253](https://github.com/matrix-org/matrix-react-sdk/pull/6253) + * Consolidate types with js-sdk changes + [\#6220](https://github.com/matrix-org/matrix-react-sdk/pull/6220) + * Fix edit history modal + [\#6258](https://github.com/matrix-org/matrix-react-sdk/pull/6258) + * Convert MemberList to TS + [\#6249](https://github.com/matrix-org/matrix-react-sdk/pull/6249) + * Fix two PRs duplicating the css attribute + [\#6259](https://github.com/matrix-org/matrix-react-sdk/pull/6259) + * Improve invite error messages in InviteDialog for room invites + [\#6201](https://github.com/matrix-org/matrix-react-sdk/pull/6201) + * Fix invite dialog being cut off when it has limited results + [\#6256](https://github.com/matrix-org/matrix-react-sdk/pull/6256) + * Fix pinning event in a room which hasn't had events pinned in before + [\#6255](https://github.com/matrix-org/matrix-react-sdk/pull/6255) + * Allow modal widget buttons to be disabled when the modal opens + [\#6178](https://github.com/matrix-org/matrix-react-sdk/pull/6178) + * Decrease e2e shield fill mask size so that it doesn't overlap + [\#6250](https://github.com/matrix-org/matrix-react-sdk/pull/6250) + * Dial Pad UI bug fixes + [\#5786](https://github.com/matrix-org/matrix-react-sdk/pull/5786) + * Simple handling of mid-call output changes + [\#6247](https://github.com/matrix-org/matrix-react-sdk/pull/6247) + * Improve ForwardDialog performance by using TruncatedList + [\#6228](https://github.com/matrix-org/matrix-react-sdk/pull/6228) + * Fix dependency and lockfile mismatch + [\#6246](https://github.com/matrix-org/matrix-react-sdk/pull/6246) + * Improve room directory click behaviour + [\#6234](https://github.com/matrix-org/matrix-react-sdk/pull/6234) + * Fix keyboard accessibility of the space panel + [\#6239](https://github.com/matrix-org/matrix-react-sdk/pull/6239) + * Add ways to manage addresses for Spaces + [\#6151](https://github.com/matrix-org/matrix-react-sdk/pull/6151) + * Hide communities invites and the community autocompleter when Spaces on + [\#6244](https://github.com/matrix-org/matrix-react-sdk/pull/6244) + * Convert bunch of files to TS + [\#6241](https://github.com/matrix-org/matrix-react-sdk/pull/6241) + * Open local addresses section by default when there are no existing local + addresses + [\#6179](https://github.com/matrix-org/matrix-react-sdk/pull/6179) + * Allow reordering of the space panel via Drag and Drop + [\#6137](https://github.com/matrix-org/matrix-react-sdk/pull/6137) + * Replace drag and drop mechanism in communities with something simpler + [\#6134](https://github.com/matrix-org/matrix-react-sdk/pull/6134) + * EventTilePreview fixes + [\#6000](https://github.com/matrix-org/matrix-react-sdk/pull/6000) + * Upgrade @types/react and @types/react-dom + [\#6233](https://github.com/matrix-org/matrix-react-sdk/pull/6233) + * Fix type error in the SpaceStore + [\#6242](https://github.com/matrix-org/matrix-react-sdk/pull/6242) + * Add experimental options to the Spaces beta + [\#6199](https://github.com/matrix-org/matrix-react-sdk/pull/6199) + * Consolidate types with js-sdk changes + [\#6215](https://github.com/matrix-org/matrix-react-sdk/pull/6215) + * Fix branch matching for Buildkite + [\#6236](https://github.com/matrix-org/matrix-react-sdk/pull/6236) + * Migrate SearchBar to TypeScript + [\#6230](https://github.com/matrix-org/matrix-react-sdk/pull/6230) + * Add support to keyboard shortcuts dialog for [digits] + [\#6088](https://github.com/matrix-org/matrix-react-sdk/pull/6088) + * Fix modal opening race condition + [\#6238](https://github.com/matrix-org/matrix-react-sdk/pull/6238) + * Deprecate FormButton in favour of AccessibleButton + [\#6229](https://github.com/matrix-org/matrix-react-sdk/pull/6229) + * Add PR template + [\#6216](https://github.com/matrix-org/matrix-react-sdk/pull/6216) + * Prefer canonical aliases while autocompleting rooms + [\#6222](https://github.com/matrix-org/matrix-react-sdk/pull/6222) + * Fix quote button + [\#6232](https://github.com/matrix-org/matrix-react-sdk/pull/6232) + * Restore branch matching support for GitHub Actions e2e tests + [\#6224](https://github.com/matrix-org/matrix-react-sdk/pull/6224) + * Fix View Source accessing renamed private field on MatrixEvent + [\#6225](https://github.com/matrix-org/matrix-react-sdk/pull/6225) + * Fix ConfirmUserActionDialog returning an input field rather than text + [\#6219](https://github.com/matrix-org/matrix-react-sdk/pull/6219) + * Revert "Partially restore immutable event objects at the rendering layer" + [\#6221](https://github.com/matrix-org/matrix-react-sdk/pull/6221) + * Add jq to e2e tests Dockerfile + [\#6218](https://github.com/matrix-org/matrix-react-sdk/pull/6218) + * Partially restore immutable event objects at the rendering layer + [\#6196](https://github.com/matrix-org/matrix-react-sdk/pull/6196) + * Update MSC number references for voice messages + [\#6197](https://github.com/matrix-org/matrix-react-sdk/pull/6197) + * Fix phase enum usage in JS modules as well + [\#6214](https://github.com/matrix-org/matrix-react-sdk/pull/6214) + * Migrate some dialogs to TypeScript + [\#6185](https://github.com/matrix-org/matrix-react-sdk/pull/6185) + * Typescript fixes due to MatrixEvent being TSified + [\#6208](https://github.com/matrix-org/matrix-react-sdk/pull/6208) + * Allow click-to-ping, quote & emoji picker for edit composer too + [\#5858](https://github.com/matrix-org/matrix-react-sdk/pull/5858) + * Add call silencing + [\#6082](https://github.com/matrix-org/matrix-react-sdk/pull/6082) + * Fix types in SlashCommands + [\#6207](https://github.com/matrix-org/matrix-react-sdk/pull/6207) + * Benchmark multiple common user scenario + [\#6190](https://github.com/matrix-org/matrix-react-sdk/pull/6190) + * Fix forward dialog message preview display names + [\#6204](https://github.com/matrix-org/matrix-react-sdk/pull/6204) + * Remove stray bullet point in reply preview + [\#6206](https://github.com/matrix-org/matrix-react-sdk/pull/6206) + * Stop requesting null next replies from the server + [\#6203](https://github.com/matrix-org/matrix-react-sdk/pull/6203) + * Fix soft crash caused by a broken shouldComponentUpdate + [\#6202](https://github.com/matrix-org/matrix-react-sdk/pull/6202) + * Keep composer reply when scrolling away from a highlighted event + [\#6200](https://github.com/matrix-org/matrix-react-sdk/pull/6200) + * Cache virtual/native room mappings when they're created + [\#6194](https://github.com/matrix-org/matrix-react-sdk/pull/6194) + * Disable comment-on-alert + [\#6191](https://github.com/matrix-org/matrix-react-sdk/pull/6191) + * Bump postcss from 7.0.35 to 7.0.36 + [\#6195](https://github.com/matrix-org/matrix-react-sdk/pull/6195) + +Changes in [3.24.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.24.0) (2021-06-21) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0-rc.1...v3.24.0) + + * Upgrade to JS SDK 12.0.0 + * [Release] Keep composer reply when scrolling away from a highlighted event + [\#6211](https://github.com/matrix-org/matrix-react-sdk/pull/6211) + * [Release] Remove stray bullet point in reply preview + [\#6210](https://github.com/matrix-org/matrix-react-sdk/pull/6210) + * [Release] Stop requesting null next replies from the server + [\#6209](https://github.com/matrix-org/matrix-react-sdk/pull/6209) + +Changes in [3.24.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.24.0-rc.1) (2021-06-15) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.23.0...v3.24.0-rc.1) + + * Upgrade to JS SDK 12.0.0-rc.1 + * Translations update from Weblate + [\#6192](https://github.com/matrix-org/matrix-react-sdk/pull/6192) + * Disable comment-on-alert for PR coming from a fork + [\#6189](https://github.com/matrix-org/matrix-react-sdk/pull/6189) + * Add JS benchmark tracking in CI + [\#6177](https://github.com/matrix-org/matrix-react-sdk/pull/6177) + * Upgrade matrix-react-test-utils for React 17 peer deps + [\#6187](https://github.com/matrix-org/matrix-react-sdk/pull/6187) + * Fix display name overlaps on the IRC layout + [\#6186](https://github.com/matrix-org/matrix-react-sdk/pull/6186) + * Small fixes to the spaces experience + [\#6184](https://github.com/matrix-org/matrix-react-sdk/pull/6184) + * Add footer and privacy note to the start dm dialog + [\#6111](https://github.com/matrix-org/matrix-react-sdk/pull/6111) + * Format mxids when disambiguation needed + [\#5880](https://github.com/matrix-org/matrix-react-sdk/pull/5880) + * Move various createRoom types to the js-sdk + [\#6183](https://github.com/matrix-org/matrix-react-sdk/pull/6183) + * Fix HTML tag for Event Tile when not rendered in a list + [\#6175](https://github.com/matrix-org/matrix-react-sdk/pull/6175) + * Remove legacy polyfills and unused dependencies + [\#6176](https://github.com/matrix-org/matrix-react-sdk/pull/6176) + * Fix buggy hovering/selecting of event tiles + [\#6173](https://github.com/matrix-org/matrix-react-sdk/pull/6173) + * Add room intro warning when e2ee is not enabled + [\#5929](https://github.com/matrix-org/matrix-react-sdk/pull/5929) + * Migrate end to end tests to GitHub actions + [\#6156](https://github.com/matrix-org/matrix-react-sdk/pull/6156) + * Fix expanding last collapsed sticky session when zoomed in + [\#6171](https://github.com/matrix-org/matrix-react-sdk/pull/6171) + * ⚛️ Upgrade to React@17 + [\#6165](https://github.com/matrix-org/matrix-react-sdk/pull/6165) + * Revert refreshStickyHeaders optimisations + [\#6168](https://github.com/matrix-org/matrix-react-sdk/pull/6168) + * Add logging for which rooms calls are in + [\#6170](https://github.com/matrix-org/matrix-react-sdk/pull/6170) + * Restore read receipt animation from event to event + [\#6169](https://github.com/matrix-org/matrix-react-sdk/pull/6169) + * Restore copy button icon when sharing permalink + [\#6166](https://github.com/matrix-org/matrix-react-sdk/pull/6166) + * Restore Page Up/Down key bindings when focusing the composer + [\#6167](https://github.com/matrix-org/matrix-react-sdk/pull/6167) + * Timeline rendering optimizations + [\#6143](https://github.com/matrix-org/matrix-react-sdk/pull/6143) + * Bump css-what from 5.0.0 to 5.0.1 + [\#6164](https://github.com/matrix-org/matrix-react-sdk/pull/6164) + * Bump ws from 6.2.1 to 6.2.2 in /test/end-to-end-tests + [\#6145](https://github.com/matrix-org/matrix-react-sdk/pull/6145) + * Bump trim-newlines from 3.0.0 to 3.0.1 + [\#6163](https://github.com/matrix-org/matrix-react-sdk/pull/6163) + * Fix upgrade to element home button in top left menu + [\#6162](https://github.com/matrix-org/matrix-react-sdk/pull/6162) + * Fix unpinning of pinned messages and panel empty state + [\#6140](https://github.com/matrix-org/matrix-react-sdk/pull/6140) + * Better handling for widgets that fail to load + [\#6161](https://github.com/matrix-org/matrix-react-sdk/pull/6161) + * Improved forwarding UI + [\#5999](https://github.com/matrix-org/matrix-react-sdk/pull/5999) + * Fixes for sharing room links + [\#6118](https://github.com/matrix-org/matrix-react-sdk/pull/6118) + * Fix setting watchers + [\#6160](https://github.com/matrix-org/matrix-react-sdk/pull/6160) + * Fix Stickerpicker context menu + [\#6152](https://github.com/matrix-org/matrix-react-sdk/pull/6152) + * Add warning to private space creation flow + [\#6155](https://github.com/matrix-org/matrix-react-sdk/pull/6155) + * Add prop to alwaysShowTimestamps on TimelinePanel + [\#6159](https://github.com/matrix-org/matrix-react-sdk/pull/6159) + * Fix notif panel timestamp padding + [\#6157](https://github.com/matrix-org/matrix-react-sdk/pull/6157) + * Fixes and refactoring for the ImageView + [\#6149](https://github.com/matrix-org/matrix-react-sdk/pull/6149) + * Fix timestamps + [\#6148](https://github.com/matrix-org/matrix-react-sdk/pull/6148) + * Make it easier to pan images in the lightbox + [\#6147](https://github.com/matrix-org/matrix-react-sdk/pull/6147) + * Fix scroll token for EventTile and EventListSummary node type + [\#6154](https://github.com/matrix-org/matrix-react-sdk/pull/6154) + * Convert bunch of things to Typescript + [\#6153](https://github.com/matrix-org/matrix-react-sdk/pull/6153) + * Lint the typescript tests + [\#6142](https://github.com/matrix-org/matrix-react-sdk/pull/6142) + * Fix jumping to bottom without a highlighted event + [\#6146](https://github.com/matrix-org/matrix-react-sdk/pull/6146) + * Repair event status position in timeline + [\#6141](https://github.com/matrix-org/matrix-react-sdk/pull/6141) + * Adapt for js-sdk MatrixClient conversion to TS + [\#6132](https://github.com/matrix-org/matrix-react-sdk/pull/6132) + * Improve pinned messages in Labs + [\#6096](https://github.com/matrix-org/matrix-react-sdk/pull/6096) + * Map phone number lookup results to their native rooms + [\#6136](https://github.com/matrix-org/matrix-react-sdk/pull/6136) + * Fix mx_Event containment rules and empty read avatar row + [\#6138](https://github.com/matrix-org/matrix-react-sdk/pull/6138) + * Improve switch room rendering + [\#6079](https://github.com/matrix-org/matrix-react-sdk/pull/6079) + * Add CSS containment rules for shorter reflow operations + [\#6127](https://github.com/matrix-org/matrix-react-sdk/pull/6127) + * ignore hash/fragment when de-duplicating links for url previews + [\#6135](https://github.com/matrix-org/matrix-react-sdk/pull/6135) + * Clicking jump to bottom resets room hash + [\#5823](https://github.com/matrix-org/matrix-react-sdk/pull/5823) + * Use passive option for scroll handlers + [\#6113](https://github.com/matrix-org/matrix-react-sdk/pull/6113) + * Optimise memberSort performance for large list + [\#6130](https://github.com/matrix-org/matrix-react-sdk/pull/6130) + * Tweak event border radius to match action bar + [\#6133](https://github.com/matrix-org/matrix-react-sdk/pull/6133) + * Log when we ignore a second call in a room + [\#6131](https://github.com/matrix-org/matrix-react-sdk/pull/6131) + * Performance monitoring measurements + [\#6041](https://github.com/matrix-org/matrix-react-sdk/pull/6041) + +Changes in [3.23.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.23.0) (2021-06-07) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.23.0-rc.1...v3.23.0) + + * Upgrade to JS SDK 11.2.0 + * [Release] Fix notif panel timestamp padding + [\#6158](https://github.com/matrix-org/matrix-react-sdk/pull/6158) + +Changes in [3.23.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.23.0-rc.1) (2021-06-01) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.22.0...v3.23.0-rc.1) + + * Upgrade to JS SDK 11.2.0-rc.1 + * Translations update from Weblate + [\#6128](https://github.com/matrix-org/matrix-react-sdk/pull/6128) + * Fix all DMs wrongly appearing in room list when `m.direct` is changed + [\#6122](https://github.com/matrix-org/matrix-react-sdk/pull/6122) + * Update way of checking for registration disabled + [\#6123](https://github.com/matrix-org/matrix-react-sdk/pull/6123) + * Fix the ability to remove avatar from a space via settings + [\#6126](https://github.com/matrix-org/matrix-react-sdk/pull/6126) + * Switch to stable endpoint/fields for MSC2858 + [\#6125](https://github.com/matrix-org/matrix-react-sdk/pull/6125) + * Clear stored editor state when canceling editing using a shortcut + [\#6117](https://github.com/matrix-org/matrix-react-sdk/pull/6117) + * Respect newlines in space topics + [\#6124](https://github.com/matrix-org/matrix-react-sdk/pull/6124) + * Add url param `defaultUsername` to prefill the login username field + [\#5674](https://github.com/matrix-org/matrix-react-sdk/pull/5674) + * Bump ws from 7.4.2 to 7.4.6 + [\#6115](https://github.com/matrix-org/matrix-react-sdk/pull/6115) + * Sticky headers repositioning without layout trashing + [\#6110](https://github.com/matrix-org/matrix-react-sdk/pull/6110) + * Handle user_busy in voip calls + [\#6112](https://github.com/matrix-org/matrix-react-sdk/pull/6112) + * Avoid showing warning modals from the invite dialog after it unmounts + [\#6105](https://github.com/matrix-org/matrix-react-sdk/pull/6105) + * Fix misleading child counts in spaces + [\#6109](https://github.com/matrix-org/matrix-react-sdk/pull/6109) + * Close creation menu when expanding space panel via expand hierarchy + [\#6090](https://github.com/matrix-org/matrix-react-sdk/pull/6090) + * Prevent having duplicates in pending room state + [\#6108](https://github.com/matrix-org/matrix-react-sdk/pull/6108) + * Update reactions row on event decryption + [\#6106](https://github.com/matrix-org/matrix-react-sdk/pull/6106) + * Destroy playback instance on voice message unmount + [\#6101](https://github.com/matrix-org/matrix-react-sdk/pull/6101) + * Fix message preview not up to date + [\#6102](https://github.com/matrix-org/matrix-react-sdk/pull/6102) + * Convert some Flow typed files to TS (round 2) + [\#6076](https://github.com/matrix-org/matrix-react-sdk/pull/6076) + * Remove unused middlePanelResized event listener + [\#6086](https://github.com/matrix-org/matrix-react-sdk/pull/6086) + * Fix accessing currentState on an invalid joinedRoom + [\#6100](https://github.com/matrix-org/matrix-react-sdk/pull/6100) + * Remove Promise allSettled polyfill as js-sdk uses it directly + [\#6097](https://github.com/matrix-org/matrix-react-sdk/pull/6097) + * Prevent DecoratedRoomAvatar to update its state for the same value + [\#6099](https://github.com/matrix-org/matrix-react-sdk/pull/6099) + * Skip generatePreview if event is not part of the live timeline + [\#6098](https://github.com/matrix-org/matrix-react-sdk/pull/6098) + * fix sticky headers when results num get displayed + [\#6095](https://github.com/matrix-org/matrix-react-sdk/pull/6095) + * Improve addEventsToTimeline performance scoping WhoIsTypingTile::setState + [\#6094](https://github.com/matrix-org/matrix-react-sdk/pull/6094) + * Safeguards to prevent layout trashing for window dimensions + [\#6092](https://github.com/matrix-org/matrix-react-sdk/pull/6092) + * Use local room state to render space hierarchy if the room is known + [\#6089](https://github.com/matrix-org/matrix-react-sdk/pull/6089) + * Add spinner in UserMenu to list pending long running actions + [\#6085](https://github.com/matrix-org/matrix-react-sdk/pull/6085) + * Stop overscroll in Firefox Nightly for macOS + [\#6093](https://github.com/matrix-org/matrix-react-sdk/pull/6093) + * Move SettingsStore watchers/monitors over to ES6 maps for performance + [\#6063](https://github.com/matrix-org/matrix-react-sdk/pull/6063) + * Bump libolm version. + [\#6080](https://github.com/matrix-org/matrix-react-sdk/pull/6080) + * Improve styling of the message action bar + [\#6066](https://github.com/matrix-org/matrix-react-sdk/pull/6066) + * Improve explore rooms when no results are found + [\#6070](https://github.com/matrix-org/matrix-react-sdk/pull/6070) + * Remove logo spinner + [\#6078](https://github.com/matrix-org/matrix-react-sdk/pull/6078) + * Fix add reaction prompt showing even when user is not joined to room + [\#6073](https://github.com/matrix-org/matrix-react-sdk/pull/6073) + * Vectorize spinners + [\#5680](https://github.com/matrix-org/matrix-react-sdk/pull/5680) + * Fix handling of via servers for suggested rooms + [\#6077](https://github.com/matrix-org/matrix-react-sdk/pull/6077) + * Upgrade showChatEffects to room-level setting exposure + [\#6075](https://github.com/matrix-org/matrix-react-sdk/pull/6075) + * Delete RoomView dead code + [\#6071](https://github.com/matrix-org/matrix-react-sdk/pull/6071) + * Reduce noise in tests + [\#6074](https://github.com/matrix-org/matrix-react-sdk/pull/6074) + * Fix room name issues in right panel summary card + [\#6069](https://github.com/matrix-org/matrix-react-sdk/pull/6069) + * Cache normalized room name + [\#6072](https://github.com/matrix-org/matrix-react-sdk/pull/6072) + * Update MemberList to reflect changes for invite permission change + [\#6061](https://github.com/matrix-org/matrix-react-sdk/pull/6061) + * Delete RoomView dead code + [\#6065](https://github.com/matrix-org/matrix-react-sdk/pull/6065) + * Show subspace rooms count even if it is 0 for consistency + [\#6067](https://github.com/matrix-org/matrix-react-sdk/pull/6067) + +Changes in [3.22.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.22.0) (2021-05-24) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.22.0-rc.1...v3.22.0) + + * Upgrade to JS SDK 11.1.0 + * [Release] Bump libolm version + [\#6087](https://github.com/matrix-org/matrix-react-sdk/pull/6087) + +Changes in [3.22.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.22.0-rc.1) (2021-05-19) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.21.0...v3.22.0-rc.1) + + * Upgrade to JS SDK 11.1.0-rc.1 + * Translations update from Weblate + [\#6068](https://github.com/matrix-org/matrix-react-sdk/pull/6068) + * Show DMs in space for invited members too, to match Android impl + [\#6062](https://github.com/matrix-org/matrix-react-sdk/pull/6062) + * Support filtering by alias in add existing to space dialog + [\#6057](https://github.com/matrix-org/matrix-react-sdk/pull/6057) + * Fix issue when a room without a name or alias is marked as suggested + [\#6064](https://github.com/matrix-org/matrix-react-sdk/pull/6064) + * Fix space room hierarchy not updating when removing a room + [\#6055](https://github.com/matrix-org/matrix-react-sdk/pull/6055) + * Revert "Try putting room list handling behind a lock" + [\#6060](https://github.com/matrix-org/matrix-react-sdk/pull/6060) + * Stop assuming encrypted messages are decrypted ahead of time + [\#6052](https://github.com/matrix-org/matrix-react-sdk/pull/6052) + * Add error detail when languges fail to load + [\#6059](https://github.com/matrix-org/matrix-react-sdk/pull/6059) + * Add space invaders chat effect + [\#6053](https://github.com/matrix-org/matrix-react-sdk/pull/6053) + * Create SpaceProvider and hide Spaces from the RoomProvider autocompleter + [\#6051](https://github.com/matrix-org/matrix-react-sdk/pull/6051) + * Don't mark a room as unread when redacted event is present + [\#6049](https://github.com/matrix-org/matrix-react-sdk/pull/6049) + * Add support for MSC2873: Client information for Widgets + [\#6023](https://github.com/matrix-org/matrix-react-sdk/pull/6023) + * Support UI for MSC2762: Widgets reading events from rooms + [\#5960](https://github.com/matrix-org/matrix-react-sdk/pull/5960) + * Fix crash on opening notification panel + [\#6047](https://github.com/matrix-org/matrix-react-sdk/pull/6047) + * Remove custom LoggedInView::shouldComponentUpdate logic + [\#6046](https://github.com/matrix-org/matrix-react-sdk/pull/6046) + * Fix edge cases with the new add reactions prompt button + [\#6045](https://github.com/matrix-org/matrix-react-sdk/pull/6045) + * Add ids to homeserver and passphrase fields + [\#6043](https://github.com/matrix-org/matrix-react-sdk/pull/6043) + * Update space order field validity requirements to match msc update + [\#6042](https://github.com/matrix-org/matrix-react-sdk/pull/6042) + * Try putting room list handling behind a lock + [\#6024](https://github.com/matrix-org/matrix-react-sdk/pull/6024) + * Improve progress bar progression for smaller voice messages + [\#6035](https://github.com/matrix-org/matrix-react-sdk/pull/6035) + * Fix share space edge case where space is public but not invitable + [\#6039](https://github.com/matrix-org/matrix-react-sdk/pull/6039) + * Add missing 'rel' to image view download button + [\#6033](https://github.com/matrix-org/matrix-react-sdk/pull/6033) + * Improve visible waveform for voice messages + [\#6034](https://github.com/matrix-org/matrix-react-sdk/pull/6034) + * Fix roving tab index intercepting home/end in space create menu + [\#6040](https://github.com/matrix-org/matrix-react-sdk/pull/6040) + * Decorate room avatars with publicity in add existing to space flow + [\#6030](https://github.com/matrix-org/matrix-react-sdk/pull/6030) + * Improve Spaces "Just Me" wizard + [\#6025](https://github.com/matrix-org/matrix-react-sdk/pull/6025) + * Increase hover feedback on room sub list buttons + [\#6037](https://github.com/matrix-org/matrix-react-sdk/pull/6037) + * Show alternative button during space creation wizard if no rooms + [\#6029](https://github.com/matrix-org/matrix-react-sdk/pull/6029) + * Swap rotation buttons in the image viewer + [\#6032](https://github.com/matrix-org/matrix-react-sdk/pull/6032) + * Typo: initilisation -> initialisation + [\#5915](https://github.com/matrix-org/matrix-react-sdk/pull/5915) + * Save edited state of a message when switching rooms + [\#6001](https://github.com/matrix-org/matrix-react-sdk/pull/6001) + * Fix shield icon in Untrusted Device Dialog + [\#6022](https://github.com/matrix-org/matrix-react-sdk/pull/6022) + * Do not eagerly decrypt breadcrumb rooms + [\#6028](https://github.com/matrix-org/matrix-react-sdk/pull/6028) + * Update spaces.png + [\#6031](https://github.com/matrix-org/matrix-react-sdk/pull/6031) + * Encourage more diverse reactions to content + [\#6027](https://github.com/matrix-org/matrix-react-sdk/pull/6027) + * Wrap decodeURIComponent in try-catch to protect against malformed URIs + [\#6026](https://github.com/matrix-org/matrix-react-sdk/pull/6026) + * Iterate beta feedback dialog + [\#6021](https://github.com/matrix-org/matrix-react-sdk/pull/6021) + * Disable space fields whilst their form is busy + [\#6020](https://github.com/matrix-org/matrix-react-sdk/pull/6020) + * Add missing space on beta feedback dialog + [\#6018](https://github.com/matrix-org/matrix-react-sdk/pull/6018) + * Fix colours used for the back button in space create menu + [\#6017](https://github.com/matrix-org/matrix-react-sdk/pull/6017) + * Prioritise and reduce the amount of events decrypted on application startup + [\#5980](https://github.com/matrix-org/matrix-react-sdk/pull/5980) + * Linkify topics in space room directory results + [\#6015](https://github.com/matrix-org/matrix-react-sdk/pull/6015) + * Persistent space collapsed states + [\#5972](https://github.com/matrix-org/matrix-react-sdk/pull/5972) + * Catch another instance of unlabeled avatars. + [\#6010](https://github.com/matrix-org/matrix-react-sdk/pull/6010) + * Rescale and smooth voice message playback waveform to better match + expectation + [\#5996](https://github.com/matrix-org/matrix-react-sdk/pull/5996) + * Scale voice message clock with user's font size + [\#5993](https://github.com/matrix-org/matrix-react-sdk/pull/5993) + * Remove "in development" flag from voice messages + [\#5995](https://github.com/matrix-org/matrix-react-sdk/pull/5995) + * Support voice messages on Safari + [\#5989](https://github.com/matrix-org/matrix-react-sdk/pull/5989) + * Translations update from Weblate + [\#6011](https://github.com/matrix-org/matrix-react-sdk/pull/6011) + +Changes in [3.21.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.21.0) (2021-05-17) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.21.0-rc.1...v3.21.0) + +## Security notice + +matrix-react-sdk 3.21.0 fixes a low severity issue (GHSA-8796-gc9j-63rv) +related to file upload. When uploading a file, the local file preview can lead +to execution of scripts embedded in the uploaded file, but only after several +user interactions to open the preview in a separate tab. This only impacts the +local user while in the process of uploading. It cannot be exploited remotely +or by other users. Thanks to [Muhammad Zaid Ghifari](https://github.com/MR-ZHEEV) +for responsibly disclosing this via Matrix's Security Disclosure Policy. + +## All changes + + * Upgrade to JS SDK 11.0.0 + * [Release] Add missing space on beta feedback dialog + [\#6019](https://github.com/matrix-org/matrix-react-sdk/pull/6019) + * [Release] Add feedback mechanism for beta features, namely Spaces + [\#6013](https://github.com/matrix-org/matrix-react-sdk/pull/6013) + * Add feedback mechanism for beta features, namely Spaces + [\#6012](https://github.com/matrix-org/matrix-react-sdk/pull/6012) + +Changes in [3.21.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.21.0-rc.1) (2021-05-11) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.20.0...v3.21.0-rc.1) + + * Upgrade to JS SDK 11.0.0-rc.1 + * Add disclaimer about subspaces being experimental in add existing dialog + [\#5978](https://github.com/matrix-org/matrix-react-sdk/pull/5978) + * Spaces Beta release + [\#5933](https://github.com/matrix-org/matrix-react-sdk/pull/5933) + * Improve permissions error when adding new server to room directory + [\#6009](https://github.com/matrix-org/matrix-react-sdk/pull/6009) + * Allow user to progress through space creation & setup using Enter + [\#6006](https://github.com/matrix-org/matrix-react-sdk/pull/6006) + * Upgrade sanitize types + [\#6008](https://github.com/matrix-org/matrix-react-sdk/pull/6008) + * Upgrade `cheerio` and resolve type errors + [\#6007](https://github.com/matrix-org/matrix-react-sdk/pull/6007) + * Add slash commands support to edit message composer + [\#5865](https://github.com/matrix-org/matrix-react-sdk/pull/5865) + * Fix the two todays problem + [\#5940](https://github.com/matrix-org/matrix-react-sdk/pull/5940) + * Switch the Home Space out for an All rooms space + [\#5969](https://github.com/matrix-org/matrix-react-sdk/pull/5969) + * Show device ID in UserInfo when there is no device name + [\#5985](https://github.com/matrix-org/matrix-react-sdk/pull/5985) + * Switch back to release version of `sanitize-html` + [\#6005](https://github.com/matrix-org/matrix-react-sdk/pull/6005) + * Bump hosted-git-info from 2.8.8 to 2.8.9 + [\#5998](https://github.com/matrix-org/matrix-react-sdk/pull/5998) + * Don't use the event's metadata to calc the scale of an image + [\#5982](https://github.com/matrix-org/matrix-react-sdk/pull/5982) + * Adjust MIME type of upload confirmation if needed + [\#5981](https://github.com/matrix-org/matrix-react-sdk/pull/5981) + * Forbid redaction of encryption events + [\#5991](https://github.com/matrix-org/matrix-react-sdk/pull/5991) + * Fix voice message playback being squished up against send button + [\#5988](https://github.com/matrix-org/matrix-react-sdk/pull/5988) + * Improve style of notification badges on the space panel + [\#5983](https://github.com/matrix-org/matrix-react-sdk/pull/5983) + * Add dev dependency for parse5 typings + [\#5990](https://github.com/matrix-org/matrix-react-sdk/pull/5990) + * Iterate Spaces admin UX around room management + [\#5977](https://github.com/matrix-org/matrix-react-sdk/pull/5977) + * Guard all isSpaceRoom calls behind the labs flag + [\#5979](https://github.com/matrix-org/matrix-react-sdk/pull/5979) + * Bump lodash from 4.17.20 to 4.17.21 + [\#5986](https://github.com/matrix-org/matrix-react-sdk/pull/5986) + * Bump lodash from 4.17.19 to 4.17.21 in /test/end-to-end-tests + [\#5987](https://github.com/matrix-org/matrix-react-sdk/pull/5987) + * Bump ua-parser-js from 0.7.23 to 0.7.28 + [\#5984](https://github.com/matrix-org/matrix-react-sdk/pull/5984) + * Update visual style of plain files in the timeline + [\#5971](https://github.com/matrix-org/matrix-react-sdk/pull/5971) + * Support for multiple streams (not MSC3077) + [\#5833](https://github.com/matrix-org/matrix-react-sdk/pull/5833) + * Update space ordering behaviour to match updates in MSC + [\#5963](https://github.com/matrix-org/matrix-react-sdk/pull/5963) + * Improve performance of search all spaces and space switching + [\#5976](https://github.com/matrix-org/matrix-react-sdk/pull/5976) + * Update colours and sizing for voice messages + [\#5970](https://github.com/matrix-org/matrix-react-sdk/pull/5970) + * Update link to Android SDK + [\#5973](https://github.com/matrix-org/matrix-react-sdk/pull/5973) + * Add cleanup functions for image view + [\#5962](https://github.com/matrix-org/matrix-react-sdk/pull/5962) + * Add a note about sharing your IP in P2P calls + [\#5961](https://github.com/matrix-org/matrix-react-sdk/pull/5961) + * Only aggregate DM notifications on the Space Panel in the Home Space + [\#5968](https://github.com/matrix-org/matrix-react-sdk/pull/5968) + * Add retry mechanism and progress bar to add existing to space dialog + [\#5975](https://github.com/matrix-org/matrix-react-sdk/pull/5975) + * Warn on access token reveal + [\#5755](https://github.com/matrix-org/matrix-react-sdk/pull/5755) + * Fix newly joined room appearing under the wrong space + [\#5945](https://github.com/matrix-org/matrix-react-sdk/pull/5945) + * Early rendering for voice messages in the timeline + [\#5955](https://github.com/matrix-org/matrix-react-sdk/pull/5955) + * Calculate the real waveform in the Playback class for voice messages + [\#5956](https://github.com/matrix-org/matrix-react-sdk/pull/5956) + * Don't recurse on arrayFastResample + [\#5957](https://github.com/matrix-org/matrix-react-sdk/pull/5957) + * Support a dark theme for voice messages + [\#5958](https://github.com/matrix-org/matrix-react-sdk/pull/5958) + * Handle no/blocked microphones in voice messages + [\#5959](https://github.com/matrix-org/matrix-react-sdk/pull/5959) + +Changes in [3.20.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0) (2021-05-10) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.20.0-rc.1...v3.20.0) + + * Upgrade to JS SDK 10.1.0 + * [Release] Don't use the event's metadata to calc the scale of an image + [\#6004](https://github.com/matrix-org/matrix-react-sdk/pull/6004) + +Changes in [3.20.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0-rc.1) (2021-05-04) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0...v3.20.0-rc.1) + + * Upgrade to JS SDK 10.1.0-rc.1 + * Translations update from Weblate + [\#5966](https://github.com/matrix-org/matrix-react-sdk/pull/5966) + * Fix more space panel layout and hover behaviour issues + [\#5965](https://github.com/matrix-org/matrix-react-sdk/pull/5965) + * Fix edge case with space panel alignment with subspaces on ff + [\#5964](https://github.com/matrix-org/matrix-react-sdk/pull/5964) + * Fix saving room pill part to history + [\#5951](https://github.com/matrix-org/matrix-react-sdk/pull/5951) + * Generate room preview even when minimized + [\#5948](https://github.com/matrix-org/matrix-react-sdk/pull/5948) + * Another change from recovery passphrase to Security Phrase + [\#5934](https://github.com/matrix-org/matrix-react-sdk/pull/5934) + * Sort rooms in the add existing to space dialog based on recency + [\#5943](https://github.com/matrix-org/matrix-react-sdk/pull/5943) + * Inhibit sending RR when context switching to a room + [\#5944](https://github.com/matrix-org/matrix-react-sdk/pull/5944) + * Prevent room list keyboard handling from landing focus on hidden nodes + [\#5950](https://github.com/matrix-org/matrix-react-sdk/pull/5950) + * Make the text filter search all spaces instead of just the selected one + [\#5942](https://github.com/matrix-org/matrix-react-sdk/pull/5942) + * Enable indent rule and fix indent + [\#5931](https://github.com/matrix-org/matrix-react-sdk/pull/5931) + * Prevent peeking members from reacting + [\#5946](https://github.com/matrix-org/matrix-react-sdk/pull/5946) + * Disallow inline display maths + [\#5939](https://github.com/matrix-org/matrix-react-sdk/pull/5939) + * Space creation prompt user to add existing rooms for "Just Me" spaces + [\#5923](https://github.com/matrix-org/matrix-react-sdk/pull/5923) + * Add test coverage collection script + [\#5937](https://github.com/matrix-org/matrix-react-sdk/pull/5937) + * Fix joining room using via servers regression + [\#5936](https://github.com/matrix-org/matrix-react-sdk/pull/5936) + * Revert "Fixes the two Todays problem in Redaction" + [\#5938](https://github.com/matrix-org/matrix-react-sdk/pull/5938) + * Handle encoded matrix URLs + [\#5903](https://github.com/matrix-org/matrix-react-sdk/pull/5903) + * Render ignored users setting regardless of if there are any + [\#5860](https://github.com/matrix-org/matrix-react-sdk/pull/5860) + * Fix inserting trailing colon after mention/pill + [\#5830](https://github.com/matrix-org/matrix-react-sdk/pull/5830) + * Fixes the two Todays problem in Redaction + [\#5917](https://github.com/matrix-org/matrix-react-sdk/pull/5917) + * Fix page up/down scrolling only half a page + [\#5920](https://github.com/matrix-org/matrix-react-sdk/pull/5920) + * Voice messages: Composer controls + [\#5935](https://github.com/matrix-org/matrix-react-sdk/pull/5935) + * Support MSC3086 asserted identity + [\#5886](https://github.com/matrix-org/matrix-react-sdk/pull/5886) + * Handle possible edge case with getting stuck in "unsent messages" bar + [\#5930](https://github.com/matrix-org/matrix-react-sdk/pull/5930) + * Fix suggested rooms not showing up regression from room list optimisation + [\#5932](https://github.com/matrix-org/matrix-react-sdk/pull/5932) + * Broadcast language change to ElectronPlatform + [\#5913](https://github.com/matrix-org/matrix-react-sdk/pull/5913) + * Fix VoIP PIP frame color + [\#5701](https://github.com/matrix-org/matrix-react-sdk/pull/5701) + * Convert some Flow-typed files to TypeScript + [\#5912](https://github.com/matrix-org/matrix-react-sdk/pull/5912) + * Initial SpaceStore tests work + [\#5906](https://github.com/matrix-org/matrix-react-sdk/pull/5906) + * Fix issues with space hierarchy in layout and with incompatible servers + [\#5926](https://github.com/matrix-org/matrix-react-sdk/pull/5926) + * Scale all mxc thumbs using device pixel ratio for hidpi + [\#5928](https://github.com/matrix-org/matrix-react-sdk/pull/5928) + * Fix add existing to space dialog no longer showing rooms for public spaces + [\#5918](https://github.com/matrix-org/matrix-react-sdk/pull/5918) + * Disable spaces context switching for when exploring a space + [\#5924](https://github.com/matrix-org/matrix-react-sdk/pull/5924) + * Autofocus search box in the add existing to space dialog + [\#5921](https://github.com/matrix-org/matrix-react-sdk/pull/5921) + * Use label element in add existing to space dialog for easier hit target + [\#5922](https://github.com/matrix-org/matrix-react-sdk/pull/5922) + * Dynamic max and min zoom in the new ImageView + [\#5916](https://github.com/matrix-org/matrix-react-sdk/pull/5916) + * Improve message error states + [\#5897](https://github.com/matrix-org/matrix-react-sdk/pull/5897) + * Check for null room in `VisibilityProvider` + [\#5914](https://github.com/matrix-org/matrix-react-sdk/pull/5914) + * Add unit tests for various collection-based utility functions + [\#5910](https://github.com/matrix-org/matrix-react-sdk/pull/5910) + * Spaces visual fixes + [\#5909](https://github.com/matrix-org/matrix-react-sdk/pull/5909) + * Remove reliance on DOM API to generated message preview + [\#5908](https://github.com/matrix-org/matrix-react-sdk/pull/5908) + * Expand upon voice message event & include overall waveform + [\#5888](https://github.com/matrix-org/matrix-react-sdk/pull/5888) + * Use floats for image background opacity + [\#5905](https://github.com/matrix-org/matrix-react-sdk/pull/5905) + * Show invites to spaces at the top of the space panel + [\#5902](https://github.com/matrix-org/matrix-react-sdk/pull/5902) + * Improve edge cases with spaces context switching + [\#5899](https://github.com/matrix-org/matrix-react-sdk/pull/5899) + * Fix spaces notification dots wrongly including upgraded (hidden) rooms + [\#5900](https://github.com/matrix-org/matrix-react-sdk/pull/5900) + * Iterate the spaces face pile design + [\#5898](https://github.com/matrix-org/matrix-react-sdk/pull/5898) + * Fix alignment issue with nested spaces being cut off wrong + [\#5890](https://github.com/matrix-org/matrix-react-sdk/pull/5890) + Changes in [3.19.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0) (2021-04-26) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0-rc.1...v3.19.0) diff --git a/README.md b/README.md index 73afe34df0..b3e96ef001 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Platform Targets: * WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox. * Mobile Web is not currently a target platform - instead please use the native iOS (https://github.com/matrix-org/matrix-ios-kit) and Android - (https://github.com/matrix-org/matrix-android-sdk) SDKs. + (https://github.com/matrix-org/matrix-android-sdk2) SDKs. All code lands on the `develop` branch - `master` is only used for stable releases. **Please file PRs against `develop`!!** diff --git a/__mocks__/FontManager.js b/__mocks__/FontManager.js new file mode 100644 index 0000000000..41eab4bf94 --- /dev/null +++ b/__mocks__/FontManager.js @@ -0,0 +1,6 @@ +// Stub out FontManager for tests as it doesn't validate anything we don't already know given +// our fixed test environment and it requires the installation of node-canvas. + +module.exports = { + fixupColorFonts: () => Promise.resolve(), +}; diff --git a/__mocks__/empty.js b/__mocks__/empty.js new file mode 100644 index 0000000000..51fb4fe937 --- /dev/null +++ b/__mocks__/empty.js @@ -0,0 +1,2 @@ +// Yes, this is empty. +module.exports = {}; diff --git a/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 e54de8a96c..27c4f39a09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.19.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", @@ -55,10 +55,10 @@ "dependencies": { "@babel/runtime": "^7.12.5", "await-lock": "^2.1.0", - "blueimp-canvas-to-blob": "^3.28.0", + "blurhash": "^1.1.3", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", - "cheerio": "^1.0.0-rc.5", + "cheerio": "^1.0.0-rc.9", "classnames": "^2.2.6", "commonmark": "^0.29.3", "counterpart": "^0.18.6", @@ -79,8 +79,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.13", + "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", @@ -88,18 +88,17 @@ "png-chunks-extract": "^1.0.0", "prop-types": "^15.7.2", "qrcode": "^1.4.4", - "qs": "^6.9.6", "re-resizable": "^6.9.0", - "react": "^16.14.0", - "react-beautiful-dnd": "^4.0.1", - "react-dom": "^16.14.0", + "react": "^17.0.2", + "react-beautiful-dnd": "^13.1.0", + "react-blurhash": "^0.1.3", + "react-dom": "^17.0.2", "react-focus-lock": "^2.5.0", "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.1", "rfc4648": "^1.4.0", - "sanitize-html": "github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db", + "sanitize-html": "^2.3.2", "tar-js": "^0.3.0", - "text-encoding-utf-8": "^1.0.2", "url": "^0.11.0", "what-input": "^5.2.10", "zxcvbn": "^4.4.2" @@ -107,24 +106,28 @@ "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", "@babel/traverse": "^7.12.12", + "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "@peculiar/webcrypto": "^1.1.4", "@sinonjs/fake-timers": "^7.0.2", "@types/classnames": "^2.2.11", + "@types/commonmark": "^0.27.4", "@types/counterpart": "^0.18.1", + "@types/css-font-loading-module": "^0.0.6", + "@types/diff-match-patch": "^1.0.32", "@types/flux": "^3.1.9", "@types/jest": "^26.0.20", "@types/linkifyjs": "^2.1.3", @@ -132,24 +135,24 @@ "@types/modernizr": "^3.5.3", "@types/node": "^14.14.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": "^1.27.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", - "enzyme-adapter-react-16": "^1.15.6", "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", @@ -158,10 +161,9 @@ "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", - "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", - "react-test-renderer": "^16.14.0", + "react-test-renderer": "^17.0.2", "rimraf": "^3.0.2", "stylelint": "^13.9.0", "stylelint-config-standard": "^20.0.0", @@ -169,13 +171,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" @@ -185,7 +184,10 @@ ], "moduleNameMapper": { "\\.(gif|png|svg|ttf|woff2)$": "/__mocks__/imageMock.js", - "\\$webapp/i18n/languages.json": "/__mocks__/languages.json" + "\\$webapp/i18n/languages.json": "/__mocks__/languages.json", + "decoderWorker\\.min\\.js": "/__mocks__/empty.js", + "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", + "waveWorker\\.min\\.js": "/__mocks__/empty.js" }, "transformIgnorePatterns": [ "/node_modules/(?!matrix-js-sdk).+$" diff --git a/res/css/_common.scss b/res/css/_common.scss index d6f85edb86..b128a82442 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -45,6 +45,8 @@ html { N.B. Breaks things when we have legitimate horizontal overscroll */ height: 100%; overflow: hidden; + // Stop similar overscroll bounce in Firefox Nightly for macOS + overscroll-behavior: none; } body { @@ -289,6 +291,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_staticWrapper .mx_Dialog { z-index: 4010; + contain: content; } .mx_Dialog_background { diff --git a/res/css/_components.scss b/res/css/_components.scss index 0057f8a8fc..8f80f1bf97 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,8 +57,8 @@ @import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss"; -@import "./views/avatars/_PulsedAvatar.scss"; @import "./views/avatars/_WidgetAvatar.scss"; +@import "./views/beta/_BetaCard.scss"; @import "./views/context_menus/_CallContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @@ -62,6 +67,7 @@ @import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; +@import "./views/dialogs/_BetaFeedbackDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @@ -74,6 +80,7 @@ @import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; @import "./views/dialogs/_FeedbackDialog.scss"; +@import "./views/dialogs/_ForwardDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_HostSignupDialog.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; @@ -96,6 +103,7 @@ @import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; +@import "./views/dialogs/_UntrustedDeviceDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.scss"; @@ -119,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"; @@ -161,6 +168,8 @@ @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MVideoBody.scss"; +@import "./views/messages/_MVoiceMessageBody.scss"; +@import "./views/messages/_MediaBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MjolnirBody.scss"; @@ -175,6 +184,7 @@ @import "./views/messages/_common_CryptoEvent.scss"; @import "./views/right_panel/_BaseCard.scss"; @import "./views/right_panel/_EncryptionInfo.scss"; +@import "./views/right_panel/_PinnedMessagesCard.scss"; @import "./views/right_panel/_RoomSummaryCard.scss"; @import "./views/right_panel/_UserInfo.scss"; @import "./views/right_panel/_VerificationPanel.scss"; @@ -191,6 +201,7 @@ @import "./views/rooms/_GroupLayout.scss"; @import "./views/rooms/_IRCLayout.scss"; @import "./views/rooms/_JumpToBottomButton.scss"; +@import "./views/rooms/_LinkPreviewGroup.scss"; @import "./views/rooms/_LinkPreviewWidget.scss"; @import "./views/rooms/_MemberInfo.scss"; @import "./views/rooms/_MemberList.scss"; @@ -199,7 +210,6 @@ @import "./views/rooms/_NewRoomIntro.scss"; @import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_PinnedEventTile.scss"; -@import "./views/rooms/_PinnedEventsPanel.scss"; @import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; @@ -236,6 +246,7 @@ @import "./views/settings/tabs/user/_AppearanceUserSettingsTab.scss"; @import "./views/settings/tabs/user/_GeneralUserSettingsTab.scss"; @import "./views/settings/tabs/user/_HelpUserSettingsTab.scss"; +@import "./views/settings/tabs/user/_LabsUserSettingsTab.scss"; @import "./views/settings/tabs/user/_MjolnirUserSettingsTab.scss"; @import "./views/settings/tabs/user/_NotificationUserSettingsTab.scss"; @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss"; @@ -248,12 +259,10 @@ @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"; +@import "./views/voip/_CallPreview.scss"; @import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadModal.scss"; diff --git a/res/css/structures/_ContextualMenu.scss b/res/css/structures/_ContextualMenu.scss index 658033339a..d7f2cb76e8 100644 --- a/res/css/structures/_ContextualMenu.scss +++ b/res/css/structures/_ContextualMenu.scss @@ -38,6 +38,7 @@ limitations under the License. position: absolute; font-size: $font-14px; z-index: 5001; + contain: content; } .mx_ContextualMenu_right { @@ -115,8 +116,3 @@ limitations under the License. border-top: 8px solid $menu-bg-color; border-right: 8px solid transparent; } - -.mx_ContextualMenu_spinner { - display: block; - margin: 0 auto; -} diff --git a/res/css/structures/_GroupFilterPanel.scss b/res/css/structures/_GroupFilterPanel.scss index e5a8ef6df2..444435dd57 100644 --- a/res/css/structures/_GroupFilterPanel.scss +++ b/res/css/structures/_GroupFilterPanel.scss @@ -56,6 +56,12 @@ limitations under the License. .mx_GroupFilterPanel .mx_TagTile { // opacity: 0.5; position: relative; + + .mx_BetaDot { + position: absolute; + right: -13px; + top: -11px; + } } .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype { diff --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss index 2350d9f28a..60f9ebdd08 100644 --- a/res/css/structures/_GroupView.scss +++ b/res/css/structures/_GroupView.scss @@ -323,7 +323,7 @@ limitations under the License. } .mx_GroupView_featuredThing .mx_BaseAvatar { - /* To prevent misalignment with mx_TintableSvg (in addButton) */ + /* To prevent misalignment with img (in addButton) */ vertical-align: initial; } diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 7c3cd1c513..f254ca3226 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -25,6 +25,7 @@ $roomListCollapsedWidth: 68px; // Create a row-based flexbox for the GroupFilterPanel and the room list display: flex; + contain: content; .mx_LeftPanel_GroupFilterPanelContainer { flex-grow: 0; @@ -70,6 +71,7 @@ $roomListCollapsedWidth: 68px; // aligned correctly. This is also a row-based flexbox. display: flex; align-items: center; + contain: content; &.mx_IndicatorScrollbar_leftOverflow { mask-image: linear-gradient(90deg, transparent, black 5%); @@ -109,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; @@ -183,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/_MyGroups.scss b/res/css/structures/_MyGroups.scss index 73f1332cd0..9c0062b72d 100644 --- a/res/css/structures/_MyGroups.scss +++ b/res/css/structures/_MyGroups.scss @@ -17,6 +17,11 @@ limitations under the License. .mx_MyGroups { display: flex; flex-direction: column; + + .mx_BetaCard { + margin: 0 72px; + max-width: 760px; + } } .mx_MyGroups .mx_RoomHeader_simpleHeader { @@ -30,7 +35,7 @@ limitations under the License. flex-wrap: wrap; } -.mx_MyGroups > :not(.mx_RoomHeader) { +.mx_MyGroups > :not(.mx_RoomHeader):not(.mx_BetaCard) { max-width: 960px; margin: 40px; } diff --git a/res/css/structures/_NotificationPanel.scss b/res/css/structures/_NotificationPanel.scss index 1258ace069..e54feca175 100644 --- a/res/css/structures/_NotificationPanel.scss +++ b/res/css/structures/_NotificationPanel.scss @@ -82,7 +82,6 @@ limitations under the License. color: $primary-fg-color; font-size: $font-12px; display: inline; - padding-left: 0px; } .mx_NotificationPanel .mx_EventTile_senderDetails { @@ -103,6 +102,7 @@ limitations under the License. visibility: visible; position: initial; display: inline; + padding-left: 5px; } .mx_NotificationPanel .mx_EventTile_line { diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index 5515fe4060..3222fe936c 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -25,6 +25,7 @@ limitations under the License. padding: 4px 0; box-sizing: border-box; height: 100%; + contain: strict; .mx_RoomView_MessageList { padding: 14px 18px; // top and bottom is 4px smaller to balance with the padding set above @@ -98,6 +99,76 @@ limitations under the License. mask-position: center; } +$dot-size: 8px; +$pulse-color: $pinned-unread-color; + +.mx_RightPanel_pinnedMessagesButton { + &::before { + mask-image: url('$(res)/img/element-icons/room/pin.svg'); + mask-position: center; + } + + .mx_RightPanel_pinnedMessagesButton_unreadIndicator { + position: absolute; + right: 0; + top: 0; + margin: 4px; + width: $dot-size; + height: $dot-size; + border-radius: 50%; + transform: scale(1); + background: rgba($pulse-color, 1); + box-shadow: 0 0 0 0 rgba($pulse-color, 1); + animation: mx_RightPanel_indicator_pulse 2s infinite; + animation-iteration-count: 1; + + &::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + top: 0; + left: 0; + transform: scale(1); + transform-origin: center center; + animation-name: mx_RightPanel_indicator_pulse_shadow; + animation-duration: inherit; + animation-iteration-count: inherit; + border-radius: 50%; + background: rgba($pulse-color, 1); + } + } +} + +@keyframes mx_RightPanel_indicator_pulse { + 0% { + transform: scale(0.95); + } + + 70% { + transform: scale(1); + } + + 100% { + transform: scale(0.95); + } +} + +@keyframes mx_RightPanel_indicator_pulse_shadow { + 0% { + opacity: 0.7; + } + + 70% { + transform: scale(2.2); + opacity: 0; + } + + 100% { + opacity: 0; + } +} + .mx_RightPanel_headerButton_highlight { &::before { background-color: $accent-color !important; diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index 89cb21b7a6..ec07500af5 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -61,6 +61,39 @@ limitations under the License. .mx_RoomDirectory_tableWrapper { overflow-y: auto; flex: 1 1 0; + + .mx_RoomDirectory_footer { + margin-top: 24px; + text-align: center; + + > h5 { + margin: 0; + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $primary-fg-color; + } + + > p { + margin: 40px auto 60px; + font-size: $font-14px; + line-height: $font-20px; + color: $secondary-fg-color; + max-width: 464px; // easier reading + } + + > hr { + margin: 0; + border: none; + height: 1px; + background-color: $header-panel-bg-color; + } + + .mx_RoomDirectory_newRoom { + margin: 24px auto 0; + width: max-content; + } + } } .mx_RoomDirectory_table { @@ -138,11 +171,6 @@ limitations under the License. color: $settings-grey-fg-color; } -.mx_RoomDirectory_table tr { - padding-bottom: 10px; - cursor: pointer; -} - .mx_RoomDirectory .mx_RoomView_MessageList { padding: 0; } diff --git a/res/css/structures/_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 cdbe47178d..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; @@ -152,6 +153,7 @@ limitations under the License. flex: 1; display: flex; flex-direction: column; + contain: content; } .mx_RoomView_statusArea { @@ -237,6 +239,7 @@ hr.mx_RoomView_myReadMarker { position: relative; top: -1px; z-index: 1; + will-change: width; transition: width 400ms easeinsine 1s, opacity 400ms easeinsine 1s; width: 99%; opacity: 1; diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss index a4e501b339..7b75c69e86 100644 --- a/res/css/structures/_ScrollPanel.scss +++ b/res/css/structures/_ScrollPanel.scss @@ -21,5 +21,8 @@ limitations under the License. display: flex; flex-direction: column; justify-content: flex-end; + + content-visibility: auto; + contain-intrinsic-size: 50px; } } diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 59f2ea947c..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; @@ -79,6 +84,10 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceItem { display: inline-flex; flex-flow: wrap; + + &.mx_SpaceItem_narrow { + align-self: baseline; + } } .mx_SpaceItem.collapsed { @@ -233,7 +242,6 @@ $activeBorderColor: $secondary-fg-color; .mx_SpacePanel_badgeContainer { position: absolute; - height: 16px; // Create a flexbox to make aligning dot badges easier display: flex; @@ -245,23 +253,37 @@ $activeBorderColor: $secondary-fg-color; .mx_NotificationBadge_dot { // make the smaller dot occupy the same width for centering - margin-left: 7px; - margin-right: 7px; + margin: 0 7px; } } &.collapsed { .mx_SpaceButton { .mx_SpacePanel_badgeContainer { - right: -3px; - top: -3px; + right: 0; + top: 0; + + .mx_NotificationBadge { + background-clip: padding-box; + } + + .mx_NotificationBadge_dot { + margin: 0 -1px 0 0; + border: 3px solid $groupFilterPanel-bg-color; + } + + .mx_NotificationBadge_2char, + .mx_NotificationBadge_3char { + margin: -5px -5px 0 0; + border: 2px solid $groupFilterPanel-bg-color; + } } &.mx_SpaceButton_active .mx_SpacePanel_badgeContainer { // when we draw the selection border we move the relative bounds of our parent // so update our position within the bounds of the parent to maintain position overall - right: -6px; - top: -6px; + right: -3px; + top: -3px; } } } @@ -275,7 +297,7 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceButton:hover, .mx_SpaceButton:focus-within, .mx_SpaceButton_hasMenuOpen { - &:not(.mx_SpaceButton_home) { + &:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) { // Hide the badge container on hover because it'll be a menu button .mx_SpacePanel_badgeContainer { width: 0; diff --git a/res/css/structures/_SpaceRoomDirectory.scss b/res/css/structures/_SpaceRoomDirectory.scss index c7d087d8e0..7925686bf1 100644 --- a/res/css/structures/_SpaceRoomDirectory.scss +++ b/res/css/structures/_SpaceRoomDirectory.scss @@ -86,7 +86,7 @@ limitations under the License. color: $primary-fg-color; .mx_AccessibleButton { - padding: 2px 8px; + padding: 4px 12px; font-weight: normal; & + .mx_AccessibleButton { @@ -94,6 +94,11 @@ limitations under the License. } } + .mx_AccessibleButton_kind_danger_outline, + .mx_AccessibleButton_kind_primary_outline { + padding: 3px 12px; // to account for the 1px border + } + > span { margin-left: auto; } @@ -246,11 +251,17 @@ limitations under the License. grid-row: 1/3; .mx_AccessibleButton { - padding: 8px 18px; + line-height: $font-24px; + padding: 4px 16px; display: inline-block; visibility: hidden; } + .mx_AccessibleButton_kind_danger_outline, + .mx_AccessibleButton_kind_primary_outline { + padding: 3px 16px; // to account for the 1px border + } + .mx_Checkbox { display: inline-flex; vertical-align: middle; diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 553919d862..48b565be7f 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -93,6 +93,10 @@ $SpaceRoomViewInnerWidth: 428px; } } + &:not(.mx_SpaceRoomView_landing) .mx_SpaceFeedbackPrompt { + width: $SpaceRoomViewInnerWidth; + } + .mx_SpaceRoomView_buttons { display: block; margin-top: 44px; @@ -103,6 +107,10 @@ $SpaceRoomViewInnerWidth: 428px; padding: 8px 22px; margin-left: 16px; } + + input.mx_AccessibleButton { + border: none; // override default styles + } } .mx_Field { @@ -133,6 +141,44 @@ $SpaceRoomViewInnerWidth: 428px; box-sizing: border-box; box-shadow: 2px 15px 30px $dialog-shadow-color; border-radius: 8px; + position: relative; + + // XXX remove this when spaces leaves Beta + .mx_BetaCard_betaPill { + position: absolute; + right: 24px; + top: 32px; + } + // XXX remove this when spaces leaves Beta + .mx_SpaceRoomView_preview_spaceBetaPrompt { + font-weight: $font-semi-bold; + font-size: $font-14px; + line-height: $font-24px; + color: $primary-fg-color; + margin-top: 24px; + position: relative; + padding-left: 24px; + + .mx_AccessibleButton_kind_link { + display: inline; + padding: 0; + font-size: inherit; + line-height: inherit; + } + + &::before { + content: ""; + position: absolute; + height: $font-24px; + width: 20px; + left: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + background-color: $secondary-fg-color; + } + } .mx_SpaceRoomView_preview_inviter { display: flex; @@ -238,7 +284,8 @@ $SpaceRoomViewInnerWidth: 428px; .mx_SpaceRoomView_landing_inviteButton { position: relative; - padding-left: 40px; + padding: 4px 18px 4px 40px; + line-height: $font-24px; height: min-content; &::before { @@ -254,12 +301,35 @@ $SpaceRoomViewInnerWidth: 428px; mask-image: url('$(res)/img/element-icons/room/invite.svg'); } } + + .mx_SpaceRoomView_landing_settingsButton { + position: relative; + margin-left: 16px; + width: 24px; + height: 24px; + + &::before { + position: absolute; + content: ""; + left: 0; + top: 0; + height: 24px; + width: 24px; + background: $tertiary-fg-color; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/settings.svg'); + } + } } .mx_SpaceRoomView_landing_topic { font-size: $font-15px; margin-top: 12px; margin-bottom: 16px; + white-space: pre-wrap; + word-wrap: break-word; } > hr { @@ -268,87 +338,22 @@ $SpaceRoomViewInnerWidth: 428px; background-color: $groupFilterPanel-bg-color; } - .mx_SpaceRoomView_landing_adminButtons { - margin-top: 24px; - - .mx_AccessibleButton { - position: relative; - width: 160px; - height: 124px; - box-sizing: border-box; - padding: 72px 16px 0; - border-radius: 12px; - border: 1px solid $input-border-color; - margin-right: 28px; - margin-bottom: 20px; - font-size: $font-14px; - display: inline-block; - vertical-align: bottom; - - &:last-child { - margin-right: 0; - } - - &:hover { - background-color: rgba(141, 151, 165, 0.1); - } - - &::before, &::after { - position: absolute; - content: ""; - left: 16px; - top: 16px; - height: 40px; - width: 40px; - border-radius: 20px; - } - - &::after { - mask-position: center; - mask-size: 30px; - mask-repeat: no-repeat; - background: #ffffff; // white icon fill - } - - &.mx_SpaceRoomView_landing_addButton { - &::before { - background-color: #ac3ba8; - } - - &::after { - mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); - } - } - - &.mx_SpaceRoomView_landing_createButton { - &::before { - background-color: #368bd6; - } - - &::after { - mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); - } - } - - &.mx_SpaceRoomView_landing_settingsButton { - &::before { - background-color: #5c56f5; - } - - &::after { - mask-image: url('$(res)/img/element-icons/settings.svg'); - } - } - } - } - .mx_SearchBox { margin: 0 0 20px; } + + .mx_SpaceFeedbackPrompt { + margin-bottom: 16px; + + // hide the HR as we have our own + & + hr { + display: none; + } + } } .mx_SpaceRoomView_privateScope { - .mx_AccessibleButton { + > .mx_AccessibleButton { @mixin SpacePillButton; } @@ -361,7 +366,63 @@ $SpaceRoomViewInnerWidth: 428px; } } + .mx_SpaceRoomView_betaWarning { + padding: 12px 12px 12px 54px; + position: relative; + font-size: $font-15px; + line-height: $font-24px; + width: 432px; + border-radius: 8px; + background-color: $info-plinth-bg-color; + color: $secondary-fg-color; + box-sizing: border-box; + + > h3 { + font-weight: $font-semi-bold; + font-size: inherit; + line-height: inherit; + margin: 0; + } + + > p { + font-size: inherit; + line-height: inherit; + margin: 0; + } + + &::before { + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + content: ''; + width: 20px; + height: 20px; + position: absolute; + top: 14px; + left: 14px; + background-color: $secondary-fg-color; + } + } + .mx_SpaceRoomView_inviteTeammates { + // XXX remove this when spaces leaves Beta + .mx_SpaceRoomView_inviteTeammates_betaDisclaimer { + padding: 58px 16px 16px; + position: relative; + border-radius: 8px; + background-color: $header-panel-bg-color; + max-width: $SpaceRoomViewInnerWidth; + margin: 20px 0 30px; + box-sizing: border-box; + + .mx_BetaCard_betaPill { + position: absolute; + left: 16px; + top: 16px; + } + } + .mx_SpaceRoomView_inviteTeammates_buttons { color: $secondary-fg-color; margin-top: 28px; @@ -443,3 +504,66 @@ $SpaceRoomViewInnerWidth: 428px; } } } + +.mx_SpaceFeedbackPrompt { + margin-top: 18px; + margin-bottom: 12px; + + > hr { + border: none; + border-top: 1px solid $input-border-color; + margin-bottom: 12px; + } + + > div { + display: flex; + flex-direction: row; + font-size: $font-15px; + line-height: $font-24px; + + > span { + color: $secondary-fg-color; + position: relative; + padding-left: 32px; + font-size: inherit; + line-height: inherit; + margin-right: auto; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 2px; + height: 20px; + width: 20px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + } + } + + .mx_AccessibleButton_kind_link { + color: $accent-color; + position: relative; + padding: 0 0 0 24px; + margin-left: 8px; + font-size: inherit; + line-height: inherit; + + &::before { + content: ''; + position: absolute; + left: 0; + height: 16px; + width: 16px; + background-color: $accent-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/chat-bubbles.svg'); + mask-position: center; + } + } + } +} diff --git a/res/css/structures/_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 84% rename from res/css/views/voice_messages/_PlayPauseButton.scss rename to res/css/views/audio_messages/_PlayPauseButton.scss index c8ab162694..714da3e605 100644 --- a/res/css/views/voice_messages/_PlayPauseButton.scss +++ b/res/css/views/audio_messages/_PlayPauseButton.scss @@ -18,13 +18,15 @@ 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: $primary-bg-color; + background-color: $voice-playback-button-bg-color; &::before { content: ''; position: absolute; // sizing varies by icon - background-color: $muted-fg-color; + background-color: $voice-playback-button-fg-color; mask-repeat: no-repeat; mask-size: contain; } diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/audio_messages/_PlaybackContainer.scss similarity index 62% rename from res/css/views/voice_messages/_PlaybackContainer.scss rename to res/css/views/audio_messages/_PlaybackContainer.scss index 49bd81ef81..fd01864bba 100644 --- a/res/css/views/voice_messages/_PlaybackContainer.scss +++ b/res/css/views/audio_messages/_PlaybackContainer.scss @@ -19,36 +19,34 @@ limitations under the License. // Container for live recording and playback controls .mx_VoiceMessagePrimaryContainer { - padding: 6px; // makes us 4px taller than the send/stop button - padding-right: 5px; // there's 1px from the waveform itself, so account for that - background-color: $voice-record-waveform-bg-color; - border-radius: 12px; + // 7px top and bottom for visual design. 12px left & right, but the waveform (right) + // has a 1px padding on it that we want to account for. + padding: 7px 12px 7px 11px; // Cheat at alignment a bit display: flex; align-items: center; - color: $voice-record-waveform-fg-color; - font-size: $font-14px; + contain: content; .mx_Waveform { - // We want the bars to be 2px shorter than the play/pause button in the waveform control - height: 28px; // default is 30px, so we're subtracting the 2px border off the bars - .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; } } } .mx_Clock { - padding-right: 4px; // isolate from waveform - padding-left: 8px; // isolate from live circle - width: 40px; // we're not using a monospace font, so fake it + width: $font-42px; // we're not using a monospace font, so fake it + padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended. + padding-left: 8px; // isolate from recording circle / play control } } 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/avatars/_DecoratedRoomAvatar.scss b/res/css/views/avatars/_DecoratedRoomAvatar.scss index 2631cbfb40..257b512579 100644 --- a/res/css/views/avatars/_DecoratedRoomAvatar.scss +++ b/res/css/views/avatars/_DecoratedRoomAvatar.scss @@ -16,6 +16,7 @@ limitations under the License. .mx_DecoratedRoomAvatar, .mx_ExtraTile { position: relative; + contain: content; &.mx_DecoratedRoomAvatar_cutout .mx_BaseAvatar { mask-image: url('$(res)/img/element-icons/roomlist/decorated-avatar-mask.svg'); diff --git a/res/css/views/beta/_BetaCard.scss b/res/css/views/beta/_BetaCard.scss new file mode 100644 index 0000000000..2af4e79ecd --- /dev/null +++ b/res/css/views/beta/_BetaCard.scss @@ -0,0 +1,161 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_BetaCard { + margin-bottom: 20px; + padding: 24px; + background-color: $settings-profile-placeholder-bg-color; + border-radius: 8px; + box-sizing: border-box; + + .mx_BetaCard_columns { + display: flex; + + > div { + .mx_BetaCard_title { + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + color: $primary-fg-color; + margin: 4px 0 14px; + + .mx_BetaCard_betaPill { + margin-left: 12px; + } + } + + .mx_BetaCard_caption { + font-size: $font-15px; + line-height: $font-20px; + color: $secondary-fg-color; + margin-bottom: 20px; + } + + .mx_BetaCard_buttons .mx_AccessibleButton { + display: block; + margin: 12px 0; + padding: 7px 40px; + width: auto; + } + + .mx_BetaCard_disclaimer { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + margin-top: 20px; + } + } + + > img { + margin: auto 0 auto 20px; + width: 300px; + object-fit: contain; + height: 100%; + } + } + + .mx_BetaCard_relatedSettings { + .mx_SettingsFlag { + margin: 16px 0 0; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + + .mx_SettingsFlag_microcopy { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + } + } + } +} + +.mx_BetaCard_betaPill { + background-color: $accent-color-alt; + padding: 4px 10px; + border-radius: 8px; + text-transform: uppercase; + font-size: 12px; + line-height: 15px; + color: #FFFFFF; + display: inline-block; + vertical-align: text-bottom; + + &.mx_BetaCard_betaPill_clickable { + cursor: pointer; + } +} + +$pulse-color: $accent-color-alt; +$dot-size: 12px; + +.mx_BetaDot { + border-radius: 50%; + margin: 10px; + height: $dot-size; + width: $dot-size; + transform: scale(1); + background: rgba($pulse-color, 1); + animation: mx_Beta_bluePulse 2s infinite; + animation-iteration-count: 20; + position: relative; + + &::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + top: 0; + left: 0; + transform: scale(1); + transform-origin: center center; + animation-name: mx_Beta_bluePulse_shadow; + animation-duration: inherit; + animation-iteration-count: inherit; + border-radius: 50%; + background: rgba($pulse-color, 1); + } +} + +@keyframes mx_Beta_bluePulse { + 0% { + transform: scale(0.95); + } + + 70% { + transform: scale(1); + } + + 100% { + transform: scale(0.95); + } +} + +@keyframes mx_Beta_bluePulse_shadow { + 0% { + opacity: 0.7; + } + + 70% { + transform: scale(2.2); + opacity: 0; + } + + 100% { + opacity: 0; + } +} diff --git a/res/css/views/context_menus/_MessageContextMenu.scss b/res/css/views/context_menus/_MessageContextMenu.scss index 2ecb93e734..338841cce4 100644 --- a/res/css/views/context_menus/_MessageContextMenu.scss +++ b/res/css/views/context_menus/_MessageContextMenu.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2021 Michael Weimann Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,16 +16,69 @@ limitations under the License. */ .mx_MessageContextMenu { - padding: 6px; -} -.mx_MessageContextMenu_field { - display: block; - padding: 3px 6px 3px 6px; - cursor: pointer; - white-space: nowrap; -} + .mx_IconizedContextMenu_icon { + width: 16px; + height: 16px; + display: block; -.mx_MessageContextMenu_field.mx_MessageContextMenu_fieldSet { - font-weight: bold; + &::before { + content: ''; + width: 16px; + height: 16px; + display: block; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-fg-color; + } + } + + .mx_MessageContextMenu_iconCollapse::before { + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); + } + + .mx_MessageContextMenu_iconReport::before { + mask-image: url('$(res)/img/element-icons/warning-badge.svg'); + } + + .mx_MessageContextMenu_iconLink::before { + mask-image: url('$(res)/img/element-icons/link.svg'); + } + + .mx_MessageContextMenu_iconPermalink::before { + mask-image: url('$(res)/img/element-icons/room/share.svg'); + } + + .mx_MessageContextMenu_iconUnhidePreview::before { + mask-image: url('$(res)/img/element-icons/settings/appearance.svg'); + } + + .mx_MessageContextMenu_iconForward::before { + mask-image: url('$(res)/img/element-icons/message/fwd.svg'); + } + + .mx_MessageContextMenu_iconRedact::before { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); + } + + .mx_MessageContextMenu_iconResend::before { + mask-image: url('$(res)/img/element-icons/retry.svg'); + } + + .mx_MessageContextMenu_iconSource::before { + mask-image: url('$(res)/img/element-icons/room/format-bar/code.svg'); + } + + .mx_MessageContextMenu_iconQuote::before { + mask-image: url('$(res)/img/element-icons/room/format-bar/quote.svg'); + } + + .mx_MessageContextMenu_iconPin::before { + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + } + + .mx_MessageContextMenu_iconUnpin::before { + mask-image: url('$(res)/img/element-icons/room/pin.svg'); + } } diff --git a/res/css/views/context_menus/_TagTileContextMenu.scss b/res/css/views/context_menus/_TagTileContextMenu.scss index 8929c8906e..d707f4ce7c 100644 --- a/res/css/views/context_menus/_TagTileContextMenu.scss +++ b/res/css/views/context_menus/_TagTileContextMenu.scss @@ -38,6 +38,15 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/view-community.svg'); } +.mx_TagTileContextMenu_moveUp::before { + transform: rotate(180deg); + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); +} + +.mx_TagTileContextMenu_moveDown::before { + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); +} + .mx_TagTileContextMenu_hideCommunity::before { mask-image: url('$(res)/img/element-icons/hide.svg'); } diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 84f965e6bd..2776c477fc 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -54,7 +54,8 @@ limitations under the License. display: flex; margin-top: 12px; - .mx_BaseAvatar { + // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling + .mx_DecoratedRoomAvatar { margin-right: 12px; } @@ -75,10 +76,124 @@ limitations under the License. } .mx_AddExistingToSpace_section_spaces { + .mx_BaseAvatar { + margin-right: 12px; + } + .mx_BaseAvatar_image { border-radius: 8px; } } + + .mx_AddExistingToSpace_section_experimental { + position: relative; + border-radius: 8px; + margin: 12px 0; + padding: 8px 8px 8px 42px; + background-color: $header-panel-bg-color; + + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + &::before { + content: ''; + position: absolute; + left: 10px; + top: calc(50% - 8px); // vertical centering + height: 16px; + width: 16px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + } + } + + .mx_AddExistingToSpace_footer { + display: flex; + margin-top: 20px; + + > span { + flex-grow: 1; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + .mx_ProgressBar { + height: 8px; + width: 100%; + + @mixin ProgressBarBorderRadius 8px; + } + + .mx_AddExistingToSpace_progressText { + margin-top: 8px; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + } + + > * { + vertical-align: middle; + } + } + + .mx_AddExistingToSpace_error { + padding-left: 12px; + + > img { + align-self: center; + } + + .mx_AddExistingToSpace_errorHeading { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $notice-primary-color; + } + + .mx_AddExistingToSpace_errorCaption { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $primary-fg-color; + } + } + + .mx_AccessibleButton { + display: inline-block; + align-self: center; + } + + .mx_AccessibleButton_kind_primary { + padding: 8px 36px; + } + + .mx_AddExistingToSpace_retryButton { + margin-left: 12px; + padding-left: 24px; + position: relative; + + &::before { + content: ''; + position: absolute; + background-color: $primary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + left: 0; + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + } } .mx_AddExistingToSpaceDialog { @@ -101,7 +216,7 @@ limitations under the License. .mx_BaseAvatar { display: inline-flex; - margin: 5px 16px 5px 5px; + margin: auto 16px auto 5px; vertical-align: middle; } @@ -160,44 +275,7 @@ limitations under the License. } } - .mx_AddExistingToSpaceDialog_errorText { - font-weight: $font-semi-bold; - font-size: $font-12px; - line-height: $font-15px; - color: $notice-primary-color; - margin-bottom: 28px; - } - .mx_AddExistingToSpace { display: contents; } - - .mx_AddExistingToSpaceDialog_footer { - display: flex; - margin-top: 32px; - - > span { - flex-grow: 1; - font-size: $font-14px; - line-height: $font-15px; - font-weight: $font-semi-bold; - - .mx_AccessibleButton { - font-size: inherit; - display: inline-block; - } - - > * { - vertical-align: middle; - } - } - - .mx_AccessibleButton { - display: inline-block; - } - - .mx_AccessibleButton_kind_link { - padding: 0; - } - } } diff --git a/src/utils/Accessibility.js b/res/css/views/dialogs/_BetaFeedbackDialog.scss similarity index 53% rename from src/utils/Accessibility.js rename to res/css/views/dialogs/_BetaFeedbackDialog.scss index f4909f971b..9f5f6b512e 100644 --- a/src/utils/Accessibility.js +++ b/res/css/views/dialogs/_BetaFeedbackDialog.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,15 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -/** - * Automatically focuses the captured reference when receiving a non-null - * object. Useful in scenarios where componentDidMount does not have a - * useful reference to an element, but one needs to focus the element on - * first render. Example usage: ref={focusCapturedRef} - * @param {function} ref The React reference to focus on, if not null - */ -export function focusCapturedRef(ref) { - if (ref) { - ref.focus(); +.mx_BetaFeedbackDialog { + .mx_BetaFeedbackDialog_subheading { + color: $primary-fg-color; + font-size: $font-14px; + line-height: $font-20px; + margin-bottom: 24px; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + font-size: inherit; + line-height: inherit; } } diff --git a/res/css/views/dialogs/_ForwardDialog.scss b/res/css/views/dialogs/_ForwardDialog.scss new file mode 100644 index 0000000000..95d7ce74c4 --- /dev/null +++ b/res/css/views/dialogs/_ForwardDialog.scss @@ -0,0 +1,159 @@ +/* +Copyright 2021 Robin Townsend + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ForwardDialog { + width: 520px; + color: $primary-fg-color; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + min-height: 0; + height: 80vh; + + > h3 { + margin: 0 0 6px; + color: $secondary-fg-color; + font-size: $font-12px; + font-weight: $font-semi-bold; + line-height: $font-15px; + } + + > .mx_ForwardDialog_preview { + max-height: 30%; + flex-shrink: 0; + overflow-y: auto; + + div { + pointer-events: none; + } + + .mx_EventTile_msgOption { + display: none; + } + + // When forwarding messages from encrypted rooms, EventTile will complain + // that our preview is unencrypted, which doesn't actually matter + .mx_EventTile_e2eIcon_unencrypted { + display: none; + } + + // We also hide download links to not encourage users to try interacting + .mx_MFileBody_download { + display: none; + } + } + + > hr { + width: 100%; + border: none; + border-top: 1px solid $input-border-color; + margin: 12px 0; + } + + > .mx_ForwardList { + display: contents; + + .mx_SearchBox { + // To match the space around the title + margin: 0 0 15px 0; + flex-grow: 0; + } + + .mx_ForwardList_content { + flex-grow: 1; + } + + .mx_ForwardList_noResults { + display: block; + margin-top: 24px; + } + + .mx_ForwardList_results { + &:not(:first-child) { + margin-top: 24px; + } + + .mx_ForwardList_entry { + display: flex; + justify-content: space-between; + height: 32px; + padding: 6px; + border-radius: 8px; + + &:hover { + background-color: $groupFilterPanel-bg-color; + } + + .mx_ForwardList_roomButton { + display: flex; + margin-right: 12px; + min-width: 0; + + .mx_DecoratedRoomAvatar { + margin-right: 12px; + } + + .mx_ForwardList_entry_name { + font-size: $font-15px; + line-height: 30px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 12px; + } + } + + .mx_ForwardList_sendButton { + position: relative; + + &:not(.mx_ForwardList_canSend) .mx_ForwardList_sendLabel { + // Hide the "Send" label while preserving button size + visibility: hidden; + } + + .mx_ForwardList_sendIcon, .mx_NotificationBadge { + position: absolute; + } + + .mx_NotificationBadge { + // Match the failed to send indicator's color with the disabled button + background-color: $button-danger-disabled-fg-color; + } + + &.mx_ForwardList_sending .mx_ForwardList_sendIcon { + background-color: $button-primary-bg-color; + mask-image: url('$(res)/img/element-icons/circle-sending.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 14px; + width: 14px; + height: 14px; + } + + &.mx_ForwardList_sent .mx_ForwardList_sendIcon { + background-color: $button-primary-bg-color; + mask-image: url('$(res)/img/element-icons/circle-sent.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 14px; + width: 14px; + height: 14px; + } + } + } + } + } +} diff --git a/res/css/views/dialogs/_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/_ShareDialog.scss b/res/css/views/dialogs/_ShareDialog.scss index ce3fdd021f..4d5e1409db 100644 --- a/res/css/views/dialogs/_ShareDialog.scss +++ b/res/css/views/dialogs/_ShareDialog.scss @@ -50,7 +50,8 @@ limitations under the License. margin-left: 20px; display: inherit; } -.mx_ShareDialog_matrixto_copy > div { +.mx_ShareDialog_matrixto_copy::after { + content: ""; mask-image: url($copy-button-url); background-color: $message-action-bar-fg-color; margin-left: 5px; diff --git a/res/css/views/dialogs/_SpaceSettingsDialog.scss b/res/css/views/dialogs/_SpaceSettingsDialog.scss 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/avatars/_PulsedAvatar.scss b/res/css/views/dialogs/_UntrustedDeviceDialog.scss similarity index 63% rename from res/css/views/avatars/_PulsedAvatar.scss rename to res/css/views/dialogs/_UntrustedDeviceDialog.scss index ce9e3382ab..0ecd9d4f71 100644 --- a/res/css/views/avatars/_PulsedAvatar.scss +++ b/res/css/views/dialogs/_UntrustedDeviceDialog.scss @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,17 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_PulsedAvatar { - @keyframes shadow-pulse { - 0% { - box-shadow: 0 0 0 0px rgba($accent-color, 0.2); - } - 100% { - box-shadow: 0 0 0 6px rgba($accent-color, 0); - } - } +.mx_UntrustedDeviceDialog { + .mx_Dialog_title { + display: flex; + align-items: center; - img { - animation: shadow-pulse 1s infinite; + .mx_E2EIcon { + margin-left: 0; + } } } 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/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index 0075dcb511..7bc47a3c98 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -72,16 +72,20 @@ limitations under the License. .mx_AccessibleButton_kind_danger_outline { color: $button-danger-bg-color; - background-color: $button-secondary-bg-color; + background-color: transparent; border: 1px solid $button-danger-bg-color; } -.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled, -.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled { +.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled { color: $button-danger-disabled-fg-color; background-color: $button-danger-disabled-bg-color; } +.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled { + color: $button-danger-disabled-bg-color; + border-color: $button-danger-disabled-bg-color; +} + .mx_AccessibleButton_hasKind.mx_AccessibleButton_kind_danger_sm { padding: 5px 12px; color: $button-danger-fg-color; diff --git a/res/css/views/elements/_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/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index 71035dadc3..da23957b36 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -22,6 +22,7 @@ limitations under the License. } .mx_ImageView_image_wrapper { + pointer-events: initial; display: flex; justify-content: center; align-items: center; @@ -30,7 +31,6 @@ limitations under the License. } .mx_ImageView_image { - pointer-events: all; flex-shrink: 0; } @@ -43,7 +43,7 @@ limitations under the License. } .mx_ImageView_info_wrapper { - pointer-events: all; + pointer-events: initial; padding-left: 32px; display: flex; flex-direction: row; @@ -63,7 +63,7 @@ limitations under the License. .mx_ImageView_toolbar { padding-right: 16px; - pointer-events: all; + pointer-events: initial; display: flex; align-items: center; } diff --git a/res/css/views/elements/_InlineSpinner.scss b/res/css/views/elements/_InlineSpinner.scss index 6b91e45923..ca5cb5d3a8 100644 --- a/res/css/views/elements/_InlineSpinner.scss +++ b/res/css/views/elements/_InlineSpinner.scss @@ -18,7 +18,11 @@ limitations under the License. display: inline; } -.mx_InlineSpinner_spin img { +.mx_InlineSpinner img, .mx_InlineSpinner_icon { margin: 0px 6px; vertical-align: -3px; } + +.mx_InlineSpinner_icon { + display: inline-block; +} diff --git a/res/css/views/elements/_MiniAvatarUploader.scss b/res/css/views/elements/_MiniAvatarUploader.scss index 698184a095..df4676ab56 100644 --- a/res/css/views/elements/_MiniAvatarUploader.scss +++ b/res/css/views/elements/_MiniAvatarUploader.scss @@ -28,8 +28,7 @@ limitations under the License. top: 0; } - &::before, &::after { - content: ''; + .mx_MiniAvatarUploader_indicator { position: absolute; height: 26px; @@ -37,27 +36,22 @@ limitations under the License. right: -6px; bottom: -6px; - } - &::before { background-color: $primary-bg-color; border-radius: 50%; z-index: 1; - } - &::after { - background-color: $secondary-fg-color; - mask-position: center; - mask-repeat: no-repeat; - mask-image: url('$(res)/img/element-icons/camera.svg'); - mask-size: 16px; - z-index: 2; - } + .mx_MiniAvatarUploader_cameraIcon { + height: 100%; + width: 100%; - &.mx_MiniAvatarUploader_busy::after { - background: url("$(res)/img/spinner.gif") no-repeat center; - background-size: 80%; - mask: unset; + background-color: $secondary-fg-color; + mask-position: center; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/camera.svg'); + mask-size: 16px; + z-index: 2; + } } } diff --git a/res/css/views/elements/_ProgressBar.scss b/res/css/views/elements/_ProgressBar.scss index 770978e921..c075ac74ff 100644 --- a/res/css/views/elements/_ProgressBar.scss +++ b/res/css/views/elements/_ProgressBar.scss @@ -21,7 +21,7 @@ progress.mx_ProgressBar { appearance: none; border: none; - @mixin ProgressBarBorderRadius "6px"; + @mixin ProgressBarBorderRadius 6px; @mixin ProgressBarColour $progressbar-fg-color; @mixin ProgressBarBgColour $progressbar-bg-color; ::-webkit-progress-value { diff --git a/res/css/views/elements/_Spinner.scss b/res/css/views/elements/_Spinner.scss index 01b4f23c2c..93d5e2d96c 100644 --- a/res/css/views/elements/_Spinner.scss +++ b/res/css/views/elements/_Spinner.scss @@ -26,3 +26,19 @@ limitations under the License. .mx_MatrixChat_middlePanel .mx_Spinner { height: auto; } + +@keyframes spin { + from { + transform: rotateZ(0deg); + } + to { + transform: rotateZ(360deg); + } +} + +.mx_Spinner_icon { + background-color: $primary-fg-color; + mask: url('$(res)/img/spinner.svg'); + mask-size: contain; + animation: 1.1s steps(12, end) infinite spin; +} diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index b45126acf8..c215d69ec2 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -61,9 +61,9 @@ limitations under the License. .mx_MFileBody_info { background-color: $message-body-panel-bg-color; - border-radius: 4px; - width: 270px; - padding: 8px; + border-radius: 12px; + width: 243px; // same width as a playable voice message, accounting for padding + padding: 6px 12px; color: $message-body-panel-fg-color; .mx_MFileBody_info_icon { @@ -82,7 +82,7 @@ limitations under the License. mask-position: center; mask-size: cover; mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); - background-color: $message-body-panel-fg-color; + background-color: $message-body-panel-icon-fg-color; width: 13px; height: 15px; 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/_MVoiceMessageBody.scss b/res/css/views/messages/_MVoiceMessageBody.scss new file mode 100644 index 0000000000..3dfb98f778 --- /dev/null +++ b/res/css/views/messages/_MVoiceMessageBody.scss @@ -0,0 +1,19 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_MVoiceMessageBody { + display: inline-block; // makes the playback controls magically line up +} diff --git a/res/css/views/rooms/_PinnedEventsPanel.scss b/res/css/views/messages/_MediaBody.scss similarity index 57% rename from res/css/views/rooms/_PinnedEventsPanel.scss rename to res/css/views/messages/_MediaBody.scss index 663d5bdf6e..12e441750c 100644 --- a/res/css/views/rooms/_PinnedEventsPanel.scss +++ b/res/css/views/messages/_MediaBody.scss @@ -1,5 +1,5 @@ /* -Copyright 2017 Travis Ralston +Copyright 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,24 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_PinnedEventsPanel { - border-top: 1px solid $primary-hairline-color; +// 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; } -.mx_PinnedEventsPanel_body { - max-height: 300px; - overflow-y: auto; - padding-bottom: 15px; -} - -.mx_PinnedEventsPanel_header { - margin: 0; - padding-top: 8px; - padding-bottom: 15px; -} - -.mx_PinnedEventsPanel_cancel { - margin: 12px; - float: right; - display: inline-block; -} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 3ecbef0d1f..e2fafe6c62 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -20,11 +20,12 @@ limitations under the License. visibility: hidden; cursor: pointer; display: flex; - height: 24px; + height: 32px; line-height: $font-24px; - border-radius: 4px; - background: $message-action-bar-bg-color; - top: -26px; + border-radius: 8px; + background: $primary-bg-color; + border: 1px solid $input-border-color; + top: -32px; right: 8px; user-select: none; // Ensure the action bar appears above over things, like the read marker. @@ -51,31 +52,19 @@ limitations under the License. white-space: nowrap; display: inline-block; position: relative; - border: 1px solid $message-action-bar-border-color; - margin-left: -1px; + margin: 2px; &:hover { - border-color: $message-action-bar-hover-border-color; + background: $roomlist-button-bg-color; + border-radius: 6px; z-index: 1; } - - &:first-child { - border-radius: 3px 0 0 3px; - } - - &:last-child { - border-radius: 0 3px 3px 0; - } - - &:only-child { - border-radius: 3px; - } } } - .mx_MessageActionBar_maskButton { - width: 27px; + width: 28px; + height: 28px; } .mx_MessageActionBar_maskButton::after { @@ -85,9 +74,14 @@ limitations under the License. left: 0; height: 100%; width: 100%; + mask-size: 18px; mask-repeat: no-repeat; mask-position: center; - background-color: $message-action-bar-fg-color; + background-color: $secondary-fg-color; +} + +.mx_MessageActionBar_maskButton:hover::after { + background-color: $primary-fg-color; } .mx_MessageActionBar_reactButton::after { diff --git a/res/css/views/messages/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss index 2f5695e1fb..e05065eb02 100644 --- a/res/css/views/messages/_ReactionsRow.scss +++ b/res/css/views/messages/_ReactionsRow.scss @@ -17,18 +17,56 @@ limitations under the License. .mx_ReactionsRow { margin: 6px 0; color: $primary-fg-color; + + .mx_ReactionsRow_addReactionButton { + position: relative; + display: inline-block; + visibility: hidden; // show on hover of the .mx_EventTile + width: 24px; + height: 24px; + vertical-align: middle; + margin-left: 4px; + + &::before { + content: ''; + position: absolute; + height: 100%; + width: 100%; + mask-size: 16px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $tertiary-fg-color; + mask-image: url('$(res)/img/element-icons/room/message-bar/emoji.svg'); + } + + &.mx_ReactionsRow_addReactionButton_active { + visibility: visible; // keep showing whilst the context menu is shown + } + + &:hover, &.mx_ReactionsRow_addReactionButton_active { + &::before { + background-color: $primary-fg-color; + } + } + } +} + +.mx_EventTile:hover .mx_ReactionsRow_addReactionButton { + visibility: visible; } .mx_ReactionsRow_showAll { text-decoration: none; - font-size: $font-10px; - font-weight: 600; - margin-left: 6px; - vertical-align: top; + font-size: $font-12px; + line-height: $font-20px; + margin-left: 4px; + vertical-align: middle; - &:hover, - &:link, - &:visited { - color: $accent-color; + &:link, &:visited { + color: $tertiary-fg-color; + } + + &:hover { + color: $primary-fg-color; } } diff --git a/res/css/views/messages/_ReactionsRowButton.scss b/res/css/views/messages/_ReactionsRowButton.scss index 7158ffc027..766fea2f8f 100644 --- a/res/css/views/messages/_ReactionsRowButton.scss +++ b/res/css/views/messages/_ReactionsRowButton.scss @@ -16,14 +16,15 @@ limitations under the License. .mx_ReactionsRowButton { display: inline-flex; - line-height: $font-21px; + line-height: $font-20px; margin-right: 6px; - padding: 0 6px; + padding: 1px 6px; border: 1px solid $reaction-row-button-border-color; border-radius: 10px; background-color: $reaction-row-button-bg-color; cursor: pointer; user-select: none; + vertical-align: middle; &:hover { border-color: $reaction-row-button-hover-border-color; @@ -34,6 +35,10 @@ limitations under the License. border-color: $reaction-row-button-selected-border-color; } + &.mx_AccessibleButton_disabled { + cursor: not-allowed; + } + .mx_ReactionsRowButton_content { max-width: 100px; overflow: hidden; diff --git a/res/css/views/messages/_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/_PinnedMessagesCard.scss b/res/css/views/right_panel/_PinnedMessagesCard.scss new file mode 100644 index 0000000000..785aee09ca --- /dev/null +++ b/res/css/views/right_panel/_PinnedMessagesCard.scss @@ -0,0 +1,90 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_PinnedMessagesCard { + padding-top: 0; + + .mx_BaseCard_header { + text-align: center; + margin-top: 0; + border-bottom: 1px solid $menu-border-color; + + > h2 { + font-weight: $font-semi-bold; + font-size: $font-18px; + margin: 8px 0; + } + + .mx_BaseCard_close { + margin-right: 6px; + } + } + + .mx_PinnedMessagesCard_empty { + display: flex; + height: 100%; + + > div { + height: max-content; + text-align: center; + margin: auto 40px; + + .mx_PinnedMessagesCard_MessageActionBar { + pointer-events: none; + display: flex; + height: 32px; + line-height: $font-24px; + border-radius: 8px; + background: $primary-bg-color; + border: 1px solid $input-border-color; + padding: 1px; + width: max-content; + margin: 0 auto; + box-sizing: border-box; + + .mx_MessageActionBar_maskButton { + display: inline-block; + position: relative; + } + + .mx_MessageActionBar_optionsButton { + background: $roomlist-button-bg-color; + border-radius: 6px; + z-index: 1; + + &::after { + background-color: $primary-fg-color; + } + } + } + + > h2 { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + margin-top: 24px; + margin-bottom: 20px; + } + + > span { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + } + } + } +} diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index 36882f4e8b..dc7804d072 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -36,6 +36,7 @@ limitations under the License. -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; + white-space: pre-wrap; } .mx_RoomSummaryCard_avatar { diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index 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 5d1dd04383..55f73c0315 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -16,6 +16,7 @@ limitations under the License. */ $left-gutter: 64px; +$hover-select-border: 4px; .mx_EventTile { max-width: 100%; @@ -85,12 +86,11 @@ $left-gutter: 64px; } .mx_EventTile_isEditing .mx_MessageTimestamp { - visibility: hidden !important; + visibility: hidden; } .mx_EventTile .mx_MessageTimestamp { display: block; - visibility: hidden; white-space: nowrap; left: 0px; text-align: center; @@ -104,7 +104,7 @@ $left-gutter: 64px; .mx_EventTile_line, .mx_EventTile_reply { position: relative; padding-left: $left-gutter; - border-radius: 4px; + border-radius: 8px; } .mx_RoomView_timeline_rr_enabled, @@ -142,27 +142,8 @@ $left-gutter: 64px; line-height: 57px !important; } -.mx_MessagePanel_alwaysShowTimestamps .mx_MessageTimestamp { - visibility: visible; -} - .mx_EventTile_selected > div > a > .mx_MessageTimestamp { - left: 3px; - width: auto; -} - -// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) -// The first set is to handle the 'group layout' (default) and the second for the IRC layout -.mx_EventTile_last > div > a > .mx_MessageTimestamp, -.mx_EventTile:hover > div > a > .mx_MessageTimestamp, -.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp, -.mx_EventTile.focus-visible:focus-within > div > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile_last > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile:hover > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_ReplyThread .mx_EventTile > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile.mx_EventTile_actionBarFocused > a > .mx_MessageTimestamp, -.mx_IRCLayout .mx_EventTile.focus-visible:focus-within > a > .mx_MessageTimestamp { - visibility: visible; + left: calc(-$hover-select-border); } .mx_EventTile:hover .mx_MessageActionBar, @@ -177,7 +158,7 @@ $left-gutter: 64px; */ .mx_EventTile_selected > .mx_EventTile_line { border-left: $accent-color 4px solid; - padding-left: 60px; + padding-left: calc($left-gutter - $hover-select-border); background-color: $event-selected-color; } @@ -190,8 +171,12 @@ $left-gutter: 64px; } } +.mx_EventTile_info .mx_EventTile_line { + padding-left: calc($left-gutter + 18px); +} + .mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { - padding-left: 78px; + padding-left: calc($left-gutter + 18px - $hover-select-border); } .mx_EventTile:hover .mx_EventTile_line, @@ -280,6 +265,7 @@ $left-gutter: 64px; height: $font-14px; width: $font-14px; + will-change: left, top; transition: left var(--transition-short) ease-out, top var(--transition-standard) ease-out; @@ -359,7 +345,7 @@ $left-gutter: 64px; mask-image: url('$(res)/img/e2e/normal.svg'); mask-repeat: no-repeat; mask-position: center; - mask-size: 90%; + mask-size: 80%; } } @@ -426,7 +412,7 @@ $left-gutter: 64px; .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line { - padding-left: 60px; + padding-left: calc($left-gutter - $hover-select-border); } .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line { @@ -444,7 +430,7 @@ $left-gutter: 64px; .mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line { - padding-left: 78px; + padding-left: calc($left-gutter + 18px - $hover-select-border); } /* End to end encryption stuff */ @@ -456,7 +442,7 @@ $left-gutter: 64px; .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp, .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp, .mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp { - width: $MessageTimestamp_width_hover; + left: calc(-$hover-select-border); } // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) @@ -491,8 +477,7 @@ $left-gutter: 64px; pre, code { font-family: $monospace-font-family !important; - // deliberate constants as we're behind an invert filter - color: #333; + background-color: $header-panel-bg-color; } pre { @@ -502,11 +487,6 @@ $left-gutter: 64px; overflow-x: overlay; overflow-y: visible; } - - code { - // deliberate constants as we're behind an invert filter - background-color: #f8f8f8; - } } .mx_EventTile_lineNumbers { diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss index 818509785b..ddee81a914 100644 --- a/res/css/views/rooms/_GroupLayout.scss +++ b/res/css/views/rooms/_GroupLayout.scss @@ -24,10 +24,6 @@ $left-gutter: 64px; margin-left: $left-gutter; } - > .mx_EventTile_line { - padding-left: $left-gutter; - } - > .mx_EventTile_avatar { position: absolute; } @@ -43,10 +39,6 @@ $left-gutter: 64px; line-height: $font-22px; } } - - .mx_EventTile_info .mx_EventTile_line { - padding-left: calc($left-gutter + 18px); - } } /* Compact layout overrides */ diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index b6b901757c..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; @@ -115,8 +104,7 @@ $irc-line-height: $font-18px; .mx_EventTile_line { .mx_EventTile_e2eIcon, .mx_TextualEvent, - .mx_MTextBody, - .mx_ReplyThread_wrapper_empty { + .mx_MTextBody { display: inline-block; } } @@ -174,30 +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; - > span { - display: flex; + > .mx_SenderProfile_displayName { + width: 100%; + text-align: end; + overflow: hidden; + text-overflow: ellipsis; + } - > .mx_SenderProfile_name { - 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 { @@ -205,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/_JumpToBottomButton.scss b/res/css/views/rooms/_JumpToBottomButton.scss index 6cb3b6bce9..a8dc2ce11c 100644 --- a/res/css/views/rooms/_JumpToBottomButton.scss +++ b/res/css/views/rooms/_JumpToBottomButton.scss @@ -52,6 +52,7 @@ limitations under the License. .mx_JumpToBottomButton_scrollDown { position: relative; + display: block; height: 38px; border-radius: 19px; box-sizing: border-box; diff --git a/res/css/views/rooms/_LinkPreviewGroup.scss b/res/css/views/rooms/_LinkPreviewGroup.scss new file mode 100644 index 0000000000..ed341904fd --- /dev/null +++ b/res/css/views/rooms/_LinkPreviewGroup.scss @@ -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. +*/ + +.mx_LinkPreviewGroup { + .mx_LinkPreviewGroup_hide { + cursor: pointer; + width: 18px; + height: 18px; + + img { + flex: 0 0 40px; + visibility: hidden; + } + } + + &:hover .mx_LinkPreviewGroup_hide img, + .mx_LinkPreviewGroup_hide.focus-visible:focus img { + visibility: visible; + } + + > .mx_AccessibleButton { + color: $accent-color; + text-align: center; + } +} diff --git a/res/css/views/rooms/_LinkPreviewWidget.scss b/res/css/views/rooms/_LinkPreviewWidget.scss index 022cf3ed28..0832337ecd 100644 --- a/res/css/views/rooms/_LinkPreviewWidget.scss +++ b/res/css/views/rooms/_LinkPreviewWidget.scss @@ -33,38 +33,29 @@ limitations under the License. .mx_LinkPreviewWidget_caption { margin-left: 15px; flex: 1 1 auto; + overflow-x: hidden; // cause it to wrap rather than clip } .mx_LinkPreviewWidget_title { - display: inline; font-weight: bold; white-space: normal; -} + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; -.mx_LinkPreviewWidget_siteName { - display: inline; + .mx_LinkPreviewWidget_siteName { + font-weight: normal; + } } .mx_LinkPreviewWidget_description { margin-top: 8px; white-space: normal; word-wrap: break-word; -} - -.mx_LinkPreviewWidget_cancel { - cursor: pointer; - width: 18px; - height: 18px; - - img { - flex: 0 0 40px; - visibility: hidden; - } -} - -.mx_LinkPreviewWidget:hover .mx_LinkPreviewWidget_cancel img, -.mx_LinkPreviewWidget_cancel.focus-visible:focus img { - visibility: visible; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; } .mx_MatrixChat_useCompactLayout { diff --git a/res/css/views/rooms/_NewRoomIntro.scss b/res/css/views/rooms/_NewRoomIntro.scss index 9c2a428cb3..e0cccfa885 100644 --- a/res/css/views/rooms/_NewRoomIntro.scss +++ b/res/css/views/rooms/_NewRoomIntro.scss @@ -18,8 +18,8 @@ limitations under the License. margin: 40px 0 48px 64px; .mx_MiniAvatarUploader_hasAvatar:not(.mx_MiniAvatarUploader_busy):not(:hover) { - &::before, &::after { - content: unset; + .mx_MiniAvatarUploader_indicator { + display: none; } } diff --git a/res/css/views/rooms/_PinnedEventTile.scss b/res/css/views/rooms/_PinnedEventTile.scss index 030a76674a..15b3c16faa 100644 --- a/res/css/views/rooms/_PinnedEventTile.scss +++ b/res/css/views/rooms/_PinnedEventTile.scss @@ -16,62 +16,91 @@ limitations under the License. .mx_PinnedEventTile { min-height: 40px; - margin-bottom: 5px; width: 100%; - border-radius: 5px; // for the hover -} + padding: 0 4px 12px; -.mx_PinnedEventTile:hover { - background-color: $event-selected-color; -} + display: grid; + grid-template-areas: + "avatar name remove" + "content content content" + "footer footer footer"; + grid-template-rows: max-content auto max-content; + grid-template-columns: 24px auto 24px; + grid-row-gap: 12px; + grid-column-gap: 8px; -.mx_PinnedEventTile .mx_PinnedEventTile_sender, -.mx_PinnedEventTile .mx_PinnedEventTile_timestamp { - color: #868686; - font-size: 0.8em; - vertical-align: top; - display: inline-block; - padding-bottom: 3px; -} + & + .mx_PinnedEventTile { + padding: 12px 4px; + border-top: 1px solid $menu-border-color; + } -.mx_PinnedEventTile .mx_PinnedEventTile_timestamp { - padding-left: 15px; - display: none; -} + .mx_PinnedEventTile_senderAvatar { + grid-area: avatar; + } -.mx_PinnedEventTile .mx_PinnedEventTile_senderAvatar .mx_BaseAvatar { - float: left; - margin-right: 10px; -} + .mx_PinnedEventTile_sender { + grid-area: name; + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-24px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } -.mx_PinnedEventTile_actions { - float: right; - margin-right: 10px; - display: none; -} + .mx_PinnedEventTile_unpinButton { + visibility: hidden; + grid-area: remove; + position: relative; + width: 24px; + height: 24px; + border-radius: 8px; -.mx_PinnedEventTile:hover .mx_PinnedEventTile_timestamp { - display: inline-block; -} + &:hover { + background-color: $roomheader-addroom-bg-color; + } -.mx_PinnedEventTile:hover .mx_PinnedEventTile_actions { - display: block; -} + &::before { + content: ""; + position: absolute; + //top: 0; + //left: 0; + height: inherit; + width: inherit; + background: $secondary-fg-color; + mask-position: center; + mask-size: 8px; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/image-view/close.svg'); + } + } -.mx_PinnedEventTile_unpinButton { - display: inline-block; - cursor: pointer; - margin-left: 10px; -} + .mx_PinnedEventTile_message { + grid-area: content; + } -.mx_PinnedEventTile_gotoButton { - display: inline-block; - font-size: 0.7em; // Smaller text to avoid conflicting with the layout -} + .mx_PinnedEventTile_footer { + grid-area: footer; + font-size: 10px; + line-height: 12px; -.mx_PinnedEventTile_message { - margin-left: 50px; - position: relative; - top: 0; - left: 0; + .mx_PinnedEventTile_timestamp { + font-size: inherit; + line-height: inherit; + color: $secondary-fg-color; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + margin-left: 12px; + font-size: inherit; + line-height: inherit; + } + } + + &:hover { + .mx_PinnedEventTile_unpinButton { + visibility: visible; + } + } } diff --git a/res/css/views/rooms/_RoomBreadcrumbs.scss b/res/css/views/rooms/_RoomBreadcrumbs.scss index 6512797401..152b0a45cd 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs.scss @@ -32,14 +32,14 @@ limitations under the License. // first triggering the enter state with the newest breadcrumb off screen (-40px) then // sliding it into view. &.mx_RoomBreadcrumbs-enter { - margin-left: -40px; // 32px for the avatar, 8px for the margin + transform: translateX(-40px); // 32px for the avatar, 8px for the margin } &.mx_RoomBreadcrumbs-enter-active { - margin-left: 0; + transform: translateX(0); // Timing function is as-requested by design. // NOTE: The transition time MUST match the value passed to CSSTransition! - transition: margin-left 640ms cubic-bezier(0.66, 0.02, 0.36, 1); + transition: transform 640ms cubic-bezier(0.66, 0.02, 0.36, 1); } .mx_RoomBreadcrumbs_placeholder { diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 387d1588a3..4142b0a2ef 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -277,24 +277,6 @@ limitations under the License. margin-top: 18px; } -.mx_RoomHeader_pinnedButton::before { - mask-image: url('$(res)/img/element-icons/room/pin.svg'); -} - -.mx_RoomHeader_pinsIndicator { - position: absolute; - right: 0; - bottom: 4px; - width: 8px; - height: 8px; - border-radius: 8px; - background-color: $pinned-color; -} - -.mx_RoomHeader_pinsIndicatorUnread { - background-color: $pinned-unread-color; -} - @media only screen and (max-width: 480px) { .mx_RoomHeader_wrapper { padding: 0; diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 9d52e40819..146b3edf71 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -61,8 +61,8 @@ limitations under the License. &.mx_RoomSublist_headerContainer_sticky { position: fixed; height: 32px; // to match the header container - // width set by JS - width: calc(100% - 22px); + // width set by JS because of a compat issue between Firefox and Chrome + width: calc(100% - 15px); } // We don't have a top style because the top is dependent on the room list header's @@ -98,7 +98,7 @@ limitations under the License. position: relative; width: 24px; height: 24px; - border-radius: 32px; + border-radius: 8px; &::before { content: ''; @@ -114,6 +114,11 @@ limitations under the License. } } + .mx_RoomSublist_auxButton:hover, + .mx_RoomSublist_menuButton:hover { + background: $roomlist-button-bg-color; + } + // Hide the menu button by default .mx_RoomSublist_menuButton { visibility: hidden; @@ -193,6 +198,7 @@ limitations under the License. // as the box model should be top aligned. Happens in both FF and Chromium display: flex; flex-direction: column; + align-self: stretch; mask-image: linear-gradient(0deg, transparent, black 4px); } diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index 72d29dfd4c..03146e0325 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -19,6 +19,10 @@ limitations under the License. margin-bottom: 4px; padding: 4px; + contain: content; // Not strict as it will break when resizing a sublist vertically + height: 40px; + box-sizing: border-box; + // The tile is also a flexbox row itself display: flex; diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss index e99e1a00e1..5501ab343e 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.scss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -36,26 +36,28 @@ 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: 7px; // distance from left edge of waveform container (container has some margin too) - background-color: $muted-fg-color; + margin-right: 8px; // distance from left edge of waveform container (container has some margin too) + background-color: $voice-record-icon-color; mask-repeat: no-repeat; mask-size: contain; mask-image: url('$(res)/img/element-icons/trashcan.svg'); } -.mx_VoiceMessagePrimaryContainer { +.mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer { // Note: remaining class properties are in the PlayerContainer CSS. margin: 6px; // force the composer area to put a gutter around us - margin-right: 12px; // isolate from stop button + margin-right: 12px; // isolate from stop/send button position: relative; // important for the live circle &.mx_VoiceRecordComposerTile_recording { - padding-left: 16px; // +10px for the live circle, +6px for regular padding + // We are putting the circle in this padding, so we need +10px from the regular + // padding on the left side. + padding-left: 22px; &::before { animation: recording-pulse 2s infinite; @@ -65,8 +67,8 @@ limitations under the License. width: 10px; height: 10px; position: absolute; - left: 8px; - top: 16px; // vertically center + left: 12px; // 12px from the left edge for container padding + top: 18px; // vertically center (middle align with clock) border-radius: 10px; } } diff --git a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss index 109edfff81..0f879d209e 100644 --- a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss @@ -22,3 +22,34 @@ limitations under the License. .mx_HelpUserSettingsTab span.mx_AccessibleButton { word-break: break-word; } + +.mx_HelpUserSettingsTab code { + word-break: break-all; + user-select: all; +} + +.mx_HelpUserSettingsTab_accessToken { + display: flex; + justify-content: space-between; + border-radius: 5px; + border: solid 1px $light-fg-color; + margin-bottom: 10px; + margin-top: 10px; + padding: 10px; +} + +.mx_HelpUserSettingsTab_accessToken_copy { + flex-shrink: 0; + cursor: pointer; + margin-left: 20px; + display: inherit; +} + +.mx_HelpUserSettingsTab_accessToken_copy > div { + mask-image: url($copy-button-url); + background-color: $message-action-bar-fg-color; + margin-left: 5px; + width: 20px; + height: 20px; + background-repeat: no-repeat; +} diff --git a/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss b/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss new file mode 100644 index 0000000000..540db48d65 --- /dev/null +++ b/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss @@ -0,0 +1,25 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_LabsUserSettingsTab { + .mx_SettingsTab_section { + margin-top: 32px; + + .mx_SettingsFlag { + margin-right: 0; // remove right margin to align with beta cards + } + } +} diff --git a/res/css/views/spaces/_SpaceBasicSettings.scss b/res/css/views/spaces/_SpaceBasicSettings.scss 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/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss index ef3fea351b..88b9d8f693 100644 --- a/res/css/views/spaces/_SpaceCreateMenu.scss +++ b/res/css/views/spaces/_SpaceCreateMenu.scss @@ -29,6 +29,7 @@ $spacePanelWidth: 71px; width: 480px; box-sizing: border-box; background-color: $primary-bg-color; + position: relative; > div { > h2 { @@ -44,6 +45,13 @@ $spacePanelWidth: 71px; } } + // XXX remove this when spaces leaves Beta + .mx_BetaCard_betaPill { + position: absolute; + top: 24px; + right: 24px; + } + .mx_SpaceCreateMenuType { @mixin SpacePillButton; } @@ -59,7 +67,7 @@ $spacePanelWidth: 71px; width: 28px; height: 28px; position: relative; - background-color: $theme-button-bg-color; + background-color: $roomlist-button-bg-color; border-radius: 14px; margin-bottom: 12px; @@ -70,7 +78,7 @@ $spacePanelWidth: 71px; width: 28px; top: 0; left: 0; - background-color: $muted-fg-color; + background-color: $tertiary-fg-color; transform: rotate(90deg); mask-repeat: no-repeat; mask-position: 2px 3px; diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss index 8262075559..0c09070334 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_CallContainer.scss @@ -30,8 +30,8 @@ limitations under the License. pointer-events: initial; // restore pointer events so the user can leave/interact cursor: pointer; - .mx_CallView_video { - width: 350px; + .mx_VideoFeed_remote.mx_VideoFeed_voice { + min-height: 150px; } .mx_VideoFeed_local { @@ -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/_CallPreview.scss b/res/css/views/voip/_CallPreview.scss new file mode 100644 index 0000000000..92348fb465 --- /dev/null +++ b/res/css/views/voip/_CallPreview.scss @@ -0,0 +1,21 @@ +/* +Copyright 2021 Šimon Brandner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CallPreview { + position: fixed; + left: 0; + top: 0; +} diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 7292e325df..205d431752 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -39,7 +39,6 @@ limitations under the License. .mx_CallView_pip { width: 320px; padding-bottom: 8px; - margin-top: 10px; background-color: $voipcall-plinth-color; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); border-radius: 8px; @@ -65,14 +64,17 @@ limitations under the License. } } -.mx_CallView_voice { +.mx_CallView_content { position: relative; display: flex; - flex-direction: column; + border-radius: 8px; +} + +.mx_CallView_voice { align-items: center; justify-content: center; + flex-direction: column; background-color: $inverted-bg-color; - border-radius: 8px; } .mx_CallView_voice_avatarsContainer { @@ -109,9 +111,7 @@ limitations under the License. .mx_CallView_video { width: 100%; height: 100%; - position: relative; z-index: 30; - border-radius: 8px; overflow: hidden; } 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/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 8ead8bba3e..4a3fbdf597 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -14,21 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_VideoFeed_voice { + background-color: $inverted-bg-color; +} + + .mx_VideoFeed_remote { width: 100%; height: 100%; - background-color: #000; - z-index: 50; + display: flex; + justify-content: center; + align-items: center; + + &.mx_VideoFeed_video { + background-color: #000; + } } .mx_VideoFeed_local { - width: 25%; - height: 25%; + max-width: 25%; + max-height: 25%; position: absolute; right: 10px; top: 10px; z-index: 100; border-radius: 4px; + + &.mx_VideoFeed_video { + background-color: transparent; + } } .mx_VideoFeed_mirror { diff --git a/res/img/betas/spaces.png b/res/img/betas/spaces.png new file mode 100644 index 0000000000..f4cfa90b4e Binary files /dev/null and b/res/img/betas/spaces.png differ diff --git a/res/img/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/room/composer/emoji.svg b/res/img/element-icons/room/composer/emoji.svg index 9613d9edd9..b02cb69364 100644 --- a/res/img/element-icons/room/composer/emoji.svg +++ b/res/img/element-icons/room/composer/emoji.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/res/img/element-icons/room/message-bar/emoji.svg b/res/img/element-icons/room/message-bar/emoji.svg index 697f656b8a..07fee5b834 100644 --- a/res/img/element-icons/room/message-bar/emoji.svg +++ b/res/img/element-icons/room/message-bar/emoji.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/res/img/element-icons/room/pin.svg b/res/img/element-icons/room/pin.svg index 16941b329b..2448fc61c5 100644 --- a/res/img/element-icons/room/pin.svg +++ b/res/img/element-icons/room/pin.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/res/img/element-icons/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/spinner.gif b/res/img/spinner.gif deleted file mode 100644 index ab4871214b..0000000000 Binary files a/res/img/spinner.gif and /dev/null differ diff --git a/res/img/spinner.svg b/res/img/spinner.svg index 08965e982e..c3680f19d2 100644 --- a/res/img/spinner.svg +++ b/res/img/spinner.svg @@ -1,141 +1,96 @@ - - - start - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/res/img/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 f9983dce9e..57cbc7efa9 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -9,6 +9,7 @@ $header-panel-text-primary-color: #B9BEC6; $header-panel-text-secondary-color: #c8c8cd; $text-primary-color: #ffffff; $text-secondary-color: #B9BEC6; +$quaternary-fg-color: #6F7882; $search-bg-color: #181b21; $search-placeholder-color: #61708b; $room-highlight-color: #343a46; @@ -117,6 +118,7 @@ $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; @@ -205,9 +207,16 @@ $breadcrumb-placeholder-bg-color: #272c35; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #21262c82; -$message-body-panel-icon-bg-color: #8e99a4; -$message-body-panel-fg-color: $primary-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #394049; // "Dark Tile" +$message-body-panel-icon-fg-color: #21262C; // "Separator" +$message-body-panel-icon-bg-color: $tertiary-fg-color; + +$voice-record-stop-border-color: $quaternary-fg-color; +$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; +$voice-record-icon-color: $quaternary-fg-color; +$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; +$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; @@ -263,24 +272,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28); } // markdown overrides: -.mx_EventTile_content .markdown-body pre:hover { - border-color: #808080 !important; // inverted due to rules below - scrollbar-color: rgba(0, 0, 0, 0.2) transparent; // copied from light theme due to inversion below - // the code above works only in Firefox, this is for other browsers - // see https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color - &::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.2); // copied from light theme due to inversion below - } -} .mx_EventTile_content .markdown-body { - pre, code { - filter: invert(1); - } - - pre code { - filter: none; - } - table { tr { background-color: #000000; @@ -290,18 +282,9 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28); background-color: #080808; } } - - blockquote { - color: #919191; - } } -// diff highlight colors -// intentionally swapped to avoid inversion -.hljs-addition { - background: #fdd; -} - -.hljs-deletion { - background: #dfd; +// highlight.js overrides +.hljs-tag { + color: inherit; // Without this they'd be weirdly blue which doesn't match the theme } diff --git a/res/themes/dark/css/dark.scss b/res/themes/dark/css/dark.scss index f9695018e4..600cfd528a 100644 --- a/res/themes/dark/css/dark.scss +++ b/res/themes/dark/css/dark.scss @@ -9,3 +9,4 @@ @import "_dark.scss"; @import "../../light/css/_mods.scss"; @import "../../../../res/css/_components.scss"; +@import url("highlight.js/styles/atom-one-dark.css"); diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 194e89e548..555ef4f66c 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -20,6 +20,9 @@ $tertiary-fg-color: $primary-fg-color; $primary-bg-color: $bg-color; $muted-fg-color: $header-panel-text-primary-color; +// Legacy theme backports +$quaternary-fg-color: #6F7882; + // used for dialog box text $light-fg-color: $header-panel-text-secondary-color; @@ -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; @@ -200,9 +205,17 @@ $breadcrumb-placeholder-bg-color: #272c35; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #21262c82; -$message-body-panel-icon-bg-color: #8e99a4; -$message-body-panel-fg-color: $primary-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #394049; +$message-body-panel-icon-fg-color: $primary-bg-color; +$message-body-panel-icon-bg-color: $secondary-fg-color; + +// See non-legacy dark for variable information +$voice-record-stop-border-color: #6F7882; +$voice-record-waveform-incomplete-fg-color: #6F7882; +$voice-record-icon-color: #6F7882; +$voice-playback-button-bg-color: $tertiary-fg-color; +$voice-playback-button-fg-color: #21262C; // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; @@ -236,7 +249,7 @@ $composer-shadow-color: tranparent; @define-mixin mx_DialogButton_secondary { // flip colours for the secondary ones font-weight: 600; - border: 1px solid $accent-color ! important; + border: 1px solid $accent-color !important; color: $accent-color; background-color: $button-secondary-bg-color; } @@ -254,18 +267,7 @@ $composer-shadow-color: tranparent; } // markdown overrides: -.mx_EventTile_content .markdown-body pre:hover { - border-color: #808080 !important; // inverted due to rules below -} .mx_EventTile_content .markdown-body { - pre, code { - filter: invert(1); - } - - pre code { - filter: none; - } - table { tr { background-color: #000000; @@ -277,12 +279,7 @@ $composer-shadow-color: tranparent; } } -// diff highlight colors -// intentionally swapped to avoid inversion -.hljs-addition { - background: #fdd; -} - -.hljs-deletion { - background: #dfd; +// highlight.js overrides: +.hljs-tag { + color: inherit; // Without this they'd be weirdly blue which doesn't match the theme } diff --git a/res/themes/legacy-dark/css/legacy-dark.scss b/res/themes/legacy-dark/css/legacy-dark.scss index 2a4d432d26..840794f7c0 100644 --- a/res/themes/legacy-dark/css/legacy-dark.scss +++ b/res/themes/legacy-dark/css/legacy-dark.scss @@ -4,3 +4,4 @@ @import "../../legacy-light/css/_legacy-light.scss"; @import "_legacy-dark.scss"; @import "../../../../res/css/_components.scss"; +@import url("highlight.js/styles/atom-one-dark.css"); diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index d7352e5684..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; @@ -191,14 +196,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%) $groupFilterPanel-divider-color: $roomlist-header-color; -// See non-legacy _light for variable information -$voice-record-stop-border-color: #E3E8F0; -$voice-record-stop-symbol-color: #ff4b55; -$voice-record-waveform-bg-color: #E3E8F0; -$voice-record-waveform-fg-color: $muted-fg-color; -$voice-record-waveform-incomplete-fg-color: #C1C6CD; -$voice-record-live-circle-color: #ff4b55; - $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #fff; @@ -331,9 +328,19 @@ $breadcrumb-placeholder-bg-color: #e8eef5; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #e3e8f082; -$message-body-panel-icon-bg-color: #ffffff; -$message-body-panel-fg-color: $muted-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #E3E8F0; +$message-body-panel-icon-fg-color: $secondary-fg-color; +$message-body-panel-icon-bg-color: $primary-bg-color; + +// See non-legacy _light for variable information +$voice-record-stop-symbol-color: #ff4b55; +$voice-record-live-circle-color: #ff4b55; +$voice-record-stop-border-color: #E3E8F0; +$voice-record-waveform-incomplete-fg-color: #C1C6CD; +$voice-record-icon-color: $tertiary-fg-color; +$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; +$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; diff --git a/res/themes/legacy-light/css/legacy-light.scss b/res/themes/legacy-light/css/legacy-light.scss index e39a1765f3..347d240fc6 100644 --- a/res/themes/legacy-light/css/legacy-light.scss +++ b/res/themes/legacy-light/css/legacy-light.scss @@ -3,3 +3,4 @@ @import "_fonts.scss"; @import "_legacy-light.scss"; @import "../../../../res/css/_components.scss"; +@import url("highlight.js/styles/atom-one-light.css"); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 20ccc2ee41..7e958c2af6 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -21,6 +21,7 @@ $notice-primary-bg-color: rgba(255, 75, 85, 0.16); $primary-fg-color: #2e2f32; $secondary-fg-color: #737D8C; $tertiary-fg-color: #8D99A5; +$quaternary-fg-color: #C1C6CD; $header-panel-bg-color: #f3f8fd; // typical text (dark-on-white in light skin) @@ -172,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; @@ -182,13 +185,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%) $groupFilterPanel-divider-color: $roomlist-header-color; -$voice-record-stop-border-color: #E3E8F0; -$voice-record-stop-symbol-color: #ff4b55; // $warning-color, but without letting people change it in themes -$voice-record-waveform-bg-color: #E3E8F0; -$voice-record-waveform-fg-color: $muted-fg-color; -$voice-record-waveform-incomplete-fg-color: #C1C6CD; -$voice-record-live-circle-color: #ff4b55; // $warning-color, but without letting people change it in themes - $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #FFF; @@ -328,9 +324,21 @@ $breadcrumb-placeholder-bg-color: #e8eef5; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #e3e8f082; -$message-body-panel-icon-bg-color: #ffffff; -$message-body-panel-fg-color: $muted-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #E3E8F0; // "Separator" +$message-body-panel-icon-fg-color: $secondary-fg-color; +$message-body-panel-icon-bg-color: $primary-bg-color; + +// These two don't change between themes. They are the $warning-color, but we don't +// want custom themes to affect them by accident. +$voice-record-stop-symbol-color: #ff4b55; +$voice-record-live-circle-color: #ff4b55; + +$voice-record-stop-border-color: #E3E8F0; // "Separator" +$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; +$voice-record-icon-color: $tertiary-fg-color; +$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; +$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; diff --git a/res/themes/light/css/light.scss b/res/themes/light/css/light.scss index f31ce5c139..4e912bc756 100644 --- a/res/themes/light/css/light.scss +++ b/res/themes/light/css/light.scss @@ -4,3 +4,4 @@ @import "_light.scss"; @import "_mods.scss"; @import "../../../../res/css/_components.scss"; +@import url("highlight.js/styles/atom-one-light.css"); diff --git a/scripts/ci/Dockerfile b/scripts/ci/Dockerfile index 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/install-deps.sh b/scripts/ci/install-deps.sh index bbda74ef9d..fcbf6b1198 100755 --- a/scripts/ci/install-deps.sh +++ b/scripts/ci/install-deps.sh @@ -6,8 +6,8 @@ scripts/fetchdep.sh matrix-org matrix-js-sdk pushd matrix-js-sdk yarn link -yarn install $@ +yarn install --pure-lockfile $@ popd yarn link matrix-js-sdk -yarn install $@ +yarn install --pure-lockfile $@ diff --git a/scripts/ci/layered.sh b/scripts/ci/layered.sh index 039f90c7df..2e163456fe 100755 --- a/scripts/ci/layered.sh +++ b/scripts/ci/layered.sh @@ -13,13 +13,13 @@ scripts/fetchdep.sh matrix-org matrix-js-sdk pushd matrix-js-sdk yarn link -yarn install +yarn install --pure-lockfile popd # Now set up the react-sdk yarn link matrix-js-sdk yarn link -yarn install +yarn install --pure-lockfile yarn reskindex # Finally, set up element-web @@ -27,6 +27,6 @@ scripts/fetchdep.sh vector-im element-web pushd element-web yarn link matrix-js-sdk yarn link matrix-react-sdk -yarn install +yarn install --pure-lockfile yarn build:res popd 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 0ab26ef943..7192eb81cc 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -15,7 +15,10 @@ limitations under the License. */ import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first -import * as ModernizrStatic from "modernizr"; +// Load types for the WG CSS Font Loading APIs https://github.com/Microsoft/TypeScript/issues/13569 +import "@types/css-font-loading-module"; +import "@types/modernizr"; + import ContentMessages from "../ContentMessages"; import { IMatrixClientPeg } from "../MatrixClientPeg"; import ToastStore from "../stores/ToastStore"; @@ -23,35 +26,41 @@ import DeviceListener from "../DeviceListener"; import { RoomListStoreClass } from "../stores/room-list/RoomListStore"; import { PlatformPeg } from "../PlatformPeg"; import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore"; -import {IntegrationManagers} from "../integrations/IntegrationManagers"; -import {ModalManager} from "../Modal"; +import { IntegrationManagers } from "../integrations/IntegrationManagers"; +import { ModalManager } from "../Modal"; import SettingsStore from "../settings/SettingsStore"; -import {ActiveRoomObserver} from "../ActiveRoomObserver"; -import {Notifier} from "../Notifier"; -import type {Renderer} from "react-dom"; +import { ActiveRoomObserver } from "../ActiveRoomObserver"; +import { Notifier } from "../Notifier"; +import type { Renderer } from "react-dom"; import RightPanelStore from "../stores/RightPanelStore"; import WidgetStore from "../stores/WidgetStore"; import CallHandler from "../CallHandler"; -import {Analytics} from "../Analytics"; +import { Analytics } from "../Analytics"; import CountlyAnalytics from "../CountlyAnalytics"; import UserActivity from "../UserActivity"; -import {ModalWidgetStore} from "../stores/ModalWidgetStore"; +import { ModalWidgetStore } from "../stores/ModalWidgetStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import VoipUserMapper from "../VoipUserMapper"; -import {SpaceStoreClass} from "../stores/SpaceStore"; +import { 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 { - Modernizr: ModernizrStatic; matrixChat: ReturnType; mxMatrixClientPeg: IMatrixClientPeg; Olm: { init: () => Promise; }; + // Needed for Safari, unknown to TypeScript + webkitAudioContext: typeof AudioContext; + mxContentMessages: ContentMessages; mxToastStore: ToastStore; mxDeviceListener: DeviceListener; @@ -76,6 +85,11 @@ declare global { mxVoiceRecordingStore: VoiceRecordingStore; mxTypingStore: TypingStore; mxEventIndexPeg: EventIndexPeg; + mxPerformanceMonitor: PerformanceMonitor; + mxPerformanceEntryNames: any; + mxUIStore: UIStore; + mxSetupEncryptionStore?: SetupEncryptionStore; + mxRoomScrollStateStore?: RoomScrollStateStore; } interface Document { @@ -103,21 +117,30 @@ declare global { usageDetails?: {[key: string]: number}; } - export interface ISettledFulfilled { - status: "fulfilled"; - value: T; - } - export interface ISettledRejected { - status: "rejected"; - reason: any; - } - - interface PromiseConstructor { - allSettled(promises: Promise[]): Promise | ISettledRejected>>; - } - interface HTMLAudioElement { type?: string; + // sinkId & setSinkId are experimental and typescript doesn't know about them + sinkId: string; + setSinkId(outputId: string); + } + + interface HTMLVideoElement { + type?: string; + // sinkId & setSinkId are experimental and typescript doesn't know about them + sinkId: string; + setSinkId(outputId: string); + } + + // Add Chrome-specific `instant` ScrollBehaviour + type _ScrollBehavior = ScrollBehavior | "instant"; + + interface _ScrollOptions { + behavior?: _ScrollBehavior; + } + + interface _ScrollIntoViewOptions extends _ScrollOptions { + block?: ScrollLogicalPosition; + inline?: ScrollLogicalPosition; } interface Element { @@ -125,6 +148,7 @@ declare global { // 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..ce8287de56 100644 --- a/src/Analytics.tsx +++ b/src/Analytics.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; -import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler'; +import { getCurrentLanguage, _t, _td, IVariables } from './languageHandler'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; import Modal from './Modal'; @@ -390,6 +390,7 @@ export class Analytics { { expl: _td('Your device resolution'), value: resolution }, ]; + // FIXME: Using an import will result in test failures const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, { title: _t('Analytics'), diff --git a/src/AsyncWrapper.js b/src/AsyncWrapper.tsx similarity index 64% rename from src/AsyncWrapper.js rename to src/AsyncWrapper.tsx index 359828b312..ef8924add8 100644 --- a/src/AsyncWrapper.js +++ b/src/AsyncWrapper.tsx @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,52 +14,61 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ComponentType } from "react"; + import * as sdk from './index'; -import PropTypes from 'prop-types'; import { _t } from './languageHandler'; +import { IDialogProps } from "./components/views/dialogs/IDialogProps"; + +type AsyncImport = { default: T }; + +interface IProps extends IDialogProps { + // A promise which resolves with the real component + prom: Promise>; +} + +interface IState { + component?: ComponentType; + error?: Error; +} /** * Wrap an asynchronous loader function with a react component which shows a * spinner until the real component loads. */ -export default class AsyncWrapper extends React.Component { - static propTypes = { - /** A promise which resolves with the real component - */ - prom: PropTypes.object.isRequired, - }; +export default class AsyncWrapper extends React.Component { + private unmounted = false; - state = { + public state = { component: null, error: null, }; componentDidMount() { - this._unmounted = false; // XXX: temporary logging to try to diagnose // https://github.com/vector-im/element-web/issues/3148 console.log('Starting load of AsyncWrapper for modal'); this.props.prom.then((result) => { - if (this._unmounted) { - return; - } + if (this.unmounted) return; + // Take the 'default' member if it's there, then we support // passing in just an import()ed module, since ES6 async import // always returns a module *namespace*. - const component = result.default ? result.default : result; - this.setState({component}); + const component = (result as AsyncImport).default + ? (result as AsyncImport).default + : result as ComponentType; + this.setState({ component }); }).catch((e) => { console.warn('AsyncWrapper promise failed', e); - this.setState({error: e}); + this.setState({ error: e }); }); } componentWillUnmount() { - this._unmounted = true; + this.unmounted = true; } - _onWrapperCancelClick = () => { + private onWrapperCancelClick = () => { this.props.onFinished(false); }; @@ -69,14 +77,13 @@ export default class AsyncWrapper extends React.Component { const Component = this.state.component; return ; } else if (this.state.error) { + // FIXME: Using an import will result in test failures const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return - {_t("Unable to load! Check your network connectivity and try again.")} + return + { _t("Unable to load! Check your network connectivity and try again.") } ; diff --git a/src/Avatar.ts b/src/Avatar.ts index d218ae8b46..4c4bd1c265 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -14,17 +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"; - -export type ResizeMethod = "crop" | "scale"; +import { mediaFromMxc } from "./customisations/Media"; +import SettingsStore from "./settings/SettingsStore"; // 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); @@ -38,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); } @@ -143,7 +153,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi } // space rooms cannot be DMs so skip the rest - if (room.isSpaceRoom()) return null; + if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null; let otherMember = null; const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); 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 91fd7e4c7d..6e1e6ce83a 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -55,7 +55,7 @@ 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'; @@ -63,11 +63,11 @@ 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"; @@ -77,14 +77,15 @@ 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 DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker" +import DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker"; import { Action } from './dispatcher/actions'; import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring"; +import EventEmitter from 'events'; import SdkConfig from './SdkConfig'; import { ensureDMExists, findDMForUser } from './createRoom'; @@ -98,7 +99,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', @@ -123,9 +124,9 @@ interface ThirdpartyLookupResponseFields { } interface ThirdpartyLookupResponse { - userid: string, - protocol: string, - fields: ThirdpartyLookupResponseFields, + userid: string; + protocol: string; + fields: ThirdpartyLookupResponseFields; } // Unlike 'CallType' in js-sdk, this one includes screen sharing @@ -138,22 +139,12 @@ export enum PlaceCallType { ScreenSharing = 'screensharing', } -function getRemoteAudioElement(): HTMLAudioElement { - // this needs to be somewhere at the top of the DOM which - // always exists to avoid audio interruptions. - // Might as well just use DOM. - const remoteAudioElement = document.getElementById("remoteAudio") as HTMLAudioElement; - if (!remoteAudioElement) { - console.error( - "Failed to find remoteAudio element - cannot play audio!" + - "You need to add an to the DOM.", - ); - return null; - } - return remoteAudioElement; +export enum CallHandlerEvent { + CallsChanged = "calls_changed", + CallChangeRoom = "call_change_room", } -export default class CallHandler { +export default class CallHandler extends EventEmitter { private calls = new Map(); // roomId -> call // Calls started as an attended transfer, ie. with the intention of transferring another // call with a different party to this one. @@ -175,7 +166,7 @@ export default class CallHandler { static sharedInstance() { if (!window.mxCallHandler) { - window.mxCallHandler = new CallHandler() + window.mxCallHandler = new CallHandler(); } return window.mxCallHandler; @@ -194,7 +185,7 @@ export default class CallHandler { const nativeUser = this.assertedIdentityNativeUsers[call.callId]; if (nativeUser) { const room = findDMForUser(MatrixClientPeg.get(), nativeUser); - if (room) return room.roomId + if (room) return room.roomId; } } @@ -247,7 +238,7 @@ export default class CallHandler { 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( @@ -255,7 +246,7 @@ export default class CallHandler { ); } - 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); @@ -273,7 +264,7 @@ export default class CallHandler { } public getSupportsVirtualRooms() { - return this.supportsPstnProtocol; + return this.supportsSipNativeVirtual; } public pstnLookup(phoneNumber: string): Promise { @@ -308,7 +299,7 @@ export default class CallHandler { action: 'incoming_call', call: call, }, true); - } + }; getCallForRoom(roomId: string): MatrixCall { return this.calls.get(roomId) || null; @@ -471,6 +462,9 @@ export default class CallHandler { if (call.hangupReason === CallErrorCode.UserHangup) { title = _t("Call Declined"); description = _t("The other party declined the call."); + } else if (call.hangupReason === CallErrorCode.UserBusy) { + title = _t("User Busy"); + description = _t("The user you called is busy."); } else if (call.hangupReason === CallErrorCode.InviteTimeout) { title = _t("Call Failed"); // XXX: full stop appended as some relic here, but these @@ -514,6 +508,7 @@ export default class CallHandler { } this.calls.set(mappedRoomId, newCall); + this.emit(CallHandlerEvent.CallsChanged, this.calls); this.setCallListeners(newCall); this.setCallState(newCall, newCall.state); }); @@ -526,7 +521,9 @@ export default class CallHandler { let newNativeAssertedIdentity = newAssertedIdentity; if (newAssertedIdentity) { const response = await this.sipNativeLookup(newAssertedIdentity); - if (response.length) newNativeAssertedIdentity = response[0].userid; + if (response.length && response[0].fields.lookup_success) { + newNativeAssertedIdentity = response[0].userid; + } } console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`); @@ -545,11 +542,9 @@ export default class CallHandler { if (newMappedRoomId !== mappedRoomId) { this.removeCallForRoom(mappedRoomId); mappedRoomId = newMappedRoomId; + console.log("Moving call to room " + mappedRoomId); this.calls.set(mappedRoomId, call); - dis.dispatch({ - action: Action.CallChangeRoom, - call, - }); + this.emit(CallHandlerEvent.CallChangeRoom, call); } } }); @@ -598,11 +593,6 @@ export default class CallHandler { } } - private setCallAudioElement(call: MatrixCall) { - const audioElement = getRemoteAudioElement(); - if (audioElement) call.setRemoteAudioElement(audioElement); - } - private setCallState(call: MatrixCall, status: CallState) { const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); @@ -618,7 +608,9 @@ export default class CallHandler { } private removeCallForRoom(roomId: string) { + console.log("Removing call for room ", roomId); this.calls.delete(roomId); + this.emit(CallHandlerEvent.CallsChanged, this.calls); } private showICEFallbackPrompt() { @@ -679,11 +671,7 @@ export default class CallHandler { }, null, true); } - private async placeCall( - roomId: string, type: PlaceCallType, - localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, - transferee: MatrixCall, - ) { + private async placeCall(roomId: string, type: PlaceCallType, transferee: MatrixCall) { Analytics.trackEvent('voip', 'placeCall', 'type', type); CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); @@ -694,23 +682,21 @@ export default class CallHandler { console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); const call = MatrixClientPeg.get().createCall(mappedRoomId); + console.log("Adding call for room ", roomId); this.calls.set(roomId, call); + this.emit(CallHandlerEvent.CallsChanged, this.calls); if (transferee) { this.transferees[call.callId] = transferee; } this.setCallListeners(call); - this.setCallAudioElement(call); this.setActiveCallRoomId(roomId); if (type === PlaceCallType.Voice) { call.placeVoiceCall(); } else if (type === 'video') { - call.placeVideoCall( - remoteElement, - localElement, - ); + call.placeVideoCall(); } else if (type === PlaceCallType.ScreenSharing) { const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); if (screenCapErrorString) { @@ -724,13 +710,12 @@ export default class CallHandler { } call.placeScreenSharingCall( - remoteElement, - localElement, async (): Promise => { - const {finished} = Modal.createDialog(DesktopCapturerSourcePicker); + const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); const [source] = await finished; return source; - }); + }, + ); } else { console.error("Unknown conf call type: " + type); } @@ -787,17 +772,12 @@ export default class CallHandler { } else if (members.length === 2) { console.info(`Place ${payload.type} call in ${payload.room_id}`); - this.placeCall( - payload.room_id, payload.type, payload.local_element, payload.remote_element, - payload.transferee, - ); + this.placeCall(payload.room_id, payload.type, payload.transferee); } else { // > 2 dis.dispatch({ action: "place_conference_call", room_id: payload.room_id, type: payload.type, - remote_element: payload.remote_element, - local_element: payload.local_element, }); } } @@ -827,12 +807,17 @@ export default class CallHandler { const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); if (this.getCallForRoom(mappedRoomId)) { - // ignore multiple incoming calls to the same room + console.log( + "Got incoming call for room " + mappedRoomId + + " but there's already a call for this room: ignoring", + ); return; } Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); - this.calls.set(mappedRoomId, call) + console.log("Adding call for room ", mappedRoomId); + this.calls.set(mappedRoomId, call); + this.emit(CallHandlerEvent.CallsChanged, this.calls); this.setCallListeners(call); // get ready to send encrypted events in the room, so if the user does answer @@ -875,7 +860,6 @@ export default class CallHandler { const call = this.calls.get(payload.room_id); call.answer(); - this.setCallAudioElement(call); this.setActiveCallRoomId(payload.room_id); CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false); dis.dispatch({ @@ -884,7 +868,41 @@ export default class CallHandler { }); break; } + case Action.DialNumber: + this.dialNumber(payload.number); + break; } + }; + + private async dialNumber(number: string) { + const results = await this.pstnLookup(number); + if (!results || results.length === 0 || !results[0].userid) { + Modal.createTrackedDialog('', '', ErrorDialog, { + title: _t("Unable to look up phone number"), + description: _t("There was an error looking up the phone number"), + }); + return; + } + const userId = results[0].userid; + + // Now check to see if this is a virtual user, in which case we should find the + // native user + let nativeUserId; + if (this.getSupportsVirtualRooms()) { + const nativeLookupResults = await this.sipNativeLookup(userId); + const lookupSuccess = nativeLookupResults.length > 0 && nativeLookupResults[0].fields.lookup_success; + nativeUserId = lookupSuccess ? nativeLookupResults[0].userid : userId; + console.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId); + } else { + nativeUserId = userId; + } + + const roomId = await ensureDMExists(MatrixClientPeg.get(), nativeUserId); + + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); } setActiveCallRoomId(activeCallRoomId: string) { @@ -948,7 +966,7 @@ export default class CallHandler { 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 7c7940cab5..0000000000 --- a/src/CallMediaHandler.js +++ /dev/null @@ -1,88 +0,0 @@ -/* - Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import SettingsStore from "./settings/SettingsStore"; -import {SettingLevel} from "./settings/SettingLevel"; -import {setMatrixCallAudioInput, setMatrixCallAudioOutput, 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 audioOutDeviceId = SettingsStore.getValue("webrtc_audiooutput"); - const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); - const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); - - setMatrixCallAudioOutput(audioOutDeviceId); - setMatrixCallAudioInput(audioDeviceId); - setMatrixCallVideoInput(videoDeviceId); - }, - - setAudioOutput: function(deviceId) { - SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); - setMatrixCallAudioOutput(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 95b45cce4a..0ab193081b 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'; @@ -28,8 +29,6 @@ import encrypt from "browser-encrypt-attachment"; import extractPngChunks from "png-chunks-extract"; import Spinner from "./components/views/elements/Spinner"; -// Polyfill for Canvas.toBlob API using Canvas.toDataURL -import "blueimp-canvas-to-blob"; import { Action } from "./dispatcher/actions"; import CountlyAnalytics from "./CountlyAnalytics"; import { @@ -39,7 +38,8 @@ 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; const MAX_HEIGHT = 600; @@ -48,6 +48,8 @@ const MAX_HEIGHT = 600; // 5669 px (x-axis) , 5669 px (y-axis) , per metre const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01]; +export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448 + export class UploadCanceledError extends Error {} type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; @@ -78,6 +80,7 @@ interface IThumbnail { }; w: number; h: number; + [BLURHASH_FIELD]: string; }; thumbnail: Blob; } @@ -125,7 +128,17 @@ 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, + // use 4 components on the longer dimension, if square then both + imageData.width >= imageData.height ? 4 : 3, + imageData.height >= imageData.width ? 4 : 3, + ); canvas.toBlob(function(thumbnail) { resolve({ info: { @@ -137,8 +150,9 @@ function createThumbnail( }, w: inputWidth, h: inputHeight, + [BLURHASH_FIELD]: blurhash, }, - thumbnail: thumbnail, + thumbnail, }); }, mimeType); }); @@ -190,7 +204,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 }; } /** @@ -208,12 +222,12 @@ function infoForImageFile(matrixClient, roomId, imageFile) { } let imageInfo; - return loadImageElement(imageFile).then(function(r) { + return loadImageElement(imageFile).then((r) => { return createThumbnail(r.img, r.width, r.height, thumbnailType); - }).then(function(result) { + }).then((result) => { imageInfo = result.info; return uploadFile(matrixClient, roomId, result.thumbnail); - }).then(function(result) { + }).then((result) => { imageInfo.thumbnail_url = result.url; imageInfo.thumbnail_file = result.file; return imageInfo; @@ -221,7 +235,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. @@ -230,20 +245,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); @@ -264,12 +284,12 @@ function infoForVideoFile(matrixClient, roomId, videoFile) { const thumbnailType = "image/jpeg"; let videoInfo; - return loadVideoElement(videoFile).then(function(video) { + return loadVideoElement(videoFile).then((video) => { return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType); - }).then(function(result) { + }).then((result) => { videoInfo = result.info; return uploadFile(matrixClient, roomId, result.thumbnail); - }).then(function(result) { + }).then((result) => { videoInfo.thumbnail_url = result.url; videoInfo.thumbnail_file = result.file; return videoInfo; @@ -308,7 +328,12 @@ function readFileAsArrayBuffer(file: File | Blob): Promise { * If the file is unencrypted then the object will have a "url" key. * If the file is encrypted then the object will have a "file" key. */ -function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blob, progressHandler?: any) { +export function uploadFile( + matrixClient: MatrixClient, + roomId: string, + file: File | Blob, + progressHandler?: any, // TODO: Types +): Promise<{url?: string, file?: any}> { // TODO: Types let canceled = false; if (matrixClient.isRoomEncrypted(roomId)) { // If the room is encrypted then encrypt the file before uploading it. @@ -339,11 +364,11 @@ function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blo 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 { @@ -353,11 +378,11 @@ function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blo const promise1 = basePromise.then(function(url) { if (canceled) throw new UploadCanceledError(); // If the attachment isn't encrypted then include the URL directly. - return {"url": url}; + return { url }; }); - promise1.abort = () => { + (promise1 as any).abort = () => { canceled = true; - MatrixClientPeg.get().cancelUpload(basePromise); + matrixClient.cancelUpload(basePromise); }; return promise1; } @@ -367,13 +392,13 @@ export default class ContentMessages { private inprogress: IUpload[] = []; private mediaConfig: IMediaConfig = null; - sendStickerContentToRoom(url: string, roomId: string, info: string, text: string, matrixClient: MatrixClient) { + sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) { const startTime = CountlyAnalytics.getTimestamp(); - const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { + const prom = matrixClient.sendStickerMessage(roomId, url, info, text).catch((e) => { console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); throw e; }); - CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, {msgtype: "m.sticker"}); + CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, { msgtype: "m.sticker" }); return prom; } @@ -387,14 +412,15 @@ export default class ContentMessages { async sendContentListToRoom(files: File[], roomId: string, matrixClient: MatrixClient) { if (matrixClient.isGuest()) { - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); return; } const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); if (isQuoting) { + // FIXME: Using an import will result in Element crashing const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const {finished} = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, { + const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, { title: _t('Replying With Files'), description: (
{_t( @@ -411,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(); } @@ -427,8 +453,9 @@ export default class ContentMessages { } if (tooBigFiles.length > 0) { + // FIXME: Using an import will result in Element crashing const UploadFailureDialog = sdk.getComponent("dialogs.UploadFailureDialog"); - const {finished} = Modal.createTrackedDialog<[boolean]>('Upload Failure', '', UploadFailureDialog, { + const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Failure', '', UploadFailureDialog, { badFiles: tooBigFiles, totalFiles: files.length, contentMessages: this, @@ -437,15 +464,16 @@ export default class ContentMessages { if (!shouldContinue) return; } - const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); let uploadAll = false; // Promise to complete before sending next file into room, used for synchronisation of file-sending // to match the order the files were specified in - let promBefore = Promise.resolve(); + let promBefore: Promise = Promise.resolve(); for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; if (!uploadAll) { - const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', + // FIXME: Using an import will result in Element crashing + const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); + const { finished } = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', '', UploadConfirmDialog, { file, currentIndex: i, @@ -466,7 +494,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) { @@ -476,8 +504,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 }); } } @@ -538,15 +566,15 @@ 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); + dis.fire(Action.FocusSendMessageComposer); 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; @@ -573,13 +601,14 @@ export default class ContentMessages { }, function(err) { error = err; if (!upload.canceled) { - let desc = _t("The file '%(fileName)s' failed to upload.", {fileName: upload.fileName}); + let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName }); if (err.http_status === 413) { desc = _t( "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", - {fileName: upload.fileName}, + { fileName: upload.fileName }, ); } + // FIXME: Using an import will result in Element crashing const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Upload failed', '', ErrorDialog, { title: _t('Upload Failed'), @@ -600,10 +629,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' }); } }); } @@ -617,11 +646,11 @@ export default class ContentMessages { return true; } - private ensureMediaConfigFetched() { + private ensureMediaConfigFetched(matrixClient: MatrixClient) { if (this.mediaConfig !== null) return; console.log("[Media Config] Fetching"); - return MatrixClientPeg.get().getMediaConfig().then((config) => { + return matrixClient.getMediaConfig().then((config) => { console.log("[Media Config] Fetched config:", config); return config; }).catch(() => { diff --git a/src/CountlyAnalytics.ts b/src/CountlyAnalytics.ts index 974c08df18..a75c578536 100644 --- a/src/CountlyAnalytics.ts +++ b/src/CountlyAnalytics.ts @@ -14,21 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {randomString} from "matrix-js-sdk/src/randomstring"; +import { randomString } from "matrix-js-sdk/src/randomstring"; +import { IContent } from "matrix-js-sdk/src/models/event"; +import { sleep } from "matrix-js-sdk/src/utils"; -import {getCurrentLanguage} from './languageHandler'; +import { getCurrentLanguage } from './languageHandler'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import {sleep} from "./utils/promise"; +import { MatrixClientPeg } from "./MatrixClientPeg"; import RoomViewStore from "./stores/RoomViewStore"; - -// polyfill textencoder if necessary -import * as TextEncodingUtf8 from 'text-encoding-utf-8'; -let TextEncoder = window.TextEncoder; -if (!TextEncoder) { - TextEncoder = TextEncodingUtf8.TextEncoder; -} +import { Action } from "./dispatcher/actions"; const INACTIVITY_TIME = 20; // seconds const HEARTBEAT_INTERVAL = 5_000; // ms @@ -261,11 +256,11 @@ interface ICreateRoomEvent extends IEvent { num_users: number; is_encrypted: boolean; is_public: boolean; - } + }; } interface IJoinRoomEvent extends IEvent { - key: "join_room"; + key: Action.JoinRoom; dur: number; // how long it took to join (until remote echo) segmentation: { room_id: string; // hashed @@ -344,8 +339,8 @@ const getRoomStats = (roomId: string) => { "is_encrypted": cli?.isRoomEncrypted(roomId), // eslint-disable-next-line camelcase "is_public": room?.currentState.getStateEvents("m.room.join_rules", "")?.getContent()?.join_rule === "public", - } -} + }; +}; // async wrapper for regex-powered String.prototype.replace const strReplaceAsync = async (str: string, regex: RegExp, fn: (...args: string[]) => Promise) => { @@ -420,7 +415,7 @@ export default class CountlyAnalytics { this.anonymous = anonymous; if (anonymous) { - await this.changeUserKey(randomString(64)) + await this.changeUserKey(randomString(64)); } else { await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true); } @@ -444,7 +439,7 @@ export default class CountlyAnalytics { await this.track("Opt-Out" ); this.endSession(); window.clearInterval(this.heartbeatIntervalId); - window.clearTimeout(this.activityIntervalId) + window.clearTimeout(this.activityIntervalId); this.baseUrl = null; // remove listeners bound in trackSessions() window.removeEventListener("beforeunload", this.endSession); @@ -668,14 +663,14 @@ export default class CountlyAnalytics { } private queue(args: Omit & Partial>) { - const {count = 1, ...rest} = args; + const { count = 1, ...rest } = args; const ev = { ...this.getTimeParams(), ...rest, count, platform: this.appPlatform, app_version: this.appVersion, - } + }; this.pendingEvents.push(ev); if (this.pendingEvents.length > MAX_PENDING_EVENTS) { @@ -684,7 +679,9 @@ export default class CountlyAnalytics { } private getOrientation = (): Orientation => { - return window.innerWidth > window.innerHeight ? Orientation.Landscape : Orientation.Portrait; + return window.matchMedia("(orientation: landscape)").matches + ? Orientation.Landscape + : Orientation.Portrait; }; private reportOrientation = () => { @@ -753,7 +750,7 @@ export default class CountlyAnalytics { const request: Parameters[0] = { begin_session: 1, user_details: JSON.stringify(userDetails), - } + }; const metrics = this.getMetrics(); if (metrics) { @@ -777,7 +774,7 @@ export default class CountlyAnalytics { private endSession = () => { if (this.sessionStarted) { - window.removeEventListener("resize", this.reportOrientation) + window.removeEventListener("resize", this.reportOrientation); this.reportViewDuration(); this.request({ @@ -813,7 +810,9 @@ export default class CountlyAnalytics { window.addEventListener("mousemove", this.onUserActivity); window.addEventListener("click", this.onUserActivity); window.addEventListener("keydown", this.onUserActivity); - window.addEventListener("scroll", this.onUserActivity); + // Using the passive option to not block the main thread + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners + window.addEventListener("scroll", this.onUserActivity, { passive: true }); this.activityIntervalId = setInterval(() => { this.inactivityCounter++; @@ -858,7 +857,7 @@ export default class CountlyAnalytics { } public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) { - this.track("join_room", { type }, roomId, { + this.track(Action.JoinRoom, { type }, roomId, { dur: CountlyAnalytics.getTimestamp() - startTime, }); } @@ -870,7 +869,7 @@ export default class CountlyAnalytics { roomId: string, isEdit: boolean, isReply: boolean, - content: {format?: string, msgtype: string}, + content: IContent, ) { if (this.disabled) return; const cli = MatrixClientPeg.get(); 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..d033063677 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from "./dispatcher/dispatcher"; import { hideToast as hideBulkUnverifiedSessionsToast, @@ -160,7 +160,8 @@ export default class DeviceListener { // which result in account data changes affecting checks below. if ( ev.getType().startsWith('m.secret_storage.') || - ev.getType().startsWith('m.cross_signing.') + ev.getType().startsWith('m.cross_signing.') || + ev.getType() === 'm.megolm_backup.v1' ) { this._recheck(); } diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index e7ae3217bb..ea1813876c 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -19,9 +19,8 @@ import Modal from './Modal'; import * as sdk from './'; import MultiInviter from './utils/MultiInviter'; import { _t } from './languageHandler'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import GroupStore from './stores/GroupStore'; -import {allSettled} from "./utils/promise"; import StyledCheckbox from './components/views/elements/StyledCheckbox'; export function showGroupInviteDialog(groupId) { @@ -104,7 +103,7 @@ function _onGroupInviteFinished(groupId, addrs) { if (errorList.length > 0) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite the following users to the group', '', ErrorDialog, { - title: _t("Failed to invite the following users to %(groupId)s:", {groupId: groupId}), + title: _t("Failed to invite the following users to %(groupId)s:", { groupId: groupId }), description: errorList.join(", "), }); } @@ -112,7 +111,7 @@ function _onGroupInviteFinished(groupId, addrs) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite users to group', '', ErrorDialog, { title: _t("Failed to invite users to community"), - description: _t("Failed to invite users to %(groupId)s", {groupId: groupId}), + description: _t("Failed to invite users to %(groupId)s", { groupId: groupId }), }); }); } @@ -120,7 +119,7 @@ function _onGroupInviteFinished(groupId, addrs) { function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { const matrixClient = MatrixClientPeg.get(); const errorList = []; - return allSettled(addrs.map((addr) => { + return Promise.allSettled(addrs.map((addr) => { return GroupStore .addRoomToGroup(groupId, addr.address, addRoomsPublicly) .catch(() => { errorList.push(addr.address); }) @@ -138,7 +137,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { // Add this group as related if (!groups.includes(groupId)) { groups.push(groupId); - return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, ''); + return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', { groups }, ''); } }); })).then(() => { @@ -148,13 +147,15 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog( 'Failed to add the following room to the group', - '', ErrorDialog, - { - title: _t( - "Failed to add the following rooms to %(groupId)s:", - {groupId}, - ), - description: errorList.join(", "), - }); + '', + ErrorDialog, + { + title: _t( + "Failed to add the following rooms to %(groupId)s:", + { groupId }, + ), + description: errorList.join(", "), + }, + ); }); } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 6b2568d68c..5e83fdc2a0 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); @@ -59,6 +60,8 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; +const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; + /* * Return true if the given string contains emoji * Uses a much, much simpler regex than emojibase's so will give false @@ -66,7 +69,7 @@ export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet' * need emojification. * unicodeToImage uses this function. */ -function mightContainEmoji(str: string) { +function mightContainEmoji(str: string): boolean { return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); } @@ -76,7 +79,7 @@ function mightContainEmoji(str: string) { * @param {String} char The emoji character * @return {String} The shortcode (such as :thumbup:) */ -export function unicodeToShortcode(char: string) { +export function unicodeToShortcode(char: string): string { const data = getEmojiFromUnicode(char); return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); } @@ -87,7 +90,7 @@ export function unicodeToShortcode(char: string) { * @param {String} shortcode The shortcode (such as :thumbup:) * @return {String} The emoji character; null if none exists */ -export function shortcodeToUnicode(shortcode: string) { +export function shortcodeToUnicode(shortcode: string): string { shortcode = shortcode.slice(1, shortcode.length - 1); const data = SHORTCODE_TO_EMOJI.get(shortcode); return data ? data.unicode : null; @@ -124,20 +127,20 @@ export function processHtmlForSending(html: string): string { * Given an untrusted HTML string, return a React node with an sanitized version * of that HTML. */ -export function sanitizedHtmlNode(insaneHtml: string) { +export function sanitizedHtmlNode(insaneHtml: string): ReactNode { const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); return
; } -export function getHtmlText(insaneHtml: string) { +export function getHtmlText(insaneHtml: string): string { return sanitizeHtml(insaneHtml, { allowedTags: [], allowedAttributes: {}, selfClosing: [], allowedSchemes: [], disallowedTagsMode: 'discard', - }) + }); } /** @@ -148,7 +151,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; @@ -175,18 +178,31 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to return { tagName, attribs }; }, 'img': function(tagName: string, attribs: sanitizeHtml.Attributes) { + let src = attribs.src; // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // because transformTags is used _before_ we filter by allowedSchemesByTag and // we don't want to allow images with `https?` `src`s. // We also drop inline images (as if they were not present at all) when the "show // images" preference is disabled. Future work might expose some UI to reveal them // like standalone image events have. - if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) { - return { tagName, attribs: {}}; + if (!src || !SettingsStore.getValue("showImages")) { + return { tagName, attribs: {} }; } + + if (!src.startsWith("mxc://")) { + const match = MEDIA_API_MXC_REGEX.exec(src); + if (match) { + src = `mxc://${match[1]}/${match[2]}`; + } + } + + if (!src.startsWith("mxc://")) { + return { tagName, attribs: {} }; + } + const width = Number(attribs.width) || 800; const height = Number(attribs.height) || 600; - attribs.src = mediaFromMxc(attribs.src).getThumbnailOfSourceHttp(width, height); + attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height); return { tagName, attribs }; }, 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) { @@ -351,20 +367,21 @@ class HtmlHighlighter extends BaseHighlighter { } } -interface IContent { - format?: string; - // eslint-disable-next-line camelcase - formatted_body?: string; - body: string; -} - interface IOpts { highlightLink?: string; disableBigEmoji?: boolean; stripReplyFallback?: boolean; returnString?: boolean; forComposerQuote?: boolean; - ref?: React.Ref; + ref?: React.Ref; +} + +export interface IOptsReturnNode extends IOpts { + returnString: false | undefined; +} + +export interface IOptsReturnString extends IOpts { + returnString: true; } /* turn a matrix event body into html @@ -380,6 +397,8 @@ interface IOpts { * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer * opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString) */ +export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnString): string; +export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnNode): ReactNode; export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) { const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; let bodyHasEmoji = false; @@ -399,9 +418,14 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts try { if (highlights && highlights.length > 0) { const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); - const safeHighlights = highlights.map(function(highlight) { - return sanitizeHtml(highlight, sanitizeParams); - }); + const safeHighlights = highlights + // sanitizeHtml can hang if an unclosed HTML tag is thrown at it + // A search for ` !highlight.includes("<")) + .map((highlight: string): string => sanitizeHtml(highlight, sanitizeParams)); // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. sanitizeParams.textFilter = function(safeText) { return highlighter.applyHighlights(safeText, safeHighlights).join(''); @@ -422,8 +446,12 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts safeBody = sanitizeHtml(formattedBody, sanitizeParams); if (SettingsStore.getValue("feature_latex_maths")) { - const phtml = cheerio.load(safeBody, - { _useHtmlParser2: true, decodeEntities: false }) + const phtml = cheerio.load(safeBody, { + // @ts-ignore: The `_useHtmlParser2` internal option is the + // simplest way to both parse and render using `htmlparser2`. + _useHtmlParser2: true, + decodeEntities: false, + }); // @ts-ignore - The types for `replaceWith` wrongly expect // Cheerio instance to be returned. phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) { @@ -431,6 +459,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')), { throwOnError: false, + // @ts-ignore - `e` can be an Element, not just a Node displayMode: e.name == 'div', output: "htmlAndMathml", }); @@ -496,7 +525,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @returns {string} Linkified string */ -export function linkifyString(str: string, options = linkifyMatrix.options) { +export function linkifyString(str: string, options = linkifyMatrix.options): string { return _linkifyString(str, options); } @@ -507,7 +536,7 @@ export function linkifyString(str: string, options = linkifyMatrix.options) { * @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options * @returns {object} */ -export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) { +export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options): HTMLElement { return _linkifyElement(element, options); } @@ -518,7 +547,7 @@ export function linkifyElement(element: HTMLElement, options = linkifyMatrix.opt * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @returns {string} */ -export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) { +export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options): string { return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } @@ -529,7 +558,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatri * @param {Node} node * @returns {bool} */ -export function checkBlockNode(node: Node) { +export function checkBlockNode(node: Node): boolean { switch (node.nodeName) { case "H1": case "H2": diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index 1687adf13b..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'; @@ -163,7 +163,7 @@ export default class IdentityAuthClient {
), button: _t("Trust"), - }); + }); const [confirmed] = await finished; if (confirmed) { // eslint-disable-next-line react-hooks/rules-of-hooks 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..61ded93833 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'; @@ -33,7 +33,6 @@ import Presence from './Presence'; import dis from './dispatcher/dispatcher'; import DMRoomMap from './utils/DMRoomMap'; import Modal from './Modal'; -import * as sdk from './index'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; import PlatformPeg from "./PlatformPeg"; import { sendLoginRequest } from "./Login"; @@ -41,17 +40,21 @@ import * as StorageManager from './utils/StorageManager'; import SettingsStore from "./settings/SettingsStore"; import TypingStore from "./stores/TypingStore"; import ToastStore from "./stores/ToastStore"; -import {IntegrationManagers} from "./integrations/IntegrationManagers"; -import {Mjolnir} from "./mjolnir/Mjolnir"; +import { IntegrationManagers } from "./integrations/IntegrationManagers"; +import { Mjolnir } from "./mjolnir/Mjolnir"; import DeviceListener from "./DeviceListener"; -import {Jitsi} from "./widgets/Jitsi"; -import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, 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"; +import LazyLoadingResyncDialog from "./components/views/dialogs/LazyLoadingResyncDialog"; +import LazyLoadingDisabledDialog from "./components/views/dialogs/LazyLoadingDisabledDialog"; +import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog"; +import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -154,7 +157,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]; } @@ -238,8 +241,6 @@ export function handleInvalidStoreError(e: InvalidStoreError): Promise { return Promise.resolve().then(() => { const lazyLoadEnabled = e.value; if (lazyLoadEnabled) { - const LazyLoadingResyncDialog = - sdk.getComponent("views.dialogs.LazyLoadingResyncDialog"); return new Promise((resolve) => { Modal.createDialog(LazyLoadingResyncDialog, { onFinished: resolve, @@ -250,8 +251,6 @@ export function handleInvalidStoreError(e: InvalidStoreError): Promise { // between LL/non-LL version on same host. // as disabling LL when previously enabled // is a strong indicator of this (/develop & /app) - const LazyLoadingDisabledDialog = - sdk.getComponent("views.dialogs.LazyLoadingDisabledDialog"); return new Promise((resolve) => { Modal.createDialog(LazyLoadingDisabledDialog, { onFinished: resolve, @@ -303,7 +302,7 @@ export interface IStoredSession { hsUrl: string; isUrl: string; hasAccessToken: boolean; - accessToken: string | object; + accessToken: string | IEncryptedPayload; userId: string; deviceId: string; isGuest: boolean; @@ -346,11 +345,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 +401,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(); @@ -451,9 +450,6 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): async function handleLoadSessionFailure(e: Error): Promise { console.error("Unable to load session", e); - const SessionRestoreErrorDialog = - sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); - const modal = Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, { error: e.message, }); @@ -495,7 +491,7 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise { - const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog'); return new Promise(resolve => { Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, { onFinished: resolve, @@ -745,7 +740,7 @@ export function softLogout(): void { // Ensure that we dispatch a view change **before** stopping the client so // so that React components unmount first. This avoids React soft crashes // that can occur when components try to use a null client. - dis.dispatch({action: 'on_client_not_viable'}); // generic version of on_logged_out + dis.dispatch({ action: 'on_client_not_viable' }); // generic version of on_logged_out stopMatrixClient(/*unsetClient=*/false); // DO NOT CALL LOGOUT. A soft logout preserves data, logout does not. @@ -772,7 +767,7 @@ async function startMatrixClient(startSyncing = true): Promise { // to add listeners for the 'sync' event so otherwise we'd have // a race condition (and we need to dispatch synchronously for this // to work). - dis.dispatch({action: 'will_start_client'}, true); + dis.dispatch({ action: 'will_start_client' }, true); // reset things first just in case TypingStore.sharedInstance().reset(); @@ -814,7 +809,7 @@ async function startMatrixClient(startSyncing = true): Promise { // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. - dis.dispatch({action: 'client_started'}); + dis.dispatch({ action: 'client_started' }); if (isSoftLogout()) { softLogout(); @@ -830,9 +825,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 d584df7dfe..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"; @@ -31,12 +31,12 @@ interface IPasswordFlow { } export enum IdentityProviderBrand { - Gitlab = "org.matrix.gitlab", - Github = "org.matrix.github", - Apple = "org.matrix.apple", - Google = "org.matrix.google", - Facebook = "org.matrix.facebook", - Twitter = "org.matrix.twitter", + Gitlab = "gitlab", + Github = "github", + Apple = "apple", + Google = "google", + Facebook = "facebook", + Twitter = "twitter", } export interface IIdentityProvider { @@ -48,7 +48,8 @@ export interface IIdentityProvider { export interface ISSOFlow { type: "m.login.sso" | "m.login.cas"; - "org.matrix.msc2858.identity_providers": IIdentityProvider[]; // Unstable prefix for MSC2858 + // eslint-disable-next-line camelcase + identity_providers: IIdentityProvider[]; } export type LoginFlow = ISSOFlow | IPasswordFlow; @@ -189,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..063c5f4cad 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 { @@ -219,6 +219,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } catch (e) { if (e && e.name === 'InvalidCryptoStoreError') { // The js-sdk found a crypto DB too new for it to use + // FIXME: Using an import will result in test failures const CryptoStoreTooNewDialog = sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog"); Modal.createDialog(CryptoStoreTooNewDialog); diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts new file mode 100644 index 0000000000..073f24523d --- /dev/null +++ b/src/MediaDeviceHandler.ts @@ -0,0 +1,125 @@ +/* +Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2021 Šimon Brandner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import SettingsStore from "./settings/SettingsStore"; +import { SettingLevel } from "./settings/SettingLevel"; +import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix"; +import EventEmitter from 'events'; + +// XXX: MediaDeviceKind is a union type, so we make our own enum +export enum MediaDeviceKindEnum { + AudioOutput = "audiooutput", + AudioInput = "audioinput", + VideoInput = "videoinput", +} + +export type IMediaDevices = Record>; + +export enum MediaDeviceHandlerEvent { + AudioOutputChanged = "audio_output_changed", +} + +export default class MediaDeviceHandler extends EventEmitter { + private static internalInstance; + + public static get instance(): MediaDeviceHandler { + if (!MediaDeviceHandler.internalInstance) { + MediaDeviceHandler.internalInstance = new MediaDeviceHandler(); + } + return MediaDeviceHandler.internalInstance; + } + + public static async hasAnyLabeledDevices(): Promise { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.some(d => Boolean(d.label)); + } + + public static async getDevices(): Promise { + // Only needed for Electron atm, though should work in modern browsers + // once permission has been granted to the webapp + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const output = { + [MediaDeviceKindEnum.AudioOutput]: [], + [MediaDeviceKindEnum.AudioInput]: [], + [MediaDeviceKindEnum.VideoInput]: [], + }; + + devices.forEach((device) => output[device.kind].push(device)); + return output; + } catch (error) { + console.warn('Unable to refresh WebRTC Devices: ', error); + } + } + + /** + * Retrieves devices from the SettingsStore and tells the js-sdk to use them + */ + public static loadDevices(): void { + const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); + const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); + + setMatrixCallAudioInput(audioDeviceId); + setMatrixCallVideoInput(videoDeviceId); + } + + public setAudioOutput(deviceId: string): void { + SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); + this.emit(MediaDeviceHandlerEvent.AudioOutputChanged, deviceId); + } + + /** + * This will not change the device that a potential call uses. The call will + * need to be ended and started again for this change to take effect + * @param {string} deviceId + */ + public setAudioInput(deviceId: string): void { + SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); + setMatrixCallAudioInput(deviceId); + } + + /** + * This will not change the device that a potential call uses. The call will + * need to be ended and started again for this change to take effect + * @param {string} deviceId + */ + public setVideoInput(deviceId: string): void { + SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); + setMatrixCallVideoInput(deviceId); + } + + public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { + switch (kind) { + case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break; + case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break; + case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break; + } + } + + public static getAudioOutput(): string { + return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput"); + } + + public static getAudioInput(): string { + return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput"); + } + + public static getVideoInput(): string { + return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput"); + } +} diff --git a/src/Modal.tsx b/src/Modal.tsx index 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 3e927cea0c..415adcafc8 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -27,16 +27,16 @@ import * as TextForEvent from './TextForEvent'; import Analytics from './Analytics'; import * as Avatar from './Avatar'; import dis from './dispatcher/dispatcher'; -import * as sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; import SettingsStore from "./settings/SettingsStore"; import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast"; -import {SettingLevel} from "./settings/SettingLevel"; -import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers"; +import { SettingLevel } from "./settings/SettingLevel"; +import { isPushNotifyDisabled } from "./settings/controllers/NotificationControllers"; import RoomViewStore from "./stores/RoomViewStore"; import UserActivity from "./UserActivity"; -import {mediaFromMxc} from "./customisations/Media"; +import { mediaFromMxc } from "./customisations/Media"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; /* * 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); } @@ -240,7 +240,6 @@ export const Notifier = { ? _t('%(brand)s does not have permission to send you notifications - ' + 'please check your browser settings', { brand }) : _t('%(brand)s was not given permission to send notifications - please try again', { brand }); - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, { title: _t('Unable to enable Notifications'), description, @@ -331,6 +330,8 @@ export const Notifier = { if (!this.isSyncing) return; // don't alert for any messages initially if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return; + MatrixClientPeg.get().decryptEventIfNeeded(ev); + // If it's an encrypted event and the type is still 'm.room.encrypted', // it hasn't yet been decrypted, so wait until it is. if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) { diff --git a/src/PasswordReset.js b/src/PasswordReset.js index 6fe6ca82cc..88ae00d088 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -54,7 +54,7 @@ export default class PasswordReset { return res; }, function(err) { if (err.errcode === 'M_THREEPID_NOT_FOUND') { - err.message = _t('This email address was not found'); + err.message = _t('This email address was not found'); } else if (err.httpStatus) { err.message = err.message + ` (Status ${err.httpStatus})`; } diff --git a/src/Presence.ts b/src/Presence.ts index eb56c5714e..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(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/Resend.js b/src/Resend.ts similarity index 70% rename from src/Resend.js rename to src/Resend.ts index f1e5fb38f5..38b84a28e0 100644 --- a/src/Resend.js +++ b/src/Resend.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,35 +14,37 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event'; +import { Room } from 'matrix-js-sdk/src/models/room'; + +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; -import { EventStatus } from 'matrix-js-sdk/src/models/event'; export default class Resend { - static resendUnsentEvents(room) { - return Promise.all(room.getPendingEvents().filter(function(ev) { + static resendUnsentEvents(room: Room): Promise { + return Promise.all(room.getPendingEvents().filter(function(ev: MatrixEvent) { return ev.status === EventStatus.NOT_SENT; - }).map(function(event) { + }).map(function(event: MatrixEvent) { return Resend.resend(event); })); } - static cancelUnsentEvents(room) { - room.getPendingEvents().filter(function(ev) { + static cancelUnsentEvents(room: Room): void { + room.getPendingEvents().filter(function(ev: MatrixEvent) { return ev.status === EventStatus.NOT_SENT; - }).forEach(function(event) { + }).forEach(function(event: MatrixEvent) { Resend.removeFromQueue(event); }); } - static resend(event) { + static resend(event: MatrixEvent): Promise { const room = MatrixClientPeg.get().getRoom(event.getRoomId()); return MatrixClientPeg.get().resendEvent(event, room).then(function(res) { dis.dispatch({ action: 'message_sent', event: event, }); - }, function(err) { + }, function(err: Error) { // XXX: temporary logging to try to diagnose // https://github.com/vector-im/element-web/issues/3148 console.log('Resend got send failure: ' + err.name + '(' + err + ')'); @@ -55,7 +56,7 @@ export default class Resend { }); } - static removeFromQueue(event) { + static removeFromQueue(event: MatrixEvent): void { MatrixClientPeg.get().cancelPendingEvent(event); } } diff --git a/src/Roles.ts b/src/Roles.ts index b4be97fdce..ae0d316d30 100644 --- a/src/Roles.ts +++ b/src/Roles.ts @@ -31,6 +31,6 @@ export function textualPowerLevel(level: number, usersDefault: number): string { if (LEVEL_ROLE_MAP[level]) { return LEVEL_ROLE_MAP[level]; } else { - return _t("Custom (%(level)s)", {level}); + return _t("Custom (%(level)s)", { level }); } } diff --git a/src/RoomAliasCache.js b/src/RoomAliasCache.ts similarity index 81% rename from src/RoomAliasCache.js rename to src/RoomAliasCache.ts index bb511ba4d7..c318db2d3f 100644 --- a/src/RoomAliasCache.js +++ b/src/RoomAliasCache.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -24,12 +24,12 @@ limitations under the License. * A similar thing could also be achieved via `pushState` with a state object, * but keeping it separate like this seems easier in case we do want to extend. */ -const aliasToIDMap = new Map(); +const aliasToIDMap = new Map(); -export function storeRoomAliasInCache(alias, id) { +export function storeRoomAliasInCache(alias: string, id: string): void { aliasToIDMap.set(alias, id); } -export function getCachedRoomIDForAlias(alias) { +export function getCachedRoomIDForAlias(alias: string): string { return aliasToIDMap.get(alias); } diff --git a/src/RoomInvite.js b/src/RoomInvite.tsx similarity index 50% rename from src/RoomInvite.js rename to src/RoomInvite.tsx index aa758ecbdc..7d093f4092 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"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; + +export interface IInviteResult { + states: CompletionStates; + inviter: MultiInviter; +} /** * Invites multiple addresses to a room @@ -32,24 +41,23 @@ 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 +69,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 +91,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,13 +104,12 @@ 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); }).catch((err) => { console.error(err.stack); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { title: _t("Failed to invite"), description: ((err && err.message) ? err.message : _t("Operation failed")), @@ -110,35 +117,66 @@ 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 79% rename from src/Rooms.js rename to src/Rooms.ts index 955498faaa..b27d00e804 100644 --- a/src/Rooms.js +++ b/src/Rooms.ts @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,7 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { Room } from "matrix-js-sdk/src/models/room"; + +import { MatrixClientPeg } from './MatrixClientPeg'; +import AliasCustomisations from './customisations/Alias'; /** * Given a room object, return the alias we should use for it, @@ -25,11 +28,22 @@ import {MatrixClientPeg} from './MatrixClientPeg'; * @param {Object} room The room object * @returns {string} A display alias for the given room */ -export function getDisplayAliasForRoom(room) { - return room.getCanonicalAlias() || room.getAltAliases()[0]; +export function getDisplayAliasForRoom(room: Room): string { + return getDisplayAliasForAliasSet( + room.getCanonicalAlias(), room.getAltAliases(), + ); } -export function looksLikeDirectMessageRoom(room, myUserId) { +// The various display alias getters should all feed through this one path so +// there's a single place to change the logic. +export function getDisplayAliasForAliasSet(canonicalAlias: string, altAliases: string[]): string { + if (AliasCustomisations.getDisplayAliasForAliasSet) { + return AliasCustomisations.getDisplayAliasForAliasSet(canonicalAlias, altAliases); + } + return canonicalAlias || altAliases?.[0]; +} + +export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean { const myMembership = room.getMyMembership(); const me = room.getMember(myUserId); @@ -48,7 +62,7 @@ export function looksLikeDirectMessageRoom(room, myUserId) { return false; } -export function guessAndSetDMRoom(room, isDirect) { +export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise { let newTarget; if (isDirect) { const guessedUserId = guessDMRoomTargetId( @@ -70,7 +84,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 +116,6 @@ export function setDMRoom(roomId, userId) { dmRoomMap[userId] = roomList; } - return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap); } @@ -114,7 +127,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 f65b8920b3..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, @@ -66,7 +66,7 @@ async function serverSideSearchProcess(term, roomId = undefined) { highlights: [], }; - return client._processRoomEventsSearch(searchResult, result.response); + return client.processRoomEventsSearch(searchResult, result.response); } function compareEvents(a, b) { @@ -131,7 +131,7 @@ async function combinedSearch(searchTerm) { }, }; - const result = client._processRoomEventsSearch(emptyResult, response); + const result = client.processRoomEventsSearch(emptyResult, response); // Restore our encryption info so we can properly re-verify the events. restoreEncryptionInfo(result.results); @@ -185,7 +185,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) { }, }; - const processedResult = MatrixClientPeg.get()._processRoomEventsSearch(emptyResult, response); + const processedResult = MatrixClientPeg.get().processRoomEventsSearch(emptyResult, response); // Restore our encryption info so we can properly re-verify the events. restoreEncryptionInfo(processedResult.results); @@ -210,7 +210,7 @@ async function localPagination(searchResult) { }, }; - const result = MatrixClientPeg.get()._processRoomEventsSearch(searchResult, response); + const result = MatrixClientPeg.get().processRoomEventsSearch(searchResult, response); // Restore our encryption info so we can properly re-verify the events. const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0)); @@ -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); } @@ -520,7 +520,7 @@ async function combinedPagination(searchResult) { const oldResultCount = searchResult.results ? searchResult.results.length : 0; // Let the client process the combined result. - const result = client._processRoomEventsSearch(searchResult, response); + const result = client.processRoomEventsSearch(searchResult, response); // Restore our encryption info so we can properly re-verify the events. const newResultCount = result.results.length - oldResultCount; diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 203830d232..370b21b396 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -14,11 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix'; +import { ICryptoCallbacks } from 'matrix-js-sdk/src/matrix'; +import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import Modal from './Modal'; import * as sdk from './index'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import { MatrixClientPeg } from './MatrixClientPeg'; import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; import { _t } from './languageHandler'; @@ -28,6 +29,7 @@ import AccessSecretStorageDialog from './components/views/dialogs/security/Acces import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; import SettingsStore from "./settings/SettingsStore"; import SecurityCustomisations from "./customisations/Security"; +import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; // This stores the secret storage private keys in memory for the JS SDK. This is // only meant to act as a cache to avoid prompting the user multiple times @@ -41,8 +43,8 @@ let secretStorageBeingAccessed = false; let nonInteractive = false; let dehydrationCache: { - key?: Uint8Array, - keyInfo?: ISecretStorageKeyInfo, + key?: Uint8Array; + keyInfo?: ISecretStorageKeyInfo; } = {}; function isCachingAllowed(): boolean { @@ -134,7 +136,7 @@ async function getSecretStorageKey( const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); if (keyFromCustomisations) { - console.log("Using key from security customisations (secret storage)") + console.log("Using key from security customisations (secret storage)"); cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations); return [keyId, keyFromCustomisations]; } @@ -184,7 +186,7 @@ export async function getDehydrationKey( ): Promise { const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); if (keyFromCustomisations) { - console.log("Using key from security customisations (dehydration)") + console.log("Using key from security customisations (dehydration)"); return keyFromCustomisations; } @@ -223,7 +225,7 @@ export async function getDehydrationKey( const key = await inputToKey(input); // need to copy the key because rehydration (unpickling) will clobber it - dehydrationCache = {key: new Uint8Array(key), keyInfo}; + dehydrationCache = { key: new Uint8Array(key), keyInfo }; return key; } @@ -244,7 +246,7 @@ async function onSecretRequested( deviceId: string, requestId: string, name: string, - deviceTrust: IDeviceTrustLevel, + deviceTrust: DeviceTrustLevel, ): Promise { console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust); const client = MatrixClientPeg.get(); @@ -271,7 +273,7 @@ async function onSecretRequested( } return key && encodeBase64(key); } else if (name === "m.megolm_backup.v1") { - const key = await client._crypto.getSessionBackupPrivateKey(); + const key = await client.crypto.getSessionBackupPrivateKey(); if (!key) { console.log( `session backup key requested by ${deviceId}, but not found in cache`, @@ -353,6 +355,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f throw new Error("Secret storage creation canceled"); } } else { + // FIXME: Using an import will result in test failures const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); await cli.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async (makeRequest) => { 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 6ce1439164..7753ff6f75 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -17,28 +17,27 @@ See the License for the specific language governing permissions and limitations under the License. */ - import * as React from 'react'; +import { User } from "matrix-js-sdk/src/models/user"; import * 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 } from "parse5"; +import { parseFragment as parseHtml, Element as ChildElement } from "parse5"; import BugReportDialog from "./components/views/dialogs/BugReportDialog"; import { ensureDMExists } from "./createRoom"; import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; @@ -46,10 +45,16 @@ import { Action } from "./dispatcher/actions"; import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from "./utils/membership"; import SdkConfig from "./SdkConfig"; import SettingsStore from "./settings/SettingsStore"; -import {UIFeature} from "./settings/UIFeature"; -import {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"; +import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog'; +import ErrorDialog from './components/views/dialogs/ErrorDialog'; +import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog'; +import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog"; +import InfoDialog from "./components/views/dialogs/InfoDialog"; +import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -63,7 +68,6 @@ const singleMxcUpload = async (): Promise => { fileSelector.onchange = (ev: HTMLInputEvent) => { const file = ev.target.files[0]; - const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { file, onFinished: (shouldContinue) => { @@ -143,11 +147,15 @@ export class Command { } function reject(error) { - return {error}; + return { error }; } function success(promise?: Promise) { - return {promise}; + return { promise }; +} + +function successSync(value: any) { + return success(Promise.resolve(value)); } /* Disable the "unexpected this" error for these commands - all of the run @@ -160,7 +168,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 +184,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -189,7 +197,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -202,7 +210,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -215,7 +223,7 @@ export const Commands = [ if (args) { message = message + ' ' + args; } - return success(ContentHelpers.makeTextMessage(message)); + return successSync(ContentHelpers.makeTextMessage(message)); }, category: CommandCategories.messages, }), @@ -224,7 +232,7 @@ export const Commands = [ args: '', description: _td('Sends a message as plain text, without interpreting it as markdown'), runFn: function(roomId, messages) { - return success(ContentHelpers.makeTextMessage(messages)); + return successSync(ContentHelpers.makeTextMessage(messages)); }, category: CommandCategories.messages, }), @@ -233,7 +241,7 @@ export const Commands = [ args: '', description: _td('Sends a message as html, without interpreting it as markdown'), runFn: function(roomId, messages) { - return success(ContentHelpers.makeHtmlMessage(messages, messages)); + return successSync(ContentHelpers.makeHtmlMessage(messages, messages)); }, category: CommandCategories.messages, }), @@ -242,7 +250,6 @@ export const Commands = [ args: '', description: _td('Searches DuckDuckGo for results'), runFn: function() { - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); // TODO Don't explain this away, actually show a search UI here. Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { title: _t('/ddg is not a command'), @@ -265,10 +272,8 @@ export const Commands = [ return reject(_t("You do not have the required permissions to use this command.")); } - const RoomUpgradeWarningDialog = sdk.getComponent("dialogs.RoomUpgradeWarningDialog"); - - const {finished} = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', - RoomUpgradeWarningDialog, {roomId: roomId, targetVersion: args}, /*className=*/null, + const { finished } = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', + RoomUpgradeWarningDialog, { roomId: roomId, targetVersion: args }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); return success(finished.then(async ([resp]) => { @@ -284,7 +289,7 @@ export const Commands = [ if (resp.invite) { checkForUpgradeFn = async (newRoom) => { // The upgradePromise should be done by the time we await it here. - const {replacement_room: newRoomId} = await upgradePromise; + const { replacement_room: newRoomId } = await upgradePromise; if (newRoom.roomId !== newRoomId) return; const toInvite = [ @@ -310,7 +315,6 @@ export const Commands = [ if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn); - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, { title: _t('Error upgrading room'), description: _t( @@ -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, @@ -430,7 +434,6 @@ export const Commands = [ const topic = topicEvents && topicEvents.getContent().topic; const topicHtml = topic ? linkifyAndSanitizeHtml(topic) : _t('This room has no topic.'); - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, { title: room.name, description:
, @@ -733,11 +736,10 @@ export const Commands = [ ignoredUsers.push(userId); // de-duped internally in the js-sdk return success( cli.setIgnoredUsers(ignoredUsers).then(() => { - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'User ignored', InfoDialog, { title: _t('Ignored user'), description:
-

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

+

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

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

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

+

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

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

, @@ -978,7 +977,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 +987,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, }), @@ -996,8 +995,6 @@ export const Commands = [ command: "help", description: _td("Displays list of commands with usages and descriptions"), runFn: function() { - const SlashCommandHelpDialog = sdk.getComponent('dialogs.SlashCommandHelpDialog'); - Modal.createTrackedDialog('Slash Commands', 'Help', SlashCommandHelpDialog); return success(); }, @@ -1015,9 +1012,8 @@ export const Commands = [ const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId); dis.dispatch({ action: Action.ViewUser, - // XXX: We should be using a real member object and not assuming what the - // receiver wants. - member: member || {userId}, + // XXX: We should be using a real member object and not assuming what the receiver wants. + member: member || { userId } as User, }); return success(); }, @@ -1169,16 +1165,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 +1182,15 @@ Commands.forEach(cmd => { }); }); -export function parseCommandString(input: string) { +export function parseCommandString(input: string): { cmd?: string, args?: string } { // trim any trailing whitespace, as it can confuse the parser for // IRC-style commands input = input.replace(/\s+$/, ''); if (input[0] !== '/') return {}; // not a command const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/); - let cmd; - let args; + let cmd: string; + let args: string; if (bits) { cmd = bits[1].substring(1).toLowerCase(); args = bits[2]; @@ -1202,7 +1198,12 @@ export function parseCommandString(input: string) { cmd = input; } - return {cmd, args}; + return { cmd, args }; +} + +interface ICmd { + cmd?: Command; + args?: string; } /** @@ -1213,8 +1214,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 1bdff36cbc..351d1c0951 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,25 +33,29 @@ 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) { } } -interface Policy { +export interface LocalisedPolicy { + name: string; + url: string; +} + +export interface Policy { // @ts-ignore: No great way to express indexed types together with other keys version: string; - [lang: string]: { - url: string; - }; + [lang: string]: LocalisedPolicy; } -type Policies = { - [policy: string]: Policy, + +export type Policies = { + [policy: string]: Policy; }; export type TermsInteractionCallback = ( policiesAndServicePairs: { - service: Service, - policies: Policies, + service: Service; + policies: Policies; }[], agreedUrls: string[], extraClassNames?: string, @@ -99,7 +104,7 @@ export async function startTermsFlow( // fetch the set of agreed policy URLs from account data const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms'); - let agreedUrlSet; + let agreedUrlSet: Set; if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) { agreedUrlSet = new Set(); } else { @@ -113,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; @@ -127,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 }); } } @@ -144,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); } @@ -176,14 +181,15 @@ export async function startTermsFlow( export function dialogTermsInteractionCallback( policiesAndServicePairs: { - service: Service, - policies: { [policy: string]: Policy }, + service: Service; + policies: { [policy: string]: Policy }; }[], agreedUrls: string[], extraClassNames?: string, ): Promise { return new Promise((resolve, reject) => { console.log("Terms that need agreement", policiesAndServicePairs); + // FIXME: Using an import will result in test failures const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); Modal.createTrackedDialog('Terms of Service', '', TermsDialog, { diff --git a/src/TextForEvent.js b/src/TextForEvent.js deleted file mode 100644 index a6787c647d..0000000000 --- a/src/TextForEvent.js +++ /dev/null @@ -1,605 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ -import {MatrixClientPeg} from './MatrixClientPeg'; -import { _t } from './languageHandler'; -import * as Roles from './Roles'; -import {isValid3pidInvite} from "./RoomInvite"; -import SettingsStore from "./settings/SettingsStore"; -import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; -import {WIDGET_LAYOUT_EVENT_TYPE} from "./stores/widgets/WidgetLayoutStore"; - -function textForMemberEvent(ev) { - // XXX: SYJS-16 "sender is sometimes null for join messages" - const senderName = ev.sender ? ev.sender.name : ev.getSender(); - const targetName = ev.target ? ev.target.name : ev.getStateKey(); - const prevContent = ev.getPrevContent(); - const content = ev.getContent(); - - const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : ''; - switch (content.membership) { - case 'invite': { - const threePidContent = content.third_party_invite; - if (threePidContent) { - if (threePidContent.display_name) { - return _t('%(targetName)s accepted the invitation for %(displayName)s.', { - targetName, - displayName: threePidContent.display_name, - }); - } else { - return _t('%(targetName)s accepted an invitation.', {targetName}); - } - } else { - return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); - } - } - case 'ban': - return _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + reason; - case 'join': - if (prevContent && prevContent.membership === 'join') { - if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { - return _t('%(oldDisplayName)s changed their display name to %(displayName)s.', { - oldDisplayName: prevContent.displayname, - displayName: content.displayname, - }); - } else if (!prevContent.displayname && content.displayname) { - return _t('%(senderName)s set their display name to %(displayName)s.', { - senderName: ev.getSender(), - displayName: content.displayname, - }); - } else if (prevContent.displayname && !content.displayname) { - return _t('%(senderName)s removed their display name (%(oldDisplayName)s).', { - senderName, - oldDisplayName: prevContent.displayname, - }); - } else if (prevContent.avatar_url && !content.avatar_url) { - return _t('%(senderName)s removed their profile picture.', {senderName}); - } else if (prevContent.avatar_url && content.avatar_url && - prevContent.avatar_url !== content.avatar_url) { - return _t('%(senderName)s changed their profile picture.', {senderName}); - } else if (!prevContent.avatar_url && content.avatar_url) { - return _t('%(senderName)s set a profile picture.', {senderName}); - } else if (SettingsStore.getValue("showHiddenEventsInTimeline")) { - // This is a null rejoin, it will only be visible if the Labs option is enabled - return _t("%(senderName)s made no change.", {senderName}); - } else { - return ""; - } - } else { - if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); - return _t('%(targetName)s joined the room.', {targetName}); - } - case 'leave': - if (ev.getSender() === ev.getStateKey()) { - if (prevContent.membership === "invite") { - return _t('%(targetName)s rejected the invitation.', {targetName}); - } else { - return _t('%(targetName)s left the room.', {targetName}); - } - } else if (prevContent.membership === "ban") { - return _t('%(senderName)s unbanned %(targetName)s.', {senderName, targetName}); - } else if (prevContent.membership === "invite") { - return _t('%(senderName)s withdrew %(targetName)s\'s invitation.', { - senderName, - targetName, - }) + ' ' + reason; - } else if (prevContent.membership === "join") { - return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason; - } else { - return ""; - } - } -} - -function textForTopicEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - return _t('%(senderDisplayName)s changed the topic to "%(topic)s".', { - senderDisplayName, - topic: ev.getContent().topic, - }); -} - -function textForRoomNameEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - - if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { - return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName}); - } - if (ev.getPrevContent().name) { - return _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', { - senderDisplayName, - oldRoomName: ev.getPrevContent().name, - newRoomName: ev.getContent().name, - }); - } - return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', { - senderDisplayName, - roomName: ev.getContent().name, - }); -} - -function textForTombstoneEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - return _t('%(senderDisplayName)s upgraded this room.', {senderDisplayName}); -} - -function textForJoinRulesEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - switch (ev.getContent().join_rule) { - case "public": - return _t('%(senderDisplayName)s made the room public to whoever knows the link.', {senderDisplayName}); - case "invite": - return _t('%(senderDisplayName)s made the room invite only.', {senderDisplayName}); - default: - // The spec supports "knock" and "private", however nothing implements these. - return _t('%(senderDisplayName)s changed the join rule to %(rule)s', { - senderDisplayName, - rule: ev.getContent().join_rule, - }); - } -} - -function textForGuestAccessEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - switch (ev.getContent().guest_access) { - case "can_join": - return _t('%(senderDisplayName)s has allowed guests to join the room.', {senderDisplayName}); - case "forbidden": - return _t('%(senderDisplayName)s has prevented guests from joining the room.', {senderDisplayName}); - default: - // There's no other options we can expect, however just for safety's sake we'll do this. - return _t('%(senderDisplayName)s changed guest access to %(rule)s', { - senderDisplayName, - rule: ev.getContent().guest_access, - }); - } -} - -function textForRelatedGroupsEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - const groups = ev.getContent().groups || []; - const prevGroups = ev.getPrevContent().groups || []; - const added = groups.filter((g) => !prevGroups.includes(g)); - const removed = prevGroups.filter((g) => !groups.includes(g)); - - if (added.length && !removed.length) { - return _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', { - senderDisplayName, - groups: added.join(', '), - }); - } else if (!added.length && removed.length) { - return _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', { - senderDisplayName, - groups: removed.join(', '), - }); - } else if (added.length && removed.length) { - return _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' + - '%(oldGroups)s in this room.', { - senderDisplayName, - newGroups: added.join(', '), - oldGroups: removed.join(', '), - }); - } else { - // Don't bother rendering this change (because there were no changes) - return ''; - } -} - -function textForServerACLEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - const prevContent = ev.getPrevContent(); - const current = ev.getContent(); - const prev = { - deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], - allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], - allow_ip_literals: !(prevContent.allow_ip_literals === false), - }; - - let text = ""; - if (prev.deny.length === 0 && prev.allow.length === 0) { - text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName}); - } else { - text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName}); - } - - if (!Array.isArray(current.allow)) { - current.allow = []; - } - - // If we know for sure everyone is banned, mark the room as obliterated - if (current.allow.length === 0) { - return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used."); - } - - return text; -} - -function textForMessageEvent(ev) { - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - let message = senderDisplayName + ': ' + ev.getContent().body; - if (ev.getContent().msgtype === "m.emote") { - message = "* " + senderDisplayName + " " + message; - } else if (ev.getContent().msgtype === "m.image") { - message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); - } - return message; -} - -function textForCanonicalAliasEvent(ev) { - const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - const oldAlias = ev.getPrevContent().alias; - const oldAltAliases = ev.getPrevContent().alt_aliases || []; - const newAlias = ev.getContent().alias; - const newAltAliases = ev.getContent().alt_aliases || []; - const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias)); - const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias)); - - if (!removedAltAliases.length && !addedAltAliases.length) { - if (newAlias) { - return _t('%(senderName)s set the main address for this room to %(address)s.', { - senderName: senderName, - address: ev.getContent().alias, - }); - } else if (oldAlias) { - return _t('%(senderName)s removed the main address for this room.', { - senderName: senderName, - }); - } - } else if (newAlias === oldAlias) { - if (addedAltAliases.length && !removedAltAliases.length) { - return _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', { - senderName: senderName, - addresses: addedAltAliases.join(", "), - count: addedAltAliases.length, - }); - } if (removedAltAliases.length && !addedAltAliases.length) { - return _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', { - senderName: senderName, - addresses: removedAltAliases.join(", "), - count: removedAltAliases.length, - }); - } if (removedAltAliases.length && addedAltAliases.length) { - return _t('%(senderName)s changed the alternative addresses for this room.', { - senderName: senderName, - }); - } - } else { - // both alias and alt_aliases where modified - return _t('%(senderName)s changed the main and alternative addresses for this room.', { - senderName: senderName, - }); - } - // in case there is no difference between the two events, - // say something as we can't simply hide the tile from here - return _t('%(senderName)s changed the addresses for this room.', { - senderName: senderName, - }); -} - -function textForCallAnswerEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)'); - return _t('%(senderName)s answered the call.', {senderName}) + ' ' + supported; -} - -function textForCallHangupEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - const eventContent = event.getContent(); - let reason = ""; - if (!MatrixClientPeg.get().supportsVoip()) { - reason = _t('(not supported by this browser)'); - } else if (eventContent.reason) { - if (eventContent.reason === "ice_failed") { - // We couldn't establish a connection at all - reason = _t('(could not connect media)'); - } else if (eventContent.reason === "ice_timeout") { - // We established a connection but it died - reason = _t('(connection failed)'); - } else if (eventContent.reason === "user_media_failed") { - // The other side couldn't open capture devices - reason = _t("(their device couldn't start the camera / microphone)"); - } else if (eventContent.reason === "unknown_error") { - // An error code the other side doesn't have a way to express - // (as opposed to an error code they gave but we don't know about, - // in which case we show the error code) - reason = _t("(an error occurred)"); - } else if (eventContent.reason === "invite_timeout") { - reason = _t('(no answer)'); - } else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") { - // workaround for https://github.com/vector-im/element-web/issues/5178 - // it seems Android randomly sets a reason of "user hangup" which is - // interpreted as an error code :( - // https://github.com/vector-im/riot-android/issues/2623 - // Also the correct hangup code as of VoIP v1 (with underscore) - reason = ''; - } else { - reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason}); - } - } - return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason; -} - -function textForCallRejectEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - return _t('%(senderName)s declined the call.', {senderName}); -} - -function textForCallInviteEvent(event) { - const senderName = event.sender ? event.sender.name : _t('Someone'); - // FIXME: Find a better way to determine this from the event? - let isVoice = true; - if (event.getContent().offer && event.getContent().offer.sdp && - event.getContent().offer.sdp.indexOf('m=video') !== -1) { - isVoice = false; - } - const isSupported = MatrixClientPeg.get().supportsVoip(); - - // This ladder could be reduced down to a couple string variables, however other languages - // can have a hard time translating those strings. In an effort to make translations easier - // and more accurate, we break out the string-based variables to a couple booleans. - if (isVoice && isSupported) { - return _t("%(senderName)s placed a voice call.", {senderName}); - } else if (isVoice && !isSupported) { - return _t("%(senderName)s placed a voice call. (not supported by this browser)", {senderName}); - } else if (!isVoice && isSupported) { - return _t("%(senderName)s placed a video call.", {senderName}); - } else if (!isVoice && !isSupported) { - return _t("%(senderName)s placed a video call. (not supported by this browser)", {senderName}); - } -} - -function textForThreePidInviteEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - - if (!isValid3pidInvite(event)) { - const targetDisplayName = event.getPrevContent().display_name || _t("Someone"); - return _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', { - senderName, - targetDisplayName, - }); - } - - return _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', { - senderName, - targetDisplayName: event.getContent().display_name, - }); -} - -function textForHistoryVisibilityEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - switch (event.getContent().history_visibility) { - case 'invited': - return _t('%(senderName)s made future room history visible to all room members, ' - + 'from the point they are invited.', {senderName}); - case 'joined': - return _t('%(senderName)s made future room history visible to all room members, ' - + 'from the point they joined.', {senderName}); - case 'shared': - return _t('%(senderName)s made future room history visible to all room members.', {senderName}); - case 'world_readable': - return _t('%(senderName)s made future room history visible to anyone.', {senderName}); - default: - return _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', { - senderName, - visibility: event.getContent().history_visibility, - }); - } -} - -// Currently will only display a change if a user's power level is changed -function textForPowerEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - if (!event.getPrevContent() || !event.getPrevContent().users || - !event.getContent() || !event.getContent().users) { - return ''; - } - const userDefault = event.getContent().users_default || 0; - // Construct set of userIds - const users = []; - Object.keys(event.getContent().users).forEach( - (userId) => { - if (users.indexOf(userId) === -1) users.push(userId); - }, - ); - Object.keys(event.getPrevContent().users).forEach( - (userId) => { - if (users.indexOf(userId) === -1) users.push(userId); - }, - ); - const diff = []; - // XXX: This is also surely broken for i18n - users.forEach((userId) => { - // Previous power level - const from = event.getPrevContent().users[userId]; - // Current power level - const to = event.getContent().users[userId]; - if (to !== from) { - diff.push( - _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { - userId, - fromPowerLevel: Roles.textualPowerLevel(from, userDefault), - toPowerLevel: Roles.textualPowerLevel(to, userDefault), - }), - ); - } - }); - if (!diff.length) { - return ''; - } - return _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', { - senderName, - powerLevelDiffText: diff.join(", "), - }); -} - -function textForPinnedEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - return _t("%(senderName)s changed the pinned messages for the room.", {senderName}); -} - -function textForWidgetEvent(event) { - const senderName = event.getSender(); - const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent(); - const {name, type, url} = event.getContent() || {}; - - let widgetName = name || prevName || type || prevType || ''; - // Apply sentence case to widget name - if (widgetName && widgetName.length > 0) { - widgetName = widgetName[0].toUpperCase() + widgetName.slice(1); - } - - // If the widget was removed, its content should be {}, but this is sufficiently - // equivalent to that condition. - if (url) { - if (prevUrl) { - return _t('%(widgetName)s widget modified by %(senderName)s', { - widgetName, senderName, - }); - } else { - return _t('%(widgetName)s widget added by %(senderName)s', { - widgetName, senderName, - }); - } - } else { - return _t('%(widgetName)s widget removed by %(senderName)s', { - widgetName, senderName, - }); - } -} - -function textForWidgetLayoutEvent(event) { - const senderName = event.sender?.name || event.getSender(); - return _t("%(senderName)s has updated the widget layout", {senderName}); -} - -function textForMjolnirEvent(event) { - const senderName = event.getSender(); - const {entity: prevEntity} = event.getPrevContent(); - const {entity, recommendation, reason} = event.getContent(); - - // Rule removed - if (!entity) { - if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s removed the rule banning users matching %(glob)s", - {senderName, glob: prevEntity}); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s removed the rule banning rooms matching %(glob)s", - {senderName, glob: prevEntity}); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s removed the rule banning servers matching %(glob)s", - {senderName, glob: prevEntity}); - } - - // Unknown type. We'll say something, but we shouldn't end up here. - return _t("%(senderName)s removed a ban rule matching %(glob)s", {senderName, glob: prevEntity}); - } - - // Invalid rule - if (!recommendation || !reason) return _t(`%(senderName)s updated an invalid ban rule`, {senderName}); - - // Rule updated - if (entity === prevEntity) { - if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // Unknown type. We'll say something but we shouldn't end up here. - return _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // New rule - if (!prevEntity) { - if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // Unknown type. We'll say something but we shouldn't end up here. - return _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s", - {senderName, glob: entity, reason}); - } - - // else the entity !== prevEntity - count as a removal & add - if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + - "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}); - } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + - "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}); - } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + - "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}); - } - - // Unknown type. We'll say something but we shouldn't end up here. - return _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " + - "for %(reason)s", {senderName, oldGlob: prevEntity, newGlob: entity, reason}); -} - -const handlers = { - 'm.room.message': textForMessageEvent, - 'm.call.invite': textForCallInviteEvent, - 'm.call.answer': textForCallAnswerEvent, - 'm.call.hangup': textForCallHangupEvent, - 'm.call.reject': textForCallRejectEvent, -}; - -const stateHandlers = { - 'm.room.canonical_alias': textForCanonicalAliasEvent, - 'm.room.name': textForRoomNameEvent, - 'm.room.topic': textForTopicEvent, - 'm.room.member': textForMemberEvent, - 'm.room.third_party_invite': textForThreePidInviteEvent, - 'm.room.history_visibility': textForHistoryVisibilityEvent, - 'm.room.power_levels': textForPowerEvent, - 'm.room.pinned_events': textForPinnedEvent, - 'm.room.server_acl': textForServerACLEvent, - 'm.room.tombstone': textForTombstoneEvent, - 'm.room.join_rules': textForJoinRulesEvent, - 'm.room.guest_access': textForGuestAccessEvent, - 'm.room.related_groups': textForRelatedGroupsEvent, - - // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) - 'im.vector.modular.widgets': textForWidgetEvent, - [WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent, -}; - -// Add all the Mjolnir stuff to the renderer -for (const evType of ALL_RULE_TYPES) { - stateHandlers[evType] = textForMjolnirEvent; -} - -export function textForEvent(ev) { - const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; - if (handler) return handler(ev); - return ''; -} diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx new file mode 100644 index 0000000000..ef24fb8e48 --- /dev/null +++ b/src/TextForEvent.tsx @@ -0,0 +1,695 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { MatrixClientPeg } from './MatrixClientPeg'; +import { _t } from './languageHandler'; +import * as Roles from './Roles'; +import { isValid3pidInvite } from "./RoomInvite"; +import SettingsStore from "./settings/SettingsStore"; +import { ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./mjolnir/BanList"; +import { WIDGET_LAYOUT_EVENT_TYPE } from "./stores/widgets/WidgetLayoutStore"; +import { RightPanelPhases } from './stores/RightPanelStorePhases'; +import { Action } from './dispatcher/actions'; +import defaultDispatcher from './dispatcher/dispatcher'; +import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +// These functions are frequently used just to check whether an event has +// any text to display at all. For this reason they return deferred values +// to avoid the expense of looking up translations when they're not needed. + +function textForMemberEvent(ev): () => string | null { + // XXX: SYJS-16 "sender is sometimes null for join messages" + const senderName = ev.sender ? ev.sender.name : ev.getSender(); + const targetName = ev.target ? ev.target.name : ev.getStateKey(); + const prevContent = ev.getPrevContent(); + const content = ev.getContent(); + const reason = content.reason; + + switch (content.membership) { + case 'invite': { + const threePidContent = content.third_party_invite; + if (threePidContent) { + if (threePidContent.display_name) { + return () => _t('%(targetName)s accepted the invitation for %(displayName)s', { + targetName, + displayName: threePidContent.display_name, + }); + } else { + return () => _t('%(targetName)s accepted an invitation', { targetName }); + } + } else { + return () => _t('%(senderName)s invited %(targetName)s', { senderName, targetName }); + } + } + case 'ban': + return () => reason + ? _t('%(senderName)s banned %(targetName)s: %(reason)s', { senderName, targetName, reason }) + : _t('%(senderName)s banned %(targetName)s', { senderName, targetName }); + case 'join': + if (prevContent && prevContent.membership === 'join') { + if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { + return () => _t('%(oldDisplayName)s changed their display name to %(displayName)s', { + oldDisplayName: prevContent.displayname, + displayName: content.displayname, + }); + } else if (!prevContent.displayname && content.displayname) { + return () => _t('%(senderName)s set their display name to %(displayName)s', { + senderName: ev.getSender(), + displayName: content.displayname, + }); + } else if (prevContent.displayname && !content.displayname) { + return () => _t('%(senderName)s removed their display name (%(oldDisplayName)s)', { + senderName, + oldDisplayName: prevContent.displayname, + }); + } else if (prevContent.avatar_url && !content.avatar_url) { + return () => _t('%(senderName)s removed their profile picture', { senderName }); + } else if (prevContent.avatar_url && content.avatar_url && + prevContent.avatar_url !== content.avatar_url) { + return () => _t('%(senderName)s changed their profile picture', { senderName }); + } else if (!prevContent.avatar_url && content.avatar_url) { + return () => _t('%(senderName)s set a profile picture', { senderName }); + } else if (SettingsStore.getValue("showHiddenEventsInTimeline")) { + // This is a null rejoin, it will only be visible if using 'show hidden events' (labs) + return () => _t("%(senderName)s made no change", { senderName }); + } else { + return null; + } + } else { + if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); + return () => _t('%(targetName)s joined the room', { targetName }); + } + case 'leave': + if (ev.getSender() === ev.getStateKey()) { + if (prevContent.membership === "invite") { + return () => _t('%(targetName)s rejected the invitation', { targetName }); + } else { + return () => reason + ? _t('%(targetName)s left the room: %(reason)s', { targetName, reason }) + : _t('%(targetName)s left the room', { targetName }); + } + } else if (prevContent.membership === "ban") { + return () => _t('%(senderName)s unbanned %(targetName)s', { senderName, targetName }); + } else if (prevContent.membership === "invite") { + return () => reason + ? _t('%(senderName)s withdrew %(targetName)s\'s invitation: %(reason)s', { + senderName, + targetName, + reason, + }) + : _t('%(senderName)s withdrew %(targetName)s\'s invitation', { senderName, targetName }); + } else if (prevContent.membership === "join") { + return () => reason + ? _t('%(senderName)s kicked %(targetName)s: %(reason)s', { + senderName, + targetName, + reason, + }) + : _t('%(senderName)s kicked %(targetName)s', { senderName, targetName }); + } else { + return null; + } + } +} + +function textForTopicEvent(ev): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', { + senderDisplayName, + topic: ev.getContent().topic, + }); +} + +function textForRoomNameEvent(ev): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + + if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { + return () => _t('%(senderDisplayName)s removed the room name.', { senderDisplayName }); + } + if (ev.getPrevContent().name) { + return () => _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', { + senderDisplayName, + oldRoomName: ev.getPrevContent().name, + newRoomName: ev.getContent().name, + }); + } + return () => _t('%(senderDisplayName)s changed the room name to %(roomName)s.', { + senderDisplayName, + roomName: ev.getContent().name, + }); +} + +function textForTombstoneEvent(ev): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName }); +} + +function textForJoinRulesEvent(ev): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + switch (ev.getContent().join_rule) { + case "public": + return () => _t('%(senderDisplayName)s made the room public to whoever knows the link.', { + senderDisplayName, + }); + case "invite": + return () => _t('%(senderDisplayName)s made the room invite only.', { + senderDisplayName, + }); + default: + // The spec supports "knock" and "private", however nothing implements these. + return () => _t('%(senderDisplayName)s changed the join rule to %(rule)s', { + senderDisplayName, + rule: ev.getContent().join_rule, + }); + } +} + +function textForGuestAccessEvent(ev): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + switch (ev.getContent().guest_access) { + case "can_join": + return () => _t('%(senderDisplayName)s has allowed guests to join the room.', { senderDisplayName }); + case "forbidden": + return () => _t('%(senderDisplayName)s has prevented guests from joining the room.', { senderDisplayName }); + default: + // There's no other options we can expect, however just for safety's sake we'll do this. + return () => _t('%(senderDisplayName)s changed guest access to %(rule)s', { + senderDisplayName, + rule: ev.getContent().guest_access, + }); + } +} + +function textForRelatedGroupsEvent(ev): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const groups = ev.getContent().groups || []; + const prevGroups = ev.getPrevContent().groups || []; + const added = groups.filter((g) => !prevGroups.includes(g)); + const removed = prevGroups.filter((g) => !groups.includes(g)); + + if (added.length && !removed.length) { + return () => _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', { + senderDisplayName, + groups: added.join(', '), + }); + } else if (!added.length && removed.length) { + return () => _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', { + senderDisplayName, + groups: removed.join(', '), + }); + } else if (added.length && removed.length) { + return () => _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' + + '%(oldGroups)s in this room.', { + senderDisplayName, + newGroups: added.join(', '), + oldGroups: removed.join(', '), + }); + } else { + // Don't bother rendering this change (because there were no changes) + return null; + } +} + +function textForServerACLEvent(ev): () => string | null { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const prevContent = ev.getPrevContent(); + const current = ev.getContent(); + const prev = { + deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], + allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], + allow_ip_literals: !(prevContent.allow_ip_literals === false), + }; + + let getText = null; + if (prev.deny.length === 0 && prev.allow.length === 0) { + getText = () => _t("%(senderDisplayName)s set the server ACLs for this room.", { senderDisplayName }); + } else { + getText = () => _t("%(senderDisplayName)s changed the server ACLs for this room.", { senderDisplayName }); + } + + if (!Array.isArray(current.allow)) { + current.allow = []; + } + + // If we know for sure everyone is banned, mark the room as obliterated + if (current.allow.length === 0) { + return () => getText() + " " + + _t("🎉 All servers are banned from participating! This room can no longer be used."); + } + + return getText; +} + +function textForMessageEvent(ev): () => string | null { + return () => { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + let message = senderDisplayName + ': ' + ev.getContent().body; + if (ev.getContent().msgtype === "m.emote") { + message = "* " + senderDisplayName + " " + message; + } else if (ev.getContent().msgtype === "m.image") { + message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName }); + } + return message; + }; +} + +function textForCanonicalAliasEvent(ev): () => string | null { + const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const oldAlias = ev.getPrevContent().alias; + const oldAltAliases = ev.getPrevContent().alt_aliases || []; + const newAlias = ev.getContent().alias; + const newAltAliases = ev.getContent().alt_aliases || []; + const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias)); + const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias)); + + if (!removedAltAliases.length && !addedAltAliases.length) { + if (newAlias) { + return () => _t('%(senderName)s set the main address for this room to %(address)s.', { + senderName: senderName, + address: ev.getContent().alias, + }); + } else if (oldAlias) { + return () => _t('%(senderName)s removed the main address for this room.', { + senderName: senderName, + }); + } + } else if (newAlias === oldAlias) { + if (addedAltAliases.length && !removedAltAliases.length) { + return () => _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', { + senderName: senderName, + addresses: addedAltAliases.join(", "), + count: addedAltAliases.length, + }); + } if (removedAltAliases.length && !addedAltAliases.length) { + return () => _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', { + senderName: senderName, + addresses: removedAltAliases.join(", "), + count: removedAltAliases.length, + }); + } if (removedAltAliases.length && addedAltAliases.length) { + return () => _t('%(senderName)s changed the alternative addresses for this room.', { + senderName: senderName, + }); + } + } else { + // both alias and alt_aliases where modified + return () => _t('%(senderName)s changed the main and alternative addresses for this room.', { + senderName: senderName, + }); + } + // in case there is no difference between the two events, + // say something as we can't simply hide the tile from here + return () => _t('%(senderName)s changed the addresses for this room.', { + senderName: senderName, + }); +} + +function textForCallAnswerEvent(event): () => string | null { + return () => { + const senderName = event.sender ? event.sender.name : _t('Someone'); + const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)'); + return _t('%(senderName)s answered the call.', { senderName }) + ' ' + supported; + }; +} + +function textForCallHangupEvent(event): () => string | null { + const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); + const eventContent = event.getContent(); + let getReason = () => ""; + if (!MatrixClientPeg.get().supportsVoip()) { + getReason = () => _t('(not supported by this browser)'); + } else if (eventContent.reason) { + if (eventContent.reason === "ice_failed") { + // We couldn't establish a connection at all + getReason = () => _t('(could not connect media)'); + } else if (eventContent.reason === "ice_timeout") { + // We established a connection but it died + getReason = () => _t('(connection failed)'); + } else if (eventContent.reason === "user_media_failed") { + // The other side couldn't open capture devices + getReason = () => _t("(their device couldn't start the camera / microphone)"); + } else if (eventContent.reason === "unknown_error") { + // An error code the other side doesn't have a way to express + // (as opposed to an error code they gave but we don't know about, + // in which case we show the error code) + getReason = () => _t("(an error occurred)"); + } else if (eventContent.reason === "invite_timeout") { + getReason = () => _t('(no answer)'); + } else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") { + // workaround for https://github.com/vector-im/element-web/issues/5178 + // it seems Android randomly sets a reason of "user hangup" which is + // interpreted as an error code :( + // https://github.com/vector-im/riot-android/issues/2623 + // Also the correct hangup code as of VoIP v1 (with underscore) + getReason = () => ''; + } else { + getReason = () => _t('(unknown failure: %(reason)s)', { reason: eventContent.reason }); + } + } + return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason(); +} + +function textForCallRejectEvent(event): () => string | null { + return () => { + const senderName = event.sender ? event.sender.name : _t('Someone'); + return _t('%(senderName)s declined the call.', { senderName }); + }; +} + +function textForCallInviteEvent(event): () => string | null { + const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); + // FIXME: Find a better way to determine this from the event? + let isVoice = true; + if (event.getContent().offer && event.getContent().offer.sdp && + event.getContent().offer.sdp.indexOf('m=video') !== -1) { + isVoice = false; + } + const isSupported = MatrixClientPeg.get().supportsVoip(); + + // This ladder could be reduced down to a couple string variables, however other languages + // can have a hard time translating those strings. In an effort to make translations easier + // and more accurate, we break out the string-based variables to a couple booleans. + if (isVoice && isSupported) { + return () => _t("%(senderName)s placed a voice call.", { + senderName: getSenderName(), + }); + } else if (isVoice && !isSupported) { + return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", { + senderName: getSenderName(), + }); + } else if (!isVoice && isSupported) { + return () => _t("%(senderName)s placed a video call.", { + senderName: getSenderName(), + }); + } else if (!isVoice && !isSupported) { + return () => _t("%(senderName)s placed a video call. (not supported by this browser)", { + senderName: getSenderName(), + }); + } +} + +function textForThreePidInviteEvent(event): () => string | null { + const senderName = event.sender ? event.sender.name : event.getSender(); + + if (!isValid3pidInvite(event)) { + return () => _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', { + senderName, + targetDisplayName: event.getPrevContent().display_name || _t("Someone"), + }); + } + + return () => _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', { + senderName, + targetDisplayName: event.getContent().display_name, + }); +} + +function textForHistoryVisibilityEvent(event): () => string | null { + const senderName = event.sender ? event.sender.name : event.getSender(); + switch (event.getContent().history_visibility) { + case 'invited': + return () => _t('%(senderName)s made future room history visible to all room members, ' + + 'from the point they are invited.', { senderName }); + case 'joined': + return () => _t('%(senderName)s made future room history visible to all room members, ' + + 'from the point they joined.', { senderName }); + case 'shared': + return () => _t('%(senderName)s made future room history visible to all room members.', { senderName }); + case 'world_readable': + return () => _t('%(senderName)s made future room history visible to anyone.', { senderName }); + default: + return () => _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', { + senderName, + visibility: event.getContent().history_visibility, + }); + } +} + +// Currently will only display a change if a user's power level is changed +function textForPowerEvent(event): () => string | null { + const senderName = event.sender ? event.sender.name : event.getSender(); + if (!event.getPrevContent() || !event.getPrevContent().users || + !event.getContent() || !event.getContent().users) { + return null; + } + const previousUserDefault = event.getPrevContent().users_default || 0; + const currentUserDefault = event.getContent().users_default || 0; + // Construct set of userIds + const users = []; + Object.keys(event.getContent().users).forEach( + (userId) => { + if (users.indexOf(userId) === -1) users.push(userId); + }, + ); + Object.keys(event.getPrevContent().users).forEach( + (userId) => { + if (users.indexOf(userId) === -1) users.push(userId); + }, + ); + const diffs = []; + users.forEach((userId) => { + // Previous power level + let from = event.getPrevContent().users[userId]; + if (!Number.isInteger(from)) { + from = previousUserDefault; + } + // Current power level + let to = event.getContent().users[userId]; + if (!Number.isInteger(to)) { + to = currentUserDefault; + } + if (from === previousUserDefault && to === currentUserDefault) { return; } + if (to !== from) { + diffs.push({ userId, from, to }); + } + }); + if (!diffs.length) { + return null; + } + // XXX: This is also surely broken for i18n + return () => _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', { + senderName, + powerLevelDiffText: diffs.map(diff => + _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { + userId: diff.userId, + fromPowerLevel: Roles.textualPowerLevel(diff.from, previousUserDefault), + toPowerLevel: Roles.textualPowerLevel(diff.to, currentUserDefault), + }), + ).join(", "), + }); +} + +const onPinnedMessagesClick = (): void => { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.PinnedMessages, + allowClose: false, + }); +}; + +function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null { + if (!SettingsStore.getValue("feature_pinning")) return null; + const senderName = event.sender ? event.sender.name : event.getSender(); + + if (allowJSX) { + return () => ( + + { + _t( + "%(senderName)s changed the pinned messages for the room.", + { senderName }, + { "a": (sub) => { sub } }, + ) + } + + ); + } + + return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName }); +} + +function textForWidgetEvent(event): () => string | null { + const senderName = event.getSender(); + const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent(); + const { name, type, url } = event.getContent() || {}; + + let widgetName = name || prevName || type || prevType || ''; + // Apply sentence case to widget name + if (widgetName && widgetName.length > 0) { + widgetName = widgetName[0].toUpperCase() + widgetName.slice(1); + } + + // If the widget was removed, its content should be {}, but this is sufficiently + // equivalent to that condition. + if (url) { + if (prevUrl) { + return () => _t('%(widgetName)s widget modified by %(senderName)s', { + widgetName, senderName, + }); + } else { + return () => _t('%(widgetName)s widget added by %(senderName)s', { + widgetName, senderName, + }); + } + } else { + return () => _t('%(widgetName)s widget removed by %(senderName)s', { + widgetName, senderName, + }); + } +} + +function textForWidgetLayoutEvent(event): () => string | null { + const senderName = event.sender?.name || event.getSender(); + return () => _t("%(senderName)s has updated the widget layout", { senderName }); +} + +function textForMjolnirEvent(event): () => string | null { + const senderName = event.getSender(); + const { entity: prevEntity } = event.getPrevContent(); + const { entity, recommendation, reason } = event.getContent(); + + // Rule removed + if (!entity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s removed the rule banning users matching %(glob)s", + { senderName, glob: prevEntity }); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s removed the rule banning rooms matching %(glob)s", + { senderName, glob: prevEntity }); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s removed the rule banning servers matching %(glob)s", + { senderName, glob: prevEntity }); + } + + // Unknown type. We'll say something, but we shouldn't end up here. + return () => _t("%(senderName)s removed a ban rule matching %(glob)s", { senderName, glob: prevEntity }); + } + + // Invalid rule + if (!recommendation || !reason) return () => _t(`%(senderName)s updated an invalid ban rule`, { senderName }); + + // Rule updated + if (entity === prevEntity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return () => _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // New rule + if (!prevEntity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return () => _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s", + { senderName, glob: entity, reason }); + } + + // else the entity !== prevEntity - count as a removal & add + if (USER_RULE_TYPES.includes(event.getType())) { + return () => _t( + "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + { senderName, oldGlob: prevEntity, newGlob: entity, reason }, + ); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return () => _t( + "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + { senderName, oldGlob: prevEntity, newGlob: entity, reason }, + ); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return () => _t( + "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + { senderName, oldGlob: prevEntity, newGlob: entity, reason }, + ); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return () => _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " + + "for %(reason)s", { senderName, oldGlob: prevEntity, newGlob: entity, reason }); +} + +interface IHandlers { + [type: string]: (ev: MatrixEvent, allowJSX?: boolean) => (() => string | JSX.Element | null); +} + +const handlers: IHandlers = { + 'm.room.message': textForMessageEvent, + 'm.call.invite': textForCallInviteEvent, + 'm.call.answer': textForCallAnswerEvent, + 'm.call.hangup': textForCallHangupEvent, + 'm.call.reject': textForCallRejectEvent, +}; + +const stateHandlers: IHandlers = { + 'm.room.canonical_alias': textForCanonicalAliasEvent, + 'm.room.name': textForRoomNameEvent, + 'm.room.topic': textForTopicEvent, + 'm.room.member': textForMemberEvent, + 'm.room.third_party_invite': textForThreePidInviteEvent, + 'm.room.history_visibility': textForHistoryVisibilityEvent, + 'm.room.power_levels': textForPowerEvent, + 'm.room.pinned_events': textForPinnedEvent, + 'm.room.server_acl': textForServerACLEvent, + 'm.room.tombstone': textForTombstoneEvent, + 'm.room.join_rules': textForJoinRulesEvent, + 'm.room.guest_access': textForGuestAccessEvent, + 'm.room.related_groups': textForRelatedGroupsEvent, + + // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) + 'im.vector.modular.widgets': textForWidgetEvent, + [WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent, +}; + +// Add all the Mjolnir stuff to the renderer +for (const evType of ALL_RULE_TYPES) { + stateHandlers[evType] = textForMjolnirEvent; +} + +export function hasText(ev): boolean { + const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; + return Boolean(handler?.(ev)); +} + +export function textForEvent(ev: MatrixEvent): string; +export function textForEvent(ev: MatrixEvent, allowJSX: true): string | JSX.Element; +export function textForEvent(ev: MatrixEvent, allowJSX = false): string | JSX.Element { + const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; + return handler?.(ev, allowJSX)?.() || ''; +} diff --git a/src/Tinter.js b/src/Tinter.js deleted file mode 100644 index ca5a460e16..0000000000 --- a/src/Tinter.js +++ /dev/null @@ -1,458 +0,0 @@ -/* -Copyright 2015 OpenMarket Ltd -Copyright 2017 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -const DEBUG = 0; - -// utility to turn #rrggbb or rgb(r,g,b) into [red,green,blue] -function colorToRgb(color) { - if (!color) { - return [0, 0, 0]; - } - - if (color[0] === '#') { - color = color.slice(1); - if (color.length === 3) { - color = color[0] + color[0] + - color[1] + color[1] + - color[2] + color[2]; - } - const val = parseInt(color, 16); - const r = (val >> 16) & 255; - const g = (val >> 8) & 255; - const b = val & 255; - return [r, g, b]; - } else { - const match = color.match(/rgb\((.*?),(.*?),(.*?)\)/); - if (match) { - return [ - parseInt(match[1]), - parseInt(match[2]), - parseInt(match[3]), - ]; - } - } - return [0, 0, 0]; -} - -// utility to turn [red,green,blue] into #rrggbb -function rgbToColor(rgb) { - const val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; - return '#' + (0x1000000 + val).toString(16).slice(1); -} - -class Tinter { - constructor() { - // The default colour keys to be replaced as referred to in CSS - // (should be overridden by .mx_theme_accentColor and .mx_theme_secondaryAccentColor) - this.keyRgb = [ - "rgb(118, 207, 166)", // Vector Green - "rgb(234, 245, 240)", // Vector Light Green - "rgb(211, 239, 225)", // roomsublist-label-bg-color (20% Green overlaid on Light Green) - ]; - - // Some algebra workings for calculating the tint % of Vector Green & Light Green - // x * 118 + (1 - x) * 255 = 234 - // x * 118 + 255 - 255 * x = 234 - // x * 118 - x * 255 = 234 - 255 - // (255 - 118) x = 255 - 234 - // x = (255 - 234) / (255 - 118) = 0.16 - - // The colour keys to be replaced as referred to in SVGs - this.keyHex = [ - "#76CFA6", // Vector Green - "#EAF5F0", // Vector Light Green - "#D3EFE1", // roomsublist-label-bg-color (20% Green overlaid on Light Green) - "#FFFFFF", // white highlights of the SVGs (for switching to dark theme) - "#000000", // black lowlights of the SVGs (for switching to dark theme) - ]; - - // track the replacement colours actually being used - // defaults to our keys. - this.colors = [ - this.keyHex[0], - this.keyHex[1], - this.keyHex[2], - this.keyHex[3], - this.keyHex[4], - ]; - - // track the most current tint request inputs (which may differ from the - // end result stored in this.colors - this.currentTint = [ - undefined, - undefined, - undefined, - undefined, - undefined, - ]; - - this.cssFixups = [ - // { theme: { - // style: a style object that should be fixed up taken from a stylesheet - // attr: name of the attribute to be clobbered, e.g. 'color' - // index: ordinal of primary, secondary or tertiary - // }, - // } - ]; - - // CSS attributes to be fixed up - this.cssAttrs = [ - "color", - "backgroundColor", - "borderColor", - "borderTopColor", - "borderBottomColor", - "borderLeftColor", - ]; - - this.svgAttrs = [ - "fill", - "stroke", - ]; - - // List of functions to call when the tint changes. - this.tintables = []; - - // the currently loaded theme (if any) - this.theme = undefined; - - // whether to force a tint (e.g. after changing theme) - this.forceTint = false; - } - - /** - * Register a callback to fire when the tint changes. - * This is used to rewrite the tintable SVGs with the new tint. - * - * It's not possible to unregister a tintable callback. So this can only be - * used to register a static callback. If a set of tintables will change - * over time then the best bet is to register a single callback for the - * entire set. - * - * To ensure the tintable work happens at least once, it is also called as - * part of registration. - * - * @param {Function} tintable Function to call when the tint changes. - */ - registerTintable(tintable) { - this.tintables.push(tintable); - tintable(); - } - - getKeyRgb() { - return this.keyRgb; - } - - tint(primaryColor, secondaryColor, tertiaryColor) { - return; - // eslint-disable-next-line no-unreachable - this.currentTint[0] = primaryColor; - this.currentTint[1] = secondaryColor; - this.currentTint[2] = tertiaryColor; - - this.calcCssFixups(); - - if (DEBUG) { - console.log("Tinter.tint(" + primaryColor + ", " + - secondaryColor + ", " + - tertiaryColor + ")"); - } - - if (!primaryColor) { - primaryColor = this.keyRgb[0]; - secondaryColor = this.keyRgb[1]; - tertiaryColor = this.keyRgb[2]; - } - - if (!secondaryColor) { - const x = 0.16; // average weighting factor calculated from vector green & light green - const rgb = colorToRgb(primaryColor); - rgb[0] = x * rgb[0] + (1 - x) * 255; - rgb[1] = x * rgb[1] + (1 - x) * 255; - rgb[2] = x * rgb[2] + (1 - x) * 255; - secondaryColor = rgbToColor(rgb); - } - - if (!tertiaryColor) { - const x = 0.19; - const rgb1 = colorToRgb(primaryColor); - const rgb2 = colorToRgb(secondaryColor); - rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0]; - rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1]; - rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2]; - tertiaryColor = rgbToColor(rgb1); - } - - if (this.forceTint == false && - this.colors[0] === primaryColor && - this.colors[1] === secondaryColor && - this.colors[2] === tertiaryColor) { - return; - } - - this.forceTint = false; - - this.colors[0] = primaryColor; - this.colors[1] = secondaryColor; - this.colors[2] = tertiaryColor; - - if (DEBUG) { - console.log("Tinter.tint final: (" + primaryColor + ", " + - secondaryColor + ", " + - tertiaryColor + ")"); - } - - // go through manually fixing up the stylesheets. - this.applyCssFixups(); - - // tell all the SVGs to go fix themselves up - // we don't do this as a dispatch otherwise it will visually lag - this.tintables.forEach(function(tintable) { - tintable(); - }); - } - - tintSvgWhite(whiteColor) { - this.currentTint[3] = whiteColor; - - if (!whiteColor) { - whiteColor = this.colors[3]; - } - if (this.colors[3] === whiteColor) { - return; - } - this.colors[3] = whiteColor; - this.tintables.forEach(function(tintable) { - tintable(); - }); - } - - tintSvgBlack(blackColor) { - this.currentTint[4] = blackColor; - - if (!blackColor) { - blackColor = this.colors[4]; - } - if (this.colors[4] === blackColor) { - return; - } - this.colors[4] = blackColor; - this.tintables.forEach(function(tintable) { - tintable(); - }); - } - - - setTheme(theme) { - this.theme = theme; - - // update keyRgb from the current theme CSS itself, if it defines it - if (document.getElementById('mx_theme_accentColor')) { - this.keyRgb[0] = window.getComputedStyle( - document.getElementById('mx_theme_accentColor')).color; - } - if (document.getElementById('mx_theme_secondaryAccentColor')) { - this.keyRgb[1] = window.getComputedStyle( - document.getElementById('mx_theme_secondaryAccentColor')).color; - } - if (document.getElementById('mx_theme_tertiaryAccentColor')) { - this.keyRgb[2] = window.getComputedStyle( - document.getElementById('mx_theme_tertiaryAccentColor')).color; - } - - this.calcCssFixups(); - this.forceTint = true; - - this.tint(this.currentTint[0], this.currentTint[1], this.currentTint[2]); - - if (theme === 'dark') { - // abuse the tinter to change all the SVG's #fff to #2d2d2d - // XXX: obviously this shouldn't be hardcoded here. - this.tintSvgWhite('#2d2d2d'); - this.tintSvgBlack('#dddddd'); - } else { - this.tintSvgWhite('#ffffff'); - this.tintSvgBlack('#000000'); - } - } - - calcCssFixups() { - // cache our fixups - if (this.cssFixups[this.theme]) return; - - if (DEBUG) { - console.debug("calcCssFixups start for " + this.theme + " (checking " + - document.styleSheets.length + - " stylesheets)"); - } - - this.cssFixups[this.theme] = []; - - for (let i = 0; i < document.styleSheets.length; i++) { - const ss = document.styleSheets[i]; - try { - if (!ss) continue; // well done safari >:( - // Chromium apparently sometimes returns null here; unsure why. - // see $14534907369972FRXBx:matrix.org in HQ - // ...ah, it's because there's a third party extension like - // privacybadger inserting its own stylesheet in there with a - // resource:// URI or something which results in a XSS error. - // See also #vector:matrix.org/$145357669685386ebCfr:matrix.org - // ...except some browsers apparently return stylesheets without - // hrefs, which we have no choice but ignore right now - - // XXX seriously? we are hardcoding the name of vector's CSS file in - // here? - // - // Why do we need to limit it to vector's CSS file anyway - if there - // are other CSS files affecting the doc don't we want to apply the - // same transformations to them? - // - // Iterating through the CSS looking for matches to hack on feels - // pretty horrible anyway. And what if the application skin doesn't use - // Vector Green as its primary color? - // --richvdh - - // Yes, tinting assumes that you are using the Element skin for now. - // The right solution will be to move the CSS over to react-sdk. - // And yes, the default assets for the base skin might as well use - // Vector Green as any other colour. - // --matthew - - // stylesheets we don't have permission to access (eg. ones from extensions) have a null - // href and will throw exceptions if we try to access their rules. - if (!ss.href || !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue; - if (ss.disabled) continue; - if (!ss.cssRules) continue; - - if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href); - - for (let j = 0; j < ss.cssRules.length; j++) { - const rule = ss.cssRules[j]; - if (!rule.style) continue; - if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue; - for (let k = 0; k < this.cssAttrs.length; k++) { - const attr = this.cssAttrs[k]; - for (let l = 0; l < this.keyRgb.length; l++) { - if (rule.style[attr] === this.keyRgb[l]) { - this.cssFixups[this.theme].push({ - style: rule.style, - attr: attr, - index: l, - }); - } - } - } - } - } catch (e) { - // Catch any random exceptions that happen here: all sorts of things can go - // wrong with this (nulls, SecurityErrors) and mostly it's for other - // stylesheets that we don't want to proces anyway. We should not propagate an - // exception out since this will cause the app to fail to start. - console.log("Failed to calculate CSS fixups for a stylesheet: " + ss.href, e); - } - } - if (DEBUG) { - console.log("calcCssFixups end (" + - this.cssFixups[this.theme].length + - " fixups)"); - } - } - - applyCssFixups() { - if (DEBUG) { - console.log("applyCssFixups start (" + - this.cssFixups[this.theme].length + - " fixups)"); - } - for (let i = 0; i < this.cssFixups[this.theme].length; i++) { - const cssFixup = this.cssFixups[this.theme][i]; - try { - cssFixup.style[cssFixup.attr] = this.colors[cssFixup.index]; - } catch (e) { - // Firefox Quantum explodes if you manually edit the CSS in the - // inspector and then try to do a tint, as apparently all the - // fixups are then stale. - console.error("Failed to apply cssFixup in Tinter! ", e.name); - } - } - if (DEBUG) console.log("applyCssFixups end"); - } - - // XXX: we could just move this all into TintableSvg, but as it's so similar - // to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg) - // keeping it here for now. - calcSvgFixups(svgs) { - // go through manually fixing up SVG colours. - // we could do this by stylesheets, but keeping the stylesheets - // updated would be a PITA, so just brute-force search for the - // key colour; cache the element and apply. - - if (DEBUG) console.log("calcSvgFixups start for " + svgs); - const fixups = []; - for (let i = 0; i < svgs.length; i++) { - let svgDoc; - try { - svgDoc = svgs[i].contentDocument; - } catch (e) { - let msg = 'Failed to get svg.contentDocument of ' + svgs[i].toString(); - if (e.message) { - msg += e.message; - } - if (e.stack) { - msg += ' | stack: ' + e.stack; - } - console.error(msg); - } - if (!svgDoc) continue; - const tags = svgDoc.getElementsByTagName("*"); - for (let j = 0; j < tags.length; j++) { - const tag = tags[j]; - for (let k = 0; k < this.svgAttrs.length; k++) { - const attr = this.svgAttrs[k]; - for (let l = 0; l < this.keyHex.length; l++) { - if (tag.getAttribute(attr) && - tag.getAttribute(attr).toUpperCase() === this.keyHex[l]) { - fixups.push({ - node: tag, - attr: attr, - index: l, - }); - } - } - } - } - } - if (DEBUG) console.log("calcSvgFixups end"); - - return fixups; - } - - applySvgFixups(fixups) { - if (DEBUG) console.log("applySvgFixups start for " + fixups); - for (let i = 0; i < fixups.length; i++) { - const svgFixup = fixups[i]; - svgFixup.node.setAttribute(svgFixup.attr, this.colors[svgFixup.index]); - } - if (DEBUG) console.log("applySvgFixups end"); - } -} - -if (global.singletonTinter === undefined) { - global.singletonTinter = new Tinter(); -} -export default global.singletonTinter; diff --git a/src/Unread.js b/src/Unread.ts similarity index 78% rename from src/Unread.js rename to src/Unread.ts index 12c15eb6af..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,26 +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; } + + 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 e5bed2e812..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(); @@ -33,7 +35,7 @@ export default class VoipUserMapper { private async userToVirtualUser(userId: string): Promise { const results = await CallHandler.sharedInstance().sipVirtualLookup(userId); - if (results.length === 0) return null; + if (results.length === 0 || !results[0].fields.lookup_success) return null; return results[0].userid; } @@ -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 @@ -82,14 +94,14 @@ export default class VoipUserMapper { return Boolean(claimedNativeRoomId); } - public async onNewInvitedRoom(invitedRoom: Room) { + public async onNewInvitedRoom(invitedRoom: Room): Promise { if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return; const inviterId = invitedRoom.getDMInviter(); console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`); const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId); if (result.length === 0) { - return true; + return; } if (result[0].fields.is_virtual) { @@ -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..c5cf85facd 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -17,10 +17,10 @@ limitations under the License. import * as React from "react"; import classNames from "classnames"; -import * as sdk from "../index"; import Modal from "../Modal"; import { _t, _td } from "../languageHandler"; -import {isMac, Key} from "../Keyboard"; +import { isMac, Key } from "../Keyboard"; +import InfoDialog from "../components/views/dialogs/InfoDialog"; // TS: once languageHandler is TS we can probably inline this into the enum _td("Navigation"); @@ -57,6 +57,8 @@ export enum Modifiers { // Meta-modifier: isMac ? CMD : CONTROL export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL; +// Meta-key representing the digits [0-9] often found at the top of standard keyboard layouts +export const DIGITS = "digits"; interface IKeybind { modifiers?: Modifiers[]; @@ -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), }); @@ -372,7 +375,6 @@ export const toggleDialog = () => {
; }); - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, { className: "mx_KeyboardShortcutsDialog", title: _t("Keyboard Shortcuts"), diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index b49a90d175..87f525bdfc 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -26,8 +26,8 @@ import React, { Dispatch, } from "react"; -import {Key} from "../Keyboard"; -import {FocusHandler, Ref} from "./roving/types"; +import { Key } from "../Keyboard"; +import { FocusHandler, Ref } from "./roving/types"; /** * Module to simplify implementing the Roving TabIndex accessibility technique @@ -156,18 +156,18 @@ interface IProps { onKeyDown?(ev: React.KeyboardEvent, state: IState); } -export const RovingTabIndexProvider: React.FC = ({children, handleHomeEnd, onKeyDown}) => { +export const RovingTabIndexProvider: React.FC = ({ children, handleHomeEnd, onKeyDown }) => { const [state, dispatch] = useReducer>(reducer, { activeRef: null, refs: [], }); - const context = useMemo(() => ({state, dispatch}), [state]); + const context = useMemo(() => ({ state, dispatch }), [state]); const onKeyDownHandler = useCallback((ev) => { let handled = false; // Don't interfere with input default keydown behaviour - if (handleHomeEnd && ev.target.tagName !== "INPUT") { + if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { // check if we actually have any items switch (ev.key) { case Key.HOME: @@ -196,7 +196,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn }, [context.state, onKeyDown, handleHomeEnd]); return - { children({onKeyDownHandler}) } + { children({ onKeyDownHandler }) } ; }; @@ -218,13 +218,13 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] useLayoutEffect(() => { context.dispatch({ type: Type.Register, - payload: {ref}, + payload: { ref }, }); // teardown return () => { context.dispatch({ type: Type.Unregister, - payload: {ref}, + payload: { ref }, }); }; }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -232,7 +232,7 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] const onFocus = useCallback(() => { context.dispatch({ type: Type.SetFocus, - payload: {ref}, + payload: { ref }, }); }, [ref, context]); @@ -241,6 +241,6 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] }; // re-export the semantic helper components for simplicity -export {RovingTabIndexWrapper} from "./roving/RovingTabIndexWrapper"; -export {RovingAccessibleButton} from "./roving/RovingAccessibleButton"; -export {RovingAccessibleTooltipButton} from "./roving/RovingAccessibleTooltipButton"; +export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper"; +export { RovingAccessibleButton } from "./roving/RovingAccessibleButton"; +export { RovingAccessibleTooltipButton } from "./roving/RovingAccessibleTooltipButton"; diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx index e756d948e5..8d882fadea 100644 --- a/src/accessibility/Toolbar.tsx +++ b/src/accessibility/Toolbar.tsx @@ -16,8 +16,8 @@ limitations under the License. import React from "react"; -import {IState, RovingTabIndexProvider} from "./RovingTabIndex"; -import {Key} from "../Keyboard"; +import { IState, RovingTabIndexProvider } from "./RovingTabIndex"; +import { Key } from "../Keyboard"; interface IProps extends Omit, "onKeyDown"> { } @@ -25,7 +25,7 @@ interface IProps extends Omit, "onKeyDown"> { // This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines. // https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar // All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref` -const Toolbar: React.FC = ({children, ...props}) => { +const Toolbar: React.FC = ({ children, ...props }) => { const onKeyDown = (ev: React.KeyboardEvent, state: IState) => { const target = ev.target as HTMLElement; // Don't interfere with input default keydown behaviour @@ -62,7 +62,7 @@ const Toolbar: React.FC = ({children, ...props}) => { }; return - {({onKeyDownHandler}) =>
+ {({ onKeyDownHandler }) =>
{ children }
} ; diff --git a/src/accessibility/context_menu/MenuGroup.tsx b/src/accessibility/context_menu/MenuGroup.tsx index 9334e17a18..97f9694f83 100644 --- a/src/accessibility/context_menu/MenuGroup.tsx +++ b/src/accessibility/context_menu/MenuGroup.tsx @@ -23,7 +23,7 @@ interface IProps extends React.HTMLAttributes { } // Semantic component for representing a role=group for grouping menu radios/checkboxes -export const MenuGroup: React.FC = ({children, label, ...props}) => { +export const MenuGroup: React.FC = ({ children, label, ...props }) => { return
{ children }
; diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx index 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.js b/src/actions/MatrixActionCreators.ts similarity index 68% rename from src/actions/MatrixActionCreators.js rename to src/actions/MatrixActionCreators.ts index 93a4fcf07c..70b86f8ee5 100644 --- a/src/actions/MatrixActionCreators.js +++ b/src/actions/MatrixActionCreators.ts @@ -1,5 +1,5 @@ /* -Copyright 2017 New Vector Ltd +Copyright 2017, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,7 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import dis from '../dispatcher/dispatcher'; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; + +import dis from "../dispatcher/dispatcher"; +import { ActionPayload } from "../dispatcher/payloads"; // TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events // become dispatches in the same place. @@ -27,7 +33,7 @@ import dis from '../dispatcher/dispatcher'; * @param {string} prevState the previous sync state. * @returns {Object} an action of type MatrixActions.sync. */ -function createSyncAction(matrixClient, state, prevState) { +function createSyncAction(matrixClient: MatrixClient, state: string, prevState: string): ActionPayload { return { action: 'MatrixActions.sync', state, @@ -53,7 +59,7 @@ function createSyncAction(matrixClient, state, prevState) { * @param {MatrixEvent} accountDataEvent the account data event. * @returns {AccountDataAction} an action of type MatrixActions.accountData. */ -function createAccountDataAction(matrixClient, accountDataEvent) { +function createAccountDataAction(matrixClient: MatrixClient, accountDataEvent: MatrixEvent): ActionPayload { return { action: 'MatrixActions.accountData', event: accountDataEvent, @@ -81,7 +87,11 @@ function createAccountDataAction(matrixClient, accountDataEvent) { * @param {Room} room the room where account data was changed * @returns {RoomAccountDataAction} an action of type MatrixActions.Room.accountData. */ -function createRoomAccountDataAction(matrixClient, accountDataEvent, room) { +function createRoomAccountDataAction( + matrixClient: MatrixClient, + accountDataEvent: MatrixEvent, + room: Room, +): ActionPayload { return { action: 'MatrixActions.Room.accountData', event: accountDataEvent, @@ -106,7 +116,7 @@ function createRoomAccountDataAction(matrixClient, accountDataEvent, room) { * @param {Room} room the Room that was stored. * @returns {RoomAction} an action of type `MatrixActions.Room`. */ -function createRoomAction(matrixClient, room) { +function createRoomAction(matrixClient: MatrixClient, room: Room): ActionPayload { return { action: 'MatrixActions.Room', room }; } @@ -127,7 +137,7 @@ function createRoomAction(matrixClient, room) { * @param {Room} room the Room whose tags were changed. * @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`. */ -function createRoomTagsAction(matrixClient, roomTagsEvent, room) { +function createRoomTagsAction(matrixClient: MatrixClient, roomTagsEvent: MatrixEvent, room: Room): ActionPayload { return { action: 'MatrixActions.Room.tags', room }; } @@ -140,7 +150,7 @@ function createRoomTagsAction(matrixClient, roomTagsEvent, room) { * @param {Room} room the room the receipt happened in. * @returns {Object} an action of type MatrixActions.Room.receipt. */ -function createRoomReceiptAction(matrixClient, event, room) { +function createRoomReceiptAction(matrixClient: MatrixClient, event: MatrixEvent, room: Room): ActionPayload { return { action: 'MatrixActions.Room.receipt', event, @@ -178,7 +188,17 @@ function createRoomReceiptAction(matrixClient, event, room) { * @param {EventTimeline} data.timeline the timeline being altered. * @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`. */ -function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTimeline, removed, data) { +function createRoomTimelineAction( + matrixClient: MatrixClient, + timelineEvent: MatrixEvent, + room: Room, + toStartOfTimeline: boolean, + removed: boolean, + data: { + liveEvent: boolean; + timeline: EventTimeline; + }, +): ActionPayload { return { action: 'MatrixActions.Room.timeline', event: timelineEvent, @@ -208,8 +228,13 @@ function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTi * @param {string} oldMembership the previous membership, can be null. * @returns {RoomMembershipAction} an action of type `MatrixActions.Room.myMembership`. */ -function createSelfMembershipAction(matrixClient, room, membership, oldMembership) { - return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership}; +function createSelfMembershipAction( + matrixClient: MatrixClient, + room: Room, + membership: string, + oldMembership: string, +): ActionPayload { + return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership }; } /** @@ -228,61 +253,65 @@ function createSelfMembershipAction(matrixClient, room, membership, oldMembershi * @param {MatrixEvent} event the matrix event that was decrypted. * @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`. */ -function createEventDecryptedAction(matrixClient, event) { +function createEventDecryptedAction(matrixClient: MatrixClient, event: MatrixEvent): ActionPayload { return { action: 'MatrixActions.Event.decrypted', event }; } +type Listener = () => void; +type ActionCreator = (matrixClient: MatrixClient, ...args: any) => ActionPayload; + +// A list of callbacks to call to unregister all listeners added +let matrixClientListenersStop: Listener[] = []; + +/** + * Start listening to events of type eventName on matrixClient and when they are emitted, + * dispatch an action created by the actionCreator function. + * @param {MatrixClient} matrixClient a MatrixClient to register a listener with. + * @param {string} eventName the event to listen to on MatrixClient. + * @param {function} actionCreator a function that should return an action to dispatch + * when given the MatrixClient as an argument as well as + * arguments emitted in the MatrixClient event. + */ +function addMatrixClientListener(matrixClient: MatrixClient, eventName: string, actionCreator: ActionCreator): void { + const listener: Listener = (...args) => { + const payload = actionCreator(matrixClient, ...args); + if (payload) { + dis.dispatch(payload, true); + } + }; + matrixClient.on(eventName, listener); + matrixClientListenersStop.push(() => { + matrixClient.removeListener(eventName, listener); + }); +} + /** * This object is responsible for dispatching actions when certain events are emitted by * the given MatrixClient. */ export default { - // A list of callbacks to call to unregister all listeners added - _matrixClientListenersStop: [], - /** * Start listening to certain events from the MatrixClient and dispatch actions when * they are emitted. * @param {MatrixClient} matrixClient the MatrixClient to listen to events from */ - start(matrixClient) { - this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); - this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); - this._addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction); - this._addMatrixClientListener(matrixClient, 'Room', createRoomAction); - this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); - this._addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction); - this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction); - this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction); - this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction); - }, - - /** - * Start listening to events of type eventName on matrixClient and when they are emitted, - * dispatch an action created by the actionCreator function. - * @param {MatrixClient} matrixClient a MatrixClient to register a listener with. - * @param {string} eventName the event to listen to on MatrixClient. - * @param {function} actionCreator a function that should return an action to dispatch - * when given the MatrixClient as an argument as well as - * arguments emitted in the MatrixClient event. - */ - _addMatrixClientListener(matrixClient, eventName, actionCreator) { - const listener = (...args) => { - const payload = actionCreator(matrixClient, ...args); - if (payload) { - dis.dispatch(payload, true); - } - }; - matrixClient.on(eventName, listener); - this._matrixClientListenersStop.push(() => { - matrixClient.removeListener(eventName, listener); - }); + start(matrixClient: MatrixClient) { + addMatrixClientListener(matrixClient, 'sync', createSyncAction); + addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); + addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction); + addMatrixClientListener(matrixClient, 'Room', createRoomAction); + addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); + addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction); + addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction); + addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction); + addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction); }, /** * Stop listening to events. */ stop() { - this._matrixClientListenersStop.forEach((stopListener) => stopListener()); + matrixClientListenersStop.forEach((stopListener) => stopListener()); + matrixClientListenersStop = []; }, }; diff --git a/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index 88946ee26f..a7f629c40d 100644 --- a/src/actions/RoomListActions.ts +++ b/src/actions/RoomListActions.ts @@ -19,13 +19,13 @@ import { asyncAction } from './actionCreators'; import Modal from '../Modal'; import * as Rooms from '../Rooms'; import { _t } from '../languageHandler'; -import * as sdk from '../index'; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { AsyncActionPayload } from "../dispatcher/payloads"; import RoomListStore from "../stores/room-list/RoomListStore"; import { SortAlgorithm } from "../stores/room-list/algorithms/models"; import { DefaultTagID } from "../stores/room-list/models"; +import ErrorDialog from '../components/views/dialogs/ErrorDialog'; export default class RoomListActions { /** @@ -88,7 +88,6 @@ export default class RoomListActions { return Rooms.guessAndSetDMRoom( room, newTag === DefaultTagID.DM, ).catch((err) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to set direct chat tag " + err); Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, { title: _t('Failed to set direct chat tag'), @@ -109,10 +108,9 @@ export default class RoomListActions { const promiseToDelete = matrixClient.deleteRoomTag( roomId, oldTag, ).catch(function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to remove tag " + oldTag + " from room: " + err); Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, { - title: _t('Failed to remove tag %(tagName)s from room', {tagName: oldTag}), + title: _t('Failed to remove tag %(tagName)s from room', { tagName: oldTag }), description: ((err && err.message) ? err.message : _t('Operation failed')), }); }); @@ -129,10 +127,9 @@ export default class RoomListActions { metaData = metaData || {}; const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to add tag " + newTag + " to room: " + err); Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, { - title: _t('Failed to add tag %(tagName)s to room', {tagName: newTag}), + title: _t('Failed to add tag %(tagName)s to room', { tagName: newTag }), description: ((err && err.message) ? err.message : _t('Operation failed')), }); diff --git a/src/actions/TagOrderActions.ts b/src/actions/TagOrderActions.ts index 021cd11b55..dc538134a1 100644 --- a/src/actions/TagOrderActions.ts +++ b/src/actions/TagOrderActions.ts @@ -53,11 +53,11 @@ export default class TagOrderActions { Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); return matrixClient.setAccountData( 'im.vector.web.tag_ordering', - {tags, removedTags, _storeId: storeId}, + { tags, removedTags, _storeId: storeId }, ); }, () => { // For an optimistic update - return {tags, removedTags}; + return { tags, removedTags }; }); } @@ -100,11 +100,11 @@ export default class TagOrderActions { Analytics.trackEvent('TagOrderActions', 'removeTag'); return matrixClient.setAccountData( 'im.vector.web.tag_ordering', - {tags, removedTags, _storeId: storeId}, + { tags, removedTags, _storeId: storeId }, ); }, () => { // For an optimistic update - return {removedTags}; + return { removedTags }; }); } } diff --git a/src/actions/actionCreators.ts b/src/actions/actionCreators.ts index c789e3cd07..81e0b95098 100644 --- a/src/actions/actionCreators.ts +++ b/src/actions/actionCreators.ts @@ -51,9 +51,9 @@ export function asyncAction(id: string, fn: () => Promise, pendingFn: () => request: typeof pendingFn === 'function' ? pendingFn() : undefined, }); fn().then((result) => { - dispatch({action: id + '.success', result}); + dispatch({ action: id + '.success', result }); }).catch((err) => { - dispatch({action: id + '.failure', err}); + dispatch({ action: id + '.failure', err }); }); }; return new AsyncActionPayload(helper); diff --git a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js index de50feaedb..a19494c753 100644 --- a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js @@ -22,8 +22,8 @@ import { _t } from '../../../../languageHandler'; import SettingsStore from "../../../../settings/SettingsStore"; import EventIndexPeg from "../../../../indexing/EventIndexPeg"; -import {Action} from "../../../../dispatcher/actions"; -import {SettingLevel} from "../../../../settings/SettingLevel"; +import { Action } from "../../../../dispatcher/actions"; +import { SettingLevel } from "../../../../settings/SettingLevel"; /* * Allows the user to disable the Event Index. diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx index 0710c513da..c5c8022346 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx @@ -15,15 +15,17 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../../index'; import { _t } from '../../../../languageHandler'; import SdkConfig from '../../../../SdkConfig'; import SettingsStore from "../../../../settings/SettingsStore"; import Modal from '../../../../Modal'; -import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils"; +import { formatBytes, formatCountLong } from "../../../../utils/FormattingUtils"; import EventIndexPeg from "../../../../indexing/EventIndexPeg"; -import {SettingLevel} from "../../../../settings/SettingLevel"; +import { SettingLevel } from "../../../../settings/SettingLevel"; +import Field from '../../../../components/views/elements/Field'; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; interface IProps { onFinished: (confirmed: boolean) => void; @@ -139,13 +141,12 @@ 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); }; render() { const brand = SdkConfig.get().brand; - const Field = sdk.getComponent('views.elements.Field'); let crawlerState; if (this.state.currentRoom === null) { @@ -176,15 +177,12 @@ export default class ManageEventIndexDialog extends React.Component
); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return ( { - 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) => { @@ -310,7 +310,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

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

@@ -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} })}
-
- {content} -
+
+ {content} +
); } diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index 84cb58536a..e1254929db 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -15,16 +15,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import PropTypes from 'prop-types'; import * as sdk from '../../../../index'; -import {MatrixClientPeg} from '../../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../../MatrixClientPeg'; import FileSaver from 'file-saver'; -import {_t, _td} from '../../../../languageHandler'; +import { _t, _td } from '../../../../languageHandler'; import Modal from '../../../../Modal'; import { promptForBackupPassphrase } from '../../../../SecurityManager'; -import {copyNode} from "../../../../utils/strings"; -import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents"; +import { copyNode } from "../../../../utils/strings"; +import { SSOAuthEntry } from "../../../../components/views/auth/InteractiveAuthEntryComponents"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; import StyledRadioButton from '../../../../components/views/elements/StyledRadioButton'; import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; @@ -155,7 +155,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { backupSigStatus, }; } catch (e) { - this.setState({phase: PHASE_LOADERROR}); + this.setState({ phase: PHASE_LOADERROR }); } } @@ -385,7 +385,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _onLoadRetryClick = () => { - this.setState({phase: PHASE_LOADING}); + this.setState({ phase: PHASE_LOADING }); this._fetchBackupInfo(); } @@ -394,11 +394,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _onCancelClick = () => { - this.setState({phase: PHASE_CONFIRM_SKIP}); + this.setState({ phase: PHASE_CONFIRM_SKIP }); } _onGoBackClick = () => { - this.setState({phase: PHASE_CHOOSE_KEY_PASSPHRASE}); + this.setState({ phase: PHASE_CHOOSE_KEY_PASSPHRASE }); } _onPassPhraseNextClick = async (e) => { @@ -412,7 +412,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return; } - this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + this.setState({ phase: PHASE_PASSPHRASE_CONFIRM }); }; _onPassPhraseConfirmNextClick = async (e) => { @@ -647,7 +647,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } return

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

@@ -856,9 +856,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)} fixedWidth={false} > -
- {content} -
+
+ {content} +
); } diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js index eeb68b94bd..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; } @@ -170,8 +170,11 @@ export default class ExportE2eKeysDialog extends React.Component {
-
-
- -
-
- -
+
+ +
+
+ +
-
- -
-
- -
+
+ +
+
+ +
diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js index 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 a40ce7144d..51ab2e2cf7 100644 --- a/src/autocomplete/AutocompleteProvider.tsx +++ b/src/autocomplete/AutocompleteProvider.tsx @@ -17,7 +17,7 @@ limitations under the License. */ import React from 'react'; -import type {ICompletion, ISelectionRange} from './Autocompleter'; +import type { ICompletion, ISelectionRange } from './Autocompleter'; export interface ICommand { command: string | null; @@ -93,7 +93,12 @@ export default class AutocompleteProvider { }; } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { return []; } diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 2615736e09..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,10 @@ import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; import EmojiProvider from './EmojiProvider'; import NotifProvider from './NotifProvider'; -import {timeout} from "../utils/promise"; -import AutocompleteProvider, {ICommand} from "./AutocompleteProvider"; +import { timeout } from "../utils/promise"; +import AutocompleteProvider, { ICommand } from "./AutocompleteProvider"; +import SettingsStore from "../settings/SettingsStore"; +import SpaceProvider from "./SpaceProvider"; export interface ISelectionRange { beginning?: boolean; // whether the selection is in the first block of the editor or not @@ -52,10 +55,16 @@ 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. const PROVIDER_COMPLETION_TIMEOUT = 3000; @@ -82,15 +91,24 @@ export default class Autocompleter { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { /* Note: This intentionally waits for all providers to return, otherwise, we run into a condition where new completions are displayed while the user is interacting with the list, which makes it difficult to predict whether an action will actually do what is intended */ // list of results from each provider, each being a list of completions or null if it times out - const completionsList: ICompletion[][] = await Promise.all(this.providers.map(provider => { - return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT); + const completionsList: ICompletion[][] = await Promise.all(this.providers.map(async provider => { + return await timeout( + provider.getCompletions(query, selection, force, limit), + null, + PROVIDER_COMPLETION_TIMEOUT, + ); })); // map then filter to maintain the index for the map-operation, for this.providers to line up diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index c2d1290e08..e9a7742dee 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -18,12 +18,12 @@ limitations under the License. */ import React from 'react'; -import {_t} from '../languageHandler'; +import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import QueryMatcher from './QueryMatcher'; -import {TextualCompletion} from './Components'; -import {ICompletion, ISelectionRange} from "./Autocompleter"; -import {Command, Commands, CommandMap} from '../SlashCommands'; +import { TextualCompletion } from './Components'; +import { ICompletion, ISelectionRange } from "./Autocompleter"; +import { Command, Commands, CommandMap } from '../SlashCommands'; const COMMAND_RE = /(^\/\w*)(?: .*)?/g; @@ -34,12 +34,17 @@ export default class CommandProvider extends AutocompleteProvider { super(COMMAND_RE); this.matcher = new QueryMatcher(Commands, { keys: ['command', 'args', 'description'], - funcs: [({aliases}) => aliases.join(" ")], // aliases + funcs: [({ aliases }) => aliases.join(" ")], // aliases }); } - async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise { - const {command, range} = this.getCurrentCommand(query, selection); + async getCompletions( + query: string, + selection: ISelectionRange, + force?: boolean, + limit = -1, + ): Promise { + const { command, range } = this.getCurrentCommand(query, selection); if (!command) return []; let matches = []; @@ -55,14 +60,14 @@ export default class CommandProvider extends AutocompleteProvider { } else { if (query === '/') { // If they have just entered `/` show everything + // We exclude the limit on purpose to have a comprehensive list matches = Commands; } else { // otherwise fuzzy match against all of the fields - matches = this.matcher.match(command[1]); + matches = this.matcher.match(command[1], limit); } } - return matches.filter(cmd => cmd.isEnabled()).map((result) => { let completion = result.getCommand() + ' '; const usedAlias = result.aliases.find(alias => `/${alias}` === command[1]); diff --git a/src/autocomplete/CommunityProvider.tsx b/src/autocomplete/CommunityProvider.tsx index b7a4e0960e..de99675b4b 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 * as sdk from '../index'; -import {sortBy} from "lodash"; -import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; -import {ICompletion, ISelectionRange} from "./Autocompleter"; +import { PillCompletion } from './Components'; +import { sortBy } from "lodash"; +import { makeGroupPermalink } from "../utils/permalinks/Permalinks"; +import { ICompletion, ISelectionRange } from "./Autocompleter"; import FlairStore from "../stores/FlairStore"; -import {mediaFromMxc} from "../customisations/Media"; +import { mediaFromMxc } from "../customisations/Media"; +import BaseAvatar from '../components/views/avatars/BaseAvatar'; const COMMUNITY_REGEX = /\B\+\S*/g; @@ -50,9 +50,12 @@ export default class CommunityProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { - const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar'); - + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { // Disable autocompletions when composing commands because of various issues // (see https://github.com/vector-im/element-web/issues/4762) if (/^(\/join|\/leave)/.test(query)) { @@ -61,11 +64,11 @@ export default class CommunityProvider extends AutocompleteProvider { const cli = MatrixClientPeg.get(); let completions = []; - const {command, range} = this.getCurrentCommand(query, selection, force); + const { command, range } = this.getCurrentCommand(query, selection, force); if (command) { - const joinedGroups = cli.getGroups().filter(({myMembership}) => myMembership === 'join'); + const joinedGroups = cli.getGroups().filter(({ myMembership }) => myMembership === 'join'); - const groups = (await Promise.all(joinedGroups.map(async ({groupId}) => { + const groups = (await Promise.all(joinedGroups.map(async ({ groupId }) => { try { return FlairStore.getGroupProfileCached(cli, groupId); } catch (e) { // if FlairStore failed, fall back to just groupId @@ -81,11 +84,11 @@ export default class CommunityProvider extends AutocompleteProvider { this.matcher.setObjects(groups); const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); completions = sortBy(completions, [ (c) => score(matchedString, c.groupId), (c) => c.groupId.length, - ]).map(({avatarUrl, groupId, name}) => ({ + ]).map(({ avatarUrl, groupId, name }) => ({ completion: groupId, suffix: ' ', type: "community", diff --git a/src/autocomplete/Components.tsx b/src/autocomplete/Components.tsx index 4b0d35698d..8f155f7f55 100644 --- a/src/autocomplete/Components.tsx +++ b/src/autocomplete/Components.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {forwardRef} from 'react'; +import React, { forwardRef } from 'react'; import classNames from 'classnames'; /* These were earlier stateless functional components but had to be converted @@ -31,7 +31,7 @@ interface ITextualCompletionProps { } export const TextualCompletion = forwardRef((props, ref) => { - const {title, subtitle, description, className, ...restProps} = props; + const { title, subtitle, description, className, ...restProps } = props; return (
((props, ref) => { - const {title, subtitle, description, className, children, ...restProps} = props; + const { title, subtitle, description, className, children, ...restProps } = props; return (
{ - const {command, range} = this.getCurrentCommand(query, selection); + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { + const { command, range } = this.getCurrentCommand(query, selection); if (!query || !command) { return []; } @@ -46,7 +51,8 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { method: 'GET', }); const json = await response.json(); - const results = json.Results.map((result) => { + const maxLength = limit > -1 ? limit : json.Results.length; + const results = json.Results.slice(0, maxLength).map((result) => { return { completion: result.Text, component: ( diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 705474f8d0..2fc77e9a17 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -21,9 +21,9 @@ import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import QueryMatcher from './QueryMatcher'; -import {PillCompletion} from './Components'; -import {ICompletion, ISelectionRange} from './Autocompleter'; -import {uniq, sortBy} from 'lodash'; +import { PillCompletion } from './Components'; +import { ICompletion, ISelectionRange } from './Autocompleter'; +import { uniq, sortBy } from 'lodash'; import SettingsStore from "../settings/SettingsStore"; import { shortcodeToUnicode } from '../HtmlUtils'; import { EMOJI, IEmoji } from '../emoji'; @@ -84,16 +84,21 @@ export default class EmojiProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force?: boolean, + limit = -1, + ): Promise { if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) { return []; // don't give any suggestions if the user doesn't want them } let completions = []; - const {command, range} = this.getCurrentCommand(query, selection); + const { command, range } = this.getCurrentCommand(query, selection); if (command) { const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); // Do second match with shouldMatchWordsOnly in order to match against 'name' completions = completions.concat(this.nameMatcher.match(matchedString)); @@ -116,7 +121,7 @@ export default class EmojiProvider extends AutocompleteProvider { sorters.push((c) => c._orderBy); completions = sortBy(uniq(completions), sorters); - completions = completions.map(({shortname}) => { + completions = completions.map(({ shortname }) => { const unicode = shortcodeToUnicode(shortname); return { completion: unicode, diff --git a/src/autocomplete/NotifProvider.tsx b/src/autocomplete/NotifProvider.tsx index ef1823c0ca..31b834ccfe 100644 --- a/src/autocomplete/NotifProvider.tsx +++ b/src/autocomplete/NotifProvider.tsx @@ -15,13 +15,14 @@ limitations under the License. */ import React from 'react'; -import Room from "matrix-js-sdk/src/models/room"; +import { Room } from "matrix-js-sdk/src/models/room"; + import AutocompleteProvider from './AutocompleteProvider'; import { _t } from '../languageHandler'; -import {MatrixClientPeg} from '../MatrixClientPeg'; -import {PillCompletion} from './Components'; -import * as sdk from '../index'; -import {ICompletion, ISelectionRange} from "./Autocompleter"; +import { MatrixClientPeg } from '../MatrixClientPeg'; +import { PillCompletion } from './Components'; +import { ICompletion, ISelectionRange } from "./Autocompleter"; +import RoomAvatar from '../components/views/avatars/RoomAvatar'; const AT_ROOM_REGEX = /@\S*/g; @@ -33,14 +34,17 @@ export default class NotifProvider extends AutocompleteProvider { this.room = room; } - async getCompletions(query: string, selection: ISelectionRange, force= false): Promise { - const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); - + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { const client = MatrixClientPeg.get(); if (!this.room.currentState.mayTriggerNotifOfType('room', client.credentials.userId)) return []; - const {command, range} = this.getCurrentCommand(query, selection, force); + const { command, range } = this.getCurrentCommand(query, selection, force); if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) { return [{ completion: '@room', diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index 91fbea4d6a..3948be301c 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -16,12 +16,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {at, uniq} from 'lodash'; -import {removeHiddenChars} from "matrix-js-sdk/src/utils"; +import { at, uniq } from 'lodash'; +import { removeHiddenChars } from "matrix-js-sdk/src/utils"; interface IOptions { keys: Array; - funcs?: Array<(T) => string>; + funcs?: Array<(T) => string | string[]>; shouldMatchWordsOnly?: boolean; // whether to apply unhomoglyph and strip diacritics to fuzz up the search. Defaults to true fuzzy?: boolean; @@ -69,7 +69,12 @@ export default class QueryMatcher { if (this._options.funcs) { for (const f of this._options.funcs) { - keyValues.push(f(object)); + const v = f(object); + if (Array.isArray(v)) { + keyValues.push(...v); + } else { + keyValues.push(v); + } } } @@ -87,7 +92,7 @@ export default class QueryMatcher { } } - match(query: string): T[] { + match(query: string, limit = -1): T[] { query = this.processQuery(query); if (this._options.shouldMatchWordsOnly) { query = query.replace(/[^\w]/g, ''); @@ -107,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 })), ); } } @@ -129,7 +134,10 @@ export default class QueryMatcher { }); // Now map the keys to the result objects. Also remove any duplicates. - return uniq(matches.map((match) => match.object)); + const dedupped = uniq(matches.map((match) => match.object)); + const maxLength = limit === -1 ? dedupped.length : limit; + + return dedupped.slice(0, maxLength); } private processQuery(query: string): string { diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index 74deacf61f..7865a76daa 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -1,8 +1,7 @@ /* Copyright 2016 Aviral Dasgupta -Copyright 2017 Vector Creations Ltd -Copyright 2017, 2018 New Vector Ltd Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2017, 2018, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,27 +16,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import Room from "matrix-js-sdk/src/models/room"; +import React from "react"; +import { uniqBy, sortBy } from "lodash"; +import { Room } from "matrix-js-sdk/src/models/room"; + import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { MatrixClientPeg } from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; -import {PillCompletion} from './Components'; -import * as sdk from '../index'; -import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; -import {ICompletion, ISelectionRange} from "./Autocompleter"; -import {uniqBy, sortBy} from "lodash"; +import { PillCompletion } from './Components'; +import { makeRoomPermalink } from "../utils/permalinks/Permalinks"; +import { ICompletion, ISelectionRange } from "./Autocompleter"; +import RoomAvatar from '../components/views/avatars/RoomAvatar'; +import 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 = "") { @@ -49,7 +46,7 @@ function matcherObject(room: Room, displayedAlias: string, matchName = "") { } export default class RoomProvider extends AutocompleteProvider { - matcher: QueryMatcher; + protected matcher: QueryMatcher; constructor() { super(ROOM_REGEX); @@ -58,15 +55,28 @@ export default class RoomProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { - const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); + protected getRooms() { + const cli = MatrixClientPeg.get(); + let rooms = cli.getVisibleRooms(); - const client = MatrixClientPeg.get(); + if (SettingsStore.getValue("feature_spaces")) { + rooms = rooms.filter(r => !r.isSpaceRoom()); + } + + return rooms; + } + + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { let completions = []; - const {command, range} = this.getCurrentCommand(query, selection, force); + const { command, range } = this.getCurrentCommand(query, selection, force); if (command) { // the only reason we need to do this is because Fuse only matches on properties - let matcherObjects = client.getVisibleRooms().reduce((aliases, room) => { + let matcherObjects = this.getRooms().reduce((aliases, room) => { if (room.getCanonicalAlias()) { aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name)); } @@ -90,9 +100,9 @@ export default class RoomProvider extends AutocompleteProvider { this.matcher.setObjects(matcherObjects); const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); completions = sortBy(completions, [ - (c) => score(matchedString, c.displayedAlias), + (c) => canonicalScore(c.displayedAlias, c.room), (c) => c.displayedAlias.length, ]); completions = uniqBy(completions, (match) => match.room); @@ -110,7 +120,7 @@ export default class RoomProvider extends AutocompleteProvider { ), range, }; - }).filter((completion) => !!completion.completion && completion.completion.length > 0).slice(0, 4); + }).filter((completion) => !!completion.completion && completion.completion.length > 0); } return completions; } diff --git a/src/autocomplete/SpaceProvider.tsx b/src/autocomplete/SpaceProvider.tsx new file mode 100644 index 0000000000..1c99aee5ac --- /dev/null +++ b/src/autocomplete/SpaceProvider.tsx @@ -0,0 +1,43 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import { _t } from '../languageHandler'; +import { MatrixClientPeg } from '../MatrixClientPeg'; +import RoomProvider from "./RoomProvider"; + +export default class SpaceProvider extends RoomProvider { + protected getRooms() { + return MatrixClientPeg.get().getVisibleRooms().filter(r => r.isSpaceRoom()); + } + + getName() { + return _t("Spaces"); + } + + renderCompletions(completions: React.ReactNode[]): React.ReactNode { + return ( +
+ { completions } +
+ ); + } +} diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 5f0cfc2df1..d8f17c54d0 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -20,19 +20,19 @@ limitations under the License. import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import {PillCompletion} from './Components'; -import * as sdk from '../index'; +import { PillCompletion } from './Components'; import QueryMatcher from './QueryMatcher'; -import {sortBy} from 'lodash'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { sortBy } from 'lodash'; +import { MatrixClientPeg } from '../MatrixClientPeg'; -import MatrixEvent from "matrix-js-sdk/src/models/event"; -import Room from "matrix-js-sdk/src/models/room"; -import RoomMember from "matrix-js-sdk/src/models/room-member"; -import RoomState from "matrix-js-sdk/src/models/room-state"; -import EventTimeline from "matrix-js-sdk/src/models/event-timeline"; -import {makeUserPermalink} from "../utils/permalinks/Permalinks"; -import {ICompletion, ISelectionRange} from "./Autocompleter"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { RoomState } from "matrix-js-sdk/src/models/room-state"; +import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; +import { makeUserPermalink } from "../utils/permalinks/Permalinks"; +import { ICompletion, ISelectionRange } from "./Autocompleter"; +import MemberAvatar from '../components/views/avatars/MemberAvatar'; const USER_REGEX = /\B@\S*/g; @@ -102,14 +102,17 @@ export default class UserProvider extends AutocompleteProvider { this.users = null; }; - async getCompletions(rawQuery: string, selection: ISelectionRange, force = false): Promise { - const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); - + async getCompletions( + rawQuery: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { // lazy-load user list into matcher if (!this.users) this._makeUsers(); let completions = []; - const {command, range} = this.getCurrentCommand(rawQuery, selection, force); + const { command, range } = this.getCurrentCommand(rawQuery, selection, force); if (!command) return completions; @@ -118,7 +121,7 @@ export default class UserProvider extends AutocompleteProvider { if (fullMatch && fullMatch !== '@') { // Don't include the '@' in our search query - it's only used as a way to trigger completion const query = fullMatch.startsWith('@') ? fullMatch.substring(1) : fullMatch; - completions = this.matcher.match(query).map((user) => { + completions = this.matcher.match(query, limit).map((user) => { const displayName = (user.name || user.userId || ''); return { // Length of completion should equal length of text in decorator. draft-js @@ -153,7 +156,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.js b/src/components/structures/AutoHideScrollbar.js deleted file mode 100644 index 14f7c9ca83..0000000000 --- a/src/components/structures/AutoHideScrollbar.js +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; - -export default class AutoHideScrollbar extends React.Component { - constructor(props) { - super(props); - this._collectContainerRef = this._collectContainerRef.bind(this); - } - - _collectContainerRef(ref) { - if (ref && !this.containerRef) { - this.containerRef = ref; - } - if (this.props.wrappedRef) { - this.props.wrappedRef(ref); - } - } - - getScrollTop() { - return this.containerRef.scrollTop; - } - - render() { - return (
- { this.props.children } -
); - } -} diff --git a/src/components/structures/AutoHideScrollbar.tsx b/src/components/structures/AutoHideScrollbar.tsx new file mode 100644 index 0000000000..184d883dda --- /dev/null +++ b/src/components/structures/AutoHideScrollbar.tsx @@ -0,0 +1,69 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { HTMLAttributes, WheelEvent } from "react"; + +interface IProps extends Omit, "onScroll"> { + className?: string; + onScroll?: (event: Event) => void; + onWheel?: (event: WheelEvent) => void; + style?: React.CSSProperties; + tabIndex?: number; + wrappedRef?: (ref: HTMLDivElement) => void; +} + +export default class AutoHideScrollbar extends React.Component { + private containerRef: React.RefObject = React.createRef(); + + public componentDidMount() { + if (this.containerRef.current && this.props.onScroll) { + // Using the passive option to not block the main thread + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners + this.containerRef.current.addEventListener("scroll", this.props.onScroll, { passive: true }); + } + + if (this.props.wrappedRef) { + this.props.wrappedRef(this.containerRef.current); + } + } + + public componentWillUnmount() { + if (this.containerRef.current && this.props.onScroll) { + this.containerRef.current.removeEventListener("scroll", this.props.onScroll); + } + } + + public getScrollTop(): number { + return this.containerRef.current.scrollTop; + } + + public render() { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { className, onScroll, onWheel, style, tabIndex, wrappedRef, children, ...otherProps } = this.props; + + return (
+ { children } +
); + } +} diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 9d9d57d8a6..407dc6f04c 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -16,13 +16,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {CSSProperties, RefObject, useRef, useState} from "react"; +import React, { CSSProperties, RefObject, useRef, useState } from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; -import {Key} from "../../Keyboard"; -import {Writeable} from "../../@types/common"; -import {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 // of doing reusable widgets like dialog boxes & menus where we go and @@ -222,10 +223,12 @@ export class ContextMenu extends React.PureComponent { }; private onKeyDown = (ev: React.KeyboardEvent) => { + // don't let keyboard handling escape the context menu + ev.stopPropagation(); + if (!this.props.managed) { if (ev.key === Key.ESCAPE) { this.props.onFinished(); - ev.stopPropagation(); ev.preventDefault(); } return; @@ -258,7 +261,6 @@ export class ContextMenu extends React.PureComponent { if (handled) { // consume all other keys in context menu - ev.stopPropagation(); ev.preventDefault(); } }; @@ -369,7 +371,7 @@ export class ContextMenu extends React.PureComponent { return (
@@ -397,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, @@ -409,12 +411,12 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None const buttonBottom = elementRect.bottom + window.pageYOffset; const buttonTop = elementRect.top + window.pageYOffset; // Align the right edge of the menu to the right edge of the button - menuOptions.right = window.innerWidth - buttonRight; + menuOptions.right = UIStore.instance.windowWidth - buttonRight; // Align the menu vertically on whichever side of the button has more space available. - if (buttonBottom < window.innerHeight / 2) { + if (buttonBottom < UIStore.instance.windowHeight / 2) { menuOptions.top = buttonBottom + vPadding; } else { - menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding; + menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding; } return menuOptions; @@ -429,12 +431,12 @@ export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFac const buttonBottom = elementRect.bottom + window.pageYOffset; const buttonTop = elementRect.top + window.pageYOffset; // Align the right edge of the menu to the right edge of the button - menuOptions.right = window.innerWidth - buttonRight; + menuOptions.right = UIStore.instance.windowWidth - buttonRight; // Align the menu vertically on whichever side of the button has more space available. - if (buttonBottom < window.innerHeight / 2) { + if (buttonBottom < UIStore.instance.windowHeight / 2) { menuOptions.top = buttonBottom + vPadding; } else { - menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding; + menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding; } return menuOptions; @@ -450,7 +452,7 @@ export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFa // Align the left edge of the menu to the left edge of the button menuOptions.left = buttonLeft; // Align the menu vertically above the menu - menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding; + menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding; return menuOptions; }; @@ -496,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 79% rename from src/components/structures/FilePanel.js rename to src/components/structures/FilePanel.tsx index 32db5c251c..36f774a130 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.tsx @@ -16,40 +16,58 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {Filter} from 'matrix-js-sdk/src/filter'; -import * as sdk from '../../index'; -import {MatrixClientPeg} from '../../MatrixClientPeg'; +import { Filter } from 'matrix-js-sdk/src/filter'; +import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; +import { Direction } from "matrix-js-sdk/src/models/event-timeline"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from 'matrix-js-sdk/src/models/room'; +import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; + +import { MatrixClientPeg } from '../../MatrixClientPeg'; import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from '../../languageHandler'; import BaseCard from "../views/right_panel/BaseCard"; -import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; -import DesktopBuildsNotice, {WarningKind} from "../views/elements/DesktopBuildsNotice"; -import {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'; +import TimelinePanel from "./TimelinePanel"; +import Spinner from "../views/elements/Spinner"; +import { TileShape } from '../views/rooms/EventTile'; + +interface IProps { + roomId: string; + onClose: () => void; + resizeNotifier: ResizeNotifier; +} + +interface IState { + timelineSet: EventTimelineSet; +} /* * Component which shows the filtered file using a TimelinePanel */ @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; + const client = MatrixClientPeg.get(); + client.decryptEventIfNeeded(ev); + if (ev.isBeingDecrypted()) { this.decryptingEvents.add(ev.getId()); } else { @@ -57,7 +75,7 @@ class FilePanel extends React.Component { } }; - onEventDecrypted = (ev, err) => { + private onEventDecrypted = (ev: MatrixEvent, err?: any): void => { if (ev.getRoomId() !== this.props.roomId) return; const eventId = ev.getId(); @@ -67,7 +85,7 @@ class FilePanel extends React.Component { this.addEncryptedLiveEvent(ev); }; - addEncryptedLiveEvent(ev, toStartOfTimeline) { + public addEncryptedLiveEvent(ev: MatrixEvent): void { if (!this.state.timelineSet) return; const timeline = this.state.timelineSet.getLiveTimeline(); @@ -81,7 +99,7 @@ class FilePanel extends React.Component { } } - async componentDidMount() { + public async componentDidMount(): Promise { const client = MatrixClientPeg.get(); await this.updateTimelineSet(this.props.roomId); @@ -102,7 +120,7 @@ class FilePanel extends React.Component { } } - componentWillUnmount() { + public componentWillUnmount(): void { const client = MatrixClientPeg.get(); if (client === null) return; @@ -114,7 +132,7 @@ class FilePanel extends React.Component { } } - async fetchFileEventsServer(room) { + public async fetchFileEventsServer(room: Room): Promise { const client = MatrixClientPeg.get(); const filter = new Filter(client.credentials.userId); @@ -138,7 +156,11 @@ class FilePanel extends React.Component { return timelineSet; } - onPaginationRequest = (timelineWindow, direction, limit) => { + private onPaginationRequest = ( + timelineWindow: TimelineWindow, + direction: Direction, + limit: number, + ): Promise => { const client = MatrixClientPeg.get(); const eventIndex = EventIndexPeg.get(); const roomId = this.props.roomId; @@ -156,7 +178,7 @@ class FilePanel extends React.Component { } }; - async updateTimelineSet(roomId: string) { + public async updateTimelineSet(roomId: string): Promise { const client = MatrixClientPeg.get(); const room = client.getRoom(roomId); const eventIndex = EventIndexPeg.get(); @@ -192,7 +214,7 @@ class FilePanel extends React.Component { } } - render() { + public render() { if (MatrixClientPeg.get().isGuest()) { return
- { _t("You must register to use this functionality", - {}, - { 'a': (sub) => { sub } }) - } + { _t("You must register to use this functionality", + {}, + { 'a': (sub) => { sub } }) + }
; } else if (this.noRoom) { @@ -217,8 +239,6 @@ class FilePanel extends React.Component { } // wrap a TimelinePanel with the jump-to-event bits turned off. - const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); - const Loader = sdk.getComponent("elements.Spinner"); const emptyState = (

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

@@ -244,7 +264,7 @@ class FilePanel extends React.Component { timelineSet={this.state.timelineSet} showUrlPreview = {false} onPaginationRequest={this.onPaginationRequest} - tileShape="file_grid" + tileShape={TileShape.FileGrid} resizeNotifier={this.props.resizeNotifier} empty={emptyState} /> @@ -257,7 +277,7 @@ class FilePanel extends React.Component { onClose={this.props.onClose} previousPhase={RightPanelPhases.RoomSummary} > - + ); } diff --git a/src/components/structures/GenericErrorPage.js b/src/components/structures/GenericErrorPage.js index cfd2016d47..c9ed4ae622 100644 --- a/src/components/structures/GenericErrorPage.js +++ b/src/components/structures/GenericErrorPage.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("structures.GenericErrorPage") export default class GenericErrorPage extends React.PureComponent { diff --git a/src/components/structures/GroupFilterPanel.js b/src/components/structures/GroupFilterPanel.js index 976b2d81a5..5d1be64f25 100644 --- a/src/components/structures/GroupFilterPanel.js +++ b/src/components/structures/GroupFilterPanel.js @@ -24,13 +24,12 @@ import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; import { _t } from '../../languageHandler'; -import { Droppable } from 'react-beautiful-dnd'; import classNames from 'classnames'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; import SettingsStore from "../../settings/SettingsStore"; import UserTagTile from "../views/elements/UserTagTile"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; @replaceableComponent("structures.GroupFilterPanel") class GroupFilterPanel extends React.Component { @@ -83,15 +82,15 @@ class GroupFilterPanel extends React.Component { } }; - onMouseDown = e => { + onClick = e => { // only dispatch if its not a no-op if (this.state.selectedTags.length > 0) { - dis.dispatch({action: 'deselect_tags'}); + dis.dispatch({ action: 'deselect_tags' }); } }; onClearFilterClick = ev => { - dis.dispatch({action: 'deselect_tags'}); + dis.dispatch({ action: 'deselect_tags' }); }; renderGlobalIcon() { @@ -123,12 +122,19 @@ class GroupFilterPanel extends React.Component { mx_GroupFilterPanel_items_selected: itemsSelected, }); + let betaDot; + if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) { + betaDot =
; + } + let createButton = ( + className="mx_TagTile mx_TagTile_plus"> + { betaDot } + ); if (SettingsStore.getValue("feature_communities_v2_prototypes")) { @@ -144,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 ed6167cbe7..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,16 +34,16 @@ 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 {allSettled, sleep} from "../../utils/promise"; +import { makeGroupPermalink, makeUserPermalink } from "../../utils/permalinks/Permalinks"; +import { Group } from "matrix-js-sdk/src/models/group"; +import { sleep } from "matrix-js-sdk/src/utils"; import RightPanelStore from "../../stores/RightPanelStore"; import AutoHideScrollbar from "./AutoHideScrollbar"; -import {mediaFromMxc} from "../../customisations/Media"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../customisations/Media"; +import { replaceableComponent } from "../../utils/replaceableComponent"; const LONG_DESC_PLACEHOLDER = _td( -`

HTML for your community's page

+ `

HTML for your community's page

Use the long description to introduce new members to the community, or distribute some important links @@ -99,7 +99,7 @@ class CategoryRoomList extends React.Component { onFinished: (success, addrs) => { if (!success) return; const errorList = []; - allSettled(addrs.map((addr) => { + Promise.allSettled(addrs.map((addr) => { return GroupStore .addRoomToGroupSummary(this.props.groupId, addr.address) .catch(() => { errorList.push(addr.address); }); @@ -110,26 +110,27 @@ class CategoryRoomList extends React.Component { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog( 'Failed to add the following room to the group summary', - '', ErrorDialog, - { - title: _t( - "Failed to add the following rooms to the summary of %(groupId)s:", - {groupId: this.props.groupId}, - ), - description: errorList.join(", "), - }); + '', + ErrorDialog, + { + title: _t( + "Failed to add the following rooms to the summary of %(groupId)s:", + { groupId: this.props.groupId }, + ), + description: errorList.join(", "), + }, + ); }); }, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); }; render() { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); const addButton = this.props.editing ? ( - +

{ _t('Add a Room') }
@@ -146,8 +147,8 @@ class CategoryRoomList extends React.Component { let catHeader =
; if (this.props.category && this.props.category.profile) { catHeader =
- { this.props.category.profile.name } -
; + { this.props.category.profile.name } +
; } return
{ catHeader } @@ -190,13 +191,14 @@ class FeaturedRoom extends React.Component { Modal.createTrackedDialog( 'Failed to remove room from group summary', '', ErrorDialog, - { - title: _t( - "Failed to remove the room from the summary of %(groupId)s", - {groupId: this.props.groupId}, - ), - description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}), - }); + { + title: _t( + "Failed to remove the room from the summary of %(groupId)s", + { groupId: this.props.groupId }, + ), + description: _t("The room '%(roomName)s' could not be removed from the summary.", { roomName }), + }, + ); }); }; @@ -271,7 +273,7 @@ class RoleUserList extends React.Component { onFinished: (success, addrs) => { if (!success) return; const errorList = []; - allSettled(addrs.map((addr) => { + Promise.allSettled(addrs.map((addr) => { return GroupStore .addUserToGroupSummary(addr.address) .catch(() => { errorList.push(addr.address); }); @@ -283,27 +285,27 @@ class RoleUserList extends React.Component { Modal.createTrackedDialog( 'Failed to add the following users to the community summary', '', ErrorDialog, - { - title: _t( - "Failed to add the following users to the summary of %(groupId)s:", - {groupId: this.props.groupId}, - ), - description: errorList.join(", "), - }); + { + title: _t( + "Failed to add the following users to the summary of %(groupId)s:", + { groupId: this.props.groupId }, + ), + description: errorList.join(", "), + }, + ); }); }, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); }; render() { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); const addButton = this.props.editing ? ( - -
- { _t('Add a User') } -
-
) :
; + +
+ { _t('Add a User') } +
+ ) :
; const userNodes = this.props.users.map((u) => { return { - dis.dispatch({action: 'close_settings'}); + dis.dispatch({ action: 'close_settings' }); }; _onNameChange = (value) => { @@ -614,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({ @@ -626,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, { @@ -643,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({ @@ -682,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. @@ -691,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"), @@ -701,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. @@ -710,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"), @@ -721,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. @@ -734,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"), @@ -767,8 +774,8 @@ export default class GroupView extends React.Component { title: _t("Leave Community"), description: ( - { _t("Leave %(groupName)s?", {groupName: this.props.groupId}) } - { warnings } + { _t("Leave %(groupName)s?", { groupName: this.props.groupId }) } + { warnings } ), button: _t("Leave"), @@ -776,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. @@ -785,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"), @@ -849,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'); @@ -865,7 +871,7 @@ export default class GroupView extends React.Component { onClick={this._onAddRoomsClick} >
- +
{ _t('Add rooms to this community') } @@ -1055,10 +1061,11 @@ export default class GroupView extends React.Component { return null; } - const membershipButtonClasses = classnames([ - 'mx_RoomHeader_textButton', - 'mx_GroupView_textButton', - ], + const membershipButtonClasses = classnames( + [ + 'mx_RoomHeader_textButton', + 'mx_GroupView_textButton', + ], membershipButtonExtraClasses, ); @@ -1329,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 { @@ -1339,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..4ed160d493 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 { @@ -96,6 +96,7 @@ const HomePage: React.FC = ({ justRegistered = false }) => { const pageUrl = getHomePageUrl(config); if (pageUrl) { + // FIXME: Using an import will result in wrench-element-tests failures const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); return ; } @@ -117,7 +118,6 @@ const HomePage: React.FC = ({ justRegistered = false }) => { ; } - return
{ introSection } diff --git a/src/components/structures/HostSignupAction.tsx b/src/components/structures/HostSignupAction.tsx index 769775d549..41dc8a6da8 100644 --- a/src/components/structures/HostSignupAction.tsx +++ b/src/components/structures/HostSignupAction.tsx @@ -22,17 +22,20 @@ 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 {} +interface IProps { + onClick?(): void; +} interface IState {} @replaceableComponent("structures.HostSignupAction") export default class HostSignupAction extends React.PureComponent { private openDialog = async () => { + this.props.onClick?.(); await HostSignupStore.instance.setHostSignupActive(true); - } + }; public render(): React.ReactNode { const hostSignupConfig = SdkConfig.get().hostSignup; diff --git a/src/components/structures/IndicatorScrollbar.js b/src/components/structures/IndicatorScrollbar.js index 341ab2df71..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 { @@ -59,7 +59,9 @@ export default class IndicatorScrollbar extends React.Component { _collectScroller(scroller) { if (scroller && !this._scrollElement) { this._scrollElement = scroller; - this._scrollElement.addEventListener("scroll", this.checkOverflow); + // Using the passive option to not block the main thread + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners + this._scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true }); this.checkOverflow(); } } @@ -68,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; @@ -183,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 e4762e35ad..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,10 +40,11 @@ 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"; interface IProps { isMinimized: boolean; @@ -66,6 +68,7 @@ const cssClasses = [ @replaceableComponent("structures.LeftPanel") export default class LeftPanel extends React.Component { + private ref: React.RefObject = createRef(); private listContainerRef: React.RefObject = createRef(); private groupFilterPanelWatcherRef: string; private bgImageWatcherRef: string; @@ -88,12 +91,16 @@ 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") }); }); + } - // We watch the middle panel because we don't actually get resized, the middle panel does. - // We listen to the noisy channel to avoid choppy reaction times. - this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); + public componentDidMount() { + UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current); + UIStore.instance.on("ListContainer", this.refreshStickyHeaders); + // Using the passive option to not block the main thread + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners + this.listContainerRef.current?.addEventListener("scroll", this.onScroll, { passive: true }); } public componentWillUnmount() { @@ -103,21 +110,38 @@ export default class LeftPanel extends React.Component { RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); - this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize); + UIStore.instance.stopTrackingElementDimensions("ListContainer"); + UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders); + this.listContainerRef.current?.removeEventListener("scroll", this.onScroll); + } + + public componentDidUpdate(prevProps: IProps, prevState: IState): void { + if (prevState.activeSpace !== this.state.activeSpace) { + this.refreshStickyHeaders(); + } } private updateActiveSpace = (activeSpace: Room) => { this.setState({ activeSpace }); }; + private onDialPad = () => { + dis.fire(Action.OpenDialPad); + }; + private onExplore = () => { dis.fire(Action.ViewRoomDirectory); }; + private refreshStickyHeaders = () => { + if (!this.listContainerRef.current) return; // ignore: no headers to sticky + this.handleStickyHeaders(this.listContainerRef.current); + }; + private onBreadcrumbsUpdate = () => { const newVal = BreadcrumbsStore.instance.visible; if (newVal !== this.state.showBreadcrumbs) { - this.setState({showBreadcrumbs: newVal}); + this.setState({ showBreadcrumbs: newVal }); // Update the sticky headers too as the breadcrumbs will be popping in or out. if (!this.listContainerRef.current) return; // ignore: no headers to sticky @@ -156,9 +180,6 @@ export default class LeftPanel extends React.Component { const bottomEdge = list.offsetHeight + list.scrollTop; const sublists = list.querySelectorAll(".mx_RoomSublist:not(.mx_RoomSublist_hidden)"); - const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles - const headerStickyWidth = list.clientWidth - headerRightMargin; - // We track which styles we want on a target before making the changes to avoid // excessive layout updates. const targetStyles = new Map { header.classList.add("mx_RoomSublist_headerContainer_stickyBottom"); } - const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight); + const offset = UIStore.instance.windowHeight - + (list.parentElement.offsetTop + list.parentElement.offsetHeight); const newBottom = `${offset}px`; if (header.style.bottom !== newBottom) { header.style.bottom = newBottom; @@ -247,14 +269,20 @@ export default class LeftPanel extends React.Component { header.classList.add("mx_RoomSublist_headerContainer_sticky"); } - const newWidth = `${headerStickyWidth}px`; - if (header.style.width !== newWidth) { - header.style.width = newWidth; + const listDimensions = UIStore.instance.getElementDimensions("ListContainer"); + if (listDimensions) { + const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles + const headerStickyWidth = listDimensions.width - headerRightMargin; + const newWidth = `${headerStickyWidth}px`; + if (header.style.width !== newWidth) { + header.style.width = newWidth; + } } } else if (!style.stickyTop && !style.stickyBottom) { if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) { header.classList.remove("mx_RoomSublist_headerContainer_sticky"); } + if (header.style.width) { header.style.removeProperty('width'); } @@ -276,16 +304,11 @@ export default class LeftPanel extends React.Component { } } - private onScroll = (ev: React.MouseEvent) => { + private onScroll = (ev: Event) => { const list = ev.target as HTMLDivElement; this.handleStickyHeaders(list); }; - private onResize = () => { - if (!this.listContainerRef.current) return; // ignore: no headers to sticky - this.handleStickyHeaders(this.listContainerRef.current); - }; - private onFocus = (ev: React.FocusEvent) => { this.focusedElement = ev.target; }; @@ -347,7 +370,7 @@ export default class LeftPanel extends React.Component { if (element) { classes = element.classList; } - } while (element && !cssClasses.some(c => classes.contains(c))); + } while (element && (!cssClasses.some(c => classes.contains(c)) || element.offsetParent === null)); if (element) { element.focus(); @@ -379,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} + { const roomList = ; const containerClasses = classNames({ @@ -435,17 +475,16 @@ export default class LeftPanel extends React.Component { ); return ( -
+
{leftLeftPanel}
); diff --git a/src/components/structures/LeftPanelWidget.tsx b/src/components/structures/LeftPanelWidget.tsx index e88af282ba..e0b597b883 100644 --- a/src/components/structures/LeftPanelWidget.tsx +++ b/src/components/structures/LeftPanelWidget.tsx @@ -14,29 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useContext, useEffect, useMemo} from "react"; -import {Resizable} from "re-resizable"; +import React, { useContext, useMemo } from "react"; +import { Resizable } from "re-resizable"; import classNames from "classnames"; import AccessibleButton from "../views/elements/AccessibleButton"; -import {useRovingTabIndex} from "../../accessibility/RovingTabIndex"; -import {Key} from "../../Keyboard"; -import {useLocalStorageState} from "../../hooks/useLocalStorageState"; +import { useRovingTabIndex } from "../../accessibility/RovingTabIndex"; +import { Key } from "../../Keyboard"; +import { useLocalStorageState } from "../../hooks/useLocalStorageState"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils"; -import {useAccountData} from "../../hooks/useAccountData"; +import WidgetUtils, { IWidgetEvent } from "../../utils/WidgetUtils"; +import { useAccountData } from "../../hooks/useAccountData"; import AppTile from "../views/elements/AppTile"; -import {useSettingValue} from "../../hooks/useSettings"; - -interface IProps { - onResize(): void; -} +import { useSettingValue } from "../../hooks/useSettings"; +import UIStore from "../../stores/UIStore"; const MIN_HEIGHT = 100; const MAX_HEIGHT = 500; // or 50% of the window height const INITIAL_HEIGHT = 280; -const LeftPanelWidget: React.FC = ({ onResize }) => { +const LeftPanelWidget: React.FC = () => { const cli = useContext(MatrixClientContext); const mWidgetsEvent = useAccountData>(cli, "m.widgets"); @@ -56,7 +53,6 @@ const LeftPanelWidget: React.FC = ({ onResize }) => { const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT); const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true); - useEffect(onResize, [expanded, onResize]); const [onFocus, isActive, ref] = useRovingTabIndex(); const tabIndex = isActive ? 0 : -1; @@ -66,15 +62,14 @@ const LeftPanelWidget: React.FC = ({ onResize }) => { let content; if (expanded) { content = { setHeight(height + d.height); }} handleWrapperClass="mx_LeftPanelWidget_resizerHandles" - handleClasses={{top: "mx_LeftPanelWidget_resizerHandle"}} + handleClasses={{ top: "mx_LeftPanelWidget_resizerHandle" }} className="mx_LeftPanelWidget_resizeBox" enable={{ top: true }} > diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 0255a3bf35..89fa8db376 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -19,21 +19,17 @@ limitations under the License. import * as React from 'react'; import * as PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; -import { DragDropContext } from 'react-beautiful-dnd'; -import {Key} from '../../Keyboard'; +import { Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; -import CallMediaHandler from '../../CallMediaHandler'; +import MediaDeviceHandler from '../../MediaDeviceHandler'; import { fixupColorFonts } from '../../utils/FontManager'; -import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; -import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg'; +import { IMatrixClientCreds } from '../../MatrixClientPeg'; import SettingsStore from "../../settings/SettingsStore"; -import TagOrderActions from '../../actions/TagOrderActions'; -import RoomListActions from '../../actions/RoomListActions'; import ResizeHandle from '../views/elements/ResizeHandle'; -import {Resizer, CollapseDistributor} from '../../resizer'; +import { Resizer, CollapseDistributor } from '../../resizer'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts"; import HomePage from "./HomePage"; @@ -51,14 +47,22 @@ import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPay import RoomListStore from "../../stores/room-list/RoomListStore"; import NonUrgentToastContainer from "./NonUrgentToastContainer"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; -import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import Modal from "../../Modal"; import { ICollapseConfig } from "../../resizer/distributors/collapse"; import HostSignupContainer from '../views/host_signup/HostSignupContainer'; import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBindingsManager'; import { IOpts } from "../../createRoom"; import SpacePanel from "../views/spaces/SpacePanel"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import CallHandler, { CallHandlerEvent } from '../../CallHandler'; +import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; +import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall'; +import RoomView from './RoomView'; +import ToastContainer from './ToastContainer'; +import MyGroups from "./MyGroups"; +import UserView from "./UserView"; +import GroupView from "./GroupView"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -78,17 +82,17 @@ interface IProps { hideToSRUsers: boolean; resizeNotifier: ResizeNotifier; // eslint-disable-next-line camelcase - page_type: string; - autoJoin: boolean; + page_type?: string; + autoJoin?: boolean; threepidInvite?: IThreepidInvite; - roomOobData?: object; + roomOobData?: IOOBData; currentRoomId: string; collapseLhs: boolean; config: { piwik: { policyUrl: string; - }, - [key: string]: any, + }; + [key: string]: any; }; currentUserId?: string; currentGroupId?: string; @@ -119,6 +123,7 @@ interface IState { usageLimitEventContent?: IUsageLimit; usageLimitEventTs?: number; useCompactLayout: boolean; + activeCalls: Array; } /** @@ -160,12 +165,13 @@ class LoggedInView extends React.Component { // use compact timeline view useCompactLayout: SettingsStore.getValue('useCompactLayout'), usageLimitDismissed: false, + activeCalls: [], }; // stash the MatrixClient in case we log out before we are unmounted this._matrixClient = this.props.matrixClient; - CallMediaHandler.loadDevices(); + MediaDeviceHandler.loadDevices(); fixupColorFonts(); @@ -175,6 +181,7 @@ class LoggedInView extends React.Component { componentDidMount() { document.addEventListener('keydown', this._onNativeKeyDown, false); + CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged); this._updateServerNoticeEvents(); @@ -199,6 +206,7 @@ class LoggedInView extends React.Component { componentWillUnmount() { document.removeEventListener('keydown', this._onNativeKeyDown, false); + CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged); this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); @@ -206,15 +214,11 @@ class LoggedInView extends React.Component { this.resizer.detach(); } - // Child components assume that the client peg will not be null, so give them some - // sort of assurance here by only allowing a re-render if the client is truthy. - // - // This is required because `LoggedInView` maintains its own state and if this state - // updates after the client peg has been made null (during logout), then it will - // attempt to re-render and the children will throw errors. - shouldComponentUpdate() { - return Boolean(MatrixClientPeg.get()); - } + private onCallsChanged = () => { + this.setState({ + activeCalls: CallHandler.sharedInstance().getAllActiveCalls(), + }); + }; canResetTimelineInRoom = (roomId) => { if (!this._roomView.current) { @@ -232,10 +236,10 @@ class LoggedInView extends React.Component { onCollapsed: (_collapsed) => { collapsed = _collapsed; if (_collapsed) { - dis.dispatch({action: "hide_left_panel"}); + dis.dispatch({ action: "hide_left_panel" }); window.localStorage.setItem("mx_lhs_size", '0'); } else { - dis.dispatch({action: "show_left_panel"}); + dis.dispatch({ action: "show_left_panel" }); } }, onResized: (_size) => { @@ -272,7 +276,7 @@ class LoggedInView extends React.Component { onAccountData = (event) => { if (event.getType() === "m.ignored_user_list") { - dis.dispatch({action: "ignore_state_changed"}); + dis.dispatch({ action: "ignore_state_changed" }); } }; @@ -319,7 +323,7 @@ class LoggedInView extends React.Component { this.setState({ usageLimitDismissed: true, }); - } + }; _calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) { const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; @@ -355,7 +359,7 @@ class LoggedInView extends React.Component { const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM); for (const eventId of pinnedEventIds) { - const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0); + const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId); const event = timeline.getEvents().find(ev => ev.getId() === eventId); if (event) events.push(event); } @@ -394,7 +398,7 @@ class LoggedInView extends React.Component { // refocusing during a paste event will make the // paste end up in the newly focused element, // so dispatch synchronously before paste happens - dis.fire(Action.FocusComposer, true); + dis.fire(Action.FocusSendMessageComposer, true); } }; @@ -548,7 +552,7 @@ class LoggedInView extends React.Component { if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) { // synchronous dispatch so we focus before key generates input - dis.fire(Action.FocusComposer, true); + dis.fire(Action.FocusSendMessageComposer, true); ev.stopPropagation(); // we should *not* preventDefault() here as // that would prevent typing in the now-focussed composer @@ -566,57 +570,7 @@ class LoggedInView extends React.Component { } }; - _onDragEnd = (result) => { - // Dragged to an invalid destination, not onto a droppable - if (!result.destination) { - return; - } - - const dest = result.destination.droppableId; - - if (dest === 'tag-panel-droppable') { - // Could be "GroupTile +groupId:domain" - const draggableId = result.draggableId.split(' ').pop(); - - // Dispatch synchronously so that the GroupFilterPanel receives an - // optimistic update from GroupFilterOrderStore before the previous - // state is shown. - dis.dispatch(TagOrderActions.moveTag( - this._matrixClient, - draggableId, - result.destination.index, - ), true); - } else if (dest.startsWith('room-sub-list-droppable_')) { - this._onRoomTileEndDrag(result); - } - }; - - _onRoomTileEndDrag = (result) => { - let newTag = result.destination.droppableId.split('_')[1]; - let prevTag = result.source.droppableId.split('_')[1]; - if (newTag === 'undefined') newTag = undefined; - if (prevTag === 'undefined') prevTag = undefined; - - const roomId = result.draggableId.split('_')[1]; - - const oldIndex = result.source.index; - const newIndex = result.destination.index; - - dis.dispatch(RoomListActions.tagRoom( - this._matrixClient, - this._matrixClient.getRoom(roomId), - prevTag, newTag, - oldIndex, newIndex, - ), true); - }; - render() { - const RoomView = sdk.getComponent('structures.RoomView'); - const UserView = sdk.getComponent('structures.UserView'); - const GroupView = sdk.getComponent('structures.GroupView'); - const MyGroups = sdk.getComponent('structures.MyGroups'); - const ToastContainer = sdk.getComponent('structures.ToastContainer'); - let pageElement; switch (this.props.page_type) { @@ -661,6 +615,12 @@ class LoggedInView extends React.Component { bodyClasses += ' mx_MatrixChat_useCompactLayout'; } + const audioFeedArraysForCalls = this.state.activeCalls.map((call) => { + return ( + + ); + }); + return (
{ aria-hidden={this.props.hideToSRUsers} > - -
- { SettingsStore.getValue("feature_spaces") ? : null } - - - { pageElement } -
-
+
+ { SettingsStore.getValue("feature_spaces") ? : null } + + + { pageElement } +
+ {audioFeedArraysForCalls}
); } diff --git a/src/components/structures/MainSplit.js b/src/components/structures/MainSplit.js index 5818d303fc..69d3bd0b51 100644 --- a/src/components/structures/MainSplit.js +++ b/src/components/structures/MainSplit.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import { Resizable } from 're-resizable'; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; @replaceableComponent("structures.MainSplit") export default class MainSplit extends React.Component { @@ -73,7 +73,7 @@ export default class MainSplit extends React.Component { onResize={this._onResize} onResizeStop={this._onResizeStop} className="mx_RightPanel_ResizeWrapper" - handleClasses={{left: "mx_RightPanel_ResizeHandle"}} + handleClasses={{ left: "mx_RightPanel_ResizeHandle" }} > { panelView }
; diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 078b296295..d692b0fa7f 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -19,6 +19,8 @@ import { createClient } from "matrix-js-sdk/src/matrix"; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { sleep, defer, IDeferred } from "matrix-js-sdk/src/utils"; + // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss import 'focus-visible'; // what-input helps improve keyboard accessibility @@ -34,8 +36,6 @@ import dis from "../../dispatcher/dispatcher"; import Notifier from '../../Notifier'; import Modal from "../../Modal"; -import Tinter from "../../Tinter"; -import * as sdk from '../../index'; import { showRoomInviteDialog, showStartChatInviteDialog } from '../../RoomInvite'; import * as Rooms from '../../Rooms'; import linkifyMatrix from "../../linkify-matrix"; @@ -44,11 +44,11 @@ import * as Lifecycle from '../../Lifecycle'; import '../../stores/LifecycleStore'; import PageTypes from '../../PageTypes'; -import createRoom, {IOpts} from "../../createRoom"; -import {_t, _td, getCurrentLanguage} from '../../languageHandler'; +import createRoom, { IOpts } from "../../createRoom"; +import { _t, _td, getCurrentLanguage } from '../../languageHandler'; import SettingsStore from "../../settings/SettingsStore"; import ThemeController from "../../settings/controllers/ThemeController"; -import { startAnyRegistrationFlow } from "../../Registration.js"; +import { startAnyRegistrationFlow } from "../../Registration"; import { messageForSyncError } from '../../utils/ErrorUtils'; import ResizeNotifier from "../../utils/ResizeNotifier"; import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils"; @@ -56,7 +56,6 @@ import DMRoomMap from '../../utils/DMRoomMap'; import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import { FontWatcher } from '../../settings/watchers/FontWatcher'; import { storeRoomAliasInCache } from '../../RoomAliasCache'; -import { defer, IDeferred, sleep } from "../../utils/promise"; import ToastStore from "../../stores/ToastStore"; import * as StorageManager from "../../utils/StorageManager"; import type LoggedInViewType from "./LoggedInView"; @@ -66,7 +65,7 @@ import { showToast as showAnalyticsToast, hideToast as hideAnalyticsToast, } from "../../toasts/AnalyticsToast"; -import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast"; +import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import ErrorDialog from "../views/dialogs/ErrorDialog"; import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; @@ -74,17 +73,38 @@ import { SettingLevel } from "../../settings/SettingLevel"; import { leaveRoomBehaviour } from "../../utils/membership"; import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog"; import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore"; -import {UIFeature} from "../../settings/UIFeature"; +import { UIFeature } from "../../settings/UIFeature"; import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; import DialPadModal from "../views/voip/DialPadModal"; import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast'; import { shouldUseLoginForWelcome } from "../../utils/pages"; import SpaceStore from "../../stores/SpaceStore"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; import RoomListStore from "../../stores/room-list/RoomListStore"; -import {RoomUpdateCause} from "../../stores/room-list/models"; +import { RoomUpdateCause } from "../../stores/room-list/models"; import defaultDispatcher from "../../dispatcher/dispatcher"; import SecurityCustomisations from "../../customisations/Security"; +import Spinner from "../views/elements/Spinner"; +import QuestionDialog from "../views/dialogs/QuestionDialog"; +import UserSettingsDialog from '../views/dialogs/UserSettingsDialog'; +import CreateGroupDialog from '../views/dialogs/CreateGroupDialog'; +import CreateRoomDialog from '../views/dialogs/CreateRoomDialog'; +import RoomDirectory from './RoomDirectory'; +import KeySignatureUploadFailedDialog from "../views/dialogs/KeySignatureUploadFailedDialog"; +import IncomingSasDialog from "../views/dialogs/IncomingSasDialog"; +import CompleteSecurity from "./auth/CompleteSecurity"; +import LoggedInView from './LoggedInView'; +import Welcome from "../views/auth/Welcome"; +import ForgotPassword from "./auth/ForgotPassword"; +import E2eSetup from "./auth/E2eSetup"; +import Registration from './auth/Registration'; +import Login from "./auth/Login"; +import ErrorBoundary from '../views/elements/ErrorBoundary'; +import VerificationRequestToast from '../views/toasts/VerificationRequestToast'; + +import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; +import UIStore, { UI_EVENTS } from "../../stores/UIStore"; +import SoftLogout from './auth/SoftLogout'; /** constants for MatrixChat.state.view */ export enum Views { @@ -153,7 +173,12 @@ interface IRoomInfo { /* eslint-enable camelcase */ interface IProps { // TODO type things better - config: Record; + config: { + piwik: { + policyUrl: string; + }; + [key: string]: any; + }; serverConfig?: ValidatedServerConfig; onNewScreen: (screen: string, replaceLast: boolean) => void; enableGuest?: boolean; @@ -201,7 +226,7 @@ interface IState { resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; ready: boolean; - threepidInvite?: IThreepidInvite, + threepidInvite?: IThreepidInvite; roomOobData?: object; pendingInitialSync?: boolean; justRegistered?: boolean; @@ -223,13 +248,13 @@ export default class MatrixChat extends React.PureComponent { firstSyncPromise: IDeferred; private screenAfterLogin?: IScreen; - private windowWidth: number; private pageChanging: boolean; private tokenLogin?: boolean; private accountPassword?: string; private accountPasswordTimer?: NodeJS.Timeout; private focusComposer: boolean; private subTitleStatus: string; + private prevWindowWidth: number; private readonly loggedInView: React.RefObject; private readonly dispatcherRef: any; @@ -275,17 +300,11 @@ export default class MatrixChat extends React.PureComponent { } } - this.windowWidth = 10000; - this.handleResize(); - window.addEventListener('resize', this.handleResize); + this.prevWindowWidth = UIStore.instance.windowWidth || 1000; + UIStore.instance.on(UI_EVENTS.Resize, this.handleResize); this.pageChanging = false; - // check we have the right tint applied for this theme. - // N.B. we don't call the whole of setTheme() here as we may be - // racing with the theme CSS download finishing from index.js - Tinter.tint(); - // For PersistentElement this.state.resizeNotifier.on("middlePanelResized", this.dispatchTimelineResize); @@ -376,7 +395,7 @@ export default class MatrixChat extends React.PureComponent { this.onLoggedIn(); } - const promisesList = [this.firstSyncPromise.promise]; + const promisesList: Promise[] = [this.firstSyncPromise.promise]; if (cryptoEnabled) { // wait for the client to finish downloading cross-signing keys for us so we // know whether or not we have keys set up on this account @@ -399,7 +418,7 @@ export default class MatrixChat extends React.PureComponent { if (SecurityCustomisations.SHOW_ENCRYPTION_SETUP_UI === false) { this.onLoggedIn(); } else { - this.setStateForNewView({view: Views.COMPLETE_SECURITY}); + this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); } } else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) { this.setStateForNewView({ view: Views.E2E_SETUP }); @@ -424,7 +443,7 @@ export default class MatrixChat extends React.PureComponent { CountlyAnalytics.instance.trackPageChange(durationMs); } if (this.focusComposer) { - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); this.focusComposer = false; } } @@ -434,7 +453,7 @@ export default class MatrixChat extends React.PureComponent { dis.unregister(this.dispatcherRef); this.themeWatcher.stop(); this.fontWatcher.stop(); - window.removeEventListener('resize', this.handleResize); + UIStore.destroy(); this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize); if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer); @@ -452,7 +471,7 @@ export default class MatrixChat extends React.PureComponent { let props = this.state.serverConfig; if (!props) props = this.props.serverConfig; // for unit tests if (!props) props = SdkConfig.get()["validated_server_config"]; - return {serverConfig: props}; + return { serverConfig: props }; } private loadSession() { @@ -470,9 +489,9 @@ export default class MatrixChat extends React.PureComponent { if (!loadedSession) { // fall back to showing the welcome screen... unless we have a 3pid invite pending if (ThreepidInviteStore.instance.pickBestInvite()) { - dis.dispatch({action: 'start_registration'}); + dis.dispatch({ action: 'start_registration' }); } else { - dis.dispatch({action: "view_welcome_page"}); + dis.dispatch({ action: "view_welcome_page" }); } } else if (SettingsStore.getValue("analyticsOptIn")) { CountlyAnalytics.instance.enable(/* anonymous = */ false); @@ -484,42 +503,22 @@ export default class MatrixChat extends React.PureComponent { } startPageChangeTimer() { - // Tor doesn't support performance - if (!performance || !performance.mark) return null; - - // This shouldn't happen because UNSAFE_componentWillUpdate and componentDidUpdate - // are used. - if (this.pageChanging) { - console.warn('MatrixChat.startPageChangeTimer: timer already started'); - return; - } - this.pageChanging = true; - performance.mark('element_MatrixChat_page_change_start'); + PerformanceMonitor.instance.start(PerformanceEntryNames.PAGE_CHANGE); } stopPageChangeTimer() { - // Tor doesn't support performance - if (!performance || !performance.mark) return null; + const perfMonitor = PerformanceMonitor.instance; - if (!this.pageChanging) { - console.warn('MatrixChat.stopPageChangeTimer: timer not started'); - return; - } - this.pageChanging = false; - performance.mark('element_MatrixChat_page_change_stop'); - performance.measure( - 'element_MatrixChat_page_change_delta', - 'element_MatrixChat_page_change_start', - 'element_MatrixChat_page_change_stop', - ); - performance.clearMarks('element_MatrixChat_page_change_start'); - performance.clearMarks('element_MatrixChat_page_change_stop'); - const measurement = performance.getEntriesByName('element_MatrixChat_page_change_delta').pop(); + perfMonitor.stop(PerformanceEntryNames.PAGE_CHANGE); - // In practice, sometimes the entries list is empty, so we get no measurement - if (!measurement) return null; + const entries = perfMonitor.getEntries({ + name: PerformanceEntryNames.PAGE_CHANGE, + }); + const measurement = entries.pop(); - return measurement.duration; + return measurement + ? measurement.duration + : null; } shouldTrackPageChange(prevState: IState, state: IState) { @@ -542,7 +541,6 @@ export default class MatrixChat extends React.PureComponent { onAction = (payload) => { // console.log(`MatrixClientPeg.onAction: ${payload.action}`); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); // Start the onboarding process for certain actions if (MatrixClientPeg.get() && MatrixClientPeg.get().isGuest() && @@ -556,7 +554,7 @@ export default class MatrixChat extends React.PureComponent { action: 'do_after_sync_prepared', deferred_action: payload, }); - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); return; } @@ -581,11 +579,11 @@ export default class MatrixChat extends React.PureComponent { } // redispatch the change with a more specific action - dis.dispatch({action: 'id_server_changed'}); + dis.dispatch({ action: 'id_server_changed' }); } break; case 'logout': - dis.dispatch({action: "hangup_all"}); + dis.dispatch({ action: "hangup_all" }); Lifecycle.logout(); break; case 'require_registration': @@ -636,13 +634,12 @@ export default class MatrixChat extends React.PureComponent { onFinished: (confirm) => { if (confirm) { // FIXME: controller shouldn't be loading a view :( - const Loader = sdk.getComponent("elements.Spinner"); - const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); + const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); MatrixClientPeg.get().leave(payload.room_id).then(() => { modal.close(); if (this.state.currentRoomId === payload.room_id) { - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({ action: 'view_home_page' }); } }, (err) => { modal.close(); @@ -673,9 +670,8 @@ export default class MatrixChat extends React.PureComponent { } case Action.ViewUserSettings: { const tabPayload = payload as OpenToTabPayload; - const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog"); Modal.createTrackedDialog('User settings', '', UserSettingsDialog, - {initialTabId: tabPayload.initialTabId}, + { initialTabId: tabPayload.initialTabId }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); // View the welcome or home page if we need something to look at @@ -683,14 +679,15 @@ export default class MatrixChat extends React.PureComponent { break; } case 'view_create_room': - this.createRoom(payload.public); + this.createRoom(payload.public, payload.defaultName); break; case 'view_create_group': { - let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog") - if (SettingsStore.getValue("feature_communities_v2_prototypes")) { - CreateGroupDialog = CreateCommunityPrototypeDialog; - } - Modal.createTrackedDialog('Create Community', '', CreateGroupDialog); + const prototype = SettingsStore.getValue("feature_communities_v2_prototypes"); + Modal.createTrackedDialog( + 'Create Community', + '', + prototype ? CreateCommunityPrototypeDialog : CreateGroupDialog, + ); break; } case Action.ViewRoomDirectory: { @@ -700,7 +697,6 @@ export default class MatrixChat extends React.PureComponent { room_id: SpaceStore.instance.activeSpace.roomId, }); } else { - const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); Modal.createTrackedDialog('Room directory', '', RoomDirectory, { initialText: payload.initialText, }, 'mx_RoomDirectory_dialogWrapper', false, true); @@ -740,12 +736,14 @@ export default class MatrixChat extends React.PureComponent { this.showScreenAfterLogin(); break; case 'toggle_my_groups': + // persist that the user has interacted with this, use it to dismiss the beta dot + localStorage.setItem("mx_seenSpacesBeta", "1"); // We just dispatch the page change rather than have to worry about // what the logic is for each of these branches. if (this.state.page_type === PageTypes.MyGroups) { - dis.dispatch({action: 'view_last_screen'}); + dis.dispatch({ action: 'view_last_screen' }); } else { - dis.dispatch({action: 'view_my_groups'}); + dis.dispatch({ action: 'view_my_groups' }); } break; case 'hide_left_panel': @@ -786,7 +784,7 @@ export default class MatrixChat extends React.PureComponent { this.onLoggedOut(); break; case 'will_start_client': - this.setState({ready: false}, () => { + this.setState({ ready: false }, () => { // if the client is about to start, we are, by definition, not ready. // Set ready to false now, then it'll be set to true when the sync // listener we set below fires. @@ -906,6 +904,11 @@ export default class MatrixChat extends React.PureComponent { let presentedId = roomInfo.room_alias || roomInfo.room_id; const room = MatrixClientPeg.get().getRoom(roomInfo.room_id); if (room) { + // Not all timeline events are decrypted ahead of time anymore + // Only the critical ones for a typical UI are + // This will start the decryption process for all events when a + // user views a room + room.decryptAllEvents(); const theAlias = Rooms.getDisplayAliasForRoom(room); if (theAlias) { presentedId = theAlias; @@ -1017,12 +1020,12 @@ export default class MatrixChat extends React.PureComponent { return; } this.notifyNewScreen('user/' + userId); - this.setState({currentUserId: userId}); + this.setState({ currentUserId: userId }); this.setPage(PageTypes.UserView); }); } - private async createRoom(defaultPublic = false) { + private async createRoom(defaultPublic = false, defaultName?: string) { const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId(); if (communityId) { // double check the user will have permission to associate this room with the community @@ -1035,8 +1038,10 @@ export default class MatrixChat extends React.PureComponent { } } - const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); - const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic }); + const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { + defaultPublic, + defaultName, + }); const [shouldCreate, opts] = await modal.finished; if (shouldCreate) { @@ -1094,7 +1099,7 @@ export default class MatrixChat extends React.PureComponent { private leaveRoomWarnings(roomId: string) { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); - const isSpace = roomToLeave?.isSpaceRoom(); + const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); // Show a warning if there are additional complications. const warnings = []; @@ -1129,18 +1134,23 @@ export default class MatrixChat extends React.PureComponent { } private leaveRoom(roomId: string) { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const warnings = this.leaveRoomWarnings(roomId); - const isSpace = roomToLeave?.isSpaceRoom(); + const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { title: isSpace ? _t("Leave space") : _t("Leave room"), description: ( { isSpace - ? _t("Are you sure you want to leave the space '%(spaceName)s'?", {spaceName: roomToLeave.name}) - : _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) } + ? _t( + "Are you sure you want to leave the space '%(spaceName)s'?", + { spaceName: roomToLeave.name }, + ) + : _t( + "Are you sure you want to leave the room '%(roomName)s'?", + { roomName: roomToLeave.name }, + )} { warnings } ), @@ -1150,8 +1160,7 @@ export default class MatrixChat extends React.PureComponent { const d = leaveRoomBehaviour(roomId); // FIXME: controller shouldn't be loading a view :( - const Loader = sdk.getComponent("elements.Spinner"); - const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); + const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); d.finally(() => modal.close()); dis.dispatch({ @@ -1178,7 +1187,7 @@ export default class MatrixChat extends React.PureComponent { }).catch((err) => { const errCode = err.errcode || _td("unknown error code"); Modal.createTrackedDialog("Failed to forget room", '', ErrorDialog, { - title: _t("Failed to forget room %(errCode)s", {errCode}), + title: _t("Failed to forget room %(errCode)s", { errCode }), description: ((err && err.message) ? err.message : _t("Operation failed")), }); }); @@ -1262,7 +1271,7 @@ export default class MatrixChat extends React.PureComponent { if (welcomeUserRoom === null) { // We didn't redirect to the welcome user room, so show // the homepage. - dis.dispatch({action: 'view_home_page', justRegistered: true}); + dis.dispatch({ action: 'view_home_page', justRegistered: true }); } } else if (ThreepidInviteStore.instance.pickBestInvite()) { // The user has a 3pid invite pending - show them that @@ -1271,11 +1280,11 @@ export default class MatrixChat extends React.PureComponent { // HACK: This is a pretty brutal way of threading the invite back through // our systems, but it's the safest we have for now. const params = ThreepidInviteStore.instance.translateToWireFormat(threepidInvite); - this.showScreen(`room/${threepidInvite.roomId}`, params) + this.showScreen(`room/${threepidInvite.roomId}`, params); } else { // The user has just logged in after registering, // so show the homepage. - dis.dispatch({action: 'view_home_page', justRegistered: true}); + dis.dispatch({ action: 'view_home_page', justRegistered: true }); } } else { this.showScreenAfterLogin(); @@ -1311,9 +1320,9 @@ export default class MatrixChat extends React.PureComponent { this.viewLastRoom(); } else { if (MatrixClientPeg.get().isGuest()) { - dis.dispatch({action: 'view_welcome_page'}); + dis.dispatch({ action: 'view_welcome_page' }); } else { - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({ action: 'view_home_page' }); } } } @@ -1393,15 +1402,15 @@ export default class MatrixChat extends React.PureComponent { // So dispatch directly from here. Ideally we'd use a SyncStateStore that // would do this dispatch and expose the sync state itself (by listening to // its own dispatch). - dis.dispatch({action: 'sync_state', prevState, state}); + dis.dispatch({ action: 'sync_state', prevState, state }); if (state === "ERROR" || state === "RECONNECTING") { if (data.error instanceof InvalidStoreError) { Lifecycle.handleInvalidStoreError(data.error); } - this.setState({syncError: data.error || true}); + this.setState({ syncError: data.error || true }); } else if (this.state.syncError) { - this.setState({syncError: null}); + this.setState({ syncError: null }); } this.updateStatusIndicator(state, prevState); @@ -1418,7 +1427,7 @@ export default class MatrixChat extends React.PureComponent { showNotificationsToast(false); } - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); this.setState({ ready: true, }); @@ -1446,7 +1455,6 @@ export default class MatrixChat extends React.PureComponent { }); }); cli.on('no_consent', function(message, consentUri) { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('No Consent Dialog', '', QuestionDialog, { title: _t('Terms and Conditions'), description:
@@ -1469,7 +1477,7 @@ export default class MatrixChat extends React.PureComponent { }); const dft = new DecryptionFailureTracker((total, errorCode) => { - Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total); + Analytics.trackEvent('E2E', 'Decryption failure', errorCode, String(total)); CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total }); }, (errorCode) => { // Map JS-SDK error codes to tracker codes for aggregation @@ -1555,8 +1563,6 @@ export default class MatrixChat extends React.PureComponent { }); cli.on("crypto.keySignatureUploadFailure", (failures, source, continuation) => { - const KeySignatureUploadFailedDialog = - sdk.getComponent('views.dialogs.KeySignatureUploadFailedDialog'); Modal.createTrackedDialog( 'Failed to upload key signatures', 'Failed to upload key signatures', @@ -1566,7 +1572,6 @@ export default class MatrixChat extends React.PureComponent { cli.on("crypto.verification.request", request => { if (request.verifier) { - const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog"); Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, { verifier: request.verifier, }, null, /* priority = */ false, /* static = */ true); @@ -1575,16 +1580,12 @@ export default class MatrixChat extends React.PureComponent { key: 'verifreq_' + request.channel.transactionId, title: _t("Verification requested"), icon: "verification", - props: {request}, - component: sdk.getComponent("toasts.VerificationRequestToast"), + props: { request }, + component: VerificationRequestToast, priority: 90, }); } }); - // Fire the tinter right on startup to ensure the default theme is applied - // A later sync can/will correct the tint to be the right value for the user - const colorScheme = SettingsStore.getValue("roomColor"); - Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color); } /** @@ -1625,11 +1626,13 @@ export default class MatrixChat extends React.PureComponent { action: 'start_registration', params: params, }); + PerformanceMonitor.instance.start(PerformanceEntryNames.REGISTER); } else if (screen === 'login') { dis.dispatch({ action: 'start_login', params: params, }); + PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN); } else if (screen === 'forgot_password') { dis.dispatch({ action: 'start_password_recovery', @@ -1674,7 +1677,7 @@ export default class MatrixChat extends React.PureComponent { // TODO if logged in, skip SSO let cli = MatrixClientPeg.get(); if (!cli) { - const {hsUrl, isUrl} = this.props.serverConfig; + const { hsUrl, isUrl } = this.props.serverConfig; cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, @@ -1684,6 +1687,10 @@ export default class MatrixChat extends React.PureComponent { const type = screen === "start_sso" ? "sso" : "cas"; PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin()); } else if (screen === 'groups') { + if (SettingsStore.getValue("feature_spaces")) { + dis.dispatch({ action: "view_home_page" }); + return; + } dis.dispatch({ action: 'view_my_groups', }); @@ -1767,6 +1774,11 @@ export default class MatrixChat extends React.PureComponent { subAction: params.action, }); } else if (screen.indexOf('group/') === 0) { + if (SettingsStore.getValue("feature_spaces")) { + dis.dispatch({ action: "view_home_page" }); + return; + } + const groupId = screen.substring(6); // TODO: Check valid group ID @@ -1789,7 +1801,7 @@ export default class MatrixChat extends React.PureComponent { onAliasClick(event: MouseEvent, alias: string) { event.preventDefault(); - dis.dispatch({action: 'view_room', room_alias: alias}); + dis.dispatch({ action: 'view_room', room_alias: alias }); } onUserClick(event: MouseEvent, userId: string) { @@ -1805,7 +1817,7 @@ export default class MatrixChat extends React.PureComponent { onGroupClick(event: MouseEvent, groupId: string) { event.preventDefault(); - dis.dispatch({action: 'view_group', group_id: groupId}); + dis.dispatch({ action: 'view_group', group_id: groupId }); } onLogoutClick(event: React.MouseEvent) { @@ -1817,18 +1829,19 @@ export default class MatrixChat extends React.PureComponent { } handleResize = () => { - const hideLhsThreshold = 1000; - const showLhsThreshold = 1000; + const LHS_THRESHOLD = 1000; + const width = UIStore.instance.windowWidth; - if (this.windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) { - dis.dispatch({ action: 'hide_left_panel' }); - } - if (this.windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) { + if (this.prevWindowWidth < LHS_THRESHOLD && width >= LHS_THRESHOLD) { dis.dispatch({ action: 'show_left_panel' }); } + if (this.prevWindowWidth >= LHS_THRESHOLD && width < LHS_THRESHOLD) { + dis.dispatch({ action: 'hide_left_panel' }); + } + + this.prevWindowWidth = width; this.state.resizeNotifier.notifyWindowResized(); - this.windowWidth = window.innerWidth; }; private dispatchTimelineResize() { @@ -1866,14 +1879,14 @@ export default class MatrixChat extends React.PureComponent { onSendEvent(roomId: string, event: MatrixEvent) { const cli = MatrixClientPeg.get(); if (!cli) { - dis.dispatch({action: 'message_send_failed'}); + dis.dispatch({ action: 'message_send_failed' }); return; } cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => { - dis.dispatch({action: 'message_sent'}); + dis.dispatch({ action: 'message_sent' }); }, (err) => { - dis.dispatch({action: 'message_send_failed'}); + dis.dispatch({ action: 'message_send_failed' }); }); } @@ -1920,7 +1933,7 @@ export default class MatrixChat extends React.PureComponent { } onServerConfigChange = (serverConfig: ValidatedServerConfig) => { - this.setState({serverConfig}); + this.setState({ serverConfig }); }; private makeRegistrationUrl = (params: {[key: string]: string}) => { @@ -1949,6 +1962,9 @@ export default class MatrixChat extends React.PureComponent { // Create and start the client await Lifecycle.setLoggedIn(credentials); await this.postLoginSetup(); + + PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN); + PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER); }; // complete security / e2e setup has finished @@ -1973,21 +1989,18 @@ export default class MatrixChat extends React.PureComponent { let view = null; if (this.state.view === Views.LOADING) { - const Spinner = sdk.getComponent('elements.Spinner'); view = (
); } else if (this.state.view === Views.COMPLETE_SECURITY) { - const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity'); view = ( ); } else if (this.state.view === Views.E2E_SETUP) { - const E2eSetup = sdk.getComponent('structures.auth.E2eSetup'); view = ( { * we should go through and figure out what we actually need to pass down, as well * as using something like redux to avoid having a billion bits of state kicking around. */ - const LoggedInView = sdk.getComponent('structures.LoggedInView'); view = ( { ref={this.loggedInView} matrixClient={MatrixClientPeg.get()} onRoomCreated={this.onRoomCreated} - onCloseAllSettings={this.onCloseAllSettings} onRegistered={this.onRegistered} currentRoomId={this.state.currentRoomId} /> ); } else { // we think we are logged in, but are still waiting for the /sync to complete - const Spinner = sdk.getComponent('elements.Spinner'); let errorBox; if (this.state.syncError && !isStoreError) { errorBox =
@@ -2041,10 +2051,8 @@ export default class MatrixChat extends React.PureComponent { ); } } else if (this.state.view === Views.WELCOME) { - const Welcome = sdk.getComponent('auth.Welcome'); view = ; } else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) { - const Registration = sdk.getComponent('structures.auth.Registration'); const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail; view = ( { /> ); } else if (this.state.view === Views.FORGOT_PASSWORD && SettingsStore.getValue(UIFeature.PasswordReset)) { - const ForgotPassword = sdk.getComponent('structures.auth.ForgotPassword'); view = ( { ); } else if (this.state.view === Views.LOGIN) { const showPasswordReset = SettingsStore.getValue(UIFeature.PasswordReset); - const Login = sdk.getComponent('structures.auth.Login'); view = ( { onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined} onServerConfigChange={this.onServerConfigChange} fragmentAfterLogin={fragmentAfterLogin} + defaultUsername={this.props.startingFragmentQueryParams.defaultUsername} {...this.getServerProperties()} /> ); } else if (this.state.view === Views.SOFT_LOGOUT) { - const SoftLogout = sdk.getComponent('structures.auth.SoftLogout'); view = ( { console.error(`Unknown view ${this.state.view}`); } - const ErrorBoundary = sdk.getComponent('elements.ErrorBoundary'); return {view} ; diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.tsx similarity index 58% rename from src/components/structures/MessagePanel.js rename to src/components/structures/MessagePanel.tsx index 132d9ab4c3..a0a1ac9b10 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.tsx @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,31 +14,47 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React, { createRef, KeyboardEvent, ReactNode, SyntheticEvent, TransitionEvent } from 'react'; import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import shouldHideEvent from '../../shouldHideEvent'; -import {wantsDateSeparator} from '../../DateUtils'; -import * as sdk from '../../index'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import { EventType } from 'matrix-js-sdk/src/@types/event'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { Relations } from "matrix-js-sdk/src/models/relations"; +import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; -import {MatrixClientPeg} from '../../MatrixClientPeg'; +import shouldHideEvent from '../../shouldHideEvent'; +import { wantsDateSeparator } from '../../DateUtils'; +import { MatrixClientPeg } from '../../MatrixClientPeg'; import SettingsStore from '../../settings/SettingsStore'; -import {Layout, LayoutPropType} from "../../settings/Layout"; -import {_t} from "../../languageHandler"; -import {haveTileForEvent} from "../views/rooms/EventTile"; -import {textForEvent} from "../../TextForEvent"; +import RoomContext from "../../contexts/RoomContext"; +import { Layout } from "../../settings/Layout"; +import { _t } from "../../languageHandler"; +import EventTile, { haveTileForEvent, IReadReceiptProps, TileShape } from "../views/rooms/EventTile"; +import { hasText } from "../../TextForEvent"; import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer"; import DMRoomMap from "../../utils/DMRoomMap"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import defaultDispatcher from '../../dispatcher/dispatcher'; +import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile'; +import ScrollPanel, { IScrollState } from "./ScrollPanel"; +import EventListSummary from '../views/elements/EventListSummary'; +import MemberEventListSummary from '../views/elements/MemberEventListSummary'; +import DateSeparator from '../views/messages/DateSeparator'; +import ErrorBoundary from '../views/elements/ErrorBoundary'; +import ResizeNotifier from "../../utils/ResizeNotifier"; +import Spinner from "../views/elements/Spinner"; +import TileErrorBoundary from '../views/messages/TileErrorBoundary'; +import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import EditorStateTransfer from "../../utils/EditorStateTransfer"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes -const continuedTypes = ['m.sticker', 'm.room.message']; +const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; +const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl]; // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL -function shouldFormContinuation(prevEvent, mxEvent) { +function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): boolean { // sanity check inputs if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false; // check if within the max continuation period @@ -51,8 +65,8 @@ function shouldFormContinuation(prevEvent, mxEvent) { // Some events should appear as continuations from previous events of different types. if (mxEvent.getType() !== prevEvent.getType() && - (!continuedTypes.includes(mxEvent.getType()) || - !continuedTypes.includes(prevEvent.getType()))) return false; + (!continuedTypes.includes(mxEvent.getType() as EventType) || + !continuedTypes.includes(prevEvent.getType() as EventType))) return false; // Check if the sender is the same and hasn't changed their displayname/avatar between these events if (mxEvent.sender.userId !== prevEvent.sender.userId || @@ -65,91 +79,157 @@ function shouldFormContinuation(prevEvent, mxEvent) { return true; } -const isMembershipChange = (e) => e.getType() === 'm.room.member' || e.getType() === 'm.room.third_party_invite'; +interface IProps { + // the list of MatrixEvents to display + events: MatrixEvent[]; + + // true to give the component a 'display: none' style. + hidden?: boolean; + + // true to show a spinner at the top of the timeline to indicate + // back-pagination in progress + backPaginating?: boolean; + + // true to show a spinner at the end of the timeline to indicate + // forward-pagination in progress + forwardPaginating?: boolean; + + // ID of an event to highlight. If undefined, no event will be highlighted. + highlightedEventId?: string; + + // The room these events are all in together, if any. + // (The notification panel won't have a room here, for example.) + room?: Room; + + // Should we show URL Previews + showUrlPreview?: boolean; + + // event after which we should show a read marker + readMarkerEventId?: string; + + // whether the read marker should be visible + readMarkerVisible?: boolean; + + // the userid of our user. This is used to suppress the read marker + // for pending messages. + ourUserId?: string; + + // true to suppress the date at the start of the timeline + suppressFirstDateSeparator?: boolean; + + // whether to show read receipts + showReadReceipts?: boolean; + + // true if updates to the event list should cause the scroll panel to + // scroll down when we are at the bottom of the window. See ScrollPanel + // for more details. + stickyBottom?: boolean; + + // className for the panel + className: string; + + // shape parameter to be passed to EventTiles + tileShape?: TileShape; + + // show twelve hour timestamps + isTwelveHour?: boolean; + + // show timestamps always + alwaysShowTimestamps?: boolean; + + // whether to show reactions for an event + showReactions?: boolean; + + // which layout to use + layout?: Layout; + + // whether or not to show flair at all + enableFlair?: boolean; + + resizeNotifier: ResizeNotifier; + permalinkCreator?: RoomPermalinkCreator; + editState?: EditorStateTransfer; + + // callback which is called when the panel is scrolled. + onScroll?(event: Event): void; + + // callback which is called when the user interacts with the room timeline + onUserScroll(event: SyntheticEvent): void; + + // callback which is called when more content is needed. + onFillRequest?(backwards: boolean): Promise; + + // helper function to access relations for an event + onUnfillRequest?(backwards: boolean, scrollToken: string): void; + + getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations; +} + +interface IState { + ghostReadMarkers: string[]; + showTypingNotifications: boolean; +} + +interface IReadReceiptForUser { + lastShownEventId: string; + receipt: IReadReceiptProps; +} /* (almost) stateless UI component which builds the event tiles in the room timeline. */ @replaceableComponent("structures.MessagePanel") -export default class MessagePanel extends React.Component { - static propTypes = { - // true to give the component a 'display: none' style. - hidden: PropTypes.bool, +export default class MessagePanel extends React.Component { + static contextType = RoomContext; - // true to show a spinner at the top of the timeline to indicate - // back-pagination in progress - backPaginating: PropTypes.bool, + // opaque readreceipt info for each userId; used by ReadReceiptMarker + // to manage its animations + private readonly readReceiptMap: Record = {}; - // true to show a spinner at the end of the timeline to indicate - // forward-pagination in progress - forwardPaginating: PropTypes.bool, + // Track read receipts by event ID. For each _shown_ event ID, we store + // the list of read receipts to display: + // [ + // { + // userId: string, + // member: RoomMember, + // ts: number, + // }, + // ] + // This is recomputed on each render. It's only stored on the component + // for ease of passing the data around since it's computed in one pass + // over all events. + private readReceiptsByEvent: Record = {}; - // the list of MatrixEvents to display - events: PropTypes.array.isRequired, + // Track read receipts by user ID. For each user ID we've ever shown a + // a read receipt for, we store an object: + // { + // lastShownEventId: string, + // receipt: { + // userId: string, + // member: RoomMember, + // ts: number, + // }, + // } + // so that we can always keep receipts displayed by reverting back to + // the last shown event for that user ID when needed. This may feel like + // it duplicates the receipt storage in the room, but at this layer, we + // are tracking _shown_ event IDs, which the JS SDK knows nothing about. + // This is recomputed on each render, using the data from the previous + // render as our fallback for any user IDs we can't match a receipt to a + // displayed event in the current render cycle. + private readReceiptsByUserId: Record = {}; - // ID of an event to highlight. If undefined, no event will be highlighted. - highlightedEventId: PropTypes.string, + private readonly showHiddenEventsInTimeline: boolean; + private isMounted = false; - // The room these events are all in together, if any. - // (The notification panel won't have a room here, for example.) - room: PropTypes.object, + private readMarkerNode = createRef(); + private whoIsTyping = createRef(); + private scrollPanel = createRef(); - // Should we show URL Previews - showUrlPreview: PropTypes.bool, + private readonly showTypingNotificationsWatcherRef: string; + private eventNodes: Record; - // event after which we should show a read marker - readMarkerEventId: PropTypes.string, - - // whether the read marker should be visible - readMarkerVisible: PropTypes.bool, - - // the userid of our user. This is used to suppress the read marker - // for pending messages. - ourUserId: PropTypes.string, - - // true to suppress the date at the start of the timeline - suppressFirstDateSeparator: PropTypes.bool, - - // whether to show read receipts - showReadReceipts: PropTypes.bool, - - // true if updates to the event list should cause the scroll panel to - // scroll down when we are at the bottom of the window. See ScrollPanel - // for more details. - stickyBottom: PropTypes.bool, - - // callback which is called when the panel is scrolled. - onScroll: PropTypes.func, - - // callback which is called when more content is needed. - onFillRequest: PropTypes.func, - - // className for the panel - className: PropTypes.string.isRequired, - - // shape parameter to be passed to EventTiles - tileShape: PropTypes.string, - - // show twelve hour timestamps - isTwelveHour: PropTypes.bool, - - // show timestamps always - alwaysShowTimestamps: PropTypes.bool, - - // helper function to access relations for an event - getRelationsForEvent: PropTypes.func, - - // whether to show reactions for an event - showReactions: PropTypes.bool, - - // which layout to use - layout: LayoutPropType, - - // whether or not to show flair at all - enableFlair: PropTypes.bool, - }; - - constructor(props) { - super(props); + constructor(props, context) { + super(props, context); this.state = { // previous positions the read marker has been in, so we can @@ -158,65 +238,21 @@ export default class MessagePanel extends React.Component { showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), }; - // opaque readreceipt info for each userId; used by ReadReceiptMarker - // to manage its animations - this._readReceiptMap = {}; - - // Track read receipts by event ID. For each _shown_ event ID, we store - // the list of read receipts to display: - // [ - // { - // userId: string, - // member: RoomMember, - // ts: number, - // }, - // ] - // This is recomputed on each render. It's only stored on the component - // for ease of passing the data around since it's computed in one pass - // over all events. - this._readReceiptsByEvent = {}; - - // Track read receipts by user ID. For each user ID we've ever shown a - // a read receipt for, we store an object: - // { - // lastShownEventId: string, - // receipt: { - // userId: string, - // member: RoomMember, - // ts: number, - // }, - // } - // so that we can always keep receipts displayed by reverting back to - // the last shown event for that user ID when needed. This may feel like - // it duplicates the receipt storage in the room, but at this layer, we - // are tracking _shown_ event IDs, which the JS SDK knows nothing about. - // This is recomputed on each render, using the data from the previous - // render as our fallback for any user IDs we can't match a receipt to a - // displayed event in the current render cycle. - this._readReceiptsByUserId = {}; - // Cache hidden events setting on mount since Settings is expensive to // query, and we check this in a hot code path. - this._showHiddenEventsInTimeline = - SettingsStore.getValue("showHiddenEventsInTimeline"); + this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline"); - this._isMounted = false; - - this._readMarkerNode = createRef(); - this._whoIsTyping = createRef(); - this._scrollPanel = createRef(); - - this._showTypingNotificationsWatcherRef = + this.showTypingNotificationsWatcherRef = SettingsStore.watchSetting("showTypingNotifications", null, this.onShowTypingNotificationsChange); } componentDidMount() { - this._isMounted = true; + this.isMounted = true; } componentWillUnmount() { - this._isMounted = false; - SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef); + this.isMounted = false; + SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef); } componentDidUpdate(prevProps, prevState) { @@ -229,14 +265,14 @@ export default class MessagePanel extends React.Component { } } - onShowTypingNotificationsChange = () => { + private onShowTypingNotificationsChange = (): void => { this.setState({ showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), }); }; /* get the DOM node representing the given event */ - getNodeForEventId(eventId) { + public getNodeForEventId(eventId: string): HTMLElement { if (!this.eventNodes) { return undefined; } @@ -246,8 +282,8 @@ export default class MessagePanel extends React.Component { /* return true if the content is fully scrolled down right now; else false. */ - isAtBottom() { - return this._scrollPanel.current && this._scrollPanel.current.isAtBottom(); + public isAtBottom(): boolean { + return this.scrollPanel.current?.isAtBottom(); } /* get the current scroll state. See ScrollPanel.getScrollState for @@ -255,8 +291,8 @@ export default class MessagePanel extends React.Component { * * returns null if we are not mounted. */ - getScrollState() { - return this._scrollPanel.current ? this._scrollPanel.current.getScrollState() : null; + public getScrollState(): IScrollState { + return this.scrollPanel.current?.getScrollState() ?? null; } // returns one of: @@ -265,15 +301,15 @@ export default class MessagePanel extends React.Component { // -1: read marker is above the window // 0: read marker is within the window // +1: read marker is below the window - getReadMarkerPosition() { - const readMarker = this._readMarkerNode.current; - const messageWrapper = this._scrollPanel.current; + public getReadMarkerPosition(): number { + const readMarker = this.readMarkerNode.current; + const messageWrapper = this.scrollPanel.current; if (!readMarker || !messageWrapper) { return null; } - const wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect(); + const wrapperRect = (ReactDOM.findDOMNode(messageWrapper) as HTMLElement).getBoundingClientRect(); const readMarkerRect = readMarker.getBoundingClientRect(); // the read-marker pretends to have zero height when it is actually @@ -289,17 +325,17 @@ export default class MessagePanel extends React.Component { /* jump to the top of the content. */ - scrollToTop() { - if (this._scrollPanel.current) { - this._scrollPanel.current.scrollToTop(); + public scrollToTop(): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.scrollToTop(); } } /* jump to the bottom of the content. */ - scrollToBottom() { - if (this._scrollPanel.current) { - this._scrollPanel.current.scrollToBottom(); + public scrollToBottom(): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.scrollToBottom(); } } @@ -308,9 +344,9 @@ export default class MessagePanel extends React.Component { * * @param {number} mult: -1 to page up, +1 to page down */ - scrollRelative(mult) { - if (this._scrollPanel.current) { - this._scrollPanel.current.scrollRelative(mult); + public scrollRelative(mult: number): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.scrollRelative(mult); } } @@ -319,9 +355,9 @@ export default class MessagePanel extends React.Component { * * @param {KeyboardEvent} ev: the keyboard event to handle */ - handleScrollKey(ev) { - if (this._scrollPanel.current) { - this._scrollPanel.current.handleScrollKey(ev); + public handleScrollKey(ev: KeyboardEvent): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.handleScrollKey(ev); } } @@ -335,38 +371,41 @@ export default class MessagePanel extends React.Component { * node (specifically, the bottom of it) will be positioned. If omitted, it * defaults to 0. */ - scrollToEvent(eventId, pixelOffset, offsetBase) { - if (this._scrollPanel.current) { - this._scrollPanel.current.scrollToToken(eventId, pixelOffset, offsetBase); + public scrollToEvent(eventId: string, pixelOffset: number, offsetBase: number): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.scrollToToken(eventId, pixelOffset, offsetBase); } } - scrollToEventIfNeeded(eventId) { + public scrollToEventIfNeeded(eventId: string): void { const node = this.eventNodes[eventId]; if (node) { - node.scrollIntoView({block: "nearest", behavior: "instant"}); + node.scrollIntoView({ + block: "nearest", + behavior: "instant", + }); } } /* check the scroll state and send out pagination requests if necessary. */ - checkFillState() { - if (this._scrollPanel.current) { - this._scrollPanel.current.checkFillState(); + public checkFillState(): void { + if (this.scrollPanel.current) { + this.scrollPanel.current.checkFillState(); } } - _isUnmounting = () => { - return !this._isMounted; + private isUnmounting = (): boolean => { + return !this.isMounted; }; // TODO: Implement granular (per-room) hide options - _shouldShowEvent(mxEv) { + public shouldShowEvent(mxEv: MatrixEvent): boolean { if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) { return false; // ignored = no show (only happens if the ignore happens after an event was received) } - if (this._showHiddenEventsInTimeline) { + if (this.showHiddenEventsInTimeline) { return true; } @@ -377,10 +416,10 @@ export default class MessagePanel extends React.Component { // Always show highlighted event if (this.props.highlightedEventId === mxEv.getId()) return true; - return !shouldHideEvent(mxEv); + return !shouldHideEvent(mxEv, this.context); } - _readMarkerForEvent(eventId, isLastEvent) { + public readMarkerForEvent(eventId: string, isLastEvent: boolean): ReactNode { const visible = !isLastEvent && this.props.readMarkerVisible; if (this.props.readMarkerEventId === eventId) { @@ -393,13 +432,13 @@ export default class MessagePanel extends React.Component { // confused. if (visible) { hr =
; } return (
  • @@ -418,8 +457,8 @@ export default class MessagePanel extends React.Component { // transition (ie. the read markers do but the event tiles do not) // and TransitionGroup requires that all its children are Transitions. const hr =
    ; @@ -427,8 +466,10 @@ export default class MessagePanel extends React.Component { // we get a new DOM node (restarting the animation) when the ghost // moves to a different event. return ( -
  • +
  • { hr }
  • ); @@ -437,7 +478,7 @@ export default class MessagePanel extends React.Component { return null; } - _collectGhostReadMarker = (node) => { + private collectGhostReadMarker = (node: HTMLElement): void => { if (node) { // now the element has appeared, change the style which will trigger the CSS transition requestAnimationFrame(() => { @@ -447,15 +488,15 @@ export default class MessagePanel extends React.Component { } }; - _onGhostTransitionEnd = (ev) => { + private onGhostTransitionEnd = (ev: TransitionEvent): void => { // we can now clean up the ghost element - const finishedEventId = ev.target.dataset.eventid; + const finishedEventId = (ev.target as HTMLElement).dataset.eventid; this.setState({ ghostReadMarkers: this.state.ghostReadMarkers.filter(eid => eid !== finishedEventId), }); }; - _getNextEventInfo(arr, i) { + private getNextEventInfo(arr: MatrixEvent[], i: number): { nextEvent: MatrixEvent, nextTile: MatrixEvent } { const nextEvent = i < arr.length - 1 ? arr[i + 1] : null; @@ -464,12 +505,16 @@ export default class MessagePanel extends React.Component { // when rendering the tile. The shouldShowEvent function is pretty quick at what // it does, so this should have no significant cost even when a room is used for // not-chat purposes. - const nextTile = arr.slice(i + 1).find(e => this._shouldShowEvent(e)); + const nextTile = arr.slice(i + 1).find(e => this.shouldShowEvent(e)); - return {nextEvent, nextTile}; + return { nextEvent, nextTile }; } - _getEventTiles() { + private get roomHasPendingEdit(): string { + return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`); + } + + private getEventTiles(): ReactNode[] { this.eventNodes = {}; let i; @@ -485,7 +530,7 @@ export default class MessagePanel extends React.Component { let lastShownNonLocalEchoIndex = -1; for (i = this.props.events.length-1; i >= 0; i--) { const mxEv = this.props.events[i]; - if (!this._shouldShowEvent(mxEv)) { + if (!this.shouldShowEvent(mxEv)) { continue; } @@ -509,18 +554,18 @@ export default class MessagePanel extends React.Component { // Note: the EventTile might still render a "sent/sending receipt" independent of // this information. When not providing read receipt information, the tile is likely // to assume that sent receipts are to be shown more often. - this._readReceiptsByEvent = {}; + this.readReceiptsByEvent = {}; if (this.props.showReadReceipts) { - this._readReceiptsByEvent = this._getReadReceiptsByShownEvent(); + this.readReceiptsByEvent = this.getReadReceiptsByShownEvent(); } - let grouper = null; + let grouper: BaseGrouper = null; for (i = 0; i < this.props.events.length; i++) { const mxEv = this.props.events[i]; const eventId = mxEv.getId(); const last = (mxEv === lastShownEvent); - const {nextEvent, nextTile} = this._getNextEventInfo(this.props.events, i); + const { nextEvent, nextTile } = this.getNextEventInfo(this.props.events, i); if (grouper) { if (grouper.shouldGroup(mxEv)) { @@ -541,20 +586,28 @@ export default class MessagePanel extends React.Component { } } if (!grouper) { - const wantTile = this._shouldShowEvent(mxEv); + const wantTile = this.shouldShowEvent(mxEv); + const isGrouped = false; if (wantTile) { - // make sure we unpack the array returned by _getTilesForEvent, + // make sure we unpack the array returned by getTilesForEvent, // otherwise react will auto-generate keys and we will end up // replacing all of the DOM elements every time we paginate. - ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextTile)); + ret.push(...this.getTilesForEvent(prevEvent, mxEv, last, isGrouped, nextEvent, nextTile)); prevEvent = mxEv; } - const readMarker = this._readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex); + const readMarker = this.readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex); if (readMarker) ret.push(readMarker); } } + if (!this.props.editState && this.roomHasPendingEdit) { + defaultDispatcher.dispatch({ + action: "edit_event", + event: this.props.room.findEventById(this.roomHasPendingEdit), + }); + } + if (grouper) { ret.push(...grouper.getTiles()); } @@ -562,15 +615,18 @@ export default class MessagePanel extends React.Component { return ret; } - _getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) { - const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary'); - const EventTile = sdk.getComponent('rooms.EventTile'); - const DateSeparator = sdk.getComponent('messages.DateSeparator'); + public getTilesForEvent( + prevEvent: MatrixEvent, + mxEv: MatrixEvent, + last = false, + isGrouped = false, + nextEvent?: MatrixEvent, + nextEventWithTile?: MatrixEvent, + ): ReactNode[] { const ret = []; const isEditing = this.props.editState && this.props.editState.getEvent().getId() === mxEv.getId(); - // local echoes have a fake date, which could even be yesterday. Treat them // as 'today' for the date separators. let ts1 = mxEv.getTs(); @@ -581,15 +637,15 @@ export default class MessagePanel extends React.Component { } // do we need a date separator since the last event? - const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate); - if (wantsDateSeparator) { + const wantsDateSeparator = this.wantsDateSeparator(prevEvent, eventDate); + if (wantsDateSeparator && !isGrouped) { const dateSeparator =
  • ; ret.push(dateSeparator); } let willWantDateSeparator = false; if (nextEvent) { - willWantDateSeparator = this._wantsDateSeparator(mxEv, nextEvent.getDate() || new Date()); + willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date()); } // is this a continuation of the previous message? @@ -598,16 +654,12 @@ export default class MessagePanel extends React.Component { const eventId = mxEv.getId(); const highlight = (eventId === this.props.highlightedEventId); - // we can't use local echoes as scroll tokens, because their event IDs change. - // Local echos have a send "status". - const scrollToken = mxEv.status ? undefined : eventId; - - const readReceipts = this._readReceiptsByEvent[eventId]; + const readReceipts = this.readReceiptsByEvent[eventId]; let isLastSuccessful = false; const isSentState = s => !s || s === 'sent'; const isSent = isSentState(mxEv.getAssociatedStatus()); - const hasNextEvent = nextEvent && this._shouldShowEvent(nextEvent); + const hasNextEvent = nextEvent && this.shouldShowEvent(nextEvent); if (!hasNextEvent && isSent) { isLastSuccessful = true; } else if (hasNextEvent && isSent && !isSentState(nextEvent.getAssociatedStatus())) { @@ -630,45 +682,42 @@ export default class MessagePanel extends React.Component { // use txnId as key if available so that we don't remount during sending ret.push( -
  • - - - -
  • , + + + , ); return ret; } - _wantsDateSeparator(prevEvent, nextEventDate) { + public wantsDateSeparator(prevEvent: MatrixEvent, nextEventDate: Date): boolean { if (prevEvent == null) { // first event in the panel: depends if we could back-paginate from // here. @@ -679,7 +728,7 @@ export default class MessagePanel extends React.Component { // Get a list of read receipts that should be shown next to this event // Receipts are objects which have a 'userId', 'roomMember' and 'ts'. - _getReadReceiptsForEvent(event) { + private getReadReceiptsForEvent(event: MatrixEvent): IReadReceiptProps[] { const myUserId = MatrixClientPeg.get().credentials.userId; // get list of read receipts, sorted most recent first @@ -687,7 +736,7 @@ export default class MessagePanel extends React.Component { if (!room) { return null; } - const receipts = []; + const receipts: IReadReceiptProps[] = []; room.getReceiptsForEvent(event).forEach((r) => { if (!r.userId || r.type !== "m.read" || r.userId === myUserId) { return; // ignore non-read receipts and receipts from self. @@ -708,13 +757,13 @@ export default class MessagePanel extends React.Component { // Get an object that maps from event ID to a list of read receipts that // should be shown next to that event. If a hidden event has read receipts, // they are folded into the receipts of the last shown event. - _getReadReceiptsByShownEvent() { + private getReadReceiptsByShownEvent(): Record { const receiptsByEvent = {}; const receiptsByUserId = {}; let lastShownEventId; for (const event of this.props.events) { - if (this._shouldShowEvent(event)) { + if (this.shouldShowEvent(event)) { lastShownEventId = event.getId(); } if (!lastShownEventId) { @@ -722,7 +771,7 @@ export default class MessagePanel extends React.Component { } const existingReceipts = receiptsByEvent[lastShownEventId] || []; - const newReceipts = this._getReadReceiptsForEvent(event); + const newReceipts = this.getReadReceiptsForEvent(event); receiptsByEvent[lastShownEventId] = existingReceipts.concat(newReceipts); // Record these receipts along with their last shown event ID for @@ -741,16 +790,16 @@ export default class MessagePanel extends React.Component { // someone which had one in the last. By looking through our previous // mapping of receipts by user ID, we can cover recover any receipts // that would have been lost by using the same event ID from last time. - for (const userId in this._readReceiptsByUserId) { + for (const userId in this.readReceiptsByUserId) { if (receiptsByUserId[userId]) { continue; } - const { lastShownEventId, receipt } = this._readReceiptsByUserId[userId]; + const { lastShownEventId, receipt } = this.readReceiptsByUserId[userId]; const existingReceipts = receiptsByEvent[lastShownEventId] || []; receiptsByEvent[lastShownEventId] = existingReceipts.concat(receipt); receiptsByUserId[userId] = { lastShownEventId, receipt }; } - this._readReceiptsByUserId = receiptsByUserId; + this.readReceiptsByUserId = receiptsByUserId; // After grouping receipts by shown events, do another pass to sort each // receipt list. @@ -763,21 +812,21 @@ export default class MessagePanel extends React.Component { return receiptsByEvent; } - _collectEventNode = (eventId, node) => { - this.eventNodes[eventId] = node; - } + private collectEventNode = (eventId: string, node: EventTile): void => { + this.eventNodes[eventId] = node?.ref?.current; + }; // once dynamic content in the events load, make the scrollPanel check the // scroll offsets. - _onHeightChanged = () => { - const scrollPanel = this._scrollPanel.current; + public onHeightChanged = (): void => { + const scrollPanel = this.scrollPanel.current; if (scrollPanel) { scrollPanel.checkScroll(); } }; - _onTypingShown = () => { - const scrollPanel = this._scrollPanel.current; + private onTypingShown = (): void => { + const scrollPanel = this.scrollPanel.current; // this will make the timeline grow, so checkScroll scrollPanel.checkScroll(); if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { @@ -785,8 +834,8 @@ export default class MessagePanel extends React.Component { } }; - _onTypingHidden = () => { - const scrollPanel = this._scrollPanel.current; + private onTypingHidden = (): void => { + const scrollPanel = this.scrollPanel.current; if (scrollPanel) { // as hiding the typing notifications doesn't // update the scrollPanel, we tell it to apply @@ -798,12 +847,12 @@ export default class MessagePanel extends React.Component { } }; - updateTimelineMinHeight() { - const scrollPanel = this._scrollPanel.current; + public updateTimelineMinHeight(): void { + const scrollPanel = this.scrollPanel.current; if (scrollPanel) { const isAtBottom = scrollPanel.isAtBottom(); - const whoIsTyping = this._whoIsTyping.current; + const whoIsTyping = this.whoIsTyping.current; const isTypingVisible = whoIsTyping && whoIsTyping.isVisible(); // when messages get added to the timeline, // but somebody else is still typing, @@ -815,18 +864,14 @@ export default class MessagePanel extends React.Component { } } - onTimelineReset() { - const scrollPanel = this._scrollPanel.current; + public onTimelineReset(): void { + const scrollPanel = this.scrollPanel.current; if (scrollPanel) { scrollPanel.clearPreventShrinking(); } } render() { - const ErrorBoundary = sdk.getComponent('elements.ErrorBoundary'); - const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); - const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile"); - const Spinner = sdk.getComponent("elements.Spinner"); let topSpinner; let bottomSpinner; if (this.props.backPaginating) { @@ -838,20 +883,13 @@ export default class MessagePanel extends React.Component { const style = this.props.hidden ? { display: 'none' } : {}; - const className = classNames( - this.props.className, - { - "mx_MessagePanel_alwaysShowTimestamps": this.props.alwaysShowTimestamps, - }, - ); - let whoIsTyping; if (this.props.room && !this.props.tileShape && this.state.showTypingNotifications) { whoIsTyping = ( + onShown={this.onTypingShown} + onHidden={this.onTypingHidden} + ref={this.whoIsTyping} /> ); } @@ -867,10 +905,10 @@ export default class MessagePanel extends React.Component { return ( { topSpinner } - { this._getEventTiles() } + { this.getEventTiles() } { whoIsTyping } { bottomSpinner } @@ -888,6 +926,31 @@ export default class MessagePanel extends React.Component { } } +abstract class BaseGrouper { + static canStartGroup = (panel: MessagePanel, ev: MatrixEvent): boolean => true; + + public events: MatrixEvent[] = []; + // events that we include in the group but then eject out and place above the group. + public ejectedEvents: MatrixEvent[] = []; + public readMarker: ReactNode; + + constructor( + public readonly panel: MessagePanel, + public readonly event: MatrixEvent, + public readonly prevEvent: MatrixEvent, + public readonly lastShownEvent: MatrixEvent, + public readonly nextEvent?: MatrixEvent, + public readonly nextEventTile?: MatrixEvent, + ) { + this.readMarker = panel.readMarkerForEvent(event.getId(), event === lastShownEvent); + } + + public abstract shouldGroup(ev: MatrixEvent): boolean; + public abstract add(ev: MatrixEvent): void; + public abstract getTiles(): ReactNode[]; + public abstract getNewPrevEvent(): MatrixEvent; +} + /* Grouper classes determine when events can be grouped together in a summary. * Groupers should have the following methods: * - canStartGroup (static): determines if a new group should be started with the @@ -903,36 +966,21 @@ export default class MessagePanel extends React.Component { // Wrap initial room creation events into an EventListSummary // Grouping only events sent by the same user that sent the `m.room.create` and only until // the first non-state event or membership event which is not regarding the sender of the `m.room.create` event -class CreationGrouper { - static canStartGroup = function(panel, ev) { - return ev.getType() === "m.room.create"; +class CreationGrouper extends BaseGrouper { + static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { + return ev.getType() === EventType.RoomCreate; }; - constructor(panel, createEvent, prevEvent, lastShownEvent) { - this.panel = panel; - this.createEvent = createEvent; - this.prevEvent = prevEvent; - this.lastShownEvent = lastShownEvent; - this.events = []; - // events that we include in the group but then eject out and place - // above the group. - this.ejectedEvents = []; - this.readMarker = panel._readMarkerForEvent( - createEvent.getId(), - createEvent === lastShownEvent, - ); - } - - shouldGroup(ev) { + public shouldGroup(ev: MatrixEvent): boolean { const panel = this.panel; - const createEvent = this.createEvent; - if (!panel._shouldShowEvent(ev)) { + const createEvent = this.event; + if (!panel.shouldShowEvent(ev)) { return true; } - if (panel._wantsDateSeparator(this.createEvent, ev.getDate())) { + if (panel.wantsDateSeparator(this.event, ev.getDate())) { return false; } - if (ev.getType() === "m.room.member" + if (ev.getType() === EventType.RoomMember && (ev.getStateKey() !== createEvent.getSender() || ev.getContent()["membership"] !== "join")) { return false; } @@ -942,37 +990,35 @@ class CreationGrouper { return false; } - add(ev) { + public add(ev: MatrixEvent): void { const panel = this.panel; - this.readMarker = this.readMarker || panel._readMarkerForEvent( + this.readMarker = this.readMarker || panel.readMarkerForEvent( ev.getId(), ev === this.lastShownEvent, ); - if (!panel._shouldShowEvent(ev)) { + if (!panel.shouldShowEvent(ev)) { return; } - if (ev.getType() === "m.room.encryption") { + if (ev.getType() === EventType.RoomEncryption) { this.ejectedEvents.push(ev); } else { this.events.push(ev); } } - getTiles() { + public getTiles(): ReactNode[] { // If we don't have any events to group, don't even try to group them. The logic // below assumes that we have a group of events to deal with, but we might not if // the events we were supposed to group were redacted. if (!this.events || !this.events.length) return []; - const DateSeparator = sdk.getComponent('messages.DateSeparator'); - const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - const panel = this.panel; const ret = []; - const createEvent = this.createEvent; + const isGrouped = true; + const createEvent = this.event; const lastShownEvent = this.lastShownEvent; - if (panel._wantsDateSeparator(this.prevEvent, createEvent.getDate())) { + if (panel.wantsDateSeparator(this.prevEvent, createEvent.getDate())) { const ts = createEvent.getTs(); ret.push(
  • , @@ -980,14 +1026,14 @@ class CreationGrouper { } // If this m.room.create event should be shown (room upgrade) then show it before the summary - if (panel._shouldShowEvent(createEvent)) { + if (panel.shouldShowEvent(createEvent)) { // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered - ret.push(...panel._getTilesForEvent(createEvent, createEvent, false)); + ret.push(...panel.getTilesForEvent(createEvent, createEvent)); } for (const ejected of this.ejectedEvents) { - ret.push(...panel._getTilesForEvent( - createEvent, ejected, createEvent === lastShownEvent, + ret.push(...panel.getTilesForEvent( + createEvent, ejected, createEvent === lastShownEvent, isGrouped, )); } @@ -996,7 +1042,7 @@ class CreationGrouper { // of EventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent); + return panel.getTilesForEvent(e, e, e === lastShownEvent, isGrouped); }).reduce((a, b) => a.concat(b), []); // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one const ev = this.events[this.events.length - 1]; @@ -1014,13 +1060,13 @@ class CreationGrouper { ret.push( - { eventTiles } + { eventTiles } , ); @@ -1031,62 +1077,59 @@ class CreationGrouper { return ret; } - getNewPrevEvent() { - return this.createEvent; + public getNewPrevEvent(): MatrixEvent { + return this.event; } } -class RedactionGrouper { - static canStartGroup = function(panel, ev) { - return panel._shouldShowEvent(ev) && ev.isRedacted(); - } +class RedactionGrouper extends BaseGrouper { + static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { + return panel.shouldShowEvent(ev) && ev.isRedacted(); + }; - constructor(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile) { - this.panel = panel; - this.readMarker = panel._readMarkerForEvent( - ev.getId(), - ev === lastShownEvent, - ); + constructor( + panel: MessagePanel, + ev: MatrixEvent, + prevEvent: MatrixEvent, + lastShownEvent: MatrixEvent, + nextEvent: MatrixEvent, + nextEventTile: MatrixEvent, + ) { + super(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile); this.events = [ev]; - this.prevEvent = prevEvent; - this.lastShownEvent = lastShownEvent; - this.nextEvent = nextEvent; - this.nextEventTile = nextEventTile; } - shouldGroup(ev) { + public shouldGroup(ev: MatrixEvent): boolean { // absorb hidden events so that they do not break up streams of messages & redaction events being grouped - if (!this.panel._shouldShowEvent(ev)) { + if (!this.panel.shouldShowEvent(ev)) { return true; } - if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) { + if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) { return false; } return ev.isRedacted(); } - add(ev) { - this.readMarker = this.readMarker || this.panel._readMarkerForEvent( + public add(ev: MatrixEvent): void { + this.readMarker = this.readMarker || this.panel.readMarkerForEvent( ev.getId(), ev === this.lastShownEvent, ); - if (!this.panel._shouldShowEvent(ev)) { + if (!this.panel.shouldShowEvent(ev)) { return; } this.events.push(ev); } - getTiles() { + public getTiles(): ReactNode[] { if (!this.events || !this.events.length) return []; - const DateSeparator = sdk.getComponent('messages.DateSeparator'); - const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - + const isGrouped = true; const panel = this.panel; const ret = []; const lastShownEvent = this.lastShownEvent; - if (panel._wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { + if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { const ts = this.events[0].getTs(); ret.push(
  • , @@ -1097,11 +1140,12 @@ class RedactionGrouper { this.prevEvent ? this.events[0].getId() : "initial" ); - const senders = new Set(); + const senders = new Set(); let eventTiles = this.events.map((e, i) => { senders.add(e.sender); const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; - return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile); + return panel.getTilesForEvent( + prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { @@ -1113,7 +1157,7 @@ class RedactionGrouper { key={key} threshold={2} events={this.events} - onToggle={panel._onHeightChanged} // Update scroll state + onToggle={panel.onHeightChanged} // Update scroll state summaryMembers={Array.from(senders)} summaryText={_t("%(count)s messages deleted.", { count: eventTiles.length })} > @@ -1128,64 +1172,58 @@ class RedactionGrouper { return ret; } - getNewPrevEvent() { + public getNewPrevEvent(): MatrixEvent { return this.events[this.events.length - 1]; } } // Wrap consecutive member events in a ListSummary, ignore if redacted -class MemberGrouper { - static canStartGroup = function(panel, ev) { - return panel._shouldShowEvent(ev) && isMembershipChange(ev); +class MemberGrouper extends BaseGrouper { + static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { + return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType); + }; + + constructor( + public readonly panel: MessagePanel, + public readonly event: MatrixEvent, + public readonly prevEvent: MatrixEvent, + public readonly lastShownEvent: MatrixEvent, + ) { + super(panel, event, prevEvent, lastShownEvent); + this.events = [event]; } - constructor(panel, ev, prevEvent, lastShownEvent) { - this.panel = panel; - this.readMarker = panel._readMarkerForEvent( - ev.getId(), - ev === lastShownEvent, - ); - this.events = [ev]; - this.prevEvent = prevEvent; - this.lastShownEvent = lastShownEvent; - } - - shouldGroup(ev) { - if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) { + public shouldGroup(ev: MatrixEvent): boolean { + if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) { return false; } - return isMembershipChange(ev); + return membershipTypes.includes(ev.getType() as EventType); } - add(ev) { - if (ev.getType() === 'm.room.member') { - // We'll just double check that it's worth our time to do so, through an - // ugly hack. If textForEvent returns something, we should group it for - // rendering but if it doesn't then we'll exclude it. - const renderText = textForEvent(ev); - if (!renderText || renderText.trim().length === 0) return; // quietly ignore + public add(ev: MatrixEvent): void { + if (ev.getType() === EventType.RoomMember) { + // We can ignore any events that don't actually have a message to display + if (!hasText(ev)) return; } - this.readMarker = this.readMarker || this.panel._readMarkerForEvent( + this.readMarker = this.readMarker || this.panel.readMarkerForEvent( ev.getId(), ev === this.lastShownEvent, ); this.events.push(ev); } - getTiles() { + public getTiles(): ReactNode[] { // If we don't have any events to group, don't even try to group them. The logic // below assumes that we have a group of events to deal with, but we might not if // the events we were supposed to group were redacted. if (!this.events || !this.events.length) return []; - const DateSeparator = sdk.getComponent('messages.DateSeparator'); - const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); - + const isGrouped = true; const panel = this.panel; const lastShownEvent = this.lastShownEvent; const ret = []; - if (panel._wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { + if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { const ts = this.events[0].getTs(); ret.push(
  • , @@ -1213,7 +1251,7 @@ class MemberGrouper { // of MemberEventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent); + return panel.getTilesForEvent(e, e, e === lastShownEvent, isGrouped); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { @@ -1221,12 +1259,13 @@ class MemberGrouper { } ret.push( - - { eventTiles } + { eventTiles } , ); @@ -1237,7 +1276,7 @@ class MemberGrouper { return ret; } - getNewPrevEvent() { + public getNewPrevEvent(): MatrixEvent { return this.events[0]; } } diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 2ab11dad25..87447b6aba 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -24,7 +24,8 @@ import dis from '../../dispatcher/dispatcher'; import AccessibleButton from '../views/elements/AccessibleButton'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import BetaCard from "../views/beta/BetaCard"; @replaceableComponent("structures.MyGroups") export default class MyGroups extends React.Component { @@ -40,19 +41,19 @@ export default class MyGroups extends React.Component { } _onCreateGroupClick = () => { - dis.dispatch({action: 'view_create_group'}); + dis.dispatch({ action: 'view_create_group' }); }; _fetch() { this.context.getJoinedGroups().then((result) => { - this.setState({groups: result.groups, error: null}); + this.setState({ groups: result.groups, error: null }); }, (err) => { if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') { // Indicate that the guest isn't in any groups (which should be true) - this.setState({groups: [], error: null}); + this.setState({ groups: [], error: null }); return; } - this.setState({groups: null, error: err}); + this.setState({ groups: null, error: err }); }); } @@ -81,8 +82,7 @@ export default class MyGroups extends React.Component {

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

    {/*
    - +
    @@ -139,6 +139,7 @@ export default class MyGroups extends React.Component {
    */}
    +
    { contentHeader } { content } diff --git a/src/components/structures/NonUrgentToastContainer.tsx b/src/components/structures/NonUrgentToastContainer.tsx index 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.js b/src/components/structures/NotificationPanel.tsx similarity index 68% rename from src/components/structures/NotificationPanel.js rename to src/components/structures/NotificationPanel.tsx index 41aafc8b13..8c8fab7ece 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.tsx @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016, 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,29 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from "prop-types"; +import React from "react"; import { _t } from '../../languageHandler'; -import {MatrixClientPeg} from "../../MatrixClientPeg"; -import * as sdk from "../../index"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; import BaseCard from "../views/right_panel/BaseCard"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import TimelinePanel from "./TimelinePanel"; +import Spinner from "../views/elements/Spinner"; +import { TileShape } from "../views/rooms/EventTile"; + +interface IProps { + onClose(): void; +} /* * Component which shows the global notification list using a TimelinePanel */ @replaceableComponent("structures.NotificationPanel") -class NotificationPanel extends React.Component { - static propTypes = { - onClose: PropTypes.func.isRequired, - }; - +export default class NotificationPanel extends React.PureComponent { render() { - // wrap a TimelinePanel with the jump-to-event bits turned off. - const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); - const Loader = sdk.getComponent("elements.Spinner"); - const emptyState = (

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

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

    @@ -47,19 +42,21 @@ class NotificationPanel extends React.Component { let content; const timelineSet = MatrixClientPeg.get().getNotifTimelineSet(); if (timelineSet) { + // wrap a TimelinePanel with the jump-to-event bits turned off. content = ( ); } else { console.error("No notifTimelineSet available!"); - content = ; + content = ; } return @@ -67,5 +64,3 @@ class NotificationPanel extends React.Component { ; } } - -export default NotificationPanel; diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.tsx similarity index 74% rename from src/components/structures/RightPanel.js rename to src/components/structures/RightPanel.tsx index 5bcb3b2450..63027ab627 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.tsx @@ -1,6 +1,6 @@ /* Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2015 - 2020 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,76 +16,100 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {Room} from "matrix-js-sdk/src/models/room"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { User } from "matrix-js-sdk/src/models/user"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; -import RateLimitedFunc from '../../ratelimitedfunc'; -import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GroupStore from '../../stores/GroupStore'; import { - RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS, RIGHT_PANEL_SPACE_PHASES, + RightPanelPhases, } from "../../stores/RightPanelStorePhases"; import RightPanelStore from "../../stores/RightPanelStore"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import {Action} from "../../dispatcher/actions"; +import { Action } from "../../dispatcher/actions"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import WidgetCard from "../views/right_panel/WidgetCard"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import SettingsStore from "../../settings/SettingsStore"; +import { ActionPayload } from "../../dispatcher/payloads"; +import MemberList from "../views/rooms/MemberList"; +import GroupMemberList from "../views/groups/GroupMemberList"; +import GroupRoomList from "../views/groups/GroupRoomList"; +import GroupRoomInfo from "../views/groups/GroupRoomInfo"; +import UserInfo from "../views/right_panel/UserInfo"; +import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo"; +import FilePanel from "./FilePanel"; +import NotificationPanel from "./NotificationPanel"; +import ResizeNotifier from "../../utils/ResizeNotifier"; +import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard"; +import { throttle } from 'lodash'; + +interface IProps { + room?: Room; // if showing panels for a given room, this is set + groupId?: string; // if showing panels for a given group, this is set + user?: User; // used if we know the user ahead of opening the panel + resizeNotifier: ResizeNotifier; +} + +interface IState { + phase: RightPanelPhases; + isUserPrivilegedInGroup?: boolean; + member?: RoomMember; + verificationRequest?: VerificationRequest; + verificationRequestPromise?: Promise; + space?: Room; + widgetId?: string; + groupRoomId?: string; + groupId?: string; + event: MatrixEvent; +} @replaceableComponent("structures.RightPanel") -export default class RightPanel extends React.Component { - static get propTypes() { - return { - room: PropTypes.instanceOf(Room), // if showing panels for a given room, this is set - groupId: PropTypes.string, // if showing panels for a given group, this is set - user: PropTypes.object, // used if we know the user ahead of opening the panel - }; - } - +export default class RightPanel extends React.Component { static contextType = MatrixClientContext; + private dispatcherRef: string; + constructor(props, context) { super(props, context); this.state = { ...RightPanelStore.getSharedInstance().roomPanelPhaseParams, - phase: this._getPhaseFromProps(), + phase: this.getPhaseFromProps(), isUserPrivilegedInGroup: null, - member: this._getUserForPanel(), + member: this.getUserForPanel(), }; - this.onAction = this.onAction.bind(this); - this.onRoomStateMember = this.onRoomStateMember.bind(this); - this.onGroupStoreUpdated = this.onGroupStoreUpdated.bind(this); - this.onInviteToGroupButtonClick = this.onInviteToGroupButtonClick.bind(this); - this.onAddRoomToGroupButtonClick = this.onAddRoomToGroupButtonClick.bind(this); - - this._delayedUpdate = new RateLimitedFunc(() => { - this.forceUpdate(); - }, 500); } - // Helper function to split out the logic for _getPhaseFromProps() and the constructor + private readonly delayedUpdate = throttle((): void => { + this.forceUpdate(); + }, 500, { leading: true, trailing: true }); + + // Helper function to split out the logic for getPhaseFromProps() and the constructor // as both are called at the same time in the constructor. - _getUserForPanel() { + private getUserForPanel() { if (this.state && this.state.member) return this.state.member; const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams; return this.props.user || lastParams['member']; } // gets the current phase from the props and also maybe the store - _getPhaseFromProps() { + private getPhaseFromProps() { const rps = RightPanelStore.getSharedInstance(); - const userForPanel = this._getUserForPanel(); + const userForPanel = this.getUserForPanel(); if (this.props.groupId) { if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) { - dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.GroupMemberList}); + dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.GroupMemberList }); return RightPanelPhases.GroupMemberList; } return rps.groupPanelPhase; - } else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) { + } else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom() + && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase) + ) { return RightPanelPhases.SpaceMemberList; } else if (userForPanel) { // XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state @@ -115,7 +139,7 @@ export default class RightPanel extends React.Component { this.dispatcherRef = dis.register(this.onAction); const cli = this.context; cli.on("RoomState.members", this.onRoomStateMember); - this._initGroupStore(this.props.groupId); + this.initGroupStore(this.props.groupId); } componentWillUnmount() { @@ -123,61 +147,47 @@ export default class RightPanel extends React.Component { if (this.context) { this.context.removeListener("RoomState.members", this.onRoomStateMember); } - this._unregisterGroupStore(this.props.groupId); + this.unregisterGroupStore(); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase if (newProps.groupId !== this.props.groupId) { - this._unregisterGroupStore(this.props.groupId); - this._initGroupStore(newProps.groupId); + this.unregisterGroupStore(); + this.initGroupStore(newProps.groupId); } } - _initGroupStore(groupId) { + private initGroupStore(groupId: string) { if (!groupId) return; GroupStore.registerListener(groupId, this.onGroupStoreUpdated); } - _unregisterGroupStore() { + private unregisterGroupStore() { GroupStore.unregisterListener(this.onGroupStoreUpdated); } - onGroupStoreUpdated() { + private onGroupStoreUpdated = () => { this.setState({ isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId), }); - } + }; - onInviteToGroupButtonClick() { - showGroupInviteDialog(this.props.groupId).then(() => { - this.setState({ - phase: RightPanelPhases.GroupMemberList, - }); - }); - } - - onAddRoomToGroupButtonClick() { - showGroupAddRoomDialog(this.props.groupId).then(() => { - this.forceUpdate(); - }); - } - - onRoomStateMember(ev, state, member) { + private onRoomStateMember = (ev: MatrixEvent, _, member: RoomMember) => { if (!this.props.room || member.roomId !== this.props.room.roomId) { return; } // redraw the badge on the membership list if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) { - this._delayedUpdate(); + this.delayedUpdate(); } else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId && member.userId === this.state.member.userId) { // refresh the member info (e.g. new power level) - this._delayedUpdate(); + this.delayedUpdate(); } - } + }; - onAction(payload) { + private onAction = (payload: ActionPayload) => { if (payload.action === Action.AfterRightPanelPhaseChange) { this.setState({ phase: payload.phase, @@ -191,9 +201,9 @@ export default class RightPanel extends React.Component { space: payload.space, }); } - } + }; - onClose = () => { + private onClose = () => { // XXX: There are three different ways of 'closing' this panel depending on what state // things are in... this knows far more than it should do about the state of the rest // of the app and is generally a bit silly. @@ -221,16 +231,6 @@ export default class RightPanel extends React.Component { }; render() { - const MemberList = sdk.getComponent('rooms.MemberList'); - const UserInfo = sdk.getComponent('right_panel.UserInfo'); - const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo'); - const NotificationPanel = sdk.getComponent('structures.NotificationPanel'); - const FilePanel = sdk.getComponent('structures.FilePanel'); - - const GroupMemberList = sdk.getComponent('groups.GroupMemberList'); - const GroupRoomList = sdk.getComponent('groups.GroupRoomList'); - const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo'); - let panel =
    ; const roomId = this.props.room ? this.props.room.roomId : undefined; @@ -282,6 +282,7 @@ export default class RightPanel extends React.Component { user={this.state.member} groupId={this.props.groupId} key={this.state.member.userId} + phase={this.state.phase} onClose={this.onClose} />; break; @@ -296,6 +297,12 @@ export default class RightPanel extends React.Component { panel = ; break; + case RightPanelPhases.PinnedMessages: + if (SettingsStore.getValue("feature_pinning")) { + panel = ; + } + break; + case RightPanelPhases.FilePanel: panel = ; break; diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.tsx similarity index 61% rename from src/components/structures/RoomDirectory.js rename to src/components/structures/RoomDirectory.tsx index 3613261da6..b1974d6c0a 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.tsx @@ -1,7 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2019, 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,39 +15,93 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import {MatrixClientPeg} from "../../MatrixClientPeg"; -import * as sdk from "../../index"; +import React from "react"; + +import { MatrixClientPeg } from "../../MatrixClientPeg"; import dis from "../../dispatcher/dispatcher"; import Modal from "../../Modal"; import { linkifyAndSanitizeHtml } from '../../HtmlUtils'; -import PropTypes from 'prop-types'; import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; -import {ALL_ROOMS} 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"; import FlairStore from "../../stores/FlairStore"; import CountlyAnalytics from "../../CountlyAnalytics"; -import {replaceableComponent} from "../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../customisations/Media"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../customisations/Media"; +import { IDialogProps } from "../views/dialogs/IDialogProps"; +import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; +import BaseAvatar from "../views/avatars/BaseAvatar"; +import ErrorDialog from "../views/dialogs/ErrorDialog"; +import QuestionDialog from "../views/dialogs/QuestionDialog"; +import BaseDialog from "../views/dialogs/BaseDialog"; +import DirectorySearchBox from "../views/elements/DirectorySearchBox"; +import NetworkDropdown from "../views/directory/NetworkDropdown"; +import ScrollPanel from "./ScrollPanel"; +import Spinner from "../views/elements/Spinner"; +import { ActionPayload } from "../../dispatcher/payloads"; +import { getDisplayAliasForAliasSet } from "../../Rooms"; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 800; -function track(action) { +const LAST_SERVER_KEY = "mx_last_room_directory_server"; +const LAST_INSTANCE_KEY = "mx_last_room_directory_instance"; + +function track(action: string) { Analytics.trackEvent('RoomDirectory', action); } +interface IProps extends IDialogProps { + initialText?: string; +} + +interface IState { + publicRooms: IRoom[]; + loading: boolean; + protocolsLoading: boolean; + error?: string; + instanceId: string; + roomServer: string; + filterString: string; + selectedCommunityId?: string; + communityName?: string; +} + +/* eslint-disable camelcase */ +interface IRoom { + room_id: string; + name?: string; + avatar_url?: string; + topic?: string; + canonical_alias?: string; + aliases?: string[]; + world_readable: boolean; + guest_can_join: boolean; + num_joined_members: number; +} + +interface IPublicRoomsRequest { + limit?: number; + since?: string; + server?: string; + filter?: object; + include_all_networks?: boolean; + third_party_instance_id?: string; +} +/* eslint-enable camelcase */ + @replaceableComponent("structures.RoomDirectory") -export default class RoomDirectory extends React.Component { - static propTypes = { - initialText: PropTypes.string, - onFinished: PropTypes.func.isRequired, - }; +export default class RoomDirectory extends React.Component { + private readonly startTime: number; + private unmounted = false; + private nextBatch: string = null; + private filterTimeout: NodeJS.Timeout; + private protocols: Protocols; constructor(props) { super(props); @@ -56,41 +109,51 @@ export default class RoomDirectory extends React.Component { CountlyAnalytics.instance.trackRoomDirectoryBegin(); this.startTime = CountlyAnalytics.getTimestamp(); - const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0]; - this.state = { - publicRooms: [], - loading: true, - protocolsLoading: true, - error: null, - instanceId: undefined, - roomServer: MatrixClientPeg.getHomeserverName(), - filterString: this.props.initialText || "", - selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes") - ? selectedCommunityId - : null, - communityName: null, - }; + const selectedCommunityId = SettingsStore.getValue("feature_communities_v2_prototypes") + ? GroupFilterOrderStore.getSelectedTags()[0] + : null; - this._unmounted = false; - this.nextBatch = null; - this.filterTimeout = null; - this.scrollPanel = null; - this.protocols = null; - - this.state.protocolsLoading = true; + let protocolsLoading = true; if (!MatrixClientPeg.get()) { // We may not have a client yet when invoked from welcome page - this.state.protocolsLoading = false; - return; - } - - if (!this.state.selectedCommunityId) { + protocolsLoading = false; + } else if (!selectedCommunityId) { MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { this.protocols = response; - this.setState({protocolsLoading: false}); + const myHomeserver = MatrixClientPeg.getHomeserverName(); + const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY); + const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY); + + let roomServer = myHomeserver; + if ( + SdkConfig.get().roomDirectory?.servers?.includes(lsRoomServer) || + SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer) + ) { + roomServer = lsRoomServer; + } + + let instanceId: string = null; + if (roomServer === myHomeserver && ( + lsInstanceId === ALL_ROOMS || + Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId)) + )) { + instanceId = lsInstanceId; + } + + // Refresh the room list only if validation failed and we had to change these + if (this.state.instanceId !== instanceId || this.state.roomServer !== roomServer) { + this.setState({ + protocolsLoading: false, + instanceId, + roomServer, + }); + this.refreshRoomList(); + return; + } + this.setState({ protocolsLoading: false }); }, (err) => { console.warn(`error loading third party protocols: ${err}`); - this.setState({protocolsLoading: false}); + this.setState({ protocolsLoading: false }); if (MatrixClientPeg.get().isGuest()) { // Guests currently aren't allowed to use this API, so // ignore this as otherwise this error is literally the @@ -103,19 +166,31 @@ export default class RoomDirectory extends React.Component { error: _t( '%(brand)s failed to get the protocol list from the homeserver. ' + 'The homeserver may be too old to support third party networks.', - {brand}, + { brand }, ), }); }); } else { // We don't use the protocols in the communities v2 prototype experience - this.state.protocolsLoading = false; + protocolsLoading = false; // Grab the profile info async FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => { - this.setState({communityName: profile.name}); + this.setState({ communityName: profile.name }); }); } + + this.state = { + publicRooms: [], + loading: true, + error: null, + instanceId: localStorage.getItem(LAST_INSTANCE_KEY), + roomServer: localStorage.getItem(LAST_SERVER_KEY), + filterString: this.props.initialText || "", + selectedCommunityId, + communityName: null, + protocolsLoading, + }; } componentDidMount() { @@ -126,10 +201,10 @@ export default class RoomDirectory extends React.Component { if (this.filterTimeout) { clearTimeout(this.filterTimeout); } - this._unmounted = true; + this.unmounted = true; } - refreshRoomList = () => { + private refreshRoomList = () => { if (this.state.selectedCommunityId) { this.setState({ publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => { @@ -165,44 +240,44 @@ export default class RoomDirectory extends React.Component { this.getMoreRooms(); }; - getMoreRooms() { - if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms - if (!MatrixClientPeg.get()) return Promise.resolve(); + private getMoreRooms(): Promise { + if (this.state.selectedCommunityId) return Promise.resolve(false); // no more rooms + if (!MatrixClientPeg.get()) return Promise.resolve(false); this.setState({ loading: true, }); - const my_filter_string = this.state.filterString; - const my_server = this.state.roomServer; + const filterString = this.state.filterString; + const roomServer = this.state.roomServer; // remember the next batch token when we sent the request // too. If it's changed, appending to the list will corrupt it. - const my_next_batch = this.nextBatch; - const opts = {limit: 20}; - if (my_server != MatrixClientPeg.getHomeserverName()) { - opts.server = my_server; + const nextBatch = this.nextBatch; + const opts: IPublicRoomsRequest = { limit: 20 }; + if (roomServer != MatrixClientPeg.getHomeserverName()) { + opts.server = roomServer; } if (this.state.instanceId === ALL_ROOMS) { opts.include_all_networks = true; } else if (this.state.instanceId) { - opts.third_party_instance_id = this.state.instanceId; + opts.third_party_instance_id = this.state.instanceId as string; } if (this.nextBatch) opts.since = this.nextBatch; - if (my_filter_string) opts.filter = { generic_search_term: my_filter_string }; + if (filterString) opts.filter = { generic_search_term: filterString }; return MatrixClientPeg.get().publicRooms(opts).then((data) => { if ( - my_filter_string != this.state.filterString || - my_server != this.state.roomServer || - my_next_batch != this.nextBatch) { + filterString != this.state.filterString || + roomServer != this.state.roomServer || + nextBatch != this.nextBatch) { // if the filter or server has changed since this request was sent, // throw away the result (don't even clear the busy flag // since we must still have a request in flight) - return; + return false; } - if (this._unmounted) { + if (this.unmounted) { // if we've been unmounted, we don't care either. - return; + return false; } if (this.state.filterString) { @@ -211,25 +286,24 @@ export default class RoomDirectory extends React.Component { } this.nextBatch = data.next_batch; - this.setState((s) => { - s.publicRooms.push(...(data.chunk || [])); - s.loading = false; - return s; - }); + this.setState((s) => ({ + ...s, + publicRooms: [...s.publicRooms, ...(data.chunk || [])], + loading: false, + })); return Boolean(data.next_batch); }, (err) => { if ( - my_filter_string != this.state.filterString || - my_server != this.state.roomServer || - my_next_batch != this.nextBatch) { - // as above: we don't care about errors for old - // requests either - return; + filterString != this.state.filterString || + roomServer != this.state.roomServer || + nextBatch != this.nextBatch) { + // as above: we don't care about errors for old requests either + return false; } - if (this._unmounted) { + if (this.unmounted) { // if we've been unmounted, we don't care either. - return; + return false; } console.error("Failed to get publicRooms: %s", JSON.stringify(err)); @@ -252,29 +326,25 @@ export default class RoomDirectory extends React.Component { * HS admins to do this through the RoomSettings interface, but * this needs SPEC-417. */ - removeFromDirectory(room) { - const alias = get_display_alias_for_room(room); + private removeFromDirectory(room: IRoom) { + const alias = getDisplayAliasForRoom(room); const name = room.name || alias || _t('Unnamed room'); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - let desc; if (alias) { - desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', {alias, name}); + desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', { alias, name }); } else { - desc = _t('Remove %(name)s from the directory?', {name: name}); + desc = _t('Remove %(name)s from the directory?', { name: name }); } Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, { title: _t('Remove from Directory'), description: desc, - onFinished: (should_delete) => { - if (!should_delete) return; + onFinished: (shouldDelete: boolean) => { + if (!shouldDelete) return; - const Loader = sdk.getComponent("elements.Spinner"); - const modal = Modal.createDialog(Loader); - let step = _t('remove %(name)s from the directory.', {name: name}); + const modal = Modal.createDialog(Spinner); + let step = _t('remove %(name)s from the directory.', { name: name }); MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => { if (!alias) return; @@ -289,23 +359,24 @@ export default class RoomDirectory extends React.Component { console.error("Failed to " + step + ": " + err); Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, { title: _t('Error'), - description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')), + description: (err && err.message) + ? err.message + : _t('The server may be unavailable or overloaded'), }); }); }, }); } - onRoomClicked = (room, ev) => { + private onRoomClicked = (room: 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); } }; - onOptionChange = (server, instanceId) => { + private onOptionChange = (server: string, instanceId?: string) => { // clear next batch so we don't try to load more rooms this.nextBatch = null; this.setState({ @@ -323,17 +394,25 @@ export default class RoomDirectory extends React.Component { // find the five gitter ones, at which point we do not want // to render all those rooms when switching back to 'all networks'. // Easiest to just blow away the state & re-fetch. + + // We have to be careful here so that we don't set instanceId = "undefined" + localStorage.setItem(LAST_SERVER_KEY, server); + if (instanceId) { + localStorage.setItem(LAST_INSTANCE_KEY, instanceId); + } else { + localStorage.removeItem(LAST_INSTANCE_KEY); + } }; - onFillRequest = (backwards) => { + private onFillRequest = (backwards: boolean) => { if (backwards || !this.nextBatch) return Promise.resolve(false); return this.getMoreRooms(); }; - onFilterChange = (alias) => { + private onFilterChange = (alias: string) => { this.setState({ - filterString: alias || null, + filterString: alias || "", }); // don't send the request for a little bit, @@ -349,10 +428,10 @@ export default class RoomDirectory extends React.Component { }, 700); }; - onFilterClear = () => { + private onFilterClear = () => { // update immediately this.setState({ - filterString: null, + filterString: "", }, this.refreshRoomList); if (this.filterTimeout) { @@ -360,7 +439,7 @@ export default class RoomDirectory extends React.Component { } }; - onJoinFromSearchClick = (alias) => { + private onJoinFromSearchClick = (alias: string) => { // If we don't have a particular instance id selected, just show that rooms alias if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { // If the user specified an alias without a domain, add on whichever server is selected @@ -373,9 +452,10 @@ export default class RoomDirectory extends React.Component { // This is a 3rd party protocol. Let's see if we can join it const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); const instance = instanceForInstanceId(this.protocols, this.state.instanceId); - const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null; + const fields = protocolName + ? this.getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) + : null; if (!fields) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const brand = SdkConfig.get().brand; Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, { title: _t('Unable to join network'), @@ -387,14 +467,12 @@ export default class RoomDirectory extends React.Component { if (resp.length > 0 && resp[0].alias) { this.showRoomAlias(resp[0].alias, true); } else { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Room not found', '', ErrorDialog, { title: _t('Room not found'), description: _t('Couldn\'t find a matching Matrix room'), }); } }, (e) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, { title: _t('Fetching third party location failed'), description: _t('Unable to look up room ID from server'), @@ -403,36 +481,37 @@ export default class RoomDirectory extends React.Component { } }; - onPreviewClick = (ev, room) => { + private onPreviewClick = (ev: ButtonEvent, room: IRoom) => { this.showRoom(room, null, false, true); ev.stopPropagation(); }; - onViewClick = (ev, room) => { + private onViewClick = (ev: ButtonEvent, room: IRoom) => { this.showRoom(room); ev.stopPropagation(); }; - onJoinClick = (ev, room) => { + private onJoinClick = (ev: ButtonEvent, room: IRoom) => { this.showRoom(room, null, true); ev.stopPropagation(); }; - onCreateRoomClick = room => { + private onCreateRoomClick = () => { this.onFinished(); dis.dispatch({ action: 'view_create_room', public: true, + defaultName: this.state.filterString.trim(), }); }; - showRoomAlias(alias, autoJoin=false) { + private showRoomAlias(alias: string, autoJoin = false) { this.showRoom(null, alias, autoJoin); } - showRoom(room, room_alias, autoJoin = false, shouldPeek = false) { + private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) { this.onFinished(); - const payload = { + const payload: ActionPayload = { action: 'view_room', auto_join: autoJoin, should_peek: shouldPeek, @@ -444,20 +523,20 @@ export default class RoomDirectory extends React.Component { // to the directory. if (MatrixClientPeg.get().isGuest()) { if (!room.world_readable && !room.guest_can_join) { - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); return; } } - if (!room_alias) { - room_alias = get_display_alias_for_room(room); + if (!roomAlias) { + roomAlias = getDisplayAliasForRoom(room); } payload.oob_data = { avatarUrl: room.avatar_url, // XXX: This logic is duplicated from the JS SDK which // would normally decide what the name is. - name: room.name || room_alias || _t('Unnamed room'), + name: room.name || roomAlias || _t('Unnamed room'), }; if (this.state.roomServer) { @@ -471,21 +550,19 @@ export default class RoomDirectory extends React.Component { // which servers to start querying. However, there's no other way to join rooms in // this list without aliases at present, so if roomAlias isn't set here we have no // choice but to supply the ID. - if (room_alias) { - payload.room_alias = room_alias; + if (roomAlias) { + payload.room_alias = roomAlias; } else { payload.room_id = room.room_id; } dis.dispatch(payload); } - createRoomCells(room) { + private createRoomCells(room: IRoom) { const client = MatrixClientPeg.get(); const clientRoom = client.getRoom(room.room_id); const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join"; const isGuest = client.isGuest(); - const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); let previewButton; let joinOrViewButton; @@ -495,20 +572,26 @@ export default class RoomDirectory extends React.Component { // it is readable, the preview appears as normal. if (!hasJoinedRoom && (room.world_readable || isGuest)) { previewButton = ( - this.onPreviewClick(ev, room)}>{_t("Preview")} + this.onPreviewClick(ev, room)}> + { _t("Preview") } + ); } if (hasJoinedRoom) { joinOrViewButton = ( - this.onViewClick(ev, room)}>{_t("View")} + this.onViewClick(ev, room)}> + { _t("View") } + ); } else if (!isGuest) { joinOrViewButton = ( - this.onJoinClick(ev, room)}>{_t("Join")} + this.onJoinClick(ev, room)}> + { _t("Join") } + ); } - let name = room.name || get_display_alias_for_room(room) || _t('Unnamed room'); + let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room'); if (name.length > MAX_NAME_LENGTH) { name = `${name.substring(0, MAX_NAME_LENGTH)}...`; } @@ -524,72 +607,80 @@ 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" > -
    , -
    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 }} /> -
    { get_display_alias_for_room(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 }
    , ]; } - collectScrollPanel = (element) => { - this.scrollPanel = element; - }; - - _stringLooksLikeId(s, field_type) { + private stringLooksLikeId(s: string, fieldType: IFieldType) { let pat = /^#[^\s]+:[^\s]/; - if (field_type && field_type.regexp) { - pat = new RegExp(field_type.regexp); + if (fieldType && fieldType.regexp) { + pat = new RegExp(fieldType.regexp); } return pat.test(s); } - _getFieldsForThirdPartyLocation(userInput, protocol, instance) { + private getFieldsForThirdPartyLocation(userInput: string, protocol: IProtocol, instance: IInstance) { // make an object with the fields specified by that protocol. We // require that the values of all but the last field come from the // instance. The last is the user input. @@ -605,71 +696,73 @@ export default class RoomDirectory extends React.Component { return fields; } - /** - * called by the parent component when PageUp/Down/etc is pressed. - * - * We pass it down to the scroll panel. - */ - handleScrollKey = ev => { - if (this.scrollPanel) { - this.scrollPanel.handleScrollKey(ev); - } - }; - - onFinished = () => { + private onFinished = () => { CountlyAnalytics.instance.trackRoomDirectory(this.startTime); - this.props.onFinished(); + this.props.onFinished(false); }; render() { - const Loader = sdk.getComponent("elements.Spinner"); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let content; if (this.state.error) { content = this.state.error; } else if (this.state.protocolsLoading) { - content = ; + content = ; } else { const cells = (this.state.publicRooms || []) - .reduce((cells, room) => cells.concat(this.createRoomCells(room)), [],); + .reduce((cells, room) => cells.concat(this.createRoomCells(room)), []); // we still show the scrollpanel, at least for now, because // otherwise we don't fetch more because we don't get a fill // request from the scrollpanel because there isn't one let spinner; if (this.state.loading) { - spinner = ; + spinner = ; } - let scrollpanel_content; + const createNewButton = <> +
    + + { _t("Create new room") } + + ; + + let scrollPanelContent; + let footer; if (cells.length === 0 && !this.state.loading) { - scrollpanel_content = { _t('No rooms to show') }; + footer = <> +
    { _t('No results for "%(query)s"', { query: this.state.filterString.trim() }) }
    +

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

    + { createNewButton } + ; } else { - scrollpanel_content =
    + scrollPanelContent =
    { cells }
    ; + if (!this.state.loading && !this.nextBatch) { + footer = createNewButton; + } } - const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); - content = - { scrollpanel_content } + { scrollPanelContent } { spinner } + { footer &&
    + { footer } +
    }
    ; } let listHeader; if (!this.state.protocolsLoading) { - const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown'); - const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox'); - const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); - let instance_expected_field_type; + let instanceExpectedFieldType; if ( protocolName && this.protocols && @@ -677,21 +770,27 @@ export default class RoomDirectory extends React.Component { this.protocols[protocolName].location_fields.length > 0 && this.protocols[protocolName].field_types ) { - const last_field = this.protocols[protocolName].location_fields.slice(-1)[0]; - instance_expected_field_type = this.protocols[protocolName].field_types[last_field]; + const lastField = this.protocols[protocolName].location_fields.slice(-1)[0]; + instanceExpectedFieldType = this.protocols[protocolName].field_types[lastField]; } let placeholder = _t('Find a room…'); if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { - placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer}); - } else if (instance_expected_field_type) { - placeholder = instance_expected_field_type.placeholder; + placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", { + exampleRoom: "#example:" + this.state.roomServer, + }); + } else if (instanceExpectedFieldType) { + placeholder = instanceExpectedFieldType.placeholder; } - let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type); + let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType); if (protocolName) { const instance = instanceForInstanceId(this.protocols, this.state.instanceId); - if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) { + if (this.getFieldsForThirdPartyLocation( + this.state.filterString, + this.protocols[protocolName], + instance, + ) === null) { showJoinButton = false; } } @@ -723,12 +822,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 => { - return ({sub}); - }}, + { a: sub => ( + + { sub } + + ) }, ); const title = this.state.selectedCommunityId @@ -756,6 +854,6 @@ export default class RoomDirectory extends React.Component { // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // but works with the objects we get from the public room list -function get_display_alias_for_room(room) { - return room.canonical_alias || (room.aliases ? room.aliases[0] : ""); +function getDisplayAliasForRoom(room: IRoom) { + return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases); } diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index a64feed42c..e8080b4f7b 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -17,6 +17,8 @@ limitations under the License. import * as React from "react"; import { createRef } from "react"; import classNames from "classnames"; +import { Room } from "matrix-js-sdk/src/models/room"; + import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import { ActionPayload } from "../../dispatcher/payloads"; @@ -25,8 +27,8 @@ import { Action } from "../../dispatcher/actions"; import RoomListStore from "../../stores/room-list/RoomListStore"; import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition"; import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; -import {replaceableComponent} from "../../utils/replaceableComponent"; -import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore"; interface IProps { isMinimized: boolean; @@ -40,6 +42,7 @@ interface IProps { interface IState { query: string; focused: boolean; + inSpaces: boolean; } @replaceableComponent("structures.RoomSearch") @@ -54,11 +57,13 @@ export default class RoomSearch extends React.PureComponent { this.state = { query: "", focused: false, + inSpaces: false, }; this.dispatcherRef = defaultDispatcher.register(this.onAction); // clear filter when changing spaces, in future we may wish to maintain a filter per-space SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput); + SpaceStore.instance.on(UPDATE_TOP_LEVEL_SPACES, this.onSpaces); } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { @@ -79,8 +84,15 @@ export default class RoomSearch extends React.PureComponent { public componentWillUnmount() { defaultDispatcher.unregister(this.dispatcherRef); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput); + SpaceStore.instance.off(UPDATE_TOP_LEVEL_SPACES, this.onSpaces); } + private onSpaces = (spaces: Room[]) => { + this.setState({ + inSpaces: spaces.length > 0, + }); + }; + private onAction = (payload: ActionPayload) => { if (payload.action === 'view_room' && payload.clear_search) { this.clearInput(); @@ -96,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) => { @@ -119,7 +131,7 @@ export default class RoomSearch extends React.PureComponent { switch (action) { case RoomListAction.ClearSearch: this.clearInput(); - defaultDispatcher.fire(Action.FocusComposer); + defaultDispatcher.fire(Action.FocusSendMessageComposer); break; case RoomListAction.NextRoom: case RoomListAction.PrevRoom: @@ -152,6 +164,11 @@ export default class RoomSearch extends React.PureComponent { 'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused, }); + let placeholder = _t("Filter"); + if (this.state.inSpaces) { + placeholder = _t("Filter all spaces"); + } + let icon = (
    ); @@ -165,7 +182,7 @@ export default class RoomSearch extends React.PureComponent { onBlur={this.onBlur} onChange={this.onChange} onKeyDown={this.onKeyDown} - placeholder={_t("Filter")} + placeholder={placeholder} autoComplete="off" /> ); diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 38e3cd97e8..80ea26c3f2 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,15 +115,15 @@ 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}); - dis.fire(Action.FocusComposer); + this.setState({ isResending: true }); + dis.fire(Action.FocusSendMessageComposer); }; _onCancelAllClick = () => { Resend.cancelUnsentEvents(this.props.room); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); }; _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { @@ -200,20 +200,22 @@ export default class RoomStatusBar extends React.Component { } else if (resourceLimitError) { title = messageForResourceLimitError( resourceLimitError.data.limit_type, - resourceLimitError.data.admin_contact, { - 'monthly_active_user': _td( - "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " + - "Please contact your service administrator to continue using the service.", - ), - 'hs_disabled': _td( - "Your message wasn't sent because this homeserver has been blocked by it's administrator. " + - "Please contact your service administrator to continue using the service.", - ), - '': _td( - "Your message wasn't sent because this homeserver has exceeded a resource limit. " + - "Please contact your service administrator to continue using the service.", - ), - }); + resourceLimitError.data.admin_contact, + { + 'monthly_active_user': _td( + "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " + + "Please contact your service administrator to continue using the service.", + ), + 'hs_disabled': _td( + "Your message wasn't sent because this homeserver has been blocked by it's administrator. " + + "Please contact your service administrator to continue using the service.", + ), + '': _td( + "Your message wasn't sent because this homeserver has exceeded a resource limit. " + + "Please contact your service administrator to continue using the service.", + ), + }, + ); } else { title = _t('Some of your messages have not been sent'); } @@ -265,7 +267,7 @@ export default class RoomStatusBar extends React.Component {
    /!\ + height="24" title="/!\ " alt="/!\ " />
    {_t('Connectivity to the server has been lost.')} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 7168b7d139..8e0b8a5f4a 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'; @@ -33,20 +34,17 @@ import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; import ResizeNotifier from '../../utils/ResizeNotifier'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; -import * as sdk from '../../index'; import CallHandler, { 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"; +import { Layout } from "../../settings/Layout"; import AccessibleButton from "../views/elements/AccessibleButton"; import RightPanelStore from "../../stores/RightPanelStore"; import { haveTileForEvent } from "../views/rooms/EventTile"; @@ -54,20 +52,17 @@ import RoomContext from "../../contexts/RoomContext"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; import { Action } from "../../dispatcher/actions"; -import { SettingLevel } from "../../settings/SettingLevel"; import { IMatrixClientCreds } from "../../MatrixClientPeg"; import ScrollPanel from "./ScrollPanel"; import TimelinePanel from "./TimelinePanel"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; -import ForwardMessage from "../views/rooms/ForwardMessage"; -import SearchBar from "../views/rooms/SearchBar"; +import SearchBar, { SearchScope } from "../views/rooms/SearchBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; -import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel"; import AuxPanel from "../views/rooms/AuxPanel"; import RoomHeader from "../views/rooms/RoomHeader"; import { 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'; @@ -82,7 +77,18 @@ import { getKeyBindingsManager, RoomAction } from '../../KeyBindingsManager'; import { objectHasDiff } from "../../utils/objects"; import SpaceRoomView from "./SpaceRoomView"; import { IOpts } from "../../createRoom"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import UIStore from "../../stores/UIStore"; +import EditorStateTransfer from "../../utils/EditorStateTransfer"; +import { throttle } from "lodash"; +import ErrorDialog from '../views/dialogs/ErrorDialog'; +import SearchResultTile from '../views/rooms/SearchResultTile'; +import Spinner from "../views/elements/Spinner"; +import UploadBar from './UploadBar'; +import RoomStatusBar from "./RoomStatusBar"; +import MessageComposer from '../views/rooms/MessageComposer'; +import JumpToBottomButton from "../views/rooms/JumpToBottomButton"; +import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -95,22 +101,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; @@ -136,16 +128,15 @@ export interface IState { // Whether to highlight the event scrolled to isInitialEventHighlighted?: boolean; replyToEvent?: MatrixEvent; - forwardingEvent?: MatrixEvent; numUnreadMessages: number; draggingFile: boolean; searching: boolean; searchTerm?: string; - searchScope?: "All" | "Room"; + searchScope?: SearchScope; searchResults?: XOR<{}, { count: number; highlights: string[]; - results: MatrixEvent[]; + results: SearchResult[]; next_batch: string; // eslint-disable-line camelcase }>; searchHighlights?: string[]; @@ -155,8 +146,6 @@ export interface IState { canPeek: boolean; showApps: boolean; isPeeking: boolean; - showingPinned: boolean; - showReadReceipts: boolean; showRightPanel: boolean; // error object, as from the matrix client/server API // If we failed to load information about the room, @@ -175,14 +164,17 @@ export interface IState { statusBarVisible: boolean; // We load this later by asking the js-sdk to suggest a version for us. // This object is the result of Room#getRecommendedVersion() - upgradeRecommendation?: { - version: string; - needsUpgrade: boolean; - urgent: boolean; - }; + + upgradeRecommendation?: IRecommendedVersion; canReact: boolean; canReply: boolean; layout: Layout; + lowBandwidth: boolean; + showReadReceipts: boolean; + showRedactions: boolean; + showJoinLeaves: boolean; + showAvatarChanges: boolean; + showDisplaynameChanges: boolean; matrixClientIsReady: boolean; showUrlPreview?: boolean; e2eStatus?: E2EStatus; @@ -190,6 +182,10 @@ export interface IState { rejectError?: Error; hasPinnedWidgets?: boolean; dragCounter: number; + // whether or not a spaces context switch brought us here, + // if it did we don't want the room to be marked as read as soon as it is loaded. + wasContextSwitch?: boolean; + editState?: EditorStateTransfer; } @replaceableComponent("structures.RoomView") @@ -197,8 +193,7 @@ export default class RoomView extends React.Component { private readonly dispatcherRef: string; private readonly roomStoreToken: EventSubscription; private readonly rightPanelStoreToken: EventSubscription; - private readonly showReadReceiptsWatchRef: string; - private readonly layoutWatcherRef: string; + private settingWatchers: string[]; private unmounted = false; private permalinkCreators: Record = {}; @@ -229,8 +224,6 @@ export default class RoomView extends React.Component { canPeek: false, showApps: false, isPeeking: false, - showingPinned: false, - showReadReceipts: true, showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, joining: false, atEndOfLiveTimeline: true, @@ -240,6 +233,12 @@ export default class RoomView extends React.Component { canReact: false, canReply: false, layout: SettingsStore.getValue("layout"), + lowBandwidth: SettingsStore.getValue("lowBandwidth"), + showReadReceipts: true, + showRedactions: true, + showJoinLeaves: true, + showAvatarChanges: true, + showDisplaynameChanges: true, matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), dragCounter: 0, }; @@ -266,16 +265,21 @@ export default class RoomView extends React.Component { WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); - this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null, - this.onReadReceiptsChange); - this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onLayoutChange); + this.settingWatchers = [ + SettingsStore.watchSetting("layout", null, () => + this.setState({ layout: SettingsStore.getValue("layout") }), + ), + SettingsStore.watchSetting("lowBandwidth", null, () => + this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }), + ), + ]; } private onWidgetStoreUpdate = () => { if (this.state.room) { this.checkWidgets(this.state.room); } - } + }; private checkWidgets = (room) => { this.setState({ @@ -321,13 +325,45 @@ export default class RoomView extends React.Component { initialEventId: RoomViewStore.getInitialEventId(), isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), replyToEvent: RoomViewStore.getQuotingEvent(), - forwardingEvent: RoomViewStore.getForwardingEvent(), // we should only peek once we have a ready client shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(), - showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId), showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), + showRedactions: SettingsStore.getValue("showRedactions", roomId), + showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), + showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), + showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), + wasContextSwitch: RoomViewStore.getWasContextSwitch(), }; + // Add watchers for each of the settings we just looked up + this.settingWatchers = this.settingWatchers.concat([ + SettingsStore.watchSetting("showReadReceipts", null, () => + this.setState({ + showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), + }), + ), + SettingsStore.watchSetting("showRedactions", null, () => + this.setState({ + showRedactions: SettingsStore.getValue("showRedactions", roomId), + }), + ), + SettingsStore.watchSetting("showJoinLeaves", null, () => + this.setState({ + showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), + }), + ), + SettingsStore.watchSetting("showAvatarChanges", null, () => + this.setState({ + showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), + }), + ), + SettingsStore.watchSetting("showDisplaynameChanges", null, () => + this.setState({ + showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), + }), + ), + ]); + if (!initial && this.state.shouldPeek && !newState.shouldPeek) { // Stop peeking because we have joined this room now this.context.stopPeeking(); @@ -486,7 +522,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 }); } } } @@ -524,7 +560,16 @@ export default class RoomView extends React.Component { } shouldComponentUpdate(nextProps, nextState) { - return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState)); + const hasPropsDiff = objectHasDiff(this.props, nextProps); + + const { upgradeRecommendation, ...state } = this.state; + const { upgradeRecommendation: newUpgradeRecommendation, ...newState } = nextState; + + const hasStateDiff = + newUpgradeRecommendation?.needsUpgrade !== upgradeRecommendation?.needsUpgrade || + objectHasDiff(state, newState); + + return hasPropsDiff || hasStateDiff; } componentDidUpdate() { @@ -623,24 +668,24 @@ export default class RoomView extends React.Component { ); } - if (this.showReadReceiptsWatchRef) { - SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef); + // cancel any pending calls to the throttled updated + this.updateRoomMembers.cancel(); + + for (const watcher of this.settingWatchers) { + SettingsStore.unwatchSetting(watcher); } - - // 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 - - SettingsStore.unwatchSetting(this.layoutWatcherRef); } - private onLayoutChange = () => { - this.setState({ - layout: SettingsStore.getValue("layout"), - }); + private onUserScroll = () => { + if (this.state.initialEventId && this.state.isInitialEventHighlighted) { + dis.dispatch({ + action: 'view_room', + room_id: this.state.room.roomId, + event_id: this.state.initialEventId, + highlighted: false, + replyingToEvent: this.state.replyToEvent, + }); + } }; private onRightPanelStoreUpdate = () => { @@ -760,6 +805,35 @@ 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 + dis.dispatch({ + ...payload, + action: this.state.editState ? "edit_composer_insert" : "send_composer_insert", + }); + break; + } + + case Action.FocusAComposer: { + // re-dispatch to the correct composer + dis.fire(this.state.editState ? Action.FocusEditMessageComposer : Action.FocusSendMessageComposer); + break; + } + + case "scroll_to_bottom": + this.messagePanel?.jumpToLiveTimeline(); + break; } }; @@ -793,9 +867,9 @@ export default class RoomView extends React.Component { // update unread count when scrolled up if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { // no change - } else if (!shouldHideEvent(ev)) { + } else if (!shouldHideEvent(ev, this.state)) { this.setState((state, props) => { - return {numUnreadMessages: state.numUnreadMessages + 1}; + return { numUnreadMessages: state.numUnreadMessages + 1 }; }); } } @@ -820,7 +894,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}` }); } }); }; @@ -873,7 +947,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.` + @@ -901,7 +975,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({ @@ -972,15 +1046,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) { @@ -992,12 +1057,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); } @@ -1024,7 +1084,7 @@ export default class RoomView extends React.Component { return; } - this.updateRoomMembers(member); + this.updateRoomMembers(); }; private onMyMembership = (room: Room, membership: string, oldMembership: string) => { @@ -1041,15 +1101,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(); @@ -1070,7 +1130,7 @@ export default class RoomView extends React.Component { } } - private onSearchResultsFillRequest = (backwards: boolean) => { + private onSearchResultsFillRequest = (backwards: boolean): Promise => { if (!backwards) { return Promise.resolve(false); } @@ -1105,12 +1165,13 @@ export default class RoomView extends React.Component { room_id: this.getRoomId(), }, }); - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); } else { Promise.resolve().then(() => { const signUrl = this.props.threepidInvite?.signUrl; dis.dispatch({ - action: 'join_room', + action: Action.JoinRoom, + roomId: this.getRoomId(), opts: { inviteSignUrl: signUrl }, _type: "unknown", // TODO: instrumentation }); @@ -1139,13 +1200,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 }); } }; @@ -1184,7 +1245,7 @@ export default class RoomView extends React.Component { ContentMessages.sharedInstance().sendContentListToRoom( ev.dataTransfer.files, this.state.room.roomId, this.context, ); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); this.setState({ draggingFile: false, @@ -1192,9 +1253,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; } @@ -1207,7 +1268,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, @@ -1228,14 +1289,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; @@ -1248,7 +1309,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 @@ -1273,13 +1334,13 @@ export default class RoomView extends React.Component { searchResults: results, }); }, (error) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Search failed", error); Modal.createTrackedDialog('Search failed', '', ErrorDialog, { title: _t("Search failed"), description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or search timed out :(")), }); + return false; }).finally(() => { this.setState({ searchInProgress: false, @@ -1288,9 +1349,6 @@ export default class RoomView extends React.Component { } private getSearchResultTiles() { - const SearchResultTile = sdk.getComponent('rooms.SearchResultTile'); - const Spinner = sdk.getComponent("elements.Spinner"); - // XXX: todo: merge overlapping results somehow? // XXX: why doesn't searching on name work? @@ -1371,13 +1429,6 @@ export default class RoomView extends React.Component { return ret; } - private onPinnedClick = () => { - const nowShowingPinned = !this.state.showingPinned; - const roomId = this.state.room.roomId; - this.setState({showingPinned: nowShowingPinned, searching: false}); - SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned); - }; - private onCallPlaced = (type: PlaceCallType) => { dis.dispatch({ action: 'place_call', @@ -1390,18 +1441,6 @@ export default class RoomView extends React.Component { dis.dispatch({ action: "open_room_settings" }); }; - private onCancelClick = () => { - console.log("updateTint from onCancelClick"); - this.updateTint(); - if (this.state.forwardingEvent) { - dis.dispatch({ - action: 'forward_event', - event: null, - }); - } - dis.fire(Action.FocusComposer); - }; - private onAppsClick = () => { dis.dispatch({ action: "appsDrawer", @@ -1409,13 +1448,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', @@ -1436,7 +1468,6 @@ export default class RoomView extends React.Component { console.error("Failed to reject invite: %s", error); const msg = error.message ? error.message : JSON.stringify(error); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, { title: _t("Failed to reject invite"), description: msg, @@ -1470,7 +1501,6 @@ export default class RoomView extends React.Component { console.error("Failed to reject invite: %s", error); const msg = error.message ? error.message : JSON.stringify(error); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, { title: _t("Failed to reject invite"), description: msg, @@ -1494,7 +1524,6 @@ export default class RoomView extends React.Component { private onSearchClick = () => { this.setState({ searching: !this.state.searching, - showingPinned: false, }); }; @@ -1507,8 +1536,19 @@ export default class RoomView extends React.Component { // jump down to the bottom of this room, where new events are arriving private jumpToLiveTimeline = () => { - this.messagePanel.jumpToLiveTimeline(); - dis.fire(Action.FocusComposer); + if (this.state.initialEventId && this.state.isInitialEventHighlighted) { + // If we were viewing a highlighted event, firing view_room without + // an event will take care of both clearing the URL fragment and + // jumping to the bottom + dis.dispatch({ + action: 'view_room', + room_id: this.state.room.roomId, + }); + } else { + // Otherwise we have to jump manually + this.messagePanel.jumpToLiveTimeline(); + dis.fire(Action.FocusSendMessageComposer); + } }; // jump up to wherever our read marker is @@ -1530,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; @@ -1581,59 +1621,30 @@ export default class RoomView extends React.Component { // a maxHeight on the underlying remote video tag. // header + footer + status + give us at least 120px of scrollback at all times. - let auxPanelMaxHeight = window.innerHeight - + let auxPanelMaxHeight = UIStore.instance.windowHeight - (54 + // height of RoomHeader 36 + // height of the status area - 51 + // minimum height of the message compmoser + 51 + // minimum height of the message composer 120); // amount of desired scrollback // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway // but it's better than the video going missing entirely if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; - this.setState({auxPanelMaxHeight: auxPanelMaxHeight}); - }; - - private onFullscreenClick = () => { - dis.dispatch({ - action: 'video_fullscreen', - fullscreen: true, - }, true); - }; - - private onMuteAudioClick = () => { - const call = this.getCallForRoom(); - if (!call) { - return; + if (this.state.auxPanelMaxHeight !== auxPanelMaxHeight) { + this.setState({ auxPanelMaxHeight }); } - const newState = !call.isMicrophoneMuted(); - call.setMicrophoneMuted(newState); - this.forceUpdate(); // TODO: just update the voip buttons - }; - - private onMuteVideoClick = () => { - const call = this.getCallForRoom(); - if (!call) { - return; - } - const newState = !call.isLocalVideoMuted(); - call.setLocalVideoMuted(newState); - this.forceUpdate(); // TODO: just update the voip buttons }; private onStatusBarVisible = () => { - if (this.unmounted) return; - this.setState({ - statusBarVisible: true, - }); + if (this.unmounted || this.state.statusBarVisible) return; + this.setState({ statusBarVisible: true }); }; private onStatusBarHidden = () => { // This is currently not desired as it is annoying if it keeps expanding and collapsing - if (this.unmounted) return; - this.setState({ - statusBarVisible: false, - }); + if (this.unmounted || !this.state.statusBarVisible) return; + this.setState({ statusBarVisible: false }); }; /** @@ -1668,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() { @@ -1690,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() { @@ -1746,7 +1753,10 @@ export default class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); - if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself + if (myMembership === "invite" + // SpaceRoomView handles invites itself + && (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom()) + ) { if (this.state.joining || this.state.rejecting) { return ( @@ -1824,10 +1834,8 @@ export default class RoomView extends React.Component { let isStatusAreaExpanded = true; if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { - const UploadBar = sdk.getComponent('structures.UploadBar'); statusBar = ; } else if (!this.state.searchResults) { - const RoomStatusBar = sdk.getComponent('structures.RoomStatusBar'); isStatusAreaExpanded = this.state.statusBarVisible; statusBar = { let aux = null; let previewBar; - let hideCancel = false; - if (this.state.forwardingEvent) { - aux = ; - } else if (this.state.searching) { - hideCancel = true; // has own cancel + if (this.state.searching) { aux = { />; } else if (showRoomUpgradeBar) { aux = ; - hideCancel = true; - } else if (this.state.showingPinned) { - hideCancel = true; // has own cancel - aux = ; } else if (myMembership !== "join") { // We do have a room object for this room, but we're not currently in it. // We may have a 3rd party invite to it. @@ -1874,7 +1874,6 @@ export default class RoomView extends React.Component { inviterName = this.props.oobData.inviterName; } const invitedEmail = this.props.threepidInvite?.toEmail; - hideCancel = true; previewBar = ( { room={this.state.room} /> ); - if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) { + if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) { return (
    { previewBar } @@ -1904,13 +1903,13 @@ export default class RoomView extends React.Component { > {_t( "You have %(count)s unread notifications in a prior version of this room.", - {count: hiddenHighlightCount}, + { count: hiddenHighlightCount }, )} ); } - if (SettingsStore.getValue("feature_spaces") && this.state.room?.isSpaceRoom()) { + if (this.state.room?.isSpaceRoom()) { return { myMembership === 'join' && !this.state.searchResults ); if (canSpeak) { - const MessageComposer = sdk.getComponent('rooms.MessageComposer'); messageComposer = { hideMessagePanel = true; } - const shouldHighlight = this.state.isInitialEventHighlighted; let highlightedEventId = null; - if (this.state.forwardingEvent) { - highlightedEventId = this.state.forwardingEvent.getId(); - } else if (shouldHighlight) { + if (this.state.isInitialEventHighlighted) { highlightedEventId = this.state.initialEventId; } @@ -2014,12 +2007,14 @@ export default class RoomView extends React.Component { timelineSet={this.state.room.getUnfilteredTimelineSet()} showReadReceipts={this.state.showReadReceipts} manageReadReceipts={!this.state.isPeeking} + sendReadReceiptOnLoad={!this.state.wasContextSwitch} manageReadMarkers={!this.state.isPeeking} hidden={hideMessagePanel} highlightedEventId={highlightedEventId} eventId={this.state.initialEventId} eventPixelOffset={this.state.initialEventPixelOffset} onScroll={this.onMessageListScroll} + onUserScroll={this.onUserScroll} onReadMarkerUpdated={this.updateTopUnreadMessagesBar} showUrlPreview = {this.state.showUrlPreview} className={messagePanelClassNames} @@ -2028,12 +2023,12 @@ export default class RoomView extends React.Component { resizeNotifier={this.props.resizeNotifier} showReactions={true} layout={this.state.layout} + editState={this.state.editState} />); let topUnreadMessagesBar = null; // Do not show TopUnreadMessagesBar if we have search results showing, it makes no sense if (this.state.showTopUnreadMessagesBar && !this.state.searchResults) { - const TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar'); topUnreadMessagesBar = ( ); @@ -2041,11 +2036,11 @@ export default class RoomView extends React.Component { let jumpToBottom; // Do not show JumpToBottomButton if we have search results showing, it makes no sense if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) { - const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton'); jumpToBottom = ( 0} + highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0} numUnreadMessages={this.state.numUnreadMessages} onScrollToBottomClick={this.jumpToLiveTimeline} + roomId={this.state.roomId} />); } @@ -2082,10 +2077,7 @@ export default class RoomView extends React.Component { inRoom={myMembership === 'join'} onSearchClick={this.onSearchClick} onSettingsClick={this.onSettingsClick} - onPinnedClick={this.onPinnedClick} - onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null} onForgetClick={(myMembership === "leave") ? this.onForgetClick : null} - onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} e2eStatus={this.state.e2eStatus} onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null} appsShown={this.state.showApps} diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.tsx similarity index 71% rename from src/components/structures/ScrollPanel.js rename to src/components/structures/ScrollPanel.tsx index a014a6e4fe..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,93 +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, - - /* 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() { @@ -199,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 @@ -221,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(); }; @@ -234,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. @@ -274,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; @@ -289,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. @@ -326,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 = []; @@ -344,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) { @@ -361,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; @@ -409,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); @@ -421,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; } @@ -432,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 @@ -441,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) { @@ -473,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. * @@ -487,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(); }; /** @@ -523,33 +553,41 @@ export default class ScrollPanel extends React.Component { * * @param {number} mult: -1 to page up, +1 to page down */ - scrollRelative = mult => { - const scrollNode = this._getScrollNode(); + 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) { case RoomAction.ScrollUp: this.scrollRelative(-1); + isScrolling = true; break; case RoomAction.RoomScrollDown: this.scrollRelative(1); + isScrolling = true; break; case RoomAction.JumpToFirstMessage: this.scrollToTop(); + isScrolling = true; break; case RoomAction.JumpToLatestMessage: this.scrollToBottom(); + isScrolling = true; break; } + if (isScrolling && this.props.onUserScroll) { + this.props.onUserScroll(ev); + } }; /* Scroll the panel to bring the DOM node with the scroll token @@ -563,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 @@ -581,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; } @@ -622,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, @@ -632,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"); @@ -668,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."); } @@ -682,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) { @@ -701,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 @@ -718,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 && @@ -756,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; }; /** @@ -802,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; @@ -829,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; @@ -845,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 @@ -884,16 +922,21 @@ export default class ScrollPanel extends React.Component { // give the
      an explicit role=list because Safari+VoiceOver seems to think an ordered-list with // list-style-type: none; is no longer a list - return ( - { this.props.fixedChildren } -
      -
        - { this.props.children } -
      -
      -
      - ); + onWheel={this.props.onUserScroll} + className={`mx_ScrollPanel ${this.props.className}`} + style={this.props.style} + > + { this.props.fixedChildren } +
      +
        + { this.props.children } +
      +
      + + ); } } diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index 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 ( -
      +
      = ({ numChildRooms, children, }) => { - const name = room.name || room.canonical_alias || room.aliases?.[0] + const cli = MatrixClientPeg.get(); + const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null; + const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0] || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room")); const [showChildren, toggleShowChildren] = useStateToggle(true); - const cli = MatrixClientPeg.get(); - const cliRoom = cli.getRoom(room.room_id); - const myMembership = cliRoom?.getMyMembership(); - - const onPreviewClick = () => onViewRoomClick(false); - const onJoinClick = () => onViewRoomClick(true); + const onPreviewClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + onViewRoomClick(false); + }; + const onJoinClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + onViewRoomClick(true); + }; let button; - if (myMembership === "join") { + if (joinedRoom) { button = { _t("View") } ; @@ -127,24 +138,34 @@ const Tile: React.FC = ({ } else { checkbox = { ev.stopPropagation() }} + onClick={ev => { ev.stopPropagation(); }} > ; } } - let url: string; - if (room.avatar_url) { - url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20); + let avatar; + if (joinedRoom) { + avatar = ; + } else { + avatar = ; } let description = _t("%(count)s members", { count: room.num_joined_members }); - if (numChildRooms) { + if (numChildRooms !== undefined) { description += " · " + _t("%(count)s rooms", { count: numChildRooms }); } - if (room.topic) { - description += " · " + room.topic; + + const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic; + if (topic) { + description += " · " + topic; } let suggestedSection; @@ -155,13 +176,22 @@ const Tile: React.FC = ({ } const content = - + { avatar }
      { name } { suggestedSection }
      -
      +
      e && linkifyElement(e)} + onClick={ev => { + // prevent clicks on links from bubbling up to the room tile + if ((ev.target as HTMLElement).tagName === "A") { + ev.stopPropagation(); + } + }} + > { description }
      @@ -254,7 +284,11 @@ export const HierarchyLevel = ({ const space = cli.getRoom(spaceId); const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); - const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null); + const children = Array.from(relations.get(spaceId)?.values() || []); + const sortedChildren = sortBy(children, ev => { + // XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting + return getChildOrder(ev.content.order, null, ev.state_key); + }); const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { const roomId = ev.state_key; if (!rooms.has(roomId)) return result; @@ -286,7 +320,7 @@ export const HierarchyLevel = ({ key={roomId} room={rooms.get(roomId)} numChildRooms={Array.from(relations.get(roomId)?.values() || []) - .filter(ev => rooms.get(ev.state_key)?.room_type !== RoomType.Space).length} + .filter(ev => rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length} suggested={relations.get(spaceId)?.get(roomId)?.content.suggested} selected={selectedMap?.get(spaceId)?.has(roomId)} onViewRoomClick={(autoJoin) => { @@ -307,7 +341,7 @@ export const HierarchyLevel = ({ )) } - + ; }; // mutate argument refreshToken to force a reload @@ -331,9 +365,9 @@ export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: a parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev); childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id); } - if (Array.isArray(ev.content["via"])) { + if (Array.isArray(ev.content.via)) { const set = viaMap.getOrCreate(ev.state_key, new Set()); - ev.content["via"].forEach(via => set.add(via)); + ev.content.via.forEach(via => set.add(via)); } }); @@ -350,6 +384,7 @@ export const SpaceHierarchy: React.FC = ({ initialText = "", showRoom, refreshToken, + additionalButtons, children, }) => { const cli = MatrixClientPeg.get(); @@ -403,7 +438,7 @@ export const SpaceHierarchy: React.FC = ({ let content; if (roomsMap) { - const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length; + const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length; const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at let countsStr; @@ -415,78 +450,88 @@ export const SpaceHierarchy: React.FC = ({ countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces }); } - let editSection; + let manageButtons; if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][]; }); - let buttons; - if (selectedRelations.length) { - const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { - return parentChildMap.get(parentId)?.get(childId)?.content.suggested; - }); + const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { + return parentChildMap.get(parentId)?.get(childId)?.content.suggested; + }); - const disabled = removing || saving; + const disabled = !selectedRelations.length || removing || saving; - buttons = <> - { - setRemoving(true); - try { - for (const [parentId, childId] of selectedRelations) { - await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId); - parentChildMap.get(parentId).get(childId).content = {}; - parentChildMap.set(parentId, new Map(parentChildMap.get(parentId))); - } - } catch (e) { - setError(_t("Failed to remove some rooms. Try again later")); - } - setRemoving(false); - }} - kind="danger_outline" - disabled={disabled} - > - { removing ? _t("Removing...") : _t("Remove") } - - { - setSaving(true); - try { - for (const [parentId, childId] of selectedRelations) { - const suggested = !selectionAllSuggested; - const existingContent = parentChildMap.get(parentId)?.get(childId)?.content; - if (!existingContent || existingContent.suggested === suggested) continue; - - const content = { - ...existingContent, - suggested: !selectionAllSuggested, - }; - - await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId); - - parentChildMap.get(parentId).get(childId).content = content; - parentChildMap.set(parentId, new Map(parentChildMap.get(parentId))); - } - } catch (e) { - setError("Failed to update some suggestions. Try again later"); - } - setSaving(false); - }} - kind="primary_outline" - disabled={disabled} - > - { saving - ? _t("Saving...") - : (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested")) - } - - ; + let Button: React.ComponentType> = AccessibleButton; + let props = {}; + if (!selectedRelations.length) { + Button = AccessibleTooltipButton; + props = { + tooltip: _t("Select a room below first"), + yOffset: -40, + }; } - editSection = - { buttons } - ; + manageButtons = <> + + + ; } let results; @@ -532,7 +577,10 @@ export const SpaceHierarchy: React.FC = ({ content = <>
      { countsStr } - { editSection } + + { additionalButtons } + { manageButtons } +
      { error &&
      { error } @@ -550,7 +598,7 @@ export const SpaceHierarchy: React.FC = ({ return <> = ({ 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}; - }}, + } }, ) } void }) => { + if (!SdkConfig.get().bug_report_endpoint_url) return null; + + return
      +
      +
      + { _t("Spaces are a beta feature.") } + { + if (onClick) onClick(); + Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, { + featureId: "feature_spaces", + }); + }}> + { _t("Feedback") } + +
      +
      ; +}; + const RoomMemberCount = ({ room, children }) => { const members = useRoomMembers(room); const count = members.length; @@ -127,7 +162,14 @@ const SpaceInfo = ({ space }) => { ) : null} } -
      +
      ; +}; + +const onBetaClick = () => { + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Labs, + }); }; const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { @@ -136,9 +178,29 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => const [busy, setBusy] = useState(false); + const spacesEnabled = SettingsStore.getValue("feature_spaces"); + + const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave + && space.getJoinRule() !== JoinRule.Public; + let inviterSection; let joinButtons; - if (myMembership === "invite") { + if (myMembership === "join") { + // XXX remove this when spaces leaves Beta + joinButtons = ( + { + dis.dispatch({ + action: "leave_room", + room_id: space.roomId, + }); + }} + > + { _t("Leave") } + + ); + } else if (myMembership === "invite") { const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender(); const inviter = inviteSender && space.getMember(inviteSender); @@ -174,6 +236,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => setBusy(true); onJoinButtonClicked(); }} + disabled={!spacesEnabled} > { _t("Accept") } @@ -186,17 +249,43 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => setBusy(true); onJoinButtonClicked(); }} + disabled={!spacesEnabled || cannotJoin} > { _t("Join") } - ) + ); } if (busy) { joinButtons = ; } + let footer; + if (!spacesEnabled) { + footer =
      + { myMembership === "join" + ? _t("To view %(spaceName)s, turn on the Spaces beta", { + spaceName: space.name, + }, { + a: sub => { sub }, + }) + : _t("To join %(spaceName)s, turn on the Spaces beta", { + spaceName: space.name, + }, { + a: sub => { sub }, + }) + } +
      ; + } else if (cannotJoin) { + footer =
      + { _t("To view %(spaceName)s, you need an invite", { + spaceName: space.name, + }) } +
      ; + } + return
      + { inviterSection }

      @@ -214,9 +303,71 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
      { joinButtons }
      + { footer }

      ; }; +const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => { + const cli = useContext(MatrixClientContext); + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + + let contextMenu; + if (menuDisplayed) { + const rect = handle.current.getBoundingClientRect(); + contextMenu = + + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + + if (await showCreateNewRoom(cli, space)) { + onNewRoomAdded(); + } + }} + /> + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + + const [added] = await showAddExistingRooms(cli, space); + if (added) { + onNewRoomAdded(); + } + }} + /> + + ; + } + + return <> + + { _t("Add") } + + { contextMenu } + ; +}; + const SpaceLanding = ({ space }) => { const cli = useContext(MatrixClientContext); const myMembership = useMyRoomMembership(space); @@ -241,32 +392,20 @@ const SpaceLanding = ({ space }) => { const [refreshToken, forceUpdate] = useStateToggle(false); - let addRoomButtons; + let addRoomButton; if (canAddRooms) { - addRoomButtons = - { - const [added] = await showAddExistingRooms(cli, space); - if (added) { - forceUpdate(); - } - }}> - { _t("Add existing rooms & spaces") } - - { - showCreateNewRoom(cli, space); - }}> - { _t("Create a new room") } - - ; + addRoomButton = ; } let settingsButton; if (shouldShowSpaceSettings(cli, space)) { - settingsButton = { - showSpaceSettings(cli, space); - }}> - { _t("Settings") } - ; + settingsButton = { + showSpaceSettings(cli, space); + }} + title={_t("Settings")} + />; } const onMembersClick = () => { @@ -293,17 +432,24 @@ const SpaceLanding = ({ space }) => { { inviteButton } -
      -
      - -
      -
      -
      - { addRoomButtons } { settingsButton }
      + + {(topic, ref) => ( +
      + { topic } +
      + )} +
      + +
      - +
      ; }; @@ -312,7 +458,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { const [error, setError] = useState(""); const numFields = 3; const placeholders = [_t("General"), _t("Random"), _t("Support")]; - // TODO vary default prefills for "Just Me" spaces const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]); const fields = new Array(numFields).fill(0).map((_, i) => { const name = "roomName" + i; @@ -325,14 +470,18 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { value={roomNames[i]} onChange={ev => setRoomName(i, ev.target.value)} autoFocus={i === 2} + disabled={busy} />; }); - const onNextClick = async () => { + const onNextClick = async (ev) => { + ev.preventDefault(); + if (busy) return; setError(""); setBusy(true); try { - await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => { + const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean); + await Promise.all(filteredRoomNames.map(name => { return createRoom({ createOpts: { preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, @@ -345,7 +494,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { parentSpace: space, }); })); - onFinished(); + onFinished(filteredRoomNames.length > 0); } catch (e) { console.error("Failed to create initial space rooms", e); setError(_t("Failed to create initial space rooms")); @@ -353,7 +502,10 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { setBusy(false); }; - let onClick = onFinished; + let onClick = (ev) => { + ev.preventDefault(); + onFinished(false); + }; let buttonLabel = _t("Skip for now"); if (roomNames.some(name => name.trim())) { onClick = onNextClick; @@ -365,45 +517,26 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
      { description }
      { error &&
      { error }
      } - { fields } + + { fields } +
      - { buttonLabel } - + element="input" + type="submit" + form="mx_SpaceSetupFirstRooms" + value={buttonLabel} + />
      +
      ; }; const SpaceAddExistingRooms = ({ space, onFinished }) => { - const [selectedToAdd, setSelectedToAdd] = useState(new Set()); - - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - let onClick = onFinished; - let buttonLabel = _t("Skip for now"); - if (selectedToAdd.size > 0) { - onClick = async () => { - // TODO rate limiting - setBusy(true); - try { - await allSettled(Array.from(selectedToAdd).map((room) => - SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room)))); - onFinished(true); - } catch (e) { - console.error("Failed to add rooms to space", e); - setError(_t("Failed to add rooms to space")); - } - setBusy(false); - }; - buttonLabel = busy ? _t("Adding...") : _t("Add"); - } - return

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

      @@ -411,36 +544,28 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => { "no one will be informed. You can add more later.") }
      - { error &&
      { error }
      } - { - if (checked) { - selectedToAdd.add(room); - } else { - selectedToAdd.delete(room); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} + emptySelectionButton={ + + { _t("Skip for now") } + + } + onFinished={onFinished} />
      - - { buttonLabel } - +
      +
      ; }; -const SpaceSetupPublicShare = ({ space, onFinished }) => { +const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => { return
      -

      { _t("Share %(name)s", { name: space.name }) }

      +

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

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

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

      - { _t("Make sure the right people have access to %(name)s", { name: space.name }) } + { _t("Make sure the right people have access to %(name)s", { + name: justCreatedOpts?.createOpts?.name || space.name, + }) }
      { 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") }
      +
      +

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

      +

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

      +
      +
      ; }; @@ -506,10 +639,13 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { ref={fieldRefs[i]} onValidate={validateEmailRules} autoFocus={i === 0} + disabled={busy} />; }); - const onNextClick = async () => { + const onNextClick = async (ev) => { + ev.preventDefault(); + if (busy) return; setError(""); for (let i = 0; i < fieldRefs.length; i++) { const fieldRef = fieldRefs[i]; @@ -543,11 +679,14 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { setBusy(false); }; - let onClick = onFinished; + let onClick = (ev) => { + ev.preventDefault(); + onFinished(); + }; let buttonLabel = _t("Skip for now"); if (emailAddresses.some(name => name.trim())) { onClick = onNextClick; - buttonLabel = busy ? _t("Inviting...") : _t("Continue") + buttonLabel = busy ? _t("Inviting...") : _t("Continue"); } return
      @@ -556,8 +695,21 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { { _t("Make sure the right people have access. You can invite more later.") }
      +
      + + { _t("This is an experimental feature. For now, " + + "new users receiving an invite will have to open the invite on to actually join.", {}, { + b: sub => { sub }, + link: () => + app.element.io + , + }) } +
      + { error &&
      { error }
      } - { fields } +
      + { fields } +
      {
      - - { buttonLabel } - +
      +
      ; }; @@ -671,7 +830,7 @@ export default class SpaceRoomView extends React.PureComponent { let suggestedRooms = SpaceStore.instance.suggestedRooms; if (SpaceStore.instance.activeSpace !== this.props.space) { // the space store has the suggested rooms loaded for a different space, fetch the right ones - suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)).rooms; + suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)); } if (suggestedRooms.length) { @@ -679,9 +838,11 @@ export default class SpaceRoomView extends React.PureComponent { defaultDispatcher.dispatch({ action: "view_room", room_id: room.room_id, + room_alias: room.canonical_alias || room.aliases?.[0], + via_servers: room.viaServers, oobData: { avatarUrl: room.avatar_url, - name: room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"), + name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"), }, }); return; @@ -693,7 +854,7 @@ export default class SpaceRoomView extends React.PureComponent { private renderBody() { switch (this.state.phase) { case Phase.Landing: - if (this.state.myMembership === "join") { + if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) { return ; } else { return { return this.setState({ phase: Phase.PublicShare })} + onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })} />; case Phase.PublicShare: - return ; + return ; case Phase.PrivateScope: return { this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms }); }} @@ -735,7 +902,7 @@ export default class SpaceRoomView extends React.PureComponent { title={_t("What projects are you working on?")} description={_t("We'll create rooms for each of them. " + "You can add more later too, including already existing ones.")} - onFinished={() => this.setState({ phase: Phase.Landing })} + onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })} />; case Phase.PrivateExistingRooms: return { 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"); } } private _renderTabLabel(tab: Tab) { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let classes = "mx_TabbedView_tabLabel "; const idx = this.props.tabs.indexOf(tab); diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.tsx similarity index 71% rename from src/components/structures/TimelinePanel.js rename to src/components/structures/TimelinePanel.tsx index 12f5d6e890..85a048e9b8 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,27 +14,39 @@ 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"; import dis from "../../dispatcher/dispatcher"; -import * as sdk from "../../index"; import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; import shouldHideEvent from '../../shouldHideEvent'; +import { haveTileForEvent, TileShape } from "../views/rooms/EventTile"; +import { UIFeature } from "../../settings/UIFeature"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { arrayFastClone } from "../../utils/arrays"; +import MessagePanel from "./MessagePanel"; +import { IScrollState } from "./ScrollPanel"; +import { ActionPayload } from "../../dispatcher/payloads"; +import ResizeNotifier from "../../utils/ResizeNotifier"; +import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import Spinner from "../views/elements/Spinner"; import EditorStateTransfer from '../../utils/EditorStateTransfer'; -import {haveTileForEvent} from "../views/rooms/EventTile"; -import {UIFeature} from "../../settings/UIFeature"; -import {objectHasDiff} from "../../utils/objects"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import ErrorDialog from '../views/dialogs/ErrorDialog'; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -45,99 +54,183 @@ const READ_RECEIPT_INTERVAL_MS = 500; const DEBUG = false; -let debuglog = function() {}; +let debuglog = function(...s: any[]) {}; if (DEBUG) { // using bind means that we get to keep useful line numbers in the console debuglog = console.log.bind(console); } +interface IProps { + // The js-sdk EventTimelineSet object for the timeline sequence we are + // representing. This may or may not have a room, depending on what it's + // a timeline representing. If it has a room, we maintain RRs etc for + // that room. + timelineSet: EventTimelineSet; + showReadReceipts?: boolean; + // Enable managing RRs and RMs. These require the timelineSet to have a room. + manageReadReceipts?: boolean; + sendReadReceiptOnLoad?: boolean; + manageReadMarkers?: boolean; + + // true to give the component a 'display: none' style. + hidden?: boolean; + + // ID of an event to highlight. If undefined, no event will be highlighted. + // typically this will be either 'eventId' or undefined. + highlightedEventId?: string; + + // id of an event to jump to. If not given, will go to the end of the live timeline. + eventId?: string; + + // where to position the event given by eventId, in pixels from the bottom of the viewport. + // If not given, will try to put the event half way down the viewport. + eventPixelOffset?: number; + + // Should we show URL Previews + showUrlPreview?: boolean; + + // maximum number of events to show in a timeline + timelineCap?: number; + + // classname to use for the messagepanel + className?: string; + + // shape property to be passed to EventTiles + tileShape?: TileShape; + + // placeholder to use if the timeline is empty + empty?: ReactNode; + + // whether to show reactions for an event + showReactions?: boolean; + + // which layout to use + layout?: Layout; + + // whether to always show timestamps for an event + alwaysShowTimestamps?: boolean; + + resizeNotifier?: ResizeNotifier; + editState?: EditorStateTransfer; + permalinkCreator?: RoomPermalinkCreator; + membersLoaded?: boolean; + + // callback which is called when the panel is scrolled. + onScroll?(event: Event): void; + + // callback which is called when the user interacts with the room timeline + onUserScroll?(event: SyntheticEvent): void; + + // callback which is called when the read-up-to mark is updated. + onReadMarkerUpdated?(): void; + + // callback which is called when we wish to paginate the timeline window. + onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise; +} + +interface IState { + events: MatrixEvent[]; + liveEvents: MatrixEvent[]; + // track whether our room timeline is loading + timelineLoading: boolean; + + // the index of the first event that is to be shown + firstVisibleEventIndex: number; + + // canBackPaginate == false may mean: + // + // * we haven't (successfully) loaded the timeline yet, or: + // + // * we have got to the point where the room was created, or: + // + // * the server indicated that there were no more visible events + // (normally implying we got to the start of the room), or: + // + // * we gave up asking the server for more events + canBackPaginate: boolean; + + // canForwardPaginate == false may mean: + // + // * we haven't (successfully) loaded the timeline yet + // + // * we have got to the end of time and are now tracking the live + // timeline, or: + // + // * the server indicated that there were no more visible events + // (not sure if this ever happens when we're not at the live + // timeline), or: + // + // * we are looking at some historical point, but gave up asking + // the server for more events + canForwardPaginate: boolean; + + // start with the read-marker visible, so that we see its animated + // disappearance when switching into the room. + readMarkerVisible: boolean; + + readMarkerEventId: string; + + backPaginating: boolean; + forwardPaginating: boolean; + + // cache of matrixClient.getSyncState() (but from the 'sync' event) + clientSyncState: SyncState; + + // should the event tiles have twelve hour times + isTwelveHour: boolean; + + // always show timestamps on event tiles? + alwaysShowTimestamps: boolean; + + // how long to show the RM for when it's visible in the window + readMarkerInViewThresholdMs: number; + + // how long to show the RM for when it's scrolled off-screen + readMarkerOutOfViewThresholdMs: number; + + editState?: EditorStateTransfer; +} + +interface IEventIndexOpts { + ignoreOwn?: boolean; + allowPartial?: boolean; +} + /* * Component which shows the event timeline in a room view. * * Also responsible for handling and sending read receipts. */ @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, - manageReadMarkers: PropTypes.bool, - - // true to give the component a 'display: none' style. - hidden: PropTypes.bool, - - // ID of an event to highlight. If undefined, no event will be highlighted. - // typically this will be either 'eventId' or undefined. - highlightedEventId: PropTypes.string, - - // id of an event to jump to. If not given, will go to the end of the - // live timeline. - eventId: PropTypes.string, - - // where to position the event given by eventId, in pixels from the - // bottom of the viewport. If not given, will try to put the event - // half way down the viewport. - eventPixelOffset: PropTypes.number, - - // Should we show URL Previews - showUrlPreview: PropTypes.bool, - - // callback which is called when the panel is scrolled. - onScroll: PropTypes.func, - - // callback which is called when the read-up-to mark is updated. - onReadMarkerUpdated: PropTypes.func, - - // callback which is called when we wish to paginate the timeline - // window. - onPaginationRequest: PropTypes.func, - - // maximum number of events to show in a timeline - timelineCap: PropTypes.number, - - // classname to use for the messagepanel - className: PropTypes.string, - - // shape property to be passed to EventTiles - tileShape: PropTypes.string, - - // placeholder to use if the timeline is empty - empty: PropTypes.node, - - // whether to show reactions for an event - showReactions: PropTypes.bool, - - // which layout to use - layout: LayoutPropType, - } +class TimelinePanel extends React.Component { + static contextType = RoomContext; // a map from room id to read marker event timestamp - static roomReadMarkerTsMap = {}; + static roomReadMarkerTsMap: Record = {}; static defaultProps = { // By default, disable the timelineCap in favour of unpaginating based on // event tile heights. (See _unpaginateEvents) timelineCap: Number.MAX_VALUE, className: 'mx_RoomView_messagePanel', + sendReadReceiptOnLoad: true, }; - constructor(props) { - super(props); + private lastRRSentEventId: string = undefined; + private lastRMSentEventId: string = undefined; + + private readonly messagePanel = createRef(); + private readonly dispatcherRef: string; + private timelineWindow?: TimelineWindow; + private unmounted = false; + private readReceiptActivityTimer: Timer; + private readMarkerActivityTimer: Timer; + + constructor(props, context) { + super(props, context); debuglog("TimelinePanel: mounting"); - this.lastRRSentEventId = undefined; - this.lastRMSentEventId = undefined; - - this._messagePanel = createRef(); - // XXX: we could track RM per TimelineSet rather than per Room. // but for now we just do it per room for simplicity. let initialReadMarker = null; @@ -146,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 @@ -234,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 @@ -255,50 +307,28 @@ class TimelinePanel extends React.Component { console.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue"); } - if (newProps.eventId != this.props.eventId) { + const differentEventId = newProps.eventId != this.props.eventId; + const differentHighlightedEventId = newProps.highlightedEventId != this.props.highlightedEventId; + if (differentEventId || differentHighlightedEventId) { console.log("TimelinePanel switching to eventId " + newProps.eventId + " (was " + this.props.eventId + ")"); - return this._initTimeline(newProps); + return this.initTimeline(newProps); } } - shouldComponentUpdate(nextProps, nextState) { - if (objectHasDiff(this.props, nextProps)) { - if (DEBUG) { - console.group("Timeline.shouldComponentUpdate: props change"); - console.log("props before:", this.props); - console.log("props after:", nextProps); - console.groupEnd(); - } - return true; - } - - if (objectHasDiff(this.state, nextState)) { - if (DEBUG) { - console.group("Timeline.shouldComponentUpdate: state change"); - console.log("state before:", this.state); - console.log("state after:", nextState); - console.groupEnd(); - } - return true; - } - - return false; - } - componentWillUnmount() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. // // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; - if (this._readReceiptActivityTimer) { - this._readReceiptActivityTimer.abort(); - this._readReceiptActivityTimer = null; + if (this.readReceiptActivityTimer) { + this.readReceiptActivityTimer.abort(); + this.readReceiptActivityTimer = null; } - if (this._readMarkerActivityTimer) { - this._readMarkerActivityTimer.abort(); - this._readMarkerActivityTimer = null; + if (this.readMarkerActivityTimer) { + this.readMarkerActivityTimer.abort(); + this.readMarkerActivityTimer = null; } dis.unregister(this.dispatcherRef); @@ -318,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); @@ -337,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 { @@ -360,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'; @@ -372,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); } @@ -384,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, @@ -405,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; } @@ -416,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)); @@ -427,7 +466,7 @@ class TimelinePanel extends React.Component { }); }; - onMessageListScroll = e => { + private onMessageListScroll = e => { if (this.props.onScroll) { this.props.onScroll(e); } @@ -438,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; @@ -476,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; } @@ -495,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, @@ -526,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(); } @@ -542,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 @@ -563,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 @@ -574,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 @@ -583,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 @@ -608,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; @@ -623,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 @@ -673,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. // @@ -689,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) { @@ -755,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, @@ -767,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, @@ -777,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, }); @@ -785,8 +822,10 @@ class TimelinePanel extends React.Component { return; } const lastDisplayedEvent = this.state.events[lastDisplayedIndex]; - this._setReadMarker(lastDisplayedEvent.getId(), - lastDisplayedEvent.getTs()); + this.setReadMarker( + lastDisplayedEvent.getId(), + lastDisplayedEvent.getTs(), + ); // the read-marker should become invisible, so that if the user scrolls // down, they don't see it. @@ -800,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; @@ -833,61 +871,63 @@ class TimelinePanel extends React.Component { i--; const ev = events[i]; - this._setReadMarker(ev.getId(), ev.getTs()); + this.setReadMarker(ev.getId(), ev.getTs()); } /* jump down to the bottom of this room, where new events are arriving */ - jumpToLiveTimeline = () => { + public jumpToLiveTimeline = (): void => { // if we can't forward-paginate the existing timeline, then there // is no point reloading it - just jump straight to the bottom. // // Otherwise, reload the timeline rather than trying to paginate // through all of space-time. - if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - this._loadTimeline(); + if (this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + this.loadTimeline(); } else { - if (this._messagePanel.current) { - this._messagePanel.current.scrollToBottom(); - } + this.messagePanel.current?.scrollToBottom(); } }; + public scrollToEventIfNeeded = (eventId: string): void => { + this.messagePanel.current?.scrollToEventIfNeeded(eventId); + }; + /* scroll to show the read-up-to marker. We put it 1/3 of the way down * the container. */ - jumpToReadMarker = () => { + public jumpToReadMarker = (): void => { if (!this.props.manageReadMarkers) return; - if (!this._messagePanel.current) return; + if (!this.messagePanel.current) return; if (!this.state.readMarkerEventId) return; // we may not have loaded the event corresponding to the read-marker - // into the _timelineWindow. In that case, attempts to scroll to it + // into the timelineWindow. In that case, attempts to scroll to it // will fail. // // a quick way to figure out if we've loaded the relevant event is // simply to check if the messagepanel knows where the read-marker is. - const ret = this._messagePanel.current.getReadMarkerPosition(); + const ret = this.messagePanel.current.getReadMarkerPosition(); if (ret !== null) { // The messagepanel knows where the RM is, so we must have loaded // the relevant event. - this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId, - 0, 1/3); + this.messagePanel.current.scrollToEvent(this.state.readMarkerEventId, + 0, 1/3); return; } // Looks like we haven't loaded the event corresponding to the read-marker. // As with jumpToLiveTimeline, we want to reload the timeline around the // read-marker. - this._loadTimeline(this.state.readMarkerEventId, 0, 1/3); + this.loadTimeline(this.state.readMarkerEventId, 0, 1/3); }; /* update the read-up-to marker to match the read receipt */ - forgetReadMarker = () => { + public forgetReadMarker = (): void => { if (!this.props.manageReadMarkers) return; - const rmId = this._getCurrentReadReceipt(); + const rmId = this.getCurrentReadReceipt(); // see if we know the timestamp for the rr event const tl = this.props.timelineSet.getTimelineForEvent(rmId); @@ -899,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: @@ -929,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; } @@ -952,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 @@ -967,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; @@ -990,7 +1028,7 @@ class TimelinePanel extends React.Component { offsetBase = 0.5; } - return this._loadTimeline(initialEvent, pixelOffset, offsetBase); + return this.loadTimeline(initialEvent, pixelOffset, offsetBase); } /** @@ -1006,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 @@ -1043,13 +1079,15 @@ class TimelinePanel extends React.Component { return; } if (eventId) { - this._messagePanel.current.scrollToEvent(eventId, pixelOffset, - offsetBase); + this.messagePanel.current.scrollToEvent(eventId, pixelOffset, + offsetBase); } else { - this._messagePanel.current.scrollToBottom(); + this.messagePanel.current.scrollToBottom(); } - this.sendReadReceipt(); + if (this.props.sendReadReceiptOnLoad) { + this.sendReadReceipt(); + } }); }; @@ -1058,7 +1096,6 @@ class TimelinePanel extends React.Component { console.error( `Error loading timeline panel at ${eventId}: ${error}`, ); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); let onFinished; @@ -1106,10 +1143,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: [], @@ -1124,25 +1161,36 @@ class TimelinePanel extends React.Component { // handle the completion of a timeline load or localEchoUpdate, by // reloading the events from the timelinewindow and pending event list into // the state. - _reloadEvents() { + private reloadEvents(): void { // we might have switched rooms since the load started - just bin // the results if so. if (this.unmounted) return; - this.setState(this._getEvents()); + this.setState(this.getEvents()); } // get the list of events from the timeline window and the pending event list - _getEvents() { - const events = this._timelineWindow.getEvents(); - const firstVisibleEventIndex = this._checkForPreJoinUISI(events); + private getEvents(): Pick { + const events: MatrixEvent[] = this.timelineWindow.getEvents(); + + // `arrayFastClone` performs a shallow copy of the array + // we want the last event to be decrypted first but displayed last + // `reverse` is destructive and unfortunately mutates the "events" array + arrayFastClone(events) + .reverse() + .forEach(event => { + const client = MatrixClientPeg.get(); + client.decryptEventIfNeeded(event); + }); + + const firstVisibleEventIndex = this.checkForPreJoinUISI(events); // Hold onto the live events separately. The read receipt and read marker // should use this list, so that they don't advance into pending events. const liveEvents = [...events]; // if we're at the end of the live timeline, append the pending events - if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + if (!this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) { events.push(...this.props.timelineSet.getPendingEvents()); } @@ -1163,7 +1211,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 || @@ -1227,7 +1275,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; @@ -1236,15 +1284,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; @@ -1287,7 +1334,7 @@ class TimelinePanel extends React.Component { const shouldIgnore = !!ev.status || // local echo (ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message - const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev); + const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev, this.context); if (isWithoutTile || !node) { // don't start counting if the event should be ignored, @@ -1321,7 +1368,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) { @@ -1332,7 +1379,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 @@ -1357,7 +1404,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. @@ -1368,12 +1415,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 @@ -1388,7 +1436,7 @@ class TimelinePanel extends React.Component { if (this.state.timelineLoading) { return (
      - +
      ); } @@ -1409,7 +1457,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. @@ -1418,11 +1466,11 @@ class TimelinePanel extends React.Component { ['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState) ); const events = this.state.firstVisibleEventIndex - ? this.state.events.slice(this.state.firstVisibleEventIndex) - : this.state.events; + ? this.state.events.slice(this.state.firstVisibleEventIndex) + : this.state.events; return (
  • {React.createElement(component, toastProps)}
    ); + + containerClasses = classNames("mx_ToastContainer", { + "mx_ToastContainer_stacked": isStacked, + }); } - - const containerClasses = classNames("mx_ToastContainer", { - "mx_ToastContainer_stacked": isStacked, - }); - - return ( -
    - {toast} -
    - ); + return toast + ? ( +
    + {toast} +
    + ) + : null; } } diff --git a/src/components/structures/UploadBar.tsx b/src/components/structures/UploadBar.tsx index e19e312f58..c8e90a1c0a 100644 --- a/src/components/structures/UploadBar.tsx +++ b/src/components/structures/UploadBar.tsx @@ -25,7 +25,8 @@ import { Action } from "../../dispatcher/actions"; import ProgressBar from "../views/elements/ProgressBar"; import AccessibleButton from "../views/elements/AccessibleButton"; import { IUpload } from "../../models/IUpload"; -import {replaceableComponent} from "../../utils/replaceableComponent"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; interface IProps { room: Room; @@ -38,6 +39,8 @@ interface IState { @replaceableComponent("structures.UploadBar") export default class UploadBar extends React.Component { + static contextType = MatrixClientContext; + private dispatcherRef: string; private mounted: boolean; @@ -47,7 +50,7 @@ export default class UploadBar extends React.Component { // Set initial state to any available upload in this room - we might be mounting // earlier than the first progress event, so should show something relevant. const uploadsHere = this.getUploadsInRoom(); - this.state = {currentUpload: uploadsHere[0], uploadsHere}; + this.state = { currentUpload: uploadsHere[0], uploadsHere }; } componentDidMount() { @@ -74,7 +77,7 @@ export default class UploadBar extends React.Component { case Action.UploadFailed: { if (!this.mounted) return; const uploadsHere = this.getUploadsInRoom(); - this.setState({currentUpload: uploadsHere[0], uploadsHere}); + this.setState({ currentUpload: uploadsHere[0], uploadsHere }); break; } } @@ -82,7 +85,7 @@ export default class UploadBar extends React.Component { private onCancelClick = (ev) => { ev.preventDefault(); - ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise); + ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise, this.context); }; render() { diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 65861624e6..d85817486b 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -26,14 +26,14 @@ import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import { _t } from "../../languageHandler"; import { ContextMenuButton } from "./ContextMenu"; -import { USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB } from "../views/dialogs/UserSettingsDialog"; +import { UserTab } from "../views/dialogs/UserSettingsDialog"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import FeedbackDialog from "../views/dialogs/FeedbackDialog"; import Modal from "../../Modal"; import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; -import {getCustomTheme} from "../../theme"; -import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; +import { getCustomTheme } from "../../theme"; +import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import { getHomePageUrl } from "../../utils/pages"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; @@ -56,8 +56,9 @@ import HostSignupAction from "./HostSignupAction"; import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes"; import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore"; import RoomName from "../views/elements/RoomName"; -import {replaceableComponent} from "../../utils/replaceableComponent"; - +import { replaceableComponent } from "../../utils/replaceableComponent"; +import InlineSpinner from "../views/elements/InlineSpinner"; +import TooltipButton from "../views/elements/TooltipButton"; interface IProps { isMinimized: boolean; } @@ -68,6 +69,7 @@ interface IState { contextMenuPosition: PartialDOMRect; isDarkTheme: boolean; selectedSpace?: Room; + pendingRoomJoin: Set; } @replaceableComponent("structures.UserMenu") @@ -84,6 +86,7 @@ export default class UserMenu extends React.Component { this.state = { contextMenuPosition: null, isDarkTheme: this.isUserOnDarkTheme(), + pendingRoomJoin: new Set(), }; OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); @@ -103,6 +106,7 @@ export default class UserMenu extends React.Component { this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate); + MatrixClientPeg.get().on("Room", this.onRoom); } public componentWillUnmount() { @@ -114,8 +118,13 @@ export default class UserMenu extends React.Component { if (SettingsStore.getValue("feature_spaces")) { SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); } + MatrixClientPeg.get().removeListener("Room", this.onRoom); } + private onRoom = (room: Room): void => { + this.removePendingJoinRoom(room.roomId); + }; + private onTagStoreUpdate = () => { this.forceUpdate(); // we don't have anything useful in state to update }; @@ -143,24 +152,48 @@ export default class UserMenu extends React.Component { }; private onThemeChanged = () => { - this.setState({isDarkTheme: this.isUserOnDarkTheme()}); + this.setState({ isDarkTheme: this.isUserOnDarkTheme() }); }; private onAction = (ev: ActionPayload) => { - if (ev.action !== Action.ToggleUserMenu) return; // not interested - - if (this.state.contextMenuPosition) { - this.setState({contextMenuPosition: null}); - } else { - if (this.buttonRef.current) this.buttonRef.current.click(); + switch (ev.action) { + case Action.ToggleUserMenu: + if (this.state.contextMenuPosition) { + this.setState({ contextMenuPosition: null }); + } else { + if (this.buttonRef.current) this.buttonRef.current.click(); + } + break; + case Action.JoinRoom: + this.addPendingJoinRoom(ev.roomId); + break; + case Action.JoinRoomReady: + case Action.JoinRoomError: + this.removePendingJoinRoom(ev.roomId); + break; } }; + private addPendingJoinRoom(roomId: string): void { + this.setState({ + pendingRoomJoin: new Set(this.state.pendingRoomJoin) + .add(roomId), + }); + } + + private removePendingJoinRoom(roomId: string): void { + if (this.state.pendingRoomJoin.delete(roomId)) { + this.setState({ + pendingRoomJoin: new Set(this.state.pendingRoomJoin), + }); + } + } + private onOpenMenuClick = (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; - this.setState({contextMenuPosition: target.getBoundingClientRect()}); + this.setState({ contextMenuPosition: target.getBoundingClientRect() }); }; private onContextMenu = (ev: React.MouseEvent) => { @@ -177,7 +210,7 @@ export default class UserMenu extends React.Component { }; private onCloseMenu = () => { - this.setState({contextMenuPosition: null}); + this.setState({ contextMenuPosition: null }); }; private onSwitchThemeClick = (ev: React.MouseEvent) => { @@ -195,9 +228,9 @@ export default class UserMenu extends React.Component { ev.preventDefault(); ev.stopPropagation(); - const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId}; + const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId }; defaultDispatcher.dispatch(payload); - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; private onShowArchived = (ev: ButtonEvent) => { @@ -214,7 +247,7 @@ export default class UserMenu extends React.Component { ev.stopPropagation(); Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog); - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; private onSignOutClick = async (ev: ButtonEvent) => { @@ -224,30 +257,30 @@ export default class UserMenu extends React.Component { const cli = MatrixClientPeg.get(); if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) { // log out without user prompt if they have no local megolm sessions - dis.dispatch({action: 'logout'}); + dis.dispatch({ action: 'logout' }); } else { Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); } - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; private onSignInClick = () => { dis.dispatch({ action: 'start_login' }); - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; private onRegisterClick = () => { dis.dispatch({ action: 'start_registration' }); - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; private onHomeClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); - defaultDispatcher.dispatch({action: 'view_home_page'}); - this.setState({contextMenuPosition: null}); // also close the menu + defaultDispatcher.dispatch({ action: 'view_home_page' }); + this.setState({ contextMenuPosition: null }); // also close the menu }; private onCommunitySettingsClick = (ev: ButtonEvent) => { @@ -257,7 +290,7 @@ export default class UserMenu extends React.Component { Modal.createTrackedDialog('Edit Community', '', EditCommunityPrototypeDialog, { communityId: CommunityPrototypeStore.instance.getSelectedCommunityId(), }); - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; private onCommunityMembersClick = (ev: ButtonEvent) => { @@ -274,7 +307,7 @@ export default class UserMenu extends React.Component { action: 'view_room', room_id: chat.roomId, }, true); - dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList}); + dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList }); } else { // "This should never happen" clauses go here for the prototype. Modal.createTrackedDialog('Failed to find general chat', '', ErrorDialog, { @@ -282,7 +315,7 @@ export default class UserMenu extends React.Component { description: _t("Failed to find the general chat for this community"), }); } - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; private onCommunityInviteClick = (ev: ButtonEvent) => { @@ -290,7 +323,7 @@ export default class UserMenu extends React.Component { ev.stopPropagation(); showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId()); - this.setState({contextMenuPosition: null}); // also close the menu + this.setState({ contextMenuPosition: null }); // also close the menu }; private onDndToggle = (ev) => { @@ -324,7 +357,7 @@ export default class UserMenu extends React.Component { ), })}
    - ) + ); } else if (hostSignupConfig) { if (hostSignupConfig && hostSignupConfig.url) { // If hostSignup.domains is set to a non-empty array, only show @@ -333,9 +366,7 @@ export default class UserMenu extends React.Component { const mxDomain = MatrixClientPeg.get().getDomain(); const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`))); if (!hostSignupConfig.domains || validDomains.length > 0) { - topSection =
    - -
    ; + topSection = ; } } } @@ -377,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 = ( @@ -617,6 +648,14 @@ export default class UserMenu extends React.Component { /> {name} + {this.state.pendingRoomJoin.size > 0 && ( + + + + )} {dnd} {buttons}
    diff --git a/src/components/structures/UserView.js b/src/components/structures/UserView.js index 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..6382e143f9 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: (serverConfig: ValidatedServerConfig) => void; + onLoginClick?: () => void; + onComplete: () => void; +} + +interface IState { + phase: Phase; + email: string; + password: string; + password2: string; + errorText: string; + + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError: string; + + passwordFieldValid: boolean; +} @replaceableComponent("structures.auth.ForgotPassword") -export default class ForgotPassword extends React.Component { - 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 34a5410928..9f12521a34 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -14,28 +14,29 @@ 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 * as sdk from '../../../index'; -import Login, {ISSOFlow, LoginFlow} from '../../../Login'; +import { _t, _td } from '../../../languageHandler'; +import Login, { ISSOFlow, LoginFlow } from '../../../Login'; import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; -import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import classNames from "classnames"; import AuthPage from "../../views/auth/AuthPage"; import 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"; +import AuthBody from "../../views/auth/AuthBody"; +import AuthHeader from "../../views/auth/AuthHeader"; // These are used in several places, and come from the js-sdk's autodiscovery // stuff. We define them here so that they'll be picked up by i18n. @@ -59,6 +60,7 @@ interface IProps { fallbackHsUrl?: string; defaultDeviceDisplayName?: string; fragmentAfterLogin?: string; + defaultUsername?: string; // Called when the user has logged in. Params: // - The object returned by the login API @@ -119,7 +121,7 @@ export default class LoginComponent extends React.PureComponent flows: null, - username: "", + username: props.defaultUsername? props.defaultUsername: '', phoneCountry: null, phoneNumber: "", @@ -165,7 +167,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 { @@ -173,7 +175,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({ @@ -200,7 +202,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) { @@ -251,7 +253,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 }, )}
    @@ -362,7 +364,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 @@ -500,9 +502,9 @@ export default class LoginComponent extends React.PureComponent return { flows.map(flow => { const stepRenderer = this.stepRendererMap[flow.type]; - return { stepRenderer() } + return { stepRenderer() }; }) } - + ; } private renderPasswordStep = () => { @@ -540,8 +542,6 @@ export default class LoginComponent extends React.PureComponent }; render() { - const AuthHeader = sdk.getComponent("auth.AuthHeader"); - const AuthBody = sdk.getComponent("auth.AuthBody"); const loader = this.isBusy() && !this.state.busyLoggingIn ?
    : null; diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index 96fb9bdc82..8d32981e57 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -14,23 +14,28 @@ 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 { IMatrixClientCreds, 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"; +import RegistrationForm from '../../views/auth/RegistrationForm'; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import AuthBody from "../../views/auth/AuthBody"; +import AuthHeader from "../../views/auth/AuthHeader"; +import InteractiveAuth from "../InteractiveAuth"; +import Spinner from "../../views/elements/Spinner"; interface IProps { serverConfig: ValidatedServerConfig; @@ -47,13 +52,7 @@ interface IProps { // - The user's password, if available and applicable (may be cached in memory // for a short time so the user is not required to re-enter their password // for operations like uploading cross-signing keys). - onLoggedIn(params: { - userId: string; - deviceId: string - homeserverUrl: string; - identityServerUrl?: string; - accessToken: string; - }, password: string): void; + onLoggedIn(params: IMatrixClientCreds, password: string): void; makeRegistrationUrl(params: { /* eslint-disable camelcase */ client_secret: string; @@ -61,7 +60,7 @@ interface IProps { is_url?: string; session_id: string; /* eslint-enable camelcase */ - }): void; + }): string; // registration shouldn't know or care how login is done. onLoginClick(): void; onServerConfigChange(config: ValidatedServerConfig): void; @@ -131,7 +130,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 +179,7 @@ export default class Registration extends React.Component { } } - const {hsUrl, isUrl} = serverConfig; + const { hsUrl, isUrl } = serverConfig; const cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, @@ -223,13 +222,14 @@ export default class Registration extends React.Component { this.setState({ flows: e.data.flows, }); - } else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") { + } else if (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN") { + // Check for 403 or M_FORBIDDEN, Synapse used to send 403 M_UNKNOWN but now sends 403 M_FORBIDDEN. // At this point registration is pretty much disabled, but before we do that let's // quickly check to see if the server supports SSO instead. If it does, we'll send // the user off to the login page to figure their account out. 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 @@ -245,7 +245,7 @@ export default class Registration extends React.Component { } } - private onFormSubmit = formVals => { + private onFormSubmit = async (formVals): Promise => { this.setState({ errorText: "", busy: true, @@ -266,9 +266,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? @@ -431,7 +431,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(); @@ -441,10 +441,6 @@ export default class Registration extends React.Component { }; private renderRegisterComponent() { - const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); - const Spinner = sdk.getComponent('elements.Spinner'); - const RegistrationForm = sdk.getComponent('auth.RegistrationForm'); - if (this.state.matrixClient && this.state.doingUIAuth) { return { let ssoSection; if (this.state.ssoFlow) { let continueWithSection; - const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"] || []; + const providers = this.state.ssoFlow.identity_providers || []; // when there is only a single (or 0) providers we show a wide button with `Continue with X` text if (providers.length > 1) { // i18n: ssoButtons is a placeholder to help translators understand context @@ -486,7 +482,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()}

    ; } @@ -509,10 +511,6 @@ export default class Registration extends React.Component { } render() { - const AuthHeader = sdk.getComponent('auth.AuthHeader'); - const AuthBody = sdk.getComponent("auth.AuthBody"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let errorText; const err = this.state.errorText; if (err) { @@ -562,7 +560,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..c7ce74077b 100644 --- a/src/components/structures/auth/SetupEncryptionBody.js +++ b/src/components/structures/auth/SetupEncryptionBody.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,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/src/crypto/api'; +import EncryptionPanel from "../../views/right_panel/EncryptionPanel"; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import Spinner from '../../views/elements/Spinner'; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -function keyHasPassphrase(keyInfo) { - return ( +function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean { + return Boolean( keyInfo.passphrase && keyInfo.passphrase.salt && - keyInfo.passphrase.iterations + keyInfo.passphrase.iterations, ); } -@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..d232f55dd1 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -15,17 +15,22 @@ limitations under the License. */ import React from 'react'; -import {_t} from '../../../languageHandler'; -import * as sdk from '../../../index'; +import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import * as Lifecycle from '../../../Lifecycle'; import Modal from '../../../Modal'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {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"; +import ConfirmWipeDeviceDialog from '../../views/dialogs/ConfirmWipeDeviceDialog'; +import Field from '../../views/elements/Field'; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import Spinner from "../../views/elements/Spinner"; +import AuthHeader from "../../views/auth/AuthHeader"; +import AuthBody from "../../views/auth/AuthBody"; const LOGIN_VIEW = { LOADING: 1, @@ -49,7 +54,7 @@ interface IProps { fragmentAfterLogin?: string; // Called when the SSO login completes - onTokenLoginCompleted: () => void, + onTokenLoginCompleted: () => void; } interface IState { @@ -79,7 +84,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; } @@ -94,7 +99,6 @@ export default class SoftLogout extends React.Component { } onClearAll = () => { - const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog'); Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, { onFinished: (wipeData) => { if (!wipeData) return; @@ -109,7 +113,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 +129,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 +172,12 @@ export default class SoftLogout extends React.Component { Lifecycle.hydrateSession(credentials).catch((e) => { console.error(e); - this.setState({busy: false, errorText: _t("Failed to re-authenticate")}); + this.setState({ busy: false, errorText: _t("Failed to re-authenticate") }); }); }; async trySsoLogin() { - this.setState({busy: true}); + this.setState({ busy: true }); const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY); const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl(); @@ -188,7 +192,7 @@ export default class SoftLogout extends React.Component { credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams); } catch (e) { console.error(e); - this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED}); + this.setState({ busy: false, loginView: LOGIN_VIEW.UNSUPPORTED }); return; } @@ -196,13 +200,12 @@ export default class SoftLogout extends React.Component { if (this.props.onTokenLoginCompleted) this.props.onTokenLoginCompleted(); }).catch((e) => { console.error(e); - this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED}); + this.setState({ busy: false, loginView: LOGIN_VIEW.UNSUPPORTED }); }); } private renderSignInSection() { if (this.state.loginView === LOGIN_VIEW.LOADING) { - const Spinner = sdk.getComponent("elements.Spinner"); return ; } @@ -214,9 +217,6 @@ export default class SoftLogout extends React.Component { } if (this.state.loginView === LOGIN_VIEW.PASSWORD) { - const Field = sdk.getComponent("elements.Field"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let error = null; if (this.state.errorText) { error = {this.state.errorText}; @@ -286,10 +286,6 @@ export default class SoftLogout extends React.Component { } render() { - const AuthHeader = sdk.getComponent("auth.AuthHeader"); - const AuthBody = sdk.getComponent("auth.AuthBody"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - return ( 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 76% rename from src/components/views/voice_messages/PlaybackWaveform.tsx rename to src/components/views/audio_messages/PlaybackWaveform.tsx index 123af5dfa5..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 very coarse precision, otherwise 0.002 ends up highlighting a bar. - const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(1)); - this.setState({progress}); + // Track percentages to a general precision to avoid over-waking the component. + const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(3)); + this.setState({ progress }); }; public render() { 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.js b/src/components/views/auth/InteractiveAuthEntryComponents.tsx similarity index 70% rename from src/components/views/auth/InteractiveAuthEntryComponents.js rename to src/components/views/auth/InteractiveAuthEntryComponents.tsx index 6cbecd22ee..4b1ecec740 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2016-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,17 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; +import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react'; +import classNames from 'classnames'; +import { MatrixClient } from "matrix-js-sdk/src/client"; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; import Spinner from "../elements/Spinner"; import CountlyAnalytics from "../../../CountlyAnalytics"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { LocalisedPolicy, Policies } from '../../../Terms'; +import Field from '../elements/Field'; +import CaptchaForm from "./CaptchaForm"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -74,36 +74,72 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; * focus: set the input focus appropriately in the form. */ +enum AuthType { + Password = "m.login.password", + Recaptcha = "m.login.recaptcha", + Terms = "m.login.terms", + Email = "m.login.email.identity", + Msisdn = "m.login.msisdn", + Sso = "m.login.sso", + SsoUnstable = "org.matrix.login.sso", +} + +/* eslint-disable camelcase */ +interface IAuthDict { + type?: AuthType; + // TODO: Remove `user` once servers support proper UIA + // See https://github.com/vector-im/element-web/issues/10312 + user?: string; + identifier?: any; + password?: string; + response?: string; + // TODO: Remove `threepid_creds` once servers support proper UIA + // See https://github.com/vector-im/element-web/issues/10312 + // See https://github.com/matrix-org/matrix-doc/issues/2220 + threepid_creds?: any; + threepidCreds?: any; +} +/* eslint-enable camelcase */ + export const DEFAULT_PHASE = 0; -@replaceableComponent("views.auth.PasswordAuthEntry") -export class PasswordAuthEntry extends React.Component { - static LOGIN_TYPE = "m.login.password"; +interface IAuthEntryProps { + matrixClient: MatrixClient; + loginType: string; + authSessionId: string; + errorText?: string; + // Is the auth logic currently waiting for something to happen? + busy?: boolean; + onPhaseChange: (phase: number) => void; + submitAuthDict: (auth: IAuthDict) => void; +} - static propTypes = { - matrixClient: PropTypes.object.isRequired, - submitAuthDict: PropTypes.func.isRequired, - errorText: PropTypes.string, - // is the auth logic currently waiting for something to - // happen? - busy: PropTypes.bool, - onPhaseChange: PropTypes.func.isRequired, - }; +interface IPasswordAuthEntryState { + password: string; +} + +@replaceableComponent("views.auth.PasswordAuthEntry") +export class PasswordAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Password; + + constructor(props) { + super(props); + + this.state = { + password: "", + }; + } componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); } - state = { - password: "", - }; - - _onSubmit = e => { + private onSubmit = (e: FormEvent) => { e.preventDefault(); if (this.props.busy) return; this.props.submitAuthDict({ - type: PasswordAuthEntry.LOGIN_TYPE, + type: AuthType.Password, // TODO: Remove `user` once servers support proper UIA // See https://github.com/vector-im/element-web/issues/10312 user: this.props.matrixClient.credentials.userId, @@ -115,7 +151,7 @@ export class PasswordAuthEntry extends React.Component { }); }; - _onPasswordFieldChange = ev => { + private onPasswordFieldChange = (ev: ChangeEvent) => { // enable the submit button iff the password is non-empty this.setState({ password: ev.target.value, @@ -123,14 +159,13 @@ export class PasswordAuthEntry extends React.Component { }; render() { - const passwordBoxClass = classnames({ + const passwordBoxClass = classNames({ "error": this.props.errorText, }); let submitButtonOrSpinner; if (this.props.busy) { - const Loader = sdk.getComponent("elements.Spinner"); - submitButtonOrSpinner = ; + submitButtonOrSpinner = ; } else { submitButtonOrSpinner = (

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

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

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

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

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

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

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

    -
    +
    @@ -584,40 +629,40 @@ export class MsisdnAuthEntry extends React.Component { } } -@replaceableComponent("views.auth.SSOAuthEntry") -export class SSOAuthEntry extends React.Component { - static propTypes = { - matrixClient: PropTypes.object.isRequired, - authSessionId: PropTypes.string.isRequired, - loginType: PropTypes.string.isRequired, - submitAuthDict: PropTypes.func.isRequired, - errorText: PropTypes.string, - onPhaseChange: PropTypes.func.isRequired, - continueText: PropTypes.string, - continueKind: PropTypes.string, - onCancel: PropTypes.func, - }; +interface ISSOAuthEntryProps extends IAuthEntryProps { + continueText?: string; + continueKind?: string; + onCancel?: () => void; +} - static LOGIN_TYPE = "m.login.sso"; - static UNSTABLE_LOGIN_TYPE = "org.matrix.login.sso"; +interface ISSOAuthEntryState { + phase: number; + attemptFailed: boolean; +} + +@replaceableComponent("views.auth.SSOAuthEntry") +export class SSOAuthEntry extends React.Component { + static LOGIN_TYPE = AuthType.Sso; + static UNSTABLE_LOGIN_TYPE = AuthType.SsoUnstable; static PHASE_PREAUTH = 1; // button to start SSO static PHASE_POSTAUTH = 2; // button to confirm SSO completed - _ssoUrl: string; + private ssoUrl: string; + private popupWindow: Window; constructor(props) { super(props); // We actually send the user through fallback auth so we don't have to // deal with a redirect back to us, losing application context. - this._ssoUrl = props.matrixClient.getFallbackAuthUrl( + this.ssoUrl = props.matrixClient.getFallbackAuthUrl( this.props.loginType, this.props.authSessionId, ); - this._popupWindow = null; - window.addEventListener("message", this._onReceiveMessage); + this.popupWindow = null; + window.addEventListener("message", this.onReceiveMessage); this.state = { phase: SSOAuthEntry.PHASE_PREAUTH, @@ -625,44 +670,44 @@ export class SSOAuthEntry extends React.Component { }; } - componentDidMount(): void { + componentDidMount() { this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH); } componentWillUnmount() { - window.removeEventListener("message", this._onReceiveMessage); - if (this._popupWindow) { - this._popupWindow.close(); - this._popupWindow = null; + window.removeEventListener("message", this.onReceiveMessage); + if (this.popupWindow) { + this.popupWindow.close(); + this.popupWindow = null; } } - attemptFailed = () => { + public attemptFailed = () => { this.setState({ attemptFailed: true, }); }; - _onReceiveMessage = event => { + private onReceiveMessage = (event: MessageEvent) => { if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) { - if (this._popupWindow) { - this._popupWindow.close(); - this._popupWindow = null; + if (this.popupWindow) { + this.popupWindow.close(); + this.popupWindow = null; } } }; - onStartAuthClick = () => { + private onStartAuthClick = () => { // Note: We don't use PlatformPeg's startSsoAuth functions because we almost // certainly will need to open the thing in a new tab to avoid losing application // context. - this._popupWindow = window.open(this._ssoUrl, "_blank"); - this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH}); + this.popupWindow = window.open(this.ssoUrl, "_blank"); + this.setState({ phase: SSOAuthEntry.PHASE_POSTAUTH }); this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH); }; - onConfirmClick = () => { + private onConfirmClick = () => { this.props.submitAuthDict({}); }; @@ -716,46 +761,37 @@ export class SSOAuthEntry extends React.Component { } @replaceableComponent("views.auth.FallbackAuthEntry") -export class FallbackAuthEntry extends React.Component { - static propTypes = { - matrixClient: PropTypes.object.isRequired, - authSessionId: PropTypes.string.isRequired, - loginType: PropTypes.string.isRequired, - submitAuthDict: PropTypes.func.isRequired, - errorText: PropTypes.string, - onPhaseChange: PropTypes.func.isRequired, - }; +export class FallbackAuthEntry extends React.Component { + private popupWindow: Window; + private fallbackButton = createRef(); constructor(props) { super(props); // we have to make the user click a button, as browsers will block // the popup if we open it immediately. - this._popupWindow = null; - window.addEventListener("message", this._onReceiveMessage); - - this._fallbackButton = createRef(); + this.popupWindow = null; + window.addEventListener("message", this.onReceiveMessage); } - componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); } componentWillUnmount() { - window.removeEventListener("message", this._onReceiveMessage); - if (this._popupWindow) { - this._popupWindow.close(); + window.removeEventListener("message", this.onReceiveMessage); + if (this.popupWindow) { + this.popupWindow.close(); } } - focus = () => { - if (this._fallbackButton.current) { - this._fallbackButton.current.focus(); + public focus = () => { + if (this.fallbackButton.current) { + this.fallbackButton.current.focus(); } }; - _onShowFallbackClick = e => { + private onShowFallbackClick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -763,10 +799,10 @@ export class FallbackAuthEntry extends React.Component { this.props.loginType, this.props.authSessionId, ); - this._popupWindow = window.open(url, "_blank"); + this.popupWindow = window.open(url, "_blank"); }; - _onReceiveMessage = event => { + private onReceiveMessage = (event: MessageEvent) => { if ( event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl() @@ -786,27 +822,31 @@ export class FallbackAuthEntry extends React.Component { } return ( ); } } -const AuthEntryComponents = [ - PasswordAuthEntry, - RecaptchaAuthEntry, - EmailIdentityAuthEntry, - MsisdnAuthEntry, - TermsAuthEntry, - SSOAuthEntry, -]; - -export default function getEntryComponentForLoginType(loginType) { - for (const c of AuthEntryComponents) { - if (c.LOGIN_TYPE === loginType || c.UNSTABLE_LOGIN_TYPE === loginType) { - return c; - } +export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component { + switch (loginType) { + case AuthType.Password: + return PasswordAuthEntry; + case AuthType.Recaptcha: + return RecaptchaAuthEntry; + case AuthType.Email: + return EmailIdentityAuthEntry; + case AuthType.Msisdn: + return MsisdnAuthEntry; + case AuthType.Terms: + return TermsAuthEntry; + case AuthType.Sso: + case AuthType.SsoUnstable: + return SSOAuthEntry; + default: + return FallbackAuthEntry; } - return FallbackAuthEntry; } diff --git a/src/components/views/auth/LanguageSelector.js b/src/components/views/auth/LanguageSelector.js index 0738ee43e4..88293310e7 100644 --- a/src/components/views/auth/LanguageSelector.js +++ b/src/components/views/auth/LanguageSelector.js @@ -15,12 +15,12 @@ limitations under the License. */ import SdkConfig from "../../../SdkConfig"; -import {getCurrentLanguage} from "../../../languageHandler"; +import { getCurrentLanguage } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import PlatformPeg from "../../../PlatformPeg"; import * as sdk from '../../../index'; import React from 'react'; -import {SettingLevel} from "../../../settings/SettingLevel"; +import { SettingLevel } from "../../../settings/SettingLevel"; function onChange(newLang) { if (getCurrentLanguage() !== newLang) { @@ -29,7 +29,7 @@ function onChange(newLang) { } } -export default function LanguageSelector({disabled}) { +export default function LanguageSelector({ disabled }) { if (SdkConfig.get()['disable_login_language_selector']) return
    ; const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown'); diff --git a/src/components/views/auth/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..25ea347d24 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from 'react'; -import * as sdk from '../../../index'; import * as Email from '../../../email'; import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; import Modal from '../../../Modal'; @@ -25,12 +24,13 @@ 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"; +import CountryDropdown from "./CountryDropdown"; enum RegistrationField { Email = "field_email", @@ -471,7 +471,6 @@ export default class RegistrationForm extends React.PureComponent { +const calculateUrls = (url, urls, lowBandwidth) => { // work out the full set of urls to try to load. This is formed like so: // imageUrls: [ props.url, ...props.urls ] let _urls = []; - if (!SettingsStore.getValue("lowBandwidth")) { + if (!lowBandwidth) { _urls = urls || []; if (url) { @@ -62,8 +64,14 @@ const calculateUrls = (url, urls) => { return Array.from(new Set(_urls)); }; -const useImageUrl = ({url, urls}): [string, () => void] => { - const [imageUrls, setUrls] = useState(calculateUrls(url, urls)); +const useImageUrl = ({ url, urls }): [string, () => void] => { + // Since this is a hot code path and the settings store can be slow, we + // use the cached lowBandwidth value from the room context if it exists + const roomContext = useContext(RoomContext); + const lowBandwidth = roomContext ? + roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth"); + + const [imageUrls, setUrls] = useState(calculateUrls(url, urls, lowBandwidth)); const [urlsIndex, setIndex] = useState(0); const onError = useCallback(() => { @@ -71,7 +79,7 @@ const useImageUrl = ({url, urls}): [string, () => void] => { }, []); useEffect(() => { - setUrls(calculateUrls(url, urls)); + setUrls(calculateUrls(url, urls, lowBandwidth)); setIndex(0); }, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps @@ -107,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); @@ -179,7 +187,7 @@ const BaseAvatar = (props: IProps) => { width: toPx(width), height: toPx(height), }} - title={title} alt="" + title={title} alt={_t("Avatar")} inputRef={inputRef} {...otherProps} /> ); diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index e95022687a..5e6bf45f07 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -20,25 +20,24 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { User } from "matrix-js-sdk/src/models/user"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { TagID } from '../../../stores/room-list/models'; import RoomAvatar from "./RoomAvatar"; import NotificationBadge from '../rooms/NotificationBadge'; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { NotificationState } from "../../../stores/notifications/NotificationState"; -import {isPresenceEnabled} from "../../../utils/presence"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {_t} from "../../../languageHandler"; +import { isPresenceEnabled } from "../../../utils/presence"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { _t } from "../../../languageHandler"; import TextWithTooltip from "../elements/TextWithTooltip"; import DMRoomMap from "../../../utils/DMRoomMap"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { IOOBData } from "../../../stores/ThreepidInviteStore"; interface IProps { room: Room; avatarSize: number; - tag: TagID; displayBadge?: boolean; forceCount?: boolean; - oobData?: object; + oobData?: IOOBData; viewAvatarOnClick?: boolean; } @@ -121,7 +120,10 @@ 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 new file mode 100644 index 0000000000..3127e1a915 --- /dev/null +++ b/src/components/views/beta/BetaCard.tsx @@ -0,0 +1,116 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import classNames from "classnames"; + +import { _t } from "../../../languageHandler"; +import AccessibleButton from "../elements/AccessibleButton"; +import SettingsStore from "../../../settings/SettingsStore"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import TextWithTooltip from "../elements/TextWithTooltip"; +import Modal from "../../../Modal"; +import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog"; +import SdkConfig from "../../../SdkConfig"; +import SettingsFlag from "../elements/SettingsFlag"; + +interface IProps { + title?: string; + featureId: string; +} + +export const BetaPill = ({ onClick }: { onClick?: () => void }) => { + if (onClick) { + return +
    + { _t("Spaces is a beta feature") } +
    +
    + { _t("Tap for more info") } +
    +
    } + onClick={onClick} + tooltipProps={{ yOffset: -10 }} + > + { _t("Beta") } + ; + } + + return + { _t("Beta") } + ; +}; + +const BetaCard = ({ title: titleOverride, featureId }: IProps) => { + const info = SettingsStore.getBetaInfo(featureId); + if (!info) return null; // Beta is invalid/disabled + + const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading, extraSettings } = info; + const value = SettingsStore.getValue(featureId); + + let feedbackButton; + if (value && feedbackLabel && feedbackSubheading && SdkConfig.get().bug_report_endpoint_url) { + feedbackButton = { + Modal.createTrackedDialog("Beta Feedback", featureId, BetaFeedbackDialog, { featureId }); + }} + kind="primary" + > + { _t("Feedback") } + ; + } + + return
    +
    +
    +

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

    + { _t(caption) } +
    + { feedbackButton } + SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)} + kind={feedbackButton ? "primary_outline" : "primary"} + > + { value ? _t("Leave the beta") : _t("Join the beta") } + +
    + { disclaimer &&
    + { disclaimer(value) } +
    } +
    + +
    + { extraSettings &&
    + { extraSettings.map(key => ( + + )) } +
    } +
    ; +}; + +export default BetaCard; 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 deleted file mode 100644 index 142b8c80a8..0000000000 --- a/src/components/views/context_menus/MessageContextMenu.js +++ /dev/null @@ -1,389 +0,0 @@ -/* -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2015, 2016, 2018, 2019, 2021 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import {EventStatus} from 'matrix-js-sdk/src/models/event'; - -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import dis from '../../../dispatcher/dispatcher'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import Modal from '../../../Modal'; -import Resend from '../../../Resend'; -import SettingsStore from '../../../settings/SettingsStore'; -import { isUrlPermitted } from '../../../HtmlUtils'; -import { isContentActionable } from '../../../utils/EventUtils'; -import {MenuItem} from "../../structures/ContextMenu"; -import {EventType} from "matrix-js-sdk/src/@types/event"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; - -export function canCancel(eventStatus) { - return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; -} - -@replaceableComponent("views.context_menus.MessageContextMenu") -export default class MessageContextMenu extends React.Component { - static propTypes = { - /* the MatrixEvent associated with the context menu */ - mxEvent: PropTypes.object.isRequired, - - /* an optional EventTileOps implementation that can be used to unhide preview widgets */ - eventTileOps: PropTypes.object, - - /* an optional function to be called when the user clicks collapse thread, if not provided hide button */ - collapseReplyThread: PropTypes.func, - - /* callback called when the menu is dismissed */ - onFinished: PropTypes.func, - - /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */ - onCloseDialog: PropTypes.func, - }; - - state = { - canRedact: false, - canPin: false, - }; - - componentDidMount() { - MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions); - this._checkPermissions(); - } - - componentWillUnmount() { - const cli = MatrixClientPeg.get(); - if (cli) { - cli.removeListener('RoomMember.powerLevel', this._checkPermissions); - } - } - - _checkPermissions = () => { - const cli = MatrixClientPeg.get(); - const room = cli.getRoom(this.props.mxEvent.getRoomId()); - - // We explicitly decline to show the redact option on ACL events as it has a potential - // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 - const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) - && this.props.mxEvent.getType() !== EventType.RoomServerAcl; - let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli); - - // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality - if (!SettingsStore.getValue("feature_pinning")) canPin = false; - - this.setState({canRedact, canPin}); - }; - - _isPinned() { - const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); - const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', ''); - if (!pinnedEvent) return false; - const content = pinnedEvent.getContent(); - return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); - } - - onResendReactionsClick = () => { - for (const reaction of this._getUnsentReactions()) { - Resend.resend(reaction); - } - this.closeMenu(); - }; - - onReportEventClick = () => { - const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog"); - Modal.createTrackedDialog('Report Event', '', ReportEventDialog, { - mxEvent: this.props.mxEvent, - }, 'mx_Dialog_reportEvent'); - this.closeMenu(); - }; - - onViewSourceClick = () => { - const ViewSource = sdk.getComponent('structures.ViewSource'); - Modal.createTrackedDialog('View Event Source', '', ViewSource, { - mxEvent: this.props.mxEvent, - }, 'mx_Dialog_viewsource'); - this.closeMenu(); - }; - - onRedactClick = () => { - const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); - Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { - onFinished: async (proceed, reason) => { - if (!proceed) return; - - const cli = MatrixClientPeg.get(); - try { - if (this.props.onCloseDialog) this.props.onCloseDialog(); - await cli.redactEvent( - this.props.mxEvent.getRoomId(), - this.props.mxEvent.getId(), - undefined, - reason ? { reason } : {}, - ); - } catch (e) { - const code = e.errcode || e.statusCode; - // only show the dialog if failing for something other than a network error - // (e.g. no errcode or statusCode) as in that case the redactions end up in the - // detached queue and we show the room status bar to allow retry - if (typeof code !== "undefined") { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - // display error message stating you couldn't delete this. - Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { - title: _t('Error'), - description: _t('You cannot delete this message. (%(code)s)', {code}), - }); - } - } - }, - }, 'mx_Dialog_confirmredact'); - this.closeMenu(); - }; - - onForwardClick = () => { - if (this.props.onCloseDialog) this.props.onCloseDialog(); - dis.dispatch({ - action: 'forward_event', - event: this.props.mxEvent, - }); - this.closeMenu(); - }; - - onPinClick = () => { - MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '') - .catch((e) => { - // Intercept the Event Not Found error and fall through the promise chain with no event. - if (e.errcode === "M_NOT_FOUND") return null; - throw e; - }) - .then((event) => { - const eventIds = (event ? event.pinned : []) || []; - if (!eventIds.includes(this.props.mxEvent.getId())) { - // Not pinned - add - eventIds.push(this.props.mxEvent.getId()); - } else { - // Pinned - remove - eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1); - } - - const cli = MatrixClientPeg.get(); - cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, ''); - }); - this.closeMenu(); - }; - - closeMenu = () => { - if (this.props.onFinished) this.props.onFinished(); - }; - - onUnhidePreviewClick = () => { - if (this.props.eventTileOps) { - this.props.eventTileOps.unhideWidget(); - } - this.closeMenu(); - }; - - onQuoteClick = () => { - dis.dispatch({ - action: 'quote', - event: this.props.mxEvent, - }); - this.closeMenu(); - }; - - onPermalinkClick = (e: Event) => { - e.preventDefault(); - const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); - Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { - target: this.props.mxEvent, - permalinkCreator: this.props.permalinkCreator, - }); - this.closeMenu(); - }; - - onCollapseReplyThreadClick = () => { - this.props.collapseReplyThread(); - this.closeMenu(); - }; - - _getReactions(filter) { - const cli = MatrixClientPeg.get(); - const room = cli.getRoom(this.props.mxEvent.getRoomId()); - const eventId = this.props.mxEvent.getId(); - return room.getPendingEvents().filter(e => { - const relation = e.getRelation(); - return relation && - relation.rel_type === "m.annotation" && - relation.event_id === eventId && - filter(e); - }); - } - - _getPendingReactions() { - return this._getReactions(e => canCancel(e.status)); - } - - _getUnsentReactions() { - return this._getReactions(e => e.status === EventStatus.NOT_SENT); - } - - render() { - const cli = MatrixClientPeg.get(); - const me = cli.getUserId(); - const mxEvent = this.props.mxEvent; - const eventStatus = mxEvent.status; - const unsentReactionsCount = this._getUnsentReactions().length; - let resendReactionsButton; - let redactButton; - let forwardButton; - let pinButton; - let unhidePreviewButton; - let externalURLButton; - let quoteButton; - let collapseReplyThread; - - // status is SENT before remote-echo, null after - const isSent = !eventStatus || eventStatus === EventStatus.SENT; - if (!mxEvent.isRedacted()) { - if (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') } - - ); - } - } - - let permalink; - if (this.props.permalinkCreator) { - permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); - } - // XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID) - const permalinkButton = ( - - { mxEvent.isRedacted() || mxEvent.getType() !== 'm.room.message' - ? _t('Share Permalink') : _t('Share Message') } - - ); - - if (this.props.eventTileOps) { // this event is rendered using TextualBody - quoteButton = ( - - { _t('Quote') } - - ); - } - - // Bridges can provide a 'external_url' to link back to the source. - if ( - typeof(mxEvent.event.content.external_url) === "string" && - isUrlPermitted(mxEvent.event.content.external_url) - ) { - externalURLButton = ( - - { _t('Source URL') } - - ); - } - - if (this.props.collapseReplyThread) { - collapseReplyThread = ( - - { _t('Collapse Reply Thread') } - - ); - } - - let reportEventButton; - if (mxEvent.getSender() !== me) { - reportEventButton = ( - - { _t('Report Content') } - - ); - } - - return ( -
    - { resendReactionsButton } - { redactButton } - { forwardButton } - { pinButton } - { viewSourceButton } - { unhidePreviewButton } - { permalinkButton } - { quoteButton } - { externalURLButton } - { collapseReplyThread } - { reportEventButton } -
    - ); - } -} diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx new file mode 100644 index 0000000000..999e98f4ad --- /dev/null +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -0,0 +1,436 @@ +/* +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2015 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; + +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import dis from '../../../dispatcher/dispatcher'; +import { _t } from '../../../languageHandler'; +import Modal from '../../../Modal'; +import Resend from '../../../Resend'; +import SettingsStore from '../../../settings/SettingsStore'; +import { isUrlPermitted } from '../../../HtmlUtils'; +import { isContentActionable } from '../../../utils/EventUtils'; +import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard"; +import ForwardDialog from "../dialogs/ForwardDialog"; +import { Action } from "../../../dispatcher/actions"; +import ReportEventDialog from '../dialogs/ReportEventDialog'; +import ViewSource from '../../structures/ViewSource'; +import ConfirmRedactDialog from '../dialogs/ConfirmRedactDialog'; +import ErrorDialog from '../dialogs/ErrorDialog'; +import ShareDialog from '../dialogs/ShareDialog'; +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; + +export function canCancel(eventStatus: EventStatus): boolean { + return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; +} + +interface IEventTileOps { + isWidgetHidden(): boolean; + unhideWidget(): void; +} + +interface IProps { + /* the MatrixEvent associated with the context menu */ + mxEvent: MatrixEvent; + /* an optional EventTileOps implementation that can be used to unhide preview widgets */ + eventTileOps?: IEventTileOps; + permalinkCreator?: RoomPermalinkCreator; + /* an optional function to be called when the user clicks collapse thread, if not provided hide button */ + collapseReplyThread?(): void; + /* callback called when the menu is dismissed */ + onFinished(): void; + /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */ + onCloseDialog?(): void; +} + +interface IState { + canRedact: boolean; + canPin: boolean; +} + +@replaceableComponent("views.context_menus.MessageContextMenu") +export default class MessageContextMenu extends React.Component { + state = { + canRedact: false, + canPin: false, + }; + + componentDidMount() { + MatrixClientPeg.get().on('RoomMember.powerLevel', this.checkPermissions); + this.checkPermissions(); + } + + componentWillUnmount() { + const cli = MatrixClientPeg.get(); + if (cli) { + cli.removeListener('RoomMember.powerLevel', this.checkPermissions); + } + } + + private checkPermissions = (): void => { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.mxEvent.getRoomId()); + + // We explicitly decline to show the redact option on ACL events as it has a potential + // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 + // Similarly for encryption events, since redacting them "breaks everything" + const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) + && this.props.mxEvent.getType() !== EventType.RoomServerAcl + && this.props.mxEvent.getType() !== EventType.RoomEncryption; + let canPin = room.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli); + + // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality + if (!SettingsStore.getValue("feature_pinning")) canPin = false; + + this.setState({ canRedact, canPin }); + }; + + private isPinned(): boolean { + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, ''); + if (!pinnedEvent) return false; + const content = pinnedEvent.getContent(); + return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); + } + + private onResendReactionsClick = (): void => { + for (const reaction of this.getUnsentReactions()) { + Resend.resend(reaction); + } + this.closeMenu(); + }; + + private onReportEventClick = (): void => { + Modal.createTrackedDialog('Report Event', '', ReportEventDialog, { + mxEvent: this.props.mxEvent, + }, 'mx_Dialog_reportEvent'); + this.closeMenu(); + }; + + private onViewSourceClick = (): void => { + Modal.createTrackedDialog('View Event Source', '', ViewSource, { + mxEvent: this.props.mxEvent, + }, 'mx_Dialog_viewsource'); + this.closeMenu(); + }; + + private onRedactClick = (): void => { + Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { + onFinished: async (proceed: boolean, reason?: string) => { + if (!proceed) return; + + const cli = MatrixClientPeg.get(); + try { + this.props.onCloseDialog?.(); + await cli.redactEvent( + this.props.mxEvent.getRoomId(), + this.props.mxEvent.getId(), + undefined, + reason ? { reason } : {}, + ); + } catch (e) { + const code = e.errcode || e.statusCode; + // only show the dialog if failing for something other than a network error + // (e.g. no errcode or statusCode) as in that case the redactions end up in the + // detached queue and we show the room status bar to allow retry + if (typeof code !== "undefined") { + // display error message stating you couldn't delete this. + Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { + title: _t('Error'), + description: _t('You cannot delete this message. (%(code)s)', { code }), + }); + } + } + }, + }, 'mx_Dialog_confirmredact'); + this.closeMenu(); + }; + + private onForwardClick = (): void => { + Modal.createTrackedDialog('Forward Message', '', ForwardDialog, { + matrixClient: MatrixClientPeg.get(), + event: this.props.mxEvent, + permalinkCreator: this.props.permalinkCreator, + }); + this.closeMenu(); + }; + + private onPinClick = (): void => { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.mxEvent.getRoomId()); + const eventId = this.props.mxEvent.getId(); + + const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || []; + if (pinnedIds.includes(eventId)) { + pinnedIds.splice(pinnedIds.indexOf(eventId), 1); + } else { + pinnedIds.push(eventId); + cli.setRoomAccountData(room.roomId, ReadPinsEventId, { + event_ids: [ + ...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), + eventId, + ], + }); + } + cli.sendStateEvent(this.props.mxEvent.getRoomId(), EventType.RoomPinnedEvents, { pinned: pinnedIds }, ""); + this.closeMenu(); + }; + + private closeMenu = (): void => { + this.props.onFinished(); + }; + + private onUnhidePreviewClick = (): void => { + this.props.eventTileOps?.unhideWidget(); + this.closeMenu(); + }; + + private onQuoteClick = (): void => { + dis.dispatch({ + action: Action.ComposerInsert, + event: this.props.mxEvent, + }); + this.closeMenu(); + }; + + private onPermalinkClick = (e: React.MouseEvent): void => { + e.preventDefault(); + Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { + target: this.props.mxEvent, + permalinkCreator: this.props.permalinkCreator, + }); + this.closeMenu(); + }; + + private onCollapseReplyThreadClick = (): void => { + this.props.collapseReplyThread(); + this.closeMenu(); + }; + + private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.mxEvent.getRoomId()); + const eventId = this.props.mxEvent.getId(); + return room.getPendingEvents().filter(e => { + const relation = e.getRelation(); + return relation?.rel_type === RelationType.Annotation && relation.event_id === eventId && filter(e); + }); + } + + private getPendingReactions(): MatrixEvent[] { + return this.getReactions(e => canCancel(e.status)); + } + + private getUnsentReactions(): MatrixEvent[] { + return this.getReactions(e => e.status === EventStatus.NOT_SENT); + } + + render() { + const cli = MatrixClientPeg.get(); + const me = cli.getUserId(); + const mxEvent = this.props.mxEvent; + const eventStatus = mxEvent.status; + const unsentReactionsCount = this.getUnsentReactions().length; + + let resendReactionsButton: JSX.Element; + let redactButton: JSX.Element; + let forwardButton: JSX.Element; + let pinButton: JSX.Element; + let unhidePreviewButton: JSX.Element; + let externalURLButton: JSX.Element; + let quoteButton: JSX.Element; + let collapseReplyThread: JSX.Element; + let redactItemList: JSX.Element; + + // status is SENT before remote-echo, null after + const isSent = !eventStatus || eventStatus === EventStatus.SENT; + if (!mxEvent.isRedacted()) { + if (unsentReactionsCount !== 0) { + resendReactionsButton = ( + + ); + } + } + + if (isSent && this.state.canRedact) { + redactButton = ( + + ); + } + + if (isContentActionable(mxEvent)) { + forwardButton = ( + + ); + + if (this.state.canPin) { + pinButton = ( + + ); + } + } + + const viewSourceButton = ( + + ); + + if (this.props.eventTileOps) { + if (this.props.eventTileOps.isWidgetHidden()) { + unhidePreviewButton = ( + + ); + } + } + + let permalink; + if (this.props.permalinkCreator) { + permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + } + const permalinkButton = ( + + ); + + if (this.props.eventTileOps) { // this event is rendered using TextualBody + quoteButton = ( + + ); + } + + // Bridges can provide a 'external_url' to link back to the source. + if (typeof (mxEvent.getContent().external_url) === "string" && + isUrlPermitted(mxEvent.getContent().external_url) + ) { + externalURLButton = ( + + ); + } + + if (this.props.collapseReplyThread) { + collapseReplyThread = ( + + ); + } + + let reportEventButton: JSX.Element; + if (mxEvent.getSender() !== me) { + reportEventButton = ( + + ); + } + + const commonItemsList = ( + + { quoteButton } + { forwardButton } + { pinButton } + { permalinkButton } + { reportEventButton } + { externalURLButton } + { unhidePreviewButton } + { viewSourceButton } + { resendReactionsButton } + { collapseReplyThread } + + ); + + if (redactButton) { + redactItemList = ( + + { redactButton } + + ); + } + + return ( + + { commonItemsList } + { redactItemList } + + ); + } +} diff --git a/src/components/views/context_menus/StatusMessageContextMenu.js b/src/components/views/context_menus/StatusMessageContextMenu.js index 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 623fe04f2f..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"; @@ -40,6 +40,8 @@ interface IProps extends React.ComponentProps { showUnpin?: boolean; // override delete handler onDeleteClick?(): void; + // override edit handler + onEditClick?(): void; } const WidgetContextMenu: React.FC = ({ @@ -47,11 +49,12 @@ const WidgetContextMenu: React.FC = ({ app, userWidget, onDeleteClick, + onEditClick, showUnpin, ...props }) => { const cli = useContext(MatrixClientContext); - const {room, roomId} = useContext(RoomContext); + const { room, roomId } = useContext(RoomContext); const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id); const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId); @@ -89,12 +92,16 @@ const WidgetContextMenu: React.FC = ({ let editButton; if (canModify && WidgetUtils.isManagedByManager(app)) { - const onEditClick = () => { - WidgetUtils.editWidget(room, app); + const _onEditClick = () => { + if (onEditClick) { + onEditClick(); + } else { + WidgetUtils.editWidget(room, app); + } onFinished(); }; - editButton = ; + editButton = ; } let snapshotButton; @@ -116,24 +123,29 @@ const WidgetContextMenu: React.FC = ({ let deleteButton; if (onDeleteClick || canModify) { - const onDeleteClickDefault = () => { - // Show delete confirmation dialog - Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { - title: _t("Delete Widget"), - description: _t( - "Deleting a widget removes it for all users in this room." + - " Are you sure you want to delete this widget?"), - button: _t("Delete widget"), - onFinished: (confirmed) => { - if (!confirmed) return; - WidgetUtils.setRoomWidget(roomId, app.id); - }, - }); + const _onDeleteClick = () => { + if (onDeleteClick) { + onDeleteClick(); + } else { + // Show delete confirmation dialog + Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { + title: _t("Delete Widget"), + description: _t( + "Deleting a widget removes it for all users in this room." + + " Are you sure you want to delete this widget?"), + button: _t("Delete widget"), + onFinished: (confirmed) => { + if (!confirmed) return; + WidgetUtils.setRoomWidget(roomId, app.id); + }, + }); + } + onFinished(); }; deleteButton = ; } diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 1f45a76008..5024b98def 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -14,26 +14,34 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useContext, 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 {allSettled} 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 ProgressBar from "../elements/ProgressBar"; +import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import QueryMatcher from "../../../autocomplete/QueryMatcher"; +import TruncatedList from "../elements/TruncatedList"; +import EntityTile from "../rooms/EntityTile"; +import BaseAvatar from "../avatars/BaseAvatar"; interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -43,46 +51,172 @@ interface IProps extends IDialogProps { const Entry = ({ room, checked, onChange }) => { return ; }; interface IAddExistingToSpaceProps { space: Room; - selected: Set; - onChange(checked: boolean, room: Room): void; + footerPrompt?: ReactNode; + emptySelectionButton?: ReactNode; + onFinished(added: boolean): void; } -export const AddExistingToSpace: React.FC = ({ space, selected, onChange }) => { +export const AddExistingToSpace: React.FC = ({ + space, + footerPrompt, + emptySelectionButton, + onFinished, +}) => { const cli = useContext(MatrixClientContext); + const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]); + + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); const [query, setQuery] = useState(""); - const lcQuery = query.toLowerCase(); + const lcQuery = query.toLowerCase().trim(); - const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); - const existingSubspacesSet = new Set(existingSubspaces); - const existingRoomsSet = new Set(SpaceStore.instance.getChildRooms(space.roomId)); + const existingSubspacesSet = useMemo(() => new Set(SpaceStore.instance.getChildSpaces(space.roomId)), [space]); + const existingRoomsSet = useMemo(() => new Set(SpaceStore.instance.getChildRooms(space.roomId)), [space]); - const joinRule = space.getJoinRule(); - const [spaces, rooms, dms] = cli.getVisibleRooms().reduce((arr, room) => { - if (room.getMyMembership() !== "join") return arr; - if (!room.name.toLowerCase().includes(lcQuery)) return arr; + const [spaces, rooms, dms] = useMemo(() => { + let rooms = visibleRooms; - if (room.isSpaceRoom()) { - if (room !== space && !existingSubspacesSet.has(room)) { - arr[0].push(room); + if (lcQuery) { + const matcher = new QueryMatcher(visibleRooms, { + keys: ["name"], + funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)], + shouldMatchWordsOnly: false, + }); + + rooms = matcher.match(lcQuery); + } + + const joinRule = space.getJoinRule(); + return sortRooms(rooms).reduce((arr, room) => { + if (room.isSpaceRoom()) { + if (room !== space && !existingSubspacesSet.has(room)) { + arr[0].push(room); + } + } else if (!existingRoomsSet.has(room)) { + if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { + arr[1].push(room); + } else if (joinRule !== "public") { + // Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones. + arr[2].push(room); + } } - } else if (!existingRoomsSet.has(room)) { - if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { - arr[1].push(room); - } else if (joinRule !== "public") { - // Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones. - arr[2].push(room); + return arr; + }, [[], [], []]); + }, [visibleRooms, space, lcQuery, existingRoomsSet, existingSubspacesSet]); + + const addRooms = async () => { + setError(null); + setProgress(0); + + let error; + + for (const room of selectedToAdd) { + const via = calculateRoomVia(room); + try { + await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => { + if (e.errcode === "M_LIMIT_EXCEEDED") { + await sleep(e.data.retry_after_ms); + return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry + } + + throw e; + }); + setProgress(i => i + 1); + } catch (e) { + console.error("Failed to add rooms to space", e); + setError(error = e); + break; } } - return arr; - }, [[], [], []]); + + if (!error) { + onFinished(true); + } + }; + + const busy = progress !== null; + + let footer; + if (error) { + footer = <> + + + +
    { _t("Not all selected were added") }
    +
    { _t("Try again") }
    +
    + + + { _t("Retry") } + + ; + } else if (busy) { + footer = + +
    + { _t("Adding rooms... (%(progress)s out of %(count)s)", { + count: selectedToAdd.size, + progress, + }) } +
    +
    ; + } else { + let button = emptySelectionButton; + if (!button || selectedToAdd.size > 0) { + button = + { _t("Add") } + ; + } + + footer = <> + + { footerPrompt } + + + { button } + ; + } + + const onChange = !busy && !error ? (checked, room) => { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + } : null; + + const [truncateAt, setTruncateAt] = useState(20); + function overflowTile(overflowCount, totalCount) { + const text = _t("and %(count)s others...", { count: overflowCount }); + return ( + + } name={text} presenceState="online" suppressOnHover={true} + onClick={() => setTruncateAt(totalCount)} /> + ); + } return
    = ({ space, autoComplete={true} autoFocus={true} /> - + { rooms.length > 0 ? (

    { _t("Rooms") }

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

    { _t("Spaces") }

    +
    +
    { _t("Feeling experimental?") }
    +
    { _t("You can add existing spaces to a space.") }
    +
    { spaces.map(space => { return { + checked={selectedToAdd.has(space)} + onChange={onChange ? (checked) => { onChange(checked, space); - }} + } : null} />; }) }
    @@ -132,10 +275,10 @@ export const AddExistingToSpace: React.FC = ({ space, return { + checked={selectedToAdd.has(room)} + onChange={onChange ? (checked) => { onChange(checked, room); - }} + } : null} />; }) }
    @@ -145,16 +288,16 @@ export const AddExistingToSpace: React.FC = ({ space, { _t("No results") } : undefined } + +
    + { footer } +
    ; }; const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { const [selectedSpace, setSelectedSpace] = useState(space); const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); - const [selectedToAdd, setSelectedToAdd] = useState(new Set()); - - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); let spaceOptionSection; if (existingSubspaces.length > 0) { @@ -201,51 +344,20 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onFinished={onFinished} fixedWidth={false} > - { error &&
    { error }
    } - { - if (checked) { - selectedToAdd.add(room); - } else { - selectedToAdd.delete(room); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} + onFinished={onFinished} + footerPrompt={<> +
    { _t("Want to add a new room instead?") }
    + onCreateRoomClick(cli, space)} kind="link"> + { _t("Create a new room") } + + } />
    -
    - -
    { _t("Don't want to add an existing room?") }
    - onCreateRoomClick(cli, space)} kind="link"> - { _t("Create a new room") } - -
    - - { - // TODO rate limiting - setBusy(true); - try { - await allSettled(Array.from(selectedToAdd).map((room) => - SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room)))); - onFinished(true); - } catch (e) { - console.error("Failed to add rooms to space", e); - setError(_t("Failed to add rooms to space")); - } - setBusy(false); - }} - > - { busy ? _t("Adding...") : _t("Add") } - -
    + onFinished(false)} /> ; }; 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 70% rename from src/components/views/dialogs/AskInviteAnywayDialog.js rename to src/components/views/dialogs/AskInviteAnywayDialog.tsx index e6cd45ba6b..26fad0c724 100644 --- a/src/components/views/dialogs/AskInviteAnywayDialog.js +++ b/src/components/views/dialogs/AskInviteAnywayDialog.tsx @@ -15,51 +15,52 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; -import {SettingLevel} from "../../../settings/SettingLevel"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; + +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() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - + public render() { const errorList = this.props.unknownProfileUsers .map(address =>
  • {address.userId}: {address.errorText}
  • ); 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 +68,13 @@ export default class AskInviteAnywayDialog extends React.Component {
    - - -
    diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index 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 new file mode 100644 index 0000000000..5a2f16f169 --- /dev/null +++ b/src/components/views/dialogs/BetaFeedbackDialog.tsx @@ -0,0 +1,111 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useState } from "react"; + +import QuestionDialog from './QuestionDialog'; +import { _t } from '../../../languageHandler'; +import Field from "../elements/Field"; +import SdkConfig from "../../../SdkConfig"; +import { IDialogProps } from "./IDialogProps"; +import SettingsStore from "../../../settings/SettingsStore"; +import { submitFeedback } from "../../../rageshake/submit-rageshake"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; +import AccessibleButton from "../elements/AccessibleButton"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { UserTab } from "./UserSettingsDialog"; + +interface IProps extends IDialogProps { + featureId: string; +} + +const BetaFeedbackDialog: React.FC = ({ featureId, onFinished }) => { + const info = SettingsStore.getBetaInfo(featureId); + + const [comment, setComment] = useState(""); + const [canContact, setCanContact] = useState(false); + + const sendFeedback = async (ok: boolean) => { + if (!ok) return onFinished(false); + + const extraData = SettingsStore.getBetaInfo(featureId)?.extraSettings.reduce((o, k) => { + o[k] = SettingsStore.getValue(k); + return o; + }, {}); + + submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact, extraData); + onFinished(true); + + Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, { + title: _t("Beta feedback"), + description: _t("Thank you for your feedback, we really appreciate it."), + button: _t("Done"), + hasCloseButton: false, + fixedWidth: false, + }); + }; + + return ( +
    + { _t(info.feedbackSubheading) } +   + { _t("Your platform and username will be noted to help us use your feedback as much as we can.")} + + { + onFinished(false); + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Labs, + }); + }}> + { _t("To leave the beta, visit your settings.") } + +
    + + { + setComment(ev.target.value); + }} + autoFocus={true} + /> + + setCanContact((e.target as HTMLInputElement).checked)} + > + { _t("You may contact me if you have any follow up questions") } + + } + button={_t("Send feedback")} + buttonDisabled={!comment} + onFinished={sendFeedback} + />); +}; + +export default BetaFeedbackDialog; diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.tsx similarity index 72% rename from src/components/views/dialogs/BugReportDialog.js rename to src/components/views/dialogs/BugReportDialog.tsx index 8948c14c7c..6baf24f797 100644 --- a/src/components/views/dialogs/BugReportDialog.js +++ b/src/components/views/dialogs/BugReportDialog.tsx @@ -18,17 +18,39 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -import sendBugReport, {downloadBugReport} from '../../../rageshake/submit-rageshake'; +import sendBugReport, { downloadBugReport } from '../../../rageshake/submit-rageshake'; import AccessibleButton from "../elements/AccessibleButton"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import QuestionDialog from "./QuestionDialog"; +import BaseDialog from "./BaseDialog"; +import Field from '../elements/Field'; +import Spinner from "../elements/Spinner"; +import DialogButtons from "../elements/DialogButtons"; + +interface IProps { + onFinished: (success: boolean) => void; + initialText?: string; + label?: string; +} + +interface IState { + sendLogs: boolean; + busy: boolean; + err: string; + issueUrl: string; + text: string; + progress: string; + downloadBusy: boolean; + downloadProgress: string; +} @replaceableComponent("views.dialogs.BugReportDialog") -export default class BugReportDialog extends React.Component { +export default class BugReportDialog extends React.Component { + private unmounted: boolean; + constructor(props) { super(props); this.state = { @@ -41,25 +63,18 @@ export default class BugReportDialog extends React.Component { downloadBusy: false, downloadProgress: null, }; - this._unmounted = false; - this._onSubmit = this._onSubmit.bind(this); - this._onCancel = this._onCancel.bind(this); - this._onTextChange = this._onTextChange.bind(this); - this._onIssueUrlChange = this._onIssueUrlChange.bind(this); - this._onSendLogsChange = this._onSendLogsChange.bind(this); - this._sendProgressCallback = this._sendProgressCallback.bind(this); - this._downloadProgressCallback = this._downloadProgressCallback.bind(this); + this.unmounted = false; } - componentWillUnmount() { - this._unmounted = true; + public componentWillUnmount() { + this.unmounted = true; } - _onCancel(ev) { + private onCancel = (): void => { this.props.onFinished(false); - } + }; - _onSubmit(ev) { + private onSubmit = (): void => { if ((!this.state.text || !this.state.text.trim()) && (!this.state.issueUrl || !this.state.issueUrl.trim())) { this.setState({ err: _t("Please tell us what went wrong or, better, create a GitHub issue that describes the problem."), @@ -72,17 +87,16 @@ export default class BugReportDialog extends React.Component { (this.state.issueUrl.length > 0 ? this.state.issueUrl : 'No issue link given'); this.setState({ busy: true, progress: null, err: null }); - this._sendProgressCallback(_t("Preparing to send logs")); + this.sendProgressCallback(_t("Preparing to send logs")); sendBugReport(SdkConfig.get().bug_report_endpoint_url, { userText, sendLogs: true, - progressCallback: this._sendProgressCallback, + progressCallback: this.sendProgressCallback, label: this.props.label, }).then(() => { - if (!this._unmounted) { + if (!this.unmounted) { this.props.onFinished(false); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); // N.B. first param is passed to piwik and so doesn't want i18n Modal.createTrackedDialog('Bug report sent', '', QuestionDialog, { title: _t('Logs sent'), @@ -91,7 +105,7 @@ export default class BugReportDialog extends React.Component { }); } }, (err) => { - if (!this._unmounted) { + if (!this.unmounted) { this.setState({ busy: false, progress: null, @@ -99,16 +113,16 @@ export default class BugReportDialog extends React.Component { }); } }); - } + }; - _onDownload = async (ev) => { + private onDownload = async (): Promise => { this.setState({ downloadBusy: true }); - this._downloadProgressCallback(_t("Preparing to download logs")); + this.downloadProgressCallback(_t("Preparing to download logs")); try { await downloadBugReport({ sendLogs: true, - progressCallback: this._downloadProgressCallback, + progressCallback: this.downloadProgressCallback, label: this.props.label, }); @@ -117,7 +131,7 @@ export default class BugReportDialog extends React.Component { downloadProgress: null, }); } catch (err) { - if (!this._unmounted) { + if (!this.unmounted) { this.setState({ downloadBusy: false, downloadProgress: _t("Failed to send logs: ") + `${err.message}`, @@ -126,38 +140,29 @@ export default class BugReportDialog extends React.Component { } }; - _onTextChange(ev) { - this.setState({ text: ev.target.value }); - } + private onTextChange = (ev: React.FormEvent): void => { + this.setState({ text: ev.currentTarget.value }); + }; - _onIssueUrlChange(ev) { - this.setState({ issueUrl: ev.target.value }); - } + private onIssueUrlChange = (ev: React.FormEvent): void => { + this.setState({ issueUrl: ev.currentTarget.value }); + }; - _onSendLogsChange(ev) { - this.setState({ sendLogs: ev.target.checked }); - } - - _sendProgressCallback(progress) { - if (this._unmounted) { + private sendProgressCallback = (progress: string): void => { + if (this.unmounted) { return; } - this.setState({progress: progress}); - } + this.setState({ progress }); + }; - _downloadProgressCallback(downloadProgress) { - if (this._unmounted) { + private downloadProgressCallback = (downloadProgress: string): void => { + if (this.unmounted) { return; } this.setState({ downloadProgress }); - } - - render() { - const Loader = sdk.getComponent("elements.Spinner"); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const Field = sdk.getComponent('elements.Field'); + }; + public render() { let error = null; if (this.state.err) { error =
    @@ -169,7 +174,7 @@ export default class BugReportDialog extends React.Component { if (this.state.busy) { progress = (
    - + {this.state.progress} ...
    ); @@ -183,8 +188,8 @@ export default class BugReportDialog extends React.Component { } return ( -
    @@ -213,7 +218,7 @@ export default class BugReportDialog extends React.Component {

    - + { _t("Download logs") } {this.state.downloadProgress && {this.state.downloadProgress} ...} @@ -223,7 +228,7 @@ export default class BugReportDialog extends React.Component { type="text" className="mx_BugReportDialog_field_input" label={_t("GitHub issue")} - onChange={this._onIssueUrlChange} + onChange={this.onIssueUrlChange} value={this.state.issueUrl} placeholder="https://github.com/vector-im/element-web/issues/..." /> @@ -232,7 +237,7 @@ export default class BugReportDialog extends React.Component { element="textarea" label={_t("Notes")} rows={5} - onChange={this._onTextChange} + onChange={this.onTextChange} value={this.state.text} placeholder={_t( "If there is additional context that would help in " + @@ -245,17 +250,12 @@ export default class BugReportDialog extends React.Component { {error}
    ); } } - -BugReportDialog.propTypes = { - onFinished: PropTypes.func.isRequired, - initialText: PropTypes.string, -}; diff --git a/src/components/views/dialogs/ChangelogDialog.js b/src/components/views/dialogs/ChangelogDialog.tsx similarity index 81% rename from src/components/views/dialogs/ChangelogDialog.js rename to src/components/views/dialogs/ChangelogDialog.tsx index 50bc13cff5..d484d94249 100644 --- a/src/components/views/dialogs/ChangelogDialog.js +++ b/src/components/views/dialogs/ChangelogDialog.tsx @@ -16,21 +16,27 @@ Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import request from 'browser-request'; import { _t } from '../../../languageHandler'; +import QuestionDialog from "./QuestionDialog"; +import Spinner from "../elements/Spinner"; + +interface IProps { + newVersion: string; + version: string; + onFinished: (success: boolean) => void; +} const REPOS = ['vector-im/element-web', 'matrix-org/matrix-react-sdk', 'matrix-org/matrix-js-sdk']; -export default class ChangelogDialog extends React.Component { +export default class ChangelogDialog extends React.Component { constructor(props) { super(props); this.state = {}; } - componentDidMount() { + public componentDidMount() { const version = this.props.newVersion.split('-'); const version2 = this.props.version.split('-'); if (version == null || version2 == null) return; @@ -44,12 +50,12 @@ export default class ChangelogDialog extends React.Component { this.setState({ [REPOS[i]]: response.statusText }); return; } - this.setState({[REPOS[i]]: JSON.parse(body).commits}); + this.setState({ [REPOS[i]]: JSON.parse(body).commits }); }); } } - _elementsForCommit(commit) { + private elementsForCommit(commit): JSX.Element { return (
  • @@ -59,10 +65,7 @@ export default class ChangelogDialog extends React.Component { ); } - render() { - const Spinner = sdk.getComponent('views.elements.Spinner'); - const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); - + public render() { const logs = REPOS.map(repo => { let content; if (this.state[repo] == null) { @@ -72,7 +75,7 @@ export default class ChangelogDialog extends React.Component { msg: this.state[repo], }); } else { - content = this.state[repo].map(this._elementsForCommit); + content = this.state[repo].map(this.elementsForCommit); } return (
    @@ -88,20 +91,13 @@ export default class ChangelogDialog extends React.Component {
    ); - return ( + /> ); } } - -ChangelogDialog.propTypes = { - version: PropTypes.string.isRequired, - newVersion: PropTypes.string.isRequired, - onFinished: PropTypes.func.isRequired, -}; diff --git a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx index 2635f95bb7..7627489deb 100644 --- a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx +++ b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx @@ -26,12 +26,12 @@ import SdkConfig from "../../../SdkConfig"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import InviteDialog from "./InviteDialog"; import BaseAvatar from "../avatars/BaseAvatar"; -import {inviteMultipleToRoom, showAnyInviteErrors} from "../../../RoomInvite"; +import { inviteMultipleToRoom, showAnyInviteErrors } from "../../../RoomInvite"; import StyledCheckbox from "../elements/StyledCheckbox"; import Modal from "../../../Modal"; import ErrorDialog from "./ErrorDialog"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../../customisations/Media"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../../customisations/Media"; interface IProps extends IDialogProps { roomId: string; @@ -86,7 +86,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< ev.preventDefault(); ev.stopPropagation(); - this.setState({busy: true}); + this.setState({ busy: true }); try { const targets = [...this.state.emailTargets, ...this.state.userTargets]; const result = await inviteMultipleToRoom(this.props.roomId, targets); @@ -95,10 +95,10 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< if (success) { this.props.onFinished(true); } else { - this.setState({busy: false}); + this.setState({ busy: false }); } } catch (e) { - this.setState({busy: false}); + this.setState({ busy: false }); console.error(e); Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { title: _t("Failed to invite"), @@ -114,7 +114,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< } else { targets[index] = ev.target.value; } - this.setState({emailTargets: targets}); + this.setState({ emailTargets: targets }); }; private onAddressBlur = (index: number) => { @@ -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 79% rename from src/components/views/dialogs/ConfirmAndWaitRedactDialog.js rename to src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx index 37d5510756..d21fde329c 100644 --- a/src/components/views/dialogs/ConfirmAndWaitRedactDialog.js +++ b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx @@ -15,9 +15,22 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ConfirmRedactDialog from './ConfirmRedactDialog'; +import ErrorDialog from './ErrorDialog'; +import BaseDialog from "./BaseDialog"; +import Spinner from "../elements/Spinner"; + +interface IProps { + redact: () => Promise; + onFinished: (success: boolean) => void; +} + +interface IState { + isRedacting: boolean; + redactionErrorCode: string | number; +} /* * A dialog for confirming a redaction. @@ -32,7 +45,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 +54,16 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent { }; } - onParentFinished = async (proceed) => { + public onParentFinished = async (proceed: boolean): Promise => { if (proceed) { - this.setState({isRedacting: true}); + this.setState({ isRedacting: true }); try { await this.props.redact(); this.props.onFinished(true); } catch (error) { const code = error.errcode || error.statusCode; if (typeof code !== "undefined") { - this.setState({redactionErrorCode: code}); + this.setState({ redactionErrorCode: code }); } else { this.props.onFinished(true); } @@ -60,21 +73,18 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent { } }; - render() { + public render() { if (this.state.isRedacting) { if (this.state.redactionErrorCode) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const code = this.state.redactionErrorCode; return ( ); } else { - const BaseDialog = sdk.getComponent("dialogs.BaseDialog"); - const Spinner = sdk.getComponent('elements.Spinner'); return ( ; } } diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.tsx similarity index 86% rename from src/components/views/dialogs/ConfirmRedactDialog.js rename to src/components/views/dialogs/ConfirmRedactDialog.tsx index bd63d3acc1..a2f2b10144 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.js +++ b/src/components/views/dialogs/ConfirmRedactDialog.tsx @@ -15,17 +15,20 @@ 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"; +import TextInputDialog from "./TextInputDialog"; + +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 ( void; +} /* * A dialog for confirming an operation on another user. @@ -32,58 +53,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() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); - const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); - + public render() { const confirmButtonClass = this.props.danger ? 'danger' : ''; let reasonBox; @@ -92,7 +78,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 65% rename from src/components/views/dialogs/ConfirmWipeDeviceDialog.js rename to src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx index 4faaad0f7e..544d0df1c9 100644 --- a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js +++ b/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx @@ -15,33 +15,33 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import {_t} from "../../../languageHandler"; -import * as sdk from "../../../index"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { _t } from "../../../languageHandler"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; + +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); }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return ( - +

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

    ); diff --git a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx index 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 76% rename from src/components/views/dialogs/CreateGroupDialog.js rename to src/components/views/dialogs/CreateGroupDialog.tsx index e6c7a67aca..d6bb582079 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.tsx @@ -15,44 +15,52 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; +import Spinner from "../elements/Spinner"; + +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 +75,16 @@ export default class CreateGroupDialog extends React.Component { return error; } - _onFormSubmit = e => { + private onFormSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (this._checkGroupId()) return; + if (this.checkGroupId()) return; - const profile = {}; + const profile: any = {}; if (this.state.groupName !== '') { profile.name = this.state.groupName; } - this.setState({creating: true}); + this.setState({ creating: true }); MatrixClientPeg.get().createGroup({ localpart: this.state.groupId, profile: profile, @@ -88,9 +96,9 @@ export default class CreateGroupDialog extends React.Component { }); this.props.onFinished(true); }).catch((e) => { - this.setState({createError: e}); + this.setState({ createError: e }); }).finally(() => { - this.setState({creating: false}); + this.setState({ creating: false }); }); }; @@ -99,9 +107,6 @@ export default class CreateGroupDialog extends React.Component { }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const Spinner = sdk.getComponent('elements.Spinner'); - if (this.state.creating) { return ; } @@ -121,7 +126,7 @@ export default class CreateGroupDialog extends React.Component { - +
    @@ -129,9 +134,9 @@ export default class CreateGroupDialog extends React.Component {
    @@ -144,10 +149,10 @@ export default class CreateGroupDialog extends React.Component { + diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.tsx similarity index 56% rename from src/components/views/dialogs/CreateRoomDialog.js rename to src/components/views/dialogs/CreateRoomDialog.tsx index e9dc6e2be0..b5c0096771 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -1,6 +1,6 @@ /* Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,27 +15,47 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import {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 * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; -import withValidation from '../elements/Validation'; +import withValidation, { IFieldState } from '../elements/Validation'; import { _t } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {Key} from "../../../Keyboard"; -import {privateShouldBeEncrypted} from "../../../createRoom"; -import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { Key } from "../../../Keyboard"; +import { IOpts, privateShouldBeEncrypted } from "../../../createRoom"; +import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Field from "../elements/Field"; +import RoomAliasField from "../elements/RoomAliasField"; +import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; +import DialogButtons from "../elements/DialogButtons"; +import BaseDialog from "../dialogs/BaseDialog"; +import { Preset, Visibility } from "matrix-js-sdk/src/@types/partials"; + +interface IProps { + defaultPublic?: boolean; + defaultName?: string; + parentSpace?: Room; + onFinished(proceed: boolean, opts?: IOpts): void; +} + +interface IState { + isPublic: boolean; + isEncrypted: boolean; + name: string; + topic: string; + alias: string; + detailsOpen: boolean; + noFederate: boolean; + nameIsValid: boolean; + canChangeEncryption: boolean; +} @replaceableComponent("views.dialogs.CreateRoomDialog") -export default class CreateRoomDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - defaultPublic: PropTypes.bool, - parentSpace: PropTypes.instanceOf(Room), - }; +export default class CreateRoomDialog extends React.Component { + private nameField = createRef(); + private aliasField = createRef(); constructor(props) { super(props); @@ -44,7 +64,7 @@ export default class CreateRoomDialog extends React.Component { this.state = { isPublic: this.props.defaultPublic || false, isEncrypted: privateShouldBeEncrypted(), - name: "", + name: this.props.defaultName || "", topic: "", alias: "", detailsOpen: false, @@ -53,27 +73,26 @@ export default class CreateRoomDialog extends React.Component { canChangeEncryption: true, }; - MatrixClientPeg.get().doesServerForceEncryptionForPreset("private") - .then(isForced => this.setState({canChangeEncryption: !isForced})); + MatrixClientPeg.get().doesServerForceEncryptionForPreset(Preset.PrivateChat) + .then(isForced => this.setState({ canChangeEncryption: !isForced })); } - _roomCreateOptions() { - const opts = {}; - const createOpts = opts.createOpts = {}; + private roomCreateOptions() { + const opts: IOpts = {}; + const createOpts: IOpts["createOpts"] = opts.createOpts = {}; createOpts.name = this.state.name; if (this.state.isPublic) { - createOpts.visibility = "public"; - createOpts.preset = "public_chat"; + createOpts.visibility = Visibility.Public; + createOpts.preset = Preset.PublicChat; opts.guestAccess = false; - const {alias} = this.state; - const localPart = alias.substr(1, alias.indexOf(":") - 1); - createOpts['room_alias_name'] = localPart; + const { alias } = this.state; + createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1); } if (this.state.topic) { createOpts.topic = this.state.topic; } if (this.state.noFederate) { - createOpts.creation_content = {'m.federate': false}; + createOpts.creation_content = { 'm.federate': false }; } if (!this.state.isPublic) { @@ -98,16 +117,14 @@ export default class CreateRoomDialog extends React.Component { } componentDidMount() { - this._detailsRef.addEventListener("toggle", this.onDetailsToggled); // move focus to first field when showing dialog - this._nameFieldRef.focus(); + this.nameField.current.focus(); } componentWillUnmount() { - this._detailsRef.removeEventListener("toggle", this.onDetailsToggled); } - _onKeyDown = event => { + private onKeyDown = (event: KeyboardEvent) => { if (event.key === Key.ENTER) { this.onOk(); event.preventDefault(); @@ -115,26 +132,26 @@ export default class CreateRoomDialog extends React.Component { } }; - onOk = async () => { - const activeElement = document.activeElement; + private onOk = async () => { + const activeElement = document.activeElement as HTMLElement; if (activeElement) { activeElement.blur(); } - await this._nameFieldRef.validate({allowEmpty: false}); - if (this._aliasFieldRef) { - await this._aliasFieldRef.validate({allowEmpty: false}); + await this.nameField.current.validate({ allowEmpty: false }); + if (this.aliasField.current) { + await this.aliasField.current.validate({ allowEmpty: false }); } // Validation and state updates are async, so we need to wait for them to complete // first. Queue a `setState` callback and wait for it to resolve. - await new Promise(resolve => this.setState({}, resolve)); - if (this.state.nameIsValid && (!this._aliasFieldRef || this._aliasFieldRef.isValid)) { - this.props.onFinished(true, this._roomCreateOptions()); + await new Promise(resolve => this.setState({}, resolve)); + if (this.state.nameIsValid && (!this.aliasField.current || this.aliasField.current.isValid)) { + this.props.onFinished(true, this.roomCreateOptions()); } else { let field; if (!this.state.nameIsValid) { - field = this._nameFieldRef; - } else if (this._aliasFieldRef && !this._aliasFieldRef.isValid) { - field = this._aliasFieldRef; + field = this.nameField.current; + } else if (this.aliasField.current && !this.aliasField.current.isValid) { + field = this.aliasField.current; } if (field) { field.focus(); @@ -143,49 +160,45 @@ export default class CreateRoomDialog extends React.Component { } }; - onCancel = () => { + private onCancel = () => { this.props.onFinished(false); }; - onNameChange = ev => { - this.setState({name: ev.target.value}); + private onNameChange = (ev: ChangeEvent) => { + this.setState({ name: ev.target.value }); }; - onTopicChange = ev => { - this.setState({topic: ev.target.value}); + private onTopicChange = (ev: ChangeEvent) => { + this.setState({ topic: ev.target.value }); }; - onPublicChange = isPublic => { - this.setState({isPublic}); + private onPublicChange = (isPublic: boolean) => { + this.setState({ isPublic }); }; - onEncryptedChange = isEncrypted => { - this.setState({isEncrypted}); + private onEncryptedChange = (isEncrypted: boolean) => { + this.setState({ isEncrypted }); }; - onAliasChange = alias => { - this.setState({alias}); + private onAliasChange = (alias: string) => { + this.setState({ alias }); }; - onDetailsToggled = ev => { - this.setState({detailsOpen: ev.target.open}); + private onDetailsToggled = (ev: SyntheticEvent) => { + this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open }); }; - onNoFederateChange = noFederate => { - this.setState({noFederate}); + private onNoFederateChange = (noFederate: boolean) => { + this.setState({ noFederate }); }; - collectDetailsRef = ref => { - this._detailsRef = ref; - }; - - onNameValidate = async fieldState => { - const result = await CreateRoomDialog._validateRoomName(fieldState); - this.setState({nameIsValid: result.valid}); + private onNameValidate = async (fieldState: IFieldState) => { + const result = await CreateRoomDialog.validateRoomName(fieldState); + this.setState({ nameIsValid: result.valid }); return result; }; - static _validateRoomName = withValidation({ + private static validateRoomName = withValidation({ rules: [ { key: "required", @@ -196,18 +209,17 @@ export default class CreateRoomDialog extends React.Component { }); render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const Field = sdk.getComponent('views.elements.Field'); - const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch'); - const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField'); - let aliasField; if (this.state.isPublic) { const domain = MatrixClientPeg.get().getDomain(); aliasField = (
    - this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} value={this.state.alias} /> +
    ); } @@ -264,26 +276,44 @@ export default class CreateRoomDialog extends React.Component { let title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room'); if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { const name = CommunityPrototypeStore.instance.getSelectedCommunityName(); - title = _t("Create a room in %(communityName)s", {communityName: name}); + title = _t("Create a room in %(communityName)s", { communityName: name }); } return ( - +
    - this._nameFieldRef = ref} label={ _t('Name') } onChange={this.onNameChange} onValidate={this.onNameValidate} value={this.state.name} className="mx_CreateRoomDialog_name" /> - - + + + { publicPrivateLabel } { e2eeSection } { aliasField } -
    - { this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') } +
    + + { this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') } + { +interface IProps { + onFinished: (success: boolean) => void; +} + +const CryptoStoreTooNewDialog: React.FC = (props: IProps) => { const brand = SdkConfig.get().brand; const _onLogoutClicked = () => { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('Logout e2e db too new', '', QuestionDialog, { title: _t("Sign out"), description: _t( @@ -39,8 +44,8 @@ export default (props) => { focus: false, onFinished: (doLogout) => { if (doLogout) { - dis.dispatch({action: 'logout'}); - props.onFinished(); + dis.dispatch({ action: 'logout' }); + props.onFinished(true); } }, }); @@ -54,8 +59,6 @@ export default (props) => { { brand }, ); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( { ); }; + +export default CryptoStoreTooNewDialog; diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.tsx similarity index 78% rename from src/components/views/dialogs/DeactivateAccountDialog.js rename to src/components/views/dialogs/DeactivateAccountDialog.tsx index 4e52549d51..b2ac849314 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"; +import BaseDialog from "./BaseDialog"; + +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,34 +139,32 @@ export default class DeactivateAccountDialog extends React.Component { }); // As mentioned above, set up for auth again to get updated UIA session info - this._initAuth(/* shouldErase= */ev.target.checked); + this.initAuth(/* shouldErase= */ev.currentTarget.checked); }; - _onCancel() { + private onCancel(): void { this.props.onFinished(false); } - _initAuth(shouldErase) { + private initAuth(shouldErase: boolean): void { MatrixClientPeg.get().deactivateAccount(null, shouldErase).then(r => { // If we got here, oops. The server didn't require any auth. // Our application lifecycle will catch the error and do the logout bits. // We'll try to log something in an vain attempt to record what happened (storage // is also obliterated on logout). console.warn("User's account got deactivated without confirmation: Server had no auth"); - this.setState({errStr: _t("Server did not require any authentication")}); + this.setState({ errStr: _t("Server did not require any authentication") }); }).catch(e => { if (e && e.httpStatus === 401 && e.data) { // Valid UIA response - this.setState({authData: e.data, authEnabled: true}); + this.setState({ authData: e.data, authEnabled: true }); } else { - this.setState({errStr: _t("Server did not return valid authentication information.")}); + this.setState({ errStr: _t("Server did not return valid authentication information.") }); } }); } - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - + public render() { let error = null; if (this.state.errStr) { error =
    @@ -166,9 +180,9 @@ export default class DeactivateAccountDialog extends React.Component { @@ -214,7 +228,7 @@ export default class DeactivateAccountDialog extends React.Component {

    {_t( "Please forget all messages I have sent when my account is deactivated " + @@ -235,7 +249,3 @@ export default class DeactivateAccountDialog extends React.Component { ); } } - -DeactivateAccountDialog.propTypes = { - onFinished: PropTypes.func.isRequired, -}; diff --git a/src/components/views/dialogs/DevtoolsDialog.js b/src/components/views/dialogs/DevtoolsDialog.tsx similarity index 68% rename from src/components/views/dialogs/DevtoolsDialog.js rename to src/components/views/dialogs/DevtoolsDialog.tsx index 9f5513e0a3..86b8f93d7b 100644 --- a/src/components/views/dialogs/DevtoolsDialog.js +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -1,5 +1,6 @@ /* Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2018-2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,14 +15,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useState, useEffect} from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; +import React, { useState, useEffect, ChangeEvent, MouseEvent } from 'react'; import SyntaxHighlight from '../elements/SyntaxHighlight'; import { _t } from '../../../languageHandler'; import Field from "../elements/Field"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {useEventEmitter} from "../../../hooks/useEventEmitter"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; import { PHASE_UNSENT, @@ -30,69 +29,98 @@ import { PHASE_DONE, PHASE_STARTED, PHASE_CANCELLED, + VerificationRequest, } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import WidgetStore from "../../../stores/WidgetStore"; -import {UPDATE_EVENT} from "../../../stores/AsyncStore"; -import {SETTINGS} from "../../../settings/Settings"; -import SettingsStore, {LEVEL_ORDER} from "../../../settings/SettingsStore"; +import WidgetStore, { IApp } from "../../../stores/WidgetStore"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import { SETTINGS } from "../../../settings/Settings"; +import SettingsStore, { LEVEL_ORDER } from "../../../settings/SettingsStore"; import Modal from "../../../Modal"; import ErrorDialog from "./ErrorDialog"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {Room} from "matrix-js-sdk/src/models/room"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { SettingLevel } from '../../../settings/SettingLevel'; +import BaseDialog from "./BaseDialog"; +import TruncatedList from "../elements/TruncatedList"; -class GenericEditor extends React.PureComponent { - // static propTypes = {onBack: PropTypes.func.isRequired}; +interface IGenericEditorProps { + onBack: () => void; +} - constructor(props) { - super(props); - this._onChange = this._onChange.bind(this); - this.onBack = this.onBack.bind(this); - } +interface IGenericEditorState { + message?: string; + [inputId: string]: boolean | string; +} - onBack() { +abstract class GenericEditor< + P extends IGenericEditorProps = IGenericEditorProps, + S extends IGenericEditorState = IGenericEditorState, +> extends React.PureComponent { + protected onBack = () => { if (this.state.message) { this.setState({ message: null }); } else { this.props.onBack(); } - } + }; - _onChange(e) { - this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); - } + 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 }); + }; - _buttons() { + protected abstract send(); + + protected buttons(): React.ReactNode { return

    - { !this.state.message && } + { !this.state.message && }
    ; } - textInput(id, label) { - return ; + protected textInput(id: string, label: string): React.ReactNode { + return ; } } -export class SendCustomEvent extends GenericEditor { - static getLabel() { return _t('Send Custom Event'); } - - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - forceStateEvent: PropTypes.bool, - forceGeneralEvent: PropTypes.bool, - inputs: PropTypes.object, +interface ISendCustomEventProps extends IGenericEditorProps { + room: Room; + forceStateEvent?: boolean; + forceGeneralEvent?: boolean; + inputs?: { + eventType?: string; + stateKey?: string; + evContent?: string; }; +} + +interface ISendCustomEventState extends IGenericEditorState { + isStateEvent: boolean; + eventType: string; + stateKey: string; + evContent: string; +} + +export class SendCustomEvent extends GenericEditor { + static getLabel() { return _t('Send Custom Event'); } static contextType = MatrixClientContext; constructor(props) { super(props); - this._send = this._send.bind(this); - const {eventType, stateKey, evContent} = Object.assign({ + const { eventType, stateKey, evContent } = Object.assign({ eventType: '', stateKey: '', evContent: '{\n\n}', @@ -107,7 +135,7 @@ export class SendCustomEvent extends GenericEditor { }; } - send(content) { + private doSend(content: object): Promise { const cli = this.context; if (this.state.isStateEvent) { return cli.sendStateEvent(this.props.room.roomId, this.state.eventType, content, this.state.stateKey); @@ -116,7 +144,7 @@ export class SendCustomEvent extends GenericEditor { } } - async _send() { + protected send = async () => { if (this.state.eventType === '') { this.setState({ message: _t('You must specify an event type!') }); return; @@ -125,13 +153,13 @@ export class SendCustomEvent extends GenericEditor { let message; try { const content = JSON.parse(this.state.evContent); - await this.send(content); + await this.doSend(content); message = _t('Event sent!'); } catch (e) { message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; } this.setState({ message }); - } + }; render() { if (this.state.message) { @@ -139,7 +167,7 @@ export class SendCustomEvent extends GenericEditor {
    { this.state.message }
    - { this._buttons() } + { this.buttons() }
    ; } @@ -155,37 +183,53 @@ export class SendCustomEvent extends GenericEditor {
    + autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
    - { !this.state.message && } - { showTglFlip &&
    - -
    ; } } -class SendAccountData extends GenericEditor { - static getLabel() { return _t('Send Account Data'); } - - static propTypes = { - room: PropTypes.instanceOf(Room).isRequired, - isRoomAccountData: PropTypes.bool, - forceMode: PropTypes.bool, - inputs: PropTypes.object, +interface ISendAccountDataProps extends IGenericEditorProps { + room: Room; + isRoomAccountData: boolean; + forceMode: boolean; + inputs?: { + eventType?: string; + evContent?: string; }; +} + +interface ISendAccountDataState extends IGenericEditorState { + isRoomAccountData: boolean; + eventType: string; + evContent: string; +} + +class SendAccountData extends GenericEditor { + static getLabel() { return _t('Send Account Data'); } static contextType = MatrixClientContext; constructor(props) { super(props); - this._send = this._send.bind(this); - const {eventType, evContent} = Object.assign({ + const { eventType, evContent } = Object.assign({ eventType: '', evContent: '{\n\n}', }, this.props.inputs); @@ -198,7 +242,7 @@ class SendAccountData extends GenericEditor { }; } - send(content) { + private doSend(content: object): Promise { const cli = this.context; if (this.state.isRoomAccountData) { return cli.setRoomAccountData(this.props.room.roomId, this.state.eventType, content); @@ -206,7 +250,7 @@ class SendAccountData extends GenericEditor { return cli.setAccountData(this.state.eventType, content); } - async _send() { + protected send = async () => { if (this.state.eventType === '') { this.setState({ message: _t('You must specify an event type!') }); return; @@ -215,13 +259,13 @@ class SendAccountData extends GenericEditor { let message; try { const content = JSON.parse(this.state.evContent); - await this.send(content); + await this.doSend(content); message = _t('Event sent!'); } catch (e) { message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; } this.setState({ message }); - } + }; render() { if (this.state.message) { @@ -229,7 +273,7 @@ class SendAccountData extends GenericEditor {
    { this.state.message }
    - { this._buttons() } + { this.buttons() }
    ; } @@ -239,14 +283,23 @@ class SendAccountData extends GenericEditor {
    + autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
    - { !this.state.message && } - { !this.state.message &&
    - -
    ; @@ -256,17 +309,22 @@ class SendAccountData extends GenericEditor { const INITIAL_LOAD_TILES = 20; const LOAD_TILES_STEP_SIZE = 50; -class FilteredList extends React.PureComponent { - static propTypes = { - children: PropTypes.any, - query: PropTypes.string, - onChange: PropTypes.func, - }; +interface IFilteredListProps { + children: React.ReactElement[]; + query: string; + onChange: (value: string) => void; +} - static filterChildren(children, query) { +interface IFilteredListState { + filteredChildren: React.ReactElement[]; + truncateAt: number; +} + +class FilteredList extends React.PureComponent { + static filterChildren(children: React.ReactElement[], query: string): React.ReactElement[] { if (!query) return children; const lcQuery = query.toLowerCase(); - return children.filter((child) => child.key.toLowerCase().includes(lcQuery)); + return children.filter((child) => child.key.toString().toLowerCase().includes(lcQuery)); } constructor(props) { @@ -287,69 +345,71 @@ class FilteredList extends React.PureComponent { }); } - showAll = () => { + private showAll = () => { this.setState({ truncateAt: this.state.truncateAt + LOAD_TILES_STEP_SIZE, }); }; - createOverflowElement = (overflowCount: number, totalCount: number) => { + private createOverflowElement = (overflowCount: number, totalCount: number) => { return ; }; - onQuery = (ev) => { + private onQuery = (ev: ChangeEvent) => { if (this.props.onChange) this.props.onChange(ev.target.value); }; - getChildren = (start: number, end: number) => { + private getChildren = (start: number, end: number): React.ReactElement[] => { return this.state.filteredChildren.slice(start, end); }; - getChildCount = (): number => { + private getChildCount = (): number => { return this.state.filteredChildren.length; }; render() { - const TruncatedList = sdk.getComponent("elements.TruncatedList"); return
    + type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery} + className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" + // force re-render so that autoFocus is applied when this component is re-used + key={this.props.children[0] ? this.props.children[0].key : ''} /> + getChildCount={this.getChildCount} + truncateAt={this.state.truncateAt} + createOverflowElement={this.createOverflowElement} />
    ; } } -class RoomStateExplorer extends React.PureComponent { - static getLabel() { return _t('Explore Room State'); } +interface IExplorerProps { + room: Room; + onBack: () => void; +} - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - }; +interface IRoomStateExplorerState { + eventType?: string; + event?: MatrixEvent; + editing: boolean; + queryEventType: string; + queryStateKey: string; +} + +class RoomStateExplorer extends React.PureComponent { + static getLabel() { return _t('Explore Room State'); } static contextType = MatrixClientContext; - roomStateEvents: Map>; + private roomStateEvents: Map>; constructor(props) { super(props); this.roomStateEvents = this.props.room.currentState.events; - this.onBack = this.onBack.bind(this); - this.editEv = this.editEv.bind(this); - this.onQueryEventType = this.onQueryEventType.bind(this); - this.onQueryStateKey = this.onQueryStateKey.bind(this); - this.state = { eventType: null, event: null, @@ -360,19 +420,19 @@ class RoomStateExplorer extends React.PureComponent { }; } - browseEventType(eventType) { + private browseEventType(eventType: string) { return () => { this.setState({ eventType }); }; } - onViewSourceClick(event) { + private onViewSourceClick(event: MatrixEvent) { return () => { this.setState({ event }); }; } - onBack() { + private onBack = () => { if (this.state.editing) { this.setState({ editing: false }); } else if (this.state.event) { @@ -382,19 +442,19 @@ class RoomStateExplorer extends React.PureComponent { } else { this.props.onBack(); } - } + }; - editEv() { + private editEv = () => { this.setState({ editing: true }); - } + }; - onQueryEventType(filterEventType) { + private onQueryEventType = (filterEventType: string) => { this.setState({ queryEventType: filterEventType }); - } + }; - onQueryStateKey(filterStateKey) { + private onQueryStateKey = (filterStateKey: string) => { this.setState({ queryStateKey: filterStateKey }); - } + }; render() { if (this.state.event) { @@ -464,24 +524,22 @@ class RoomStateExplorer extends React.PureComponent { } } -class AccountDataExplorer extends React.PureComponent { - static getLabel() { return _t('Explore Account Data'); } +interface IAccountDataExplorerState { + [inputId: string]: boolean | string | any; + isRoomAccountData: boolean; + event?: MatrixEvent; + editing: boolean; + queryEventType: string; +} - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - }; +class AccountDataExplorer extends React.PureComponent { + static getLabel() { return _t('Explore Account Data'); } static contextType = MatrixClientContext; constructor(props) { super(props); - this.onBack = this.onBack.bind(this); - this.editEv = this.editEv.bind(this); - this._onChange = this._onChange.bind(this); - this.onQueryEventType = this.onQueryEventType.bind(this); - this.state = { isRoomAccountData: false, event: null, @@ -491,20 +549,20 @@ class AccountDataExplorer extends React.PureComponent { }; } - getData() { + private getData(): Record { if (this.state.isRoomAccountData) { return this.props.room.accountData; } return this.context.store.accountData; } - onViewSourceClick(event) { + private onViewSourceClick(event: MatrixEvent) { return () => { this.setState({ event }); }; } - onBack() { + private onBack = () => { if (this.state.editing) { this.setState({ editing: false }); } else if (this.state.event) { @@ -512,19 +570,19 @@ class AccountDataExplorer extends React.PureComponent { } else { this.props.onBack(); } - } + }; - _onChange(e) { - this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); - } + private onChange = (e: ChangeEvent) => { + this.setState({ [e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }); + }; - editEv() { + private editEv = () => { this.setState({ editing: true }); - } + }; - onQueryEventType(queryEventType) { + private onQueryEventType = (queryEventType: string) => { this.setState({ queryEventType }); - } + }; render() { if (this.state.event) { @@ -572,30 +630,39 @@ class AccountDataExplorer extends React.PureComponent {
    - { !this.state.message &&
    - -
    } +
    + +
    ; } } -class ServersInRoomList extends React.PureComponent { +interface IServersInRoomListState { + query: string; +} + +class ServersInRoomList extends React.PureComponent { static getLabel() { return _t('View Servers in Room'); } - static propTypes = { - onBack: PropTypes.func.isRequired, - room: PropTypes.instanceOf(Room).isRequired, - }; - static contextType = MatrixClientContext; + private servers: React.ReactElement[]; + constructor(props) { super(props); const room = this.props.room; - const servers = new Set(); + const servers = new Set(); room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1])); this.servers = Array.from(servers).map(s => to continue.": "برای ادامه، عبارت امنیتی خود را وارد کرده و یا .", + "Click the button below to confirm setting up encryption.": "برای تأیید و فعال‌سازی رمزگذاری ، روی دکمه زیر کلیک کنید.", + "Unable to access secret storage. Please verify that you entered the correct Security Phrase.": "دسترسی به حافظه نهان امکان‌پذیر نیست. لطفاً تأیید کنید که عبارت امنیتی صحیح را وارد کرده‌اید.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "اگر همه موارد را بازراه‌اندازی (reset) کنید، دیگر هیچ نشست تائید شده‌ای و هیچ کاربر تائيد‌ شده‌ای نخواهید داشت و ممکن است نتوانید پیام‌های گذشته‌ی خود را مشاهده نمائید.", + "Backup could not be decrypted with this Security Key: please verify that you entered the correct Security Key.": "نسخه پشتیبان با این کلید امنیتی رمزگشایی نمی شود: لطفاً بررسی کنید که کلید امنیتی درست را وارد کرده اید.", + "Backup could not be decrypted with this Security Phrase: please verify that you entered the correct Security Phrase.": "نسخه پشتیبان با این عبارت امنیتی رمزگشایی نمی‌شود: لطفاً بررسی کنید که عبارت امنیتی درست را وارد کرده اید.", + "Warning: you should only set up key backup from a trusted computer.": "هشدا: پشتیبان گیری از کلید را فقط از یک رایانه مطمئن انجام دهید.", + "Access your secure message history and set up secure messaging by entering your Security Phrase.": "با وارد کردن عبارت امنیتی خود به سابقه پیام‌های رمز شدتان دسترسی پیدا کرده و پیام امن ارسال کنید.", + "Only do this if you have no other device to complete verification with.": "این کار را فقط درصورتی انجام دهید که دستگاه دیگری برای تکمیل فرآیند تأیید ندارید.", + "Forgotten or lost all recovery methods? Reset all": "همه روش‌های بازیابی را فراموش کرده یا از دست داده‌اید؟ بازراه‌اندازی (reset) همه", + "If you've forgotten your Security Phrase you can use your Security Key or set up new recovery options": "اگر عبارت امنیتی خود را فراموش کرده اید، می توانید از کلید امنیتی خود استفاده کنید یا تنظیمات پشتیانی‌گیری را مجددا انجام دهید", + "The widget will verify your user ID, but won't be able to perform actions for you:": "ابزارک شناسه‌ی کاربری شما را تائید خواهد کرد، اما نمی‌تواند این کارها را برای شما انجام دهد:", + "This looks like a valid Security Key!": "به نظر می رسد این یک کلید امنیتی معتبر است!", + "Warning: You should only set up key backup from a trusted computer.": "هشدار: پشتیان کلید تنها باید در یک رایانه‌ی مطمئن انجام شود.", + "Allow this widget to verify your identity": "به این ابزارک اجازه دهید هویت شما را تأیید کند", + "Approve": "تایید", + "Some files are too large to be uploaded. The file size limit is %(limit)s.": "برخی از فایل‌ها برای بارگذاری بیش از حد بزرگ هستند. محدودیت اندازه فایل %(limit)s است.", + "Access your secure message history and set up secure messaging by entering your Security Key.": "با وارد کردن کلید امنیتی خود به تاریخچه‌ی پیام‌‌های رمز شده خود دسترسی پیدا کرده و پیام امن ارسال کنید.", + "These files are too large to upload. The file size limit is %(limit)s.": "این فایل‌ها برای بارگذاری بیش از حد بزرگ هستند. محدودیت اندازه فایل %(limit)s است.", + "If you've forgotten your Security Key you can ": "اگر کلید امنیتی خود را فراموش کرده‌اید ، می توانید ", + "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "این فایل برای بارگذاری بسیار بزرگ است. محدودیت اندازه‌ی فایل برابر %(limit)s است اما حجم این فایل %(sizeOfThisFile)s.", + "Ask this user to verify their session, or manually verify it below.": "از این کاربر بخواهید نشست خود را تأیید کرده و یا آن را به صورت دستی تأیید کنید.", + "Away": "بعدا", + "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) وارد یک نشست جدید شد بدون اینکه آن را تائید کند:", + "This homeserver would like to make sure you are not a robot.": "این سرور می خواهد مطمئن شود که شما یک ربات نیستید.", + "Confirm your identity by entering your account password below.": "با وارد کردن رمز ورود حساب خود در زیر ، هویت خود را تأیید کنید.", + "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "کلید عمومی captcha در پیکربندی سرور خانگی وجود ندارد. لطفاً این را به ادمین سرور خود گزارش دهید.", + "Verify your other session using one of the options below.": "نست دیگر خود را با استفاده از یکی از راهکارهای زیر تأیید کنید.", + "You signed in to a new session without verifying it:": "شما وارد یک نشست جدید شده‌اید بدون اینکه آن را تائید کنید:", + "Please review and accept all of the homeserver's policies": "لطفاً کلیه خط مشی‌های سرور را مرور و قبول کنید", + "Please review and accept the policies of this homeserver:": "لطفاً خط مشی‌های این سرور را مرور و قبول کنید:", + "Next": "بعدی", + "A confirmation email has been sent to %(emailAddress)s": "یک ایمیل تأیید به %(emailAddress)s ارسال شد", + "Document": "سند", + "Summary": "خلاصه", + "Service": "سرویس", + "Open the link in the email to continue registration.": "برای ادامه ثبت نام ، پیوند موجود در ایمیل را باز کنید.", + "Token incorrect": "کد نامعتبر است", + "To continue you need to accept the terms of this service.": "برای ادامه باید شرایط این سرویس را بپذیرید.", + "Use bots, bridges, widgets and sticker packs": "از بات‌ها، پل‌ها ارتباطی، ابزارک‌ها و بسته‌های استیکر استفاده کنید", + "A text message has been sent to %(msisdn)s": "کد فعال‌سازی به %(msisdn)s ارسال شد", + "Your browser likely removed this data when running low on disk space.": "هنگام کمبود فضای دیسک ، مرورگر شما این داده ها را حذف می کند.", + "Use an email address to recover your account": "برای بازیابی حساب خود از آدرس ایمیل استفاده کنید", + "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "برخی از داده‌های نشست ، از جمله کلیدهای رمزنگاری پیام‌ها موجود نیست. برای برطرف کردن این مشکل از برنامه خارج شده و مجددا وارد شوید و از کلیدها را از نسخه‌ی پشتیبان بازیابی نمائيد.", + "Enter email address (required on this homeserver)": "آدرس ایمیل را وارد کنید (در این سرور اجباری است)", + "Other users can invite you to rooms using your contact details": "سایر کاربران می توانند شما را با استفاده از اطلاعات تماستان به اتاق ها دعوت کنند", + "Enter phone number (required on this homeserver)": "شماره تلفن را وارد کنید (در این سرور اجباری است)", + "Use lowercase letters, numbers, dashes and underscores only": "فقط از حروف کوچک، اعداد، خط تیره و زیر خط استفاده کنید", + "Use email to optionally be discoverable by existing contacts.": "از ایمیل استفاده کنید تا به طور اختیاری توسط مخاطبین موجود قابل کشف باشید.", + "Use email or phone to optionally be discoverable by existing contacts.": "از ایمیل یا تلفن استفاده کنید تا به طور اختیاری توسط مخاطبین موجود قابل کشف باشید.", + "To help us prevent this in future, please send us logs.": "برای کمک به ما در جلوگیری از این امر در آینده ، لطفا لاگ‌ها را برای ما ارسال کنید.", + "You must register to use this functionality": "برای استفاده از این قابلیت باید ثبت نام کنید", + "

    HTML for your community's page

    \n

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

    \n

    \n You can even add images with Matrix URLs \n

    \n": "

    html مرتبط با اجتماع شما

    \n

    \n برای معرفی اجتماع به اعضای جدید، یا ذکر نکات مهم،\n از توضیحات مفصل استفاده کنیدلینک\n

    \n

    \n شما حتی می‌توانید تصاویر را با استفاده از url ماتریکس به این صفحه اضافه کنید \n

    \n", + "Saving...": "در حال ذخیره‌سازی...", + "Edit settings relating to your space.": "تنظیمات مربوط به فضای کاری خود را ویرایش کنید.", + "This will allow you to reset your password and receive notifications.": "با این کار می‌توانید گذرواژه خود را تغییر داده و اعلان‌ها را دریافت کنید.", + "Please check your email and click on the link it contains. Once this is done, click continue.": "لطفاً ایمیل خود را بررسی کرده و روی لینکی که برایتان ارسال شده، کلیک کنید. پس از انجام این کار، روی ادامه کلیک کنید.", + "Verification Pending": "در انتظار تائید", + "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "پاک کردن فضای ذخیره‌سازی مرورگر ممکن است این مشکل را برطرف کند ، اما شما را از برنامه خارج کرده و باعث می‌شود هرگونه سابقه گفتگوی رمزشده غیرقابل خواندن باشد.", + "If you have previously used a more recent version of %(brand)s, your session may be incompatible with this version. Close this window and return to the more recent version.": "اگر در گذشته از نسخه جدیدتر %(brand)s استفاده کرده‌اید ، نشست شما ممکن است با این نسخه ناسازگار باشد. این پنجره را بسته و به نسخه جدیدتر برگردید.", + "We encountered an error trying to restore your previous session.": "هنگام تلاش برای بازیابی نشست قبلی شما، با خطایی روبرو شدیم.", + "Refresh": "رفرش", + "Failed to add the following rooms to the summary of %(groupId)s:": "افزدون اتاق‌های زیر به خلاصه %(groupId)s انجام نشد:", + "You most likely do not want to reset your event index store": "به احتمال زیاد نمی‌خواهید مخزن فهرست رویدادهای خود را حذف کنید", + "Failed to remove the room from the summary of %(groupId)s": "اتاق از خلاصه %(groupId)s حذف نشد", + "Use your preferred Matrix homeserver if you have one, or host your own.": "از یک سرور مبتنی بر پروتکل ماتریکس که ترجیح می‌دهید استفاده کرده، و یا از سرور شخصی خودتان استفاده کنید.", + "The room '%(roomName)s' could not be removed from the summary.": "اتاق «%(roomName)s» از خلاصه حذف نمی شود.", + "Failed to add the following users to the summary of %(groupId)s:": "افزودن کاربران زیر به خلاصه %(groupId)s انجام نشد:", + "We call the places where you can host your account ‘homeservers’.": "ما به زیرساختی که شما می‌توانید بر روی آن حساب کاربری ایجاد کنید، \"سرور\" می‌گوییم.", + "Failed to remove a user from the summary of %(groupId)s": "حذف کاربر از خلاصه %(groupId)s انجام نشد", + "The user '%(displayName)s' could not be removed from the summary.": "کاربر «%(displayName)s» را نمی توان از خلاصه حذف کرد.", + "Leave %(groupName)s?": "%(groupName)s را ترک می‌کنید؟", + "Want more than a community? Get your own server": "بیش از یک اجتماع می خواهید؟سرور خود را دریافت کنید", + "Matrix.org is the biggest public homeserver in the world, so it’s a good place for many.": "Matrix.org بزرگترین سرور عمومی در جهان است ، بنابراین مکان خوبی برای بسیاری از افراد جهت برقراری ارتباط به شمار می‌رود.", + "Changes made to your community name and avatar might not be seen by other users for up to 30 minutes.": "تغییراتی که در نام و آواتار در اجتماع شما ایجاد شده است ممکن است توسط کاربران دیگر حداکثر تا ۳۰ دقیقه دیده شود.", + "Recent changes that have not yet been received": "تغییرات اخیری که هنوز دریافت نشده‌اند", + "The server is not configured to indicate what the problem is (CORS).": "سرور طوری پیکربندی نشده تا نشان دهد مشکل چیست (CORS).", + "%(inviter)s has invited you to join this community": "%(inviter)s از شما برای پیوستن به این اجتماع دعوت کرده است", + "A connection error occurred while trying to contact the server.": "هنگام تلاش برای اتصال به سرور خطایی رخ داده است.", + "Your community hasn't got a Long Description, a HTML page to show to community members.
    Click here to open settings and give it one!": "اجتماع شما دارای توصیف طولانی (صفحه HTML برای نشان دادن به اعضای انجمن) نیست.
    برای باز کردن تنظیمات اینجا کلیک کنید و یک توصیف به آن اضافه کنید!", + "Your area is experiencing difficulties connecting to the internet.": "منطقه شما در اتصال به اینترنت با مشکل روبرو است.", + "A browser extension is preventing the request.": "پلاگینی در مرورگر مانع از ارسال درخواست می‌گردد.", + "Your firewall or anti-virus is blocking the request.": "دیوار آتش یا آنتی‌ویروس شما مانع از ارسال درخواست می‌شود.", + "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "برای ادامه استفاده از سرور %(homeserverDomain)s باید شرایط و ضوابط ما را بررسی کرده و موافقت کنید.", + "The server (%(serverName)s) took too long to respond.": "زمان پاسخگویی سرور (%(serverName)s) بسیار طولانی شده‌است.", + "Data from an older version of %(brand)s has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "داده هایی از نسخه قدیمی %(brand)s شناسایی شده است. این امر باعث اختلال در رمزنگاری سرتاسر در نسخه قدیمی شده است. پیام های رمزگذاری شده سرتاسر که اخیراً رد و بدل شده اند ممکن است با استفاده از نسخه قدیمی رمزگشایی نشوند. همچنین ممکن است پیام های رد و بدل شده با این نسخه با مشکل مواجه شود. اگر مشکلی رخ داد، از سیستم خارج شوید و مجددا وارد شوید. برای حفظ سابقه پیام، کلیدهای خود را خروجی گرفته و دوباره وارد کنید.", + "Your server isn't responding to some of your requests. Below are some of the most likely reasons.": "سرور شما به برخی از درخواست‌ها پاسخ نمی‌دهد. در ادامه برخی از دلایل محتمل آن ذکر شده است.", + "You'll upgrade this room from to .": "این اتاق را از به ارتقا خواهید داد.", + "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "به‌روزرسانی اتاق اقدامی پیشرفته بوده و معمولاً در صورتی توصیه می‌شود که اتاق به دلیل اشکالات، فقدان قابلیت‌ها یا آسیب پذیری‌های امنیتی، پایدار و قابل استفاده نباشد.", + "Did you know: you can use communities to filter your %(brand)s experience!": "آیا می دانید: برای فیلتر کردن تجربیات خود در %(brand)s می توانید از اجتماع‌ها استفاده کنید!", + "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)s خود مشکلی دارید، لطفاً اشکال را گزارش کنید.", + "To set up a filter, drag a community avatar over to the filter panel on the far left hand side of the screen. You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.": "برای تنظیم فیلتر، آواتار یک اجتماع را به صفحه فیلتر در سمت چپ صفحه بکشید. همواره می‌توانید با کلیک برر روی آواتار در صفحه فیلتر، فقط اتاق ها و افراد مرتبط با آن انجمن را مشاهده کنید.", + "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)s خود مشکلی دارید ، لطفاً یک اشکال گزارش دهید.", + "Put a link back to the old room at the start of the new room so people can see old messages": "در ابتدای اتاق جدید پیوندی به اتاق قدیمی قرار دهید تا افراد بتوانند پیام‌های موجود در اتاق قدیمی را ببینند", + "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "از گفتگوی کاربران در نسخه قدیمی اتاق جلوگیری کرده و با ارسال پیامی به کاربران توصیه کنید به اتاق جدید منتقل شوند", + "Update any local room aliases to point to the new room": "برای اشاره به اتاق جدید، نام‌های مستعار (aliases) اتاق محلی را به‌روز کنید", + "Create a new room with the same name, description and avatar": "یک اتاق جدید با همان نام ، توضیحات و نمایه ایجاد کنید", + "Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:": "ارتقاء این اتاق نیازمند بستن نسخه‌ی فعلی و ساختن درجای یک اتاق جدید است. برای داشتن بهترین تجربه‌ی کاربری ممکن، ما:", + "The room upgrade could not be completed": "متاسفانه فرآیند ارتقاء اتاق به پایان نرسید", + "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.": "گزارش این پیام شناسه‌ی منحصر به فرد رخداد آن را برای مدیر سرور ارسال می‌کند. اگر پیام‌های این اتاق رمزشده باشند، مدیر سرور شما امکان خواندن متن آن پیام یا مشاهده‌ی عکس یا فایل‌های دیگر را نخواهد داشت.", + "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.": "حواستان را جمع کنید، اگر ایمیلی اضافه نکرده و گذرواژه‌ی خود را فراموش کنید ، ممکن است دسترسی به حساب کاربری خود را برای همیشه از دست دهید.", + "Doesn't look like a valid email address": "به نظر نمی‌رسد یک آدرس ایمیل معتبر باشد", + "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s نتوانست لیست پروتکل را از سرور میزبان دریافت کند. ممکن است سرور برای پشتیبانی از شبکه های شخص ثالث خیلی قدیمی باشد.", + "If they don't match, the security of your communication may be compromised.": "اگر آنها مطابقت نداشته‌باشند ، ممکن است امنیت ارتباطات شما به خطر افتاده باشد.", + "If you didn’t sign in to this session, your account may be compromised.": "اگر وارد این نشست نشده‌اید ، ممکن است حساب کاربری شما به خطر افتاد باشد.", + "%(brand)s failed to get the public room list.": "%(brand)s نتوانست لیست اتاق‌های عمومی را دریافت کند.", + "Use this session to verify your new one, granting it access to encrypted messages:": "از این نشست برای تائید نشست‌های جدید خود و اعطای دسترسی به پیام‌های رمزشده استفاده کنید:", + "Delete the room address %(alias)s and remove %(name)s from the directory?": "اتاق با آدرس %(alias)s حذف شده و نام %(name)s از فهرست اتاق‌ها نیز پاک شود؟", + "We recommend you change your password and Security Key in Settings immediately": "ما به شما توصیه می‌کنیم بلافاصله گذرواژه و کلید امنیتی خود را در تنظیمات تغییر دهید", + "The internet connection either session is using": "اتصال اینترنت و یا نشست در حال استفاده است", + "%(brand)s does not know how to join a room on this network": "%(brand)s نمی‌تواند به اتاقی در این شبکه بپیوندد", + "Data on this screen is shared with %(widgetDomain)s": "داده‌های این صفحه با %(widgetDomain)s به اشتراک گذاشته می‌شود", + "Your homeserver doesn't seem to support this feature.": "به نظر نمی‌رسد که سرور شما از این قابلیت پشتیبانی کند.", + "Unable to look up room ID from server": "جستجوی شناسه اتاق از سرور انجام نشد", + "Not currently indexing messages for any room.": "در حال حاضر ایندکس پیام ها برای هیچ اتاقی انجام نمی‌شود.", + "Session ID": "شناسه‌ی نشست", + "Confirm this user's session by comparing the following with their User Settings:": "این نشست کاربر را از طریق مقایسه‌ی این با تنظیمات کاربری تائيد کنید:", + "Confirm by comparing the following with the User Settings in your other session:": "از طریق مقایسه‌ی این با تنظیمات کاربری در نشست‌های دیگرتان، تائيد کنید:", + "Are you sure you want to sign out?": "آیا مطمئن هستید که می خواهید از برنامه خارج شوید؟", + "You'll lose access to your encrypted messages": "دسترسی به پیام‌های رمزشده‌ی خود را از دست خواهید داد", + "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "پیام‌های رمزشده با رمزنگاری سرتاسر ایمن می‌شوند. فقط شما و طرف گیرنده(ها) کلیدهای خواندن این پیام ها را در اختیار دارید.", + "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "هم‌اکنون %(brand)s از طریق بارگیری و نمایش اطلاعات کاربران تنها در زمان‌هایی که نیاز است، حدود ۳ تا ۵ مرتبه حافظه‌ی کمتری استفاده می‌کند. لطفا تا همگام‌سازی با سرور منتظر بمانید!", + "If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "اگر نسخه دیگری از %(brand)s هنوز در تب‌های دیگر باز است، لطفاً آن را ببندید زیرا استفاده از %(brand)s با قابلیت بارگیری تکه‌تکه‌ی فعال روی یکی و غیرفعال روی دیگری، باعث ایجاد مشکل می شود.", + "You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.": "شما از %(brand)s بر روی %(host)s با قابلیت بارگیری اعضا به شکل تکه‌تکه استفاده می‌کنید. در این نسخه قابلیت بارگیری تکه‌تکه غیرفعال است. از آن‌جایی که حافظه‌ی کش مورد استفاده برای این دو پیکربندی با هم سازگار نیست، %(brand)s نیاز به همگام‌سازی مجدد حساب کاربری شما دارد.", + "%(brand)s encountered an error during upload of:": "%(brand)s در حین بارگذاری این دچار مشکل شد:", + "Transfer": "منتقل کردن", + "Invited people will be able to read old messages.": "افراد دعوت‌شده خواهند توانست پیام‌های قدیمی را بخوانند.", + "Invite someone using their name, username (like ) or share this room.": "با استفاده از نام یا نام کاربری (مانند ) از افراد دعوت کرده و یا این اتاق را به اشتراک بگذارید.", + "Invite someone using their name, email address, username (like ) or share this room.": "با استفاده از نام، آدرس ایمیل، نام کاربری (مانند ) از فردی دعوت کرده و یا این اتاق را به اشتراک بگذارید.", + "Invite someone using their name, email address, username (like ) or share this space.": "با استفاده از نام ، آدرس ایمیل ، نام کاربری (مانند ) کسی را دعوت کرده یا این فضای کاری را به اشتراک بگذارید.", + "Invite someone using their name, username (like ) or share this space.": "با استفاده از نام یا نام کاربری (مانند ) از افراد دعوت کرده و یا این فضای کاری را به اشتراک بگذارید.", + "Go": "برو", + "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click here": "این کار آنها را به %(communityName)s دعوت نمی‌کند. برای دعوت افراد به %(communityName)s،اینجا کلیک کنید", + "Start a conversation with someone using their name or username (like ).": "با استفاده از نام یا نام کاربری (مانند )، گفتگوی جدیدی را با دیگران شروع کنید.", + "Start a conversation with someone using their name, email address or username (like ).": "با استفاده از نام، آدرس ایمیل و یا نام کاربری (مانند )، یک گفتگوی جدید را شروع کنید.", + "May include members not in %(communityName)s": "ممکن شامل اعضایی که در %(communityName)s نیستند نیز شود" } diff --git a/src/i18n/strings/fi.json b/src/i18n/strings/fi.json index 5452176cd9..23140846b3 100644 --- a/src/i18n/strings/fi.json +++ b/src/i18n/strings/fi.json @@ -2950,5 +2950,58 @@ "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Uusi kirjautuminen tilillesi: %(name)s (%(deviceID)s) osoitteesta %(ip)s", "This homeserver has been blocked by its administrator.": "Tämä kotipalvelin on ylläpitäjänsä estämä.", "You're already in a call with this person.": "Olet jo puhelussa tämän henkilön kanssa.", - "Already in call": "Olet jo puhelussa" + "Already in call": "Olet jo puhelussa", + "Please choose a strong password": "Valitse vahva salasana", + "You can add more later too, including already existing ones.": "Voit lisätä niitä myöhemmin, mukaan lukien olemassa olevia.", + "Let's create a room for each of them.": "Tehdään huone jokaiselle.", + "What do you want to organise?": "Mitä haluat järjestää?", + "Random": "Satunnainen", + "Search names and descriptions": "Etsi nimistä ja kuvauksista", + "Failed to remove some rooms. Try again later": "Joitakin huoneita ei voitu poistaa. Yritä myöhemmin uudelleen.", + "Select a room below first": "Valitse ensin huone alta", + "You can select all or individual messages to retry or delete": "Voit valita kaikki tai yksittäisiä viestejä yritettäväksi uudelleen tai poistettavaksi", + "Sending": "Lähetetään", + "Retry all": "Yritä kaikkia uudelleen", + "Delete all": "Poista kaikki", + "Some of your messages have not been sent": "Osaa viesteistäsi ei ole lähetetty", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Olet ainoa henkilö täällä. Jos lähdet, kukaan ei voi liittyä tulevaisuudessa, et myöskään sinä.", + "Beta": "Beeta", + "Tap for more info": "Lisää tietoa napauttamalla", + "The server is not configured to indicate what the problem is (CORS).": "Palvelinta ei ole säädetty ilmoittamaan, mikä ongelma on kyseessä (CORS).", + "Invited people will be able to read old messages.": "Kutsutut ihmiset voivat lukea vanhoja viestejä.", + "We couldn't create your DM.": "Yksityisviestiä ei voitu luoda.", + "Thank you for your feedback, we really appreciate it.": "Kiitos palautteesta, arvostamme sitä.", + "Beta feedback": "Palautetta beetaversiosta", + "Want to add a new room instead?": "Haluatko kuitenkin lisätä uuden huoneen?", + "Add existing rooms": "Lisää olemassa olevia huoneita", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Lisätään huonetta...", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Lisätään huoneita... (%(progress)s out of %(count)s)", + "Not all selected were added": "Kaikkia valittuja ei lisätty", + "You are not allowed to view this server's rooms list": "Sinulla ei ole oikeuksia nähdä tämän palvelimen huoneluetteloa", + "View message": "Näytä viesti", + "%(count)s people you know have already joined|one": "%(count)s tuntemasi henkilö on jo liittynyt", + "%(count)s people you know have already joined|other": "%(count)s tuntemaasi ihmistä on jo liittynyt", + "View all %(count)s members|one": "Näytä yksi jäsen", + "View all %(count)s members|other": "Näytä kaikki %(count)s jäsentä", + "Add reaction": "Lisää reaktio", + "Error processing voice message": "Virhe ääniviestin käsittelyssä", + "Delete recording": "Poista äänitys", + "Stop the recording": "Lopeta äänitys", + "Record a voice message": "Äänitä viesti", + "We were unable to access your microphone. Please check your browser settings and try again.": "Mikrofoniasi ei voitu käyttää. Tarkista selaimesi asetukset ja yritä uudelleen.", + "We didn't find a microphone on your device. Please check your settings and try again.": "Laitteestasi ei löytynyt mikrofonia. Tarkista asetuksesi ja yritä uudelleen.", + "No microphone found": "Mikrofonia ei löytynyt", + "Unable to access your microphone": "Mikrofonia ei voi käyttää", + "Quick actions": "Pikatoiminnot", + "%(seconds)ss left": "%(seconds)s s jäljellä", + "Failed to send": "Lähettäminen epäonnistui", + "You have no ignored users.": "Et ole sivuuttanut käyttäjiä.", + "Warn before quitting": "Varoita ennen lopettamista", + "Manage & explore rooms": "Hallitse ja selaa huoneita", + "Connecting": "Yhdistetään", + "unknown person": "tuntematon henkilö", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Salli vertaisyhteydet 1:1-puheluille (jos otat tämän käyttöön, toinen osapuoli saattaa nähdä IP-osoitteesi)", + "Send and receive voice messages": "Lähetä ja vastaanota ääniviestejä", + "Show options to enable 'Do not disturb' mode": "Näytä asetukset Älä häiritse -tilan ottamiseksi käyttöön", + "%(deviceId)s from %(ip)s": "%(deviceId)s osoitteesta %(ip)s" } diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index fcc5ec9afe..16373f0853 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -936,7 +936,7 @@ "Failed to load group members": "Échec du chargement des membres du groupe", "Failed to invite users to the room:": "Échec de l’invitation d'utilisateurs dans le salon :", "There was an error joining the room": "Une erreur est survenue en rejoignant le salon", - "You do not have permission to invite people to this room.": "Vous n’avez pas la permission d’envoyer des invitations dans ce salon.", + "You do not have permission to invite people to this room.": "Vous n’avez pas la permission d’inviter des personnes dans ce salon.", "User %(user_id)s does not exist": "L’utilisateur %(user_id)s n’existe pas", "Unknown server error": "Erreur de serveur inconnue", "Show a reminder to enable Secure Message Recovery in encrypted rooms": "Afficher un rappel pour activer la récupération de messages sécurisée dans les salons chiffrés", @@ -1873,7 +1873,7 @@ "This user has not verified all of their sessions.": "Cet utilisateur n’a pas vérifié toutes ses sessions.", "You have verified this user. This user has verified all of their sessions.": "Vous avez vérifié cet utilisateur. Cet utilisateur a vérifié toutes ses sessions.", "Someone is using an unknown session": "Quelqu’un utilise une session inconnue", - "Mod": "Modo", + "Mod": "Modérateur", "Your key share request has been sent - please check your other sessions for key share requests.": "Votre demande de partage de clé a été envoyée − vérifiez les demandes de partage de clé sur vos autres sessions.", "Key share requests are sent to your other sessions automatically. If you rejected or dismissed the key share request on your other sessions, click here to request the keys for this session again.": "Les demandes de partage de clé sont envoyées à vos autres sessions automatiquement. Si vous avez rejeté ou ignoré la demande de partage de clé sur vos autres sessions, cliquez ici pour redemander les clés pour cette session.", "If your other sessions do not have the key for this message you will not be able to decrypt them.": "Si vos autres sessions n’ont pas la clé pour ce message vous ne pourrez pas le déchiffrer.", @@ -2842,7 +2842,7 @@ "Change the topic of your active room": "Changer le sujet dans le salon actuel", "Change the topic of this room": "Changer le sujet de ce salon", "Send stickers into this room": "Envoyer des autocollants dans ce salon", - "Remain on your screen when viewing another room, when running": "Reste sur votre écran quand vous regardez un autre salon lors de l’appel", + "Remain on your screen when viewing another room, when running": "Reste sur votre écran lors de l’appel quand vous regardez un autre salon", "Takes the call in the current room off hold": "Reprend l’appel en attente dans ce salon", "Places the call in the current room on hold": "Met l’appel dans ce salon en attente", "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "Ajoute (╯°□°)╯︵ ┻━┻ en préfixe du message", @@ -2861,14 +2861,14 @@ "Send videos as you in this room": "Envoie des vidéos sous votre nom dans ce salon", "See images posted to this room": "Voir les images publiées dans ce salon", "See images posted to your active room": "Voir les images publiées dans votre salon actif", - "See messages posted to your active room": "Voir les messages publiés dans votre salon actif", + "See messages posted to your active room": "Voir les messages envoyés dans le salon actuel", "See messages posted to this room": "Voir les messages publiés dans ce salon", "Send messages as you in your active room": "Envoie des messages sous votre nom dans votre salon actif", "Send messages as you in this room": "Envoie des messages sous votre nom dans ce salon", "The %(capability)s capability": "La capacité %(capability)s", "See %(eventType)s events posted to your active room": "Voir les événements %(eventType)s publiés dans votre salon actuel", "Send %(eventType)s events as you in your active room": "Envoie des événements %(eventType)s sous votre nom dans votre salon actuel", - "See %(eventType)s events posted to this room": "Voir les événements %(eventType)s publiés dans ce salon", + "See %(eventType)s events posted to this room": "Voir les événements %(eventType)s envoyés dans ce salon", "Send %(eventType)s events as you in this room": "Envoie des événements %(eventType)s sous votre nom dans ce salon", "Send stickers to your active room as you": "Envoie des autocollants sous votre nom dans le salon actuel", "Continue with %(ssoButtons)s": "Continuer avec %(ssoButtons)s", @@ -2930,7 +2930,7 @@ "Don't miss a reply": "Ne ratez pas une réponse", "See %(msgtype)s messages posted to your active room": "Voir les messages de type %(msgtype)s publiés dans le salon actuel", "See %(msgtype)s messages posted to this room": "Voir les messages de type %(msgtype)s publiés dans ce salon", - "Send %(msgtype)s messages as you in this room": "Envoie des messages de type%(msgtype)s sous votre nom dans ce salon", + "Send %(msgtype)s messages as you in this room": "Envoie les messages de type %(msgtype)s sous votre nom dans ce salon", "Send %(msgtype)s messages as you in your active room": "Envoie des messages de type %(msgtype)s sous votre nom dans votre salon actif", "See general files posted to your active room": "Voir les fichiers postés dans votre salon actuel", "See general files posted to this room": "Voir les fichiers postés dans ce salon", @@ -3024,7 +3024,7 @@ "Use Command + F to search": "Utilisez Commande + F pour rechercher", "Show line numbers in code blocks": "Afficher les numéros de ligne dans les blocs de code", "Expand code blocks by default": "Développer les blocs de code par défaut", - "Show stickers button": "Afficher le bouton autocollants", + "Show stickers button": "Afficher le bouton des autocollants", "Use app": "Utiliser l’application", "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web est expérimental sur téléphone. Pour une meilleure expérience et bénéficier des dernières fonctionnalités, utilisez notre application native gratuite.", "Use app for a better experience": "Utilisez une application pour une meilleure expérience", @@ -3034,7 +3034,7 @@ "Send text messages as you in this room": "Envoyez des messages textuels sous votre nom dans ce salon", "See when the name changes in your active room": "Suivre les changements de nom dans le salon actif", "Change which room, message, or user you're viewing": "Changer le salon, message, ou la personne que vous visualisez", - "Change which room you're viewing": "Changer le salon que vous visualisez", + "Change which room you're viewing": "Changer le salon que vous êtes en train de lire", "Remain on your screen while running": "Reste sur votre écran pendant l’exécution", "%(senderName)s has updated the widget layout": "%(senderName)s a mis à jour la disposition du widget", "Converts the DM to a room": "Transforme la conversation privée en salon", @@ -3043,7 +3043,7 @@ "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Votre serveur d’accueil n’est pas accessible, nous n’avons pas pu vous connecter. Merci de réessayer. Si cela persiste, merci de contacter l’administrateur de votre serveur d’accueil.", "Try again": "Réessayez", "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Nous avons demandé à votre navigateur de mémoriser votre serveur d’accueil, mais il semble l’avoir oublié. Rendez-vous à la page de connexion et réessayez.", - "We couldn't log you in": "Impossible de vous déconnecter", + "We couldn't log you in": "Nous n’avons pas pu vous connecter", "Upgrade to %(hostSignupBrand)s": "Mettre à jour vers %(hostSignupBrand)s", "Edit Values": "Modifier les valeurs", "Values at explicit levels in this room:": "Valeurs pour les rangs explicites de ce salon :", @@ -3071,7 +3071,7 @@ "Original event source": "Événement source original", "Decrypted event source": "Événement source déchiffré", "We'll create rooms for each of them. You can add existing rooms after setup.": "Nous allons créer un salon pour chacun d’entre eux. Vous pourrez ajouter des salons après l’initialisation.", - "What projects are you working on?": "Sur quels projets travaillez vous ?", + "What projects are you working on?": "Sur quels projets travaillez-vous ?", "We'll create rooms for each topic.": "Nous allons créer un salon pour chaque sujet.", "What are some things you want to discuss?": "De quoi voulez vous discuter ?", "Inviting...": "Invitation…", @@ -3083,7 +3083,7 @@ "A private space just for you": "Un espace privé seulement pour vous", "Just Me": "Seulement moi", "Ensure the right people have access to the space.": "Vérifiez que les bonnes personnes ont accès à cet espace.", - "Who are you working with?": "Avec qui travaillez vous ?", + "Who are you working with?": "Avec qui travaillez-vous ?", "Finish": "Finir", "At the moment only you can see it.": "Pour l’instant vous seul pouvez le voir.", "Creating rooms...": "Création des salons…", @@ -3111,8 +3111,8 @@ "No permissions": "Aucune permission", "Remove from Space": "Supprimer de l’espace", "Undo": "Annuler", - "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Votre message n’a pas été envoyé car ce serveur d’accueil a été banni par son administrateur. Merci de contacter votre administrateur de service pour poursuivre l’usage de ce service.", - "Are you sure you want to leave the space '%(spaceName)s'?": "Êtes vous sûr de vouloir quitter l’espace « %(spaceName)s » ?", + "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Votre message n’a pas été envoyé car ce serveur d’accueil a été bloqué par son administrateur. Merci de contacter votre administrateur de service pour continuer à utiliser le service.", + "Are you sure you want to leave the space '%(spaceName)s'?": "Êtes-vous sûr de vouloir quitter l’espace « %(spaceName)s » ?", "This space is not public. You will not be able to rejoin without an invite.": "Cet espace n’est pas public. Vous ne pourrez pas le rejoindre sans invitation.", "Start audio stream": "Démarrer une diffusion audio", "Failed to start livestream": "Échec lors du démarrage de la diffusion en direct", @@ -3175,8 +3175,8 @@ "Delete": "Supprimer", "Jump to the bottom of the timeline when you send a message": "Sauter en bas du fil de discussion lorsque vous envoyez un message", "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Prototype d’espaces. Incompatible avec les communautés, les communautés v2 et les étiquettes personnalisées. Nécessite un serveur d’accueil compatible pour certaines fonctionnalités.", - "This homeserver has been blocked by it's administrator.": "Ce serveur d’accueil a été banni par ses administrateurs.", - "This homeserver has been blocked by its administrator.": "Ce serveur d’accueil a été banni par ses administrateurs.", + "This homeserver has been blocked by it's administrator.": "Ce serveur d’accueil a été bloqué par son administrateur.", + "This homeserver has been blocked by its administrator.": "Ce serveur d’accueil a été bloqué par son administrateur.", "You're already in a call with this person.": "Vous êtes déjà en cours d’appel avec cette personne.", "Already in call": "Déjà en cours d’appel", "Space selection": "Sélection d’un espace", @@ -3188,7 +3188,7 @@ "This room is suggested as a good one to join": "Ce salon recommandé peut être intéressant à rejoindre", "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Vérifiez cette connexion pour accéder à vos messages chiffrés et prouver aux autres qu’il s’agit bien de vous.", "Verify with another session": "Vérifier avec une autre session", - "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Nous allons créer un salon pour chaque. Vous pourrez en ajouter plus tard, y compris certains déjà existant.", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Nous allons créer un salon pour chacun d’entre eux. Vous pourrez aussi en ajouter plus tard, y compris certains déjà existant.", "Let's create a room for each of them. You can add more later too, including already existing ones.": "Créons un salon pour chacun d’entre eux. Vous pourrez en ajouter plus tard, y compris certains déjà existant.", "Make sure the right people have access. You can invite more later.": "Assurez-vous que les accès sont accordés aux bonnes personnes. Vous pourrez en inviter d’autres plus tard.", "A private space to organise your rooms": "Un espace privé pour organiser vos salons", @@ -3232,7 +3232,7 @@ "Please choose a strong password": "Merci de choisir un mot de passe fort", "You can add more later too, including already existing ones.": "Vous pourrez en ajouter plus tard, y compris certains déjà existant.", "Let's create a room for each of them.": "Créons un salon pour chacun d’entre eux.", - "What are some things you want to discuss in %(spaceName)s?": "De quoi voulez vous discuter dans %(spaceName)s ?", + "What are some things you want to discuss in %(spaceName)s?": "De quoi voulez-vous discuter dans %(spaceName)s ?", "Verification requested": "Vérification requise", "Avatar": "Avatar", "Verify other login": "Vérifier l’autre connexion", @@ -3262,5 +3262,118 @@ "Send and receive voice messages (in development)": "Envoyez et recevez des messages vocaux (en développement)", "%(deviceId)s from %(ip)s": "%(deviceId)s depuis %(ip)s", "Review to ensure your account is safe": "Vérifiez pour assurer la sécurité de votre compte", - "Sends the given message as a spoiler": "Envoie le message flouté" + "Sends the given message as a spoiler": "Envoie le message flouté", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Vous êtes la seule personne ici. Si vous partez, plus personne ne pourra rejoindre cette conversation, y compris vous.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Si vous réinitialisez tout, vous allez repartir sans session et utilisateur de confiance. Vous pourriez ne pas voir certains messages passés.", + "Only do this if you have no other device to complete verification with.": "Poursuivez seulement si vous n’avez aucun autre appareil avec lequel procéder à la vérification.", + "Reset everything": "Tout réinitialiser", + "Forgotten or lost all recovery methods? Reset all": "Vous avez perdu ou oublié tous vos moyens de récupération ? Tout réinitialiser", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Si vous le faites, notez qu’aucun de vos messages ne sera supprimé, mais la recherche pourrait être dégradée pendant quelques instants, le temps de recréer l’index", + "View message": "Afficher le message", + "Zoom in": "Zoomer", + "Zoom out": "Dé-zoomer", + "%(seconds)ss left": "%(seconds)s secondes restantes", + "Change server ACLs": "Modifier les ACL du serveur", + "Show options to enable 'Do not disturb' mode": "Afficher une option pour activer le mode « Ne pas déranger »", + "You can select all or individual messages to retry or delete": "Vous pouvez choisir de renvoyer ou supprimer tous les messages ou seulement certains", + "Sending": "Envoi", + "Retry all": "Tout renvoyer", + "Delete all": "Tout supprimer", + "Some of your messages have not been sent": "Certains de vos messages n’ont pas été envoyés", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s membres dont %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Dont %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Afficher le membre", + "View all %(count)s members|other": "Afficher les %(count)s membres", + "Failed to send": "Échec de l’envoi", + "Play": "Lecture", + "Pause": "Pause", + "Enter your Security Phrase a second time to confirm it.": "Saisissez à nouveau votre phrase secrète pour la confirmer.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Choisissez des salons ou conversations à ajouter. C’est un espace rien que pour vous, personne n’en sera informé. Vous pourrez en ajouter plus tard.", + "What do you want to organise?": "Que voulez-vous organiser ?", + "Filter all spaces": "Filtrer tous les espaces", + "Delete recording": "Supprimer l’enregistrement", + "Stop the recording": "Arrêter l’enregistrement", + "%(count)s results in all spaces|one": "%(count)s résultat dans tous les espaces", + "%(count)s results in all spaces|other": "%(count)s résultats dans tous les espaces", + "You have no ignored users.": "Vous n’avez ignoré personne.", + "Your access token gives full access to your account. Do not share it with anyone.": "Votre jeton d’accès donne un accès intégral à votre compte. Ne le partagez avec personne.", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Ceci est une fonctionnalité expérimentale. Pour l’instant, les nouveaux utilisateurs recevant une invitation devront l’ouvrir sur pour poursuivre.", + "To join %(spaceName)s, turn on the Spaces beta": "Pour rejoindre %(spaceName)s, activez les espaces en bêta", + "To view %(spaceName)s, turn on the Spaces beta": "Pour visualiser %(spaceName)s, activez les espaces en bêta", + "Select a room below first": "Sélectionnez un salon ci-dessous d’abord", + "Communities are changing to Spaces": "Les communautés deviennent des espaces", + "Join the beta": "Rejoindre la bêta", + "Leave the beta": "Quitter la bêta", + "Beta": "Bêta", + "Tap for more info": "Appuyez pour plus d’information", + "Spaces is a beta feature": "Les espaces sont une fonctionnalité en bêta", + "Want to add a new room instead?": "Voulez-vous plutôt ajouter un nouveau salon ?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Ajout du salon…", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Ajout des salons… (%(progress)s sur %(count)s)", + "Not all selected were added": "Toute la sélection n’a pas été ajoutée", + "You can add existing spaces to a space.": "Vous pouvez ajouter des espaces existants à un espace.", + "Feeling experimental?": "L’esprit aventurier ?", + "You are not allowed to view this server's rooms list": "Vous n’avez pas l’autorisation d’accéder à la liste des salons de ce serveur", + "Error processing voice message": "Erreur lors du traitement du message vocal", + "We didn't find a microphone on your device. Please check your settings and try again.": "Nous n’avons pas détecté de microphone sur votre appareil. Merci de vérifier vos paramètres et de réessayer.", + "No microphone found": "Aucun microphone détecté", + "We were unable to access your microphone. Please check your browser settings and try again.": "Nous n’avons pas pu accéder à votre microphone. Merci de vérifier les paramètres de votre navigateur et de réessayer.", + "Unable to access your microphone": "Impossible d’accéder à votre microphone", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "L’esprit aventurier ? Les fonctionnalités expérimentales vous permettent de tester les nouveautés et aider à les polir avant leur lancement. En apprendre plus.", + "Access Token": "Jeton d’accès", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Les espaces sont un nouveau moyen de grouper les salons et les personnes. Une invitation est nécessaire pour rejoindre un espace existant.", + "Please enter a name for the space": "Veuillez renseigner un nom pour l’espace", + "Connecting": "Connexion", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Autoriser le pair-à-pair (p2p) pour les appels individuels (si activé, votre correspondant pourra voir votre adresse IP)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Bêta disponible pour l’application web, de bureau et Android. Certains fonctionnalités pourraient ne pas être disponibles sur votre serveur d’accueil.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Vous pouvez quitter la bêta n’importe quand à partir des paramètres, ou en appuyant sur le badge bêta comme celui ci-dessus.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s va être redémarré avec les espaces activés. Les communautés et les étiquettes personnalisées seront cachés.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Bêta disponible pour l’application web, de bureau et Android. Merci d’essayer la bêta.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s va être redémarré avec les espaces désactivés. Les communautés et les étiquettes personnalisées seront de nouveau visibles.", + "Spaces are a new way to group rooms and people.": "Les espaces sont un nouveau moyen de regrouper les salons et les personnes.", + "Message search initialisation failed": "Échec de l’initialisation de la recherche de message", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Vos commentaires aideront à améliorer les espaces. N’hésitez pas à entrer dans les détails.", + "%(featureName)s beta feedback": "Commentaires sur la bêta de %(featureName)s", + "Thank you for your feedback, we really appreciate it.": "Merci pour vos commentaires, nous en sommes vraiment reconnaissants.", + "Beta feedback": "Commentaires sur la bêta", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Si vous quittez, %(brand)s sera rechargé avec les espaces désactivés. Les communautés et les étiquettes personnalisées seront à nouveau visibles.", + "Spaces are a beta feature.": "Les espaces sont une fonctionnalité en bêta.", + "Search names and descriptions": "Rechercher par nom et description", + "You may contact me if you have any follow up questions": "Vous pouvez me contacter si vous avez des questions par la suite", + "To leave the beta, visit your settings.": "Pour quitter la bêta, consultez les paramètres.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Votre plateforme et nom d’utilisateur seront consignés pour nous aider à tirer le maximum de vos retours.", + "Add reaction": "Ajouter une réaction", + "Send and receive voice messages": "Envoyer et recevoir des messages vocaux", + "See when people join, leave, or are invited to this room": "Voir quand une personne rejoint, quitte ou est invitée sur ce salon", + "Kick, ban, or invite people to this room, and make you leave": "Exclure, bannir ou inviter une personne dans ce salon et vous permettre de partir", + "Space Autocomplete": "Autocomplétion d’espace", + "Go to my space": "Aller à mon espace", + "sends space invaders": "Envoie les Space Invaders", + "Sends the given message with a space themed effect": "Envoyer le message avec un effet lié au thème de l’espace", + "See when people join, leave, or are invited to your active room": "Afficher quand des personnes rejoignent, partent, ou sont invités dans votre salon actif", + "Kick, ban, or invite people to your active room, and make you leave": "Expulser, bannir ou inviter des personnes dans votre salon actif et en partir", + "Currently joining %(count)s rooms|one": "Vous êtes en train de rejoindre %(count)s salon", + "Currently joining %(count)s rooms|other": "Vous êtes en train de rejoindre %(count)s salons", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Essayez d'autres mots ou vérifiez les fautes de frappe. Certains salons peuvent ne pas être visibles car ils sont privés et vous devez être invité pour les rejoindre.", + "No results for \"%(query)s\"": "Aucun résultat pour « %(query)s »", + "The user you called is busy.": "L’utilisateur que vous avez appelé est indisponible.", + "User Busy": "Utilisateur indisponible", + "We're working on this as part of the beta, but just want to let you know.": "Nous travaillons sur cette partie en bêta, mais nous voulions vous en faire part.", + "Teammates might not be able to view or join any private rooms you make.": "Votre équipe pourrait ne pas voir ou rejoindre les salons privés que vous créez.", + "Or send invite link": "Ou envoyer le lien d’invitation", + "If you can't see who you’re looking for, send them your invite link below.": "Si vous ne trouvez pas la personne que vous cherchez, envoyez leur le lien d’invitation ci-dessous.", + "Some suggestions may be hidden for privacy.": "Certaines suggestions pourraient être masquées pour votre confidentialité.", + "Search for rooms or people": "Rechercher des salons ou des gens", + "Forward message": "Transférer le message", + "Open link": "Ouvrir le lien", + "Sent": "Envoyé", + "You don't have permission to do this": "Vous n’avez pas les permissions nécessaires pour effectuer cette action", + "Error - Mixed content": "Erreur - Contenu mixte", + "Error loading Widget": "Erreur lors du chargement du widget", + "Pinned messages": "Messages épinglés", + "If you have permissions, open the menu on any message and select Pin to stick them here.": "Si vous avez les permissions, ouvrez le menu de n’importe quel message et sélectionnez Épingler pour les afficher ici.", + "Nothing pinned, yet": "Rien d’épinglé, pour l’instant", + "End-to-end encryption isn't enabled": "Le chiffrement de bout en bout n’est pas activé", + "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. Enable encryption in settings.": "Vous messages privés sont normalement chiffrés, mais ce salon ne l’est pas. Ceci est souvent du à un appareil ou une méthode qui ne le prend pas en charge, comme les invitations par e-mail. Activer le chiffrement dans les paramètres." } diff --git a/src/i18n/strings/gl.json b/src/i18n/strings/gl.json index f6ebce684f..b880c5b548 100644 --- a/src/i18n/strings/gl.json +++ b/src/i18n/strings/gl.json @@ -3285,5 +3285,118 @@ "What are some things you want to discuss in %(spaceName)s?": "Sobre que temas queres conversar en %(spaceName)s?", "Let's create a room for each of them.": "Crea unha sala para cada un deles.", "You can add more later too, including already existing ones.": "Podes engadir máis posteriormente, incluíndo os xa existentes.", - "Use another login": "Usar outra conexión" + "Use another login": "Usar outra conexión", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Es a única persoa aquí. Se saes, ninguén poderá unirse no futuro, incluíndote a ti.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Se restableces todo, volverás a comezar sen sesións verificadas, usuarias de confianza, e poderías non poder ver as mensaxes anteriores.", + "Only do this if you have no other device to complete verification with.": "Fai isto únicamente se non tes outro dispositivo co que completar a verificación.", + "Reset everything": "Restablecer todo", + "Forgotten or lost all recovery methods? Reset all": "Perdidos ou esquecidos tódolos métodos de recuperación? Restabléceos", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Se o fas, ten en conta que non se borrará ningunha das túas mensaxes, mais a experiencia de busca podería degradarse durante uns momentos ata que se recrea o índice", + "View message": "Ver mensaxe", + "Zoom in": "Achegar", + "Zoom out": "Alonxar", + "%(seconds)ss left": "%(seconds)ss restantes", + "Change server ACLs": "Cambiar ACLs do servidor", + "Show options to enable 'Do not disturb' mode": "Mostrar opcións para activar o modo 'Non molestar'", + "You can select all or individual messages to retry or delete": "Podes elexir todo ou mensaxes individuais para reintentar ou eliminar", + "Sending": "Enviando", + "Retry all": "Reintentar todo", + "Delete all": "Eliminar todo", + "Some of your messages have not been sent": "Algunha das túas mensaxes non se enviou", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s membros, incluíndo a %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Incluíndo a %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Ver 1 membro", + "View all %(count)s members|other": "Ver tódolos %(count)s membros", + "Failed to send": "Fallou o envío", + "Enter your Security Phrase a second time to confirm it.": "Escribe a túa Frase de Seguridade por segunda vez para confirmala.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Elixe salas ou conversas para engadilas. Este é un espazo para ti, ninguén será notificado. Podes engadir máis posteriormente.", + "What do you want to organise?": "Que queres organizar?", + "Filter all spaces": "Filtrar os espazos", + "Delete recording": "Eliminar a gravación", + "Stop the recording": "Deter a gravación", + "%(count)s results in all spaces|one": "%(count)s resultado en tódolos espazos", + "%(count)s results in all spaces|other": "%(count)s resultados en tódolos espazos", + "You have no ignored users.": "Non tes usuarias ignoradas.", + "Play": "Reproducir", + "Pause": "Deter", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Esta é unha característica experimental. Por agora as novas usuarias convidadas deberán abrir o convite en para poder unirse.", + "To join %(spaceName)s, turn on the Spaces beta": "Para unirte a %(spaceName)s, activa a beta de Espazos", + "To view %(spaceName)s, turn on the Spaces beta": "Para ver %(spaceName)s, cambia á beta de Espazos", + "Select a room below first": "Primeiro elixe embaixo unha sala", + "Communities are changing to Spaces": "Comunidades cambia a Espazos", + "Join the beta": "Unirse á beta", + "Leave the beta": "Saír da beta", + "Beta": "Beta", + "Tap for more info": "Toca para ter máis información", + "Spaces is a beta feature": "Espazos é unha característica en beta", + "Want to add a new room instead?": "Queres engadir unha nova sala?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Engadindo sala...", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Engadindo salas... (%(progress)s de %(count)s)", + "Not all selected were added": "Non se engadiron tódolos seleccionados", + "You can add existing spaces to a space.": "Podes engadir espazos existentes a un espazo.", + "Feeling experimental?": "Sínteste aventureira?", + "You are not allowed to view this server's rooms list": "Non tes permiso para ver a lista de salas deste servidor", + "Error processing voice message": "Erro ao procesar a mensaxe de voz", + "We didn't find a microphone on your device. Please check your settings and try again.": "Non atopamos ningún micrófono no teu dispositivo. Comproba os axustes e proba outra vez.", + "No microphone found": "Non atopamos ningún micrófono", + "We were unable to access your microphone. Please check your browser settings and try again.": "Non puidemos acceder ao teu micrófono. Comproba os axustes do navegador e proba outra vez.", + "Unable to access your microphone": "Non se puido acceder ao micrófono", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Gañas de experimentar? Labs é o mellor xeito para un acceso temperá e probar novas funcións e axudar a melloralas antes de ser publicadas. Coñece máis.", + "Your access token gives full access to your account. Do not share it with anyone.": "O teu token de acceso da acceso completo á túa conta. Non o compartas con ninguén.", + "Access Token": "Token de acceso", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Espazos é un novo xeito de agrupar salas e persoas. Precisas un convite para unirte a un espazo existente.", + "Please enter a name for the space": "Escribe un nome para o espazo", + "Connecting": "Conectando", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Permitir Peer-to-Peer en chamadas 1:1 (se activas isto a outra parte podería coñecer o teu enderezo IP)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta dispoñible para web, escritorio e Android. Algunhas características poderían non estar dispoñibles no teu servidor de inicio.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Podes saír da beta desde os axustes cando queiras ou tocando na insignia beta, como a superior.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s cargará con Espazos activado. Comunidades e etiquetas personais estarán agochadas.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta dispoñible para web, escritorio e Android. Grazas por probar a beta.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s volverá a cargar con Espazos desactivado. Comunidade e etiquetas personalizadas estarán visibles de volta.", + "Spaces are a new way to group rooms and people.": "Espazos é un novo xeito de agrupar salas e persoas.", + "Spaces are a beta feature.": "Espazos é unha ferramenta en beta.", + "Search names and descriptions": "Buscar nome e descricións", + "You may contact me if you have any follow up questions": "Podes contactar conmigo se tes algunha outra suxestión", + "To leave the beta, visit your settings.": "Para saír da beta, vai aos axustes.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "A túa plataforma e nome de usuaria serán notificados para axudarnos a utilizar a túa opinión do mellor xeito posible.", + "%(featureName)s beta feedback": "Opinión acerca de %(featureName)s beta", + "Thank you for your feedback, we really appreciate it.": "Grazas pola túa opinión, realmente apreciámola.", + "Beta feedback": "Opinión sobre a beta", + "Add reaction": "Engadir reacción", + "Send and receive voice messages": "Enviar e recibir mensaxes de voz", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "A túa opinión axudaranos a mellorar os espazos. Canto máis detallada sexa moito mellor para nós.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Se saes, %(brand)s volverá a cargar con Espazos desactivados. Comunidades e etiquetas personais serán visibles outra vez.", + "Message search initialisation failed": "Fallou a inicialización da busca de mensaxes", + "Space Autocomplete": "Autocompletado do espazo", + "Go to my space": "Ir ao meu espazo", + "sends space invaders": "enviar invasores espaciais", + "Sends the given message with a space themed effect": "Envía a mensaxe cun efecto de decorado espacial", + "See when people join, leave, or are invited to your active room": "Mira cando alguén se une, sae ou é convidada á túa sala activa", + "Kick, ban, or invite people to your active room, and make you leave": "Expulsa, veta ou convida a persoas á túa sala activa, e fai que saias", + "See when people join, leave, or are invited to this room": "Mira cando se une alguén, sae ou é convidada a esta sala", + "Kick, ban, or invite people to this room, and make you leave": "Expulsa, veta, ou convida persoas a esta sala, e fai que saias", + "Currently joining %(count)s rooms|one": "Neste intre estás en %(count)s sala", + "Currently joining %(count)s rooms|other": "Neste intre estás en %(count)s salas", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Intentao con outras palabras e fíxate nos erros de escritura. Algúns resultados poderían non ser visibles porque son privados e precisas un convite.", + "No results for \"%(query)s\"": "Sen resultados para \"%(query)s\"", + "The user you called is busy.": "A persoa á que chamas está ocupada.", + "User Busy": "Usuaria ocupada", + "We're working on this as part of the beta, but just want to let you know.": "Estamos traballando nesto como parte da beta, só queriamos que o soubeses.", + "Teammates might not be able to view or join any private rooms you make.": "As outras compañeiras de grupo poderían non ver ou unirse ás salas privadas que creas.", + "Or send invite link": "Ou envía ligazón de convite", + "If you can't see who you’re looking for, send them your invite link below.": "Se non podes a quen estás a buscar, envíalle ti esta ligazón de convite.", + "Some suggestions may be hidden for privacy.": "Algunhas suxestións poderían estar agochadas por privacidade.", + "Search for rooms or people": "Busca salas ou persoas", + "Forward message": "Reenviar mensaxe", + "Open link": "Abrir ligazón", + "Sent": "Enviado", + "You don't have permission to do this": "Non tes permiso para facer isto", + "Error - Mixed content": "Erro - Contido variado", + "Error loading Widget": "Erro ao cargar o Widget", + "Pinned messages": "Mensaxes fixadas", + "If you have permissions, open the menu on any message and select Pin to stick them here.": "Se tes permisos, abre o menú en calquera mensaxe e elixe Fixar para pegalos aquí.", + "Nothing pinned, yet": "Nada fixado, por agora", + "End-to-end encryption isn't enabled": "Non está activado o cifrado de extremo-a-extremo", + "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. Enable encryption in settings.": "As túas mensaxes privadas normalmente están cifradas, pero esta sala non. Habitualmente esto é debido a que se utiliza un dispositivo ou métodos no soportados, como convites por email. Activa o cifrado nos axustes." } diff --git a/src/i18n/strings/hr.json b/src/i18n/strings/hr.json index 2511771578..8070757426 100644 --- a/src/i18n/strings/hr.json +++ b/src/i18n/strings/hr.json @@ -5,5 +5,205 @@ "The platform you're on": "Platforma na kojoj se nalazite", "The version of %(brand)s": "Verzija %(brand)s", "Your language of choice": "Izabrani jezik", - "Dismiss": "Odbaci" + "Dismiss": "Odbaci", + "France": "Francuska", + "Finland": "Finska", + "Fiji": "Fiji", + "Faroe Islands": "Farski otoci", + "Falkland Islands": "Falklandski otoci", + "Ethiopia": "Etiopija", + "Estonia": "Estonija", + "Eritrea": "Eritreja", + "Equatorial Guinea": "Ekvatorska Gvineja", + "El Salvador": "El Salvador", + "Egypt": "Egipat", + "Ecuador": "Ekvador", + "Dominican Republic": "Dominikanska Republika", + "Dominica": "Dominika", + "Djibouti": "Džibuti", + "Denmark": "Danska", + "Côte d’Ivoire": "Obala Bjelokosti", + "Czech Republic": "Češka", + "Cyprus": "Cipar", + "Curaçao": "Curaçao", + "Cuba": "Kuba", + "Croatia": "Hrvatska", + "Costa Rica": "Kostarika", + "Cook Islands": "Cookovo Otočje", + "Congo - Kinshasa": "Kongo - Kinshasa", + "Congo - Brazzaville": "Republika Kongo", + "Comoros": "Komori", + "Colombia": "Kolumbija", + "Cocos (Keeling) Islands": "Kokosovi (Keeling) otoci", + "Christmas Island": "Uskršnji otoci", + "China": "Kina", + "Chile": "Čile", + "Chad": "Čad", + "Central African Republic": "Srednjoafrička Republika", + "Cayman Islands": "Kajmanski otoci", + "Caribbean Netherlands": "Karipska Nizozemska", + "Cape Verde": "Zelenortski Otoci", + "Canada": "Kanada", + "Cameroon": "Kamerun", + "Cambodia": "Kambodža", + "Burundi": "Burundi", + "Burkina Faso": "Burkina Faso", + "Bulgaria": "Bugarska", + "Brunei": "Brunej", + "British Virgin Islands": "Britanski djevičanski otoci", + "British Indian Ocean Territory": "Britanski teritorij Indijskog oceana", + "Brazil": "Brazil", + "Bouvet Island": "Otok Bouvet", + "Botswana": "Bocvana", + "Bosnia": "Bosna i Hercegovina", + "Bolivia": "Bolivija", + "Bhutan": "Butan", + "Bermuda": "Bermuda", + "Benin": "Benin", + "Belize": "Belize", + "Belgium": "Belgija", + "Belarus": "Bjelorusija", + "Barbados": "Barbados", + "Bangladesh": "Bangladeš", + "Bahrain": "Bahrein", + "Bahamas": "Bahami", + "Azerbaijan": "Azerbejdžan", + "Austria": "Austrija", + "Australia": "Australija", + "Aruba": "Aruba", + "Armenia": "Armenija", + "Argentina": "Argentina", + "Antigua & Barbuda": "Antigva i Barbuda", + "Antarctica": "Antartika", + "Anguilla": "Angvila", + "Angola": "Angola", + "Andorra": "Andora", + "American Samoa": "Američka Samoa", + "Algeria": "Alžir", + "Albania": "Albanija", + "Åland Islands": "Alandski otoci", + "Afghanistan": "Afganistan", + "United States": "Sjedinjene Države", + "United Kingdom": "Ujedinjeno Kraljevstvo", + "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "Čini se da Vaša email adresa nije povezana s Matrix ID-om na ovom kućnom poslužitelju.", + "This email address was not found": "Ova email adresa nije pronađena", + "Unable to enable Notifications": "Omogućavanje notifikacija nije uspjelo", + "%(brand)s was not given permission to send notifications - please try again": "%(brand)s nema dopuštenje slati Vam notifikacije - molimo pokušajte ponovo", + "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s nema dopuštenje slati Vam notifikacije - molimo provjerite postavke pretraživača", + "%(name)s is requesting verification": "%(name)s traži potvrdu", + "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Vaš je kućni poslužitelj odbio vaš pokušaj prijave. Razlog je možda da je jednostavno sve predugo trajalo. Molimo pokušajte ponovno. Ako se ovo nastavi, obratite se administratoru kućnog poslužitelja.", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Vaš kućni poslužitelj nije bio dostupan i nije vas mogao prijaviti. Pokušajte ponovo. Ako se ovo nastavi, obratite se administratoru kućnog poslužitelja.", + "Try again": "Pokušaj ponovo", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Tražili smo od preglednika da zapamti koji kućni poslužitelj koristite za prijavu, ali ga je Vaš preglednik nažalost zaboravio. Idite na stranicu za prijavu i pokušajte ponovo.", + "We couldn't log you in": "Nismo Vas mogli ulogirati", + "Trust": "Vjeruj", + "Only continue if you trust the owner of the server.": "Nastavite samo ako vjerujete vlasniku poslužitelja.", + "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "Ova radnja zahtijeva pristup zadanom poslužitelju identiteta radi provjere adrese e-pošte ili telefonskog broja, no poslužitelj nema nikakve uvjete usluge.", + "Identity server has no terms of service": "Poslužitelj identiteta nema uvjete usluge", + "Unnamed Room": "Neimenovana soba", + "Failed to add the following rooms to %(groupId)s:": "Neuspješno dodavanje sljedećih soba u %(groupId)s:", + "Failed to invite users to %(groupId)s": "Neuspješno dodavanje korisnika u %(groupId)s", + "Failed to invite users to community": "Dodavanje korisnika u zajednicu nije uspjelo", + "Failed to invite the following users to %(groupId)s:": "Neuspješno dodavanje sljedećih korisnika u %(groupId)s:", + "Add to community": "Dodaj u zajednicu", + "Room name or address": "Ime ili adresa sobe", + "Add rooms to the community": "Dodaj sobe zajednici", + "Show these rooms to non-members on the community page and room list?": "Prikaži ove sobe osobama koje nisu članovi na stranici zajednice i popisu soba?", + "Which rooms would you like to add to this community?": "Koju sobu biste željeli dodati u ovu zajednicu?", + "Invite to Community": "Pozovi u zajednicu", + "Name or Matrix ID": "Ime ili Matrix ID", + "Invite new community members": "Pozovite nove članove zajednice", + "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "Upozorenje: svaka osoba koju dodate u zajednicu bit će javno vidljiva svima koji znaju ID zajednice", + "Who would you like to add to this community?": "Koga biste željeli dodati u ovu zajednicu?", + "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(day)s. %(monthName)s %(fullYear)s, %(time)s", + "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(day)s. %(monthName)s %(fullYear)s", + "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(weekDayName)s, %(day)s. %(monthName)s, %(time)s", + "%(weekDayName)s %(time)s": "%(weekDayName)s %(time)s", + "AM": "Prijepodne", + "PM": "Poslijepodne", + "Dec": "Pro", + "Nov": "Stu", + "Oct": "Lis", + "Sep": "Ruj", + "Aug": "Kol", + "Jul": "Srp", + "Jun": "Lip", + "May": "Svi", + "Apr": "Tra", + "Mar": "Ožu", + "Feb": "Velj", + "Jan": "Sij", + "Sat": "Sub", + "Fri": "Pet", + "Thu": "Čet", + "Wed": "Sri", + "Tue": "Uto", + "Mon": "Pon", + "Sun": "Ned", + "Failure to create room": "Stvaranje sobe neuspješno", + "The server does not support the room version specified.": "Poslužitelj ne podržava navedenu verziju sobe.", + "Server may be unavailable, overloaded, or you hit a bug.": "Poslužitelj je možda nedostupan, preopterećen, ili ste pronašli grešku u aplikaciji.", + "Upload Failed": "Prijenos neuspješan", + "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "Datoteka '%(fileName)s' premašuje maksimalnu veličinu ovog kućnog poslužitelja za prijenose", + "The file '%(fileName)s' failed to upload.": "Prijenos datoteke '%(fileName)s' nije uspio.", + "Continue": "Nastavi", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Trenutno nije moguće odgovoriti datotekom. Želite li prenijeti ovu datoteku bez odgovora?", + "Replying With Files": "Odgovaranje datotekama", + "This will end the conference for everyone. Continue?": "To će prekinuti konferenciju za sve. Nastaviti?", + "End conference": "Završi konferenciju", + "You do not have permission to start a conference call in this room": "Nemate dopuštenje uspostaviti konferencijski poziv u ovoj sobi", + "Permission Required": "Potrebno dopuštenje", + "A call is currently being placed!": "Poziv se upravo uspostavlja!", + "Call in Progress": "Poziv u tijeku", + "You cannot place a call with yourself.": "Ne možete uspostaviti poziv sami sa sobom.", + "You're already in a call with this person.": "Već ste u pozivu sa tom osobom.", + "Already in call": "Već u pozivu", + "You've reached the maximum number of simultaneous calls.": "Dosegli ste maksimalan broj istodobnih poziva.", + "Too Many Calls": "Previše poziva", + "You cannot place VoIP calls in this browser.": "Ne možete uspostaviti VoIP pozive u ovom pretraživaču.", + "VoIP is unsupported": "VoIP nije podržan", + "Unable to capture screen": "Nije moguće snimanje zaslona", + "No other application is using the webcam": "Da ni jedna druga aplikacija već ne koristi web kameru", + "Permission is granted to use the webcam": "Jeli dopušteno korištenje web kamere", + "A microphone and webcam are plugged in and set up correctly": "Jesu li mikrofon i web kamera priključeni i pravilno postavljeni", + "Unable to access webcam / microphone": "Nije moguće pristupiti web kameri / mikrofonu", + "Call failed because webcam or microphone could not be accessed. Check that:": "Poziv nije uspio jer nije bilo moguće pristupiti web kameri ili mikrofonu. Provjerite:", + "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Poziv nije uspio jer nije bilo moguće pristupiti mikrofonu. Provjerite je li mikrofon priključen i ispravno postavljen.", + "Unable to access microphone": "Nije moguće pristupiti mikrofonu", + "OK": "OK", + "Try using turn.matrix.org": "Pokušajte koristiti turn.matrix.org", + "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternativno, možete pokušati koristiti javni poslužitelj na turn.matrix.org, no to bi moglo biti manje pouzdano i Vaša IP adresa će biti podijeljena s tim poslužiteljem. Time također možete upravljati u Postavkama.", + "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Zamolite administratora Vašeg kućnog poslužitelja (%(homeserverDomain)s) da konfigurira TURN poslužitelj kako bi pozivi mogli pouzdano funkcionirati.", + "Call failed due to misconfigured server": "Poziv neuspješan radi pogrešno konfiguriranog poslužitelja", + "The call was answered on another device.": "Na poziv je odgovoreno sa drugog uređaja.", + "Answered Elsewhere": "Odgovoreno je drugdje", + "The call could not be established": "Poziv se nije mogao uspostaviti", + "The remote side failed to pick up": "Sugovornik nije odgovorio na poziv", + "The user you called is busy.": "Pozvani korisnik je zauzet.", + "User Busy": "Korisnik zauzet", + "The other party declined the call.": "Sugovornik je odbio poziv.", + "Call Declined": "Poziv odbijen", + "Call Failed": "Poziv neuspješan", + "Unable to load! Check your network connectivity and try again.": "Učitavanje nije moguće! Provjerite mrežnu povezanost i pokušajte ponovo.", + "Error": "Geška", + "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Gdje ova stranica uključuje identificirajuće podatke, poput ID-a sobe, korisnika ili grupe, ti se podaci uklanjaju prije slanja na poslužitelj.", + "The information being sent to us to help make %(brand)s better includes:": "Podaci koji nam se šalju radi poboljšanja %(brand)s uključuju:", + "Analytics": "Analitika", + "Your device resolution": "Razlučivost vašeg uređaja", + "Your user agent": "Vaš korisnički agent", + "e.g. ": "npr. ", + "Every page you use in the app": "Svaka stranica koju upotrebljavate u aplikaciji", + "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Bez obzira upotrebljavate li %(brand)s na uređaju na kojem je dodir primarni mehanizam unosa", + "Confirm adding this phone number by using Single Sign On to prove your identity.": "Potvrdite dodavanje ovog telefonskog broja koristeći jedinstvenu prijavu (SSO) da biste dokazali Vaš identitet.", + "Confirm adding this email address by using Single Sign On to prove your identity.": "Potvrdite dodavanje ove email adrese koristeći jedinstvenu prijavu (SSO) da biste dokazali Vaš identitet.", + "Single Sign On": "Jedinstvena prijava (SSO)", + "Use Single Sign On to continue": "Koristite jedinstvenu prijavu (SSO) za nastavak", + "Whether or not you're logged in (we don't record your username)": "Bez obzira jeste li ulogirani ili ne (ne snimamo vaše korisničko ime)", + "Add Phone Number": "Dodaj telefonski broj", + "Click the button below to confirm adding this phone number.": "Kliknite gumb ispod da biste potvrdili dodavanje ovog telefonskog broja.", + "Confirm adding phone number": "Potvrdite dodavanje telefonskog broja", + "Add Email Address": "Dodaj email adresu", + "Confirm": "Potvrdi", + "Click the button below to confirm adding this email address.": "Kliknite gumb ispod da biste potvrdili dodavanje ove email adrese.", + "Confirm adding email": "Potvrdite dodavanje email adrese" } diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index 2ec5af8a17..cb749f12a5 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -26,12 +26,12 @@ "Admin Tools": "Admin. Eszközök", "No Microphones detected": "Nem található mikrofon", "No Webcams detected": "Nem található webkamera", - "No media permissions": "Nincs media jogosultság", + "No media permissions": "Nincs média jogosultság", "You may need to manually permit %(brand)s to access your microphone/webcam": "Lehet hogy kézileg kell engedélyeznie a %(brand)snak, hogy hozzáférjen a mikrofonjához és webkamerájához", "Default Device": "Alapértelmezett eszköz", "Microphone": "Mikrofon", "Camera": "Kamera", - "Advanced": "Haladó", + "Advanced": "Speciális", "Always show message timestamps": "Üzenet időbélyeg folyamatos megjelenítése", "Authentication": "Azonosítás", "Failed to change password. Is your password correct?": "Nem sikerült megváltoztatni a jelszót. Helyesen írtad be a jelszavadat?", @@ -66,7 +66,7 @@ "%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s megváltoztatta a hozzáférési szintjét erre: %(powerLevelDiffText)s.", "%(senderDisplayName)s changed the room name to %(roomName)s.": "%(senderDisplayName)s megváltoztatta a szoba nevét erre: %(roomName)s.", "%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s törölte a szoba nevét.", - "%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s megváltoztatta a témát erre \"%(topic)s\".", + "%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s megváltoztatta a témát erre: „%(topic)s”.", "Changes your display nickname": "Megváltoztatja a becenevedet", "Click here to fix": "A javításhoz kattints ide", "Click to mute audio": "Hang némításához kattints ide", @@ -116,7 +116,7 @@ "Failed to set display name": "Megjelenítési nevet nem sikerült beállítani", "Failed to unban": "Kizárás visszavonása sikertelen", "Failed to upload profile picture!": "Profil kép feltöltése sikertelen!", - "Failed to verify email address: make sure you clicked the link in the email": "E-mail cím ellenőrzése sikertelen: ellenőrizze, hogy az e-mailben lévő hivatkozásra kattintott-e", + "Failed to verify email address: make sure you clicked the link in the email": "Az e-mail-cím ellenőrzése sikertelen: ellenőrizze, hogy az e-mailben lévő hivatkozásra kattintott-e", "Failure to create room": "Szoba létrehozása sikertelen", "Favourites": "Kedvencek", "Fill screen": "Képernyő kitöltése", @@ -230,7 +230,7 @@ "Submit": "Elküld", "Success": "Sikeres", "The phone number entered looks invalid": "A megadott telefonszám érvénytelennek tűnik", - "This email address is already in use": "Ez az e-mail cím már használatban van", + "This email address is already in use": "Ez az e-mail-cím már használatban van", "This email address was not found": "Az e-mail cím nem található", "The email address linked to your account must be entered.": "A fiókodhoz kötött e-mail címet add meg.", "The remote side failed to pick up": "A hívott fél nem vette fel", @@ -1030,7 +1030,7 @@ "Room list": "Szoba lista", "Timeline": "Idővonal", "Autocomplete delay (ms)": "Automatikus kiegészítés késleltetése (ms)", - "Roles & Permissions": "Szerepek & Jogosultságok", + "Roles & Permissions": "Szerepek és jogosultságok", "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "A üzenetek olvashatóságának változtatása csak az új üzenetekre lesz érvényes. A régi üzenetek láthatósága nem fog változni.", "Security & Privacy": "Biztonság és adatvédelem", "Encryption": "Titkosítás", @@ -1373,7 +1373,7 @@ "Message edits": "Üzenet szerkesztések", "Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:": "A szoba frissítéséhez be kell zárnod ezt a szobát és egy újat kell nyitnod e helyett. A szoba tagjainak a legjobb felhasználói élmény nyújtásához az alábbit fogjuk tenni:", "Loading room preview": "Szoba előnézetének a betöltése", - "Show all": "Mindet mutat", + "Show all": "Mind megjelenítése", "%(senderName)s made no change.": "%(senderName)s nem változtatott semmit.", "%(severalUsers)smade no changes %(count)s times|other": "%(severalUsers)s %(count)s alkalommal nem változtattak semmit", "%(severalUsers)smade no changes %(count)s times|one": "%(severalUsers)s nem változtattak semmit", @@ -1531,7 +1531,7 @@ "%(count)s unread messages including mentions.|other": "%(count)s olvasatlan üzenet megemlítéssel.", "%(count)s unread messages.|other": "%(count)s olvasatlan üzenet.", "Unread mentions.": "Olvasatlan megemlítés.", - "Show image": "Képek megmutatása", + "Show image": "Kép megjelenítése", "Please create a new issue on GitHub so that we can investigate this bug.": "Ahhoz hogy a hibát megvizsgálhassuk kérlek készíts egy új hibajegyet a GitHubon.", "To continue you need to accept the terms of this service.": "A folytatáshoz el kell fogadnod a felhasználási feltételeket.", "Document": "Dokumentum", @@ -1545,7 +1545,7 @@ "Remove %(count)s messages|one": "1 üzenet törlése", "Your email address hasn't been verified yet": "Az e-mail-címe még nincs ellenőrizve", "Click the link in the email you received to verify and then click continue again.": "Ellenőrzéshez kattints a linkre az e-mailben amit kaptál és itt kattints a folytatásra újra.", - "Add Email Address": "E-mail cím hozzáadása", + "Add Email Address": "E-mail-cím hozzáadása", "Add Phone Number": "Telefonszám hozzáadása", "%(creator)s created and configured the room.": "%(creator)s elkészítette és beállította a szobát.", "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "Először töröld a személyes adatokat az azonosítási szerverről () mielőtt lecsatlakozol. Sajnos az azonosítási szerver () jelenleg elérhetetlen.", @@ -1622,7 +1622,7 @@ "Subscribing to a ban list will cause you to join it!": "A feliratkozás egy tiltó listára azzal jár, hogy csatlakozol hozzá!", "If this isn't what you want, please use a different tool to ignore users.": "Ha nem ez az amit szeretnél, kérlek használj más eszközt a felhasználók figyelmen kívül hagyásához.", "Subscribe": "Feliratkozás", - "You have ignored this user, so their message is hidden. Show anyways.": "Ezt a felhasználót figyelmen kívül hagyod, így az üzenetei el lesznek rejtve. Mindenképpen megmutat.", + "You have ignored this user, so their message is hidden. Show anyways.": "Ezt a felhasználót figyelmen kívül hagyod, így az üzenetei el lesznek rejtve. Megjelenítés mindenképpen.", "Custom (%(level)s)": "Egyéni (%(level)s)", "Trusted": "Megbízható", "Not trusted": "Megbízhatatlan", @@ -1733,7 +1733,7 @@ "Country Dropdown": "Ország lenyíló menü", "The message you are trying to send is too large.": "Túl nagy képet próbálsz elküldeni.", "Help": "Súgó", - "Show more": "Mutass többet", + "Show more": "Több megjelenítése", "Recent Conversations": "Legújabb Beszélgetések", "Direct Messages": "Közvetlen Beszélgetések", "Go": "Menj", @@ -1795,7 +1795,7 @@ "This bridge was provisioned by .": "Ezt a hidat az alábbi felhasználó készítette: .", "Workspace: %(networkName)s": "Munkahely: %(networkName)s", "Channel: %(channelName)s": "Csatorna: %(channelName)s", - "Show less": "Kevesebbet mutat", + "Show less": "Kevesebb megjelenítése", "Securely cache encrypted messages locally for them to appear in search results, using ": "A titkosított üzenetek kereséséhez azokat biztonságos módon helyileg kell tárolnod, felhasználva: ", " to store messages from ": " üzenetek tárolásához ", "rooms.": "szobából.", @@ -2109,7 +2109,7 @@ "Server did not return valid authentication information.": "A szerver semmilyen érvényes azonosítási információt sem küldött vissza.", "There was a problem communicating with the server. Please try again.": "A szerverrel való kommunikációval probléma történt. Kérlek próbáld újra.", "Sign in with SSO": "Belépés SSO-val", - "Welcome to %(appName)s": "Üdvözöl az %(appName)s", + "Welcome to %(appName)s": "Üdvözöl a(z) %(appName)s", "Liberate your communication": "Kommunikálj szabadon", "Send a Direct Message": "Közvetlen üzenet küldése", "Explore Public Rooms": "Nyilvános szobák felfedezése", @@ -2237,7 +2237,7 @@ "Sort by": "Rendezés", "Unread rooms": "Olvasatlan szobák", "Always show first": "Mindig az elsőt mutatja", - "Show": "Mutat", + "Show": "Megjelenítés", "Message preview": "Üzenet előnézet", "List options": "Lista beállításai", "Show %(count)s more|other": "Még %(count)s megjelenítése", @@ -2945,7 +2945,7 @@ "Learn more": "Tudj meg többet", "Matrix.org is the biggest public homeserver in the world, so it’s a good place for many.": "A matrix.org a legnagyobb nyilvános Matrix szerver a világon, és sok felhasználónak megfelelő választás.", "About homeservers": "A Matrix szerverekről", - "Use your preferred Matrix homeserver if you have one, or host your own.": "Add meg az általad választott Matrix szerver címét, ha van ilyen, vagy üzemeltess egy sajátot!", + "Use your preferred Matrix homeserver if you have one, or host your own.": "Add meg az általad választott Matrix szerver címét, ha van ilyen, vagy üzemeltess egy sajátot.", "Other homeserver": "Másik Matrix szerver", "Host account on": "Fiók létrehozása itt:", "We call the places where you can host your account ‘homeservers’.": "Matrix szervereknek nevezzük azokat a helyeket, ahol fiókot lehet létrehozni.", @@ -3107,7 +3107,7 @@ "Room name": "Szoba neve", "Support": "Támogatás", "Random": "Véletlen", - "Welcome to ": "Üdvözlöm itt: ", + "Welcome to ": "Üdvözöl a(z) ", "Your private space ": "Privát tere: ", "Your public space ": "Nyilvános tere: ", "You have been invited to ": "Meghívták ide: ", @@ -3280,5 +3280,118 @@ "Send and receive voice messages (in development)": "Hang üzenetek küldése és fogadása (fejlesztés alatt)", "%(deviceId)s from %(ip)s": "%(deviceId)s innen: %(ip)s", "Review to ensure your account is safe": "Tekintse át, hogy meggyőződjön arról, hogy a fiókja biztonságban van", - "Sends the given message as a spoiler": "A megadott üzenet szpojlerként küldése" + "Sends the given message as a spoiler": "A megadott üzenet szpojlerként küldése", + "Change server ACLs": "Kiszolgáló ACL-ek módosítása", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Csak ön van itt. Ha kilép, akkor a jövőben senki nem tud majd ide belépni, beleértve önt is.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Ha mindent alapállapotba helyez, nem lesz megbízható munkamenete, nem lesznek megbízható felhasználók és a régi üzenetekhez sem biztos, hogy hozzáfér majd.", + "Only do this if you have no other device to complete verification with.": "Csak akkor tegye meg, ha nincs egyetlen másik eszköze sem az ellenőrzés elvégzéséhez.", + "Reset everything": "Minden visszaállítása", + "Forgotten or lost all recovery methods? Reset all": "Elfelejtette vagy elveszett minden visszaállítási lehetőség? Mind alaphelyzetbe állítása", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Ha ezt teszi, tudnia kell, hogy az üzenetek nem kerülnek törlésre de keresés nem lesz tökéletes amíg az indexek nem készülnek el újra", + "View message": "Üzenet megjelenítése", + "Zoom in": "Nagyít", + "Zoom out": "Kicsinyít", + "%(seconds)ss left": "%(seconds)s mp van vissza", + "Show options to enable 'Do not disturb' mode": "Mutassa a lehetőséget a „Ne zavarjanak” módhoz", + "You can select all or individual messages to retry or delete": "Újraküldéshez vagy törléshez kiválaszthatja az üzeneteket egyenként vagy az összeset együtt", + "Retry all": "Mind újraküldése", + "Sending": "Küldés", + "Delete all": "Mind törlése", + "Some of your messages have not been sent": "Néhány üzenete nem lett elküldve", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s résztvevő beleértve: %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Beleértve: %(commaSeparatedMembers)s", + "View all %(count)s members|one": "1 résztvevő megmutatása", + "View all %(count)s members|other": "Az összes %(count)s résztvevő megmutatása", + "Failed to send": "Küldés sikertelen", + "Enter your Security Phrase a second time to confirm it.": "A megerősítéshez adja meg a biztonsági jelmondatot még egyszer.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Válassz szobákat vagy beszélgetéseket amit hozzáadhat. Ez csak az ön tere, senki nem lesz értesítve. Továbbiakat később is hozzáadhat.", + "What do you want to organise?": "Mit szeretne megszervezni?", + "Filter all spaces": "Minden tér szűrése", + "Delete recording": "Felvétel törlése", + "Stop the recording": "Felvétel megállítása", + "%(count)s results in all spaces|one": "%(count)s találat van az összes térben", + "%(count)s results in all spaces|other": "%(count)s találat a terekben", + "You have no ignored users.": "Nincs figyelmen kívül hagyott felhasználó.", + "Play": "Lejátszás", + "Pause": "Szünet", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Ez egy kísérleti funkció Egyenlőre az a felhasználó aki meghívót kap a meghívóban lévő linkre kattintva tud csatlakozni.", + "To join %(spaceName)s, turn on the Spaces beta": "A csatlakozáshoz ide: %(spaceName)s először kapcsolja be a béta Tereket", + "To view %(spaceName)s, turn on the Spaces beta": "A %(spaceName)s megjelenítéséhez először kapcsolja be a béta Tereket", + "Select a room below first": "Először válasszon ki szobát alulról", + "Communities are changing to Spaces": "A közösségek Terek lesznek", + "Join the beta": "Csatlakozás béta lehetőségekhez", + "Leave the beta": "Béta kikapcsolása", + "Beta": "Béta", + "Tap for more info": "Koppints további információért", + "Spaces is a beta feature": "A terek béta állapotban van", + "Want to add a new room instead?": "Inkább új szobát adna hozzá?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Szobák hozzáadása…", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Szobák hozzáadása… (%(progress)s ennyiből: %(count)s)", + "Not all selected were added": "Nem az összes kijelölt lett hozzáadva", + "You can add existing spaces to a space.": "Létező tereket adhat a térhez.", + "Feeling experimental?": "Kísérletezni szeretne?", + "You are not allowed to view this server's rooms list": "Nincs joga ennek a szervernek a szobalistáját megnézni", + "Error processing voice message": "Hiba a hangüzenet feldolgozásánál", + "We didn't find a microphone on your device. Please check your settings and try again.": "Nem található mikrofon. Ellenőrizze a beállításokat és próbálja újra.", + "No microphone found": "Nem található mikrofon", + "We were unable to access your microphone. Please check your browser settings and try again.": "Nem lehet a mikrofont használni. Ellenőrizze a böngésző beállításait és próbálja újra.", + "Unable to access your microphone": "A mikrofont nem lehet használni", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Kedve van kísérletezni? Labs az a hely ahol először hozzá lehet jutni az új dolgokhoz, kipróbálni új lehetőségeket és segíteni a fejlődésüket mielőtt mindenkihez eljut. Tudj meg többet.", + "Your access token gives full access to your account. Do not share it with anyone.": "A hozzáférési kulcs teljes elérést biztosít a fiókhoz. Soha ne ossza meg mással.", + "Access Token": "Elérési kulcs", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "A terek egy új lehetőség a szobák és emberek csoportosításához. Létező térhez meghívóval lehet csatlakozni.", + "Please enter a name for the space": "Kérem adjon meg egy nevet a térhez", + "Connecting": "Kapcsolás", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Közvetlen hívás engedélyezése két fél között (ha ezt engedélyezi a másik fél láthatja az ön IP címét)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Béta verzió elérhető webre, asztali kliensre és Androidra. Bizonyos funkciók lehet, hogy nem elérhetők a matrix szerverén.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Bármikor elhagyhatja a béta változatot a beállításokban vagy a béta kitűzőre koppintva, mint alább.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s a Terekkel lesz újra betöltve. A közösségek és egyedi címkék rejtve maradnak.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Béta verzió elérhető webre, asztali kliensre és Androidra. Köszönjük, hogy kipróbálja.", + "Spaces are a new way to group rooms and people.": "Szobák és emberek csoportosításának új lehetősége a Terek használata.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s a Terek nélkül lesz újra betöltve. A közösségek és egyedi címkék újra megjelennek.", + "To leave the beta, visit your settings.": "A beállításokban tudja elhagyni a bétát.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "A platform és a felhasználói neve felhasználásra kerül ami segít nekünk a visszajelzést minél jobban felhasználni.", + "%(featureName)s beta feedback": "%(featureName)s béta visszajelzés", + "Thank you for your feedback, we really appreciate it.": "Köszönjük a visszajelzését, ezt nagyra értékeljük.", + "Beta feedback": "Béta visszajelzés", + "Add reaction": "Reakció hozzáadása", + "Send and receive voice messages": "Hangüzenet küldése, fogadása", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "A visszajelzése segítség a terek javításához. Minél részletesebb annál jobb.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Távozás után %(brand)s Terek nélkül lesz újra betöltve. A közösségek és egyedi címkék újra megjelennek.", + "Message search initialisation failed": "Üzenet keresés beállítása sikertelen", + "Space Autocomplete": "Tér automatikus kiegészítése", + "Go to my space": "Irány a teréhez", + "Spaces are a beta feature.": "A terek béta állapotban van.", + "Search names and descriptions": "Nevek és leírások keresése", + "You may contact me if you have any follow up questions": "Ha további kérdés merülne fel, kapcsolatba léphetnek velem", + "sends space invaders": "space invaders küldése", + "Sends the given message with a space themed effect": "Üzenet küldése világűrös effekttel", + "See when people join, leave, or are invited to your active room": "Emberek belépésének, távozásának vagy meghívásának a megjelenítése az aktív szobájában", + "Kick, ban, or invite people to your active room, and make you leave": "Kirúgni, kitiltani vagy meghívni embereket az aktív szobába és, hogy ön elhagyja a szobát", + "See when people join, leave, or are invited to this room": "Emberek belépésének, távozásának vagy meghívásának a megjelenítése ebben a szobában", + "Kick, ban, or invite people to this room, and make you leave": "Kirúgni, kitiltani vagy meghívni embereket ebbe a szobába és, hogy ön elhagyja a szobát", + "Currently joining %(count)s rooms|one": "%(count)s szobába lép be", + "Currently joining %(count)s rooms|other": "%(count)s szobába lép be", + "No results for \"%(query)s\"": "Nincs találat ehhez: %(query)s", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Próbáljon ki más szavakat vagy keressen elgépelést. Néhány találat azért nem látszik, mert privát és meghívóra van szüksége, hogy csatlakozhasson.", + "The user you called is busy.": "A hívott felhasználó foglalt.", + "User Busy": "Felhasználó foglalt", + "If you can't see who you’re looking for, send them your invite link below.": "Ha nem található a keresett személy, küldje el az alábbi hivatkozást neki.", + "Some suggestions may be hidden for privacy.": "Adatvédelem miatt néhány javaslat esetleg rejtve van.", + "If you have permissions, open the menu on any message and select Pin to stick them here.": "Ha van hozzá jogosultsága, nyissa meg a menüt bármelyik üzenetben és válassza a Kitűz menüpontot a kitűzéshez.", + "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. Enable encryption in settings.": "A személyes beszélgetések általában titkosítottak, de ez a szoba nem az. Ez azért lehet, mert nem támogatott eszköz vagy mód használt mint például e-mail meghívók. Titkosítás engedélyezése a beállításokban.", + "We're working on this as part of the beta, but just want to let you know.": "A béta funkció ezen részén dolgozunk, csak tudatni szerettük volna.", + "Teammates might not be able to view or join any private rooms you make.": "Csapattagok lehet, hogy nem láthatják vagy léphetnek be az ön által készített privát szobákba.", + "Or send invite link": "Vagy meghívó link küldése", + "Search for rooms or people": "Szobák vagy emberek keresése", + "Forward message": "Üzenet továbbítása", + "Open link": "Hivatkozás megnyitása", + "Sent": "Elküldve", + "You don't have permission to do this": "Nincs jogosultsága ehhez", + "Error - Mixed content": "Hiba - Vegyes tartalom", + "Error loading Widget": "Kisalkalmazás betöltési hiba", + "Pinned messages": "Kitűzött üzenetek", + "Nothing pinned, yet": "Semmi sincs kitűzve egyenlőre", + "End-to-end encryption isn't enabled": "Végpontok közötti titkosítás nincs engedélyezve" } diff --git a/src/i18n/strings/is.json b/src/i18n/strings/is.json index ddb3bbc66d..e8718c941a 100644 --- a/src/i18n/strings/is.json +++ b/src/i18n/strings/is.json @@ -136,7 +136,7 @@ "Attachment": "Viðhengi", "Hangup": "Leggja á", "Voice call": "Raddsamtal", - "Video call": "_Myndsímtal", + "Video call": "Myndsímtal", "Upload file": "Hlaða inn skrá", "Send an encrypted message…": "Senda dulrituð skilaboð…", "You do not have permission to post to this room": "Þú hefur ekki heimild til að senda skilaboð á þessa spjallrás", @@ -198,7 +198,7 @@ "Today": "Í dag", "Yesterday": "Í gær", "Error decrypting attachment": "Villa við afkóðun viðhengis", - "Copied!": "Afritað", + "Copied!": "Afritað!", "Custom Server Options": "Sérsniðnir valkostir vefþjóns", "Dismiss": "Hunsa", "Please check your email to continue registration.": "Skoðaðu tölvupóstinn þinn til að geta haldið áfram með skráningu.", @@ -460,5 +460,273 @@ "Create Account": "Stofna Reikning", "Please install Chrome, Firefox, or Safari for the best experience.": "vinsamlegast setja upp Chrome, Firefox, eða Safari fyrir besta reynsluna.", "Explore rooms": "Kanna herbergi", - "Sign In": "Skrá inn" + "Sign In": "Skrá inn", + "The user's homeserver does not support the version of the room.": "Heimaþjónn notandans styður ekki útgáfu herbergis.", + "The user must be unbanned before they can be invited.": "Notandinn þarf að vera afbannaður áður en að hægt er að bjóða þeim.", + "User %(user_id)s may or may not exist": "Notandi %(user_id)s gæti verið til", + "User %(user_id)s does not exist": "Notandi %(user_id)s er ekki til", + "User %(userId)s is already in the room": "Notandi %(userId)s er nú þegar í herberginu", + "You do not have permission to invite people to this room.": "Þú hefur ekki heimild til að bjóða fólk í þessa spjallrás.", + "Leave Room": "Fara af Spjallrás", + "Add room": "Bæta við herbergi", + "Use a more compact ‘Modern’ layout": "Nota þéttara ‘nútímalegt’ skipulag", + "Switch to dark mode": "Skiptu yfir í dökkstillingu", + "Switch to light mode": "Skiptu yfir í ljósstillingu", + "Modify widgets": "Breyta viðmótshluta", + "Room Info": "Herbergis upplýsingar", + "Room information": "Upplýsingar um herbergi", + "Room options": "Herbergisvalkostir", + "Invite People": "Bjóða Fólki", + "Invite people": "Bjóða fólki", + "%(count)s people|other": "%(count)s manns", + "%(count)s people|one": "%(count)s manneskja", + "People": "Fólk", + "Finland": "Finnland", + "Norway": "Noreg", + "Denmark": "Danmörk", + "Iceland": "Ísland", + "Mentions & Keywords": "Nefnir og stikkorð", + "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Ef þú hættir við núna geturðu tapað dulkóðuðum skilaboðum og gögnum ef þú missir aðgang að innskráningum þínum.", + "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Heimaþjónastjórnandi þinn hefur lokað á sjálfkrafa dulkóðun í einkaherbergjum og beinskilaboðum.", + "You can’t disable this later. Bridges & most bots won’t work yet.": "Þú getur ekki gert þetta óvirkt síðar. Brýr og flest vélmenni virka ekki ennþá.", + "Travel & Places": "Ferðalög og staðir", + "Food & Drink": "Mat og drykkur", + "Animals & Nature": "Dýr og náttúra", + "Smileys & People": "Broskarlar og fólk", + "Voice & Video": "Rödd og myndband", + "Roles & Permissions": "Hlutverk og heimildir", + "Help & About": "Hjálp og um", + "Reject & Ignore user": "Hafna og hunsa notanda", + "Security & privacy": "Öryggi og einkalíf", + "Security & Privacy": "Öryggi & Einkalíf", + "Feedback sent": "Endurgjöf sent", + "Send feedback": "Senda endurgjöf", + "Feedback": "Endurgjöf", + "%(featureName)s beta feedback": "%(featureName)s beta endurgjöf", + "Thank you for your feedback, we really appreciate it.": "Þakka þér fyrir athugasemdir þínar.", + "Beta feedback": "Beta endurgjöf", + "All settings": "Allar stillingar", + "Notification settings": "Tilkynningarstillingar", + "Change notification settings": "Breytta tilkynningastillingum", + "You can't send any messages until you review and agree to our terms and conditions.": "Þú getur ekki sent nein skilaboð fyrr en þú hefur farið yfir og samþykkir skilmála okkar.", + "Send a Direct Message": "Senda beinskilaboð", + "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.": "Tilkynning um þessi skilaboð mun senda einstakt 'atburðarauðkenni' til stjórnanda heimaþjónns. Ef skilaboð í þessu herbergi eru dulkóðuð getur stjórnandi heimaþjónns ekki lesið skilaboðatextann eða skoðað skrár eða myndir.", + "Send a message…": "Senda skilaboð…", + "Send message": "Senda skilaboð", + "Sending your message...": "Er að senda skilaboð þitt...", + "Send as message": "Senda sem skilaboð", + "You can use /help to list available commands. Did you mean to send this as a message?": "Þú getur notað /help til að lista tilteknar skipanir. Ætlaðir þú að senda þetta sem skilaboð?", + "Send messages": "Senda skilaboð", + "Sends the given message with snowfall": "Sendir skilaboðið með snjókomu", + "Sends the given message with fireworks": "Sendir skilaboðið með flugeldum", + "Sends the given message with confetti": "Sendir skilaboðið með skrauti", + "Never send encrypted messages to unverified sessions in this room from this session": "Aldrei senda dulrituð skilaboð af þessu tæki til ósannvottaðra tækja í þessu herbergi", + "Never send encrypted messages to unverified sessions from this session": "Aldrei senda dulrituð skilaboð af þessu tæki til ósannvottaðra tækja", + "Use Ctrl + Enter to send a message": "Notaðu Ctrl + Enter til að senda skilaboð", + "Use Command + Enter to send a message": "Notaðu Command + Enter til að senda skilaboð", + "Jump to the bottom of the timeline when you send a message": "Hoppaðu neðst á tímalínunni þegar þú sendir skilaboð", + "Send and receive voice messages": "Senda og taka á móti talskilaboðum", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "Send %(msgtype)s messages as you in your active room": "Senda %(msgtype)s skilaboð sem þú í virka herbergi þínu", + "Send %(msgtype)s messages as you in this room": "Senda %(msgtype)s skilaboð sem þú í þessu herbergi", + "Send text messages as you in your active room": "Senda texta skilaboð sem þú í virku herbergi þínu", + "Send text messages as you in this room": "Senda texta skilaboð sem þú í þessu herbergi", + "Send messages as you in your active room": "Senda skilaboð sem þú í virku herbergi þínu", + "Send messages as you in this room": "Senda skilaboð sem þú í þessu herbergi", + "%(senderName)s changed the pinned messages for the room.": "%(senderName)s breytti föstum skilaboðum fyrir herbergið.", + "Sends a message to the given user": "Sendir skilaboð til viðkomandi notanda", + "Sends the given message coloured as a rainbow": "Sendir gefið skilaboð litað sem regnbogi", + "Sends a message as html, without interpreting it as markdown": "Sendir skilaboð sem html, án þess að túlka það sem markdown", + "Sends a message as plain text, without interpreting it as markdown": "Sendir skilaboð sem óbreyttur texti án þess að túlka það sem markdown", + "Sends the given message as a spoiler": "Sendir skilaboðið sem spoiler", + "No need for symbols, digits, or uppercase letters": "Engin þörf á táknum, tölustöfum, eða hástöfum", + "Use a few words, avoid common phrases": "Notaðu nokkur orð. Forðastu algengar setningar", + "Unknown server error": "Óþekkt villa á þjóni", + "Message deleted by %(name)s": "Skilaboð eytt af %(name)s", + "Message deleted": "Skilaboð eytt", + "Room list": "Herbergislisti", + "Subscribed lists": "Skráðir listar", + "eg: @bot:* or example.org": "t.d.: @vélmenni:* eða dæmi.is", + "Personal ban list": "Persónulegur bann listi", + "⚠ These settings are meant for advanced users.": "⚠ Þessar stillingar eru ætlaðar fyrir háþróaða notendur.", + "Ignored users": "Hunsaðir notendur", + "You are currently subscribed to:": "Þú ert skráður til:", + "View rules": "Skoða reglur", + "You are not subscribed to any lists": "Þú ert ekki skráður fyrir neina lista", + "You are currently ignoring:": "Þú ert að hunsa:", + "You have not ignored anyone.": "Þú hefur ekki hunsað nein.", + "User rules": "Reglur notanda", + "Server rules": "Reglur netþjóns", + "Please try again or view your console for hints.": "Vinsamlegast reyndu aftur eða skoðaðu framkvæmdaraðilaatvikuskrá þína fyrir vísbendingar.", + "Error unsubscribing from list": "Galli við að afskrá frá lista", + "Error removing ignored user/server": "Villa við að fjarlægja hunsaða notanda/netþjón", + "Use the Desktop app to search encrypted messages": "Notaðu tölvuforritið til að sía dulkóðuð skilaboð", + "Use the Desktop app to see all encrypted files": "Notaðu tölvuforritið til að sjá öll dulkóðuð gögn", + "Not encrypted": "Ekki dulkóðað", + "Encrypted by a deleted session": "Dulkóðað af eyddu tæki", + "Encrypted by an unverified session": "Dulkóðað af ósannreynu tæki", + "Enable message search in encrypted rooms": "Virka skilaboðleit í dulkóðuð herbergjum", + "This room is end-to-end encrypted": "Þetta herbergi er enda-til-enda dulkóðað", + "Unencrypted": "Ódulkóðað", + "Messages in this room are end-to-end encrypted.": "Skilaboð í þessu herbergi eru enda-til-enda dulkóðuð.", + "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Hreinsun geymslu vafrans gæti lagað vandamálið en mun skrá þig út og valda því að dulkóðaður spjallferil sé ólæsilegur.", + "Send an encrypted reply…": "Senda dulritað svar…", + "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.": "Þegar hún er gerð virk er ekki hægt að óvirka dulkóðun. Skilaboð í dulkóðuðu herbergi geta ekki verið séð af netþjóni en bara af þátttakendum í herberginu. Virkun dulkóðuns gæti komið í veg fyrir að vélmenni og brúr virki rétt. Lærðu meira um dulkóðun.", + "Once enabled, encryption cannot be disabled.": "Þegar kveikt er á dulkóðun er ekki hægt að slökkva á henni.", + "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "Í dulkóðuðum herbergjum eins og þetta er slökkt á forskoðun vefslóða sjálfgefið til að tryggja að heimaþjónn þinn (þar sem forsýningin myndast) geti ekki safnað upplýsingum um tengla sem þú sérð í þessu herbergi.", + "URL Previews": "Forskoðun Vefslóða", + "URL previews are disabled by default for participants in this room.": "Forskoðun vefslóða er ekki sjálfgefið fyrir þátttakendur í þessu herbergi.", + "URL previews are enabled by default for participants in this room.": "Forskoðun vefslóða er sjálfgefið fyrir þátttakendur í þessu herbergi.", + "You have disabled URL previews by default.": "Þú hefur óvirkt forskoðun vefslóða sjálfgefið.", + "You have enabled URL previews by default.": "Þú hefur virkt forskoðun vefslóða sjálfgefið.", + "Enable URL previews by default for participants in this room": "Virkja forskoðun vefslóða sjálfgefið fyrir þátttakendur í þessu herbergi", + "Enable URL previews for this room (only affects you)": "Virkja forskoðun vefslóða fyrir þetta herbergi (einungis fyrir þig)", + "Room settings": "Herbergisstillingar", + "Room Settings - %(roomName)s": "Herbergisstillingar - %(roomName)s", + "Pinned Messages": "Föst Skilaboð", + "No pinned messages.": "Engin föst skilaboð.", + "This is the beginning of your direct message history with .": "Þetta er upphaf beinna skilaboðasögu með .", + "Recently Direct Messaged": "Nýlega Fékk Bein Skilaboð", + "Direct Messages": "Bein skilaboð", + "Direct message": "Beint skilaboð", + "Frequently Used": "Oft notað", + "Filter all spaces": "Sía öll rými", + "Filter your rooms and spaces": "Sía rými og herbergin þín", + "Filter rooms and people": "Sía fólk og herbergi", + "Filter": "Sía", + "Your Security Key is in your Downloads folder.": "Öryggislykillinn þinn er Niðurhals möppu þinni.", + "Download logs": "Niðurhal atvikaskrá", + "Preparing to download logs": "Undirbý niðurhal atvikaskráa", + "Downloading logs": "Er að niðurhala atvikaskrá", + "Error downloading theme information.": "Villa við að niðurhala þemaupplýsingum.", + "Message downloading sleep time(ms)": "Skilaboða niðurhal svefn tími(ms)", + "How fast should messages be downloaded.": "Hve hratt ætti að hlaða niður skilaboðum.", + "Download %(text)s": "Niðurhala %(text)s", + "Share Link to User": "Deila Hlekk að Notanda", + "You have verified this user. This user has verified all of their sessions.": "Þú hefur sannreynt þennan notanda. Þessi notandi hefur sannreynt öll tæki þeirra.", + "This user has not verified all of their sessions.": "Þessi notandi hefur ekki sannreynt öll tæki þeirra.", + "%(count)s verified sessions|one": "1 sannreynt tæki", + "%(count)s verified sessions|other": "%(count)s sannreyn tæki", + "Hide verified sessions": "Fela sannreyn tæki", + "Remove recent messages": "Fjarlægja nýleg skilaboð", + "Remove recent messages by %(user)s": "Fjarlægja nýleg skilaboð af %(user)s", + "Messages in this room are not end-to-end encrypted.": "Skilaboð í þessu herbergi eru ekki enda-til-enda dulkóðuð.", + "Who would you like to add to this community?": "Hvern viltu bæta við í þetta samfélagi?", + "You cannot place a call with yourself.": "Þú getur ekki byrjað símtal með sjálfum þér.", + "You cannot place VoIP calls in this browser.": "Þú getur ekki byrjað netsímtal (VoIP) köll í þessum vafra.", + "Call Failed": "Símtal Mistókst", + "Every page you use in the app": "Sérhver síða sem þú notar í forritinu", + "Which officially provided instance you are using, if any": "Hvaða opinberlega veittan heimaþjón sem þú notar, ef einhvern", + "Whether or not you're logged in (we don't record your username)": "Hvort sem þú ert skráð(ur) inn (við skráum ekki notendanafnið þitt)", + "Add Phone Number": "Bæta Við Símanúmeri", + "Click the button below to confirm adding this phone number.": "Smelltu á hnappinn hér að neðan til að staðfesta að bæta við þessu símanúmeri.", + "Confirm adding phone number": "Staðfestu að bæta við símanúmeri", + "Add Email Address": "Bæta Við Tölvupóstfangi", + "Click the button below to confirm adding this email address.": "Smelltu á hnappinn hér að neðan til að staðfesta að bæta við þessu netfangi.", + "Confirm adding email": "Staðfestu að bæta við tölvupósti", + "Upgrade": "Uppfæra", + "Verify": "Sannreyna", + "Security": "Öryggi", + "Trusted": "Traustað", + "Subscribe": "Skrá", + "Unsubscribe": "Afskrá", + "None": "Ekkert", + "Ignored/Blocked": "Hunsað/Hindrað", + "Trust": "Treysta", + "Flags": "Fánar", + "Symbols": "Tákn", + "Objects": "Hlutir", + "Activities": "Starfsemi", + "Document": "Skjal", + "Complete": "Búið", + "View": "Skoða", + "Preview": "Forskoðun", + "Strikethrough": "Yfirstrikletrað", + "Italics": "Skáletrað", + "Bold": "Feitletrað", + "ID": "Auðkenni (ID)", + "Disconnect": "Aftengja", + "Share": "Deila", + "Revoke": "Afturkalla", + "Discovery": "Uppgötvun", + "Actions": "Aðgerðir", + "Messages": "Skilaboð", + "Summary": "Yfirlit", + "Service": "Þjónusta", + "Removing…": "Er að fjarlægja…", + "Browse": "Skoða", + "Reset": "Endursetja", + "Sounds": "Hljóð", + "edited": "breytt", + "Re-join": "Taka þátt aftur", + "Banana": "Banani", + "Fire": "Eldur", + "Cloud": "Ský", + "Moon": "Tungl", + "Globe": "Heiminn", + "Mushroom": "Sveppur", + "Cactus": "Kaktus", + "Tree": "Tré", + "Flower": "Blóm", + "Butterfly": "Fiðrildi", + "Octopus": "Kolkrabbi", + "Fish": "Fiskur", + "Turtle": "Skjaldbaka", + "Penguin": "Mörgæs", + "Rooster": "Hani", + "Panda": "Pandabjörn", + "Rabbit": "Kanína", + "Elephant": "Fíll", + "Pig": "Svín", + "Unicorn": "Einhyrningur", + "Horse": "Hestur", + "Lion": "Ljón", + "Cat": "Köttur", + "Dog": "Hundur", + "Guest": "Gestur", + "Other": "Annað", + "Confirm": "Staðfesta", + "Username": "Notandanafn", + "Join": "Taka þátt", + "Encrypted": "Dulkóðað", + "Encryption": "Dulkóðun", + "Timeline": "Tímalína", + "Composer": "Ritari", + "Preferences": "Stillingar", + "Versions": "Útgáfur", + "FAQ": "Algengar spurningar", + "Theme": "Þema", + "General": "Almennt", + "No": "Nei", + "Yes": "Já", + "Verified!": "Sannreynt!", + "Retry": "Reyna aftur", + "Download": "Niðurhal", + "Next": "Næsta", + "Legal": "Löglegt", + "Demote": "Leggja til baka", + "%(oneUser)sleft %(count)s times|one": "%(oneUser)sfór", + "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)sfóru", + "%(oneUser)sjoined %(count)s times|one": "%(oneUser)sskráðist", + "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)sskráðust", + "Stickerpack": "Límmiða pakki", + "Replying": "Svara", + "%(duration)sd": "%(duration)sd", + "%(duration)sh": "%(duration)sklst", + "%(duration)sm": "%(duration)sm", + "%(duration)ss": "%(duration)ss", + "Emoji picker": "Tjáningartáknmyndvalmynd", + "Show less": "Sýna minna", + "%(count)s messages deleted.|one": "%(count)s skilaboð eytt.", + "%(count)s messages deleted.|other": "%(count)s skilaboðum eytt.", + "Message deleted on %(date)s": "Skilaboð eytt á %(date)s", + "Message edits": "Skilaboðs breytingar", + "List options": "Lista valkosti", + "Create a Group Chat": "Búa Til Hópspjall", + "Explore Public Rooms": "Kanna Almenningsherbergi", + "Explore public rooms": "Kanna almenningsherbergi", + "Explore all public rooms": "Kanna öll almenningsherbergi", + "Liberate your communication": "Frelsaðu samskipti þín", + "Welcome to ": "Velkomin til ", + "Welcome to %(appName)s": "Velkomin til %(appName)s" } diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json index 2d0edb77e6..207ff24d58 100644 --- a/src/i18n/strings/it.json +++ b/src/i18n/strings/it.json @@ -3285,5 +3285,118 @@ "You can add more later too, including already existing ones.": "Puoi aggiungerne anche altri in seguito, inclusi quelli già esistenti.", "Use another login": "Usa un altro accesso", "Verify your identity to access encrypted messages and prove your identity to others.": "Verifica la tua identità per accedere ai messaggi cifrati e provare agli altri che sei tu.", - "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Senza la verifica, non avrai accesso a tutti i tuoi messaggi e potresti apparire agli altri come non fidato." + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Senza la verifica, non avrai accesso a tutti i tuoi messaggi e potresti apparire agli altri come non fidato.", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Sei l'unica persona qui. Se esci, nessuno potrà entrare in futuro, incluso te.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Se reimposti tutto, ricomincerai senza sessioni fidate, senza utenti fidati e potresti non riuscire a vedere i messaggi passati.", + "Only do this if you have no other device to complete verification with.": "Fallo solo se non hai altri dispositivi con cui completare la verifica.", + "Reset everything": "Reimposta tutto", + "Forgotten or lost all recovery methods? Reset all": "Hai dimenticato o perso tutti i metodi di recupero? Reimposta tutto", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Se lo fai, ricorda che nessuno dei tuoi messaggi verrà eliminato, ma l'esperienza di ricerca potrà peggiorare per qualche momento mentre l'indice viene ricreato", + "View message": "Vedi messaggio", + "Zoom in": "Ingrandisci", + "Zoom out": "Rimpicciolisci", + "%(seconds)ss left": "%(seconds)ss rimanenti", + "Change server ACLs": "Modifica le ACL del server", + "Show options to enable 'Do not disturb' mode": "Mostra opzioni per attivare la modalità \"Non disturbare\"", + "You can select all or individual messages to retry or delete": "Puoi selezionare tutti o alcuni messaggi da riprovare o eliminare", + "Sending": "Invio in corso", + "Retry all": "Riprova tutti", + "Delete all": "Elimina tutti", + "Some of your messages have not been sent": "Alcuni tuoi messaggi non sono stati inviati", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s membri inclusi %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inclusi %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Vedi 1 membro", + "View all %(count)s members|other": "Vedi tutti i %(count)s membri", + "Failed to send": "Invio fallito", + "Enter your Security Phrase a second time to confirm it.": "Inserisci di nuovo la password di sicurezza per confermarla.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Scegli le stanze o le conversazioni da aggiungere. Questo è uno spazio solo per te, nessuno ne saprà nulla. Puoi aggiungerne altre in seguito.", + "What do you want to organise?": "Cosa vuoi organizzare?", + "Filter all spaces": "Filtra tutti gli spazi", + "Delete recording": "Elimina registrazione", + "Stop the recording": "Ferma la registrazione", + "%(count)s results in all spaces|one": "%(count)s risultato in tutti gli spazi", + "%(count)s results in all spaces|other": "%(count)s risultati in tutti gli spazi", + "You have no ignored users.": "Non hai utenti ignorati.", + "Play": "Riproduci", + "Pause": "Pausa", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Questa è una funzione sperimentale. Per ora, i nuovi utenti che ricevono un invito dovranno aprirlo su per entrare.", + "To join %(spaceName)s, turn on the Spaces beta": "Per entrare in %(spaceName)s, attiva la beta degli spazi", + "To view %(spaceName)s, turn on the Spaces beta": "Per vedere %(spaceName)s, attiva la beta degli spazi", + "Select a room below first": "Prima seleziona una stanza sotto", + "Communities are changing to Spaces": "Le comunità stanno diventando spazi", + "Join the beta": "Unisciti alla beta", + "Leave the beta": "Abbandona la beta", + "Beta": "Beta", + "Tap for more info": "Tocca per maggiori info", + "Spaces is a beta feature": "Gli spazi sono una funzionalità beta", + "Want to add a new room instead?": "Vuoi invece aggiungere una nuova stanza?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Aggiunta stanza...", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Aggiunta stanze... (%(progress)s di %(count)s)", + "Not all selected were added": "Non tutti i selezionati sono stati aggiunti", + "You can add existing spaces to a space.": "Puoi aggiungere spazi esistenti ad uno spazio.", + "Feeling experimental?": "Ti va di sperimentare?", + "You are not allowed to view this server's rooms list": "Non hai i permessi per vedere l'elenco di stanze del server", + "Error processing voice message": "Errore di elaborazione del vocale", + "We didn't find a microphone on your device. Please check your settings and try again.": "Non abbiamo trovato un microfono nel tuo dispositivo. Controlla le impostazioni e riprova.", + "No microphone found": "Nessun microfono trovato", + "We were unable to access your microphone. Please check your browser settings and try again.": "Non abbiamo potuto accedere al tuo microfono. Controlla le impostazioni del browser e riprova.", + "Unable to access your microphone": "Impossibile accedere al microfono", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Ti va di sperimentare? I laboratori sono il miglior modo di ottenere anteprime, testare nuove funzioni ed aiutare a modellarle prima che vengano pubblicate. Maggiori informazioni.", + "Your access token gives full access to your account. Do not share it with anyone.": "Il tuo token di accesso ti dà l'accesso al tuo account. Non condividerlo con nessuno.", + "Access Token": "Token di accesso", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Gli spazi sono un nuovo modo di raggruppare stanze e persone. Per entrare in uno spazio esistente ti serve un invito.", + "Please enter a name for the space": "Inserisci un nome per lo spazio", + "Connecting": "In connessione", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Permetti Peer-to-Peer per chiamate 1:1 (se lo attivi, l'altra parte potrebbe essere in grado di vedere il tuo indirizzo IP)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta disponibile per web, desktop e Android. Alcune funzioni potrebbero non essere disponibili nel tuo homeserver.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Puoi abbandonare la beta quando vuoi dalle impostazioni o toccando un'etichetta beta, come quella sopra.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s si ricaricherà con gli spazi attivati. Le comunità e le etichette personalizzate saranno nascoste.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta disponibile per web, desktop e Android. Grazie per la partecipazione alla beta.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s si ricaricherà con gli spazi disattivati. Le comunità e le etichette personalizzate saranno di nuovo visibili.", + "Spaces are a new way to group rooms and people.": "Gli spazi sono un nuovo modo di raggruppare stanze e persone.", + "Spaces are a beta feature.": "Gli spazi sono una funzionalità beta.", + "Search names and descriptions": "Cerca nomi e descrizioni", + "You may contact me if you have any follow up questions": "Potete contattarmi se avete altre domande", + "To leave the beta, visit your settings.": "Per abbandonare la beta, vai nelle impostazioni.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Verranno annotate la tua piattaforma e il nome utente per aiutarci ad usare la tua opinione al meglio.", + "%(featureName)s beta feedback": "Feedback %(featureName)s beta", + "Thank you for your feedback, we really appreciate it.": "Grazie per la tua opinione, lo appreziamo molto.", + "Beta feedback": "Feedback beta", + "Add reaction": "Aggiungi reazione", + "Send and receive voice messages": "Invia e ricevi messaggi vocali", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "La tua opinione aiuterà a migliorare gli spazi. Più dettagli dai, meglio è.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Se esci, %(brand)s si ricaricherà con gli spazi disattivati. Le comunità e le etichette personalizzate saranno di nuovo visibili.", + "Message search initialisation failed": "Inizializzazione ricerca messaggi fallita", + "Space Autocomplete": "Autocompletamento spazio", + "Go to my space": "Vai nel mio spazio", + "sends space invaders": "invia space invaders", + "Sends the given message with a space themed effect": "Invia il messaggio con un effetto a tema spaziale", + "Kick, ban, or invite people to your active room, and make you leave": "Buttare fuori, bandire o invitare persone nella tua stanza attiva e farti uscire", + "See when people join, leave, or are invited to this room": "Vedere quando le persone entrano, escono o sono invitate in questa stanza", + "Kick, ban, or invite people to this room, and make you leave": "Buttare fuori, bandire o invitare persone in questa stanza e farti uscire", + "See when people join, leave, or are invited to your active room": "Vedere quando le persone entrano, escono o sono invitate nella tua stanza attiva", + "Currently joining %(count)s rooms|one": "Stai entrando in %(count)s stanza", + "Currently joining %(count)s rooms|other": "Stai entrando in %(count)s stanze", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Prova parole diverse o controlla errori di battitura. Alcuni risultati potrebbero non essere visibili dato che sono privati e ti servirebbe un invito per unirti.", + "No results for \"%(query)s\"": "Nessun risultato per \"%(query)s\"", + "The user you called is busy.": "L'utente che hai chiamato è occupato.", + "User Busy": "Utente occupato", + "We're working on this as part of the beta, but just want to let you know.": "Stiamo lavorando a questo come parte della beta, ma vogliamo almeno fartelo sapere.", + "Teammates might not be able to view or join any private rooms you make.": "I tuoi compagni potrebbero non riuscire a vedere o unirsi a qualsiasi stanza privata che crei.", + "Or send invite link": "O manda un collegamento di invito", + "If you can't see who you’re looking for, send them your invite link below.": "Se non vedi chi stai cercando, mandagli il collegamento di invito sottostante.", + "Some suggestions may be hidden for privacy.": "Alcuni suggerimenti potrebbero essere nascosti per privacy.", + "Search for rooms or people": "Cerca stanze o persone", + "Forward message": "Inoltra messaggio", + "Open link": "Apri collegamento", + "Sent": "Inviato", + "You don't have permission to do this": "Non hai il permesso per farlo", + "Error - Mixed content": "Errore - Contenuto misto", + "Error loading Widget": "Errore di caricamento del widget", + "Nothing pinned, yet": "Non c'è ancora nulla di ancorato", + "If you have permissions, open the menu on any message and select Pin to stick them here.": "Se ne hai il permesso, apri il menu di qualsiasi messaggio e seleziona Fissa per ancorarlo qui.", + "Pinned messages": "Messaggi ancorati", + "End-to-end encryption isn't enabled": "La crittografia end-to-end non è attiva", + "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. Enable encryption in settings.": "I tuoi messaggi privati normalmente sono cifrati, ma questa stanza non lo è. Di solito ciò è dovuto ad un dispositivo non supportato o dal metodo usato, come gli inviti per email. Attiva la crittografia nelle impostazioni." } diff --git a/src/i18n/strings/ja.json b/src/i18n/strings/ja.json index 83d8961147..180d63f33e 100644 --- a/src/i18n/strings/ja.json +++ b/src/i18n/strings/ja.json @@ -351,7 +351,7 @@ "Mirror local video feed": "ローカルビデオ映像送信", "Send analytics data": "分析データを送信する", "Enable inline URL previews by default": "デフォルトでインライン URL プレビューを有効にする", - "Enable URL previews for this room (only affects you)": "この部屋の URL プレビューを有効にする (あなたにのみ影響する)", + "Enable URL previews for this room (only affects you)": "この部屋の URL プレビューを有効にする (あなたにのみ適用)", "Enable URL previews by default for participants in this room": "この部屋の参加者のためにデフォルトで URL プレビューを有効にする", "Room Colour": "部屋の色", "Enable widget screenshots on supported widgets": "サポートされているウィジェットでウィジェットのスクリーンショットを有効にする", @@ -502,10 +502,10 @@ "You have disabled URL previews by default.": "デフォルトで URL プレビューが無効です。", "URL previews are enabled by default for participants in this room.": "この部屋の参加者は、デフォルトで URL プレビューが有効です。", "URL previews are disabled by default for participants in this room.": "この部屋の参加者は、デフォルトで URL プレビューが無効です。", - "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "このような暗号化された部屋では、URL プレビューはデフォルトで無効になっており、あなたのホームサーバー(プレビューを作成する場所)がこの部屋に表示されているリンクに関する情報を収集できないようにしています。", + "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "この部屋のように暗号化された部屋では、URL プレビューはデフォルトで無効になっています。あなたのホームサーバー (プレビューを作成する) にこの部屋でやり取りされたリンクの情報を収集されないようにするためです。", "URL Previews": "URL プレビュー", "Historical": "履歴のある", - "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "メッセージにURLを入力すると、URLプレビューが表示され、タイトル、説明、ウェブサイトからの画像など、そのリンクに関する詳細情報が表示されます。", + "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "メッセージに URL が含まれる場合、タイトル、説明、ウェブサイトの画像などが URL プレビューとして表示されます。", "Error decrypting audio": "オーディオの復号化エラー", "Error decrypting attachment": "添付ファイルの復号化エラー", "Decrypt %(text)s": "%(text)s を復号", @@ -629,7 +629,7 @@ "ex. @bob:example.com": "例 @bob:example.com", "Add User": "ユーザーを追加", "Matrix ID": "Matirx ID", - "Matrix Room ID": "Matrix 部屋ID", + "Matrix Room ID": "Matrix 部屋 ID", "email address": "メールアドレス", "You have entered an invalid address.": "無効なアドレスを入力しました。", "Try using one of the following valid address types: %(validTypesList)s.": "次の有効なアドレスタイプのいずれかを使用してください:%(validTypesList)s", @@ -753,8 +753,8 @@ "Community %(groupId)s not found": "コミュニティ %(groupId)s が見つかりません", "Failed to load %(groupId)s": "%(groupId)s をロードできませんでした", "Failed to reject invitation": "招待を拒否できませんでした", - "This room is not public. You will not be able to rejoin without an invite.": "この部屋は公開されていません。 あなたは招待なしで再び参加することはできません。", - "Are you sure you want to leave the room '%(roomName)s'?": "本当にこの部屋「%(roomName)s」から退出してよろしいですか?", + "This room is not public. You will not be able to rejoin without an invite.": "この部屋は公開されていません。再度参加するには、招待が必要です。", + "Are you sure you want to leave the room '%(roomName)s'?": "この部屋「%(roomName)s」から退出してよろしいですか?", "Failed to leave room": "部屋からの退出に失敗しました", "Can't leave Server Notices room": "サーバー通知部屋を離れることはできません", "This room is used for important messages from the Homeserver, so you cannot leave it.": "この部屋はホームサーバーからの重要なメッセージに使用されるため、そこを離れることはできません。", @@ -1212,7 +1212,7 @@ "WARNING: Session already verified, but keys do NOT MATCH!": "警告: このセッションは検証済みです、しかし鍵が一致していません!", "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "警告: 鍵の検証に失敗しました!提供された鍵「%(fingerprint)s」は、%(userId)s およびセッション %(deviceId)s の署名鍵「%(fprint)s」と一致しません。これはつまり、あなたの会話が傍受・盗聴されようとしている恐れがあるということです!", "Show typing notifications": "入力中通知を表示する", - "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "あなたのホームサーバーが対応していない場合は (通話中に自己の IP アドレスが相手に共有されるのを防ぐために) 代替通話支援サーバー turn.matrix.org の使用を許可する", + "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "あなたのホームサーバーが対応していない場合は代替通話支援サーバー turn.matrix.org の使用を許可 (あなたの IP アドレスが通話相手に漏洩するのを防ぎます)", "Your homeserver does not support cross-signing.": "あなたのホームサーバーはクロス署名に対応していません。", "Cross-signing and secret storage are enabled.": "クロス署名および機密ストレージは有効です。", "Reset cross-signing and secret storage": "クロス署名および機密ストレージをリセット", @@ -2406,7 +2406,7 @@ "Suggested Rooms": "おすすめの部屋", "Explore space rooms": "スペース内の部屋を探索します", "You do not have permissions to add rooms to this space": "このスペースに部屋を追加する権限がありません", - "Add existing room": "既存の部屋を追加します", + "Add existing room": "既存の部屋を追加", "You do not have permissions to create new rooms in this space": "このスペースに新しい部屋を作成する権限がありません", "Send message": "メッセージを送ります", "Invite to this space": "このスペースに招待します", @@ -2417,8 +2417,8 @@ "Space options": "スペースのオプション", "Space Home": "スペースのホーム", "New room": "新しい部屋", - "Leave space": "スペースを離れる", - "Invite people": "人々を招待する", + "Leave space": "スペースを退出", + "Invite people": "人々を招待", "Share your public space": "公開スペースを共有する", "Invite members": "参加者を招待する", "Invite by email or username": "メールまたはユーザー名で招待する", @@ -2440,7 +2440,7 @@ "Create a space": "スペースを作成する", "Delete": "削除", "Jump to the bottom of the timeline when you send a message": "メッセージを送信する際にタイムライン最下部に移動します", - "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spacesはプロトタイプです。 コミュニティ、コミュニティv2、カスタムタグとは互換性がありません。 一部の機能には互換性のあるホームサーバーが必要です。", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "スペースはプロトタイプです。 コミュニティ、コミュニティv2、カスタムタグとは互換性がありません。 一部の機能には互換性のあるホームサーバーが必要です。", "This homeserver has been blocked by it's administrator.": "このホームサーバーは管理者によりブロックされています。", "This homeserver has been blocked by its administrator.": "このホームサーバーは管理者によりブロックされています。", "You're already in a call with this person.": "あなたは既にこの人と通話中です。", @@ -2448,5 +2448,60 @@ "Invite People": "ユーザーを招待", "Edit devices": "デバイスを編集", "%(count)s messages deleted.|one": "%(count)s 件のメッセージが削除されました。", - "%(count)s messages deleted.|other": "%(count)s 件のメッセージが削除されました。" + "%(count)s messages deleted.|other": "%(count)s 件のメッセージが削除されました。", + "To view %(spaceName)s, turn on the Spaces beta": "スペース Beta を有効にすると %(spaceName)s を表示できます", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "1 対 1 の通話で P2P の使用を許可 (有効にするとあなたの IP アドレスが通話相手に漏洩する可能性があります)", + "You have no ignored users.": "無視しているユーザーはいません。", + "Join the beta": "Beta に参加", + "Leave the beta": "Beta を終了", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta は、ウェブ、デスクトップ、Android で利用可能です。Beta をお試しいただきありがとうございます。", + "Your access token gives full access to your account. Do not share it with anyone.": "アクセストークンを用いるとあなたのアカウントの全てにアクセスできます。外部に公開しないでください。", + "Access Token": "アクセストークン", + "Filter all spaces": "全スペースを検索", + "Save Changes": "変更を保存", + "Edit settings relating to your space.": "スペースの設定を変更します。", + "Space settings": "スペースの設定", + "Spaces are a beta feature.": "スペースは Beta 機能です。", + "Spaces is a beta feature": "スペースは Beta 機能です", + "Spaces are a new way to group rooms and people.": "スペースは、部屋や人をグループ化する新しい方法です。", + "Spaces": "スペース", + "Welcome to ": "ようこそ ", + "Invite to just this room": "この部屋に招待", + "Invite to %(spaceName)s": "%(spaceName)s に招待", + "Quick actions": "クイックアクション", + "A private space for you and your teammates": "あなたとチームメイトのプライベートスペース", + "Me and my teammates": "自分とチームメイト", + "Just me": "自分専用", + "Make sure the right people have access to %(name)s": "必要な人が %(name)s にアクセスできるようにします", + "Who are you working with?": "誰が使いますか?", + "Beta": "Beta", + "Tap for more info": "タップして詳細を表示", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "スペースは、部屋や人をグループ化する新しい方法です。既存のスペースに参加するには、招待が必要です。", + "Check your devices": "デバイスを確認", + "Invite to %(roomName)s": "%(roomName)s へ招待", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta は、ウェブ、デスクトップ、Android で利用可能です。お使いのホームサーバーによっては一部機能が利用できない場合があります。", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s はスペースが有効な状態で再読み込みされます。コミュニティとカスタムタグは非表示になります。", + "Communities are changing to Spaces": "コミュニティはスペースに生まれ変わります", + "Beta feedback": "Beta フィードバック", + "%(featureName)s beta feedback": "%(featureName)s Beta フィードバック", + "Send feedback": "フィードバックを送信", + "Manage & explore rooms": "部屋の管理および検索", + "Select a room below first": "以下から部屋を選択してください", + "A private space to organise your rooms": "部屋を整理するためのプライベートスペース", + "Private space": "プライベートスペース", + "Leave Space": "スペースを退出", + "Make this space private": "このスペースを非公開にする", + "Welcome %(name)s": "ようこそ、%(name)s", + "Are you sure you want to leave the space '%(spaceName)s'?": "このスペース「%(spaceName)s」から退出してよろしいですか?", + "This space is not public. You will not be able to rejoin without an invite.": "このスペースは公開されていません。再度参加するには、招待が必要です。", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "この部屋のメンバーはあなただけです。あなたが退出すると、今後あなたを含めて誰もこの部屋に参加できなくなります。", + "Adding rooms... (%(progress)s out of %(count)s)|one": "部屋を追加中...", + "Adding rooms... (%(progress)s out of %(count)s)|other": "部屋を追加中... (%(progress)s / %(count)s)", + "Skip for now": "スキップ", + "What do you want to organise?": "どれを追加しますか?", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "部屋や会話を追加できます。これはあなた専用のスペースで、他の人からは見えません。後から部屋や会話を追加することもできます。", + "Support": "サポート", + "You can change these anytime.": "ここで入力した情報はいつでも編集できます。", + "Add some details to help people recognise it.": "情報を入力してください。", + "View dev tools": "開発者ツールを表示" } diff --git a/src/i18n/strings/lt.json b/src/i18n/strings/lt.json index 83b59681e7..e216c2de5a 100644 --- a/src/i18n/strings/lt.json +++ b/src/i18n/strings/lt.json @@ -260,7 +260,7 @@ "Do you want to set an email address?": "Ar norite nustatyti el. pašto adresą?", "Current password": "Dabartinis slaptažodis", "Password": "Slaptažodis", - "New Password": "Naujas Slaptažodis", + "New Password": "Naujas slaptažodis", "Failed to set display name": "Nepavyko nustatyti rodomo vardo", "Cannot add any more widgets": "Nepavyksta pridėti daugiau valdiklių", "Add a widget": "Pridėti valdiklį", @@ -2102,5 +2102,88 @@ "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "Jūs galite naudoti serverio parinktis, norėdami prisijungti prie kitų Matrix serverių, nurodydami kitą serverio URL. Tai leidžia jums naudoti Element su egzistuojančia paskyra kitame serveryje.", "Server Options": "Serverio Parinktys", "Your homeserver": "Jūsų serveris", - "Download logs": "Parsisiųsti žurnalus" + "Download logs": "Parsisiųsti žurnalus", + "edited": "pakeista", + "Edited at %(date)s. Click to view edits.": "Keista %(date)s. Spustelėkite kad peržiūrėti pakeitimus.", + "Edited at %(date)s": "Keista %(date)s", + "Click to view edits": "Spustelėkite kad peržiūrėti pakeitimus", + "Add an Integration": "Pridėti Integraciją", + "Message deleted on %(date)s": "Žinutė buvo ištrinta %(date)s", + "reacted with %(shortName)s": "reagavo su %(shortName)s", + " reacted with %(content)s": " reagavo su %(content)s", + "Reactions": "Reakcijos", + "Add reaction": "Pridėti reakciją", + "Error processing voice message": "Klaida apdorojant balso pranešimą", + "Declining …": "Atmetama …", + "Ignored attempt to disable encryption": "Bandymas išjungti šifravimą buvo ignoruotas", + "Verification timed out.": "Pasibaigė laikas patikrinimui.", + "Ask %(displayName)s to scan your code:": "Paprašykite %(displayName)s nuskaityti jūsų kodą:", + "Failed to withdraw invitation": "Nepavyko atšaukti pakvietimo", + "Disinvite this user from community?": "Atšaukti pakvietimą šiam vartotojui iš šios bendruomenės?", + "Unmute": "Atšaukti nutildymą", + "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.": "Dideliam žinučių kiekiui tai gali užtrukti kurį laiką. Prašome neperkrauti savo kliento.", + "No recent messages by %(user)s found": "Nerasta jokių naujesnių %(user)s žinučių", + "Accepting …": "Priimame …", + "You cancelled": "Jūs atšaukėte", + "You declined": "Jūs atsisakėte", + "You accepted": "Jūs priėmėte", + "Message Actions": "Žinutės Veiksmai", + "React": "Reaguoti", + "Edit devices": "Redaguoti įrenginius", + "Not encrypted": "Neužšifruota", + "Pinned messages": "Prisegtos žinutės", + "Nothing pinned, yet": "Kol kas nieko neprisegta", + "Mexico": "Meksika", + "Malaysia": "Malaizija", + "Iraq": "Irakas", + "Iran": "Iranas", + "Indonesia": "Indonezija", + "India": "Indija", + "Iceland": "Islandija", + "Greenland": "Grenlandija", + "Greece": "Graikija", + "Germany": "Vokietija", + "France": "Prancūzija", + "Finland": "Suomija", + "Estonia": "Estija", + "Egypt": "Egiptas", + "Denmark": "Danija", + "Croatia": "Kroatija", + "Colombia": "Kolumbija", + "China": "Kinija", + "Chile": "Čilė", + "Canada": "Kanada", + "Bulgaria": "Bulgarija", + "Brazil": "Brazilija", + "Belgium": "Belgija", + "Bangladesh": "Bangladešas", + "Try again": "Bandykite vėl", + "We couldn't log you in": "Mes negalėjome jūsų prijungti", + "You're already in a call with this person.": "Jūs jau esate pokalbyje su šiuo asmeniu.", + "Already in call": "Jau pokalbyje", + "Your Security Key": "Jūsų Saugumo Raktas", + "Confirm your Security Phrase": "Patvirtinkite savo Saugumo Frazę", + "Make a copy of your Security Key": "Sukurkite savo Saugumo Rakto kopiją", + "Generate a Security Key": "Generuoti Saugumo Raktą", + "Save your Security Key": "Išsaugoti savo Saugumo Raktą", + "Go to Settings": "Eiti į Nustatymus", + "Disable": "Išjungti", + "Search (must be enabled)": "Paieška (turi būti įjungta)", + "The user you called is busy.": "Vartotojas kuriam skambinate yra užsiėmęs.", + "User Busy": "Vartotojas Užsiėmęs", + "Any of the following data may be shared:": "Gali būti dalijamasi bet kuriais toliau nurodytais duomenimis:", + "Cancel search": "Atšaukti paiešką", + "Quick Reactions": "Greitos Reakcijos", + "Categories": "Kategorijos", + "Flags": "Vėliavos", + "Symbols": "Simboliai", + "Objects": "Objektai", + "Travel & Places": "Kelionės & Vietovės", + "Activities": "Veikla", + "Food & Drink": "Maistas & Gėrimai", + "Animals & Nature": "Gyvūnai & Gamta", + "Frequently Used": "Dažnai Naudojama", + "Something went wrong when trying to get your communities.": "Kažkas nepavyko bandant gauti jūsų bendruomenes.", + "Can't load this message": "Nepavyko įkelti šios žinutės", + "Submit logs": "Pateikti žurnalus" } diff --git a/src/i18n/strings/lv.json b/src/i18n/strings/lv.json index ed7da7dc6b..b56599f26e 100644 --- a/src/i18n/strings/lv.json +++ b/src/i18n/strings/lv.json @@ -1581,5 +1581,6 @@ "Failed to set topic": "Neizdevās iestatīt tematu", "Upload files": "Failu augšupielāde", "These files are too large to upload. The file size limit is %(limit)s.": "Šie faili pārsniedz augšupielādes izmēra limitu %(limit)s.", - "Upload files (%(current)s of %(total)s)": "Failu augšupielāde (%(current)s no %(total)s)" + "Upload files (%(current)s of %(total)s)": "Failu augšupielāde (%(current)s no %(total)s)", + "Check your devices": "Pārskatiet savas ierīces" } diff --git a/src/i18n/strings/nl.json b/src/i18n/strings/nl.json index de98a878e8..1818a64e54 100644 --- a/src/i18n/strings/nl.json +++ b/src/i18n/strings/nl.json @@ -6,7 +6,7 @@ "Admin": "Beheerder", "Advanced": "Geavanceerd", "Always show message timestamps": "Altijd tijdstempels van berichten tonen", - "Authentication": "Authenticatie", + "Authentication": "Login bevestigen", "%(items)s and %(lastItem)s": "%(items)s en %(lastItem)s", "and %(count)s others...|other": "en %(count)s anderen…", "and %(count)s others...|one": "en één andere…", @@ -59,7 +59,7 @@ "Close": "Sluiten", "Create new room": "Nieuw gesprek aanmaken", "Custom Server Options": "Aangepaste serverinstellingen", - "Dismiss": "Afwijzen", + "Dismiss": "Sluiten", "Error": "Fout", "Failed to forget room %(errCode)s": "Vergeten van gesprek is mislukt %(errCode)s", "Favourite": "Favoriet", @@ -171,13 +171,13 @@ "Fill screen": "Scherm vullen", "Filter room members": "Gespreksleden filteren", "Forget room": "Gesprek vergeten", - "For security, this session has been signed out. Please sign in again.": "Wegens veiligheidsredenen is deze sessie afgemeld. Gelieve u opnieuw aan te melden.", + "For security, this session has been signed out. Please sign in again.": "Wegens veiligheidsredenen is deze sessie uitgelogd. Gelieve opnieuw inloggen.", "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s": "%(userId)s van %(fromPowerLevel)s naar %(toPowerLevel)s", "Guests cannot join this room even if explicitly invited.": "Gasten - zelfs speficiek uitgenodigde - kunnen niet aan dit gesprek deelnemen.", "Hangup": "Ophangen", "Historical": "Historisch", "Home": "Thuis", - "Homeserver is": "Thuisserver is", + "Homeserver is": "Homeserver is", "Identity Server is": "Identiteitsserver is", "I have verified my email address": "Ik heb mijn e-mailadres geverifieerd", "Import": "Inlezen", @@ -193,7 +193,7 @@ "Invited": "Uitgenodigd", "Invites": "Uitnodigingen", "Invites user with given id to current room": "Nodigt de gebruiker met de gegeven ID uit in het huidige gesprek", - "Sign in with": "Aanmelden met", + "Sign in with": "Inloggen met", "Join as voice or video.": "Deelnemen met spraak of video.", "Join Room": "Gesprek toetreden", "%(targetName)s joined the room.": "%(targetName)s is tot het gesprek toegetreden.", @@ -240,7 +240,7 @@ "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s heeft %(targetDisplayName)s in het gesprek uitgenodigd.", "Server error": "Serverfout", "Server may be unavailable, overloaded, or search timed out :(": "De server is misschien onbereikbaar of overbelast, of het zoeken duurde te lang :(", - "Server may be unavailable, overloaded, or you hit a bug.": "De server is misschien onbereikbaar of overbelast, of je bent een fout tegengekomen.", + "Server may be unavailable, overloaded, or you hit a bug.": "De server is misschien onbereikbaar of overbelast, of je bent een bug tegengekomen.", "Server unavailable, overloaded, or something else went wrong.": "De server is onbereikbaar of overbelast, of er is iets anders foutgegaan.", "Session ID": "Sessie-ID", "%(senderName)s kicked %(targetName)s.": "%(senderName)s heeft %(targetName)s het gesprek uitgestuurd.", @@ -249,9 +249,9 @@ "%(senderName)s set a profile picture.": "%(senderName)s heeft een profielfoto ingesteld.", "%(senderName)s set their display name to %(displayName)s.": "%(senderName)s heeft %(displayName)s als weergavenaam aangenomen.", "Show timestamps in 12 hour format (e.g. 2:30pm)": "Tijd in 12-uursformaat tonen (bv. 2:30pm)", - "Signed Out": "Afgemeld", - "Sign in": "Aanmelden", - "Sign out": "Afmelden", + "Signed Out": "Uitgelogd", + "Sign in": "Inloggen", + "Sign out": "Uitloggen", "%(count)s of your messages have not been sent.|other": "Enkele van uw berichten zijn niet verstuurd.", "Someone": "Iemand", "The phone number entered looks invalid": "Het ingevoerde telefoonnummer ziet er ongeldig uit", @@ -266,7 +266,7 @@ "This room": "Dit gesprek", "This room is not accessible by remote Matrix servers": "Dit gesprek is niet toegankelijk vanaf externe Matrix-servers", "To use it, just wait for autocomplete results to load and tab through them.": "Om het te gebruiken, wacht u tot de autoaanvullen resultaten geladen zijn en tabt u erdoorheen.", - "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "U heeft gepoogd een gegeven punt in de tijdslijn van dit gesprek te laden, maar u bent niet bevoegd het desbetreffende bericht te zien.", + "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "U probeert een punt in de tijdlijn van dit gesprek te laden, maar u heeft niet voldoende rechten om het bericht te lezen.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Geprobeerd een gegeven punt in de tijdslijn van dit gesprek te laden, maar kon dit niet vinden.", "Unable to add email address": "Kan e-mailadres niet toevoegen", "Unable to remove contact information": "Kan contactinformatie niet verwijderen", @@ -329,7 +329,7 @@ "Please select the destination room for this message": "Selecteer het bestemmingsgesprek voor dit bericht", "New Password": "Nieuw wachtwoord", "Start automatically after system login": "Automatisch starten na systeemlogin", - "Analytics": "Statistische gegevens", + "Analytics": "Gebruiksgegevens", "Options": "Opties", "%(brand)s collects anonymous analytics to allow us to improve the application.": "%(brand)s verzamelt anonieme analysegegevens die het mogelijk maken de toepassing te verbeteren.", "Passphrases must match": "Wachtwoorden moeten overeenkomen", @@ -350,8 +350,8 @@ "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Weet u zeker dat u deze gebeurtenis wilt verwijderen? Besef wel dat het verwijderen van een van een gespreksnaams- of onderwerpswijziging die wijziging mogelijk teniet doet.", "Unknown error": "Onbekende fout", "Incorrect password": "Onjuist wachtwoord", - "Unable to restore session": "Sessieherstel lukt niet", - "If you have previously used a more recent version of %(brand)s, your session may be incompatible with this version. Close this window and return to the more recent version.": "Als u reeds een recentere versie van %(brand)s heeft gebruikt is uw sessie mogelijk onverenigbaar met deze versie. Sluit dit venster en ga terug naar die recentere versie.", + "Unable to restore session": "Herstellen van sessie mislukt", + "If you have previously used a more recent version of %(brand)s, your session may be incompatible with this version. Close this window and return to the more recent version.": "Als u een recentere versie van %(brand)s heeft gebruikt is uw sessie mogelijk niet geschikt voor deze versie. Sluit dit venster en ga terug naar die recentere versie.", "Unknown Address": "Onbekend adres", "ex. @bob:example.com": "bv. @jan:voorbeeld.com", "Add User": "Gebruiker toevoegen", @@ -628,10 +628,10 @@ "Room Notification": "Groepsgespreksmelding", "The information being sent to us to help make %(brand)s better includes:": "De informatie die naar ons wordt verstuurd om %(brand)s te verbeteren bevat:", "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Waar deze pagina identificeerbare informatie bevat, zoals een gespreks-, gebruikers- of groeps-ID, zullen deze gegevens verwijderd worden voordat ze naar de server gestuurd worden.", - "The platform you're on": "Het platform dat je gebruikt", + "The platform you're on": "Het platform dat u gebruikt", "The version of %(brand)s": "De versie van %(brand)s", "Your language of choice": "De door jou gekozen taal", - "Which officially provided instance you are using, if any": "Welke officieel aangeboden instantie je eventueel gebruikt", + "Which officially provided instance you are using, if any": "Welke officieel aangeboden instantie u eventueel gebruikt", "Whether or not you're using the Richtext mode of the Rich Text Editor": "Of u de tekstverwerker al dan niet in de modus voor opgemaakte tekst gebruikt", "Your homeserver's URL": "De URL van je homeserver", "In reply to ": "Als antwoord op ", @@ -658,8 +658,8 @@ "Who can join this community?": "Wie kan er tot deze gemeenschap toetreden?", "Everyone": "Iedereen", "Leave this community": "Deze gemeenschap verlaten", - "If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Voor het oplossen van, via GitHub, gemelde problemen helpen foutopsporingslogboeken ons enorm. Deze bevatten wel gebruiksgegevens (waaronder uw gebruikersnaam, de ID’s of bijnamen van de gesprekken en groepen die u heeft bezocht, en de namen van andere gebruikers), maar geen berichten.", - "Submit debug logs": "Foutopsporingslogboeken indienen", + "If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Voor het oplossen van, via GitHub, gemelde bugs helpen foutenlogboeken ons enorm. Deze bevatten wel uw gebruiksgegevens, maar geen berichten. Het bevat onder meer uw gebruikersnaam, de ID’s of bijnamen van de gesprekken en groepen die u heeft bezocht en de namen van andere gebruikers.", + "Submit debug logs": "Foutenlogboek versturen", "Opens the Developer Tools dialog": "Opent het dialoogvenster met ontwikkelaarsgereedschap", "Fetching third party location failed": "Het ophalen van de locatie van de derde partij is mislukt", "I understand the risks and wish to continue": "Ik begrijp de risico’s en wil graag verdergaan", @@ -727,11 +727,11 @@ "All messages (noisy)": "Alle berichten (luid)", "Enable them now": "Deze nu inschakelen", "Toolbox": "Gereedschap", - "Collecting logs": "Logboeken worden verzameld", + "Collecting logs": "Logs worden verzameld", "You must specify an event type!": "U dient een gebeurtenistype op te geven!", "(HTTP status %(httpStatus)s)": "(HTTP-status %(httpStatus)s)", "Invite to this room": "Uitnodigen voor dit gesprek", - "Send logs": "Logboeken versturen", + "Send logs": "Logs versturen", "All messages": "Alle berichten", "Call invitation": "Oproep-uitnodiging", "Downloading update...": "Update wordt gedownload…", @@ -778,17 +778,17 @@ "Thank you!": "Bedankt!", "With your current browser, the look and feel of the application may be completely incorrect, and some or all features may not function. If you want to try it anyway you can continue, but you are on your own in terms of any issues you may encounter!": "Met uw huidige browser kan de toepassing er volledig onjuist uitzien. Tevens is het mogelijk dat niet alle functies naar behoren werken. U kunt doorgaan als u het toch wilt proberen, maar bij problemen bent u volledig op uzelf aangewezen!", "Checking for an update...": "Bezig met controleren op updates…", - "Logs sent": "Logboeken verstuurd", - "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Foutopsporingslogboeken bevatten gebruiksgegevens over de toepassing, inclusief uw gebruikersnaam, de ID’s of bijnamen van de gesprekken die u heeft bezocht, evenals de gebruikersnamen van andere gebruikers. Ze bevatten geen berichten.", - "Failed to send logs: ": "Versturen van logboeken mislukt: ", - "Preparing to send logs": "Logboeken worden voorbereid voor versturen", + "Logs sent": "Logs verstuurd", + "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Foutenlogboeken bevatten gebruiksgegevens van de app inclusief uw gebruikersnaam, de ID’s of bijnamen van de gesprekken die u heeft bezocht, en de gebruikersnamen van andere gebruikers. Ze bevatten geen berichten.", + "Failed to send logs: ": "Versturen van logs mislukt: ", + "Preparing to send logs": "Logs voorbereiden voor versturen", "e.g. %(exampleValue)s": "bv. %(exampleValue)s", - "Every page you use in the app": "Iedere bladzijde die je in de toepassing gebruikt", + "Every page you use in the app": "Iedere bladzijde die u in de app gebruikt", "e.g. ": "bv. ", "Your device resolution": "De resolutie van je apparaat", "Missing roomId.": "roomId ontbreekt.", "Always show encryption icons": "Versleutelingspictogrammen altijd tonen", - "Send analytics data": "Statistische gegevens versturen", + "Send analytics data": "Gebruiksgegevens delen", "Enable widget screenshots on supported widgets": "Widget-schermafbeeldingen inschakelen op ondersteunde widgets", "Muted Users": "Gedempte gebruikers", "Popout widget": "Widget in nieuw venster openen", @@ -798,11 +798,11 @@ "Message visibility in Matrix is similar to email. Our forgetting your messages means that messages you have sent will not be shared with any new or unregistered users, but registered users who already have access to these messages will still have access to their copy.": "De zichtbaarheid van berichten in Matrix is zoals bij e-mails. Het vergeten van uw berichten betekent dat berichten die u heeft verstuurd niet meer gedeeld worden met nieuwe of ongeregistreerde gebruikers, maar geregistreerde gebruikers die al toegang hebben tot deze berichten zullen alsnog toegang hebben tot hun eigen kopie ervan.", "Please forget all messages I have sent when my account is deactivated (Warning: this will cause future users to see an incomplete view of conversations)": "Vergeet bij het sluiten van mijn account alle door mij verstuurde berichten (Let op: hierdoor zullen personen een onvolledig beeld krijgen van gesprekken)", "To continue, please enter your password:": "Voer uw wachtwoord in om verder te gaan:", - "Clear Storage and Sign Out": "Opslag wissen en afmelden", - "Send Logs": "Logboek versturen", + "Clear Storage and Sign Out": "Opslag wissen en uitloggen", + "Send Logs": "Logs versturen", "Refresh": "Herladen", "We encountered an error trying to restore your previous session.": "Het herstel van uw vorige sessie is mislukt.", - "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Het legen van de opslag van uw browser zal het probleem misschien verhelpen, maar zal u ook afmelden en uw gehele versleutelde gespreksgeschiedenis onleesbaar maken.", + "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Het wissen van de browseropslag zal het probleem misschien verhelpen, maar zal u ook uitloggen en uw gehele versleutelde gespreksgeschiedenis onleesbaar maken.", "Collapse Reply Thread": "Reactieketting dichtvouwen", "Can't leave Server Notices room": "Kan servermeldingsgesprek niet verlaten", "This room is used for important messages from the Homeserver, so you cannot leave it.": "Dit gesprek is bedoeld voor belangrijke berichten van de homeserver, dus u kunt het niet verlaten.", @@ -842,7 +842,7 @@ "Bulk options": "Bulkopties", "This homeserver has hit its Monthly Active User limit.": "Deze homeserver heeft zijn limiet voor maandelijks actieve gebruikers bereikt.", "This homeserver has exceeded one of its resource limits.": "Deze homeserver heeft één van zijn systeembronlimieten overschreden.", - "Whether or not you're logged in (we don't record your username)": "Of je al dan niet ingelogd bent (we slaan je gebruikersnaam niet op)", + "Whether or not you're logged in (we don't record your username)": "Of u al dan niet ingelogd bent (we slaan je gebruikersnaam niet op)", "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "Het bestand ‘%(fileName)s’ is groter dan de uploadlimiet van de homeserver", "Unable to load! Check your network connectivity and try again.": "Laden mislukt! Controleer je netwerktoegang en probeer het nogmaals.", "Failed to invite users to the room:": "Kon de volgende gebruikers hier niet uitnodigen:", @@ -1023,20 +1023,20 @@ "Profile picture": "Profielfoto", "Upgrade to your own domain": "Upgrade naar uw eigen domein", "Display Name": "Weergavenaam", - "Set a new account password...": "Stel een nieuw accountwachtwoord in…", + "Set a new account password...": "Stel een nieuw wachtwoord in…", "Email addresses": "E-mailadressen", "Phone numbers": "Telefoonnummers", "Language and region": "Taal en regio", "Theme": "Thema", "Account management": "Accountbeheer", - "Deactivating your account is a permanent action - be careful!": "Pas op! Het sluiten van uw account is onherroepelijk!", + "Deactivating your account is a permanent action - be careful!": "Pas op! Het sluiten van uw account kan niet teruggedraaid worden!", "General": "Algemeen", - "Legal": "Wettelijk", + "Legal": "Juridisch", "Credits": "Met dank aan", "For help with using %(brand)s, click here.": "Klik hier voor hulp bij het gebruiken van %(brand)s.", "For help with using %(brand)s, click here or start a chat with our bot using the button below.": "Klik hier voor hulp bij het gebruiken van %(brand)s, of begin een gesprek met onze robot met de knop hieronder.", - "Help & About": "Hulp & Info", - "Bug reporting": "Foutmeldingen", + "Help & About": "Hulp & info", + "Bug reporting": "Bug meldingen", "FAQ": "FAQ", "Versions": "Versies", "Preferences": "Instellingen", @@ -1046,10 +1046,10 @@ "Autocomplete delay (ms)": "Vertraging voor autoaanvullen (ms)", "Accept all %(invitedRooms)s invites": "Alle %(invitedRooms)s de uitnodigingen aannemen", "Key backup": "Sleutelback-up", - "Security & Privacy": "Veiligheid & Privacy", + "Security & Privacy": "Veiligheid & privacy", "Missing media permissions, click the button below to request.": "Mediatoestemmingen ontbreken, klik op de knop hieronder om deze aan te vragen.", "Request media permissions": "Mediatoestemmingen verzoeken", - "Voice & Video": "Spraak & Video", + "Voice & Video": "Spraak & video", "Room information": "Gespreksinformatie", "Internal room ID:": "Interne gespreks-ID:", "Room version": "Gespreksversie", @@ -1108,7 +1108,7 @@ "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Kan geen profielen voor de Matrix-ID’s hieronder vinden - wilt u ze toch uitnodigen?", "Invite anyway and never warn me again": "Alsnog uitnodigen en mij nooit meer waarschuwen", "Invite anyway": "Alsnog uitnodigen", - "Before submitting logs, you must create a GitHub issue to describe your problem.": "Voor u logboeken indient, dient u uw probleem te melden op GitHub.", + "Before submitting logs, you must create a GitHub issue to describe your problem.": "Voordat u logs indient, dient u uw probleem te melden in een GitHub issue.", "Unable to load commit detail: %(msg)s": "Kan commitdetail niet laden: %(msg)s", "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "Om uw gespreksgeschiedenis niet te verliezen vóór het uitloggen dient u uw veiligheidssleutel te exporteren. Dat moet vanuit de nieuwere versie van %(brand)s", "Incompatible Database": "Incompatibele database", @@ -1125,7 +1125,7 @@ "I don't want my encrypted messages": "Ik wil mijn versleutelde berichten niet", "Manually export keys": "Sleutels handmatig wegschrijven", "You'll lose access to your encrypted messages": "U zult de toegang tot uw versleutelde berichten verliezen", - "Are you sure you want to sign out?": "Weet u zeker dat u zich wilt afmelden?", + "Are you sure you want to sign out?": "Weet u zeker dat u wilt uitloggen?", "If you run into any bugs or have feedback you'd like to share, please let us know on GitHub.": "Als u fouten zou tegenkomen of voorstellen zou hebben, laat het ons dan weten op GitHub.", "To help avoid duplicate issues, please view existing issues first (and add a +1) or create a new issue if you can't find it.": "Voorkom dubbele meldingen: doorzoek eerst de bestaande meldingen (en voeg desgewenst een +1 toe). Maak enkel een nieuwe melding aan indien u niets kunt vinden.", "Report bugs & give feedback": "Fouten melden & feedback geven", @@ -1177,7 +1177,7 @@ "Homeserver URL": "Thuisserver-URL", "Identity Server URL": "Identiteitsserver-URL", "Free": "Gratis", - "Join millions for free on the largest public server": "Doe mee met miljoenen anderen op de grootste openbare server", + "Join millions for free on the largest public server": "Neem deel aan de grootste openbare server met miljoenen anderen", "Premium": "Premium", "Premium hosting for organisations Learn more": "Premium hosting voor organisaties Lees meer", "Other": "Overige", @@ -1193,7 +1193,7 @@ "Could not load user profile": "Kon gebruikersprofiel niet laden", "Your Matrix account on %(serverName)s": "Uw Matrix-account op %(serverName)s", "A verification email will be sent to your inbox to confirm setting your new password.": "Er is een verificatie-e-mail naar u gestuurd om het instellen van uw nieuwe wachtwoord te bevestigen.", - "Sign in instead": "Aanmelden", + "Sign in instead": "In plaats daarvan inloggen", "Your password has been reset.": "Uw wachtwoord is opnieuw ingesteld.", "Set a new password": "Stel een nieuw wachtwoord in", "Invalid homeserver discovery response": "Ongeldig homeserver-vindbaarheids-antwoord", @@ -1202,13 +1202,13 @@ "This homeserver does not support login using email address.": "Deze homeserver biedt geen ondersteuning voor inloggen met e-mailadres.", "Please contact your service administrator to continue using this service.": "Gelieve contact op te nemen met uw dienstbeheerder om deze dienst te blijven gebruiken.", "Failed to perform homeserver discovery": "Ontdekken van homeserver is mislukt", - "Sign in with single sign-on": "Aanmelden met eenmalige aanmelding", - "Create account": "Account aanmaken", + "Sign in with single sign-on": "Inloggen met eenmalig inloggen", + "Create account": "Registeren", "Registration has been disabled on this homeserver.": "Registratie is uitgeschakeld op deze homeserver.", "Unable to query for supported registration methods.": "Kan ondersteunde registratiemethoden niet opvragen.", "Create your account": "Maak uw account aan", "Keep going...": "Doe verder…", - "For maximum security, this should be different from your account password.": "Voor maximale veiligheid zou dit moeten verschillen van uw accountwachtwoord.", + "For maximum security, this should be different from your account password.": "Voor maximale veiligheid moet dit verschillen van uw accountwachtwoord.", "That matches!": "Dat komt overeen!", "That doesn't match.": "Dat komt niet overeen.", "Go back to set it again.": "Ga terug om het opnieuw in te stellen.", @@ -1228,11 +1228,11 @@ "Don't ask again": "Niet opnieuw vragen", "New Recovery Method": "Nieuwe herstelmethode", "A new recovery passphrase and key for Secure Messages have been detected.": "Er zijn een nieuw herstelwachtwoord en een nieuwe herstelsleutel voor beveiligde berichten gedetecteerd.", - "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Als u deze nieuwe herstelmethode niet heeft ingesteld, is het mogelijk dat een aanvaller toegang tot uw account probeert te krijgen. Wijzig onmiddellijk uw accountwachtwoord en stel in het instellingenmenu een nieuwe herstelmethode in.", + "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Als u deze nieuwe herstelmethode niet heeft ingesteld, is het mogelijk dat een aanvaller toegang tot uw account probeert te krijgen. Wijzig onmiddellijk uw wachtwoord en stel bij instellingen een nieuwe herstelmethode in.", "Go to Settings": "Ga naar instellingen", "Set up Secure Messages": "Beveiligde berichten instellen", "Recovery Method Removed": "Herstelmethode verwijderd", - "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Als u de herstelmethode niet heeft verwijderd, is het mogelijk dat er een aanvaller toegang tot uw account probeert te verkrijgen. Wijzig onmiddellijk uw accountwachtwoord en stel in het instellingenmenu een nieuwe herstelmethode in.", + "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Als u de herstelmethode niet heeft verwijderd, is het mogelijk dat er een aanvaller toegang tot uw account probeert te verkrijgen. Wijzig onmiddellijk uw wachtwoord en stel bij instellingen een nieuwe herstelmethode in.", "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "Let op: gesprekken bijwerken voegt gespreksleden niet automatisch toe aan de nieuwe versie van het gesprek. Er komt in het oude gesprek een koppeling naar het nieuwe, waarop gespreksleden moeten klikken om aan het nieuwe gesprek deel te nemen.", "Adds a custom widget by URL to the room": "Voegt met een URL een aangepaste widget toe aan het gesprek", "Please supply a https:// or http:// widget URL": "Voer een https://- of http://-widget-URL in", @@ -1264,8 +1264,8 @@ "GitHub issue": "GitHub-melding", "Notes": "Opmerkingen", "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Gelieve alle verdere informatie die zou kunnen helpen het probleem te analyseren (wat u aan het doen was, relevante gespreks-ID’s, gebruikers-ID’s, enz.) bij te voegen.", - "Sign out and remove encryption keys?": "Afmelden en versleutelingssleutels verwijderen?", - "To help us prevent this in future, please send us logs.": "Gelieve ons logboeken te sturen om dit in de toekomst te helpen voorkomen.", + "Sign out and remove encryption keys?": "Uitloggen en versleutelingssleutels verwijderen?", + "To help us prevent this in future, please send us logs.": "Stuur ons uw logs om dit in de toekomst te helpen voorkomen.", "Missing session data": "Sessiegegevens ontbreken", "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Sommige sessiegegevens, waaronder sleutels voor versleutelde berichten, ontbreken. Herstel de sleutels uit uw back-up door u af- en weer aan te melden.", "Your browser likely removed this data when running low on disk space.": "Uw browser heeft deze gegevens wellicht verwijderd toen de beschikbare opslagruimte vol was.", @@ -1297,7 +1297,7 @@ "Rejecting invite …": "Uitnodiging wordt geweigerd…", "Join the conversation with an account": "Neem deel aan het gesprek met een account", "Sign Up": "Registreren", - "Sign In": "Aanmelden", + "Sign In": "Inloggen", "You were kicked from %(roomName)s by %(memberName)s": "U bent uit %(roomName)s gezet door %(memberName)s", "Reason: %(reason)s": "Reden: %(reason)s", "Forget this room": "Dit gesprek vergeten", @@ -1315,7 +1315,7 @@ "%(roomName)s can't be previewed. Do you want to join it?": "%(roomName)s kan niet vooraf bekeken worden. Wilt u eraan deelnemen?", "This room doesn't exist. Are you sure you're at the right place?": "Dit gesprek bestaat niet. Weet u zeker dat u zich op de juiste plaats bevindt?", "Try again later, or ask a room admin to check if you have access.": "Probeer het later opnieuw, of vraag een gespreksbeheerder om te controleren of u wel toegang heeft.", - "%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please submit a bug report.": "De foutcode %(errcode)s is weergegeven bij het toetreden van het gesprek. Als u meent dat u dit bericht foutief te zien krijgt, gelieve dan een foutmelding in te dienen.", + "%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please submit a bug report.": "De foutcode %(errcode)s is weergegeven bij het toetreden van het gesprek. Als u meent dat u dit bericht foutief te zien krijgt, gelieve dan een bugmelding indienen.", "This room has already been upgraded.": "Dit gesprek is reeds geüpgraded.", "reacted with %(shortName)s": "heeft gereageerd met %(shortName)s", "edited": "bewerkt", @@ -1386,11 +1386,11 @@ "Resend edit": "Bewerking opnieuw versturen", "Resend %(unsentCount)s reaction(s)": "%(unsentCount)s reactie(s) opnieuw versturen", "Resend removal": "Verwijdering opnieuw versturen", - "Failed to re-authenticate due to a homeserver problem": "Opnieuw aanmelden is mislukt wegens een probleem met de homeserver", - "Failed to re-authenticate": "Opnieuw aanmelden is mislukt", + "Failed to re-authenticate due to a homeserver problem": "Opnieuw inloggen is mislukt wegens een probleem met de homeserver", + "Failed to re-authenticate": "Opnieuw inloggen is mislukt", "Enter your password to sign in and regain access to your account.": "Voer uw wachtwoord in om u aan te melden en toegang tot uw account te herkrijgen.", "Forgotten your password?": "Wachtwoord vergeten?", - "You're signed out": "U bent afgemeld", + "You're signed out": "U bent uitgelogd", "Clear personal data": "Persoonlijke gegevens wissen", "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Laat ons weten wat er verkeerd is gegaan, of nog beter, maak een foutrapport aan op GitHub, waarin u het probleem beschrijft.", "Identity Server": "Identiteitsserver", @@ -1401,7 +1401,7 @@ "Service": "Dienst", "Summary": "Samenvatting", "Sign in and regain access to your account.": "Meld u aan en herkrijg toegang tot uw account.", - "You cannot sign in to your account. Please contact your homeserver admin for more information.": "U kunt zich niet aanmelden met uw account. Neem voor meer informatie contact op met de beheerder van uw homeserver.", + "You cannot sign in to your account. Please contact your homeserver admin for more information.": "U kunt niet inloggen met uw account. Neem voor meer informatie contact op met de beheerder van uw homeserver.", "This account has been deactivated.": "Deze account is gesloten.", "Messages": "Berichten", "Actions": "Acties", @@ -1478,11 +1478,11 @@ "No recent messages by %(user)s found": "Geen recente berichten door %(user)s gevonden", "Try scrolling up in the timeline to see if there are any earlier ones.": "Probeer omhoog te scrollen in de tijdslijn om te kijken of er eerdere zijn.", "Remove recent messages by %(user)s": "Recente berichten door %(user)s verwijderen", - "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|other": "U staat op het punt %(count)s berichten door %(user)s te verwijderen. Dit is onherroepelijk. Wilt u doorgaan?", + "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|other": "U staat op het punt %(count)s berichten van %(user)s te verwijderen. Dit kan niet teruggedraaid worden. Wilt u doorgaan?", "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.": "Bij een groot aantal berichten kan dit even duren. Herlaad uw cliënt niet gedurende deze tijd.", "Remove %(count)s messages|other": "%(count)s berichten verwijderen", "Deactivate user?": "Gebruiker deactiveren?", - "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "Deze gebruiker deactiveren zal deze gebruiker uitloggen en verhinderen dat de gebruiker weer inlogt. Bovendien zal de gebruiker alle gesprekken waaraan de gebruiker deelneemt verlaten. Deze actie is onherroepelijk. Weet u zeker dat u deze gebruiker wilt deactiveren?", + "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "Deze gebruiker deactiveren zal deze gebruiker uitloggen en verhinderen dat de gebruiker weer inlogt. Bovendien zal de gebruiker alle gesprekken waaraan de gebruiker deelneemt verlaten. Deze actie is niet terug te draaien. Weet u zeker dat u deze gebruiker wilt deactiveren?", "Deactivate user": "Gebruiker deactiveren", "Remove recent messages": "Recente berichten verwijderen", "Bold": "Vet", @@ -1513,17 +1513,17 @@ "View": "Bekijken", "Find a room…": "Zoek een gesprek…", "Find a room… (e.g. %(exampleRoom)s)": "Zoek een gesprek… (bv. %(exampleRoom)s)", - "If you can't find the room you're looking for, ask for an invite or Create a new room.": "Als u het gesprek dat u zoekt niet kunt vinden, vraag dan een uitnodiging, of Maak een nieuw gesprek aan.", + "If you can't find the room you're looking for, ask for an invite or Create a new room.": "Als u het gesprek dat u zoekt niet kunt vinden, vraag dan een uitnodiging of maak een nieuw gesprek aan.", "Explore rooms": "Gesprekken ontdekken", "Show previews/thumbnails for images": "Miniaturen voor afbeeldingen tonen", "Clear cache and reload": "Cache wissen en herladen", - "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "U staat op het punt 1 bericht door %(user)s te verwijderen. Dit is onherroepelijk. Wilt u doorgaan?", + "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "U staat op het punt 1 bericht door %(user)s te verwijderen. Dit kan niet ongedaan gemaakt worden. Wilt u doorgaan?", "Remove %(count)s messages|one": "1 bericht verwijderen", "%(count)s unread messages including mentions.|other": "%(count)s ongelezen berichten, inclusief vermeldingen.", "%(count)s unread messages.|other": "%(count)s ongelezen berichten.", "Unread mentions.": "Ongelezen vermeldingen.", "Show image": "Afbeelding tonen", - "Please create a new issue on GitHub so that we can investigate this bug.": "Maak een nieuw rapport aan op GitHub opdat we dit probleem kunnen onderzoeken.", + "Please create a new issue on GitHub so that we can investigate this bug.": "Maak een nieuwe issue aan op GitHub zodat we deze bug kunnen onderzoeken.", "e.g. my-room": "bv. mijn-gesprek", "Close dialog": "Dialoog sluiten", "Please enter a name for the room": "Geef een naam voor het gesprek op", @@ -1549,7 +1549,7 @@ "Click the link in the email you received to verify and then click continue again.": "Open de koppeling in de ontvangen verificatie-e-mail, en klik dan op ‘Doorgaan’.", "%(creator)s created and configured the room.": "Gesprek gestart en ingesteld door %(creator)s.", "Setting up keys": "Sleutelconfiguratie", - "Verify this session": "Deze sessie verifiëren", + "Verify this session": "Verifieer deze sessie", "Encryption upgrade available": "Versleutelingsupgrade beschikbaar", "You can use /help to list available commands. Did you mean to send this as a message?": "Typ /help om alle opdrachten te zien. Was het uw bedoeling dit als bericht te sturen?", "Help": "Hulp", @@ -1621,7 +1621,7 @@ "Upgrade": "Upgraden", "Verify": "Verifiëren", "Later": "Later", - "Review": "Controle", + "Review": "Controleer", "Decline (%(counter)s)": "Afwijzen (%(counter)s)", "This bridge was provisioned by .": "Dank aan voor de brug.", "This bridge is managed by .": "Brug onderhouden door .", @@ -1636,14 +1636,14 @@ "Unable to load session list": "Kan sessielijst niet laden", "Delete %(count)s sessions|other": "%(count)s sessies verwijderen", "Delete %(count)s sessions|one": "%(count)s sessie verwijderen", - "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Of je %(brand)s op een apparaat gebruikt waarop een aanraakscherm de voornaamste invoermethode is", - "Whether you're using %(brand)s as an installed Progressive Web App": "Of je %(brand)s gebruikt als een geïnstalleerde Progressive-Web-App", + "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Of u %(brand)s op een apparaat gebruikt waarop een aanraakscherm de voornaamste invoermethode is", + "Whether you're using %(brand)s as an installed Progressive Web App": "Of u %(brand)s gebruikt als een geïnstalleerde Progressieve Web-App", "Your user agent": "Jouw gebruikersagent", "If you cancel now, you won't complete verifying the other user.": "Als u nu annuleert zult u de andere gebruiker niet verifiëren.", "If you cancel now, you won't complete verifying your other session.": "Als u nu annuleert zult u uw andere sessie niet verifiëren.", "Cancel entering passphrase?": "Wachtwoord annuleren?", "Show typing notifications": "Typmeldingen weergeven", - "Verify this session by completing one of the following:": "Verifieer deze sessie door een van het volgende te doen:", + "Verify this session by completing one of the following:": "Verifieer deze sessie door een van het volgende handelingen te doen:", "Scan this unique code": "Scan deze unieke code", "or": "of", "Compare unique emoji": "Vergelijk unieke emoji", @@ -1689,7 +1689,7 @@ "wait and try again later": "wachten en het later weer proberen", "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Gebruik een integratiebeheerder (%(serverName)s) om robots, widgets en stickerpakketten te beheren.", "Use an Integration Manager to manage bots, widgets, and sticker packs.": "Gebruik een integratiebeheerder om robots, widgets en stickerpakketten te beheren.", - "Manage integrations": "Beheer integraties", + "Manage integrations": "Integratiebeheerder", "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integratiebeheerders ontvangen configuratie-informatie en kunnen widgets aanpassen, gespreksuitnodigingen versturen en machtsniveau’s namens u aanpassen.", "Ban list rules - %(roomName)s": "Banlijstregels - %(roomName)s", "Server rules": "Serverregels", @@ -1746,11 +1746,11 @@ "Clear notifications": "Meldingen wissen", "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "U moet uw persoonlijke informatie van de identiteitsserver verwijderen voordat u zich ontkoppelt. Helaas kan de identiteitsserver op dit moment niet worden bereikt. Mogelijk is hij offline.", "Your homeserver does not support cross-signing.": "Uw homeserver biedt geen ondersteuning voor kruiselings ondertekenen.", - "Homeserver feature support:": "Homeserver ondersteund deze functies:", + "Homeserver feature support:": "Homeserver functie ondersteuning:", "exists": "aanwezig", "Sign In or Create Account": "Meld u aan of maak een account aan", "Use your account or create a new one to continue.": "Gebruik uw bestaande account of maak een nieuwe aan om verder te gaan.", - "Create Account": "Account aanmaken", + "Create Account": "Registeren", "Displays information about a user": "Geeft informatie weer over een gebruiker", "Order rooms by name": "Gesprekken sorteren op naam", "Show rooms with unread notifications first": "Gesprekken met ongelezen meldingen eerst tonen", @@ -1872,10 +1872,10 @@ "More options": "Meer opties", "Language Dropdown": "Taalselectie", "Destroy cross-signing keys?": "Sleutels voor kruiselings ondertekenen verwijderen?", - "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.": "Het verwijderen van sleutels voor kruiselings ondertekenen is onherroepelijk. Iedereen waarmee u geverifieerd heeft zal beveiligingswaarschuwingen te zien krijgen. U wilt dit hoogstwaarschijnlijk niet doen, tenzij u alle apparaten heeft verloren waarmee u kruiselings kon ondertekenen.", + "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.": "Het verwijderen van sleutels voor kruiselings ondertekenen is niet terug te draaien. Iedereen waarmee u geverifieerd heeft zal beveiligingswaarschuwingen te zien krijgen. U wilt dit hoogstwaarschijnlijk niet doen, tenzij u alle apparaten heeft verloren waarmee u kruiselings kon ondertekenen.", "Clear cross-signing keys": "Sleutels voor kruiselings ondertekenen wissen", "Clear all data in this session?": "Alle gegevens in deze sessie verwijderen?", - "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Het verwijderen van alle gegevens in deze sessie is onherroepelijk. Versleutelde berichten zullen verloren gaan, tenzij u een back-up van de sleutels heeft.", + "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Het verwijderen van alle gegevens in deze sessie is niet terug te draaien. Versleutelde berichten zullen verloren gaan, tenzij u een back-up van de sleutels heeft.", "Verify session": "Sessie verifiëren", "Session name": "Sessienaam", "Session key": "Sessiesleutel", @@ -1884,7 +1884,7 @@ "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "Verifieer dit apparaat om het als vertrouwd te markeren. Door dit apparaat te vertrouwen geeft u extra gemoedsrust aan uzelf en andere gebruikers bij het gebruik van eind-tot-eind-versleutelde berichten.", "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Dit apparaat verifiëren zal het als vertrouwd markeren, en gebruikers die met u geverifieerd hebben zullen het vertrouwen.", "Integrations are disabled": "Integraties zijn uitgeschakeld", - "Enable 'Manage Integrations' in Settings to do this.": "Schakel in het Algemene Instellingenmenu ‘Beheer integraties’ in om dit te doen.", + "Enable 'Manage Integrations' in Settings to do this.": "Schakel de ‘Integratiebeheerder’ in in uw Instellingen om dit te doen.", "Integrations not allowed": "Integraties niet toegestaan", "Your %(brand)s doesn't allow you to use an Integration Manager to do this. Please contact an admin.": "Uw %(brand)s laat u geen integratiebeheerder gebruiken om dit te doen. Neem contact op met een beheerder.", "Failed to invite the following users to chat: %(csvUsers)s": "Het uitnodigen van volgende gebruikers voor gesprek is mislukt: %(csvUsers)s", @@ -1909,7 +1909,7 @@ "Automatically invite users": "Gebruikers automatisch uitnodigen", "Upgrade private room": "Privégesprek upgraden", "Upgrade public room": "Openbaar gesprek upgraden", - "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Het bijwerken van een gesprek is een gevorderde actie en wordt meestal aanbevolen wanneer een gesprek onstabiel is door fouten, ontbrekende functies of problemen met de beveiliging.", + "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Het bijwerken van een gesprek is een gevorderde actie en wordt meestal aanbevolen wanneer een gesprek onstabiel is door bugs, ontbrekende functies of problemen met de beveiliging.", "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.": "Dit heeft meestal enkel een invloed op de manier waarop het gesprek door de server verwerkt wordt. Als u problemen met uw %(brand)s ondervindt, dien dan een foutmelding in.", "You'll upgrade this room from to .": "U upgrade dit gesprek van naar .", "This will allow you to return to your account after signing out, and sign in on other sessions.": "Daardoor kunt u na afmelding terugkeren tot uw account, en u bij andere sessies aanmelden.", @@ -1927,25 +1927,25 @@ "Remove for me": "Verwijderen voor mezelf", "User Status": "Gebruikersstatus", "Country Dropdown": "Landselectie", - "Confirm your identity by entering your account password below.": "Bevestig uw identiteit door hieronder uw accountwachtwoord in te voeren.", + "Confirm your identity by entering your account password below.": "Bevestig uw identiteit door hieronder uw wachtwoord in te voeren.", "No identity server is configured so you cannot add an email address in order to reset your password in the future.": "Er is geen identiteitsserver geconfigureerd, dus u kunt geen e-mailadres toevoegen om in de toekomst een nieuw wachtwoord in te stellen.", "Jump to first unread room.": "Ga naar het eerste ongelezen gesprek.", "Jump to first invite.": "Ga naar de eerste uitnodiging.", "Session verified": "Sessie geverifieerd", - "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. Ze heeft nu toegang tot uw versleutelde berichten, en de sessie zal voor andere gebruikers als vertrouwd gemarkeerd worden.", + "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. U heeft nu toegang tot uw versleutelde berichten en deze sessie zal voor andere gebruikers als vertrouwd gemarkeerd worden.", "Your new session is now verified. Other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. Ze zal voor andere gebruikers als vertrouwd gemarkeerd worden.", "Without completing security on this session, it won’t have access to encrypted messages.": "Als u de beveiliging van deze sessie niet vervolledigt, zal ze geen toegang hebben tot uw versleutelde berichten.", "Go Back": "Terugkeren", "Changing your password will reset any end-to-end encryption keys on all of your sessions, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another session before resetting your password.": "Door uw wachtwoord te wijzigen stelt u alle eind-tot-eind-versleutelingssleutels op al uw sessies opnieuw in, waardoor uw versleutelde gespreksgeschiedenis onleesbaar wordt. Stel uw sleutelback-up in of sla uw gesprekssleutels van een andere sessie op voor u een nieuw wachtwoord instelt.", "You have been logged out of all sessions and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "U bent uitgelogd bij al uw sessies en zult geen pushberichten meer ontvangen. Meld u op elk apparaat opnieuw aan om meldingen opnieuw in te schakelen.", "Regain access to your account and recover encryption keys stored in this session. Without them, you won’t be able to read all of your secure messages in any session.": "Ontvang toegang tot uw account en herstel de tijdens deze sessie opgeslagen versleutelingssleutels, zonder deze sleutels zijn sommige van uw versleutelde berichten in uw sessies onleesbaar.", - "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Let op: uw persoonlijke gegevens (waaronder versleutelingssleutels) zijn nog steeds opgeslagen in deze sessie. Wis ze wanneer u klaar bent met deze sessie, of wanneer u zich wilt aanmelden met een andere account.", + "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Let op: uw persoonlijke gegevens (waaronder versleutelingssleutels) zijn nog steeds opgeslagen in deze sessie. Wis ze wanneer u klaar bent met deze sessie, of wanneer u wilt inloggen met een andere account.", "Command Autocomplete": "Opdrachten autoaanvullen", "DuckDuckGo Results": "DuckDuckGo-resultaten", - "Enter your account password to confirm the upgrade:": "Voer uw accountwachtwoord in om het upgraden te bevestigen:", + "Enter your account password to confirm the upgrade:": "Voer uw wachtwoord in om het upgraden te bevestigen:", "Restore your key backup to upgrade your encryption": "Herstel uw sleutelback-up om uw versleuteling te upgraden", "Restore": "Herstellen", - "You'll need to authenticate with the server to confirm the upgrade.": "U zult zich moeten aanmelden bij de server om het upgraden te bevestigen.", + "You'll need to authenticate with the server to confirm the upgrade.": "U zult moeten inloggen bij de server om het upgraden te bevestigen.", "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade deze sessie om er andere sessies mee te verifiëren, waardoor deze ook de toegang verkrijgen tot uw versleutelde berichten en deze voor andere gebruikers als vertrouwd gemarkeerd worden.", "Set up with a recovery key": "Instellen met een herstelsleutel", "Keep a copy of it somewhere secure, like a password manager or even a safe.": "Bewaar een kopie op een veilige plaats, zoals in een wachtwoordbeheerder of een kluis.", @@ -1970,7 +1970,7 @@ "To report a Matrix-related security issue, please read the Matrix.org Security Disclosure Policy.": "Bekijk eerst het beveiligingsopenbaarmakingsbeleid van Matrix.org als u een probleem met de beveiliging van Matrix wilt melden.", "Not currently indexing messages for any room.": "Er worden momenteel voor geen enkel gesprek berichten geïndexeerd.", "%(doneRooms)s out of %(totalRooms)s": "%(doneRooms)s van %(totalRooms)s", - "Where you’re logged in": "Waar u ingelogd bent", + "Where you’re logged in": "Waar u bent ingelogd", "Manage the names of and sign out of your sessions below or verify them in your User Profile.": "Beheer hieronder de namen van uw sessies en meld ze af. Of verifieer ze in uw gebruikersprofiel.", "Use Single Sign On to continue": "Ga verder met eenmalige aanmelding", "Confirm adding this email address by using Single Sign On to prove your identity.": "Bevestig je identiteit met je eenmalige aanmelding om dit e-mailadres toe te voegen.", @@ -1989,7 +1989,7 @@ "Command failed": "Opdracht mislukt", "Could not find user in room": "Kon die deelnemer aan het gesprek niet vinden", "Please supply a widget URL or embed code": "Gelieve een widgetURL of in te bedden code te geven", - "Send a bug report with logs": "Rapporteer een fout, met foutopsporingslogboek bijgesloten", + "Send a bug report with logs": "Stuur een bugrapport met logs", "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s heeft het gesprek %(oldRoomName)s hernoemd tot %(newRoomName)s.", "%(senderName)s added the alternative addresses %(addresses)s for this room.|other": "%(senderName)s heeft dit gesprek de nevenadressen %(addresses)s toegekend.", "%(senderName)s added the alternative addresses %(addresses)s for this room.|one": "%(senderName)s heeft dit gesprek het nevenadres %(addresses)s toegekend.", @@ -2315,7 +2315,7 @@ "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Uw homeserver wees uw inlogpoging af. Dit kan zijn doordat het te lang heeft geduurd. Probeer het opnieuw. Als dit probleem zich blijft voordoen, neem contact op met de beheerder van uw homeserver.", "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Uw homeserver was onbereikbaar en kon u niet inloggen, probeer het opnieuw. Wanneer dit probleem zich blijft voordoen, neem contact op met de beheerder van uw homeserver.", "Try again": "Probeer opnieuw", - "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "De browser is verzocht uw homeserver te onthouden die u gebruikt om zich aan te melden, maar is deze vergeten. Ga naar de aanmeldpagina en probeer het opnieuw.", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "De browser is verzocht uw homeserver te onthouden die u gebruikt om in te loggen, maar helaas heeft de browser deze vergeten. Ga naar de inlog-pagina en probeer het opnieuw.", "We couldn't log you in": "We konden u niet inloggen", "Room Info": "Gespreksinfo", "Matrix.org is the biggest public homeserver in the world, so it’s a good place for many.": "Matrix.org is de grootste openbare homeserver van de wereld, dus het is een goede plek voor vele.", @@ -2366,7 +2366,7 @@ "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "De beheerder van uw server heeft eind-tot-eind-versleuteling standaard uitgeschakeld in alle privégesprekken en directe gesprekken.", "Scroll to most recent messages": "Spring naar meest recente bericht", "The authenticity of this encrypted message can't be guaranteed on this device.": "De echtheid van dit versleutelde bericht kan op dit apparaat niet worden gegarandeerd.", - "To link to this room, please add an address.": "Voeg een adres toe om naar deze kamer te verwijzen.", + "To link to this room, please add an address.": "Voeg een adres toe om naar dit gesprek te kunnen verwijzen.", "Remove messages sent by others": "Berichten van anderen verwijderen", "Privacy": "Privacy", "Keyboard Shortcuts": "Sneltoetsen", @@ -2416,8 +2416,8 @@ "sends snowfall": "Stuur sneeuwvlokken", "sends confetti": "verstuurt confetti", "sends fireworks": "Stuur vuurwerk", - "Downloading logs": "Logboeken downloaden", - "Uploading logs": "Logboeken versturen", + "Downloading logs": "Logs downloaden", + "Uploading logs": "Logs uploaden", "Use Ctrl + Enter to send a message": "Gebruik Ctrl + Enter om een bericht te sturen", "Use Command + Enter to send a message": "Gebruik Command (⌘) + Enter om een bericht te sturen", "Use Ctrl + F to search": "Ctrl + F om te zoeken gebruiken", @@ -2425,7 +2425,7 @@ "Use a more compact ‘Modern’ layout": "Compacte 'Modern'-layout inschakelen", "Use custom size": "Aangepaste lettergrootte gebruiken", "Font size": "Lettergrootte", - "Enable advanced debugging for the room list": "Geavanceerde foutopsporing voor de gesprekkenlijst inschakelen", + "Enable advanced debugging for the room list": "Geavanceerde bugopsporing voor de gesprekkenlijst inschakelen", "Render LaTeX maths in messages": "Weergeef LaTeX-wiskundenotatie in berichten", "Change notification settings": "Meldingsinstellingen wijzigen", "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", @@ -2449,7 +2449,7 @@ "%(senderName)s has updated the widget layout": "%(senderName)s heeft de widget-indeling bijgewerkt", "%(senderName)s declined the call.": "%(senderName)s heeft de oproep afgewezen.", "(an error occurred)": "(een fout is opgetreden)", - "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "U heeft eerder een nieuwere versie van %(brand)s in deze sessie gebruikt. Om deze versie opnieuw met eind-tot-eind-versleuteling te gebruiken, zult u zich moeten afmelden en opnieuw aanmelden.", + "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "U heeft eerder een nieuwere versie van %(brand)s in deze sessie gebruikt. Om deze versie opnieuw met eind-tot-eind-versleuteling te gebruiken, zult u moeten uitloggen en opnieuw inloggen.", "Block anyone not part of %(serverName)s from ever joining this room.": "Weiger iedereen die geen deel uitmaakt van %(serverName)s aan dit gesprek deel te nemen.", "Create a room in %(communityName)s": "Een gesprek aanmaken in %(communityName)s", "Enable end-to-end encryption": "Eind-tot-eind-versleuteling inschakelen", @@ -2467,7 +2467,7 @@ "Show": "Toon", "People you know on %(brand)s": "Personen die u kent van %(brand)s", "Add another email": "Nog een e-mailadres toevoegen", - "Download logs": "Download logboeken", + "Download logs": "Logs downloaden", "Add a new server...": "Een nieuwe server toevoegen…", "Server name": "Servernaam", "Add a new server": "Een nieuwe server toevoegen", @@ -2479,8 +2479,8 @@ "Enter a server name": "Geef een servernaam", "Continue with %(provider)s": "Doorgaan met %(provider)s", "Homeserver": "Homeserver", - "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "U kunt de aangepaste serverinstellingen gebruiken om u aan te melden bij andere Matrix-servers, door een andere homeserver-URL in te voeren. Dit laat u toe Element te gebruiken met een bestaande Matrix-account bij een andere homeserver.", - "Server Options": "Serverinstellingen", + "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "U kunt de server opties wijzigen om in te loggen bij andere Matrix-servers, wijzig hiervoor de homeserver-URL. Hiermee kunt u Element gebruiken met een al bestaand Matrix-account van een andere homeserver.", + "Server Options": "Server opties", "This address is already in use": "Dit adres is al in gebruik", "This address is available to use": "Dit adres kan worden gebruikt", "Please provide a room address": "Geef een gespreksadres", @@ -2539,7 +2539,7 @@ "Invite by email": "Via e-mail uitnodigen", "Click the button below to confirm your identity.": "Druk op de knop hieronder om uw identiteit te bevestigen.", "Confirm to continue": "Bevestig om door te gaan", - "Report a bug": "Een fout rapporteren", + "Report a bug": "Een bug rapporteren", "Comment": "Opmerking", "Add comment": "Opmerking toevoegen", "Tell us below how you feel about %(brand)s so far.": "Vertel ons hoe %(brand)s u tot dusver bevalt.", @@ -2640,7 +2640,7 @@ "Use this when referencing your community to others. The community ID cannot be changed.": "Gebruik dit om anderen naar uw gemeenschap te verwijzen. De gemeenschaps-ID kan later niet meer veranderd worden.", "Please go into as much detail as you like, so we can track down the problem.": "Gebruik a.u.b. zoveel mogelijk details, zodat wij uw probleem kunnen vinden.", "There are two ways you can provide feedback and help us improve %(brand)s.": "U kunt op twee manieren feedback geven en ons helpen %(brand)s te verbeteren.", - "Please view existing bugs on Github first. No match? Start a new one.": "Bekijk eerst de bestaande problemen op Github. Maak een nieuwe aan wanneer u uw probleem niet heeft gevonden.", + "Please view existing bugs on Github first. No match? Start a new one.": "Bekijk eerst de bestaande bugs op GitHub. Maak een nieuwe aan wanneer u uw bugs niet heeft gevonden.", "Invite someone using their name, email address, username (like ) or share this room.": "Nodig iemand uit door gebruik te maken van hun naam, e-mailadres, gebruikersnaam (zoals ) of deel dit gesprek.", "Invite someone using their name, username (like ) or share this room.": "Nodig iemand uit door gebruik te maken van hun naam, gebruikersnaam (zoals ) of deel dit gesprek.", "Send feedback": "Feedback versturen", @@ -2738,13 +2738,13 @@ "Use Security Key or Phrase": "Gebruik veiligheidssleutel of -wachtwoord", "Decide where your account is hosted": "Kies waar uw account wordt gehost", "Host account on": "Host uw account op", - "Already have an account? Sign in here": "Heeft u al een account? Aanmelden", + "Already have an account? Sign in here": "Heeft u al een account? Inloggen", "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s of %(usernamePassword)s", "Continue with %(ssoButtons)s": "Ga verder met %(ssoButtons)s", "That username already exists, please try another.": "Die gebruikersnaam bestaat al, probeer een andere.", "New? Create account": "Nieuw? Maak een account aan", "If you've joined lots of rooms, this might take a while": "Als u zich bij veel gesprekken heeft aangesloten, kan dit een tijdje duren", - "Signing In...": "Aanmelden...", + "Signing In...": "Inloggen...", "Syncing...": "Synchroniseren...", "There was a problem communicating with the homeserver, please try again later.": "Er was een communicatieprobleem met de homeserver, probeer het later opnieuw.", "Community and user menu": "Gemeenschaps- en gebruikersmenu", @@ -2754,7 +2754,7 @@ "User settings": "Gebruikersinstellingen", "Security & privacy": "Veiligheid & privacy", "New here? Create an account": "Nieuw hier? Maak een account", - "Got an account? Sign in": "Heeft u een account? Aanmelden", + "Got an account? Sign in": "Heeft u een account? Inloggen", "Failed to find the general chat for this community": "De algemene chat voor deze gemeenschap werd niet gevonden", "Filter rooms and people": "Gespreken en personen filteren", "Explore rooms in %(communityName)s": "Ontdek de gesprekken van %(communityName)s", @@ -2774,8 +2774,8 @@ "Create community": "Gemeenschap aanmaken", "Attach files from chat or just drag and drop them anywhere in a room.": "Voeg bestanden toe vanuit het gesprek of sleep ze in een gesprek.", "No files visible in this room": "Geen bestanden zichtbaar in dit gesprek", - "Sign in with SSO": "Aanmelden met SSO", - "Use email to optionally be discoverable by existing contacts.": "Gebruik e-mail om optioneel ontdekt te worden door bestaande contacten.", + "Sign in with SSO": "Inloggen met SSO", + "Use email to optionally be discoverable by existing contacts.": "Optioneel kunt u uw e-mail ook gebruiken om ontdekt te worden door al bestaande contacten.", "Use email or phone to optionally be discoverable by existing contacts.": "Gebruik e-mail of telefoon om optioneel ontdekt te kunnen worden door bestaande contacten.", "Add an email to be able to reset your password.": "Voeg een e-mail toe om uw wachtwoord te kunnen resetten.", "Forgot password?": "Wachtwoord vergeten?", @@ -2852,7 +2852,7 @@ "Continuing temporarily allows the %(hostSignupBrand)s setup process to access your account to fetch verified email addresses. This data is not stored.": "Door tijdelijk door te gaan, krijgt het installatieproces van %(hostSignupBrand)s toegang tot uw account om geverifieerde e-mailadressen op te halen. Deze gegevens worden niet opgeslagen.", "Failed to connect to your homeserver. Please close this dialog and try again.": "Kan geen verbinding maken met uw homeserver. Sluit dit dialoogvenster en probeer het opnieuw.", "Are you sure you wish to abort creation of the host? The process cannot be continued.": "Weet u zeker dat u het aanmaken van de host wilt afbreken? Het proces kan niet worden voortgezet.", - "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIP: Als u een bug start, stuur ons dan debug logs om ons te helpen het probleem op te sporen.", + "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIP: Als u een nieuwe bug maakt, stuur ons dan uw foutenlogboek om ons te helpen het probleem op te sporen.", "There was an error updating your community. The server is unable to process your request.": "Er is een fout opgetreden bij het updaten van uw gemeenschap. De server is niet in staat om uw verzoek te verwerken.", "There was an error finding this widget.": "Er is een fout opgetreden bij het vinden van deze widget.", "Server did not return valid authentication information.": "Server heeft geen geldige verificatiegegevens teruggestuurd.", @@ -2889,7 +2889,7 @@ "Submit logs": "Logs versturen", "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Berichten in dit gesprek zijn eind-tot-eind-versleuteld. Als personen deelnemen, kan u ze verifiëren in hun profiel, tik hiervoor op hun avatar.", "In encrypted rooms, verify all users to ensure it’s secure.": "Controleer alle gebruikers in versleutelde gesprekken om er zeker van te zijn dat het veilig is.", - "Verify all users in a room to ensure it's secure.": "Controleer alle gebruikers in een gesprek om er zeker van te zijn dat hij veilig is.", + "Verify all users in a room to ensure it's secure.": "Controleer alle gebruikers in een gesprek om er zeker van te zijn dat het veilig is.", "%(count)s people|one": "%(count)s persoon", "Add widgets, bridges & bots": "Widgets, bruggen & bots toevoegen", "Edit widgets, bridges & bots": "Widgets, bruggen & bots bewerken", @@ -2921,10 +2921,10 @@ "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "%(brand)s kan versleutelde berichten niet veilig lokaal opslaan in een webbrowser. Gebruik %(brand)s Desktop om versleutelde berichten in zoekresultaten te laten verschijnen.", "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|one": "Veilig lokaal opslaan van versleutelde berichten zodat ze in de zoekresultaten verschijnen, gebruik %(size)s voor het opslaan van berichten uit %(rooms)s gesprek.", "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|other": "Veilig lokaal opslaan van versleutelde berichten zodat ze in de zoekresultaten verschijnen, gebruik %(size)s voor het opslaan van berichten uit %(rooms)s gesprekken.", - "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "Elke sessie die door een gebruiker wordt gebruikt, afzonderlijk verifiëren om deze als vertrouwd aan te merken, waarbij geen vertrouwen wordt gesteld in kruiselings ondertekende apparaten.", - "User signing private key:": "Gebruiker ondertekening privésleutel:", - "Master private key:": "Hoofd privésleutel:", - "Self signing private key:": "Zelfondertekenende privésleutel:", + "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "Verifieer elke sessie die door een gebruiker wordt gebruikt afzonderlijk. Dit markeert hem als vertrouwd zonder te vertrouwen op kruislings ondertekende apparaten.", + "User signing private key:": "Gebruikerondertekening-privésleutel:", + "Master private key:": "Hoofdprivésleutel:", + "Self signing private key:": "Zelfondertekening-privésleutel:", "Cross-signing is not set up.": "Kruiselings ondertekenen is niet ingesteld.", "Cross-signing is ready for use.": "Kruiselings ondertekenen is klaar voor gebruik.", "Your server isn't responding to some requests.": "Uw server reageert niet op sommige verzoeken.", @@ -2941,13 +2941,13 @@ "Minimize dialog": "Dialoog minimaliseren", "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Als u nu annuleert, kunt u versleutelde berichten en gegevens verliezen als u geen toegang meer heeft tot uw login.", "Confirm your identity by verifying this login from one of your other sessions, granting it access to encrypted messages.": "Bevestig uw identiteit door deze login te verifiëren vanuit een van uw andere sessies, waardoor u toegang krijgt tot uw versleutelde berichten.", - "Verify this login": "Controleer deze login", + "Verify this login": "Deze inlog verifiëren", "To continue, use Single Sign On to prove your identity.": "Om verder te gaan, gebruik uw eenmalige aanmelding om uw identiteit te bewijzen.", "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Plakt ( ͡° ͜ʖ ͡°) vóór een bericht zonder opmaak", "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "Plakt ┬──┬ ノ( ゜-゜ノ) vóór een bericht zonder opmaak", "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "Plakt (╯°□°)╯︵ ┻━┻ vóór een bericht zonder opmaak", "Liberate your communication": "Bevrijd uw communicatie", - "Create a Group Chat": "Maak een groepsgesprek aan", + "Create a Group Chat": "Maak een groepsgesprek", "Send a Direct Message": "Start een direct gesprek", "Welcome to %(appName)s": "Welkom bij %(appName)s", "Add a topic to help people know what it is about.": "Stel een gespreksonderwerp in zodat de personen weten waar het over gaat.", @@ -3024,7 +3024,7 @@ "Start audio stream": "Audio-stream starten", "Failed to start livestream": "Starten van livestream is mislukt", "Unable to start audio streaming.": "Kan audio-streaming niet starten.", - "Save Changes": "Wijzigingen Opslaan", + "Save Changes": "Wijzigingen opslaan", "Saving...": "Opslaan...", "View dev tools": "Bekijk dev tools", "Leave Space": "Space verlaten", @@ -3136,7 +3136,7 @@ "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Een nieuwe login heeft toegang tot uw account: %(name)s (%(deviceID)s) op %(ip)s", "You have unverified logins": "U heeft ongeverifieerde logins", "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Zonder verifiëren heeft u geen toegang tot al uw berichten en kan u als onvertrouwd aangemerkt staan bij anderen.", - "Verify your identity to access encrypted messages and prove your identity to others.": "Verifeer uw identiteit om toegang te krijgen tot uw versleutelde berichten en uw identiteit te bewijzen voor anderen.", + "Verify your identity to access encrypted messages and prove your identity to others.": "Verifeer uw identiteit om toegang te krijgen tot uw versleutelde berichten en om uw identiteit te bewijzen voor anderen.", "Use another login": "Gebruik andere login", "Please choose a strong password": "Kies een sterk wachtwoord", "You can add more later too, including already existing ones.": "U kunt er later nog meer toevoegen, inclusief al bestaande gesprekken.", @@ -3170,6 +3170,120 @@ "Share decryption keys for room history when inviting users": "Deel ontsleutelsleutels voor de gespreksgeschiedenis wanneer u personen uitnodigd", "Send and receive voice messages (in development)": "Verstuur en ontvang audioberichten (in ontwikkeling)", "%(deviceId)s from %(ip)s": "%(deviceId)s van %(ip)s", - "Review to ensure your account is safe": "Controleer om u te verzekeren dat uw account veilig is", - "Sends the given message as a spoiler": "Verstuurt het bericht als een spoiler" + "Review to ensure your account is safe": "Controleer ze zodat uw account veilig is", + "Sends the given message as a spoiler": "Verstuurt het bericht als een spoiler", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "U bent de enige persoon hier. Als u weggaat, zal niemand in de toekomst kunnen toetreden, u ook niet.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Als u alles reset, zult u opnieuw opstarten zonder vertrouwde sessies, zonder vertrouwde gebruikers, en zult u misschien geen vroegere berichten meer kunnen zien.", + "Only do this if you have no other device to complete verification with.": "Doe dit alleen als u geen ander apparaat hebt om de verificatie mee uit te voeren.", + "Reset everything": "Alles opnieuw instellen", + "Forgotten or lost all recovery methods? Reset all": "Alles vergeten of alle herstelmethoden verloren? Alles opnieuw instellen", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Als u dat doet, let wel geen van uw berichten wordt verwijderd, maar de zoekresultaten zullen gedurende enkele ogenblikken verslechteren terwijl de index opnieuw wordt aangemaakt", + "View message": "Bericht bekijken", + "Zoom in": "Inzoomen", + "Zoom out": "Uitzoomen", + "%(seconds)ss left": "%(seconds)s's over", + "Change server ACLs": "Wijzig server ACL's", + "Show options to enable 'Do not disturb' mode": "Toon opties om de 'Niet storen' modus in te schakelen", + "You can select all or individual messages to retry or delete": "U kunt alles selecteren of per individueel bericht opnieuw verzenden of verwijderen", + "Sending": "Wordt verstuurd", + "Retry all": "Alles opnieuw proberen", + "Delete all": "Verwijder alles", + "Some of your messages have not been sent": "Enkele van uw berichten zijn niet verstuurd", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s leden inclusief %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inclusief %(commaSeparatedMembers)s", + "View all %(count)s members|one": "1 lid bekijken", + "View all %(count)s members|other": "Bekijk alle %(count)s leden", + "Failed to send": "Verzenden is mislukt", + "Enter your Security Phrase a second time to confirm it.": "Voor uw veiligheidswachtwoord een tweede keer in om het te bevestigen.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Kies een gesprek om hem toe te voegen. Dit is een space voor u, niemand zal hiervan een melding krijgen. U kan er later meer toevoegen.", + "What do you want to organise?": "Wat wilt u organiseren?", + "Filter all spaces": "Alle spaces filteren", + "Delete recording": "Opname verwijderen", + "Stop the recording": "Opname stoppen", + "%(count)s results in all spaces|one": "%(count)s resultaat in alle spaces", + "%(count)s results in all spaces|other": "%(count)s resultaten in alle spaces", + "You have no ignored users.": "U heeft geen gebruiker genegeerd.", + "Play": "Afspelen", + "Pause": "Pauze", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Dit is een experimentele functie. Voorlopig moeten nieuwe personen die een uitnodiging krijgen de gebruiken om daadwerkelijk deel te nemen.", + "To join %(spaceName)s, turn on the Spaces beta": "Om aan %(spaceName)s deel te nemen moet u de Spaces beta inschakelen", + "To view %(spaceName)s, turn on the Spaces beta": "Om %(spaceName)s te bekijken moet u de Spaces beta inschakelen", + "Select a room below first": "Start met selecteren van een gesprek hieronder", + "Communities are changing to Spaces": "Gemeenschappen worden vervangen door Spaces", + "Join the beta": "Beta inschakelen", + "Leave the beta": "Beta verlaten", + "Beta": "Beta", + "Tap for more info": "Klik voor meer info", + "Spaces is a beta feature": "Spaces zijn in beta", + "Want to add a new room instead?": "Wilt u anders een nieuw gesprek toevoegen?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Gesprek toevoegen...", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Gesprekken toevoegen... (%(progress)s van %(count)s)", + "Not all selected were added": "Niet alle geselecteerden zijn toegevoegd", + "You can add existing spaces to a space.": "U kunt bestaande spaces toevoegen aan een space.", + "Feeling experimental?": "Zin in een experiment?", + "You are not allowed to view this server's rooms list": "U heeft geen toegang tot deze server zijn gesprekkenlijst", + "Error processing voice message": "Fout bij verwerking spraakbericht", + "We didn't find a microphone on your device. Please check your settings and try again.": "We hebben geen microfoon gevonden op uw apparaat. Controleer uw instellingen en probeer het opnieuw.", + "No microphone found": "Geen microfoon gevonden", + "We were unable to access your microphone. Please check your browser settings and try again.": "We hebben geen toegang tot uw microfoon. Controleer uw browserinstellingen en probeer het opnieuw.", + "Unable to access your microphone": "Geen toegang tot uw microfoon", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Zin in een experiment? Labs is de beste manier om dingen vroeg te krijgen, nieuwe functies uit te testen en ze te helpen vormen voordat ze daadwerkelijk worden gelanceerd. Lees meer.", + "Your access token gives full access to your account. Do not share it with anyone.": "Uw toegangstoken geeft u toegang to uw account. Deel hem niet met anderen.", + "Access Token": "Toegangstoken", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Spaces zijn de nieuwe manier om gesprekken en personen te groeperen. Om aan een bestaande space deel te nemen heeft u een uitnodiging nodig.", + "Please enter a name for the space": "Vul een naam in voor deze space", + "Connecting": "Verbinden", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Peer-to-peer voor 1op1 oproepen toestaan (als u dit inschakelt kunnen andere personen mogelijk uw ipadres zien)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "De beta is beschikbaar voor web, desktop en Android. Sommige functies zijn nog niet beschikbaar op uw homeserver.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "U kunt de beta elk moment verlaten via instellingen of door op de beta badge hierboven te klikken.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s zal herladen met Spaces ingeschakeld. Gemeenschappen en labels worden verborgen.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "De beta is beschikbaar voor web, desktop en Android. Bedankt dat u de beta wilt proberen.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s zal herladen met Spaces uitgeschakeld. Gemeenschappen en labels zullen weer zichtbaar worden.", + "Spaces are a new way to group rooms and people.": "Spaces zijn de nieuwe manier om gesprekken en personen te groeperen.", + "Message search initialisation failed": "Zoeken in berichten opstarten is mislukt", + "Spaces are a beta feature.": "Spaces zijn een beta functie.", + "Search names and descriptions": "Namen en beschrijvingen zoeken", + "You may contact me if you have any follow up questions": "U mag contact met mij opnemen als u nog vervolg vragen heeft", + "To leave the beta, visit your settings.": "Om de beta te verlaten, ga naar uw instellingen.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Uw platform en gebruikersnaam zullen worden opgeslagen om onze te helpen uw feedback zo goed mogelijk te gebruiken.", + "%(featureName)s beta feedback": "%(featureName)s beta feedback", + "Thank you for your feedback, we really appreciate it.": "Bedankt voor uw feedback, we waarderen het enorm.", + "Beta feedback": "Beta feedback", + "Add reaction": "Reactie toevoegen", + "Send and receive voice messages": "Stuur en ontvang spraakberichten", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Uw feedback maakt spaces beter. Hoe meer details u kan geven, des te beter.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Als u de pagina nu verlaat zal %(brand)s herladen met Spaces uitgeschakeld. Gemeenschappen en labels zullen weer zichtbaar worden.", + "Space Autocomplete": "Space Autocomplete", + "Go to my space": "Ga naar mijn space", + "sends space invaders": "verstuur space invaders", + "Sends the given message with a space themed effect": "Verstuur het bericht met een space-thema-effect", + "See when people join, leave, or are invited to your active room": "Zie wanneer personen deelnemen, vertrekken of worden uitgenodigd in uw actieve gesprek", + "Kick, ban, or invite people to your active room, and make you leave": "Verwijder, verban of nodig personen uit voor uw actieve gesprek en uzelf laten vertrekken", + "See when people join, leave, or are invited to this room": "Zie wanneer personen deelnemen, vertrekken of worden uitgenodigd voor dit gesprek", + "Kick, ban, or invite people to this room, and make you leave": "Verwijder, verban of verwijder personen uit dit gesprek en uzelf laten vertrekken", + "Currently joining %(count)s rooms|one": "Momenteel aan het toetreden tot %(count)s gesprek", + "Currently joining %(count)s rooms|other": "Momenteel aan het toetreden tot %(count)s gesprekken", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Probeer andere woorden of controleer op typefouten. Sommige resultaten zijn mogelijk niet zichtbaar omdat ze privé zijn of u een uitnodiging nodig heeft om deel te nemen.", + "No results for \"%(query)s\"": "Geen resultaten voor \"%(query)s\"", + "The user you called is busy.": "De gebruiker die u belde is bezet.", + "User Busy": "Gebruiker Bezet", + "We're working on this as part of the beta, but just want to let you know.": "We werken hieraan als onderdeel van de beta, maar we willen het u gewoon laten weten.", + "Teammates might not be able to view or join any private rooms you make.": "Teamgenoten zijn mogelijk niet in staat zijn om privégesprekken die u maakt te bekijken of er lid van te worden.", + "Or send invite link": "Of verstuur uw uitnodigingslink", + "If you can't see who you’re looking for, send them your invite link below.": "Als u niet kunt vinden wie u zoekt, stuur ze dan uw uitnodigingslink hieronder.", + "Some suggestions may be hidden for privacy.": "Sommige suggesties kunnen om privacyredenen verborgen zijn.", + "Search for rooms or people": "Zoek naar gesprekken of personen", + "Message preview": "Voorbeeld van bericht", + "Forward message": "Bericht doorsturen", + "Open link": "Koppeling openen", + "Sent": "Verstuurd", + "You don't have permission to do this": "U heeft geen rechten om dit te doen", + "Error - Mixed content": "Fout - Gemengde inhoud", + "Error loading Widget": "Fout bij laden Widget", + "Pinned messages": "Vastgeprikte berichten", + "If you have permissions, open the menu on any message and select Pin to stick them here.": "Als u de rechten heeft, open dan het menu op elk bericht en selecteer Vastprikken om ze hier te zetten.", + "Nothing pinned, yet": "Nog niks vastgeprikt", + "End-to-end encryption isn't enabled": "Eind-tot-eind-versleuteling is uitgeschakeld", + "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. Enable encryption in settings.": "Uw privéberichten zijn normaal gesproken versleuteld, maar dit gesprek niet. Meestal is dit te wijten aan een niet-ondersteund apparaat of methode die wordt gebruikt, zoals e-mailuitnodigingen. Versleuting inschakelen in instellingen." } diff --git a/src/i18n/strings/pl.json b/src/i18n/strings/pl.json index ab9a478446..641247e6ee 100644 --- a/src/i18n/strings/pl.json +++ b/src/i18n/strings/pl.json @@ -2265,5 +2265,108 @@ "There was an error finding this widget.": "Wystąpił błąd podczas próby odnalezienia tego widżetu.", "Active Widgets": "Aktywne widżety", "Encryption not enabled": "Nie włączono szyfrowania", - "Encryption enabled": "Włączono szyfrowanie" + "Encryption enabled": "Włączono szyfrowanie", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Twój serwer domowy był nieosiągalny i nie mógł Cię zalogować. Spróbuj ponownie. Jeśli to się powtórzy, skontaktuj się z administratorem swojego serwera.", + "Try again": "Spróbuj ponownie", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Poprosiliśmy przeglądarkę o zapamiętanie, z którego serwera głównego korzystasz, aby umożliwić Ci logowanie, ale niestety Twoja przeglądarka o tym zapomniała. Przejdź do strony logowania i spróbuj ponownie.", + "We couldn't log you in": "Nie mogliśmy Cię zalogować", + "You're already in a call with this person.": "Prowadzisz już rozmowę z tą osobą.", + "Already in call": "Już dzwoni", + "Never send encrypted messages to unverified sessions in this room from this session": "Nigdy nie wysyłaj zaszyfrowanych wiadomości do niezweryfikowanych sesji z tej sesji w tym pokoju", + "Use the Desktop app to see all encrypted files": "Użyj aplikacji desktopowej, aby zobaczyć wszystkie szyfrowane pliki", + "You’re all caught up": "Jesteś na bieżąco", + "Verification requested": "Zażądano weryfikacji", + "You have no visible notifications.": "Nie masz widocznych powiadomień.", + "Connecting": "Łączenie", + "Your Security Key has been copied to your clipboard, paste it to:": "Twój klucz bezpieczeństwa został skopiowany do schowka, wklej go:", + "Your Security Key": "Twój klucz bezpieczeństwa", + "Create key backup": "Utwórz kopię zapasową klucza", + "Generate a Security Key": "Wygeneruj klucz bezpieczeństwa", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Zabezpiecz się przed utratą dostępu do szyfrowanych wiadomości i danych, tworząc kopię zapasową kluczy szyfrowania na naszym serwerze.", + "Safeguard against losing access to encrypted messages & data": "Zabezpiecz się przed utratą dostępu do szyfrowanych wiadomości i danych", + "Access Token": "Token dostępu", + "Your access token gives full access to your account. Do not share it with anyone.": "Twój token dostępu daje pełen dostęp do Twojego konta. Nie dziel się nim z nikim.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Możesz opuścić betę w każdej chwili z ustawień lub klikając plakietę Beta, taką jak powyższa.", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta dostępna w przeglądarce, na komputerach i na Androidzie. Niektóre funkcje mogą nie być dostępne na Twoim serwerze.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta dostępna w przeglądarce, na komputerach i na Androidzie. Dziękujemy za wypróbowanie bety.", + "Avatar": "Awatar", + "Leave the beta": "Opuść betę", + "Beta": "Beta", + "Tap for more info": "Naciśnij aby dowiedzieć się więcej", + "Move right": "Przenieś w prawo", + "Move left": "Przenieś w lewo", + "Join the beta": "Dołącz do bety", + "To join %(spaceName)s, turn on the Spaces beta": "Aby dołączyć do %(spaceName)s, włącz betę Przestrzeni", + "No results found": "Nie znaleziono wyników", + "Create room": "Utwórz pokój", + "Removing...": "Usuwanie…", + "Your server does not support showing space hierarchies.": "Twój serwer nie obsługuje wyświetlania hierarchii przestrzeni.", + "You can select all or individual messages to retry or delete": "Możesz zaznaczyć wszystkie lub wybrane wiadomości aby spróbować ponownie lub usunąć je", + "Sending": "Wysyłanie", + "Delete all": "Usuń wszystkie", + "Some of your messages have not been sent": "Niektóre z Twoich wiadomości nie zostały wysłane", + "Filter rooms and people": "Filtruj pokoje i ludzi", + "No results for \"%(query)s\"": "Brak wyników dla „%(query)s”", + "Explore rooms in %(communityName)s": "Eksploruj pokoje w %(communityName)s", + "Retry all": "Spróbuj ponownie wszystkie", + "Suggested": "Polecany", + "This room is suggested as a good one to join": "Ten pokój jest polecany jako dobry do dołączenia", + "%(count)s rooms|one": "%(count)s pokój", + "%(count)s rooms|other": "%(count)s pokoi", + "%(count)s members|one": "%(count)s członek", + "%(count)s members|other": "%(count)s członkowie", + "You don't have permission": "Nie masz uprawnień", + "Failed to remove some rooms. Try again later": "Nie udało się usunąć niektórych pokoi. Spróbuj ponownie później", + "Select a room below first": "Najpierw wybierz poniższy pokój", + "%(count)s rooms and 1 space|one": "%(count)s pokój i jedna przestrzeń", + "%(count)s rooms and 1 space|other": "%(count)s pokoi i jedna przestrzeń", + "To view %(spaceName)s, turn on the Spaces beta": "Aby wyświetlić %(spaceName)s, włącz betę Przestrzeni", + "Spaces are a beta feature.": "Przestrzenie są funkcją beta.", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s pokój i %(numSpaces)s przestrzeni", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s pokoi i %(numSpaces)s przestrzeni", + "Filter all spaces": "Filtruj wszystkie przestrzenie", + "Communities are changing to Spaces": "Społeczności zmieniają się na Przestrzenie", + "Spaces is a beta feature": "Przestrzenie są funkcją beta", + "You can add existing spaces to a space.": "Możesz dodać istniejące przestrzenie do przestrzeni.", + "Filter your rooms and spaces": "Filtruj pokoje i przestrzenie", + "%(count)s results in all spaces|one": "%(count)s wynik we wszystkich przestrzeniach", + "%(count)s results in all spaces|other": "%(count)s wyników we wszystkich przestrzeniach", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Przestrzenie to nowy sposób na grupowanie pokoi i ludzi. Aby dołączyć do istniejącej przestrzeni, potrzebujesz zaproszenia.", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Twoje opinie pomogą uczynić Przestrzenie lepszymi. Im bardziej szczegółowo je opiszesz, tym lepiej.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s odświeży się z włączonymi Przestrzeniami. Społeczności i niestandardowe tagi zostaną ukryte.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Jeżeli opuścisz, %(brand)s odświeży się z wyłączonymi Przestrzeniami. Społeczności i niestandardowe tagi będą z powrotem widoczne.", + "Spaces are a new way to group rooms and people.": "Przestrzenie to nowy sposób grupowania pokoi i ludzi.", + "Spaces": "Przestrzenie", + "Feeling experimental?": "Chcesz eksperymentować?", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Chcesz eksperymentować? Laboratoria to najlepszy sposób na uzyskanie nowości wcześniej, przetestowanie nowych funkcji i pomoc w kształtowaniu ich zanim będą ogólnodostępne. Dowiedz się więcej.", + "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "Będziemy przechowywać zaszyfrowaną kopię Twoich kluczy na naszym serwerze. Zabezpiecz swoją kopię zapasową frazą bezpieczeństwa.", + "Secure Backup": "Bezpieczna kopia zapasowa", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Pozwól na wykorzystanie peer-to-peer w rozmowach 1:1 (jeżeli włączono, druga strona może zobaczyć Twój adres IP)", + "Jump to the bottom of the timeline when you send a message": "Przejdź na dół osi czasu po wysłaniu wiadomości", + "Use Ctrl + F to search": "Używaj Ctrl + F do wyszukiwania", + "Show line numbers in code blocks": "Pokazuj numery wierszy w blokach kodu", + "Expand code blocks by default": "Domyślnie rozwijaj bloki kodu", + "Show stickers button": "Pokaż przycisk naklejek", + "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Zarządcy integracji otrzymują dane konfiguracji, mogą modyfikować widżety, wysyłać zaproszenia do pokoi i ustawiać poziom uprawnień w Twoim imieniu.", + "Converts the DM to a room": "Zmienia wiadomości bezpośrednie w pokój", + "Converts the room to a DM": "Zmienia pokój w wiadomość bezpośrednią", + "Sends the given message as a spoiler": "Wysyła podaną wiadomość jako spoiler", + "User Busy": "Użytkownik zajęty", + "The user you called is busy.": "Użytkownik do którego zadzwoniłeś(-aś) jest zajęty.", + "End-to-end encryption isn't enabled": "Szyfrowanie end-to-end nie jest włączone", + "Nothing pinned, yet": "Nie przypięto tu jeszcze niczego", + "If you have permissions, open the menu on any message and select Pin to stick them here.": "Jeżeli masz uprawnienia, przejdź do menu dowolnej wiadomości i wybierz Przypnij, aby przyczepić ją tutaj.", + "Pinned messages": "Przypięte wiadomości", + "Error loading Widget": "Błąd ładowania widżetu", + "Error - Mixed content": "Błąd — zawartość mieszana", + "You don't have permission to do this": "Nie masz uprawnień aby to zrobić", + "Sent": "Wysłano", + "Open link": "Otwórz odnośnik", + "Forward message": "Przekaż wiadomość", + "Message preview": "Podgląd wiadomości", + "Search for rooms or people": "Szukaj pokojów i ludzi", + "Some suggestions may be hidden for privacy.": "Niektóre propozycje mogą być ukryte z uwagi na prywatność.", + "If you can't see who you’re looking for, send them your invite link below.": "Jeżeli nie możesz zobaczyć osób, których szukasz, wyślij im poniższy odnośnik z zaproszeniem.", + "Or send invite link": "Lub wyślij odnośnik z zaproszeniem", + "We're working on this as part of the beta, but just want to let you know.": "Pracujemy nad tym w ramach bety, ale chcemy, żebyś wiedział(a)." } diff --git a/src/i18n/strings/pt_BR.json b/src/i18n/strings/pt_BR.json index 8497ae7164..e19febd6ef 100644 --- a/src/i18n/strings/pt_BR.json +++ b/src/i18n/strings/pt_BR.json @@ -163,7 +163,7 @@ "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s enviou um convite para %(targetDisplayName)s entrar na sala.", "%(senderName)s set a profile picture.": "%(senderName)s definiu uma foto de perfil.", "%(senderName)s set their display name to %(displayName)s.": "%(senderName)s definiu o nome e sobrenome para %(displayName)s.", - "This email address is already in use": "Este endereço de e-mail já está em uso", + "This email address is already in use": "Este endereço de email já está em uso", "This email address was not found": "Este endereço de e-mail não foi encontrado", "The remote side failed to pick up": "A pessoa não atendeu a chamada", "This room is not recognised.": "Esta sala não é reconhecida.", @@ -284,7 +284,7 @@ "ex. @bob:example.com": "p.ex: @joao:exemplo.com", "Add User": "Adicionar usuária(o)", "Custom Server Options": "Opções para Servidor Personalizado", - "Dismiss": "Descartar", + "Dismiss": "Dispensar", "Please check your email to continue registration.": "Por favor, confirme o seu e-mail para continuar a inscrição.", "Token incorrect": "Token incorreto", "Please enter the code it contains:": "Por favor, entre com o código que está na mensagem:", @@ -1176,7 +1176,7 @@ "Sign In or Create Account": "Faça login ou crie uma conta", "Use your account or create a new one to continue.": "Use sua conta ou crie uma nova para continuar.", "Create Account": "Criar Conta", - "Sign In": "Entrar", + "Sign In": "Fazer signin", "Custom (%(level)s)": "Personalizado (%(level)s)", "Messages": "Mensagens", "Actions": "Ações", diff --git a/src/i18n/strings/ro.json b/src/i18n/strings/ro.json index 062a89f2e3..6d0e5fe021 100644 --- a/src/i18n/strings/ro.json +++ b/src/i18n/strings/ro.json @@ -74,5 +74,43 @@ "Explore rooms": "Explorează camerele", "Sign In": "Autentificare", "Create Account": "Înregistare", - "Dismiss": "Închide" + "Dismiss": "Închide", + "You've reached the maximum number of simultaneous calls.": "Ați atins numărul maxim de apeluri simultane.", + "Too Many Calls": "Prea multe apeluri", + "No other application is using the webcam": "Nicio altă aplicație nu folosește camera web", + "Permission is granted to use the webcam": "Permisiunea de a utiliza camera web este acordată", + "A microphone and webcam are plugged in and set up correctly": "Microfonul și camera web sunt conectate și configurate corect", + "Call failed because webcam or microphone could not be accessed. Check that:": "Apelul nu a reușit deoarece camera web sau microfonul nu au putut fi accesate. Verifică:", + "Unable to access webcam / microphone": "Imposibil de accesat camera web / microfonul", + "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Apelul nu a reușit deoarece microfonul nu a putut fi accesat. Verificați dacă un microfon este conectat și configurați corect.", + "Unable to access microphone": "Nu se poate accesa microfonul", + "OK": "OK", + "Try using turn.matrix.org": "Încercați să utilizați turn.matrix.org", + "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternativ, puteți încerca să utilizați serverul public la turn.matrix.org, dar acest lucru nu va fi la fel de fiabil și va partaja adresa dvs. IP cu acel server. Puteți gestiona acest lucru și în Setări.", + "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Vă rugăm să cereți administratorului serverului dvs. (%(homeserverDomain)s) să configureze un server TURN pentru ca apelurile să funcționeze în mod fiabil.", + "Call failed due to misconfigured server": "Apelul nu a reușit din cauza serverului configurat greșit", + "The call was answered on another device.": "Apelul a primit răspuns pe un alt dispozitiv.", + "Answered Elsewhere": "A răspuns în altă parte", + "The call could not be established": "Apelul nu a putut fi stabilit", + "The user you called is busy.": "Utilizatorul pe care l-ați sunat este ocupat.", + "User Busy": "Utilizator ocupat", + "The other party declined the call.": "Cealaltă parte a refuzat apelul.", + "Call Declined": "Apel refuzat", + "Unable to load! Check your network connectivity and try again.": "Imposibil de incarcat! Verificați conectivitatea rețelei și încercați din nou.", + "Error": "Eroare", + "Your user agent": "Agentul dvs. de utilizator", + "Whether you're using %(brand)s as an installed Progressive Web App": "Dacă utilizați %(brand)s ca aplicație web progresivă instalată", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Dacă utilizați sau nu funcția „breadcrumbs” (avatare deasupra listei de camere)", + "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Dacă utilizați %(brand)s pe un dispozitiv în care atingerea este principalul mecanism de intrare", + "Add Phone Number": "Adaugă numărul de telefon", + "Click the button below to confirm adding this phone number.": "Faceți clic pe butonul de mai jos pentru a confirma adăugarea acestui număr de telefon.", + "Confirm adding phone number": "Confirmați adăugarea numărului de telefon", + "Confirm adding this phone number by using Single Sign On to prove your identity.": "Confirmați adăugarea acestui număr de telefon utilizând Single Sign On pentru a vă dovedi identitatea.", + "Add Email Address": "Adăugați o adresă de e-mail", + "Confirm": "Confirmă", + "Click the button below to confirm adding this email address.": "Faceți clic pe butonul de mai jos pentru a confirma adăugarea acestei adrese de e-mail.", + "Confirm adding email": "Confirmați adăugarea e-mailului", + "Single Sign On": "Single Sign On", + "Confirm adding this email address by using Single Sign On to prove your identity.": "Confirmați adăugarea acestei adrese de e-mail utilizând Single Sign On pentru a vă dovedi identitatea.", + "Use Single Sign On to continue": "Utilizați Single Sign On pentru a continua" } diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index 7db3758fd8..91b9919d0a 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -272,7 +272,7 @@ "Server may be unavailable, overloaded, or you hit a bug.": "Возможно, сервер недоступен, перегружен или случилась ошибка.", "Server unavailable, overloaded, or something else went wrong.": "Возможно, сервер недоступен, перегружен или что-то еще пошло не так.", "Session ID": "ID сессии", - "%(senderName)s set a profile picture.": "%(senderName)s установил себе аватар.", + "%(senderName)s set a profile picture.": "%(senderName)s установил(а) себе аватар.", "%(senderName)s set their display name to %(displayName)s.": "%(senderName)s изменил(а) отображаемое имя на %(displayName)s.", "Signed Out": "Выполнен выход", "This room is not accessible by remote Matrix servers": "Это комната недоступна из других серверов Matrix", @@ -853,7 +853,7 @@ "Sets the room name": "Устанавливает название комнаты", "Forces the current outbound group session in an encrypted room to be discarded": "Принудительно отбрасывает текущую групповую сессию для отправки сообщений в зашифрованную комнату", "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s модернизировал эту комнату.", - "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s установил %(address)s в качестве главного адреса комнаты.", + "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s установил(а) %(address)s в качестве главного адреса комнаты.", "%(senderName)s removed the main address for this room.": "%(senderName)s удалил главный адрес комнаты.", "%(displayName)s is typing …": "%(displayName)s печатает…", "%(names)s and %(count)s others are typing …|other": "%(names)s и %(count)s других печатают…", @@ -3210,5 +3210,14 @@ "Removing...": "Удаление…", "Failed to remove some rooms. Try again later": "Не удалось удалить несколько комнат. Попробуйте позже", "%(count)s rooms and 1 space|one": "%(count)s комната и одно пространство", - "%(count)s rooms and 1 space|other": "%(count)s комнат и одно пространство" + "%(count)s rooms and 1 space|other": "%(count)s комнат и одно пространство", + "Sends the given message as a spoiler": "Отправить данное сообщение под спойлером", + "Play": "Воспроизведение", + "Pause": "Пауза", + "Connecting": "Подключение", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Разрешить прямые соединения для вызовов 1:1 (при включении данной опции другая сторона может узнать ваш IP адрес)", + "Send and receive voice messages": "Отправлять и получать голосовые сообщения", + "%(deviceId)s from %(ip)s": "%(deviceId)s с %(ip)s", + "The user you called is busy.": "Вызываемый пользователь занят.", + "User Busy": "Пользователь занят" } diff --git a/src/i18n/strings/sq.json b/src/i18n/strings/sq.json index ad768b59cb..b2101151e1 100644 --- a/src/i18n/strings/sq.json +++ b/src/i18n/strings/sq.json @@ -3272,5 +3272,119 @@ "Share decryption keys for room history when inviting users": "Ndani me përdorues kyçe shfshehtëzimi, kur ftohen përdorues", "Send and receive voice messages (in development)": "Dërgoni dhe merrni mesazhe zanorë (në zhvillim)", "%(deviceId)s from %(ip)s": "%(deviceId)s prej %(ip)s", - "Review to ensure your account is safe": "Shqyrtojeni për t’u siguruar se llogaria është e parrezik" + "Review to ensure your account is safe": "Shqyrtojeni për t’u siguruar se llogaria është e parrezik", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Jeni i vetmi person këtu. Nëse e braktisni, askush s’do të jetë në gjendje të hyjë në të ardhmen, përfshi ju.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Nëse riktheni gjithçka te parazgjedhjet, do të rifilloni pa sesione të besuara, pa përdorues të besuar, dhe mund të mos jeni në gjendje të shihni mesazhe të dikurshëm.", + "Only do this if you have no other device to complete verification with.": "Bëjeni këtë vetëm nëse s’keni pajisje tjetër me të cilën të plotësoni verifikimin.", + "Reset everything": "Kthe gjithçka te parazgjedhjet", + "Forgotten or lost all recovery methods? Reset all": "Harruat, ose humbët krejt metodat e rimarrjes? Riujdisini të gjitha", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Nëse e bëni, ju lutemi, kini parasysh se s’do të fshihet asnjë nga mesazhet tuaj, por puna me kërkimin mund degradojë për pak çaste, ndërkohë që rikrijohet treguesi", + "View message": "Shihni mesazh", + "%(seconds)ss left": "Edhe %(seconds)ss", + "Change server ACLs": "Ndryshoni ACL-ra shërbyesi", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Po kryhet këshillim me %(transferTarget)s. Shpërngule te %(transferee)s", + "Show options to enable 'Do not disturb' mode": "Shfaq mundësi për aktivizim të mënyrës “Mos më shqetësoni”", + "You can select all or individual messages to retry or delete": "Për riprovim ose fshirje mund të përzgjidhni krejt mesazhet, ose të tillë individualë", + "Sending": "Po dërgohet", + "Retry all": "Riprovoji krejt", + "Delete all": "Fshiji krejt", + "Some of your messages have not been sent": "Disa nga mesazhet tuaj s’janë dërguar", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s anëtarë, përfshi %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Prfshi %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Shihni 1 anëtar", + "View all %(count)s members|other": "Shihni krejt %(count)s anëtarët", + "Failed to send": "S’u arrit të dërgohet", + "Enter your Security Phrase a second time to confirm it.": "Jepni Frazën tuaj të Sigurisë edhe një herë, për ta ripohuar.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Zgjidhni dhoma ose biseda që të shtohen. Kjo është thjesht një hapësirë për ju, s’do ta dijë kush tjetër. Mund të shtoni të tjerë më vonë.", + "What do you want to organise?": "Ç’doni të sistemoni?", + "Filter all spaces": "Filtro krejt hapësirat", + "Delete recording": "Fshije regjistrimin", + "Stop the recording": "Ndale regjistrimin", + "%(count)s results in all spaces|one": "%(count)s përfundim në krejt hapësirat", + "%(count)s results in all spaces|other": "%(count)s përfundime në krejt hapësirat", + "You have no ignored users.": "S’keni përdorues të shpërfillur.", + "Play": "Luaje", + "Pause": "Ndalesë", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Kjo është një veçori eksperimentale. Hëpërhë, përdoruesve të rinj që marrin një ftesë, do t’u duhet ta hapin ftesën në , që të marrin pjesë.", + "To join %(spaceName)s, turn on the Spaces beta": "Për të hyrë në %(spaceName)s, aktivizoni beta-n për Hapësira", + "To view %(spaceName)s, turn on the Spaces beta": "Për të parë %(spaceName)s, aktivizoni beta-n për Hapësira", + "Spaces are a beta feature.": "Hapësirat janë një veçori në version beta.", + "Search names and descriptions": "Kërko te emra dhe përshkrime", + "Select a room below first": "Së pari, përzgjidhni më poshtë një dhomë", + "Communities are changing to Spaces": "Bashkësitë po ndryshojnë në Hapësira", + "Join the beta": "Merrni pjesë te beta", + "Leave the beta": "Braktiseni beta-n", + "Beta": "Beta", + "Tap for more info": "Për më tepër hollësi, prekeni", + "Spaces is a beta feature": "Hapësirat janë një veçori në version beta", + "You may contact me if you have any follow up questions": "Mund të lidheni me mua, nëse keni pyetje të mëtejshme", + "To leave the beta, visit your settings.": "Që të braktisni beta-n, vizitoni rregullimet tuaja.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Platforma dhe emri juaj i përdoruesit do të mbahen shënim, për të na ndihmuar t’i përdorim përshtypjet tuaja sa më shumë që të mundemi.", + "%(featureName)s beta feedback": "Përshtypje për beta %(featureName)s", + "Thank you for your feedback, we really appreciate it.": "Faleminderit për përshtypjet tuaja, vërtet e çmojmë.", + "Beta feedback": "Përshtypje për versionin Beta", + "Want to add a new room instead?": "Doni të shtohet një dhomë e re, në vend të kësaj?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Po shtohet dhomë…", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Po shtohen dhoma… (%(progress)s nga %(count)s)", + "Not all selected were added": "S’u shtuan të gjithë të përzgjedhurit", + "You can add existing spaces to a space.": "Mund të shtoni hapësira ekzistuese te një hapësirë.", + "Feeling experimental?": "Ndiheni eksperimentues?", + "You are not allowed to view this server's rooms list": "S’keni leje të shihni listën e dhomave të këtij shërbyesi", + "Zoom in": "Zmadhoje", + "Zoom out": "Zvogëloje", + "Add reaction": "Shtoni reagim", + "Error processing voice message": "Gabim në përpunimin e mesazhit zanor", + "Accept on your other login…": "Pranojeni te hyrja tjetër e juaja…", + "We didn't find a microphone on your device. Please check your settings and try again.": "S’gjetëm mikrofon në pajisjen tuaj. Ju lutemi, kontrolloni rregullimet tuaja dhe riprovoni.", + "No microphone found": "S’u gjet mikrofon", + "We were unable to access your microphone. Please check your browser settings and try again.": "S’qemë në gjendje të përdorim mikrofonin tuaj. Ju lutemi, kontrolloni rregullimet e shfletuesit tuaj dhe riprovoni.", + "Unable to access your microphone": "S’arrihet të përdoret mikrofoni juaj", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Ndiheni eksperimentues? Laboratorët janë rruga më e mirë për t’u marrë herët me gjërat, për të provuar veçori të reja dhe për të ndihmuar t’u jepet formë atyre, përpara se të hidhen faktikisht në qarkullim. Mësoni më tepër.", + "Your access token gives full access to your account. Do not share it with anyone.": "Tokeni-i juaj i hyrjeve jep hyrje të plotë në llogarinë tuaj. Mos ia jepni kujt.", + "Access Token": "Token Hyrjesh", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Hapësirat janë rrugë e re për të grupuar dhoma dhe njerëz. Për t’u bërë pjesë e një hapësire ekzistuese, do t’ju duhet një ftesë.", + "Please enter a name for the space": "Ju lutemi, jepni një emër për hapësirën", + "Connecting": "Po lidhet", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Lejo Tek-për-Tek për thirrje 1:1 (nëse e aktivizoni këtë, pala tjetër mund të jetë në gjendje të shohë adresën tuaj IP)", + "New spinner design": "Rrotullues i ri", + "Send and receive voice messages": "Dërgoni dhe merrni mesazhe zanorë", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Përshtypjet tuaja do t’i bëjnë hapësirat më të mira. Sa më shumë hollësi që të jepni, aq më mirë.", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta e gatshme për web, desktop dhe Android. Disa veçori mund të mos jenë të përdorshme në shërbyesin tuaj Home.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Beta-n mund ta braktisni në çfarëdo kohe, që nga rregullimet, ose duke prekur një stemë beta, si ajo më sipër.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s do të ringarkohet me Hapësirat të aktivizuara. Bashkësitë dhe etiketat vetjake do të jenë të fshehura.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta e gatshme për web, desktop dhe Android. Faleminderit që provoni beta-n.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Nëse ikni, %(brand)s-i do të ringarkohet me Hapësira të çaktivizuara. Bashkësitë dhe etiketat vetjake do të jenë sërish të dukshme.", + "Spaces are a new way to group rooms and people.": "Hapësirat janë një rrugë e re për të grupuar dhoma dhe njerëz.", + "Message search initialisation failed": "Dështoi gatitje kërkimi mesazhesh", + "Go to my space": "Kalo te hapësira ime", + "sends space invaders": "dërgon pushtues hapësire", + "Sends the given message with a space themed effect": "E dërgon mesazhin e dhënë me një efekt teme hapësinore", + "See when people join, leave, or are invited to your active room": "Shihni kur persona vijnë, ikin ose janë ftuar në dhomën tuaj aktive", + "See when people join, leave, or are invited to this room": "Shihni kur persona vijnë, ikin ose janë ftuar në këtë dhomë", + "Space Autocomplete": "Vetëplotësim Hapësire", + "Currently joining %(count)s rooms|one": "Aktualisht duke hyrë në %(count)s dhomë", + "Currently joining %(count)s rooms|other": "Aktualisht duke hyrë në %(count)s dhoma", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Provoni fjalë të ndryshme, ose kontrolloni për gabime shkrimi. Disa përfundime mund të mos jenë të dukshme, ngaqë janë private dhe ju duhet një ftesë për të marrë pjesë në to.", + "No results for \"%(query)s\"": "S’ka përfundime për \"%(query)s\"", + "The user you called is busy.": "Përdoruesi që thirrët është i zënë.", + "User Busy": "Përdoruesi Është i Zënë", + "Kick, ban, or invite people to your active room, and make you leave": "Përzini, dëboni, ose ftoni persona te dhoma juaj aktive, dhe bëni largimin tuaj", + "Kick, ban, or invite people to this room, and make you leave": "Përzini, dëboni, ose ftoni persona në këtë dhomë, dhe bëni largimin tuaj", + "We're working on this as part of the beta, but just want to let you know.": "Po merremi me këtë, si pjesë e versionit beta, thjesht duam ta dini.", + "Teammates might not be able to view or join any private rooms you make.": "Anëtarët e ekipit mund të mos jenë në gjendje të shohin ose hyjnë në çfarëdo dhome private që krijoni.", + "Or send invite link": "Ose dërgoni një lidhje ftese", + "If you can't see who you’re looking for, send them your invite link below.": "Nëse s’e shihni atë që po kërkoni, dërgojini nga më poshtë një lidhje ftese.", + "Some suggestions may be hidden for privacy.": "Disa sugjerime mund të jenë fshehur, për arsye privatësie.", + "Search for rooms or people": "Kërkoni për dhoma ose persona", + "Forward message": "Përcille mesazhin", + "Open link": "Hape lidhjen", + "You don't have permission to do this": "S’keni leje ta bëni këtë", + "Error - Mixed content": "Gabim - Lëndë e përzierë", + "Error loading Widget": "Gabim në ngarkim Widget-i", + "Pinned messages": "Mesazhe të fiksuar", + "If you have permissions, open the menu on any message and select Pin to stick them here.": "Nëse keni leje, hapni menunë për çfarëdo mesazhi dhe përzgjidhni Fiksoje, për ta ngjitur këtu.", + "Nothing pinned, yet": "Ende pa fiksuar gjë", + "End-to-end encryption isn't enabled": "Fshehtëzimi skaj-më-skaj s’është i aktivizuar", + "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. Enable encryption in settings.": "Mesazhet tuaja private normalisht fshehtëzohen, por kjo dhomë nuk fshehtëzohet. Zakonisht kjo vjen si pasojë e përdorimit të një pajisjeje apo metode të pambuluar, bie fjala, ftesa me email. Aktivizoni fshehtëzimin që nga rregullimet." } diff --git a/src/i18n/strings/sv.json b/src/i18n/strings/sv.json index 42a7f78268..6033b561bd 100644 --- a/src/i18n/strings/sv.json +++ b/src/i18n/strings/sv.json @@ -3216,5 +3216,118 @@ "Please choose a strong password": "Vänligen välj ett starkt lösenord", "Use another login": "Använd annan inloggning", "Verify your identity to access encrypted messages and prove your identity to others.": "Verifiera din identitet för att komma åt krypterade meddelanden och bevisa din identitet för andra.", - "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Om du inte verifierar så kommer du inte ha åtkomst till alla dina meddelanden och kan synas som ej betrodd för andra." + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Om du inte verifierar så kommer du inte ha åtkomst till alla dina meddelanden och kan synas som ej betrodd för andra.", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Du är den enda personen här. Om du lämnar så kommer ingen kunna gå med igen, inklusive du.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Om du återställer allt så kommer du att börja om utan betrodda sessioner eller betrodda användare, och kommer kanske inte kunna se gamla meddelanden.", + "Only do this if you have no other device to complete verification with.": "Gör detta endast om du inte har någon annan enhet att slutföra verifikationen med.", + "Reset everything": "Återställ allt", + "Forgotten or lost all recovery methods? Reset all": "Glömt eller förlorat alla återställningsalternativ? Återställ allt", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Om du gör det, observera att inga av dina meddelanden kommer att raderas, men sökupplevelsen kan degraderas en stund medans registret byggs upp igen", + "View message": "Visa meddelande", + "Zoom in": "Zooma in", + "Zoom out": "Zooma ut", + "%(seconds)ss left": "%(seconds)ss kvar", + "Change server ACLs": "Ändra server-ACLer", + "Show options to enable 'Do not disturb' mode": "Visa alternativ för att aktivera 'Stör ej'-läget", + "Delete all": "Radera alla", + "View all %(count)s members|one": "Visa 1 medlem", + "View all %(count)s members|other": "Visa alla %(count)s medlemmar", + "You can select all or individual messages to retry or delete": "Du kan välja alla eller individuella meddelanden att försöka igen eller radera", + "Sending": "Skickar", + "Retry all": "Försök alla igen", + "Some of your messages have not been sent": "Vissa av dina meddelanden har inte skickats", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s medlemmar inklusive %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inklusive %(commaSeparatedMembers)s", + "Failed to send": "Misslyckades att skicka", + "Enter your Security Phrase a second time to confirm it.": "Ange din säkerhetsfras igen för att bekräfta den.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Välj rum eller konversationer att lägga till. Detta är bara ett utrymmer för dig, ingen kommer att informeras. Du kan lägga till fler senare.", + "What do you want to organise?": "Vad vill du organisera?", + "Filter all spaces": "Filtrera alla utrymmen", + "Delete recording": "Radera inspelningen", + "Stop the recording": "Stoppa inspelningen", + "%(count)s results in all spaces|one": "%(count)s resultat i alla utrymmen", + "%(count)s results in all spaces|other": "%(count)s resultat i alla utrymmen", + "You have no ignored users.": "Du har inga ignorerade användare.", + "Play": "Spela", + "Pause": "Pausa", + "Message search initialisation failed": "Initialisering av meddelandesökning misslyckades", + "To view %(spaceName)s, turn on the Spaces beta": "För att se %(spaceName)s, aktivera utrymmesbetan", + "Spaces are a beta feature.": "Utrymmen är en betafunktion.", + "Search names and descriptions": "Sök namn och beskrivningar", + "Select a room below first": "Välj ett rum nedan först", + "Communities are changing to Spaces": "Gemenskaper byts ut mot utrymmen", + "Join the beta": "Gå med i betan", + "Leave the beta": "Lämna betan", + "Beta": "Beta", + "Tap for more info": "Klicka för mer info", + "Spaces is a beta feature": "Utrymmen är en betafunktion", + "You may contact me if you have any follow up questions": "Ni kan kontakta mig om ni har vidare frågor", + "To leave the beta, visit your settings.": "För att lämna betan, besök dina inställningar.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Din plattform och ditt användarnamn kommer att noteras för att hjälpa oss att använda din återkoppling så mycket vi kan.", + "%(featureName)s beta feedback": "%(featureName)s betaåterkoppling", + "Thank you for your feedback, we really appreciate it.": "Tack för din återkoppling, vi uppskattar det verkligen.", + "Beta feedback": "Betaåterkoppling", + "Want to add a new room instead?": "Vill du lägga till ett nytt rum istället?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Lägger till rum…", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Lägger till rum… (%(progress)s av %(count)s)", + "Not all selected were added": "Inte alla valda tillades", + "You can add existing spaces to a space.": "Du kan lägga till existerande utrymmen till ett utrymme.", + "Feeling experimental?": "Känner du dig äventyrlig?", + "You are not allowed to view this server's rooms list": "Du tillåts inte att se den här serverns rumslista", + "Add reaction": "Lägg till reaktion", + "Error processing voice message": "Fel vid hantering av röstmeddelande", + "We didn't find a microphone on your device. Please check your settings and try again.": "Vi kunde inte hitta en mikrofon på din enhet. Vänligen kolla dina inställningar och försök igen.", + "No microphone found": "Ingen mikrofon hittad", + "We were unable to access your microphone. Please check your browser settings and try again.": "Vi kunde inte komma åt din mikrofon. Vänligen kolla dina webbläsarinställningar och försök igen.", + "Unable to access your microphone": "Kan inte komma åt din mikrofon", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Känner du dig äventyrlig? Experiment är det bästa sättet att få saker tidigt, testa nya funktioner och hjälpa till att forma dem innan de egentligen släpps Läs mer.", + "Your access token gives full access to your account. Do not share it with anyone.": "Din åtkomsttoken ger full åtkomst till ditt konto. Dela den inte med någon.", + "Access Token": "Åtkomsttoken", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Utrymmen är ett nytt sätt att gruppera rum och personer. För att gå med i existerande utrymme så behöver du en inbjudan.", + "Please enter a name for the space": "Vänligen ange ett namn för utrymmet", + "Connecting": "Ansluter", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Tillåt peer-to-peer för 1:1-samtal (om du aktiverar det hör så kan den andra parten kanske se din IP-adress)", + "Send and receive voice messages": "Skicka och ta emot röstmeddelanden", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Din återkoppling kommer att hjälpa till att göra utrymmen bättre. Ju fler detaljer du kan ge desto bättre.", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta tillgänglig för webben, skrivbord och Android. Vissa funktioner kan vara otillgängliga på din hemserver.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Du kan lämna betan när som helst från inställningarna eller genom att trycka en betabricka, som den ovan.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s kommer att ladda om med utrymmen aktiverade. Gemenskaper och anpassade taggar kommer att döljas.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta tillgänglig för webben, skrivbord och Android. Tack för att du provar betan.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Om du lämnar så kommer %(brand)s att ladda om med utrymmen inaktiverade. Gemenskaper och anpassade taggar kommer att synas igen.", + "Spaces are a new way to group rooms and people.": "Utrymmen är nya sätt att gruppera rum och personer.", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Det här är en experimentell funktion. För tillfället så behöver nya inbjudna användare öppna inbjudan på för att faktiskt gå med.", + "To join %(spaceName)s, turn on the Spaces beta": "För att gå med i %(spaceName)s, aktivera utrymmesbetan", + "Space Autocomplete": "Utrymmesautokomplettering", + "Go to my space": "Gå till mitt utrymme", + "sends space invaders": "skickar Space Invaders", + "Sends the given message with a space themed effect": "Skickar det givna meddelandet med en effekt med rymdtema", + "See when people join, leave, or are invited to your active room": "Se när folk går med, lämnar eller bjuds in till ditt aktiva rum", + "Kick, ban, or invite people to your active room, and make you leave": "Kicka, banna eller bjuda in folk till ditt aktiva rum, och tvinga dig att lämna", + "See when people join, leave, or are invited to this room": "Se när folk går med, lämnar eller bjuds in till det här rummet", + "Kick, ban, or invite people to this room, and make you leave": "Kicka, banna eller bjuda in folk till det här rummet, och tvinga dig att lämna", + "Currently joining %(count)s rooms|one": "Går just nu med i %(count)s rum", + "Currently joining %(count)s rooms|other": "Går just nu med i %(count)s rum", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Testa andra ord eller kolla efter felskrivningar. Vissa resultat kanske inte visas för att de är privata och du behöver en inbjudan för att gå med i dem.", + "No results for \"%(query)s\"": "Inga resultat för \"%(query)s\"", + "The user you called is busy.": "Användaren du ringde är upptagen.", + "User Busy": "Användare upptagen", + "We're working on this as part of the beta, but just want to let you know.": "Vi jobbar på detta som en del av betan, men vi ville låta dig veta.", + "Teammates might not be able to view or join any private rooms you make.": "Teammedlemmar kanske inte kan se eller gå med i privata rum du skapar.", + "Or send invite link": "Eller skicka inbjudningslänk", + "If you can't see who you’re looking for, send them your invite link below.": "Om du inte kan se den du letar efter, skicka dem din inbjudningslänk nedan.", + "Some suggestions may be hidden for privacy.": "Vissa förslag kan vara dolda av sekretesskäl.", + "Search for rooms or people": "Sök efter rum eller personer", + "Message preview": "Meddelandeförhandsvisning", + "Forward message": "Vidarebefordra meddelande", + "Open link": "Öppna länk", + "Sent": "Skickat", + "You don't have permission to do this": "Du har inte behörighet att göra detta", + "Error - Mixed content": "Fel - blandat innehåll", + "Error loading Widget": "Fel vid laddning av widget", + "Pinned messages": "Fästa meddelanden", + "If you have permissions, open the menu on any message and select Pin to stick them here.": "Om du har behörighet, öppna menyn på ett meddelande och välj Fäst för att fösta dem här.", + "Nothing pinned, yet": "Inget fäst än", + "End-to-end encryption isn't enabled": "Totalsträckskryptering är inte aktiverat", + "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. Enable encryption in settings.": "Dina privata meddelanden är normalt krypterade, men det här rummet är inte det. Oftast så beror detta på att en enhet eller metod som används ej stöds, som e-postinbjudningar. Aktivera kryptering i inställningarna." } diff --git a/src/i18n/strings/ta.json b/src/i18n/strings/ta.json index 4f87230ef3..9dc89b1b94 100644 --- a/src/i18n/strings/ta.json +++ b/src/i18n/strings/ta.json @@ -21,7 +21,7 @@ "Enable email notifications": "மின்னஞ்சல் அறிவிப்புகளை ஏதுவாக்கு", "Enable notifications for this account": "இந்த கணக்கிற்கான அறிவிப்புகளை ஏதுவாக்கு", "Enable them now": "இப்போது அவற்றை ஏதுவாக்கு", - "Error": "கோளாறு", + "Error": "பிழை", "Failed to add tag %(tagName)s to room": "%(tagName)s எனும் குறிச்சொல்லை அறையில் சேர்ப்பதில் தோல்வி", "Failed to change settings": "அமைப்புகள் மாற்றத்தில் தோல்வி", "Failed to forget room %(errCode)s": "அறையை மறப்பதில் தோல்வி %(errCode)s", @@ -125,17 +125,17 @@ "Register": "பதிவு செய்", "Rooms": "அறைகள்", "Add rooms to this community": "அறைகளை இந்த சமூகத்தில் சேர்க்கவும்", - "This email address is already in use": "இந்த மின் அஞ்சல் முகவரி ஏற்கனவே பயன்பாட்டில் உள்ளது", - "This phone number is already in use": "இந்த தொலைபேசி எண் ஏற்கனவே பயன்பாட்டில் உள்ளது", - "Failed to verify email address: make sure you clicked the link in the email": "மின்னஞ்சல் முகவரியைச் சரிபார்க்கத் தவறிவிட்டது: மின்னஞ்சலில் உள்ள இணைப்பைக் கிளிக் செய்துள்ளீர்கள் என்பதை உறுதிப்படுத்திக் கொள்ளுங்கள்", + "This email address is already in use": "இந்த மின்னஞ்சல் முகவரி முன்னதாகவே பயன்பாட்டில் உள்ளது", + "This phone number is already in use": "இந்த தொலைபேசி எண் முன்னதாகவே பயன்பாட்டில் உள்ளது", + "Failed to verify email address: make sure you clicked the link in the email": "மின்னஞ்சல் முகவரியை சரிபார்க்க முடியவில்லை: மின்னஞ்சலில் உள்ள இணைப்பை அழுத்தியுள்ளீர்களா என்பதை உறுதிப்படுத்தவும்", "The platform you're on": "நீங்கள் இருக்கும் தளம்", "The version of %(brand)s": "%(brand)s இன் பதிப்பு", "Whether or not you're logged in (we don't record your username)": "நீங்கள் உள்நுழைந்திருந்தாலும் இல்லாவிட்டாலும் (உங்கள் பயனர்பெயரை நாங்கள் பதிவு செய்ய மாட்டோம்)", "Your language of choice": "நீங்கள் விரும்பும் மொழி", - "Which officially provided instance you are using, if any": "நீங்கள் பயன்படுத்தும் அதிகாரப்பூர்வமாக வழங்கப்பட்ட உதாரணம் ஏதேனும் இருந்தால்", - "Whether or not you're using the Richtext mode of the Rich Text Editor": "பணக்கார உரை எடிட்டரின் ரிச்ச்டெக்ஸ்ட் பயன்முறையைப் பயன்படுத்துகிறீர்களா இல்லையா", - "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "நீங்கள் 'breadcrumbs' அம்சத்தைப் பயன்படுத்துகிறீர்களோ இல்லையோ (அறை பட்டியலுக்கு மேலே உள்ள அவதாரங்கள்)", - "Your homeserver's URL": "உங்கள் வீட்டு சேவையகத்தின் URL", + "Which officially provided instance you are using, if any": "நீங்கள் பயன்படுத்தும் அதிகாரப்பூர்வமாக வழங்கப்பட்ட நிகழ்வு ஏதேனும் இருந்தால்", + "Whether or not you're using the Richtext mode of the Rich Text Editor": "நீங்கள் ரிச்டெக்ஸ்ட் உரைத்திருத்தியின் ரிச்டெக்ஸ்ட் பயன்முறையைப் பயன்படுத்துகிறீர்களா இல்லையா", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "நீங்கள் 'breadcrumbs' அம்சத்தைப் பயன்படுத்துகிறீர்களோ இல்லையோ (அறை பட்டியலுக்கு மேலே உள்ள பயனர் படங்கள்)", + "Your homeserver's URL": "உங்கள் வீட்டுச்சேவையகத்தின் URL", "e.g. %(exampleValue)s": "உதாரணமாக %(exampleValue)s", "Every page you use in the app": "பயன்பாட்டில் நீங்கள் பயன்படுத்தும் ஒவ்வொரு பக்கமும்", "Your %(brand)s is misconfigured": "உங்கள் %(brand)s தவறாக உள்ளமைக்கப்பட்டுள்ளது", @@ -143,7 +143,7 @@ "e.g. ": "உதாரணமாக ", "Your device resolution": "உங்கள் சாதனத் தீர்மானம்", "Analytics": "பகுப்பாய்வு", - "The information being sent to us to help make %(brand)s better includes:": "%(brand)s ஐ சிறப்பாகச் செய்ய எங்களுக்கு அனுப்பப்படும் தகவல்களில் பின்வருவன அடங்கும்:", + "The information being sent to us to help make %(brand)s better includes:": "%(brand)s ஐ சிறப்பாக்க எங்களுக்கு அனுப்பப்படும் தகவல்களில் பின்வருவன அடங்கும்:", "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "இந்த பக்கம் ஒரு அறை, பயனர் அல்லது குழு ஐடி போன்ற அடையாளம் காணக்கூடிய தகவல்களை உள்ளடக்கியது, அந்த தரவு சேவையகத்திற்கு அனுப்பப்படுவதற்கு முன்பு அகற்றப்படும்.", "Call Failed": "அழைப்பு தோல்வியுற்றது", "Call Timeout": "அழைப்பு நேரம் முடிந்தது", @@ -152,19 +152,19 @@ "Existing Call": "இருக்கும் அழைப்பு", "You are already in a call.": "நீங்கள் ஏற்கனவே அழைப்பில் உள்ளீர்கள்.", "VoIP is unsupported": "VoIP ஆதரிக்கப்படவில்லை", - "You cannot place VoIP calls in this browser.": "இந்த உலாவியில் நீங்கள் VoIP அழைப்புகளை வைக்க முடியாது.", - "You cannot place a call with yourself.": "உங்களுடன் அழைப்பை மேற்கொள்ள முடியாது.", - "Call in Progress": "அழைப்பு முன்னேற்றத்தில் உள்ளது", - "A call is currently being placed!": "தற்போது அழைப்பு வைக்கப்பட்டுள்ளது!", + "You cannot place VoIP calls in this browser.": "இந்த உலாவியில் நீங்கள் VoIP அழைப்புகளை மேற்கொள்ள முடியாது.", + "You cannot place a call with yourself.": "நீங்கள் உங்களுடனே அழைப்பை மேற்கொள்ள முடியாது.", + "Call in Progress": "அழைப்பு நடந்துகொண்டிருக்கிறது", + "A call is currently being placed!": "தற்போது ஒரு அழைப்பு செயல்படுத்தப்பட்டுள்ளது!", "A call is already in progress!": "அழைப்பு ஏற்கனவே செயலில் உள்ளது!", "Permission Required": "அனுமதி தேவை", - "You do not have permission to start a conference call in this room": "இந்த அறையில் ஒரு மாநாட்டு அழைப்பைத் தொடங்க உங்களுக்கு அனுமதி இல்லை", + "You do not have permission to start a conference call in this room": "இந்த அறையில் ஒரு கூட்டு அழைப்பைத் தொடங்க உங்களுக்கு அனுமதி இல்லை", "Replying With Files": "கோப்புகளுடன் பதிலளித்தல்", "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "இந்த நேரத்தில் ஒரு கோப்புடன் பதிலளிக்க முடியாது. பதிலளிக்காமல் இந்த கோப்பை பதிவேற்ற விரும்புகிறீர்களா?", "The file '%(fileName)s' failed to upload.": "'%(fileName)s' கோப்பு பதிவேற்றத் தவறிவிட்டது.", - "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "'%(fileName)s' கோப்பு பதிவேற்றங்களுக்கான இந்த ஹோம்சர்வரின் அளவு வரம்பை மீறுகிறது", + "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "'%(fileName)s' கோப்பு இந்த வீட்டுச்சேவையகத்தின் பதிவேற்றங்களுக்கான அளவு வரம்பை மீறுகிறது", "Upload Failed": "பதிவேற்றம் தோல்வியுற்றது", - "Server may be unavailable, overloaded, or you hit a bug.": "சேவையகம் கிடைக்காமல் போகலாம், அதிக சுமை அல்லது பிழையைத் தாக்கலாம்.", + "Server may be unavailable, overloaded, or you hit a bug.": "", "The server does not support the room version specified.": "குறிப்பிடப்பட்ட அறை பதிப்பை சேவையகம் ஆதரிக்கவில்லை.", "Failure to create room": "அறையை உருவாக்கத் தவறியது", "Sun": "ஞாயிறு", @@ -181,5 +181,57 @@ "May": "மே", "Jun": "ஜூன்", "Explore rooms": "அறைகளை ஆராயுங்கள்", - "Create Account": "உங்கள் கணக்கை துவங்குங்கள்" + "Create Account": "உங்கள் கணக்கை துவங்குங்கள்", + "Who would you like to add to this community?": "இந்த சமூகத்தில் யாரைச் சேர்க்க விரும்புகிறீர்கள்?", + "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s", + "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s", + "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(time)s", + "%(weekDayName)s %(time)s": "%(weekDayName)s %(time)s", + "AM": "காலை", + "PM": "மாலை", + "Dec": "டிசம்பர்", + "Nov": "நவம்பர்", + "Oct": "அக்டோபர்", + "Sep": "செப்டம்பர்", + "Aug": "ஆகஸ்ட்", + "Jul": "ஜூலை", + "This will end the conference for everyone. Continue?": "இது அனைவருக்கும் கூட்டு அழைப்பை நிறுத்திவிடும். தொடரவா?", + "End conference": "கூட்டு அழைப்பை நிறுத்து", + "There was an error looking up the phone number": "தொலைபேசி எண்ணைத் தேடுவதில் பிழை ஏற்பட்டது", + "Unable to look up phone number": "தொலைபேசி எண்ணைத் தேட முடியவில்லை", + "You're already in a call with this person.": "நீங்கள் முன்னதாகவே இந்த நபருடன் அழைப்பில் உள்ளீர்கள்.", + "Already in call": "முன்னதாகவே அழைப்பில் உள்ளது", + "You've reached the maximum number of simultaneous calls.": "ஒரே நேரத்தில் அழைக்கக்கூடிய அதிகபட்ச அழைப்புகளை நீங்கள் அடைந்துவிட்டீர்கள்.", + "Too Many Calls": "மிக அதிக அழைப்புகள்", + "No other application is using the webcam": "வேறு எந்த பயன்பாடும் புகைப்படக்கருவியைப் பயன்படுத்துவதில்லை", + "Permission is granted to use the webcam": "புகைப்படக்கருவியைப் பயன்படுத்த அனுமதி வழங்கப்பட்டுள்ளது", + "A microphone and webcam are plugged in and set up correctly": "ஒரு ஒலிவாங்கி மற்றும் புகைப்படக்கருவி செருகப்பட்டு சரியாக அமைக்கப்பட்டுள்ளது", + "Call failed because webcam or microphone could not be accessed. Check that:": "புகைப்படக்கருவி அல்லது ஒலிவாங்கியை அணுக முடியாததால் அழைப்பு தோல்வியடைந்தது. அதை சரிபார்க்கவும்:", + "Unable to access webcam / microphone": "புகைப்படக்கருவி / ஒலிவாங்கியை அணுக முடியவில்லை", + "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "ஒலிவாங்கியை அணுக முடியாததால் அழைப்பு தோல்வியடைந்தது. ஒலிவாங்கி செருகப்பட்டுள்ளதா, சரியாக அமைக்கவும் என சரிபார்க்கவும்.", + "Unable to access microphone": "ஒலிவாங்கியை அணுக முடியவில்லை", + "Try using turn.matrix.org": "turn.matrix.org ஐப் பயன்படுத்த முயற்சிக்கவும்", + "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "மாற்றாக, நீங்கள் பொது சேவையகத்தை turn.matrix.org பயன்படுத்தி முயற்சி செய்யலாம், ஆனால் இது அவ்வளவு நம்பகமானதாக இருக்காது, மேலும் அது உங்கள் ஐபி முகவரியை அந்த சேவையகத்துடன் பகிர்ந்து கொள்ளும். இதை அமைப்புகளிலும் நிர்வகிக்கலாம்.", + "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "அழைப்புகள் நம்பத்தகுந்த வகையில் இயங்குவதற்காக, TURN சேவையகத்தை உள்ளமைக்க உங்கள் வீட்டுசேவையகத்தின் (%(homeserverDomain)s) நிர்வாகியிடம் கேளுங்கள்.", + "Call failed due to misconfigured server": "தவறாக உள்ளமைக்கப்பட்ட சேவையகம் காரணமாக அழைப்பு தோல்வியடைந்தது", + "The call was answered on another device.": "அழைப்பு மற்றொரு சாதனத்தில் பதிலளிக்கப்பட்டது.", + "Answered Elsewhere": "வேறு எங்கோ பதிலளிக்கப்பட்டது", + "The call could not be established": "அழைப்பை நிறுவ முடியவில்லை", + "The other party declined the call.": "மற்றவர் அழைப்பை மறுத்துவிட்டார்.", + "Call Declined": "அழைப்பு மறுக்கப்பட்டது", + "Unable to load! Check your network connectivity and try again.": "ஏற்ற முடியவில்லை! உங்கள் பிணைய இணைப்பைச் சரிபார்த்து மீண்டும் முயற்சிக்கவும்.", + "Your user agent": "உங்கள் பயனர் முகவர்", + "Whether you're using %(brand)s as an installed Progressive Web App": "நிறுவப்பட்ட முற்போக்கான வலை பயன்பாடாக %(brand)s ஐப் பயன்படுத்துகிறீர்களா", + "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "தொடுதல் உள்ளீட்டு பொறிமுறை முதன்மையாக இருக்கும் சாதனத்தில் நீங்கள் %(brand)s ஐப் பயன்படுத்துகிறீர்களா", + "Add Phone Number": "தொலைபேசி எண்ணை சேர்க்கவும்", + "Click the button below to confirm adding this phone number.": "இந்த தொலைபேசி எண்ணைச் சேர்ப்பதை உறுதிப்படுத்த கீழே உள்ள பொத்தானை அழுத்தவும்.", + "Confirm adding phone number": "தொலைபேசி எண்ணைச் சேர்ப்பதை உறுதிப்படுத்தவும்", + "Confirm adding this phone number by using Single Sign On to prove your identity.": "உங்கள் அடையாளத்தை நிரூபிக்க ஒற்றை உள்நுழைவைப் பயன்படுத்தி இந்த தொலைபேசி எண்ணைச் சேர்ப்பதை உறுதிப்படுத்தவும்.", + "Add Email Address": "மின்னஞ்சல் முகவரியை சேர்க்கவும்", + "Confirm": "உறுதிப்படுத்தவும்", + "Click the button below to confirm adding this email address.": "இந்த மின்னஞ்சல் முகவரியை சேர்ப்பதை உறுதிப்படுத்த கீழே உள்ள பொத்தானை அழுத்தவும்.", + "Confirm adding email": "மின்னஞ்சலை சேர்ப்பதை உறுதிப்படுத்தவும்", + "Single Sign On": "ஒற்றை உள்நுழைவு", + "Confirm adding this email address by using Single Sign On to prove your identity.": "உங்கள் அடையாளத்தை நிரூபிக்க ஒற்றை உள்நுழைவைப் பயன்படுத்தி இந்த மின்னஞ்சல் முகவரியை சேர்ப்பதை உறுதிப்படுத்தவும்.", + "Use Single Sign On to continue": "தொடர ஒற்றை உள்நுழைவைப் பயன்படுத்தவும்" } diff --git a/src/i18n/strings/tzm.json b/src/i18n/strings/tzm.json index ba63af5fb0..f9ac9cf574 100644 --- a/src/i18n/strings/tzm.json +++ b/src/i18n/strings/tzm.json @@ -4,7 +4,7 @@ "Actions": "Tugawin", "Messages": "Tuzinin", "Cancel": "Sser", - "Create Account": "Ssenflul amiḍan", + "Create Account": "senflul amiḍan", "Sign In": "Kcem", "Name or Matrix ID": "Isem neɣ ID Matrix", "Dec": "Duj", @@ -35,5 +35,122 @@ "The version of %(brand)s": "Taleqqemt n %(brand)s", "Add Phone Number": "Rnu uṭṭun n utilifun", "Add Email Address": "Rnu tasna imayl", - "Open": "Ṛẓem" + "Open": "Ṛẓem", + "Permissions": "Tisirag", + "Subscribe": "Zemmem", + "Change": "Senfel", + "Disconnect": "Kkes azday", + "exists": "illa", + "Santa": "Santa", + "Pizza": "Tapizzat", + "Corn": "Akbal", + "Cloud": "Tagut", + "Globe": "Amaḍal", + "Flower": "Ajeǧǧig", + "Butterfly": "Aferteṭṭu", + "Rooster": "Ayaẓiḍ", + "Panda": "Apanda", + "Upgrade": "Leqqem", + "Confirm": "Sentem", + "Brazil": "Brazil", + "Bolivia": "Bulivya", + "Bhutan": "Buṭan", + "Bermuda": "Birmuda", + "Benin": "Binin", + "Belize": "Biliz", + "Belgium": "Beljika", + "Belarus": "Bilarusya", + "Bahamas": "Bahamas", + "Aruba": "Aruba", + "Angola": "Angula", + "Andorra": "Andura", + "Algeria": "Dzayer", + "Albania": "Albanya", + "End": "End", + "Space": "Space", + "Shift": "Shift", + "Super": "Super", + "Ctrl": "Ctrl", + "Esc": "Esc", + "Calls": "Iɣuṛiten", + "Emoji": "Imuji", + "Afghanistan": "Afɣanistan", + "Logout": "Ffeɣ", + "Leave": "Fel", + "Phone": "Atilifun", + "Email": "Imayl", + "Go": "Ddu", + "Send": "Azen", + "example": "amedya", + "Example": "Amedya", + "Hide": "Ffer", + "Name": "Isem", + "Flags": "Icenyalen", + "Join": "Lkem", + "edited": "infel", + "Copied!": "inɣel!", + "Home": "Asnubeg", + "Reply": "Rar", + "Yes": "Yah", + "About": "Xef", + "Search…": "Arezzu…", + "A-Z": "A-Ẓ", + "Settings": "Tisɣal", + "Reject": "Agy", + "Re-join": "als-lkem", + "People": "Midden", + "Search": "Rzu", + "%(duration)sd": "%(duration)sas", + "Loading...": "Azdam...", + "Share": "Bḍu", + "Camera": "Takamiṛa", + "Microphone": "Amikṛu", + "Add": "Rnu", + "Ignore": "Nexxel", + "None": "Walu", + "Account": "Amiḍan", + "Theme": "Asgum", + "Algorithm:": "Talguritmit:", + "Save": "Ḥḍu", + "Profile": "Ifres", + "ID": "ID", + "Remove": "KKes", + "Folder": "Asdaw", + "Guitar": "Agiṭaṛ", + "Ball": "Tacama", + "Flag": "Acenyal", + "Telephone": "Atilifun", + "Key": "Tasarut", + "Book": "Adlis", + "Gift": "Timucit", + "Hat": "Tarazal", + "Robot": "Aṛubu", + "Heart": "Ul", + "Apple": "Tadeffuyt", + "Banana": "Tabanant", + "Fire": "Timessi", + "Moon": "Ayyur", + "Mushroom": "Agursel", + "Tree": "Aseklu", + "Fish": "Aselm", + "Turtle": "Ifker", + "Rabbit": "Agnin", + "Elephant": "Ilew", + "Pig": "Ilef", + "Close": "Rgel", + "Horse": "Ayyis", + "Lion": "Izem", + "Cat": "Amuc", + "Dog": "Aydi", + "or": "neɣ", + "Decline": "Agy", + "Guest": "Anebgi", + "Ok": "Wax", + "Notifications": "Tineɣmisin", + "No": "Uhu", + "Dark": "Adeɣmum", + "Usage": "Asemres", + "Feb": "Bṛa", + "Jan": "Yen", + "Continue": "Kemmel" } diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json index db5ce9b360..92da704837 100644 --- a/src/i18n/strings/uk.json +++ b/src/i18n/strings/uk.json @@ -267,8 +267,8 @@ "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(day)s %(monthName)s %(fullYear)s %(time)s", "Who would you like to add to this community?": "Кого ви хочете додати до цієї спільноти?", "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Там, де ця сторінка містить ототожненну інформацію, як-от назва кімнати, користувача чи групи, ці дані будуть вилучені перед надсиланням на сервер.", - "Call in Progress": "Іде виклик", - "A call is currently being placed!": "Зараз іде виклик!", + "Call in Progress": "Триває виклик", + "A call is currently being placed!": "Зараз триває виклик!", "A call is already in progress!": "Вже здійснюється дзвінок!", "Permission Required": "Потрібен дозвіл", "You do not have permission to start a conference call in this room": "У вас немає дозволу, щоб розпочати дзвінок-конференцію в цій кімнаті", @@ -306,8 +306,8 @@ "Missing user_id in request": "У запиті пропущено user_id", "Usage": "Використання", "Searches DuckDuckGo for results": "Здійснює пошук через DuckDuckGo", - "/ddg is not a command": "/ddg — це не команда", - "To use it, just wait for autocomplete results to load and tab through them.": "Щоб цим скористатися, просто почекайте на підказки доповнення й перемикайтеся між ними клавішею TAB.", + "/ddg is not a command": "/ddg не є командою", + "To use it, just wait for autocomplete results to load and tab through them.": "Щоб цим скористатися, просто почекайте на підказки автодоповнення й перемикайтеся між ними клавішею TAB.", "Changes your display nickname": "Змінює ваш нік", "Invites user with given id to current room": "Запрошує користувача з вказаним ідентифікатором до кімнати", "Leave room": "Залишити кімнату", @@ -457,7 +457,7 @@ "Actions": "Дії", "Other": "Інше", "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Додає ¯\\_(ツ)_/¯ на початку текстового повідомлення", - "Sends a message as plain text, without interpreting it as markdown": "Надсилає повідомлення як чистий текст, не використовуючи markdown", + "Sends a message as plain text, without interpreting it as markdown": "Надсилає повідомлення у вигляді звичайного тексту, не інтерпретуючи його як розмітку", "Upgrades a room to a new version": "Покращує кімнату до нової версії", "You do not have the required permissions to use this command.": "Вам бракує дозволу на використання цієї команди.", "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "Увага!: Поліпшення кімнати не перенесе автоматично усіх учасників до нової версії кімнати. Ми опублікуємо посилання на нову кімнату у старій версії кімнати, а учасники мають власноруч клацнути це посилання, щоб приєднатися до нової кімнати.", @@ -624,7 +624,7 @@ "Riot is now Element!": "Riot тепер - Element!", "Learn More": "Дізнатися більше", "Command error": "Помилка команди", - "Sends a message as html, without interpreting it as markdown": "Надсилає повідомлення як HTML, не інтерпритуючи Markdown", + "Sends a message as html, without interpreting it as markdown": "Надсилає повідомлення у вигляді HTML, не інтерпретуючи його як розмітку", "Failed to set topic": "Не вдалося встановити тему", "Once enabled, encryption cannot be disabled.": "Після увімкнення шифрування не можна буде вимкнути.", "Please enter verification code sent via text.": "Будь ласка, введіть звірювальний код, відправлений у текстовому повідомленні.", @@ -1602,5 +1602,33 @@ "Share Link to User": "Поділитися посиланням на користувача", "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Повідомлення тут захищено наскрізним шифруванням. Підтвердьте %(displayName)s у їхньому профілі — натиснувши на їх аватар.", "Open": "Відкрити", - "In reply to ": "У відповідь на " + "In reply to ": "У відповідь на ", + "The user you called is busy.": "Користувач, якого ви викликаєте, зайнятий.", + "User Busy": "Користувач зайнятий", + "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "Додає ┬──┬ ノ( ゜-゜ノ) на початку текстового повідомлення", + "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "Додає (╯°□°)╯︵ ┻━┻ на початку текстового повідомлення", + "We couldn't log you in": "Нам не вдалося виконати вхід", + "You're already in a call with this person.": "Ви вже спілкуєтесь із цією особою.", + "Already in call": "Вже у виклику", + "You can't send any messages until you review and agree to our terms and conditions.": "Ви не можете надсилати жодних повідомлень, поки не переглянете та не погодитесь з нашими умовами та положеннями.", + "Send message": "Надіслати повідомлення", + "Sending your message...": "Надсилання повідомлення...", + "You can use /help to list available commands. Did you mean to send this as a message?": "Ви можете скористатися /help для перегляду доступних команд. Ви мали намір надіслати це як повідомлення?", + "Send messages": "Надіслати повідомлення", + "sends confetti": "надсилає конфеті", + "sends fireworks": "надсилає феєрверк", + "sends space invaders": "надсилає тему про космічних загарбників", + "Sends the given message with a space themed effect": "Надсилає це повідомлення з космічними ефектами", + "unknown person": "невідома особа", + "Sends the given message with snowfall": "Надсилає це повідомлення зі снігопадом", + "Sends the given message with fireworks": "Надсилає це повідомлення з феєрверком", + "Sends the given message with confetti": "Надсилає це повідомлення з конфеті", + "Use Ctrl + Enter to send a message": "Натисніть Ctrl + Enter, щоб надіслати повідомлення", + "Use Command + Enter to send a message": "Натисніть Command + Enter, щоб надіслати повідомлення", + "Use Ctrl + F to search": "Натисніть Ctrl + F, щоб шукати", + "Use Command + F to search": "Натисніть Command + F, щоб шукати", + "Send text messages as you in this room": "Надіслати текстові повідомлення у цю кімнату від свого імені", + "Send messages as you in your active room": "Надіслати повідомлення у свою активну кімнату від свого імені", + "Send messages as you in this room": "Надіслати повідомлення у цю кімнату від свого імені", + "Sends the given message as a spoiler": "Надсилає вказане повідомлення згорненим" } diff --git a/src/i18n/strings/zh_Hans.json b/src/i18n/strings/zh_Hans.json index 6afe74dbee..7aa0d75539 100644 --- a/src/i18n/strings/zh_Hans.json +++ b/src/i18n/strings/zh_Hans.json @@ -51,7 +51,7 @@ "Invalid Email Address": "邮箱地址格式错误", "Invalid file%(extra)s": "无效文件%(extra)s", "Return to login screen": "返回登录页面", - "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s 没有通知发送权限 - 请检查您的浏览器设置", + "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s 没有通知发送权限 - 请检查你的浏览器设置", "%(brand)s was not given permission to send notifications - please try again": "%(brand)s 没有通知发送权限 - 请重试", "%(brand)s version:": "%(brand)s 版本:", "Room %(roomId)s not visible": "聊天室 %(roomId)s 已隐藏", @@ -65,7 +65,7 @@ "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s 向 %(targetDisplayName)s 发了加入聊天室的邀请。", "Server error": "服务器错误", "Server may be unavailable, overloaded, or search timed out :(": "服务器可能不可用、超载,或者搜索超时 :(", - "Server may be unavailable, overloaded, or you hit a bug.": "当前服务器可能处于不可用或过载状态,或者您遇到了一个 bug。", + "Server may be unavailable, overloaded, or you hit a bug.": "当前服务器可能处于不可用或过载状态,或者你遇到了一个 bug。", "Server unavailable, overloaded, or something else went wrong.": "服务器可能不可用、超载,或者其他东西出错了.", "Session ID": "会话 ID", "%(senderName)s set a profile picture.": "%(senderName)s 设置了头像。", @@ -272,7 +272,7 @@ "Upload file": "上传文件", "Usage": "用法", "Who can read history?": "谁可以阅读历史消息?", - "You are not in this room.": "您不在此聊天室中。", + "You are not in this room.": "你不在此聊天室中。", "You have no visible notifications": "没有可见的通知", "Not a valid %(brand)s keyfile": "不是有效的 %(brand)s 密钥文件", "%(targetName)s accepted an invitation.": "%(targetName)s 已接受邀请。", @@ -310,11 +310,11 @@ "(no answer)": "(无回复)", "Who can access this room?": "谁有权访问此聊天室?", "You are already in a call.": "您正在通话。", - "You do not have permission to do that in this room.": "您没有进行此操作的权限。", + "You do not have permission to do that in this room.": "你没有进行此操作的权限。", "You cannot place VoIP calls in this browser.": "无法在此浏览器中发起 VoIP 通话。", - "You do not have permission to post to this room": "您没有在此聊天室发送消息的权限", - "You seem to be in a call, are you sure you want to quit?": "您似乎正在进行通话,确定要退出吗?", - "You seem to be uploading files, are you sure you want to quit?": "您似乎正在上传文件,确定要退出吗?", + "You do not have permission to post to this room": "你没有在此聊天室发送消息的权限", + "You seem to be in a call, are you sure you want to quit?": "你似乎正在进行通话,确定要退出吗?", + "You seem to be uploading files, are you sure you want to quit?": "你似乎正在上传文件,确定要退出吗?", "Upload an avatar:": "上传头像:", "An error occurred: %(error_string)s": "发生了一个错误: %(error_string)s", "There are no visible files in this room": "此聊天室中没有可见的文件", @@ -327,13 +327,13 @@ "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s 移除了聊天室头像。", "Something went wrong!": "出了点问题!", "If you already have a Matrix account you can log in instead.": "若您已经拥有 Matrix 帐号,您也可以 登录。", - "Do you want to set an email address?": "您想要设置一个邮箱地址吗?", + "Do you want to set an email address?": "你想要设置一个邮箱地址吗?", "Upload new:": "上传新的:", "Username invalid: %(errMessage)s": "用户名无效: %(errMessage)s", "Verification Pending": "验证等待中", "(unknown failure: %(reason)s)": "(未知错误:%(reason)s)", "%(senderName)s withdrew %(targetName)s's invitation.": "%(senderName)s 收回了 %(targetName)s 的邀请。", - "You cannot place a call with yourself.": "您无法向自己发起通话。", + "You cannot place a call with yourself.": "你无法向自己发起通话。", "You have disabled URL previews by default.": "你已经默认禁用链接预览。", "You have enabled URL previews by default.": "你已经默认启用链接预览。", "Set a display name:": "设置昵称:", @@ -385,10 +385,10 @@ "%(widgetName)s widget removed by %(senderName)s": "%(senderName)s 移除了 %(widgetName)s 挂件", "%(widgetName)s widget modified by %(senderName)s": "%(senderName)s 修改了 %(widgetName)s 挂件", "Unpin Message": "取消置顶消息", - "Add rooms to this community": "添加聊天室到此社区", + "Add rooms to this community": "添加聊天室到此社群", "Call Failed": "呼叫失败", - "Invite new community members": "邀请新社区成员", - "Invite to Community": "邀请到社区", + "Invite new community members": "邀请新社群成员", + "Invite to Community": "邀请到社群", "Ignored user": "已忽略的用户", "You are now ignoring %(userId)s": "你忽略了 %(userId)s", "Unignored user": "未忽略的用户", @@ -450,28 +450,28 @@ "collapse": "折叠", "expand": "展开", "email address": "邮箱地址", - "You have entered an invalid address.": "您输入了无效的地址。", + "You have entered an invalid address.": "你输入了无效的地址。", "Leave": "退出", "Description": "描述", "Warning": "警告", "Room Notification": "聊天室通知", - "The platform you're on": "您使用的平台是", + "The platform you're on": "你使用的平台是", "The version of %(brand)s": "%(brand)s 版本", - "Your language of choice": "您选择的语言是", - "Whether or not you're using the Richtext mode of the Rich Text Editor": "您是否正在使用富文本编辑器的富文本模式", - "Your homeserver's URL": "您的主服务器的链接", + "Your language of choice": "你选择的语言是", + "Whether or not you're using the Richtext mode of the Rich Text Editor": "你是否正在使用富文本编辑器的富文本模式", + "Your homeserver's URL": "你的主服务器的链接", "The information being sent to us to help make %(brand)s better includes:": "正在给我们发送信息以帮助 %(brand)s:", - "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "此页面中含有可用于识别您身份的信息,比如聊天室、用户或群组 ID,这些数据会在发送到服务器前被移除。", + "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "此页面中含有可用于识别你身份的信息,比如聊天室、用户或群组 ID,这些数据会在发送到服务器前被移除。", "%(weekDayName)s %(time)s": "%(weekDayName)s %(time)s", "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(monthName)s %(day)s %(time)s, %(weekDayName)s", "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(fullYear)s %(monthName)s %(day)s, %(weekDayName)s", "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(fullYear)s %(monthName)s %(day)s %(time)s, %(weekDayName)s", - "Who would you like to add to this community?": "您想把谁添加至此社区中?", - "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "警告:您添加的一切用户都将会对一切知道此社区的 ID 的人公开", - "Which rooms would you like to add to this community?": "您想把哪个聊天室添加至此社区中?", - "Add rooms to the community": "添加聊天室到社区", - "Add to community": "添加到社区", - "Failed to invite users to community": "邀请用户到社区失败", + "Who would you like to add to this community?": "你想把谁添加至此社群中?", + "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "警告:你添加的一切用户都将会对一切知道此社群的 ID 的人公开", + "Which rooms would you like to add to this community?": "你想把哪个聊天室添加至此社群中?", + "Add rooms to the community": "添加聊天室到社群", + "Add to community": "添加到社群", + "Failed to invite users to community": "邀请用户到社群失败", "Enable inline URL previews by default": "默认启用链接预览", "Disinvite this user?": "是否不再邀请此用户?", "Kick this user?": "是否移除此用户?", @@ -485,24 +485,24 @@ "Members only (since the point in time of selecting this option)": "仅成员(从选中此选项时开始)", "Members only (since they were invited)": "只有成员(从他们被邀请开始)", "Members only (since they joined)": "只有成员(从他们加入开始)", - "Invalid community ID": "无效的社区 ID", - "Create Community": "创建社区", - "Community Name": "社区名称", - "Community ID": "社区 ID", + "Invalid community ID": "无效的社群 ID", + "Create Community": "创建社群", + "Community Name": "社群名称", + "Community ID": "社群 ID", "example": "示例", "Add a Room": "添加聊天室", "Add a User": "添加用户", "Unable to accept invite": "无法接受邀请", "Unable to reject invite": "无法拒绝邀请", - "Leave Community": "退出社区", - "Community Settings": "社区设置", - "Community %(groupId)s not found": "找不到社区 %(groupId)s", - "Your Communities": "我的社区", + "Leave Community": "退出社群", + "Community Settings": "社群设置", + "Community %(groupId)s not found": "找不到社群 %(groupId)s", + "Your Communities": "我的社群", "Failed to set direct chat tag": "无法设定私聊标签", "Failed to remove tag %(tagName)s from room": "移除聊天室标签 %(tagName)s 失败", "Failed to add tag %(tagName)s to room": "无法为聊天室新增标签 %(tagName)s", "Submit debug logs": "提交调试日志", - "Show these rooms to non-members on the community page and room list?": "在社区页面与聊天室列表上对非社区成员显示这些聊天室?", + "Show these rooms to non-members on the community page and room list?": "在社群页面与聊天室列表上对非社群成员显示这些聊天室?", "Failed to invite users to %(groupId)s": "邀请用户到 %(groupId)s 失败", "Failed to invite the following users to %(groupId)s:": "邀请下列用户到 %(groupId)s 失败:", "Failed to add the following rooms to %(groupId)s:": "添加以下聊天室到 %(groupId)s 失败:", @@ -510,11 +510,11 @@ "Restricted": "受限用户", "To use it, just wait for autocomplete results to load and tab through them.": "若要使用自动补全,只要等待自动补全结果加载完成,按 Tab 键切换即可。", "%(oldDisplayName)s changed their display name to %(displayName)s.": "%(oldDisplayName)s 将他们的昵称修改成了 %(displayName)s 。", - "Stickerpack": "贴图集", - "You don't currently have any stickerpacks enabled": "您目前没有启用任何贴图集", + "Stickerpack": "贴纸包", + "You don't currently have any stickerpacks enabled": "你目前未启用任何贴纸包", "Key request sent.": "已发送密钥共享请求。", - "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "如果您是房间中最后一位有权限的用户,在您降低自己的权限等级后将无法撤回此修改,因为你将无法重新获得权限。", - "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.": "您将无法撤回此修改,因为您正在将此用户的滥权等级提升至与你相同。", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "如果你是房间中最后一位有权限的用户,在你降低自己的权限等级后将无法撤回此修改,因为你将无法重新获得权限。", + "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.": "你将无法撤回此修改,因为你正在将此用户的滥权等级提升至与你相同。", "Unmute": "取消静音", "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s(滥权等级 %(powerLevelNumber)s)", "Hide Stickers": "隐藏贴图", @@ -528,23 +528,23 @@ "Offline for %(duration)s": "已离线 %(duration)s", "Unknown for %(duration)s": "未知状态已持续 %(duration)s", "Seen by %(displayName)s (%(userName)s) at %(dateTime)s": "%(displayName)s (%(userName)s) 在 %(dateTime)s 看到这里", - "'%(groupId)s' is not a valid community ID": "“%(groupId)s” 不是有效的社区 ID", + "'%(groupId)s' is not a valid community ID": "“%(groupId)s” 不是有效的社群 ID", "Flair": "个性徽章", "Code": "代码", - "Remove from community": "从社区中移除", - "Disinvite this user from community?": "是否不再邀请此用户加入本社区?", - "Remove this user from community?": "是否要从社区中移除此用户?", + "Remove from community": "从社群中移除", + "Disinvite this user from community?": "是否不再邀请此用户加入本社群?", + "Remove this user from community?": "是否要从社群中移除此用户?", "Failed to withdraw invitation": "撤回邀请失败", "Failed to remove user from community": "移除用户失败", - "Filter community members": "过滤社区成员", + "Filter community members": "过滤社群成员", "Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "你确定要从 %(groupId)s 中移除 %(roomName)s 吗?", - "Removing a room from the community will also remove it from the community page.": "从社区中移除房间时,同时也会将其从社区页面中移除。", - "Failed to remove room from community": "从社区中移除聊天室失败", + "Removing a room from the community will also remove it from the community page.": "从社群中移除房间时,同时也会将其从社群页面中移除。", + "Failed to remove room from community": "从社群中移除聊天室失败", "Failed to remove '%(roomName)s' from %(groupId)s": "从 %(groupId)s 中移除 “%(roomName)s” 失败", - "Only visible to community members": "仅对社区成员可见", - "Filter community rooms": "过滤社区聊天室", - "You're not currently a member of any communities.": "您目前不是任何一个社区的成员。", - "Communities": "社区", + "Only visible to community members": "仅对社群成员可见", + "Filter community rooms": "过滤社群聊天室", + "You're not currently a member of any communities.": "你目前不是任何一个社群的成员。", + "Communities": "社群", "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)s 已加入 %(count)s 次", "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)s 已加入", @@ -571,13 +571,13 @@ "%(oneUser)shad their invitation withdrawn %(count)s times|other": "%(oneUser)s 撤回了他们的邀请共 %(count)s 次", "%(oneUser)shad their invitation withdrawn %(count)s times|one": "%(oneUser)s 撤回了他们的邀请", "In reply to ": "回复给 ", - "Community IDs cannot be empty.": "社区 ID 不能为空。", - "Community IDs may only contain characters a-z, 0-9, or '=_-./'": "社区 ID 只能包含 a-z、0-9 或 “=_-./” 等字符", - "Something went wrong whilst creating your community": "创建社区时出现问题", - "If you have previously used a more recent version of %(brand)s, your session may be incompatible with this version. Close this window and return to the more recent version.": "如果您之前使用过较新版本的 %(brand)s,则您的会话可能与当前版本不兼容。请关闭此窗口并使用最新版本。", - "Showing flair for these communities:": "显示这些社区的个性徽章:", - "This room is not showing flair for any communities": "此聊天室没有显示任何社区的个性徽章", - "New community ID (e.g. +foo:%(localDomain)s)": "新社区 ID(例子:+foo:%(localDomain)s)", + "Community IDs cannot be empty.": "社群 ID 不能为空。", + "Community IDs may only contain characters a-z, 0-9, or '=_-./'": "社群 ID 只能包含 a-z、0-9 或 “=_-./” 等字符", + "Something went wrong whilst creating your community": "创建社群时出现问题", + "If you have previously used a more recent version of %(brand)s, your session may be incompatible with this version. Close this window and return to the more recent version.": "如果你之前使用过较新版本的 %(brand)s,则你的会话可能与当前版本不兼容。请关闭此窗口并使用最新版本。", + "Showing flair for these communities:": "显示这些社群的个性徽章:", + "This room is not showing flair for any communities": "此聊天室没有显示任何社群的个性徽章", + "New community ID (e.g. +foo:%(localDomain)s)": "新社群 ID(例子:+foo:%(localDomain)s)", "URL previews are enabled by default for participants in this room.": "此聊天室默认启用链接预览。", "URL previews are disabled by default for participants in this room.": "此聊天室默认禁用链接预览。", "%(senderDisplayName)s changed the room avatar to ": "%(senderDisplayName)s 将聊天室的头像更改为 ", @@ -585,56 +585,56 @@ "Matrix ID": "Matrix ID", "Matrix Room ID": "Matrix 聊天室 ID", "

    HTML for your community's page

    \n

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

    \n

    \n You can even use 'img' tags\n

    \n": "", - "Add rooms to the community summary": "将聊天室添加到社区简介中", - "Which rooms would you like to add to this summary?": "您想要将哪个聊天室添加到社区简介?", + "Add rooms to the community summary": "将聊天室添加到社群简介中", + "Which rooms would you like to add to this summary?": "你想要将哪个聊天室添加到社群简介?", "Add to summary": "添加到简介", "Failed to add the following rooms to the summary of %(groupId)s:": "添加以下聊天室到 %(groupId)s 的简介中时失败:", "Failed to remove the room from the summary of %(groupId)s": "从 %(groupId)s 的简介中移除此聊天室时失败", - "The room '%(roomName)s' could not be removed from the summary.": "聊天室 “%(roomName)s” 无法从社区简介中移除。", - "Failed to update community": "更新社区简介失败", - "Unable to leave community": "无法退出社区", + "The room '%(roomName)s' could not be removed from the summary.": "聊天室 “%(roomName)s” 无法从社群简介中移除。", + "Failed to update community": "更新社群简介失败", + "Unable to leave community": "无法退出社群", "Leave %(groupName)s?": "退出 %(groupName)s?", "Featured Rooms:": "核心聊天室:", "Featured Users:": "核心用户:", - "Join this community": "加入此社区", - "%(inviter)s has invited you to join this community": "%(inviter)s 邀请您加入此社区", + "Join this community": "加入此社群", + "%(inviter)s has invited you to join this community": "%(inviter)s 邀请你加入此社群", "Failed to add the following users to the summary of %(groupId)s:": "将下列用户添加至 %(groupId)s 的简介中时失败:", "Failed to remove a user from the summary of %(groupId)s": "从 %(groupId)s 的简介中移除用户时失败", - "You are an administrator of this community": "你是此社区的管理员", - "You are a member of this community": "你是此社区的成员", - "Who can join this community?": "谁可以加入此社区?", + "You are an administrator of this community": "你是此社群的管理员", + "You are a member of this community": "你是此社群的成员", + "Who can join this community?": "谁可以加入此社群?", "Everyone": "所有人", - "Leave this community": "退出此社区", + "Leave this community": "退出此社群", "Long Description (HTML)": "长描述(HTML)", "Old cryptography data detected": "检测到旧的加密数据", - "Data from an older version of %(brand)s has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "已检测到旧版%(brand)s的数据,这将导致端到端加密在旧版本中发生故障。在此版本中,使用旧版本交换的端对端加密消息可能无法解密。这也可能导致与此版本交换的消息失败。如果您遇到问题,请退出并重新登录。要保留历史消息,请先导出并在重新登录后导入您的密钥。", - "Did you know: you can use communities to filter your %(brand)s experience!": "你知道吗:你可以将社区用作过滤器以增强你的 %(brand)s 使用体验!", - "Create a new community": "创建新社区", - "Error whilst fetching joined communities": "获取已加入社区列表时出现错误", - "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "创建社区,将用户与聊天室整合在一起!搭建自定义社区主页以在 Matrix 宇宙之中标出您的私人空间。", + "Data from an older version of %(brand)s has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "已检测到旧版%(brand)s的数据,这将导致端到端加密在旧版本中发生故障。在此版本中,使用旧版本交换的端对端加密消息可能无法解密。这也可能导致与此版本交换的消息失败。如果你遇到问题,请退出并重新登录。要保留历史消息,请先导出并在重新登录后导入你的密钥。", + "Did you know: you can use communities to filter your %(brand)s experience!": "你知道吗:你可以将社群用作过滤器以增强你的 %(brand)s 使用体验!", + "Create a new community": "创建新社群", + "Error whilst fetching joined communities": "获取已加入社群列表时出现错误", + "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "创建社群,将用户与聊天室整合在一起!搭建自定义社群主页以在 Matrix 宇宙之中标出你的私人空间。", "%(count)s of your messages have not been sent.|one": "您的消息尚未发送。", "Uploading %(filename)s and %(count)s others|other": "正在上传 %(filename)s 与其他 %(count)s 个文件", "Uploading %(filename)s and %(count)s others|zero": "正在上传 %(filename)s", "Uploading %(filename)s and %(count)s others|one": "正在上传 %(filename)s 与其他 %(count)s 个文件", "Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.": "隐私对我们而言重要至极,所以我们不会在分析统计服务中收集任何个人信息或者可用于识别身份的数据。", "Learn more about how we use analytics.": "进一步了解我们如何使用分析统计服务。", - "Please note you are logging into the %(hs)s server, not matrix.org.": "请注意,您正在登录 %(hs)s,而非 matrix.org。", + "Please note you are logging into the %(hs)s server, not matrix.org.": "请注意,你正在登录 %(hs)s,而非 matrix.org。", "This homeserver doesn't offer any login flows which are supported by this client.": "此主服务器不兼容本客户端支持的任何登录方式。", "Opens the Developer Tools dialog": "打开开发者工具窗口", "Notify the whole room": "通知聊天室全体成员", - "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.": "此操作允许您将加密聊天室中收到的消息的密钥导出为本地文件。您可以将文件导入其他 Matrix 客户端,以便让别的客户端在未收到密钥的情况下解密这些消息。", - "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "导出的文件将允许任何可以读取它的人解密任何他们可以看到的加密消息,因此,您应该小心对待,以确保其安全。为解决此问题,您应当在下面输入密语以加密导出的数据。只有输入相同的密语才能导入数据。", + "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.": "此操作允许你将加密聊天室中收到的消息的密钥导出为本地文件。你可以将文件导入其他 Matrix 客户端,以便让别的客户端在未收到密钥的情况下解密这些消息。", + "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "导出的文件将允许任何可以读取它的人解密任何他们可以看到的加密消息,因此,你应此小心对待,以确保其安全。为解决此问题,你应当在下面输入密语以加密导出的数据。只有输入相同的密语才能导入数据。", "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.": "导出文件受密语保护。必须输入密语以解密此文件。", - "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "此操作允许您导入之前从另一个 Matrix 客户端中导出的加密密钥文件。导入完成后,您将能够解密那个客户端可以解密的加密消息。", + "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "此操作允许你导入之前从另一个 Matrix 客户端中导出的加密密钥文件。导入完成后,你将能够解密那个客户端可以解密的加密消息。", "Ignores a user, hiding their messages from you": "忽略用户,隐藏他们发送的消息", "Stops ignoring a user, showing their messages going forward": "解除忽略用户,显示他们的消息", - "If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "如果你在 GitHub 提交了一个 bug,调试日志可以帮助我们追踪这个问题。 调试日志包含应用程序使用数据,也就包括您的用户名、您访问的房间或社区的 ID 或别名,以及其他用户的用户名,但不包括聊天记录。", + "If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "如果你在 GitHub 提交了一个 bug,调试日志可以帮助我们追踪这个问题。 调试日志包含应用程序使用数据,也就包括你的用户名、你访问的房间或社群的 ID 或别名,以及其他用户的用户名,但不包括聊天记录。", "Tried to load a specific point in this room's timeline, but was unable to find it.": "尝试加载此聊天室的时间线的特定时间点,但是无法找到。", "%(count)s Resend all or cancel all now. You can also select individual messages to resend or cancel.|one": "现在 重新发送消息取消发送 。", "%(count)s Resend all or cancel all now. You can also select individual messages to resend or cancel.|other": "現在 重新发送消息取消发送 。你也可以单独选择消息以重新发送或取消。", "Visibility in Room List": "是否在聊天室目录中可见", - "Something went wrong when trying to get your communities.": "获取你加入的社区时发生错误。", - "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "删除挂件时将为聊天室中的所有成员删除。您确定要删除此挂件吗?", + "Something went wrong when trying to get your communities.": "获取你加入的社群时发生错误。", + "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "删除挂件时将为聊天室中的所有成员删除。你确定要删除此挂件吗?", "Fetching third party location failed": "获取第三方位置失败", "Send Account Data": "发送账号数据", "All notifications are currently disabled for all targets.": "目前所有通知都已禁用。", @@ -647,7 +647,7 @@ "Update": "更新", "What's New": "更新内容", "On": "打开", - "Changelog": "变更日志", + "Changelog": "更改日志", "Waiting for response from server": "正在等待服务器响应", "Send Custom Event": "发送自定义事件", "Advanced notification settings": "通知高级设置", @@ -679,7 +679,7 @@ "Collecting app version information": "正在收集应用版本信息", "Keywords": "关键词", "Enable notifications for this account": "对此账号启用通知", - "Invite to this community": "邀请加入此社区", + "Invite to this community": "邀请加入此社群", "Messages containing keywords": "包含 关键词 的消息", "Room not found": "找不到聊天室", "Tuesday": "星期二", @@ -730,7 +730,7 @@ "Back": "返回", "Reply": "回复", "Show message in desktop notification": "在桌面通知中显示信息", - "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "调试日志包含使用数据(包括您的用户名、您访问过的聊天室/群组的 ID 或别名,以及其他用户的用户名),不含聊天消息。", + "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "调试日志包含使用数据(包括你的用户名、你访问过的聊天室/群组的 ID 或别名,以及其他用户的用户名),不含聊天消息。", "Unhide Preview": "取消隐藏预览", "Unable to join network": "无法加入网络", "Sorry, your browser is not able to run %(brand)s.": "抱歉,您的浏览器 无法 运行 %(brand)s.", @@ -749,8 +749,8 @@ "Event Type": "事件类型", "Download this file": "下载该文件", "Pin Message": "置顶消息", - "Failed to change settings": "变更设置失败", - "View Community": "查看社区", + "Failed to change settings": "更改设置失败", + "View Community": "查看社群", "Event sent!": "事件已发送!", "View Source": "查看源码", "Event Content": "事件内容", @@ -761,13 +761,13 @@ "You need to be able to invite users to do that.": "你需要有邀请用户的权限才能进行此操作。", "Missing roomId.": "找不到此聊天室 ID 所对应的聊天室。", "e.g. ": "例如:", - "Your device resolution": "您设备的分辨率", + "Your device resolution": "你的设备分辨率", "Always show encryption icons": "总是显示加密标志", - "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "您将被带到一个第三方网站以便验证您的账号来使用 %(integrationsUrl)s 提供的集成。您希望继续吗?", - "The visibility of '%(roomName)s' in %(groupId)s could not be updated.": "无法更新聊天室 %(roomName)s 在社区 “%(groupId)s” 中的可见性。", + "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "你将被带到一个第三方网站以便验证你的账号来使用 %(integrationsUrl)s 提供的集成。你希望继续吗?", + "The visibility of '%(roomName)s' in %(groupId)s could not be updated.": "无法更新聊天室 %(roomName)s 在社群 “%(groupId)s” 中的可见性。", "Minimize apps": "最小化应用程序", "Popout widget": "在弹出式窗口中打开挂件", - "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "无法加载被回复的事件,它可能不存在,也可能是您没有权限查看它。", + "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "无法加载被回复的事件,它可能不存在,也可能是你没有权限查看它。", "And %(count)s more...|other": "和 %(count)s 个其他…", "Try using one of the following valid address types: %(validTypesList)s.": "请尝试使用以下的有效邮箱地址格式中的一种:%(validTypesList)s", "e.g. %(exampleValue)s": "例如:%(exampleValue)s", @@ -775,11 +775,11 @@ "A call is already in progress!": "您已在通话中!", "Send analytics data": "发送统计数据", "Enable widget screenshots on supported widgets": "对支持的挂件启用挂件截图", - "Demote yourself?": "是否降低您自己的权限?", + "Demote yourself?": "是否降低你自己的权限?", "Demote": "降权", "A call is currently being placed!": "正在发起通话!", "Permission Required": "需要权限", - "You do not have permission to start a conference call in this room": "您没有在此聊天室发起通话会议的权限", + "You do not have permission to start a conference call in this room": "你没有在此聊天室发起通话会议的权限", "This event could not be displayed": "无法显示此事件", "Share Link to User": "分享链接给其他用户", "Share room": "分享聊天室", @@ -790,53 +790,53 @@ "The email field must not be blank.": "必须输入电子邮箱。", "The phone number field must not be blank.": "必须输入电话号码。", "The password field must not be blank.": "必须输入密码。", - "Display your community flair in rooms configured to show it.": "在启用“显示徽章”的聊天室中显示本社区的个性徽章。", + "Display your community flair in rooms configured to show it.": "在启用“显示徽章”的聊天室中显示本社群的个性徽章。", "Failed to remove widget": "移除小挂件失败", "An error ocurred whilst trying to remove the widget from the room": "尝试从聊天室中移除小部件时发生了错误", - "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "您确定要移除(删除)此事件吗?注意,如果删除了聊天室名称或话题的修改事件,就会撤销此更改。", - "This will make your account permanently unusable. You will not be able to log in, and no one will be able to re-register the same user ID. This will cause your account to leave all rooms it is participating in, and it will remove your account details from your identity server. This action is irreversible.": "这将使您的账号永远不再可用。您将不能登录,或使用相同的用户 ID 重新注册。您的账号将退出所有已加入的聊天室,身份服务器上的账号信息也会被删除。此操作是不可逆的。", - "Deactivating your account does not by default cause us to forget messages you have sent. If you would like us to forget your messages, please tick the box below.": "默认情况下,停用您的账号不会忘记您发送的消息 。如果您希望我们忘记您发送的消息,请勾选下面的选择框。", - "Message visibility in Matrix is similar to email. Our forgetting your messages means that messages you have sent will not be shared with any new or unregistered users, but registered users who already have access to these messages will still have access to their copy.": "Matrix 中的(历史)信息可见性类似于电子邮件。我们忘记您的消息意味着您发送的消息将不会被发至新注册或未注册的用户,但是已收到您的消息的注册用户依旧可以看到他们的副本。", + "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "你确定要移除(删除)此事件吗?注意,如果删除了聊天室名称或话题的修改事件,就会撤销此更改。", + "This will make your account permanently unusable. You will not be able to log in, and no one will be able to re-register the same user ID. This will cause your account to leave all rooms it is participating in, and it will remove your account details from your identity server. This action is irreversible.": "这将使你的账号永远不再可用。你将不能登录,或使用相同的用户 ID 重新注册。你的账号将退出所有已加入的聊天室,身份服务器上的账号信息也会被删除。此操作是不可逆的。", + "Deactivating your account does not by default cause us to forget messages you have sent. If you would like us to forget your messages, please tick the box below.": "默认情况下,停用你的账号不会忘记你发送的消息 。如果你希望我们忘记你发送的消息,请勾选下面的选择框。", + "Message visibility in Matrix is similar to email. Our forgetting your messages means that messages you have sent will not be shared with any new or unregistered users, but registered users who already have access to these messages will still have access to their copy.": "Matrix 中的(历史)信息可见性类似于电子邮件。我们忘记你的消息意味着你发送的消息将不会被发至新注册或未注册的用户,但是已收到你的消息的注册用户依旧可以看到他们的副本。", "Please forget all messages I have sent when my account is deactivated (Warning: this will cause future users to see an incomplete view of conversations)": "请在停用我的账号的同时忘记我发送的所有消息(警告:这将导致未来的用户看到的对话记录不完整)", - "To continue, please enter your password:": "请输入您的密码以继续:", + "To continue, please enter your password:": "请输入你的密码以继续:", "Clear Storage and Sign Out": "清除数据并退出登录", "Send Logs": "发送日志", "Refresh": "刷新", - "Unable to join community": "无法加入社区", + "Unable to join community": "无法加入社群", "The user '%(displayName)s' could not be removed from the summary.": "无法将用户“%(displayName)s”从简介中移除。", - "Who would you like to add to this summary?": "您想将谁添加到简介中?", - "Add users to the community summary": "添加用户至社区简介", + "Who would you like to add to this summary?": "你想将谁添加到简介中?", + "Add users to the community summary": "添加用户至社群简介", "Collapse Reply Thread": "收起回复", "Share Message": "分享消息", "COPY": "复制", "Share Room Message": "分享聊天室消息", - "Share Community": "分享社区", + "Share Community": "分享社群", "Share User": "分享用户", "Share Room": "分享聊天室", - "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "清除本页储存在您浏览器上的数据或许能修复此问题,但也会导致您退出登录并无法读取任何已加密的聊天记录。", - "We encountered an error trying to restore your previous session.": "我们在尝试恢复您先前的会话时遇到了错误。", + "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "清除本页储存在你浏览器上的数据或许能修复此问题,但也会导致你退出登录并无法读取任何已加密的聊天记录。", + "We encountered an error trying to restore your previous session.": "我们在尝试恢复你先前的会话时遇到了错误。", "Link to most recent message": "最新消息的链接", "Link to selected message": "选中消息的链接", - "Changes made to your community name and avatar might not be seen by other users for up to 30 minutes.": "至多半个小时内,其他用户可能看不到您社区的 名称头像 的变化。", - "These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.": "这些聊天室对社区成员可见。社区成员可通过点击来加入它们。", - "Your community hasn't got a Long Description, a HTML page to show to community members.
    Click here to open settings and give it one!": "你的社区没有一个很长的描述,一个HTML页面可以展示给社区成员。
    点击这里即可打开设置添加详细介绍!", + "Changes made to your community name and avatar might not be seen by other users for up to 30 minutes.": "至多半个小时内,其他用户可能看不到你社群的 名称头像 的变化。", + "These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.": "这些聊天室对社群成员可见。社群成员可通过点击来加入它们。", + "Your community hasn't got a Long Description, a HTML page to show to community members.
    Click here to open settings and give it one!": "你的社群没有一个很长的描述,一个HTML页面可以展示给社群成员。
    点击这里即可打开设置添加详细介绍!", "Failed to load %(groupId)s": "%(groupId)s 加载失败", - "This room is not public. You will not be able to rejoin without an invite.": "此聊天室不是公开聊天室。如果没有成员邀请,您将无法重新加入。", + "This room is not public. You will not be able to rejoin without an invite.": "此聊天室不是公开聊天室。如果没有成员邀请,你将无法重新加入。", "Can't leave Server Notices room": "无法退出服务器公告聊天室", - "This room is used for important messages from the Homeserver, so you cannot leave it.": "此聊天室是用于发布来自主服务器的重要讯息的,所以您不能退出它。", + "This room is used for important messages from the Homeserver, so you cannot leave it.": "此聊天室是用于发布来自主服务器的重要讯息的,所以你不能退出它。", "Terms and Conditions": "条款与要求", - "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "若要继续使用主服务器 %(homeserverDomain)s,您必须浏览并同意我们的条款与要求。", + "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "若要继续使用主服务器 %(homeserverDomain)s,你必须浏览并同意我们的条款与要求。", "Review terms and conditions": "浏览条款与要求", - "To set up a filter, drag a community avatar over to the filter panel on the far left hand side of the screen. You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.": "若要设置社区过滤器,请将社区头像拖到屏幕最左侧的社区过滤器面板上。单击社区过滤器面板中的社区头像即可过滤出与该社区相关联的房间和人员。", - "You can't send any messages until you review and agree to our terms and conditions.": "在您查看并同意 我们的条款与要求 之前,您不能发送任何消息。", + "To set up a filter, drag a community avatar over to the filter panel on the far left hand side of the screen. You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.": "若要设置社群过滤器,请将社群头像拖到屏幕最左侧的社群过滤器面板上。单击社群过滤器面板中的社群头像即可过滤出与此社群相关联的房间和人员。", + "You can't send any messages until you review and agree to our terms and conditions.": "在你查看并同意 我们的条款与要求 之前,你不能发送任何消息。", "Clear filter": "清除过滤器", - "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "尝试加载此聊天室时间轴上的某处,但您没有查看相关消息的权限。", + "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "尝试加载此聊天室时间轴上的某处,但你没有查看相关消息的权限。", "No Audio Outputs detected": "未检测到可用的音频输出方式", "Audio Output": "音频输出", "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "已向 %(emailAddress)s 发送了一封电子邮件。点开邮件中的链接后,请点击下面。", "Forces the current outbound group session in an encrypted room to be discarded": "强制丢弃加密聊天室中的当前出站群组会话", "Unable to connect to Homeserver. Retrying...": "无法连接至主服务器。正在重试…", - "Sorry, your homeserver is too old to participate in this room.": "抱歉,因您的主服务器的程序版本过旧,无法加入此聊天室。", + "Sorry, your homeserver is too old to participate in this room.": "抱歉,因你的主服务器的程序版本过旧,无法加入此聊天室。", "Mirror local video feed": "镜像翻转本地视频源", "This room has been replaced and is no longer active.": "此聊天室已被取代,且不再活跃。", "The conversation continues here.": "对话在这里继续。", @@ -854,14 +854,14 @@ "Legal": "法律信息", "This homeserver has hit its Monthly Active User limit.": "此主服务器已达到其每月活跃用户限制。", "This homeserver has exceeded one of its resource limits.": "本服务器已达到其使用量限制之一。", - "Please contact your service administrator to continue using this service.": "请 联系您的服务管理员 以继续使用本服务。", - "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "您的消息未被发送,因为本主服务器已达到其使用量限制之一。请 联系您的服务管理员 以继续使用本服务。", - "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "您的消息未被发送,因为本主服务器已达到其每月活跃用户限制。请 联系您的服务管理员 以继续使用本服务。", - "Please contact your service administrator to continue using the service.": "请 联系您的服务管理员 以继续使用本服务。", - "Please contact your homeserver administrator.": "请 联系您的主服务器管理员。", - "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s 将此聊天室的主地址设为了 %(address)s。", - "%(senderName)s removed the main address for this room.": "%(senderName)s 移除了此聊天室的主地址。", - "Unable to load! Check your network connectivity and try again.": "无法加载!请检查您的网络连接并重试。", + "Please contact your service administrator to continue using this service.": "请 联系你的服务管理员 以继续使用本服务。", + "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "你的消息未被发送,因为本主服务器已达到其使用量限制之一。请 联系你的服务管理员 以继续使用本服务。", + "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "你的消息未被发送,因为本主服务器已达到其每月活跃用户限制。请 联系你的服务管理员 以继续使用本服务。", + "Please contact your service administrator to continue using the service.": "请 联系你的服务管理员 以继续使用本服务。", + "Please contact your homeserver administrator.": "请 联系你的主服务器管理员。", + "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s 将此聊天室的主要地址设为了 %(address)s。", + "%(senderName)s removed the main address for this room.": "%(senderName)s 移除了此聊天室的主要地址。", + "Unable to load! Check your network connectivity and try again.": "无法加载!请检查你的网络连接并重试。", "User %(user_id)s does not exist": "用户 %(user_id)s 不存在", "There was an error joining the room": "加入聊天室时发生错误", "Custom user status messages": "自定义用户状态信息", @@ -887,7 +887,7 @@ "Download": "下载", "Retry": "重试", "Go to Settings": "打开设置", - "You do not have permission to invite people to this room.": "您没有权限将其他用户邀请至本聊天室。", + "You do not have permission to invite people to this room.": "你没有权限将其他用户邀请至本聊天室。", "Unknown server error": "未知服务器错误", "Failed to invite users to the room:": "邀请失败:", "No need for symbols, digits, or uppercase letters": "不一定要有符号、数字或大写字母", @@ -895,12 +895,12 @@ "Avoid repeated words and characters": "避免重复词语与字符", "Avoid sequences": "避免递增或递减的序列", "Avoid recent years": "避免年份", - "Avoid years that are associated with you": "避免与您相关联的年份", - "Avoid dates and years that are associated with you": "避免与您相关联的日期与年份", + "Avoid years that are associated with you": "避免与你相关联的年份", + "Avoid dates and years that are associated with you": "避免与你相关联的日期与年份", "Capitalization doesn't help very much": "大写字母并没有很大的作用", "All-uppercase is almost as easy to guess as all-lowercase": "全大写的密码通常比全小写的更容易猜测", "Reversed words aren't much harder to guess": "把单词倒过来不会比原来的难猜很多", - "Whether or not you're logged in (we don't record your username)": "您是否已经登入(我们不会记录您的用户名)", + "Whether or not you're logged in (we don't record your username)": "你是否已经登入(我们不会记录你的用户名)", "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "文件 %(fileName)s 超过主服务器的文件大小限制", "Upgrades a room to a new version": "将聊天室升级到新版本", "Gets or sets the room topic": "获取或设置聊天室话题", @@ -952,7 +952,7 @@ "Show avatars in user and room mentions": "在用户和聊天室提及中显示头像", "Enable big emoji in chat": "在聊天中启用大型表情符号", "Send typing notifications": "发送正在输入通知", - "Enable Community Filter Panel": "启用社区筛选器面板", + "Enable Community Filter Panel": "启用社群筛选器面板", "Allow Peer-to-Peer for 1:1 calls": "允许一对一通话使用 P2P", "Prompt before sending invites to potentially invalid matrix IDs": "在发送邀请之前提示可能无效的 Matrix ID", "Messages containing my username": "包含我的用户名的消息", @@ -960,7 +960,7 @@ "Encrypted messages in group chats": "群聊中的加密消息", "The other party cancelled the verification.": "另一方取消了验证。", "Verified!": "已验证!", - "You've successfully verified this user.": "您已成功验证此用户。", + "You've successfully verified this user.": "你已成功验证此用户。", "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "此用户的安全消息是端到端加密的,不能被第三方读取。", "Got It": "收到", "Verify this user by confirming the following emoji appear on their screen.": "通过在其屏幕上显示以下表情符号来验证此用户。", @@ -1031,10 +1031,10 @@ "Pin": "别针", "Yes": "是", "No": "拒绝", - "We've sent you an email to verify your address. Please follow the instructions there and then click the button below.": "我们已向您发送了一封电子邮件,以验证您的地址。 请按照里面的说明操作,然后单击下面的按钮。", + "We've sent you an email to verify your address. Please follow the instructions there and then click the button below.": "我们已向你发送了一封电子邮件,以验证你的地址。 请按照里面的说明操作,然后单击下面的按钮。", "Email Address": "电子邮箱地址", - "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "您确定吗?如果密钥没有正确地备份您将失去您的加密消息。", - "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "加密消息已使用端对端加密保护。只有您和拥有密钥的收件人可以阅读这些消息。", + "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "你确定吗?如果密钥没有正确地备份你将失去你的加密消息。", + "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "加密消息已使用端对端加密保护。只有你和拥有密钥的收件人可以阅读这些消息。", "Unable to load key backup status": "无法载入密钥备份状态", "Restore from Backup": "从备份恢复", "Back up your keys before signing out to avoid losing them.": "在登出账号之前请备份密钥以免丢失。", @@ -1053,7 +1053,7 @@ "Language and region": "语言与区域", "Theme": "主题", "Account management": "账号管理", - "Deactivating your account is a permanent action - be careful!": "停用您的账号是一项永久性操作 - 请小心!", + "Deactivating your account is a permanent action - be careful!": "停用你的账号是一项永久性操作 - 请小心!", "General": "通用", "Credits": "感谢", "For help with using %(brand)s, click here.": "对使用 %(brand)s 的说明,请点击 这里。", @@ -1071,10 +1071,10 @@ "Ignored users": "已忽略的用户", "Bulk options": "批量选择", "Key backup": "密钥备份", - "Security & Privacy": "安全与隐私", + "Security & Privacy": "隐私安全", "Missing media permissions, click the button below to request.": "缺少媒体权限,点击下面的按钮以请求权限。", "Request media permissions": "请求媒体权限", - "Voice & Video": "语音与视频", + "Voice & Video": "语音视频", "Room information": "聊天室信息", "Internal room ID:": "内部聊天室 ID:", "Room version": "聊天室版本", @@ -1082,7 +1082,7 @@ "Developer options": "开发者选项", "Room Addresses": "聊天室地址", "Roles & Permissions": "角色与权限", - "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "历史记录阅读权限的变更只会应用到此聊天室中将来的消息。既有历史记录的可见性将不会变更。", + "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "历史记录阅读权限的更改只会应用到此聊天室中将来的消息。既有历史记录的可见性将不会更改。", "Encryption": "加密", "Once enabled, encryption cannot be disabled.": "加密一经启用,便无法禁用。", "Encrypted": "已加密", @@ -1092,32 +1092,32 @@ "Not now": "现在不要", "Don't ask me again": "不再询问", "Add some now": "立即添加", - "Error updating main address": "更新主地址时发生错误", - "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "更新聊天室的主地址时发生错误。可能是该服务器不允许,也可能是出现了一个临时错误。", - "Main address": "主地址", + "Error updating main address": "更新主要地址时发生错误", + "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "更新聊天室的主要地址时发生错误。可能是此服务器不允许,也可能是出现了一个临时错误。", + "Main address": "主要地址", "Error updating flair": "更新个性徽章时发生错误", - "There was an error updating the flair for this room. The server may not allow it or a temporary error occurred.": "更新此聊天室的个性徽章时发生错误。可能时该服务器不允许,也可能是发生了一个临时错误。", + "There was an error updating the flair for this room. The server may not allow it or a temporary error occurred.": "更新此聊天室的个性徽章时发生错误。可能时此服务器不允许,也可能是发生了一个临时错误。", "Room avatar": "聊天室头像", "Room Name": "聊天室名称", "Room Topic": "聊天室话题", "Join": "加入", "That doesn't look like a valid email address": "这看起来不像是有效的电子邮箱地址", "The following users may not exist": "以下用户可能不存在", - "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "找不到下列 Matrix ID 的用户资料,您还是要邀请吗?", + "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "找不到下列 Matrix ID 的用户资料,你还是要邀请吗?", "Invite anyway and never warn me again": "还是邀请,不用再提醒我", "Invite anyway": "还是邀请", - "Before submitting logs, you must create a GitHub issue to describe your problem.": "在提交日志之前,您必须创建一个GitHub issue 来描述您的问题。", + "Before submitting logs, you must create a GitHub issue to describe your problem.": "在提交日志之前,你必须创建一个GitHub issue 来描述你的问题。", "Unable to load commit detail: %(msg)s": "无法加载提交详情:%(msg)s", - "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "为避免丢失聊天记录,您必须在登出前导出房间密钥。 您需要回到较新版本的 %(brand)s 才能执行此操作", - "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "验证此用户并将其标记为已信任。在收发端到端加密消息时,信任用户可让您更加放心。", + "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "为避免丢失聊天记录,你必须在登出前导出房间密钥。 你需要回到较新版本的 %(brand)s 才能执行此操作", + "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "验证此用户并将其标记为已信任。在收发端到端加密消息时,信任用户可让你更加放心。", "Waiting for partner to confirm...": "等待对方确认中...", "Incoming Verification Request": "收到验证请求", - "You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.": "您之前在 %(host)s 上开启了 %(brand)s 的成员列表延迟加载设置。目前版本中延迟加载功能已被停用。因为本地缓存在这两个设置项上不相容,%(brand)s 需要重新同步您的账号。", + "You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.": "你之前在 %(host)s 上开启了 %(brand)s 的成员列表延迟加载设置。目前版本中延迟加载功能已被停用。因为本地缓存在这两个设置项上不相容,%(brand)s 需要重新同步你的账号。", "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "通过仅在需要时加载其他用户的信息,%(brand)s 现在使用的内存减少到了原来的三分之一至五分之一。 请等待与服务器重新同步!", "I don't want my encrypted messages": "我不想要我的加密消息", "Manually export keys": "手动导出密钥", - "You'll lose access to your encrypted messages": "您将失去您的加密消息的访问权", - "Are you sure you want to sign out?": "您确定要登出账号吗?", + "You'll lose access to your encrypted messages": "你将失去你的加密消息的访问权", + "Are you sure you want to sign out?": "你确定要登出账号吗?", "If you run into any bugs or have feedback you'd like to share, please let us know on GitHub.": "如果您碰到任何错误或者希望分享您的反馈,请在 Github 上联系我们。", "To help avoid duplicate issues, please view existing issues first (and add a +1) or create a new issue if you can't find it.": "要帮助避免提交重复的 issue,请您先 查阅是否为已存在的 issue (如有则添加一个 +1 ) ,或者如果找不到则 新建一个 issue 。", "Report bugs & give feedback": "上报错误及提供反馈", @@ -1125,7 +1125,7 @@ "Room Settings - %(roomName)s": "聊天室设置 - %(roomName)s", "A username can only contain lower case letters, numbers and '=_-./'": "用户名只能包含小写字母、数字和 '=_-./'", "Failed to decrypt %(failedCount)s sessions!": "%(failedCount)s 个会话解密失败!", - "Warning: you should only set up key backup from a trusted computer.": "警告:您应该只在受信任的电脑上设置密钥备份。", + "Warning: you should only set up key backup from a trusted computer.": "警告:你应此只在受信任的电脑上设置密钥备份。", "Access your secure message history and set up secure messaging by entering your recovery passphrase.": "通过输入恢复密码来访问您的安全消息历史记录和设置安全通信。", "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options": "如果忘记了恢复密码,您可以 使用恢复密钥 或者 设置新的恢复选项", "This looks like a valid recovery key!": "看起来是有效的恢复密钥!", @@ -1137,8 +1137,8 @@ "Set status": "设置状态", "Set a new status...": "设置新状态...", "Hide": "隐藏", - "This homeserver would like to make sure you are not a robot.": "此主服务器想要确认您不是机器人。", - "Please review and accept all of the homeserver's policies": "请阅读并接受该主服务器的所有政策", + "This homeserver would like to make sure you are not a robot.": "此主服务器想要确认你不是机器人。", + "Please review and accept all of the homeserver's policies": "请阅读并接受此主服务器的所有政策", "Please review and accept the policies of this homeserver:": "请阅读并接受此主服务器的政策:", "Your Modular server": "您的模组服务器", "Enter the location of your Modular homeserver. It may use your own domain name or be a subdomain of modular.im.": "输入您的模组主服务器的位置。它可能使用的是您自己的域名或者 modular.im 的子域名。", @@ -1162,14 +1162,14 @@ "Other": "其他", "Find other public servers or use a custom server": "寻找其他公共服务器或使用自定义服务器", "Couldn't load page": "无法加载页面", - "You are an administrator of this community. You will not be able to rejoin without an invite from another administrator.": "您是此社区的管理员。 没有其他管理员的邀请,您将无法重新加入。", - "This homeserver does not support communities": "此主服务器不支持社区功能", + "You are an administrator of this community. You will not be able to rejoin without an invite from another administrator.": "你是此社群的管理员。 没有其他管理员的邀请,你将无法重新加入。", + "This homeserver does not support communities": "此主服务器不支持社群功能", "Guest": "游客", "Could not load user profile": "无法加载用户资料", "Your Matrix account on %(serverName)s": "您在 %(serverName)s 上的 Matrix 账号", - "A verification email will be sent to your inbox to confirm setting your new password.": "一封验证电子邮件将发送到您的邮箱以确认您设置了新密码。", + "A verification email will be sent to your inbox to confirm setting your new password.": "一封验证电子邮件将发送到你的邮箱以确认你设置了新密码。", "Sign in instead": "登入", - "Your password has been reset.": "您的密码已重置。", + "Your password has been reset.": "你的密码已重置。", "Set a new password": "设置新密码", "Invalid homeserver discovery response": "无效的主服务器搜索响应", "Invalid identity server discovery response": "无效的身份服务器搜索响应", @@ -1182,14 +1182,14 @@ "Unable to query for supported registration methods.": "无法查询支持的注册方法。", "Create your account": "创建您的账号", "Keep going...": "请继续...", - "For maximum security, this should be different from your account password.": "为确保最大的安全性,它应该与您的账号密码不同。", + "For maximum security, this should be different from your account password.": "为确保最大的安全性,它应该与你的账号密码不同。", "That matches!": "匹配成功!", "That doesn't match.": "不匹配。", "Go back to set it again.": "返回重新设置。", "Print it and store it somewhere safe": "打印 并存放在安全的地方", "Save it on a USB key or backup drive": "保存 在 U 盘或备份磁盘中", - "Copy it to your personal cloud storage": "复制 到您的个人云端存储", - "Your keys are being backed up (the first backup could take a few minutes).": "正在备份您的密钥(第一次备份可能会花费几分钟时间)。", + "Copy it to your personal cloud storage": "复制 到你的个人云端存储", + "Your keys are being backed up (the first backup could take a few minutes).": "正在备份你的密钥(第一次备份可能会花费几分钟时间)。", "Set up Secure Message Recovery": "设置安全消息恢复", "Starting backup...": "开始备份...", "Success!": "成功!", @@ -1198,18 +1198,18 @@ "If you don't want to set this up now, you can later in Settings.": "如果您现在不想设置,您可以稍后在设置中操作。", "New Recovery Method": "新恢复方式", "A new recovery passphrase and key for Secure Messages have been detected.": "检测到安全消息的一个新恢复密码和密钥。", - "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "如果您没有设置新恢复方式,可能有攻击者正试图侵入您的账号。请立即更改您的账号密码并在设置中设定一个新恢复方式。", + "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "如果你没有设置新恢复方式,可能有攻击者正试图侵入你的账号。请立即更改你的账号密码并在设置中设定一个新恢复方式。", "Set up Secure Messages": "设置安全消息", "Recovery Method Removed": "恢复方式已移除", - "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "如果您没有移除该恢复方式,可能有攻击者正试图侵入您的账号。请立即更改您的账号密码并在设置中设定一个新的恢复方式。", + "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "如果你没有移除此恢复方式,可能有攻击者正试图侵入你的账号。请立即更改你的账号密码并在设置中设定一个新的恢复方式。", "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "在纯文本消息开头添加 ¯\\_(ツ)_/¯", "User %(userId)s is already in the room": "用户 %(userId)s 已在聊天室中", "The user must be unbanned before they can be invited.": "用户必须先解封才能被邀请。", - "Upgrade to your own domain": "升级 到您自己的域名", + "Upgrade to your own domain": "升级 到你自己的域名", "Accept all %(invitedRooms)s invites": "接受所有 %(invitedRooms)s 邀请", "Change room avatar": "更改聊天室头像", "Change room name": "更改聊天室名称", - "Change main address for the room": "更改聊天室主地址", + "Change main address for the room": "更改聊天室主要地址", "Change history visibility": "更改历史记录可见性", "Change permissions": "更改权限", "Change topic": "更改话题", @@ -1227,18 +1227,18 @@ "Enable encryption?": "启用加密?", "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.": "聊天室加密一经启用,便无法禁用。在加密聊天室中,发送的消息无法被服务器看到,只能被聊天室的参与者看到。启用加密可能会使许多机器人和桥接无法正常运作。 详细了解加密。", "Power level": "权限级别", - "Want more than a community? Get your own server": "想要的不只是社区? 架设您自己的服务器", + "Want more than a community? Get your own server": "想要的不只是社群? 架设你自己的服务器", "Please install Chrome, Firefox, or Safari for the best experience.": "请安装 ChromeFirefox,或 Safari 以获得最佳体验。", - "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "警告:升级聊天室 不会自动将聊天室成员转移到新版聊天室中。 我们将会在旧版聊天室中发布一个新版聊天室的链接 - 聊天室成员必须点击该链接以加入新聊天室。", + "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "警告:升级聊天室 不会自动将聊天室成员转移到新版聊天室中。 我们将会在旧版聊天室中发布一个新版聊天室的链接 - 聊天室成员必须点击此链接以加入新聊天室。", "Adds a custom widget by URL to the room": "通过链接为聊天室添加自定义挂件", "Please supply a https:// or http:// widget URL": "请提供一个 https:// 或 http:// 形式的插件", - "You cannot modify widgets in this room.": "您无法修改此聊天室的插件。", + "You cannot modify widgets in this room.": "你无法修改此聊天室的插件。", "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s 撤销了对 %(targetDisplayName)s 加入聊天室的邀请。", "Upgrade this room to the recommended room version": "升级此聊天室至推荐版本", - "This room is running room version , which this homeserver has marked as unstable.": "此聊天室运行的聊天室版本是 ,该版本已被主服务器标记为 不稳定 。", + "This room is running room version , which this homeserver has marked as unstable.": "此聊天室运行的聊天室版本是 ,此版本已被主服务器标记为 不稳定 。", "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "升级此聊天室将会关闭聊天室的当前实例并创建一个具有相同名称的升级版聊天室。", "Failed to revoke invite": "撤销邀请失败", - "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.": "无法撤销邀请。该服务器可能出现了临时错误,或者您没有足够的权限来撤销邀请。", + "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.": "无法撤销邀请。此服务器可能出现了临时错误,或者你没有足够的权限来撤销邀请。", "Revoke invite": "撤销邀请", "Invited by %(sender)s": "被 %(sender)s 邀请", "Maximize apps": "最大化应用程序", @@ -1246,32 +1246,32 @@ "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.": "位于 %(widgetUrl)s 的小部件想要验证您的身份。在您允许后,小部件就可以验证您的用户 ID,但不能代您执行操作。", "Remember my selection for this widget": "记住我对此挂件的选择", "Deny": "拒绝", - "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s 无法从主服务器处获取协议列表。该主服务器上的软件可能过旧,不支持第三方网络。", + "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s 无法从主服务器处获取协议列表。此主服务器上的软件可能过旧,不支持第三方网络。", "%(brand)s failed to get the public room list.": "%(brand)s 无法获取公开聊天室列表。", "The homeserver may be unavailable or overloaded.": "主服务器似乎不可用或过载。", - "You have %(count)s unread notifications in a prior version of this room.|other": "您在此聊天室的先前版本中有 %(count)s 条未读通知。", - "You have %(count)s unread notifications in a prior version of this room.|one": "您在此聊天室的先前版本中有 %(count)s 条未读通知。", + "You have %(count)s unread notifications in a prior version of this room.|other": "你在此聊天室的先前版本中有 %(count)s 条未读通知。", + "You have %(count)s unread notifications in a prior version of this room.|one": "你在此聊天室的先前版本中有 %(count)s 条未读通知。", "Add Email Address": "添加 Email 地址", "Add Phone Number": "添加电话号码", "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "是否使用“面包屑”功能(最近访问的房间的图标在房间列表上方显示)", "Call failed due to misconfigured server": "因为服务器配置错误通话失败", - "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "请联系您主服务器(%(homeserverDomain)s)的管理员设置 TURN 服务器来确保通话运作稳定。", - "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "您也可以尝试使用turn.matrix.org公共服务器,但通话质量稍差,并且其将会得知您的 IP。您可以在设置中更改此选项。", + "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "请联系你主服务器(%(homeserverDomain)s)的管理员设置 TURN 服务器来确保通话运作稳定。", + "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "你也可以尝试使用turn.matrix.org公共服务器,但通话质量稍差,并且其将会得知你的 IP。你可以在设置中更改此选项。", "Try using turn.matrix.org": "尝试使用 turn.matrix.org", - "Your %(brand)s is misconfigured": "您的 %(brand)s 配置有错误", + "Your %(brand)s is misconfigured": "你的 %(brand)s 配置有错误", "Use Single Sign On to continue": "使用单点登录继续", - "Confirm adding this email address by using Single Sign On to prove your identity.": "通过使用单点登录来证明您的身份,并确认添加此邮件地址。", + "Confirm adding this email address by using Single Sign On to prove your identity.": "通过使用单点登录来证明你的身份,并确认添加此邮件地址。", "Single Sign On": "单点登录", "Confirm adding email": "确认使用邮件", "Click the button below to confirm adding this email address.": "点击下面的按钮以确认添加此邮箱地址。", - "Confirm adding this phone number by using Single Sign On to prove your identity.": "通过单点登录以证明您的身份,并确认添加此电话号码。", + "Confirm adding this phone number by using Single Sign On to prove your identity.": "通过单点登录以证明你的身份,并确认添加此电话号码。", "Confirm adding phone number": "确认添加电话号码", "Click the button below to confirm adding this phone number.": "点击下面的按钮以确认添加此电话号码。", "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "是否在触屏设备上使用 %(brand)s", - "Whether you're using %(brand)s as an installed Progressive Web App": "您是否已将 %(brand)s 作为渐进式 Web 应用(PWA)安装", - "Your user agent": "您的用户代理(user agent)", + "Whether you're using %(brand)s as an installed Progressive Web App": "你是否已将 %(brand)s 作为渐进式 Web 应用(PWA)安装", + "Your user agent": "你的用户代理(user agent)", "Replying With Files": "回复文件", - "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "当前无法在回复中附加文件。您想要仅上传此文件而不回复吗?", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "当前无法在回复中附加文件。你想要仅上传此文件而不回复吗?", "The file '%(fileName)s' failed to upload.": "上传文件 ‘%(fileName)s’ 失败。", "The server does not support the room version specified.": "服务器不支持指定的聊天室版本。", "If you cancel now, you won't complete verifying the other user.": "如果现在取消,您将无法完成验证其他用户。", @@ -1283,11 +1283,11 @@ "Encryption upgrade available": "提供加密升级", "Set up encryption": "设置加密", "Review where you’re logged in": "查看您的登录位置", - "New login. Was this you?": "现在登录。请问是您本人吗?", + "New login. Was this you?": "现在登录。请问是你本人吗?", "Name or Matrix ID": "姓名或 Matrix ID", "Identity server has no terms of service": "身份服务器无服务条款", "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "此操作需要访问默认的身份服务器 以验证邮箱地址或电话号码,但是此服务器无任何服务条款。", - "Only continue if you trust the owner of the server.": "只有您信任服务器所有者才能继续。", + "Only continue if you trust the owner of the server.": "只有你信任服务器所有者才能继续。", "Trust": "信任", "%(name)s is requesting verification": "%(name)s 正在请求验证", "Sign In or Create Account": "登录或创建账号", @@ -1299,12 +1299,12 @@ "Actions": "动作", "Sends a message as plain text, without interpreting it as markdown": "以纯文本形式发送消息,不将其作为 markdown 处理", "Sends a message as html, without interpreting it as markdown": "以 html 格式发送消息,不将其作为 markdown 处理", - "You do not have the required permissions to use this command.": "您没有权限使用此命令。", + "You do not have the required permissions to use this command.": "你没有权限使用此命令。", "Error upgrading room": "升级聊天室时发生错误", - "Double check that your server supports the room version chosen and try again.": "请再次检查您的服务器是否支持所选聊天室版本,然后再试一次。", + "Double check that your server supports the room version chosen and try again.": "请再次检查你的服务器是否支持所选聊天室版本,然后再试一次。", "Changes the avatar of the current room": "更改当前聊天室头像", - "Changes your avatar in this current room only": "仅改变您在当前聊天室的头像", - "Changes your avatar in all rooms": "改变您在所有聊天室的头像", + "Changes your avatar in this current room only": "仅改变你在当前聊天室的头像", + "Changes your avatar in all rooms": "改变你在所有聊天室的头像", "Failed to set topic": "话题设置失败", "Use an identity server": "使用身份服务器", "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "使用身份服务器以通过电子邮件邀请其他用户。单击继续以使用默认身份服务器(%(defaultIdentityServerName)s),或在设置中进行管理。", @@ -1317,23 +1317,23 @@ "Unknown (user, session) pair:": "未知(用户、会话)对:", "Session already verified!": "会话已验证!", "WARNING: Session already verified, but keys do NOT MATCH!": "警告:会话已验证,但密钥不匹配!", - "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "警告:密钥验证失败!%(userId)s 的会话 %(deviceId)s 的签名密钥为 %(fprint)s,与提供的密钥 %(fingerprint)s 不符。这可能表示您的通讯已被截获!", - "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.": "您提供的签名密钥与您从 %(userId)s 的会话 %(deviceId)s 获取的一致。该会话被标为已验证。", - "Sends the given message coloured as a rainbow": "以彩虹色发送给定消息", + "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "警告:密钥验证失败!%(userId)s 的会话 %(deviceId)s 的签名密钥为 %(fprint)s,与提供的密钥 %(fingerprint)s 不符。这可能表示你的通讯已被截获!", + "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.": "你提供的签名密钥与你从 %(userId)s 的会话 %(deviceId)s 获取的一致。此会话被标为已验证。", + "Sends the given message coloured as a rainbow": "此消息以彩虹色进行渲染", "Sends the given emote coloured as a rainbow": "以彩虹色发送给定表情符号", "Displays list of commands with usages and descriptions": "显示指令清单与其描述和用法", "Displays information about a user": "显示关于用户的信息", "Send a bug report with logs": "发送带日志的错误报告", "Opens chat with the given user": "与指定用户发起聊天", "Sends a message to the given user": "向指定用户发消息", - "%(senderName)s made no change.": "%(senderName)s 未做出变更。", + "%(senderName)s made no change.": "%(senderName)s 未做出更改。", "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s 将聊天室名称从 %(oldRoomName)s 改为 %(newRoomName)s。", "%(senderName)s added the alternative addresses %(addresses)s for this room.|other": "%(senderName)s 为此聊天室添加备用地址 %(addresses)s。", "%(senderName)s added the alternative addresses %(addresses)s for this room.|one": "%(senderName)s 为此聊天室添加了备用地址 %(addresses)s。", "%(senderName)s removed the alternative addresses %(addresses)s for this room.|other": "%(senderName)s 为此聊天室移除了备用地址 %(addresses)s。", "%(senderName)s removed the alternative addresses %(addresses)s for this room.|one": "%(senderName)s 为此聊天室移除了备用地址 %(addresses)s。", "%(senderName)s changed the alternative addresses for this room.": "%(senderName)s 更改了此聊天室的备用地址。", - "%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s 更改了此聊天室的主地址与备用地址。", + "%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s 更改了此聊天室的主要地址与备用地址。", "%(senderName)s changed the addresses for this room.": "%(senderName)s 更改了此聊天室的地址。", "%(senderName)s placed a voice call.": "%(senderName)s 发起了语音通话。", "%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s 发起了语音通话。(此浏览器不支持)", @@ -1356,25 +1356,25 @@ "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s更改了一个由于%(reason)s而禁止聊天室%(oldGlob)s跟%(newGlob)s匹配的规则", "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s 更新了一个由于%(reason)s而禁止服务器%(oldGlob)s跟%(newGlob)s匹配的规则", "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s 更新了一个由于%(reason)s而禁止%(oldGlob)s跟%(newGlob)s匹配的规则", - "You signed in to a new session without verifying it:": "您登录了未经过验证的新会话:", - "Verify your other session using one of the options below.": "使用以下选项之一验证您的其他会话。", + "You signed in to a new session without verifying it:": "你登录了未经过验证的新会话:", + "Verify your other session using one of the options below.": "使用以下选项之一验证你的其他会话。", "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s(%(userId)s)登录到未验证的新会话:", - "Ask this user to verify their session, or manually verify it below.": "要求该用户验证其会话,或在下面手动进行验证。", + "Ask this user to verify their session, or manually verify it below.": "要求此用户验证其会话,或在下面手动进行验证。", "Not Trusted": "不可信任", "Manually Verify by Text": "手动验证文字", "Interactively verify by Emoji": "通过表情符号进行交互式验证", "Done": "完成", "Cannot reach homeserver": "不可连接到主服务器", - "Ensure you have a stable internet connection, or get in touch with the server admin": "确保您的网络连接稳定,或与服务器管理员联系", - "Ask your %(brand)s admin to check your config for incorrect or duplicate entries.": "跟您的%(brand)s管理员确认您的配置不正确或重复的条目。", + "Ensure you have a stable internet connection, or get in touch with the server admin": "确保你的网络连接稳定,或与服务器管理员联系", + "Ask your %(brand)s admin to check your config for incorrect or duplicate entries.": "跟你的%(brand)s管理员确认你的配置不正确或重复的条目。", "Cannot reach identity server": "不可连接到身份服务器", "Room name or address": "房间名称或地址", "Joins room with given address": "使用给定地址加入房间", "Verify this login": "验证此登录名", "Confirm your identity by verifying this login from one of your other sessions, granting it access to encrypted messages.": "通过从其他会话之一验证此登录名并授予其访问加密信息的权限来确认您的身份。", - "Which officially provided instance you are using, if any": "如果您在使用官方实例,是哪一个", - "Every page you use in the app": "您在应用中使用的每个页面", - "Are you sure you want to cancel entering passphrase?": "您确定要取消输入密语吗?", + "Which officially provided instance you are using, if any": "如果有的话,你正在使用官方所提供的哪个实例", + "Every page you use in the app": "你在应用中使用的每个页面", + "Are you sure you want to cancel entering passphrase?": "你确定要取消输入密语吗?", "Go Back": "后退", "Use your account to sign in to the latest version": "使用您的帐户登录到最新版本", "We’re excited to announce Riot is now Element": "我们很高兴地宣布Riot现在更名为Element", @@ -1382,9 +1382,9 @@ "Unrecognised room address:": "无法识别的聊天室地址:", "Light": "浅色", "Dark": "深色", - "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "您可以注册,但部分功能在身份服务器重新上线之前不可用。如果持续看到此警告,请检查配置或联系服务器管理员。", - "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "您可以重置密码,但部分功能在身份服务器重新上线之前不可用。如果持续看到此警告,请检查配置或联系服务器管理员。", - "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "您可以登录,但部分功能在身份服务器重新上线之前不可用。如果持续看到此警告,请检查配置或联系服务器管理员。", + "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "你可以注册,但部分功能在身份服务器重新上线之前不可用。如果持续看到此警告,请检查配置或联系服务器管理员。", + "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "你可以重置密码,但部分功能在身份服务器重新上线之前不可用。如果持续看到此警告,请检查配置或联系服务器管理员。", + "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "你可以登录,但部分功能在身份服务器重新上线之前不可用。如果持续看到此警告,请检查配置或联系服务器管理员。", "No homeserver URL provided": "未输入主服务器链接", "Unexpected error resolving homeserver configuration": "解析主服务器配置时发生未知错误", "Unexpected error resolving identity server configuration": "解析身份服务器配置时发生未知错误", @@ -1404,17 +1404,17 @@ "about a day from now": "从现在开始约一天", "%(num)s days from now": "从现在开始%(num)s天", "%(name)s (%(userId)s)": "%(name)s%(userId)s", - "Your browser does not support the required cryptography extensions": "您的浏览器不支持所需的密码学扩展", - "The user's homeserver does not support the version of the room.": "用户的主服务器不支持该聊天室版本。", + "Your browser does not support the required cryptography extensions": "你的浏览器不支持所需的密码学扩展", + "The user's homeserver does not support the version of the room.": "用户的主服务器不支持此聊天室版本。", "Help us improve %(brand)s": "请协助我们改进%(brand)s", "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "发送匿名使用情况数据,以协助我们改进%(brand)s。这将使用cookie。", "I want to help": "我乐意协助", "Verify all your sessions to ensure your account & messages are safe": "验证您的所有会话,以确保账号和消息安全", "Review": "开始验证", "Later": "稍后再说", - "Your homeserver has exceeded its user limit.": "您的主服务器已超过用户限制。", - "Your homeserver has exceeded one of its resource limits.": "您的主服务器已超过某项资源限制。", - "Contact your server admin.": "请联系您的服务器管理员。", + "Your homeserver has exceeded its user limit.": "你的主服务器已超过用户限制。", + "Your homeserver has exceeded one of its resource limits.": "你的主服务器已超过某项资源限制。", + "Contact your server admin.": "请联系你的服务器管理员。", "Ok": "确定", "Set password": "设置密码", "To return to your account in future you need to set a password": "要在之后取回您的帐户,您需要设置密码", @@ -1426,13 +1426,13 @@ "Restart": "重启应用", "Upgrade your %(brand)s": "升级您的%(brand)s", "A new version of %(brand)s is available!": "发现%(brand)s的新版本!", - "You joined the call": "您加入通话", + "You joined the call": "你加入通话", "%(senderName)s joined the call": "%(senderName)s加入通话", "Call in progress": "通话中", "You left the call": "您离开了通话", "%(senderName)s left the call": "%(senderName)s离开了通话", "Call ended": "通话结束", - "You started a call": "您开始了通话", + "You started a call": "你开始了通话", "%(senderName)s started a call": "%(senderName)s开始了通话", "Waiting for answer": "等待接听", "%(senderName)s is calling": "%(senderName)s正在通话", @@ -1461,35 +1461,35 @@ "or": "或者", "Start": "开始", "Confirm the emoji below are displayed on both sessions, in the same order:": "确认两个会话上都以同样顺序显示了下面的emoji:", - "The person who invited you already left the room.": "邀请您的人已经离开了聊天室。", - "The person who invited you already left the room, or their server is offline.": "邀请您的人已经离开了聊天室,或者其服务器为离线状态。", + "The person who invited you already left the room.": "邀请你的人已经离开了聊天室。", + "The person who invited you already left the room, or their server is offline.": "邀请你的人已经离开了聊天室,或者其服务器为离线状态。", "Change notification settings": "修改通知设置", "Manually verify all remote sessions": "手动验证所有远程会话", "My Ban List": "我的封禁列表", - "This is your list of users/servers you have blocked - don't leave the room!": "这是您屏蔽的用户和服务器的列表——请不要离开此聊天室!", + "This is your list of users/servers you have blocked - don't leave the room!": "这是你屏蔽的用户和服务器的列表——请不要离开此聊天室!", "Unknown caller": "未知来电人", "Incoming voice call": "语音来电", "Incoming video call": "视频来电", "Incoming call": "来电", - "Waiting for your other session, %(deviceName)s (%(deviceId)s), to verify…": "等待您的另一个会话 %(deviceName)s (%(deviceId)s) 进行验证…", - "Waiting for your other session to verify…": "等待您的另一个会话进行验证…", + "Waiting for your other session, %(deviceName)s (%(deviceId)s), to verify…": "等待你的另一个会话 %(deviceName)s (%(deviceId)s) 进行验证…", + "Waiting for your other session to verify…": "等待你的另一个会话进行验证…", "Waiting for %(displayName)s to verify…": "等待 %(displayName)s 进行验证…", "Cancelling…": "正在取消…", "They match": "它们匹配", "They don't match": "它们不匹配", "To be secure, do this in person or use a trusted way to communicate.": "为了安全,请当面完成或使用信任的方法交流。", "Lock": "锁", - "Your server isn't responding to some requests.": "您的服务器没有响应一些请求。", + "Your server isn't responding to some requests.": "你的服务器没有响应一些请求。", "From %(deviceName)s (%(deviceId)s)": "来自 %(deviceName)s (%(deviceId)s)", "Decline (%(counter)s)": "拒绝 (%(counter)s)", "Accept to continue:": "接受 以继续:", "Upload": "上传", "Show less": "显示更少", "Show more": "显示更多", - "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "修改密码会重置所有会话上的端对端加密的密钥,使加密聊天记录不可读,除非您先导出您的聊天室密钥,之后再重新导入。在未来会有所改进。", - "Your homeserver does not support cross-signing.": "您的主服务器不支持交叉签名。", + "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "修改密码会重置所有会话上的端对端加密的密钥,使加密聊天记录不可读,除非你先导出你的聊天室密钥,之后再重新导入。在未来会有所改进。", + "Your homeserver does not support cross-signing.": "你的主服务器不支持交叉签名。", "Cross-signing and secret storage are enabled.": "交叉签名和秘密存储已启用。", - "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.": "您的账号在秘密存储中有交叉签名身份,但并没有被此会话信任。", + "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.": "你的账号在秘密存储中有交叉签名身份,但并没有被此会话信任。", "Cross-signing and secret storage are not yet set up.": "交叉签名和秘密存储尚未设置。", "Reset cross-signing and secret storage": "重置交叉签名和秘密存储", "Bootstrap cross-signing and secret storage": "自举交叉签名和秘密存储", @@ -1505,7 +1505,7 @@ "Secret storage public key:": "秘密存储公钥:", "in account data": "在账号数据中", "exists": "存在", - "Your homeserver does not support session management.": "您的主服务器不支持会话管理。", + "Your homeserver does not support session management.": "你的主服务器不支持会话管理。", "Unable to load session list": "无法加载会话列表", "Confirm deleting these sessions": "确认删除这些会话", "Click the button below to confirm deleting these sessions.|other": "点击下方按钮以确认删除这些会话。", @@ -1518,9 +1518,9 @@ "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "逐一验证用户的每一个会话以将其标记为已信任,而不信任交叉签名的设备。", "Manage": "管理", "Enable": "启用", - "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "%(brand)s 缺少安全地在本地缓存加密信息所必须的部件。如果您想实验此功能,请构建一个自定义的带有搜索部件的 %(brand)s 桌面版。", + "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "%(brand)s 缺少安全地在本地缓存加密信息所必须的部件。如果你想实验此功能,请构建一个自定义的带有搜索部件的 %(brand)s 桌面版。", "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "%(brand)s 在浏览器中运行时不能安全地在本地缓存加密信息。请使用%(brand)s 桌面版以使加密信息出现在搜索结果中。", - "This session is backing up your keys. ": "此会话正在备份您的密钥。 ", + "This session is backing up your keys. ": "此会话正在备份你的密钥。 ", "Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.": "在登出前连接此会话到密钥备份以避免丢失可能仅在此会话上的密钥。", "Connect this session to Key Backup": "将此会话连接到密钥备份", "Backup has a valid signature from this user": "备份有来自此用户的有效签名", @@ -1533,13 +1533,13 @@ "Backup has a valid signature from unverified session ": "备份有一个有效的签名,它来自未验证的会话", "Backup has an invalid signature from verified session ": "备份有一个无效的签名,它来自已验证的会话", "Backup has an invalid signature from unverified session ": "备份有一个无效的签名,它来自未验证的会话", - "Backup is not signed by any of your sessions": "备份没有被您的任何一个会话签名", + "Backup is not signed by any of your sessions": "备份没有被你的任何一个会话签名", "This backup is trusted because it has been restored on this session": "此备份是受信任的因为它被恢复到了此会话上", "Backup key stored: ": "存储的备份密钥: ", - "Your keys are not being backed up from this session.": "您的密钥没有被此会话备份。", + "Your keys are not being backed up from this session.": "你的密钥没有被此会话备份。", "Clear notifications": "清除通知", "There are advanced notifications which are not shown here.": "有高级通知没有显示在此处。", - "You might have configured them in a client other than %(brand)s. You cannot tune them in %(brand)s but they still apply.": "您可能在非 %(brand)s 的客户端里配置了它们。您在 %(brand)s 里无法修改它们,但它们仍然适用。", + "You might have configured them in a client other than %(brand)s. You cannot tune them in %(brand)s but they still apply.": "你可能在非 %(brand)s 的客户端里配置了它们。你在 %(brand)s 里无法修改它们,但它们仍然适用。", "Enable desktop notifications for this session": "为此会话启用桌面通知", "Enable audible notifications for this session": "为此会话启用声音通知", "Identity Server URL must be HTTPS": "身份服务器连接必须是 HTTPS", @@ -1549,29 +1549,29 @@ "Change identity server": "更改身份服务器", "Disconnect from the identity server and connect to instead?": "从 身份服务器断开连接并连接到 吗?", "Terms of service not accepted or the identity server is invalid.": "服务协议未同意或身份服务器无效。", - "The identity server you have chosen does not have any terms of service.": "您选择的身份服务器没有服务协议。", + "The identity server you have chosen does not have any terms of service.": "你选择的身份服务器没有服务协议。", "Disconnect identity server": "断开身份服务器连接", "Disconnect from the identity server ?": "从身份服务器 断开连接吗?", "Disconnect": "断开连接", "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "断开连接前,你应当删除你的个人信息从身份服务器。不幸的是,身份服务器当前处于离线状态或无法访问。", - "You should:": "您应该:", + "You should:": "你应该:", "contact the administrators of identity server ": "联系身份服务器 的管理员", "wait and try again later": "等待并稍后重试", "Disconnect anyway": "仍然断开连接", - "You are still sharing your personal data on the identity server .": "您仍然在分享您的个人信息在身份服务器上。", - "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "我们推荐您在断开连接前从身份服务器上删除您的邮箱地址和电话号码。", + "You are still sharing your personal data on the identity server .": "你仍然在分享你的个人信息在身份服务器上。", + "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "我们推荐你在断开连接前从身份服务器上删除你的邮箱地址和电话号码。", "Identity Server (%(server)s)": "身份服务器(%(server)s)", "not stored": "未存储", - "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.": "您正在使用 以发现您认识的现存联系人并被其发现。您可以在下方更改您的身份服务器。", - "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.": "如果您不想使用 以发现您认识的现存联系人并被其发现,请在下方输入另一个身份服务器。", + "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.": "你正在使用 以发现你认识的现存联系人并被其发现。你可以在下方更改你的身份服务器。", + "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.": "如果你不想使用 以发现你认识的现存联系人并被其发现,请在下方输入另一个身份服务器。", "Identity Server": "身份服务器", - "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.": "您现在没有使用身份服务器。若想发现您认识的现存联系人并被其发现,请在下方添加一个身份服务器。", - "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "从您的身份服务器断开连接意味着您将不可被别的用户发现,同时您也将不能用邮箱或电话邀请别人。", - "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.": "使用身份服务器是可选的。如果您选择不使用身份服务器,您将不能被别的用户发现,也不能用邮箱或电话邀请别人。", + "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.": "你现在没有使用身份服务器。若想发现你认识的现存联系人并被其发现,请在下方添加一个身份服务器。", + "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "断开身份服务器连接意味着你将无法被其他用户发现,同时你也将无法使用电子邮件或电话邀请别人。", + "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.": "使用身份服务器是可选的。如果你选择不使用身份服务器,你将不能被别的用户发现,也不能用邮箱或电话邀请别人。", "Do not use an identity server": "不使用身份服务器", "Enter a new identity server": "输入一个新的身份服务器", "New version available. Update now.": "新版本可用。现在更新。", - "Hey you. You're the best!": "嘿呀。您就是最棒的!", + "Hey you. You're the best!": "嘿呀。你就是最棒的!", "Size must be a number": "大小必须是数字", "Custom font size can only be between %(min)s pt and %(max)s pt": "自定义字体大小只能介于 %(min)s pt 和 %(max)s pt 之间", "Error downloading theme information.": "下载主题信息时发生错误。", @@ -1581,10 +1581,10 @@ "Message layout": "信息布局", "Compact": "紧凑", "Modern": "现代", - "Set the name of a font installed on your system & %(brand)s will attempt to use it.": "设置一个安装在您的系统上的字体名称,%(brand)s 会尝试使用它。", - "Customise your appearance": "自定义您的外观", + "Set the name of a font installed on your system & %(brand)s will attempt to use it.": "设置一个安装在你的系统上的字体名称,%(brand)s 会尝试使用它。", + "Customise your appearance": "自定义你的外观", "Appearance Settings only affect this %(brand)s session.": "外观设置仅会影响此 %(brand)s 会话。", - "Your password was successfully changed. You will not receive push notifications on other sessions until you log back in to them": "您的密码已成功更改。在您重新登录别的会话之前,您将不会在那里收到推送通知", + "Your password was successfully changed. You will not receive push notifications on other sessions until you log back in to them": "你的密码已成功更改。在你重新登录别的会话之前,你将不会在那里收到推送通知", "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "同意身份服务器(%(serverName)s)的服务协议以允许自己被通过邮件地址或电话号码发现。", "Discovery": "发现", "Clear cache and reload": "清除缓存重新加载", @@ -1600,22 +1600,22 @@ "Ban list rules - %(roomName)s": "封禁列表规则 - %(roomName)s", "Server rules": "服务器规则", "User rules": "用户规则", - "You have not ignored anyone.": "您没有忽略任何人。", - "You are currently ignoring:": "您正在忽略:", - "You are not subscribed to any lists": "您没有订阅任何列表", + "You have not ignored anyone.": "你没有忽略任何人。", + "You are currently ignoring:": "你正在忽略:", + "You are not subscribed to any lists": "你没有订阅任何列表", "Unsubscribe": "取消订阅", "View rules": "查看规则", - "You are currently subscribed to:": "您正在订阅:", + "You are currently subscribed to:": "你正在订阅:", "⚠ These settings are meant for advanced users.": "⚠ 这些设置是为高级用户准备的。", - "Add users and servers you want to ignore here. Use asterisks to have %(brand)s match any characters. For example, @bot:* would ignore all users that have the name 'bot' on any server.": "在此处添加您想忽略的用户和服务器。使用星号以使 %(brand)s 匹配任何字符。例如,@bot:* 会忽略在任何服务器上以「bot」为名的用户。", - "Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.": "忽略人是通过含有封禁规则的封禁列表来完成的。订阅一个封禁列表意味着被该列表阻止的用户/服务器将会对您隐藏。", + "Add users and servers you want to ignore here. Use asterisks to have %(brand)s match any characters. For example, @bot:* would ignore all users that have the name 'bot' on any server.": "在此处添加你想忽略的用户和服务器。使用星号以使 %(brand)s 匹配任何字符。例如,@bot:* 会忽略在任何服务器上以「bot」为名的用户。", + "Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.": "忽略人是通过含有封禁规则的封禁列表来完成的。订阅一个封禁列表意味着被此列表阻止的用户/服务器将会对你隐藏。", "Personal ban list": "个人封禁列表", - "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.": "您的个人封禁列表包含所有您不想看见信息的用户/服务器。您第一次忽略用户/服务器。后,一个名叫「我的封禁列表」的新聊天室将会显示在您的聊天室列表中——留在该聊天室以保持该封禁列表生效。", + "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.": "你的个人封禁列表包含所有你不想看见信息的用户/服务器。你第一次忽略用户/服务器。后,一个名叫「我的封禁列表」的新聊天室将会显示在你的聊天室列表中——留在此聊天室以保持此封禁列表生效。", "Server or user ID to ignore": "要忽略的服务器或用户 ID", "eg: @bot:* or example.org": "例如: @bot:* 或 example.org", "Subscribed lists": "订阅的列表", - "Subscribing to a ban list will cause you to join it!": "订阅一个封禁列表会使您加入它!", - "If this isn't what you want, please use a different tool to ignore users.": "如果这不是您想要的,请使用别的的工具来忽略用户。", + "Subscribing to a ban list will cause you to join it!": "订阅一个封禁列表会使你加入它!", + "If this isn't what you want, please use a different tool to ignore users.": "如果这不是你想要的,请使用别的的工具来忽略用户。", "Room ID or address of ban list": "封禁列表的聊天室 ID 或地址", "Subscribe": "订阅", "Always show the window menu bar": "总是显示窗口菜单栏", @@ -1624,8 +1624,8 @@ "Session key:": "会话密钥:", "Message search": "信息搜索", "Cross-signing": "交叉签名", - "Where you’re logged in": "您在何处登录", - "A session's public name is visible to people you communicate with": "会话的公共名称会对和您交流的人显示", + "Where you’re logged in": "你在何处登录", + "A session's public name is visible to people you communicate with": "会话的公共名称会对和你交流的人显示", "this room": "此聊天室", "View older messages in %(roomName)s.": "查看 %(roomName)s 里更旧的信息。", "Uploaded sound": "已上传的声音", @@ -1637,26 +1637,26 @@ "Upgrade the room": "更新聊天室", "Enable room encryption": "启用聊天室加密", "Error changing power level requirement": "更改权限级别需求时出错", - "An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.": "更改此聊天室的权限级别需求时出错。请确保您有足够的权限后重试。", + "An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.": "更改此聊天室的权限级别需求时出错。请确保你有足够的权限后重试。", "Error changing power level": "更改权限级别时出错", - "An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.": "更改此用户的权限级别时出错。请确保您有足够权限后重试。", + "An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.": "更改此用户的权限级别时出错。请确保你有足够权限后重试。", "To link to this room, please add an address.": "要链接至此聊天室,请添加一个地址。", "Unable to share email address": "无法共享邮件地址", - "Your email address hasn't been verified yet": "您的邮件地址尚未被验证", - "Click the link in the email you received to verify and then click continue again.": "请点击您收到的邮件中的链接后再点击继续。", - "Verify the link in your inbox": "验证您的收件箱中的链接", + "Your email address hasn't been verified yet": "你的邮件地址尚未被验证", + "Click the link in the email you received to verify and then click continue again.": "请点击你收到的邮件中的链接后再点击继续。", + "Verify the link in your inbox": "验证你的收件箱中的链接", "Complete": "完成", "Share": "共享", - "Discovery options will appear once you have added an email above.": "您在上方添加邮箱后发现选项将会出现。", + "Discovery options will appear once you have added an email above.": "你在上方添加邮箱后发现选项将会出现。", "Unable to share phone number": "无法共享电话号码", "Please enter verification code sent via text.": "请输入短信中发送的验证码。", - "Discovery options will appear once you have added a phone number above.": "您添加电话号码后发现选项将会出现。", + "Discovery options will appear once you have added a phone number above.": "你添加电话号码后发现选项将会出现。", "Remove %(email)s?": "删除 %(email)s 吗?", "Remove %(phone)s?": "删除 %(phone)s 吗?", "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "一封短信已发送至 +%(msisdn)s。请输入其中包含的验证码。", "This user has not verified all of their sessions.": "此用户没有验证其全部会话。", - "You have not verified this user.": "您没有验证此用户。", - "You have verified this user. This user has verified all of their sessions.": "您验证了此用户。此用户已验证了其全部会话。", + "You have not verified this user.": "你没有验证此用户。", + "You have verified this user. This user has verified all of their sessions.": "你验证了此用户。此用户已验证了其全部会话。", "* %(senderName)s %(emote)s": "* %(senderName)s %(emote)s", "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", @@ -1670,33 +1670,33 @@ "Show shortcuts to recently viewed rooms above the room list": "在聊天室列表上方显示最近浏览过的聊天室的快捷方式", "Show hidden events in timeline": "显示时间线中的隐藏事件", "Low bandwidth mode": "低带宽模式", - "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "当您的主服务器没有提供通话辅助服务器时使用备用的 turn.matrix.org 服务器(您的IP地址会在通话期间被共享)", + "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "当你的主服务器没有提供通话辅助服务器时使用备用的 turn.matrix.org 服务器(你的IP地址会在通话期间被共享)", "Send read receipts for messages (requires compatible homeserver to disable)": "为消息发送已读回执(需要兼容的主服务器方可禁用)", "Scan this unique code": "扫描此唯一代码", "Compare unique emoji": "比较唯一表情符号", - "Compare a unique set of emoji if you don't have a camera on either device": "若您在两个设备上都没有相机,比较唯一一组表情符号", + "Compare a unique set of emoji if you don't have a camera on either device": "若你在两个设备上都没有相机,比较唯一一组表情符号", "Verify this session by confirming the following number appears on its screen.": "确认下方数字显示在屏幕上以验证此会话。", "This bridge is managed by .": "此桥接由 管理。", "Homeserver feature support:": "主服务器功能支持:", - "Confirm deleting these sessions by using Single Sign On to prove your identity.|other": "通过单点登录证明您的身份并确认删除这些会话。", - "Confirm deleting these sessions by using Single Sign On to prove your identity.|one": "通过单点登录证明您的身份并确认删除此会话。", + "Confirm deleting these sessions by using Single Sign On to prove your identity.|other": "通过单点登录证明你的身份并确认删除这些会话。", + "Confirm deleting these sessions by using Single Sign On to prove your identity.|one": "通过单点登录证明你的身份并确认删除此会话。", "Public Name": "公开名称", "Securely cache encrypted messages locally for them to appear in search results.": "在本地安全地缓存加密消息以使其出现在搜索结果中。", "Connecting to integration manager...": "正在连接至集成管理器...", "Cannot connect to integration manager": "不能连接到集成管理器", - "The integration manager is offline or it cannot reach your homeserver.": "此集成管理器为离线状态或者其不能访问您的主服务器。", - "check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "检查您的浏览器是否安装有可能屏蔽身份服务器的插件(例如 Privacy Badger)", - "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "使用集成管理器 (%(serverName)s) 以管理机器人、挂件和贴图集。", - "Use an Integration Manager to manage bots, widgets, and sticker packs.": "使用集成管理器以管理机器人、挂件和贴图集。", - "Manage integrations": "管理集成", - "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "集成管理器接收配置数据,并可以以您的名义修改挂件、发送聊天室邀请及设置权限级别。", + "The integration manager is offline or it cannot reach your homeserver.": "此集成管理器为离线状态或者其不能访问你的主服务器。", + "check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "检查你的浏览器是否安装有可能屏蔽身份服务器的插件(例如 Privacy Badger)", + "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "使用集成管理器 (%(serverName)s) 以管理机器人、挂件和贴纸包。", + "Use an Integration Manager to manage bots, widgets, and sticker packs.": "使用集成管理器以管理机器人、挂件和贴纸包。", + "Manage integrations": "集成管理", + "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "集成管理器接收配置数据,并可以以你的名义修改挂件、发送聊天室邀请及设置权限级别。", "Use between %(min)s pt and %(max)s pt": "请使用介于 %(min)s pt 和 %(max)s pt 之间的大小", "Deactivate account": "停用账号", "To report a Matrix-related security issue, please read the Matrix.org Security Disclosure Policy.": "要报告 Matrix 相关的安全问题,请阅读 Matrix.org 的安全公开策略。", - "Something went wrong. Please try again or view your console for hints.": "出现问题。请重试或查看您的终端以获得提示。", - "Please try again or view your console for hints.": "请重试或查看您的终端以获得提示。", - "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "您的服务器管理员未在私人聊天室和私聊中默认启用端对端加密。", - "Manage the names of and sign out of your sessions below or verify them in your User Profile.": "在下方管理会话名称,登出您的会话或在您的用户资料中验证它们。", + "Something went wrong. Please try again or view your console for hints.": "出现问题。请重试或查看你的终端以获得提示。", + "Please try again or view your console for hints.": "请重试或查看你的终端以获得提示。", + "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "你的服务器管理员未在私人聊天室和私聊中默认启用端对端加密。", + "Manage the names of and sign out of your sessions below or verify them in your User Profile.": "在下方管理会话名称,登出你的会话或在你的用户资料中验证它们。", "This room is bridging messages to the following platforms. Learn more.": "此聊天室正将消息桥接到以下平台。了解更多。", "This room isn’t bridging messages to any platforms. Learn more.": "此聊天室未将消息桥接到任何平台。了解更多。", "Bridges": "桥接", @@ -1704,10 +1704,10 @@ "This room is end-to-end encrypted": "此聊天室是端对端加密的", "Everyone in this room is verified": "聊天室中所有人都已被验证", "Edit message": "编辑消息", - "Your key share request has been sent - please check your other sessions for key share requests.": "您的密钥共享请求已发送——请检查您别的会话。", - "Key share requests are sent to your other sessions automatically. If you rejected or dismissed the key share request on your other sessions, click here to request the keys for this session again.": "密钥共享请求会自动发送至您别的会话。如果您拒绝或忽略了您别的会话上的密钥共享请求,请点击此处重新为此会话请求密钥。", - "If your other sessions do not have the key for this message you will not be able to decrypt them.": "如果您别的会话没有此消息的密钥您将不能解密它们。", - "Re-request encryption keys from your other sessions.": "从您别的会话重新请求加密密钥。", + "Your key share request has been sent - please check your other sessions for key share requests.": "你的密钥共享请求已发送——请检查你别的会话。", + "Key share requests are sent to your other sessions automatically. If you rejected or dismissed the key share request on your other sessions, click here to request the keys for this session again.": "密钥共享请求会自动发送至你别的会话。如果你拒绝或忽略了你别的会话上的密钥共享请求,请点击此处重新为此会话请求密钥。", + "If your other sessions do not have the key for this message you will not be able to decrypt them.": "如果你别的会话没有此消息的密钥你将不能解密它们。", + "Re-request encryption keys from your other sessions.": "从你别的会话重新请求加密密钥。", "This message cannot be decrypted": "此消息无法被解密", "Encrypted by an unverified session": "由未验证的会话加密", "Unencrypted": "未加密", @@ -1715,7 +1715,7 @@ "The authenticity of this encrypted message can't be guaranteed on this device.": "此加密消息的权威性不能在此设备上被保证。", "Scroll to most recent messages": "滚动到最近的消息", "Close preview": "关闭预览", - "Emoji picker": "表情符号选择器", + "Emoji picker": "Emoji 选择器", "Send a reply…": "发送回复…", "Send a message…": "发送消息…", "Bold": "粗体", @@ -1733,32 +1733,32 @@ "Join the conversation with an account": "使用一个账号加入对话", "Sign Up": "注册", "Loading room preview": "正在加载聊天室预览", - "You were kicked from %(roomName)s by %(memberName)s": "您被 %(memberName)s 踢出了 %(roomName)s", + "You were kicked from %(roomName)s by %(memberName)s": "你被 %(memberName)s 踢出了 %(roomName)s", "Reason: %(reason)s": "原因:%(reason)s", "Forget this room": "忘记此聊天室", "Re-join": "重新加入", - "You were banned from %(roomName)s by %(memberName)s": "您被 %(memberName)s 从 %(roomName)s 封禁了", - "Something went wrong with your invite to %(roomName)s": "您到 %(roomName)s 的邀请出错", - "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to a room admin.": "尝试验证您的邀请时返回错误(%(errcode)s)。您可以将此信息转告给聊天室管理员。", + "You were banned from %(roomName)s by %(memberName)s": "你被 %(memberName)s 从 %(roomName)s 封禁了", + "Something went wrong with your invite to %(roomName)s": "你到 %(roomName)s 的邀请出错", + "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to a room admin.": "尝试验证你的邀请时返回错误(%(errcode)s)。你可以将此信息转告给聊天室管理员。", "Try to join anyway": "仍然尝试加入", - "You can still join it because this is a public room.": "您仍然能加入,因为这是一个公共聊天室。", + "You can still join it because this is a public room.": "你仍然能加入,因为这是一个公共聊天室。", "Join the discussion": "加入讨论", - "This invite to %(roomName)s was sent to %(email)s which is not associated with your account": "这个到 %(roomName)s 的邀请是发送给 %(email)s 的,而此邮箱没有关联您的账号", - "Link this email with your account in Settings to receive invites directly in %(brand)s.": "要在 %(brand)s 中直接接收邀请,请在设置中将您的账号连接到此邮箱。", + "This invite to %(roomName)s was sent to %(email)s which is not associated with your account": "这个到 %(roomName)s 的邀请是发送给 %(email)s 的,而此邮箱没有关联你的账号", + "Link this email with your account in Settings to receive invites directly in %(brand)s.": "要在 %(brand)s 中直接接收邀请,请在设置中将你的账号连接到此邮箱。", "This invite to %(roomName)s was sent to %(email)s": "这个到 %(roomName)s 的邀请是发送给 %(email)s 的", "Use an identity server in Settings to receive invites directly in %(brand)s.": "要直接在 %(brand)s 中接收邀请,请在设置中使用一个身份服务器。", "Share this email in Settings to receive invites directly in %(brand)s.": "要在 %(brand)s 中直接接收邀请,请在设置中共享此邮箱。", - "Do you want to chat with %(user)s?": "您想和 %(user)s 聊天吗?", + "Do you want to chat with %(user)s?": "你想和 %(user)s 聊天吗?", " wants to chat": " 想聊天", "Start chatting": "开始聊天", - "Do you want to join %(roomName)s?": "您想加入 %(roomName)s 吗?", - " invited you": " 邀请了您", + "Do you want to join %(roomName)s?": "你想加入 %(roomName)s 吗?", + " invited you": " 邀请了你", "Reject & Ignore user": "拒绝并忽略用户", - "You're previewing %(roomName)s. Want to join it?": "您正在预览 %(roomName)s。想加入吗?", - "%(roomName)s can't be previewed. Do you want to join it?": "%(roomName)s 不能被预览。您想加入吗?", - "This room doesn't exist. Are you sure you're at the right place?": "此聊天室不存在。您确定您在正确的地方吗?", - "Try again later, or ask a room admin to check if you have access.": "请稍后重试,或询问聊天室管理员以检查您是否有权限。", - "%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please submit a bug report.": "尝试访问该房间是返回了 %(errcode)s。如果您认为您看到此消息是个错误,请提交一个错误报告。", + "You're previewing %(roomName)s. Want to join it?": "你正在预览 %(roomName)s。想加入吗?", + "%(roomName)s can't be previewed. Do you want to join it?": "%(roomName)s 不能被预览。你想加入吗?", + "This room doesn't exist. Are you sure you're at the right place?": "此聊天室不存在。你确定你在正确的地方吗?", + "Try again later, or ask a room admin to check if you have access.": "请稍后重试,或询问聊天室管理员以检查你是否有权限。", + "%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please submit a bug report.": "尝试访问该房间是返回了 %(errcode)s。如果你认为你看到此消息是个错误,请提交一个错误报告。", "Appearance": "外观", "Show rooms with unread messages first": "优先显示有未读消息的聊天室", "Show previews of messages": "显示消息预览", @@ -1788,41 +1788,41 @@ "This room has already been upgraded.": "此聊天室已经被升级。", "Unknown Command": "未知命令", "Unrecognised command: %(commandText)s": "未识别的命令:%(commandText)s", - "You can use /help to list available commands. Did you mean to send this as a message?": "您可以使用 /help 列出可用命令。您是否要将其作为消息发送?", - "Hint: Begin your message with // to start it with a slash.": "提示:以 // 开始您的消息来使其以一个斜杠开始。", + "You can use /help to list available commands. Did you mean to send this as a message?": "你可以使用 /help 列出可用命令。你是否要将其作为消息发送?", + "Hint: Begin your message with // to start it with a slash.": "提示:以 // 开始你的消息来使其以一个斜杠开始。", "Send as message": "作为消息发送", "Failed to connect to integration manager": "连接至集成管理器失败", "Mark all as read": "标记所有为已读", "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "更新此聊天室的备用地址时出现错误。可能是服务器不允许,也可能是出现了一个暂时的错误。", "Error creating address": "创建地址时出现错误", "There was an error creating that address. It may not be allowed by the server or a temporary failure occurred.": "创建地址时出现错误。可能是服务器不允许,也可能是出现了一个暂时的错误。", - "You don't have permission to delete the address.": "您没有权限删除此地址。", + "You don't have permission to delete the address.": "你没有权限删除此地址。", "There was an error removing that address. It may no longer exist or a temporary error occurred.": "删除那个地址时出现错误。可能它已不存在,也可能出现了一个暂时的错误。", "Error removing address": "删除地址时出现错误", "Local address": "本地地址", "Published Addresses": "发布的地址", - "Published addresses can be used by anyone on any server to join your room. To publish an address, it needs to be set as a local address first.": "发布的地址可以被任何服务器上的任何人用来加入您的聊天室。要发布一个地址,它必须先被设为一个本地地址。", + "Published addresses can be used by anyone on any server to join your room. To publish an address, it needs to be set as a local address first.": "发布的地址可以被任何服务器上的任何人用来加入你的聊天室。要发布一个地址,它必须先被设为一个本地地址。", "Other published addresses:": "其它发布的地址:", "No other published addresses yet, add one below": "还没有别的发布的地址,可在下方添加", "New published address (e.g. #alias:server)": "新的发布的地址(例如 #alias:server)", "Local Addresses": "本地地址", - "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "为此聊天室设置地址以便用户通过您的主服务器(%(localDomain)s)找到此聊天室", + "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "为此聊天室设置地址以便用户通过你的主服务器(%(localDomain)s)找到此聊天室", "Waiting for you to accept on your other session…": "等待您在您别的会话上接受…", "Waiting for %(displayName)s to accept…": "等待 %(displayName)s 接受…", "Accepting…": "正在接受…", "Start Verification": "开始验证", "Messages in this room are end-to-end encrypted.": "此聊天室内的消息是端对端加密的。", - "Your messages are secured and only you and the recipient have the unique keys to unlock them.": "您的消息是安全的,只有您和收件人有解开它们的唯一密钥。", + "Your messages are secured and only you and the recipient have the unique keys to unlock them.": "你的消息是安全的,只有你和收件人有解开它们的唯一密钥。", "Messages in this room are not end-to-end encrypted.": "此聊天室内的消息未端对端加密。", - "In encrypted rooms, your messages are secured and only you and the recipient have the unique keys to unlock them.": "在加密聊天室中,您的消息是安全的,只有您和收件人有解开它们的唯一密钥。", + "In encrypted rooms, your messages are secured and only you and the recipient have the unique keys to unlock them.": "在加密聊天室中,你的消息是安全的,只有你和收件人有解开它们的唯一密钥。", "Verify User": "验证用户", - "For extra security, verify this user by checking a one-time code on both of your devices.": "为了更加安全,通过在您二者的设备上检查一次性代码来验证此用户。", - "Your messages are not secure": "您的消息不安全", + "For extra security, verify this user by checking a one-time code on both of your devices.": "为了更加安全,通过在你二者的设备上检查一次性代码来验证此用户。", + "Your messages are not secure": "你的消息不安全", "One of the following may be compromised:": "以下之一可能被损害:", - "Your homeserver": "您的主服务器", - "The homeserver the user you’re verifying is connected to": "您在验证的用户连接到的主服务器", - "Yours, or the other users’ internet connection": "您的或另一位用户的网络连接", - "Yours, or the other users’ session": "您的或另一位用户的会话", + "Your homeserver": "你的主服务器", + "The homeserver the user you’re verifying is connected to": "你在验证的用户连接到的主服务器", + "Yours, or the other users’ internet connection": "你的或另一位用户的网络连接", + "Yours, or the other users’ session": "你的或另一位用户的会话", "Trusted": "受信任的", "Not trusted": "不受信任的", "%(count)s verified sessions|other": "%(count)s 个已验证的会话", @@ -1835,39 +1835,39 @@ "No recent messages by %(user)s found": "没有找到 %(user)s 最近发送的消息", "Try scrolling up in the timeline to see if there are any earlier ones.": "请尝试在时间线中向上滚动以查看是否有更早的。", "Remove recent messages by %(user)s": "删除 %(user)s 最近发送的消息", - "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|other": "您将删除 %(user)s 发送的 %(count)s 条消息。此操作不能撤销。您想继续吗?", - "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "您将删除 %(user)s 发送的 1 条消息。此操作不能撤销。您想继续吗?", - "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.": "对于大量消息,可能会消耗一段时间。在此期间请不要刷新您的客户端。", + "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|other": "你将删除 %(user)s 发送的 %(count)s 条消息。此操作不能撤销。你想继续吗?", + "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "你将删除 %(user)s 发送的 1 条消息。此操作不能撤销。你想继续吗?", + "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.": "对于大量消息,可能会消耗一段时间。在此期间请不要刷新你的客户端。", "Remove %(count)s messages|other": "删除 %(count)s 条消息", "Remove %(count)s messages|one": "删除 1 条消息", "Remove recent messages": "删除最近消息", "%(role)s in %(roomName)s": "%(roomName)s 中的 %(role)s", "Deactivate user?": "停用用户吗?", - "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "停用此用户将会使其登出并阻止其再次登入。而且此用户也会离开其所在的所有聊天室。此操作不可逆。您确定要停用此用户吗?", + "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "停用此用户将会使其登出并阻止其再次登入。而且此用户也会离开其所在的所有聊天室。此操作不可逆。你确定要停用此用户吗?", "Deactivate user": "停用用户", "Failed to deactivate user": "停用用户失败", "This client does not support end-to-end encryption.": "此客户端不支持端对端加密。", "Security": "安全", "Verify by scanning": "扫码验证", - "Ask %(displayName)s to scan your code:": "请 %(displayName)s 扫描您的代码:", - "If you can't scan the code above, verify by comparing unique emoji.": "如果您不能扫描以上代码,请通过比较唯一的表情符号来验证。", + "Ask %(displayName)s to scan your code:": "请 %(displayName)s 扫描你的代码:", + "If you can't scan the code above, verify by comparing unique emoji.": "如果你不能扫描以上代码,请通过比较唯一的表情符号来验证。", "Verify by comparing unique emoji.": "通过比较唯一的表情符号来验证。", "Verify by emoji": "通过表情符号验证", - "Almost there! Is your other session showing the same shield?": "快完成了!您的另一个会话显示了同样的盾牌吗?", + "Almost there! Is your other session showing the same shield?": "快完成了!你的另一个会话显示了同样的盾牌吗?", "Almost there! Is %(displayName)s showing the same shield?": "快完成了!%(displayName)s 显示了同样的盾牌吗?", "Verify all users in a room to ensure it's secure.": "验证聊天室中所有用户以确保其安全。", "In encrypted rooms, verify all users to ensure it’s secure.": "在加密聊天室中,验证所有用户以确保其安全。", - "You've successfully verified your device!": "您成功验证了您的设备!", - "You've successfully verified %(deviceName)s (%(deviceId)s)!": "您成功验证了 %(deviceName)s (%(deviceId)s)!", - "You've successfully verified %(displayName)s!": "您成功验证了 %(displayName)s!", + "You've successfully verified your device!": "你成功验证了你的设备!", + "You've successfully verified %(deviceName)s (%(deviceId)s)!": "你成功验证了 %(deviceName)s (%(deviceId)s)!", + "You've successfully verified %(displayName)s!": "你成功验证了 %(displayName)s!", "Verified": "已验证", "Got it": "知道了", "Start verification again from the notification.": "请从提示重新开始验证。", "Start verification again from their profile.": "请从对方资料重新开始验证。", "Verification timed out.": "雅正超时。", - "You cancelled verification on your other session.": "您在您别的会话上取消了验证。", + "You cancelled verification on your other session.": "你在你别的会话上取消了验证。", "%(displayName)s cancelled verification.": "%(displayName)s 取消了验证。", - "You cancelled verification.": "您取消了验证。", + "You cancelled verification.": "你取消了验证。", "Verification cancelled": "验证已取消", "Compare emoji": "比较表情符号", "Encryption enabled": "已启用加密", @@ -1877,20 +1877,20 @@ "React": "回应", "Message Actions": "消息操作", "Show image": "显示图像", - "You have ignored this user, so their message is hidden. Show anyways.": "您已忽略该用户,所以其消息已被隐藏。仍然显示。", - "You verified %(name)s": "您验证了 %(name)s", - "You cancelled verifying %(name)s": "您取消了 %(name)s 的验证", + "You have ignored this user, so their message is hidden. Show anyways.": "你已忽略此用户,所以其消息已被隐藏。仍然显示。", + "You verified %(name)s": "你验证了 %(name)s", + "You cancelled verifying %(name)s": "你取消了 %(name)s 的验证", "%(name)s cancelled verifying": "%(name)s 取消了验证", - "You accepted": "您接受了", + "You accepted": "你接受了", "%(name)s accepted": "%(name)s 接受了", - "You declined": "您拒绝了", - "You cancelled": "您取消了", + "You declined": "你拒绝了", + "You cancelled": "你取消了", "%(name)s declined": "%(name)s 拒绝了", "%(name)s cancelled": "%(name)s 取消了", "Accepting …": "正在接受…", "Declining …": "正在拒绝…", "%(name)s wants to verify": "%(name)s 想要验证", - "You sent a verification request": "您发送了一个验证请求", + "You sent a verification request": "你发送了一个验证请求", "Show all": "显示全部", "Reactions": "回应", " reacted with %(content)s": " 回应了 %(content)s", @@ -1917,14 +1917,14 @@ "Quick Reactions": "快速回应", "Cancel search": "取消搜索", "Any of the following data may be shared:": "以下数据之一可能被分享:", - "Your display name": "您的显示名称", - "Your avatar URL": "您的头像链接", - "Your user ID": "您的用户 ID", - "Your theme": "您的主题", + "Your display name": "你的显示名称", + "Your avatar URL": "你的头像链接", + "Your user ID": "你的用户 ID", + "Your theme": "你的主题", "%(brand)s URL": "%(brand)s 的链接", "Room ID": "聊天室 ID", "Widget ID": "挂件 ID", - "Using this widget may share data with %(widgetDomain)s & your Integration Manager.": "使用此挂件可能会和 %(widgetDomain)s 及您的集成管理器共享数据 。", + "Using this widget may share data with %(widgetDomain)s & your Integration Manager.": "使用此挂件可能会和 %(widgetDomain)s 及你的集成管理器共享数据 。", "Using this widget may share data with %(widgetDomain)s.": "使用此挂件可能会和 %(widgetDomain)s 共享数据 。", "Widgets do not use message encryption.": "挂件不适用消息加密。", "This widget may use cookies.": "此挂件可能使用 cookie。", @@ -1945,12 +1945,12 @@ "Looks good": "看着不错", "Can't find this server or its room list": "找不到此服务器或其聊天室列表", "All rooms": "所有聊天室", - "Your server": "您的服务器", - "Are you sure you want to remove %(serverName)s": "您确定要移除 %(serverName)s 吗", + "Your server": "你的服务器", + "Are you sure you want to remove %(serverName)s": "你确定要移除 %(serverName)s 吗", "Remove server": "移除服务器", "Matrix": "Matrix", "Add a new server": "添加新服务器", - "Enter the name of a new server you want to explore.": "输入您想探索的新服务器的服务器名。", + "Enter the name of a new server you want to explore.": "输入你想探索的新服务器的服务器名。", "Server name": "服务器名", "Add a new server...": "添加新服务器…", "%(networkName)s rooms": "%(networkName)s 的聊天室", @@ -1958,14 +1958,14 @@ "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "使用一个身份服务器以通过邮箱邀请。使用默认(%(defaultIdentityServerName)s)或在设置中管理。", "Use an identity server to invite by email. Manage in Settings.": "使用一个身份服务器以通过邮箱邀请。在设置中管理。", "Close dialog": "关闭对话框", - "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "请告诉我们哪里出错了,或最好创建一个 GitHub issue 来描述该问题。", - "Reminder: Your browser is unsupported, so your experience may be unpredictable.": "提醒:您的浏览器不被支持,所以您的体验可能不可预料。", + "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "请告诉我们哪里出错了,或最好创建一个 GitHub issue 来描述此问题。", + "Reminder: Your browser is unsupported, so your experience may be unpredictable.": "提醒:你的浏览器不被支持,所以你的体验可能不可预料。", "GitHub issue": "GitHub 上的 issue", "Notes": "提示", - "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "如果有额外的上下文可以帮助我们分析问题,比如您当时在做什么、房间 ID、用户 ID 等等,请将其列于此处。", + "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "如果有额外的上下文可以帮助我们分析问题,比如你当时在做什么、房间 ID、用户 ID 等等,请将其列于此处。", "Removing…": "正在移除…", "Destroy cross-signing keys?": "销毁交叉签名密钥?", - "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.": "删除交叉签名密钥是永久的。所有您验证过的人都会看到安全警报。除非您丢失了所有可以交叉签名的设备,否则几乎可以确定您不想这么做。", + "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.": "删除交叉签名密钥是永久的。所有你验证过的人都会看到安全警报。除非你丢失了所有可以交叉签名的设备,否则几乎可以确定你不想这么做。", "Clear cross-signing keys": "清楚交叉签名密钥", "Clear all data in this session?": "是否清除此会话中的所有数据?", "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "清除此会话中的所有数据是永久的。加密消息会丢失,除非其密钥已被备份。", @@ -1973,7 +1973,7 @@ "Please enter a name for the room": "请输入聊天室名称", "Set a room address to easily share your room with other people.": "设置一个聊天室地址以轻松地和别人共享您的聊天室。", "This room is private, and can only be joined by invitation.": "此聊天室是私人的,只能通过邀请加入。", - "You can’t disable this later. Bridges & most bots won’t work yet.": "您之后不能禁用此项。桥接和大部分机器人还不能正常工作。", + "You can’t disable this later. Bridges & most bots won’t work yet.": "你之后不能禁用此项。桥接和大部分机器人还不能正常工作。", "Enable end-to-end encryption": "启用端对端加密", "Create a public room": "创建一个公共聊天室", "Create a private room": "创建一个私人聊天室", @@ -1982,28 +1982,28 @@ "Hide advanced": "隐藏高级", "Show advanced": "显示高级", "Block users on other matrix homeservers from joining this room (This setting cannot be changed later!)": "阻止别的 matrix 主服务器上的用户加入此聊天室(此设置之后不能更改!)", - "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "您曾在此会话中使用了一个更新版本的 %(brand)s。要再使用此版本并使用端对端加密,您需要登出再重新登录。", - "Confirm your account deactivation by using Single Sign On to prove your identity.": "通过单点登录证明您的身份并确认停用您的账号。", - "Are you sure you want to deactivate your account? This is irreversible.": "您确定要停用您的账号吗?此操作不可逆。", + "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "你曾在此会话中使用了一个更新版本的 %(brand)s。要再使用此版本并使用端对端加密,你需要登出再重新登录。", + "Confirm your account deactivation by using Single Sign On to prove your identity.": "通过单点登录证明你的身份并确认停用你的账号。", + "Are you sure you want to deactivate your account? This is irreversible.": "你确定要停用你的账号吗?此操作不可逆。", "Confirm account deactivation": "确认账号停用", "There was a problem communicating with the server. Please try again.": "联系服务器时出现问题。请重试。", "Server did not require any authentication": "服务器不要求任何认证", "Server did not return valid authentication information.": "服务器未返回有效认证信息。", "View Servers in Room": "查看聊天室中的服务器", "Verification Requests": "验证请求", - "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "验证此用户会将其会话标记为已信任,与此同时,您的会话也会被此用户标记为已信任。", - "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "验证此设备以将其标记为已信任。在收发端对端加密消息时,信任设备可让您与其他用户更加放心。", - "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "验证此设备会将其标记为已信任,与此同时,其他验证了您的用户也会信任此设备。", + "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "验证此用户会将其会话标记为已信任,与此同时,你的会话也会被此用户标记为已信任。", + "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "验证此设备以将其标记为已信任。在收发端对端加密消息时,信任设备可让你与其他用户更加放心。", + "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "验证此设备会将其标记为已信任,与此同时,其他验证了你的用户也会信任此设备。", "Integrations are disabled": "集成已禁用", - "Enable 'Manage Integrations' in Settings to do this.": "在设置中启用「管理集成」以执行此操作。", + "Enable 'Manage Integrations' in Settings to do this.": "在设置中启用「管理管理」以执行此操作。", "Integrations not allowed": "集成未被允许", - "Your %(brand)s doesn't allow you to use an Integration Manager to do this. Please contact an admin.": "您的 %(brand)s 不允许您使用集成管理器来完成此操作。请联系管理员。", - "To continue, use Single Sign On to prove your identity.": "要继续,请使用单点登录证明您的身份。", + "Your %(brand)s doesn't allow you to use an Integration Manager to do this. Please contact an admin.": "你的 %(brand)s 不允许你使用集成管理器来完成此操作。请联系管理员。", + "To continue, use Single Sign On to prove your identity.": "要继续,请使用单点登录证明你的身份。", "Confirm to continue": "确认以继续", - "Click the button below to confirm your identity.": "点击下方按钮确认您的身份。", + "Click the button below to confirm your identity.": "点击下方按钮确认你的身份。", "Failed to invite the following users to chat: %(csvUsers)s": "邀请以下用户加入聊天失败:%(csvUsers)s", "Something went wrong trying to invite the users.": "尝试邀请用户时出错。", - "We couldn't invite those users. Please check the users you want to invite and try again.": "我们不能邀请这些用户。请检查您想邀请的用户并重试。", + "We couldn't invite those users. Please check the users you want to invite and try again.": "我们不能邀请这些用户。请检查你想邀请的用户并重试。", "Failed to find the following users": "寻找以下用户失败", "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "下列用户可能不存在或无效,因此不能被邀请:%(csvNames)s", "Recent Conversations": "最近对话", @@ -2023,48 +2023,48 @@ "Signature upload success": "签名上传成功", "Signature upload failed": "签名上传失败", "If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "如果别的 %(brand)s 版本在别的标签页中仍然开启,请关闭它,因为在同一宿主上同时使用开启了延迟加载和关闭了延迟加载的 %(brand)s 会导致问题。", - "Confirm by comparing the following with the User Settings in your other session:": "通过比较下方内容和您别的会话中的用户设置来确认:", + "Confirm by comparing the following with the User Settings in your other session:": "通过比较下方内容和你别的会话中的用户设置来确认:", "Confirm this user's session by comparing the following with their User Settings:": "通过比较下方内容和对方用户设置来确认此用户会话:", "Session name": "会话名称", "Session key": "会话密钥", - "If they don't match, the security of your communication may be compromised.": "如果它们不匹配,您通讯的安全性可能已受损。", + "If they don't match, the security of your communication may be compromised.": "如果它们不匹配,你通讯的安全性可能已受损。", "Verify session": "验证会话", - "Your homeserver doesn't seem to support this feature.": "您的主服务器似乎不支持此功能。", + "Your homeserver doesn't seem to support this feature.": "你的主服务器似乎不支持此功能。", "Message edits": "消息编辑历史", - "Your account is not secure": "您的账号不安全", - "Your password": "您的密码", + "Your account is not secure": "你的账号不安全", + "Your password": "你的密码", "This session, or the other session": "此会话,或别的会话", - "The internet connection either session is using": "您会话使用的网络连接", + "The internet connection either session is using": "你会话使用的网络连接", "We recommend you change your password and recovery key in Settings immediately": "我们推荐您立刻在设置中更改您的密码和恢复密钥", "New session": "新会话", - "Use this session to verify your new one, granting it access to encrypted messages:": "使用此会话以验证您的新会话,并允许其访问加密信息:", - "If you didn’t sign in to this session, your account may be compromised.": "如果您没有登录进此会话,您的账号可能已受损。", + "Use this session to verify your new one, granting it access to encrypted messages:": "使用此会话以验证你的新会话,并允许其访问加密信息:", + "If you didn’t sign in to this session, your account may be compromised.": "如果你没有登录进此会话,你的账号可能已受损。", "This wasn't me": "这不是我", "Use your account to sign in to the latest version of the app at ": "使用您的账户在 登录此应用的最新版", "You’re already signed in and good to go here, but you can also grab the latest versions of the app on all platforms at element.io/get-started.": "您已经登录且一切已就绪,但您也可以在 element.io/get-started 获取此应用在全平台上的最新版。", "Go to Element": "前往 Element", "We’re excited to announce Riot is now Element!": "我们很兴奋地宣布 Riot 现在是 Element 了!", "Learn more at element.io/previously-riot": "访问 element.io/previously-riot 了解更多", - "Please fill why you're reporting.": "请填写您为何做此报告。", - "Report Content to Your Homeserver Administrator": "向您的主服务器管理员举报内容", - "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.": "举报此消息会将其唯一的「事件 ID」发送给您的主服务器的管理员。如果此聊天室中的消息被加密,您的主服务器管理员将不能阅读消息文本,也不能查看任何文件或图片。", + "Please fill why you're reporting.": "请填写你为何做此报告。", + "Report Content to Your Homeserver Administrator": "向你的主服务器管理员举报内容", + "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.": "举报此消息会将其唯一的「事件 ID」发送给你的主服务器的管理员。如果此聊天室中的消息被加密,你的主服务器管理员将不能阅读消息文本,也不能查看任何文件或图片。", "Send report": "发送报告", "Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:": "更新此聊天室需要关闭此聊天室的当前实力并创建一个新的聊天室代替它。为了给聊天室成员最好的体验,我们会:", "Automatically invite users": "自动邀请用户", "Upgrade private room": "更新私人聊天室", "Upgrade public room": "更新公共聊天室", "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "更新聊天室是高级操作,通常建议在聊天室由于错误、缺失功能或安全漏洞而不稳定时使用。", - "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)s 有问题,请报告一个错误。", - "You'll upgrade this room from to .": "您将把此聊天室从 升级至 。", + "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)s 有问题,请报告一个错误。", + "You'll upgrade this room from to .": "你将把此聊天室从 升级至 。", "You're all caught up.": "全数阅毕。", "Server isn't responding": "服务器未响应", - "Your server isn't responding to some of your requests. Below are some of the most likely reasons.": "您的服务器未响应您的一些请求。下方是一些最可能的原因。", + "Your server isn't responding to some of your requests. Below are some of the most likely reasons.": "你的服务器未响应你的一些请求。下方是一些最可能的原因。", "The server (%(serverName)s) took too long to respond.": "服务器(%(serverName)s)花了太长时间响应。", - "Your firewall or anti-virus is blocking the request.": "您的防火墙或防病毒软件阻止了该请求。", - "A browser extension is preventing the request.": "一个浏览器扩展阻止了该请求。", - "The server is offline.": "该服务器为离线状态。", - "The server has denied your request.": "该服务器拒绝了您的请求。", - "Your area is experiencing difficulties connecting to the internet.": "您的区域难以连接上互联网。", + "Your firewall or anti-virus is blocking the request.": "你的防火墙或防病毒软件阻止了此请求。", + "A browser extension is preventing the request.": "一个浏览器扩展阻止了此请求。", + "The server is offline.": "此服务器为离线状态。", + "The server has denied your request.": "此服务器拒绝了你的请求。", + "Your area is experiencing difficulties connecting to the internet.": "你的区域难以连接上互联网。", "A connection error occurred while trying to contact the server.": "尝试联系服务器时出现连接错误。", "Recent changes that have not yet been received": "尚未被接受的最近更改", "Sign out and remove encryption keys?": "登出并删除加密密钥?", @@ -2073,13 +2073,13 @@ "To help us prevent this in future, please send us logs.": "要帮助我们防止其以后发生,请给我们发送日志。", "Missing session data": "缺失会话数据", "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "一些会话数据,包括加密消息密钥,已缺失。要修复此问题,登出并重新登录,然后从备份恢复密钥。", - "Your browser likely removed this data when running low on disk space.": "您的浏览器可能在磁盘空间不足时删除了此数据。", + "Your browser likely removed this data when running low on disk space.": "你的浏览器可能在磁盘空间不足时删除了此数据。", "Integration Manager": "集成管理器", "Find others by phone or email": "通过电话或邮箱寻找别人", "Be found by phone or email": "通过电话或邮箱被寻找", - "Use bots, bridges, widgets and sticker packs": "使用机器人、桥接、挂件和贴图集", + "Use bots, bridges, widgets and sticker packs": "使用机器人、桥接、挂件和贴纸包", "Terms of Service": "服务协议", - "To continue you need to accept the terms of this service.": "要继续,您需要接受此服务协议。", + "To continue you need to accept the terms of this service.": "要继续,你需要接受此服务协议。", "Service": "服务", "Summary": "总结", "Document": "文档", @@ -2101,9 +2101,9 @@ "Invalid Recovery Key": "无效的恢复密钥", "Security Phrase": "安全密码", "Unable to access secret storage. Please verify that you entered the correct recovery passphrase.": "无法访问秘密存储。请确认您输入了正确的恢复密码。", - "Enter your Security Phrase or to continue.": "输入您的安全密码或以继续。", + "Enter your Security Phrase or to continue.": "输入你的安全密码或以继续。", "Security Key": "安全密钥", - "Use your Security Key to continue.": "使用您的安全密钥以继续。", + "Use your Security Key to continue.": "使用你的安全密钥以继续。", "Restoring keys from backup": "从备份恢复密钥", "Fetching keys from server...": "正在从服务器获取密钥...", "%(completed)s of %(total)s keys restored": "%(total)s 个密钥中之 %(completed)s 个已恢复", @@ -2115,7 +2115,7 @@ "Successfully restored %(sessionCount)s keys": "成功恢复了 %(sessionCount)s 个密钥", "Enter recovery passphrase": "输入恢复密码", "Enter recovery key": "输入恢复密钥", - "Warning: You should only set up key backup from a trusted computer.": "警告:您应该只从信任的计算机设置密钥备份。", + "Warning: You should only set up key backup from a trusted computer.": "警告:你应此只从信任的计算机设置密钥备份。", "If you've forgotten your recovery key you can ": "如果您忘记了恢复密钥,您可以", "Address (optional)": "地址(可选)", "Resend edit": "重新发送编辑", @@ -2130,19 +2130,19 @@ "Remove for me": "为我删除", "User Status": "用户状态", "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "您可以使用自定义服务器选项以使用不同的主服务器链接登录至别的 Matrix 服务器。这允许您通过不同的主服务器上的现存 Matrix 账户使用 %(brand)s。", - "Confirm your identity by entering your account password below.": "在下方输入账号密码以确认您的身份。", - "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "在主服务器配置中缺少验证码公钥。请将此报告给您的主服务器管理员。", + "Confirm your identity by entering your account password below.": "在下方输入账号密码以确认你的身份。", + "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "在主服务器配置中缺少验证码公钥。请将此报告给你的主服务器管理员。", "Unable to validate homeserver/identity server": "无法验证主服务器/身份服务器", "Enter the location of your Element Matrix Services homeserver. It may use your own domain name or be a subdomain of element.io.": "输入您的 Element Matrix Services 主服务器的地址。它可能使用您自己的域名,也可能是 element.io 的子域名。", "Enter password": "输入密码", "Nice, strong password!": "不错,是个强密码!", "Password is allowed, but unsafe": "密码允许但不安全", "No identity server is configured so you cannot add an email address in order to reset your password in the future.": "没有配置身份服务器因此您不能添加邮件地址以在将来重置您的密码。", - "Use an email address to recover your account": "使用邮件地址恢复您的账号", + "Use an email address to recover your account": "使用邮件地址恢复你的账号", "Enter email address (required on this homeserver)": "输入邮件地址(此主服务器上必须)", "Doesn't look like a valid email address": "看起来不像有效的邮件地址", "Passwords don't match": "密码不匹配", - "Other users can invite you to rooms using your contact details": "别的用户可以使用您的联系人信息邀请您加入聊天室", + "Other users can invite you to rooms using your contact details": "别的用户可以使用你的联系人信息邀请你加入聊天室", "Enter phone number (required on this homeserver)": "输入电话号码(此主服务器上必须)", "Doesn't look like a valid phone number": "看着不像一个有效的电话号码", "Use lowercase letters, numbers, dashes and underscores only": "仅使用小写字母,数字,横杠和下划线", @@ -2156,7 +2156,7 @@ "Sign in with SSO": "使用单点登录", "No files visible in this room": "此聊天室中没有可见文件", "Welcome to %(appName)s": "欢迎来到 %(appName)s", - "Liberate your communication": "解放您的交流", + "Liberate your communication": "解放你的交流", "Send a Direct Message": "发送私聊", "Explore Public Rooms": "探索公共聊天室", "Create a Group Chat": "创建一个群聊", @@ -2171,7 +2171,7 @@ "View": "查看", "Find a room…": "寻找聊天室…", "Find a room… (e.g. %(exampleRoom)s)": "寻找聊天室... (例如 %(exampleRoom)s)", - "If you can't find the room you're looking for, ask for an invite or Create a new room.": "如果您不能找到您所寻找的聊天室,请索要一个邀请或创建新聊天室。", + "If you can't find the room you're looking for, ask for an invite or Create a new room.": "如果你不能找到你所寻找的聊天室,请索要一个邀请或创建新聊天室。", "Search rooms": "搜索聊天室", "Switch to light mode": "切换到浅色模式", "Switch to dark mode": "切换到深色模式", @@ -2183,7 +2183,7 @@ "Session verified": "会话已验证", "Your Matrix account on ": "您在 上的 Matrix 账户", "No identity server is configured: add one in server settings to reset your password.": "没有配置身份服务器:在服务器设置中添加一个以重设您的密码。", - "You have been logged out of all sessions and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "您已经登出了所有会话,并将不会收到推送通知。要重新启用通知,请在每个设备上重新登录。", + "You have been logged out of all sessions and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "你已经登出了所有会话,并将不会收到推送通知。要重新启用通知,请在每个设备上重新登录。", "Failed to get autodiscovery configuration from server": "从服务器获取自动发现配置时失败", "Invalid base_url for m.homeserver": "m.homeserver 的 base_url 无效", "Homeserver URL does not appear to be a valid Matrix homeserver": "主服务器链接不像是有效的 Matrix 主服务器", @@ -2192,11 +2192,11 @@ "This account has been deactivated.": "此账号已被停用。", "Syncing...": "正在同步...", "Signing In...": "正在登录...", - "If you've joined lots of rooms, this might take a while": "如果您加入了很多聊天室,可能会消耗一些时间", - "Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).": "您的新账号(%(newAccountId)s)已注册,但您已经登录了一个不同的账号(%(loggedInUserId)s)。", + "If you've joined lots of rooms, this might take a while": "如果你加入了很多聊天室,可能会消耗一些时间", + "Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).": "你的新账号(%(newAccountId)s)已注册,但你已经登录了一个不同的账号(%(loggedInUserId)s)。", "Continue with previous account": "用之前的账号继续", - "Log in to your new account.": "登录到您的新账号。", - "You can now close this window or log in to your new account.": "您现在可以关闭此窗口或登录到您的新账号。", + "Log in to your new account.": "登录到你的新账号。", + "You can now close this window or log in to your new account.": "你现在可以关闭此窗口或登录到你的新账号。", "Registration Successful": "注册成功", "Use Recovery Key or Passphrase": "使用恢复密钥或密码", "Use Recovery Key": "使用恢复密钥", @@ -2206,21 +2206,21 @@ "%(brand)s iOS": "%(brand)s iOS", "%(brand)s X for Android": "%(brand)s X for Android", "or another cross-signing capable Matrix client": "或者别的可以交叉签名的 Matrix 客户端", - "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "您的新会话现已被验证。它可以访问您的加密消息,别的用户也会视其为受信任的。", - "Your new session is now verified. Other users will see it as trusted.": "您的新会话现已被验证。别的用户会视其为受信任的。", + "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "你的新会话现已被验证。它可以访问你的加密消息,别的用户也会视其为受信任的。", + "Your new session is now verified. Other users will see it as trusted.": "你的新会话现已被验证。别的用户会视其为受信任的。", "Without completing security on this session, it won’t have access to encrypted messages.": "若不在此会话中完成安全验证,它便不能访问加密消息。", "Failed to re-authenticate due to a homeserver problem": "由于主服务器的问题,重新认证失败", "Failed to re-authenticate": "重新认证失败", - "Regain access to your account and recover encryption keys stored in this session. Without them, you won’t be able to read all of your secure messages in any session.": "重新获得访问您账号的权限,并恢复存储在此会话中的加密密钥。没有这些密钥,您将不能在任何会话中阅读您的所有安全消息。", - "Enter your password to sign in and regain access to your account.": "输入您的密码以登录并重新获取访问您账号的权限。", - "Forgotten your password?": "忘记您的密码了吗?", - "Sign in and regain access to your account.": "请登录以重新获取访问您账号的权限。", - "You cannot sign in to your account. Please contact your homeserver admin for more information.": "您不能登录进您的账号。请联系您的主服务器管理员以获取更多信息。", - "You're signed out": "您已登出", + "Regain access to your account and recover encryption keys stored in this session. Without them, you won’t be able to read all of your secure messages in any session.": "重新获得访问你账号的权限,并恢复存储在此会话中的加密密钥。没有这些密钥,你将不能在任何会话中阅读你的所有安全消息。", + "Enter your password to sign in and regain access to your account.": "输入你的密码以登录并重新获取访问你账号的权限。", + "Forgotten your password?": "忘记你的密码了吗?", + "Sign in and regain access to your account.": "请登录以重新获取访问你账号的权限。", + "You cannot sign in to your account. Please contact your homeserver admin for more information.": "你不能登录进你的账号。请联系你的主服务器管理员以获取更多信息。", + "You're signed out": "你已登出", "Clear personal data": "清除个人信息", - "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "警告:您的个人信息(包括加密密钥)仍存储于此会话中。如果您不用再使用此会话或想登录进另一个账号,请清除它。", + "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "警告:你的个人信息(包括加密密钥)仍存储于此会话中。如果你不用再使用此会话或想登录进另一个账号,请清除它。", "Command Autocomplete": "命令自动补全", - "Community Autocomplete": "社区自动补全", + "Community Autocomplete": "社群自动补全", "DuckDuckGo Results": "DuckDuckGo 结果", "Emoji Autocomplete": "表情符号自动补全", "Notification Autocomplete": "通知自动补全", @@ -2228,32 +2228,32 @@ "User Autocomplete": "用户自动补全", "Confirm encryption setup": "确认加密设置", "Click the button below to confirm setting up encryption.": "点击下方按钮以确认设置加密。", - "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "通过在您的服务器上备份加密密钥来防止丢失您对加密消息和数据的访问权。", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "通过在你的服务器上备份加密密钥来防止丢失你对加密消息和数据的访问权。", "Generate a Security Key": "生成一个安全密钥", - "We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "我们会生成一个安全密钥以便让您存储在安全的地方,比如密码管理器或保险箱里。", + "We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "我们会生成一个安全密钥以便让你存储在安全的地方,比如密码管理器或保险箱里。", "Enter a Security Phrase": "输入一个安全密码", - "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "使用一个只有您知道的密码,您也可以保存安全密钥以供备份使用。", - "Enter your account password to confirm the upgrade:": "输入您的账号密码以确认更新:", - "Restore your key backup to upgrade your encryption": "恢复您的密钥备份以更新您的加密方式", + "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "使用一个只有你知道的密码,你也可以保存安全密钥以供备份使用。", + "Enter your account password to confirm the upgrade:": "输入你的账号密码以确认更新:", + "Restore your key backup to upgrade your encryption": "恢复你的密钥备份以更新你的加密方式", "Restore": "恢复", - "You'll need to authenticate with the server to confirm the upgrade.": "您需要和服务器进行认证以确认更新。", + "You'll need to authenticate with the server to confirm the upgrade.": "你需要和服务器进行认证以确认更新。", "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "更新此会话以允许其验证其他会话、允许其他会话访问加密消息,并将它们对别的用户标记为已信任。", - "Enter a security phrase only you know, as it’s used to safeguard your data. To be secure, you shouldn’t re-use your account password.": "输入一个只有您知道的安全密码,它将被用来保护您的数据。为了安全,您不应该复用您的账号密码。", + "Enter a security phrase only you know, as it’s used to safeguard your data. To be secure, you shouldn’t re-use your account password.": "输入一个只有你知道的安全密码,它将被用来保护你的数据。为了安全,你不应该复用你的账号密码。", "Enter a recovery passphrase": "输入一个恢复密码", "Great! This recovery passphrase looks strong enough.": "棒!这个恢复密码看着够强。", "Use a different passphrase?": "使用不同的密语?", "Enter your recovery passphrase a second time to confirm it.": "再次输入您的恢复密语以确认。", "Confirm your recovery passphrase": "确认您的恢复密语", - "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.": "将您的安全密钥存储在安全的地方,像是密码管理器或保险箱里,它将被用来保护您的加密数据。", + "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.": "将你的安全密钥存储在安全的地方,像是密码管理器或保险箱里,它将被用来保护你的加密数据。", "Copy": "复制", "Unable to query secret storage status": "无法查询秘密存储状态", - "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "如果您现在取消,您可能会丢失加密的消息和数据,如果您丢失了登录信息的话。", - "You can also set up Secure Backup & manage your keys in Settings.": "您也可以在设置中设置安全备份并管理您的密钥。", + "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "如果你现在取消,你可能会丢失加密的消息和数据,如果你丢失了登录信息的话。", + "You can also set up Secure Backup & manage your keys in Settings.": "你也可以在设置中设置安全备份并管理你的密钥。", "Set up Secure backup": "设置安全备份", - "Upgrade your encryption": "更新您的加密方法", + "Upgrade your encryption": "更新你的加密方法", "Set a Security Phrase": "设置一个安全密码", "Confirm Security Phrase": "确认安全密码", - "Save your Security Key": "保存您的安全密钥", + "Save your Security Key": "保存你的安全密钥", "Unable to set up secret storage": "无法设置秘密存储", "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "我们会在服务器上存储一份您的密钥的加密副本。用恢复密码来保护您的备份。", "Set up with a recovery key": "用恢复密钥设置", @@ -2264,13 +2264,13 @@ "Your recovery key": "您的恢复密钥", "Your recovery key has been copied to your clipboard, paste it to:": "您的恢复密钥已被复制到您的剪贴板,将其粘贴至:", "Your recovery key is in your Downloads folder.": "您的恢复密钥在您的下载文件夹里。", - "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.": "若不设置安全消息恢复,您如果登出或使用另一个会话,则将不能恢复您的加密消息历史。", + "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.": "若不设置安全消息恢复,你如果登出或使用另一个会话,则将不能恢复你的加密消息历史。", "Secure your backup with a recovery passphrase": "用恢复密码保护您的备份", "Make a copy of your recovery key": "制作一份您的恢复密钥的副本", "Create key backup": "创建密钥备份", "This session is encrypting history using the new recovery method.": "此会话正在使用新的恢复方法加密历史。", "This session has detected that your recovery passphrase and key for Secure Messages have been removed.": "此会话检测到您的恢复密码和安全消息的密钥已被移除。", - "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.": "如果您出于意外这样做了,您可以在此会话上设置安全消息,以使用新的加密方式重新加密此会话的消息历史。", + "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.": "如果你出于意外这样做了,你可以在此会话上设置安全消息,以使用新的加密方式重新加密此会话的消息历史。", "If disabled, messages from encrypted rooms won't appear in search results.": "如果被禁用,加密聊天室内的消息不会显示在搜索结果中。", "Disable": "禁用", "Not currently indexing messages for any room.": "现在没有为任何聊天室索引消息。", @@ -2329,7 +2329,7 @@ "Securely cache encrypted messages locally for them to appear in search results, using ": "在本地安全缓存已加密消息以使其出现在搜索结果中,使用 ", " to store messages from ": " 存储来自 ", "rooms.": "聊天室的消息。", - "This session is not backing up your keys, but you do have an existing backup you can restore from and add to going forward.": "此会话未备份您的密钥,但您已有备份,您可以继续并从中恢复和向其添加。", + "This session is not backing up your keys, but you do have an existing backup you can restore from and add to going forward.": "此会话未备份你的密钥,但如果你已有现存备份,你可以继续并从中恢复和向其添加。", "Invalid theme schema.": "无效主题方案。", "Read Marker lifetime (ms)": "已读标记生存期 (ms)", "Read Marker off-screen lifetime (ms)": "已读标记屏幕外生存期 (ms)", @@ -2338,11 +2338,11 @@ "Unable to revoke sharing for phone number": "无法撤销电话号码共享", "Mod": "管理员", "Explore public rooms": "探索公共聊天室", - "Can't see what you’re looking for?": "看不到您要找的吗?", + "Can't see what you’re looking for?": "看不到你要找的吗?", "Explore all public rooms": "探索所有公共聊天室", "%(count)s results|other": "%(count)s 个结果", - "You can only join it with a working invite.": "您只能通过有效邀请加入。", - "The session you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "您尝试验证的会话不支持 %(brand)s 支持的扫描二维码或表情符号验证。尝试使用其他客户端。", + "You can only join it with a working invite.": "你只能通过有效邀请加入。", + "The session you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "你尝试验证的会话不支持 %(brand)s 支持的扫描二维码或表情符号验证。尝试使用其他客户端。", "Language Dropdown": "语言下拉菜单", "%(severalUsers)smade no changes %(count)s times|other": "%(severalUsers)s 未做更改 %(count)s 次", "%(severalUsers)smade no changes %(count)s times|one": "%(severalUsers)s 未做更改", @@ -2380,7 +2380,7 @@ "Group call started by %(senderName)s": "%(senderName)s 发起的群通话", "Group call ended by %(senderName)s": "%(senderName)s 结束了群通话", "Unknown App": "未知应用", - "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "社区 v2 原型。需要兼容的主服务器。高度实验性 - 谨慎使用。", + "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "社群 v2 原型。需要兼容的主服务器。高度实验性 - 谨慎使用。", "Cross-signing is ready for use.": "交叉签名已可用。", "Cross-signing is not set up.": "未设置交叉签名。", "Backup version:": "备份版本:", @@ -2396,7 +2396,7 @@ "not ready": "尚未就绪", "Secure Backup": "安全备份", "Privacy": "隐私", - "Explore community rooms": "探索社区聊天室", + "Explore community rooms": "探索社群聊天室", "%(count)s results|one": "%(count)s 个结果", "Room Info": "聊天室信息", "No other application is using the webcam": "没有其他应用程序正在使用摄像头", @@ -2406,7 +2406,7 @@ "Unable to access webcam / microphone": "无法访问摄像头/麦克风", "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "呼叫失败,因为无法访问任何麦克风。 检查是否已插入麦克风并正确设置。", "Unable to access microphone": "无法使用麦克风", - "The call was answered on another device.": "在另一台设备上应答了该通话。", + "The call was answered on another device.": "在另一台设备上应答了此通话。", "The call could not be established": "无法建立通话", "The other party declined the call.": "对方拒绝了通话。", "Call Declined": "通话被拒绝", @@ -2471,18 +2471,18 @@ "Afghanistan": "阿富汗", "United States": "美国", "United Kingdom": "英国", - "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "您的主服务器已拒绝您的登入尝试。请重试。如果此情况持续发生,请联系您的主服务器管理员。", - "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "无法访问您的主服务器,因而无法登入。请重试。如果此情况持续发生,请联系您的主服务器管理员。", + "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "你的主服务器已拒绝你的登入尝试。请重试。如果此情况持续发生,请联系你的主服务器管理员。", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "无法访问你的主服务器,因而无法登入。请重试。如果此情况持续发生,请联系你的主服务器管理员。", "Try again": "重试", - "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "我们已要求浏览器记住您使用的主服务器,但不幸的是您的浏览器已忘记。请前往登录页面重试。", - "We couldn't log you in": "我们无法使您登入", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "我们已要求浏览器记住你使用的主服务器,但不幸的是你的浏览器已忘记。请前往登录页面重试。", + "We couldn't log you in": "我们无法使你登入", "This will end the conference for everyone. Continue?": "这将结束所有人的会议。是否继续?", "End conference": "结束会议", - "You've reached the maximum number of simultaneous calls.": "您已达到并行呼叫最大数量。", + "You've reached the maximum number of simultaneous calls.": "你已达到并行呼叫最大数量。", "Too Many Calls": "太多呼叫", "Call failed because webcam or microphone could not be accessed. Check that:": "通话失败,因为无法访问网络摄像头或麦克风。请检查:", "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "呼叫失败,因为无法访问任何麦克风。 检查是否已插入麦克风并正确设置。", - "Answered Elsewhere": "在其他地方已回答", + "Answered Elsewhere": "已在其他地方回答", "Use the + to make a new room or explore existing ones below": "使用 + 创建新的聊天室或通过下面列出的方式探索已有聊天室", "Start a new chat": "开始新会话", "Room settings": "聊天室设置", @@ -2497,10 +2497,10 @@ "Creating...": "创建中……", "You can change these at any point.": "您可随时更改这些。", "Give it a photo, name and description to help you identify it.": "为它添加一张照片、姓名与描述来帮助您辨认它。", - "Your private space": "您的私有空间", - "Your public space": "您的公共空间", - "You can change this later": "您可稍后更改此项", - "Invite only, best for yourself or teams": "仅邀请,适合您自己或团队", + "Your private space": "你的私有空间", + "Your public space": "你的公共空间", + "You can change this later": "你可稍后更改此项", + "Invite only, best for yourself or teams": "仅邀请,适合你自己或团队", "Private": "私有", "Public": "公共", "Delete": "删除", @@ -2511,8 +2511,8 @@ "Voice Call": "语音通话", "Video Call": "视频通话", "%(peerName)s held the call": "%(peerName)s 挂起了通话", - "You held the call Resume": "您挂起了通话 恢复", - "You held the call Switch": "您挂起了通话 切换", + "You held the call Resume": "你挂起了通话 恢复", + "You held the call Switch": "你挂起了通话 切换", "Takes the call in the current room off hold": "解除挂起当前聊天室的通话", "Places the call in the current room on hold": "挂起当前聊天室的通话", "Show chat effects (animations when receiving e.g. confetti)": "显示聊天特效(如收到五彩纸屑时的动画效果)", @@ -2526,7 +2526,7 @@ "Show stickers button": "显示贴纸按钮", "Render LaTeX maths in messages": "在信息中渲染 LaTeX 数学", "%(senderName)s ended the call": "%(senderName)s 结束了通话", - "You ended the call": "您结束了通话", + "You ended the call": "你结束了通话", "This homeserver has been blocked by it's administrator.": "此 homeserver 已被其管理员屏蔽。", "Use app": "使用 app", "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element 网页版在移动设备上仍处于试验阶段。使用免费的原生 app 以获得更好的体验与最新的功能。", @@ -2534,7 +2534,7 @@ "Enable desktop notifications": "开启桌面通知", "Don't miss a reply": "不要错过任何回复", "This homeserver has been blocked by its administrator.": "此 homeserver 已被其管理员屏蔽。", - "Send stickers to this room as you": "以您的身份发送贴纸到此聊天室", + "Send stickers to this room as you": "以你的身份发送贴纸到此聊天室", "Change the avatar of your active room": "更改活跃聊天室的头像", "Change the avatar of this room": "更改当前聊天室的头像", "Change the name of your active room": "更改活跃聊天室的名称", @@ -2543,10 +2543,10 @@ "Change the topic of this room": "更改当前聊天室的话题", "Change which room, message, or user you're viewing": "更改当前正在查看哪个聊天室、消息或用户", "Change which room you're viewing": "更改当前正在查看哪个聊天室", - "Send stickers into your active room": "发送贴纸到您的活跃聊天室", + "Send stickers into your active room": "发送贴纸到你的活跃聊天室", "Send stickers into this room": "发送贴纸到此聊天室", - "Remain on your screen while running": "运行时始终保留在您的屏幕上", - "Remain on your screen when viewing another room, when running": "运行时始终保留在您的屏幕上,即使您在浏览其它聊天室", + "Remain on your screen while running": "运行时始终保留在你的屏幕上", + "Remain on your screen when viewing another room, when running": "运行时始终保留在你的屏幕上,即使你在浏览其它聊天室", "%(senderName)s declined the call.": "%(senderName)s 拒绝了通话。", "(an error occurred)": "(发生了一个错误)", "(their device couldn't start the camera / microphone)": "(对方的设备无法开启摄像头/麦克风)", @@ -2554,12 +2554,12 @@ "Converts the DM to a room": "将此私聊会话转化为聊天室会话", "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "在纯文本消息开头添加 ┬──┬ ノ( ゜-゜ノ)", "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "在纯文本消息开头添加 (╯°□°)╯︵ ┻━┻", - "You're already in a call with this person.": "您与此人已处在通话中。", + "You're already in a call with this person.": "你正在与其通话中。", "Already in call": "已在通话中", "Navigate composer history": "浏览编辑区历史", "Go to Home View": "转到主视图", "Search (must be enabled)": "搜索(必须启用)", - "Your Security Key": "您的安全密钥", + "Your Security Key": "你的安全密钥", "Use Security Key": "使用安全密钥", "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s 或 %(usernamePassword)s", "User settings": "用户设置", @@ -2575,7 +2575,7 @@ "Remove from Space": "从空间中移除", "Undo": "撤销", "Welcome %(name)s": "欢迎 %(name)s", - "Create community": "创建社区", + "Create community": "创建社群", "Forgot password?": "忘记密码?", "Enter Security Key": "输入安全密钥", "Invalid Security Key": "安全密钥无效", @@ -2603,7 +2603,7 @@ "Value": "值", "Setting ID": "设置 ID", "Enter name": "输入名称", - "Community ID: +:%(domain)s": "社区 ID:+:%(domain)s", + "Community ID: +:%(domain)s": "社群 ID:+:%(domain)s", "Reason (optional)": "理由(可选)", "Show": "显示", "Apply": "应用", @@ -2634,7 +2634,7 @@ "Send message": "发送消息", "Invite to this space": "邀请至此空间", "Your message was sent": "消息已发送", - "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.": "请使用您的账号数据备份加密密钥,以免您无法访问您的会话。密钥将通过一个唯一的安全密钥进行保护。", + "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.": "请使用你的账号数据备份加密密钥,以免你无法访问你的会话。密钥将通过一个唯一的安全密钥进行保护。", "Spell check dictionaries": "拼写检查字典", "Failed to save your profile": "个人资料保存失败", "The operation could not be completed": "操作无法完成", @@ -2654,11 +2654,11 @@ "Sends the given message with fireworks": "附加烟火发送", "sends fireworks": "发送烟火", "Offline encrypted messaging using dehydrated devices": "需要离线设备(dehydrated devices)的加密消息离线传递", - "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "正在开发的空间功能的原型。与社区、社区 V2 和自定义标签功能不兼容。需要主服务器兼容才能使用某些功能。", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "正在开发的空间功能的原型。与社群、社群 V2 和自定义标签功能不兼容。需要主服务器兼容才能使用某些功能。", "The %(capability)s capability": "%(capability)s 容量", "%(senderName)s has updated the widget layout": "%(senderName)s 已更新挂件布局", "Support": "支持", - "Your server does not support showing space hierarchies.": "您的服务器不支持显示空间层次结构。", + "Your server does not support showing space hierarchies.": "你的服务器不支持显示空间层次结构。", "This version of %(brand)s does not support searching encrypted messages": "当前版本的 %(brand)s 不支持搜索加密消息", "This version of %(brand)s does not support viewing some encrypted files": "当前版本的 %(brand)s 不支持查看某些加密文件", "Effects": "效果", @@ -2760,7 +2760,7 @@ "Explore rooms in %(communityName)s": "在 %(communityName)s 中探索聊天室", "%(count)s messages deleted.|one": "已删除 %(count)s 条消息。", "%(count)s messages deleted.|other": "已删除 %(count)s 条消息。", - "Cannot create rooms in this community": "无法在此社区中创建聊天室", + "Cannot create rooms in this community": "无法在此社群中创建聊天室", "Upgrade to %(hostSignupBrand)s": "升级至 %(hostSignupBrand)s", "Enter phone number": "输入电话号码", "Enter email address": "输入邮箱地址", @@ -2769,13 +2769,13 @@ "Revoke permissions": "撤销权限", "Take a picture": "拍照", "Enter Security Phrase": "输入安全密语", - "Allow this widget to verify your identity": "允许此挂件验证您的身份", + "Allow this widget to verify your identity": "允许此挂件验证你的身份", "Decline All": "全部拒绝", "Approve": "批准", "This widget would like to:": "此挂件想要:", "Approve widget permissions": "批准挂件权限", "Failed to save space settings.": "空间设置保存失败。", - "Sign into your homeserver": "登录您的主服务器", + "Sign into your homeserver": "登录你的主服务器", "Unable to validate homeserver": "无法验证主服务器", "Invalid URL": "URL 无效", "Modal Widget": "模态框挂件(Modal Widget)", @@ -2793,24 +2793,24 @@ "Widgets": "挂件", "This is the start of .": "这里是 的开始。", "Add a photo, so people can easily spot your room.": "添加图片,让人们一眼就能看到你的聊天室。", - "You can change these anytime.": "您随时可以更改它们。", - "Add some details to help people recognise it.": "添加一些细节,以便人们辨识你的社区。", - "Open space for anyone, best for communities": "适合每一个人的开放空间,社区的理想选择", + "You can change these anytime.": "你随时可以更改它们。", + "Add some details to help people recognise it.": "添加一些细节,以便人们辨识你的社群。", + "Open space for anyone, best for communities": "适合每一个人的开放空间,社群的理想选择", "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "空间是为房间和人员分组的新方法。要加入现有的空间,您需要被邀请。", "From %(deviceName)s (%(deviceId)s) at %(ip)s": "来自 %(deviceName)s(%(deviceId)s)于 %(ip)s", "New version of %(brand)s is available": "%(brand)s 有新版本可用", - "You have unverified logins": "您有未验证的登录", + "You have unverified logins": "你有未验证的登录", "You should know": "你应当知道", "Learn more in our , and .": "请通过我们的了解更多信息。", - "Failed to connect to your homeserver. Please close this dialog and try again.": "无法连接至您的主服务器。请关闭此对话框并再试一次。", - "Are you sure you wish to abort creation of the host? The process cannot be continued.": "您确定要放弃创建主机吗?被放弃的创建流程将无法再继续。", + "Failed to connect to your homeserver. Please close this dialog and try again.": "无法连接至你的主服务器。请关闭此对话框并再试一次。", + "Are you sure you wish to abort creation of the host? The process cannot be continued.": "你确定要放弃创建主机吗?被放弃的创建流程将无法再继续。", "Confirm abort of host creation": "确定放弃创建主机", "Please view existing bugs on Github first. No match? Start a new one.": "请先查找一下 Github 上已有的问题,以免重复。找不到重复问题?发起一个吧。", - "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "专业建议:如果您要发起新问题,请一并提交调试日志,以便我们找出问题根源。", + "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "专业建议:如果你要发起新问题,请一并提交调试日志,以便我们找出问题根源。", "There are two ways you can provide feedback and help us improve %(brand)s.": "有两种方式可以提供反馈,并帮助我们改进 %(brand)s。", "Please go into as much detail as you like, so we can track down the problem.": "请按照你的意愿,尽可能详细地描述问题,以便我们找出问题根源。", - "Tell us below how you feel about %(brand)s so far.": "请在下面告诉我们直到目前为止您使用 %(brand)s 的感受。", - "There was an error updating your community. The server is unable to process your request.": "更新你的社区时出现错误。服务器无法处理你的请求。", + "Tell us below how you feel about %(brand)s so far.": "请在下面告诉我们直到目前为止你使用 %(brand)s 的感受。", + "There was an error updating your community. The server is unable to process your request.": "更新你的社群时出现错误。服务器无法处理你的请求。", "Values at explicit levels": "各层级的值", "Values at explicit levels:": "各层级的值:", "Values at explicit levels in this room": "此聊天室中各层级的值", @@ -2824,12 +2824,12 @@ "Value in this room": "此聊天室中的值", "Settings Explorer": "设置浏览器", "with state key %(stateKey)s": "附带有状态键(state key)%(stateKey)s", - "Your server requires encryption to be enabled in private rooms.": "您的服务器要求私人房间启用加密。", - "An image will help people identify your community.": "图片可以让人们辨识您的社区。", - "What's the name of your community or team?": "你的社区或者团队的名称是什么?", - "You can change this later if needed.": "如果需要,您可以稍后更改。", - "Use this when referencing your community to others. The community ID cannot be changed.": "在将您的社区推荐给其他人时使用此 ID,社区 ID 不能更改。", - "There was an error creating your community. The name may be taken or the server is unable to process your request.": "创建社区时发生错误。名称可能已被使用,或者服务器无法处理您的请求。", + "Your server requires encryption to be enabled in private rooms.": "你的服务器要求私人房间启用加密。", + "An image will help people identify your community.": "图片可以让人们辨识你的社群。", + "What's the name of your community or team?": "你的社群或者团队的名称是什么?", + "You can change this later if needed.": "如果需要,你可以稍后更改。", + "Use this when referencing your community to others. The community ID cannot be changed.": "在将你的社群推荐给其他人时使用此 ID,社群 ID 不能更改。", + "There was an error creating your community. The name may be taken or the server is unable to process your request.": "创建社群时发生错误。名称可能已被使用,或者服务器无法处理你的请求。", "Invite people to join %(communityName)s": "邀请人们加入 %(communityName)s", "Send %(count)s invites|one": "发送 %(count)s 个邀请", "Send %(count)s invites|other": "发送 %(count)s 个邀请", @@ -2864,7 +2864,7 @@ "Add comment": "添加备注", "Rate %(brand)s": "评价 %(brand)s", "Feedback sent": "反馈已发送", - "Update community": "更新社区", + "Update community": "更新社群", "Failed to save settings": "设置保存失败", "Create a room in %(communityName)s": "在 %(communityName)s 中创建聊天室", "Add image (optional)": "添加图片(可选)", @@ -2879,7 +2879,7 @@ "Invite with email or username": "使用邮箱或者用户名邀请", "Invite people": "邀请人们", "Update %(brand)s": "更新 %(brand)s", - "Check your devices": "检查您的设备", + "Check your devices": "检查你的设备", "Zimbabwe": "津巴布韦", "Zambia": "赞比亚", "Western Sahara": "西撒哈拉", @@ -2960,16 +2960,16 @@ "Ignored attempt to disable encryption": "已忽略禁用加密的尝试", "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "此聊天室中的消息已被端对端加密。当人们加入,你可以点击他们的头像,在他们的资料中验证他们。", "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "此处的消息已被端对端加密。请点击对方头像,在其资料中验证 %(displayName)s。", - "Secure your backup with a Security Phrase": "使用安全密语保护您的备份", - "Confirm your Security Phrase": "确认您的安全密语", + "Secure your backup with a Security Phrase": "使用安全密语保护你的备份", + "Confirm your Security Phrase": "确认你的安全密语", "Verify with another session": "使用另一个会话验证", "Use Security Key or Phrase": "使用安全密钥或密语", "Continue with %(ssoButtons)s": "使用 %(ssoButtons)s 继续", "There was a problem communicating with the homeserver, please try again later.": "与主服务器通讯时出现问题,请稍后再试。", "Decrypted event source": "解密事件源码", "Original event source": "原始事件源码", - "Community and user menu": "社区与用户菜单", - "Community settings": "社区设置", + "Community and user menu": "社群与用户菜单", + "Community settings": "社群设置", "Invite by username": "按照用户名邀请", "Inviting...": "正在邀请…", "Welcome to ": "欢迎来到 ", @@ -3013,5 +3013,290 @@ "Guyana": "圭亚那", "Guinea-Bissau": "几内亚比绍", "Guinea": "几内亚", - "Guernsey": "根西岛" + "Guernsey": "根西岛", + "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "你可以启用此选项如果此聊天室将仅用于你的主服务器上的内部团队协作。此选项之后无法更改。", + "Unable to access secret storage. Please verify that you entered the correct Security Phrase.": "无法访问秘密存储。请确认你输入了正确的恢复密码。", + "Backup could not be decrypted with this Security Key: please verify that you entered the correct Security Key.": "无法使用此安全密钥解密备份:请检查你输入的安全密钥是否正确。", + "sends space invaders": "发送空间入侵者", + "This session has detected that your Security Phrase and key for Secure Messages have been removed.": "此会话已检测到你的安全短语和安全消息密钥被移除。", + "A new Security Phrase and key for Secure Messages have been detected.": "检测到新的安全短语和安全信息密钥。", + "Make a copy of your Security Key": "复制你的安全密钥", + "Your Security Key is in your Downloads folder.": "你的安全密钥在你的下载文件夹中。", + "Your Security Key has been copied to your clipboard, paste it to:": "你的安全密钥已 复制到你的粘贴板,将其粘贴至:", + "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.": "你的安全密钥是一张安全网——如果你忘记了你的安全短语的话,你可以用它来恢复你对已加密消息的访问权。", + "Repeat your Security Phrase...": "重复你的安全短语……", + "Enter your Security Phrase a second time to confirm it.": "再次输入你的安全短语进行确认。", + "Set up with a Security Key": "设置安全密钥", + "Great! This Security Phrase looks strong enough.": "棒!这个安全短语看着够强。", + "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "我们会将你的密钥的加密副本保存在我们的服务器上。使用安全短语来保证你的备份。", + "Space Autocomplete": "空间自动完成", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "未经验证,你将无法获取你的所有信息,且可能不被他人所信任。", + "Verify your identity to access encrypted messages and prove your identity to others.": "验证你的身份来获取已加密的消息并向其他人证明你的身份。", + "Use another login": "使用其他账号登录", + "Decide where your account is hosted": "决定账号托管位置", + "Host account on": "账号托管于", + "Already have an account? Sign in here": "已有账号?在此登录", + "That username already exists, please try another.": "用户名已存在,请试试别的。", + "New? Create account": "新来的?创建账号", + "Please choose a strong password": "请选择强密码", + "New here? Create an account": "新来的?创建账号", + "Got an account? Sign in": "有账号了?登录", + "Failed to find the general chat for this community": "找不到此社群的一般性聊天记录", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "我们将会为每个主题创建一个聊天室。你也可以稍后再进行添加,包括现有聊天室。", + "What projects are you working on?": "你正在从事哪些项目?", + "You can add more later too, including already existing ones.": "稍后你可以添加更多聊天室,包括现有的。", + "Let's create a room for each of them.": "让我们未每个主题都创建一个聊天室吧。", + "What are some things you want to discuss in %(spaceName)s?": "你想在 %(spaceName)s 中讨论什么?", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "这是实验性功能。当前收到邀请的新用户必须在上开启邀请才能真正加入。", + "Make sure the right people have access. You can invite more later.": "确保对的人可以访问。稍后你可以邀请更多人。", + "Invite your teammates": "邀请你的伙伴", + "Failed to invite the following users to your space: %(csvUsers)s": "邀请以下用户加入你的空间失败:%(csvUsers)s", + "A private space for you and your teammates": "供你和你的伙伴使用的私有空间", + "Me and my teammates": "我和我的伙伴", + "A private space to organise your rooms": "用于整理你聊天室的私有空间", + "Just me": "仅有我", + "Make sure the right people have access to %(name)s": "确保对的人有权访问 %(name)s", + "Who are you working with?": "你与谁一同工作?", + "Go to my space": "前往我的空间", + "Go to my first room": "前往我的第一个聊天室", + "It's just you at the moment, it will be even better with others.": "当前仅有你一人,与人同道而行会更好。", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "选择要添加的聊天室或对话。这是专属于你的空间,不会有人被通知。你稍后可以再增加更多。", + "Select a room below first": "首先选择一个聊天室", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s 个聊天室和 %(numSpaces)s 个空间", + "This room is suggested as a good one to join": "此聊天室很适合加入", + "You can select all or individual messages to retry or delete": "你可以选择全部或单独的消息来重试或删除", + "Sending": "正在发送", + "Delete all": "删除全部", + "Some of your messages have not been sent": "你的部分消息未被发送", + "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "你的消息未被发送,因为此主服务器已被其管理员封禁。请联络你的服务管理员已继续使用服务。", + "Filter all spaces": "过滤所有空间", + "You have no visible notifications.": "你没有可见的通知。", + "Communities are changing to Spaces": "社群正在转变为空间", + "%(creator)s created this DM.": "%(creator)s 创建了此私聊。", + "Verification requested": "已请求验证", + "Security Key mismatch": "安全密钥不符", + "Unable to set up keys": "无法设置密钥", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "如果你全部重置,你将会在没有受信任的会话重新开始、没有受信任的用户,且可能会看不到过去的消息。", + "Only do this if you have no other device to complete verification with.": "当你没有其他设备可以用于完成验证时,方可执行此操作。", + "Reset everything": "全部重置", + "Forgotten or lost all recovery methods? Reset all": "忘记或丢失了所有恢复方式?全部重置", + "Remember this": "记住", + "The widget will verify your user ID, but won't be able to perform actions for you:": "挂件将会验证你的用户 ID,但将无法为你执行动作:", + "Verify other login": "验证其他登录", + "Make this space private": "将此空间设为私有", + "Edit settings relating to your space.": "编辑关于你的空间的设置。", + "Reset event store": "重置活动存储", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "如果这样做,请注意你的信息并不会被删除,但在重新建立索引是,搜索体验可能会退步一些", + "You most likely do not want to reset your event index store": "你大概率不想重置你的活动缩影存储", + "Reset event store?": "重置活动存储?", + "Use your preferred Matrix homeserver if you have one, or host your own.": "如果你可以使用自己所偏好的 Matrix 主服务器,或是自己搭建一个。", + "We call the places where you can host your account ‘homeservers’.": "我们将你可以托管账号的地方称为「主服务器」。", + "Matrix.org is the biggest public homeserver in the world, so it’s a good place for many.": "Matrix.org 是世界上最大的公开主服务器,因此对于许多人而言是个好地方。", + "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)s 遇到问题,请回报错误。", + "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.": "请注意,如果你不添加电子邮箱并且忘记密码,你将永远失去对你账号的访问权。", + "Continuing without email": "不使用电子邮箱并继续", + "We recommend you change your password and Security Key in Settings immediately": "我们建议你立即在设置中更改你的密码和安全密钥", + "Data on this screen is shared with %(widgetDomain)s": "在此画面上的资料会与 %(widgetDomain)s 分享", + "Consult first": "先询问", + "Invited people will be able to read old messages.": "被邀请的人将能够阅读过去的消息。", + "Invite someone using their name, username (like ) or share this room.": "使用某人的名字、用户名(如 )或分享此聊天室来邀请他们。", + "Invite someone using their name, email address, username (like ) or share this room.": "使用某人的名字、电子邮箱地址或用户名来与他们开始对话(如 )或分享此聊天室。", + "Invite someone using their name, username (like ) or share this space.": "使用某人的名字、用户名(如 )邀请他们,或分享此空间。", + "Invite someone using their name, email address, username (like ) or share this space.": "使用某人的名字、电子邮箱地址或用户名(如 )邀请他们,或分享此空间。", + "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click here": "这不会邀请他们加入 %(communityName)s。要邀请某人加入 %(communityName)s,请点击这里", + "Start a conversation with someone using their name or username (like ).": "使用某人的名字或用户名开始与其进行对话(如 )。", + "Start a conversation with someone using their name, email address or username (like ).": "使用某人的名称、电子邮箱地址或用户名来与其开始对话(如 )。", + "May include members not in %(communityName)s": "可能不包括 %(communityName)s 中的成员", + "A call can only be transferred to a single user.": "通话只能转移到单个用户。", + "We couldn't create your DM.": "我们无法创建你的私聊。", + "Continuing temporarily allows the %(hostSignupBrand)s setup process to access your account to fetch verified email addresses. This data is not stored.": "继续暂时允许让 %(hostSignupBrand)s 安装过程可以访问你的账号以获取已验证电子邮箱地址。此数据不保存。", + "Block anyone not part of %(serverName)s from ever joining this room.": "阻住任何不属于 %(serverName)s 的人加入此聊天室。", + "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "如果聊天室用于与自己的主服务器外的团队进行协助的话,可以停用此功能。这将无法在稍后进行更改。", + "What do you want to organise?": "你想要组织什么?", + "Skip for now": "暂时跳过", + "Failed to create initial space rooms": "创建初始空间聊天室失败", + "To join %(spaceName)s, turn on the Spaces beta": "加入 %(spaceName)s 前,请开启空间测试版", + "To view %(spaceName)s, turn on the Spaces beta": "查看 %(spaceName)s 前,请开启空间测试版", + "Private space": "私有空间", + "Public space": "公开空间", + "Spaces are a beta feature.": "空间为测试版功能。", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "如果你找不到正在寻找的聊天室,请请求邀请或创建一个新的聊天室。", + "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone.": "私人聊天室仅能通过邀请找到与加入。公开聊天室则能够被所有人找到并加入。", + "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone in this community.": "私人聊天室仅能通过邀请找到与加入。公开聊天室则能够被所有在此社群的人找到并加入。", + "Search names and descriptions": "搜索名称和描述", + "You may want to try a different search or check for typos.": "你可能要尝试其他搜索或检查是否有错别字。", + "You may contact me if you have any follow up questions": "如果你有任何后续问题,可以联系我", + "To leave the beta, visit your settings.": "要退出测试版,请访问你的设置。", + "Your platform and username will be noted to help us use your feedback as much as we can.": "我们将会记录你的平台及用户名,以帮助我们尽我们所能地使用你的反馈。", + "%(featureName)s beta feedback": "%(featureName)s 测试版反馈", + "Thank you for your feedback, we really appreciate it.": "感谢你的反馈,我们衷心地感谢。", + "Beta feedback": "测试版反馈", + "Want to add a new room instead?": "想要添加一个新的聊天室吗?", + "Add existing rooms": "添加现有聊天室", + "You can add existing spaces to a space.": "你可以添加现有空间到另一空间中。", + "Feeling experimental?": "想要来点实验吗?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "正在新增聊天室……", + "Adding rooms... (%(progress)s out of %(count)s)|other": "正在新增聊天室……(%(count)s 中的第 %(progress)s 个)", + "Not all selected were added": "并非所有选中的都被添加", + "You are not allowed to view this server's rooms list": "你不被允许查看此服务器的聊天室列表", + "Sends the given message with a space themed effect": "此消息带有空间主题化效果", + "Kick, ban, or invite people to your active room, and make you leave": "移除、封禁或邀请人们到你所活跃的聊天室,并让你离开", + "Sends the given message as a spoiler": "此消息包含剧透", + "Retry all": "全部重试", + "View message": "查看消息", + "Zoom in": "放大", + "Zoom out": "缩小", + "%(count)s people you know have already joined|one": "已有你所认识的 %(count)s 个人加入", + "%(count)s people you know have already joined|other": "已有你所认识的 %(count)s 个人加入", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s 位成员中包括 %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "包括 %(commaSeparatedMembers)s", + "View all %(count)s members|one": "查看 1 位成员", + "View all %(count)s members|other": "查看全部 %(count)s 位成员", + "Use the Desktop app to search encrypted messages": "使用桌面端英语来搜索加密消息", + "Use the Desktop app to see all encrypted files": "使用桌面端应用来查看所有加密文件", + "Add reaction": "添加回应", + "Error processing voice message": "处理语音消息时发生错误", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "当你将自己降级后,你将无法撤销此更改。如果你是此空间的最后一名拥有权限的用户,则无法重新获得权限。", + "Unpin a widget to view it in this panel": "取消固定挂件以在此面板中查看", + "You can only pin up to %(count)s widgets|other": "你仅能固定 %(count)s 个挂件", + "Accept on your other login…": "接受你的其他登录……", + "Delete recording": "删除录制", + "Stop the recording": "停止录制", + "Record a voice message": "录制语音消息", + "We didn't find a microphone on your device. Please check your settings and try again.": "我们没能在你的设备上找到麦克风。请检查设置并重试。", + "No microphone found": "无法发现麦克风", + "We were unable to access your microphone. Please check your browser settings and try again.": "我们无法访问你的麦克风。 请检查浏览器设置并重试。", + "Unable to access your microphone": "无法访问你的麦克风", + "%(count)s results in all spaces|one": "在所有空间中有 %(count)s 个结果", + "%(count)s results in all spaces|other": "在所有空间中有 %(count)s 个结果", + "Quick actions": "快捷操作", + "You do not have permissions to add rooms to this space": "你没有权限添加聊天室至此空间", + "You do not have permissions to create new rooms in this space": "你没有权限在此空间内创建新的聊天室", + "Invite to just this room": "仅邀请至此聊天室", + "This is the beginning of your direct message history with .": "这是你与 间进行私聊的历史记录的开始。", + "Only the two of you are in this conversation, unless either of you invites anyone to join.": "除非你们其中一个邀请了别人加入,否则将仅有你们两个人在此对话中。", + "%(seconds)ss left": "剩余 %(seconds)s 秒", + "Failed to send": "发送失败", + "Change server ACLs": "更改服务器访问控制列表", + "You have no ignored users.": "你没有设置忽略用户。", + "Warn before quitting": "退出前警告", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "想要来点实验?实验室是提前体验、测试新功能并在它们正式发布前帮助它们定型的最佳方式。了解更多。", + "Your access token gives full access to your account. Do not share it with anyone.": "你的访问令牌可以完全访问你的帐户。不要将其与任何人分享。", + "Access Token": "访问令牌", + "Message search initialisation failed": "消息搜索初始化失败", + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|one": "使用 %(size)s 来存储来自 %(rooms)s 聊天室的消息。在本地安全地缓存已加密的消息以使其出现在搜索结果中。", + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|other": "使用 %(size)s 来存储来自 %(rooms)s 聊天室的消息。在本地安全地缓存已加密的消息以使其出现在搜索结果中。", + "Manage & explore rooms": "管理并探索聊天室", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "空间是一种将聊天室和人们进行分组的新方法。你需要得到邀请方可加入现有空间。", + "Please enter a name for the space": "请输入空间名称", + "Play": "播放", + "Pause": "暂停", + "%(name)s on hold": "保留 %(name)s", + "Connecting": "连接中", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "与 %(transferTarget)s 进行协商。转让至 %(transferee)s", + "unknown person": "陌生人", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "允许在一对一通话中使用点对点通讯(如果你启用此功能,对方可能会看到你的 IP 地址)", + "Send and receive voice messages": "发送并接收语音消息", + "Show options to enable 'Do not disturb' mode": "显示启用「请勿打扰」模式的选项", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "你的反馈将帮助空间变得更好。你能讲得越仔细越好。", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "测试版适用于网页端、桌面端以及安卓端。但在你的主服务器上有些特性可能不可用。", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "你随时可以在设置中退出测试版,或轻点如上所示的测试版徽章。", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s 将在空间启用时重载。社群和自定义标签将被隐藏。", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "测试版适用于网页端、桌面端以及安卓端。感谢你试用测试版。", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "如果你离开,%(brand)s 将会在空间禁用时重载。社群和自定义标记将再次可见。", + "Spaces are a new way to group rooms and people.": "空间是一种将聊天室与人们进行分组的新方式。", + "%(deviceId)s from %(ip)s": "来自 %(ip)s 的 %(deviceId)s", + "Review to ensure your account is safe": "检查以确保你的账号是安全的", + "See %(msgtype)s messages posted to your active room": "查看发布到你所活跃的聊天室的 %(msgtype)s 消息", + "See %(msgtype)s messages posted to this room": "查看发布到此聊天室的 %(msgtype)s 消息", + "Send %(msgtype)s messages as you in your active room": "在你所活跃的聊天室以你的身份发送 %(msgtype)s 消息", + "Send %(msgtype)s messages as you in this room": "在此聊天室以你的身份发送 %(msgtype)s 消息", + "See general files posted to your active room": "查看发布到你所活跃的聊天室的一般性文件", + "See general files posted to this room": "查看发布到此聊天室的一般性文件", + "Send general files as you in your active room": "在你所活跃的聊天室以你的身份发送一般性文件", + "Are you sure you want to leave the space '%(spaceName)s'?": "你确定要离开空间「%(spaceName)s」吗?", + "This space is not public. You will not be able to rejoin without an invite.": "此空间并不公开。在没有得到邀请的情况下,你将无法重新加入。", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "你是这里唯一的人。如果你离开了,以后包括你以在内任何人都将无法加入。", + "You do not have permission to create rooms in this community.": "你没有在此社群中建立聊天室的权限。", + "Now, let's help you get started": "现在,让我们协助你开始", + "Add a photo so people know it's you.": "添加照片,让人们知道这是你。", + "Great, that'll help people know it's you": "很好,这样大家就知道是你了", + "

    HTML for your community's page

    \n

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

    \n

    \n You can even add images with Matrix URLs \n

    \n": "

    社群页面的 HTML

    \n

    \n 使用详细秒速向社群介绍新成员,或分发\n 一些重要链接\n

    \n

    \n 你甚至可以使用 Matrix 链接 新增图片\n

    \n", + "Use email to optionally be discoverable by existing contacts.": "使用电子邮箱以选择性地被现有联系人搜索。", + "Use email or phone to optionally be discoverable by existing contacts.": "使用电子邮箱或电话以选择性地被现有联系人搜索。", + "Add an email to be able to reset your password.": "添加电子邮箱以重置你的密码。", + "That phone number doesn't look quite right, please check and try again": "电话号码看起来不太对,请检查并重试", + "Something went wrong in confirming your identity. Cancel and try again.": "确认你的身份时出了一点问题。取消并重试。", + "Open the link in the email to continue registration.": "打开电子邮件中的链接以继续注册。", + "A confirmation email has been sent to %(emailAddress)s": "确认电子邮件以发送至 %(emailAddress)s", + "Avatar": "头像", + "Join the beta": "加入测试版", + "Leave the beta": "退出测试版", + "Beta": "测试版", + "Tap for more info": "点击以获取更多信息", + "Spaces is a beta feature": "空间为测试功能", + "Start audio stream": "开始音频流", + "Failed to start livestream": "开始流直播失败", + "Unable to start audio streaming.": "无法开始音频流媒体。", + "Hold": "挂起", + "Resume": "恢复", + "If you've forgotten your Security Key you can ": "如果你忘记了你的安全密钥,你可以", + "Access your secure message history and set up secure messaging by entering your Security Key.": "通过输入你的安全密钥来访问你的安全消息历史记录并设置安全通信。", + "Not a valid Security Key": "安全密钥无效", + "This looks like a valid Security Key!": "看起来是有效的安全密钥!", + "If you've forgotten your Security Phrase you can use your Security Key or set up new recovery options": "如果你忘记了你的安全短语,你可以使用你的安全密钥设置新的恢复选项", + "Access your secure message history and set up secure messaging by entering your Security Phrase.": "无法通过你的安全短语访问你的安全消息历史记录并设置安全通信。", + "Backup could not be decrypted with this Security Phrase: please verify that you entered the correct Security Phrase.": "无法使用此安全短语解密备份:请确认你是否输入了正确的安全短语。", + "Incorrect Security Phrase": "安全短语错误", + "Send general files as you in this room": "查看发布到此聊天室的一般性文件", + "See videos posted to your active room": "查看发布到你所活跃的聊天室的视频", + "See videos posted to this room": "查看发布到此聊天室的视频", + "Send videos as you in your active room": "查看发布到你所活跃的聊天室的视频", + "Send videos as you in this room": "查看发布到此聊天室的视频", + "See images posted to your active room": "查看发布到你所活跃的聊天室的图片", + "See images posted to this room": "查看发布到此聊天室的图片", + "Send images as you in your active room": "在你所活跃的聊天室以你的身份发送图片", + "Send images as you in this room": "在此聊天室以你的身份发送图片", + "See emotes posted to your active room": "查看发布到你所活跃的聊天室的表情", + "See emotes posted to this room": "查看发布到此聊天室的表情", + "Send emotes as you in your active room": "在你所活跃的聊天室以你的身份发送表情", + "Send emotes as you in this room": "在此聊天室以你的身份发送表情", + "See text messages posted to your active room": "查看发布到你所活跃的聊天室的文本消息", + "See text messages posted to this room": "查看发布到此聊天室的文本消息", + "Send text messages as you in your active room": "在你所活跃的聊天室以你的身份发送文本消息", + "Send text messages as you in this room": "在此聊天室以你的身份发送文本消息", + "See messages posted to your active room": "查看发布到你所活跃的聊天室的消息", + "See messages posted to this room": "查看发布到此聊天室的消息", + "Send messages as you in your active room": "在你所活跃的聊天室以你的身份发送消息", + "Send messages as you in this room": "在此聊天室以你的身份发送消息", + "See when anyone posts a sticker to your active room": "查看何时有人发送贴纸到你所活跃的聊天室", + "Send stickers to your active room as you": "发送贴纸到你所活跃的聊天室", + "See when people join, leave, or are invited to your active room": "查看人们何时加入、离开或被邀请到你所活跃的聊天室", + "See when people join, leave, or are invited to this room": "查看人们何时加入、离开或被邀请到这个房间", + "Kick, ban, or invite people to this room, and make you leave": "移除、封禁或邀请用户到此聊天室,并让你离开", + "Currently joining %(count)s rooms|one": "目前正在加入 %(count)s 个聊天室", + "Currently joining %(count)s rooms|other": "目前正在加入 %(count)s 个聊天室", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "尝试不同的单词或检查拼写错误。某些结果可能不可见,因为它们属于私有的,你需要一个邀请才能加入。", + "No results for \"%(query)s\"": "「%(query)s」没有结果", + "The user you called is busy.": "你所拨打的用户正在忙碌中。", + "User Busy": "用户正在忙", + "We're working on this as part of the beta, but just want to let you know.": "我们正在研究让它成为测试版的一部分,但只想让你找到。", + "Teammates might not be able to view or join any private rooms you make.": "队友可能无法查看或加入你所创建的任何一个私有聊天室。", + "Or send invite link": "或发送邀请链接", + "If you can't see who you’re looking for, send them your invite link below.": "如果你找不到你正在寻找的人,请在下方向他们发送你的邀请链接。", + "Some suggestions may be hidden for privacy.": "出于隐私考虑,部分建议可能会被隐藏。", + "Search for rooms or people": "搜索聊天室或用户", + "Message preview": "消息预览", + "Forward message": "转发消息", + "Open link": "打开链接", + "Sent": "已发送", + "You don't have permission to do this": "你无权执行此操作", + "Error - Mixed content": "错误 - 混合内容", + "Error loading Widget": "加载挂件时发生错误", + "Pinned messages": "已置顶的消息", + "If you have permissions, open the menu on any message and select Pin to stick them here.": "如果你拥有权限,请打开任何消息的菜单并选择置顶将它们粘贴至此。", + "Nothing pinned, yet": "没有置顶", + "End-to-end encryption isn't enabled": "未启用端对端加密", + "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. Enable encryption in settings.": "你的私人信息通常是被加密的,但此聊天室并未加密。一般而言,这可能是因为使用了不受支持的设备或方法,如电子邮件邀请。在设置中启用加密。" } diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index c9bb9bb2d7..d9429fc1c3 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -169,9 +169,9 @@ "No Webcams detected": "未偵測到網路攝影機", "No media permissions": "沒有媒體權限", "You may need to manually permit %(brand)s to access your microphone/webcam": "您可能需要手動允許 %(brand)s 存取您的麥克風/網路攝影機", - "Are you sure you want to leave the room '%(roomName)s'?": "您確定您要想要離開房間 '%(roomName)s' 嗎?", + "Are you sure you want to leave the room '%(roomName)s'?": "你確定你要想要離開房間 '%(roomName)s' 嗎?", "Bans user with given id": "阻擋指定 ID 的使用者", - "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.": "無法連線到家伺服器 - 請檢查您的連線,確保您的家伺服器的 SSL 憑證可被信任,而瀏覽器擴充套件也沒有阻擋請求。", + "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.": "無法連線到家伺服器 - 請檢查你的連線,確保你的家伺服器的 SSL 憑證可被信任,而瀏覽器擴充套件也沒有阻擋請求。", "%(senderName)s changed their profile picture.": "%(senderName)s 已經變更了他的基本資料圖片。", "%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s 變更了 %(powerLevelDiffText)s 權限等級。", "%(senderDisplayName)s changed the room name to %(roomName)s.": "%(senderDisplayName)s 將聊天室名稱變更為 %(roomName)s。", @@ -838,7 +838,7 @@ "An error ocurred whilst trying to remove the widget from the room": "嘗試從聊天室移除小工具時發生錯誤", "System Alerts": "系統警告", "Only room administrators will see this warning": "僅聊天室管理員會看到此警告", - "Please contact your service administrator to continue using the service.": "請聯絡您的服務管理員以繼續使用服務。", + "Please contact your service administrator to continue using the service.": "請聯絡你的服務管理員以繼續使用服務。", "This homeserver has hit its Monthly Active User limit.": "這個主伺服器已經到達其每月活躍使用者限制。", "This homeserver has exceeded one of its resource limits.": "此主伺服器已經超過其中一項資源限制。", "Upgrade Room Version": "更新聊天室版本", @@ -1160,7 +1160,7 @@ "Change": "變更", "Couldn't load page": "無法載入頁面", "This homeserver does not support communities": "此家伺服器不支援社群", - "A verification email will be sent to your inbox to confirm setting your new password.": "一封驗證用的電子郵件已經傳送到您的收件匣以確認您設定了新密碼。", + "A verification email will be sent to your inbox to confirm setting your new password.": "一封驗證用的電子郵件已經傳送到你的收件匣以確認你設定了新密碼。", "Your password has been reset.": "您的密碼已重設。", "This homeserver does not support login using email address.": "此家伺服器不支援使用電子郵件地址登入。", "Registration has been disabled on this homeserver.": "註冊已在此家伺服器上停用。", @@ -1973,8 +1973,8 @@ "%(senderName)s added the alternative addresses %(addresses)s for this room.|one": "%(senderName)s 為此聊天室新增了替代位置 %(addresses)s。", "%(senderName)s removed the alternative addresses %(addresses)s for this room.|other": "%(senderName)s 為此聊天室移除了替代位置 %(addresses)s。", "%(senderName)s removed the alternative addresses %(addresses)s for this room.|one": "%(senderName)s 為此聊天室移除了替代位置 %(addresses)s。", - "%(senderName)s changed the alternative addresses for this room.": "%(senderName)s 為此聊天是變更了替代位置。", - "%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s 為此聊天是變更了主要及替代位置。", + "%(senderName)s changed the alternative addresses for this room.": "%(senderName)s 為此聊天室變更了替代位置。", + "%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s 為此聊天室變更了主要及替代位置。", "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "更新聊天室的替代位置時發生錯誤。伺服器可能不允許這麼做,或是昱到了暫時性的故障。", "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s 將聊天室名稱從 %(oldRoomName)s 變更為 %(newRoomName)s。", "%(senderName)s changed the addresses for this room.": "%(senderName)s 變更了此聊天室的位置。", @@ -2889,7 +2889,7 @@ "Send messages as you in this room": "在此聊天室以您的身份傳送訊息", "The %(capability)s capability": "%(capability)s 能力", "See %(eventType)s events posted to your active room": "檢視發佈到您的活躍聊天室的 %(eventType)s 活動", - "Send %(eventType)s events as you in your active room": "以您的身份在您的活躍聊天是傳送 %(eventType)s 活動", + "Send %(eventType)s events as you in your active room": "以您的身份在您的活躍聊天室傳送 %(eventType)s 活動", "See %(eventType)s events posted to this room": "檢視發佈到此聊天室的 %(eventType)s 活動", "Send %(eventType)s events as you in this room": "以您的身份在此聊天室傳送 %(eventType)s 活動", "with state key %(stateKey)s": "帶有狀態金鑰 %(stateKey)s", @@ -2898,17 +2898,17 @@ "Send stickers to your active room as you": "以您的身份傳送貼圖到您活躍的聊天室", "See when a sticker is posted in this room": "檢視貼圖在此聊天室中何時貼出", "Send stickers to this room as you": "以您的身份傳送貼圖到此聊天室", - "See when the avatar changes in your active room": "檢視您活躍聊天是的大頭照何時變更", - "Change the avatar of your active room": "變更您活躍聊天是的大頭照", - "See when the avatar changes in this room": "檢視此聊天是的大頭照何時變更", + "See when the avatar changes in your active room": "檢視您活躍聊天室的大頭照何時變更", + "Change the avatar of your active room": "變更您活躍聊天室的大頭照", + "See when the avatar changes in this room": "檢視此聊天室的大頭照何時變更", "Change the avatar of this room": "變更此聊天室的大頭照", "See when the name changes in your active room": "檢視您活躍聊天室的名稱何時變更", "Change the name of your active room": "變更您活躍聊天室的名稱", - "See when the name changes in this room": "檢視此聊天是的名稱何時變更", + "See when the name changes in this room": "檢視此聊天室的名稱何時變更", "Change the name of this room": "變更此聊天室的名稱", - "See when the topic changes in your active room": "檢視您活躍的聊天是的主題何時變更", - "Change the topic of your active room": "變更您活躍聊天是的主題", - "See when the topic changes in this room": "檢視此聊天是的主題何時變更", + "See when the topic changes in your active room": "檢視您活躍的聊天室的主題何時變更", + "Change the topic of your active room": "變更您活躍聊天室的主題", + "See when the topic changes in this room": "檢視此聊天室的主題何時變更", "Change the topic of this room": "變更此聊天室的主題", "Change which room you're viewing": "變更您正在檢視的聊天室", "Send stickers into your active room": "傳送貼圖到您活躍的聊天室", @@ -3226,7 +3226,7 @@ "Mark as suggested": "標記為建議", "Mark as not suggested": "標記為不建議", "Removing...": "正在移除……", - "Failed to remove some rooms. Try again later": "移除部份聊天是失敗。稍後再試", + "Failed to remove some rooms. Try again later": "移除部份聊天室失敗。稍後再試", "%(count)s rooms and 1 space|one": "%(count)s 個聊天室與 1 個空間", "%(count)s rooms and 1 space|other": "%(count)s 個聊天室與 1 個空間", "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s 個聊天室與 %(numSpaces)s 個空間", @@ -3240,7 +3240,7 @@ "Open": "開啟", "%(count)s messages deleted.|one": "已刪除 %(count)s 則訊息。", "%(count)s messages deleted.|other": "已刪除 %(count)s 則訊息。", - "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)s 遇到問題,請回報臭蟲。", + "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)s 遇到問題,請回報臭蟲。", "Invite to %(roomName)s": "邀請至 %(roomName)s", "Edit devices": "編輯裝置", "Invite People": "邀請夥伴", @@ -3288,5 +3288,118 @@ "You can add more later too, including already existing ones.": "您稍後可以新增更多內容,包含既有的。", "Please choose a strong password": "請選擇強密碼", "Use another login": "使用其他登入", - "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "未經驗證,您將無法存取您的所有訊息,且可能不被其他人信任。" + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "未經驗證,您將無法存取您的所有訊息,且可能不被其他人信任。", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "您是這裡唯一的人。如果您離開,包含您在內的任何人都無法加入。", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "如果您重設所有東西,您將會在沒有受信任的工作階段、沒有受信任的使用者,且可能會看不到過去的訊息。", + "Only do this if you have no other device to complete verification with.": "當您沒有其他裝置可以完成驗證時,才執行此動作。", + "Reset everything": "重設所有東西", + "Forgotten or lost all recovery methods? Reset all": "忘記或遺失了所有復原方法?重設全部", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "如果這樣做,請注意,您的訊息不會被刪除,但在重新建立索引時,搜尋體驗可能會降低片刻", + "View message": "檢視訊息", + "Zoom in": "放大", + "Zoom out": "縮小", + "%(seconds)ss left": "剩%(seconds)s秒", + "Change server ACLs": "變更伺服器 ACL", + "Show options to enable 'Do not disturb' mode": "顯示啟用「勿打擾」模式的選項", + "You can select all or individual messages to retry or delete": "您可以選取全部或單獨的訊息來重試或刪除", + "Sending": "正在傳送", + "Retry all": "重試全部", + "Delete all": "刪除全部", + "Some of your messages have not been sent": "您的部份訊息未傳送", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s 個成員包含 %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "包含 %(commaSeparatedMembers)s", + "View all %(count)s members|one": "檢視 1 個成員", + "View all %(count)s members|other": "檢視全部 %(count)s 個成員", + "Failed to send": "傳送失敗", + "Enter your Security Phrase a second time to confirm it.": "再次輸入您的安全密語以進行確認。", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "挑選要新增的聊天室或對話。這是專屬於您的空間,不會有人被通知。您稍後可以再新增更多。", + "What do you want to organise?": "您想要整理什麼?", + "Filter all spaces": "過濾所有空間", + "Delete recording": "刪除錄製", + "Stop the recording": "停止錄製", + "%(count)s results in all spaces|one": "所有空間中有 %(count)s 個結果", + "%(count)s results in all spaces|other": "所有空間中有 %(count)s 個結果", + "You have no ignored users.": "您沒有忽略的使用者。", + "Play": "播放", + "Pause": "暫停", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "這是實驗性功能。目前,收到邀請的新使用者必須在 上開啟邀請才能真的加入。", + "To join %(spaceName)s, turn on the Spaces beta": "要加入 %(spaceName)s,請開啟空間測試版", + "To view %(spaceName)s, turn on the Spaces beta": "要檢視 %(spaceName)s,開啟空間測試版", + "Select a room below first": "首先選取一個聊天室", + "Communities are changing to Spaces": "社群正在變更為空間", + "Join the beta": "加入測試版", + "Leave the beta": "離開測試版", + "Beta": "測試", + "Tap for more info": "點擊以取得更多資訊", + "Spaces is a beta feature": "空間為測試功能", + "Want to add a new room instead?": "想要新增新聊天室嗎?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "正在新增聊天室……", + "Adding rooms... (%(progress)s out of %(count)s)|other": "正在新增聊天室……(%(count)s 中的第 %(progress)s 個)", + "Not all selected were added": "並非所有選定的都被新增了", + "You can add existing spaces to a space.": "您可以新增既有的空間至空間中。", + "Feeling experimental?": "想要來點實驗嗎?", + "You are not allowed to view this server's rooms list": "您不被允許檢視此伺服器的聊天室清單", + "Error processing voice message": "處理語音訊息時發生錯誤", + "We didn't find a microphone on your device. Please check your settings and try again.": "我們在您的裝置上找不到麥克風。請檢查您的設定並再試一次。", + "No microphone found": "找不到麥克風", + "We were unable to access your microphone. Please check your browser settings and try again.": "我們無法存取您的麥克風。請檢查您的瀏覽器設定並再試一次。", + "Unable to access your microphone": "無法存取您的麥克風", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "想要來點實驗嗎?實驗室是儘早取得成果,測試新功能並在實際發佈前協助塑造它們的最佳方式。取得更多資訊。", + "Your access token gives full access to your account. Do not share it with anyone.": "您的存取權杖可給您帳號完整的存取權限。不要將其與任何人分享。", + "Access Token": "存取權杖", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "空間是將聊天室與人們分組的一種新方式。要加入既有的空間,您需要邀請。", + "Please enter a name for the space": "請輸入空間名稱", + "Connecting": "正在連線", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "允許在 1:1 通話中使用點對點通訊(若您啟用此功能,對方就能看到您的 IP 位置)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "供網頁、桌面與 Android 使用的測試版。部份功能可能在您的家伺服器上不可用。", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "您可以隨時從設定中退出測試版,或是點擊測試版徽章,例如上面那個。", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s 將在啟用空間的情況下重新載入。社群與自訂標籤將會隱藏。", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "測試版可用於網路、桌面與 Android。感謝您試用測試版。", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s 將在停用空間的情況下重新載入。社群與自訂標籤將再次可見。", + "Spaces are a new way to group rooms and people.": "空間是將聊天室與人們分組的一種新方式。", + "Message search initialisation failed": "訊息搜尋初始化失敗", + "Spaces are a beta feature.": "空間為測試版功能。", + "Search names and descriptions": "搜尋名稱與描述", + "You may contact me if you have any follow up questions": "如果您還有任何後續問題,可以聯絡我", + "To leave the beta, visit your settings.": "要離開測試版,請造訪您的設定。", + "Your platform and username will be noted to help us use your feedback as much as we can.": "我們將會記錄您的平台與使用者名稱,以協助我們盡可能使用您的回饋。", + "%(featureName)s beta feedback": "%(featureName)s 測試版回饋", + "Thank you for your feedback, we really appreciate it.": "感謝您的回饋,我們衷心感謝。", + "Beta feedback": "測試版回饋", + "Add reaction": "新增反應", + "Send and receive voice messages": "傳送與接收語音訊息", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "您的回饋意見將會讓空間變得更好。您可以輸入愈多細節愈好。", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "若您離開,%(brand)s 將在停用空間的情況下重新載入。社群與自訂標籤將再次可見。", + "Space Autocomplete": "空間自動完成", + "Go to my space": "到我的空間", + "sends space invaders": "傳送太空侵略者", + "Sends the given message with a space themed effect": "與太空主題效果一起傳送指定的訊息", + "See when people join, leave, or are invited to your active room": "檢視人們何時加入、離開或被邀請至您活躍的聊天室", + "Kick, ban, or invite people to your active room, and make you leave": "踢除、封鎖或邀請人們到您作用中的聊天室,然後讓您離開", + "See when people join, leave, or are invited to this room": "檢視人們何時加入、離開或被邀請至此聊天室", + "Kick, ban, or invite people to this room, and make you leave": "踢除、封鎖或邀請人們到此聊天室,然後讓您離開", + "Currently joining %(count)s rooms|one": "目前正在加入 %(count)s 個聊天室", + "Currently joining %(count)s rooms|other": "目前正在加入 %(count)s 個聊天室", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "嘗試不同的詞或是檢查拼字。某些結果可能不可見,因為其為私人的,您必須要有邀請才能加入。", + "No results for \"%(query)s\"": "「%(query)s」沒有結果", + "The user you called is busy.": "您想要通話的使用者目前忙碌中。", + "User Busy": "使用者忙碌", + "We're working on this as part of the beta, but just want to let you know.": "我們正將此作為測試版的一部分來處理,但只是想讓您知道。", + "Teammates might not be able to view or join any private rooms you make.": "隊友可能無法檢視或加入您建立的任何私人聊天室。", + "Or send invite link": "或傳送邀請連結", + "If you can't see who you’re looking for, send them your invite link below.": "如果您看不到您要找的人,請在下方向他們傳送您的邀請連結。", + "Some suggestions may be hidden for privacy.": "出於隱私考量,可能會隱藏一些建議。", + "Search for rooms or people": "搜尋聊天室或夥伴", + "Forward message": "轉寄訊息", + "Open link": "開啟連結", + "Sent": "已傳送", + "You don't have permission to do this": "您無權執行此動作", + "Error - Mixed content": "錯誤 - 混合內容", + "Error loading Widget": "載入小工具時發生錯誤", + "Pinned messages": "已釘選的訊息", + "If you have permissions, open the menu on any message and select Pin to stick them here.": "如果您有權限,請開啟任何訊息的選單,並選取釘選以將它們貼到這裡。", + "Nothing pinned, yet": "尚未釘選任何東西", + "End-to-end encryption isn't enabled": "端到端加密未啟用", + "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. Enable encryption in settings.": "您的私人訊息通常是被加密的,但此聊天室不是。一般來說,這可能是因為使用了不支援的裝置或方法,例如電子郵件邀請。在設定中啟用加密。" } diff --git a/src/identifiers.ts b/src/identifiers.ts new file mode 100644 index 0000000000..cc8b2fee4d --- /dev/null +++ b/src/identifiers.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export const ELEMENT_CLIENT_ID = "io.element.web"; diff --git a/src/indexing/BaseEventIndexManager.ts b/src/indexing/BaseEventIndexManager.ts index 6349f31524..4bae3e7c1d 100644 --- a/src/indexing/BaseEventIndexManager.ts +++ b/src/indexing/BaseEventIndexManager.ts @@ -17,7 +17,7 @@ limitations under the License. // The following interfaces take their names and member names from seshat and the spec /* eslint-disable camelcase */ -export interface MatrixEvent { +export interface IMatrixEvent { type: string; sender: string; content: {}; @@ -27,37 +27,37 @@ export interface MatrixEvent { roomId: string; } -export interface MatrixProfile { +export interface IMatrixProfile { avatar_url: string; displayname: string; } -export interface CrawlerCheckpoint { +export interface ICrawlerCheckpoint { roomId: string; token: string; - fullCrawl: boolean; + fullCrawl?: boolean; direction: string; } -export interface ResultContext { - events_before: [MatrixEvent]; - events_after: [MatrixEvent]; - profile_info: Map; +export interface IResultContext { + events_before: [IMatrixEvent]; + events_after: [IMatrixEvent]; + profile_info: Map; } -export interface ResultsElement { +export interface IResultsElement { rank: number; - result: MatrixEvent; - context: ResultContext; + result: IMatrixEvent; + context: IResultContext; } -export interface SearchResult { +export interface ISearchResult { count: number; - results: [ResultsElement]; + results: [IResultsElement]; highlights: [string]; } -export interface SearchArgs { +export interface ISearchArgs { search_term: string; before_limit: number; after_limit: number; @@ -65,22 +65,22 @@ export interface SearchArgs { room_id?: string; } -export interface EventAndProfile { - event: MatrixEvent; - profile: MatrixProfile; +export interface IEventAndProfile { + event: IMatrixEvent; + profile: IMatrixProfile; } -export interface LoadArgs { +export interface ILoadArgs { roomId: string; limit: number; - fromEvent: string; - direction: string; + fromEvent?: string; + direction?: string; } -export interface IndexStats { +export interface IIndexStats { size: number; - event_count: number; - room_count: number; + eventCount: number; + roomCount: number; } /** @@ -119,13 +119,13 @@ export default abstract class BaseEventIndexManager { * Queue up an event to be added to the index. * * @param {MatrixEvent} ev The event that should be added to the index. - * @param {MatrixProfile} profile The profile of the event sender at the + * @param {IMatrixProfile} profile The profile of the event sender at the * time of the event receival. * * @return {Promise} A promise that will resolve when the was queued up for * addition. */ - async addEventToIndex(ev: MatrixEvent, profile: MatrixProfile): Promise { + async addEventToIndex(ev: IMatrixEvent, profile: IMatrixProfile): Promise { throw new Error("Unimplemented"); } @@ -160,14 +160,13 @@ export default abstract class BaseEventIndexManager { /** * Get statistical information of the index. * - * @return {Promise} A promise that will resolve to the index + * @return {Promise} A promise that will resolve to the index * statistics. */ - async getStats(): Promise { + async getStats(): Promise { throw new Error("Unimplemented"); } - /** * Get the user version of the database. * @return {Promise} A promise that will resolve to the user stored @@ -203,13 +202,13 @@ export default abstract class BaseEventIndexManager { /** * Search the event index using the given term for matching events. * - * @param {SearchArgs} searchArgs The search configuration for the search, + * @param {ISearchArgs} searchArgs The search configuration for the search, * sets the search term and determines the search result contents. * - * @return {Promise<[SearchResult]>} A promise that will resolve to an array + * @return {Promise<[ISearchResult]>} A promise that will resolve to an array * of search results once the search is done. */ - async searchEventIndex(searchArgs: SearchArgs): Promise { + async searchEventIndex(searchArgs: ISearchArgs): Promise { throw new Error("Unimplemented"); } @@ -218,12 +217,12 @@ export default abstract class BaseEventIndexManager { * * This is used to add a batch of events to the index. * - * @param {[EventAndProfile]} events The list of events and profiles that + * @param {[IEventAndProfile]} events The list of events and profiles that * should be added to the event index. - * @param {[CrawlerCheckpoint]} checkpoint A new crawler checkpoint that + * @param {[ICrawlerCheckpoint]} checkpoint A new crawler checkpoint that * should be stored in the index which should be used to continue crawling * the room. - * @param {[CrawlerCheckpoint]} oldCheckpoint The checkpoint that was used + * @param {[ICrawlerCheckpoint]} oldCheckpoint The checkpoint that was used * to fetch the current batch of events. This checkpoint will be removed * from the index. * @@ -231,9 +230,9 @@ export default abstract class BaseEventIndexManager { * were already added to the index, false otherwise. */ async addHistoricEvents( - events: [EventAndProfile], - checkpoint: CrawlerCheckpoint | null, - oldCheckpoint: CrawlerCheckpoint | null, + events: IEventAndProfile[], + checkpoint: ICrawlerCheckpoint | null, + oldCheckpoint: ICrawlerCheckpoint | null, ): Promise { throw new Error("Unimplemented"); } @@ -241,36 +240,36 @@ export default abstract class BaseEventIndexManager { /** * Add a new crawler checkpoint to the index. * - * @param {CrawlerCheckpoint} checkpoint The checkpoint that should be added + * @param {ICrawlerCheckpoint} checkpoint The checkpoint that should be added * to the index. * * @return {Promise} A promise that will resolve once the checkpoint has * been stored. */ - async addCrawlerCheckpoint(checkpoint: CrawlerCheckpoint): Promise { + async addCrawlerCheckpoint(checkpoint: ICrawlerCheckpoint): Promise { throw new Error("Unimplemented"); } /** * Add a new crawler checkpoint to the index. * - * @param {CrawlerCheckpoint} checkpoint The checkpoint that should be + * @param {ICrawlerCheckpoint} checkpoint The checkpoint that should be * removed from the index. * * @return {Promise} A promise that will resolve once the checkpoint has * been removed. */ - async removeCrawlerCheckpoint(checkpoint: CrawlerCheckpoint): Promise { + async removeCrawlerCheckpoint(checkpoint: ICrawlerCheckpoint): Promise { throw new Error("Unimplemented"); } /** * Load the stored checkpoints from the index. * - * @return {Promise<[CrawlerCheckpoint]>} A promise that will resolve to an + * @return {Promise<[ICrawlerCheckpoint]>} A promise that will resolve to an * array of crawler checkpoints once they have been loaded from the index. */ - async loadCheckpoints(): Promise<[CrawlerCheckpoint]> { + async loadCheckpoints(): Promise { throw new Error("Unimplemented"); } @@ -286,11 +285,11 @@ export default abstract class BaseEventIndexManager { * @param {string} args.direction The direction to which we should continue * loading events from. This is used only if fromEvent is used as well. * - * @return {Promise<[EventAndProfile]>} A promise that will resolve to an + * @return {Promise<[IEventAndProfile]>} A promise that will resolve to an * array of Matrix events that contain mxc URLs accompanied with the * historic profile of the sender. */ - async loadFileEvents(args: LoadArgs): Promise<[EventAndProfile]> { + async loadFileEvents(args: ILoadArgs): Promise { throw new Error("Unimplemented"); } diff --git a/src/indexing/EventIndex.js b/src/indexing/EventIndex.ts similarity index 82% rename from src/indexing/EventIndex.js rename to src/indexing/EventIndex.ts index 2dcdb9e3a3..76104455f7 100644 --- a/src/indexing/EventIndex.js +++ b/src/indexing/EventIndex.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,34 +14,42 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { EventEmitter } from "events"; +import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; +import { Direction, EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; +import { 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 { RoomState } from 'matrix-js-sdk/src/models/room-state'; +import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; +import { sleep } from "matrix-js-sdk/src/utils"; + import PlatformPeg from "../PlatformPeg"; -import {MatrixClientPeg} from "../MatrixClientPeg"; -import {RoomMember} from 'matrix-js-sdk/src/models/room-member'; -import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline'; -import {sleep} from "../utils/promise"; +import { MatrixClientPeg } from "../MatrixClientPeg"; import SettingsStore from "../settings/SettingsStore"; -import {EventEmitter} from "events"; -import {SettingLevel} from "../settings/SettingLevel"; +import { SettingLevel } from "../settings/SettingLevel"; +import { ICrawlerCheckpoint, ILoadArgs, ISearchArgs } from "./BaseEventIndexManager"; + +// The time in ms that the crawler will wait loop iterations if there +// have not been any checkpoints to consume in the last iteration. +const CRAWLER_IDLE_TIME = 5000; + +// The maximum number of events our crawler should fetch in a single crawl. +const EVENTS_PER_CRAWL = 100; + +interface ICrawler { + cancel(): void; +} /* * Event indexing class that wraps the platform specific event indexing. */ export default class EventIndex extends EventEmitter { - constructor() { - super(); - this.crawlerCheckpoints = []; - // The time in ms that the crawler will wait loop iterations if there - // have not been any checkpoints to consume in the last iteration. - this._crawlerIdleTime = 5000; - // The maximum number of events our crawler should fetch in a single - // crawl. - this._eventsPerCrawl = 100; - this._crawler = null; - this._currentCheckpoint = null; - this.liveEventsForIndex = new Set(); - } + private crawlerCheckpoints: ICrawlerCheckpoint[] = []; + private crawler: ICrawler = null; + private currentCheckpoint: ICrawlerCheckpoint = null; - async init() { + public async init() { const indexManager = PlatformPeg.get().getEventIndexingManager(); this.crawlerCheckpoints = await indexManager.loadCheckpoints(); @@ -53,7 +61,7 @@ export default class EventIndex extends EventEmitter { /** * Register event listeners that are necessary for the event index to work. */ - registerListeners() { + public registerListeners() { const client = MatrixClientPeg.get(); client.on('sync', this.onSync); @@ -67,7 +75,7 @@ export default class EventIndex extends EventEmitter { /** * Remove the event index specific event listeners. */ - removeListeners() { + public removeListeners() { const client = MatrixClientPeg.get(); if (client === null) return; @@ -82,7 +90,7 @@ export default class EventIndex extends EventEmitter { /** * Get crawler checkpoints for the encrypted rooms and store them in the index. */ - async addInitialCheckpoints() { + public async addInitialCheckpoints() { const indexManager = PlatformPeg.get().getEventIndexingManager(); const client = MatrixClientPeg.get(); const rooms = client.getRooms(); @@ -101,16 +109,16 @@ export default class EventIndex extends EventEmitter { // our message crawler. await Promise.all(encryptedRooms.map(async (room) => { const timeline = room.getLiveTimeline(); - const token = timeline.getPaginationToken("b"); + const token = timeline.getPaginationToken(Direction.Backward); - const backCheckpoint = { + const backCheckpoint: ICrawlerCheckpoint = { roomId: room.roomId, token: token, direction: "b", fullCrawl: true, }; - const forwardCheckpoint = { + const forwardCheckpoint: ICrawlerCheckpoint = { roomId: room.roomId, token: token, direction: "f", @@ -127,8 +135,13 @@ export default class EventIndex extends EventEmitter { this.crawlerCheckpoints.push(forwardCheckpoint); } } catch (e) { - console.log("EventIndex: Error adding initial checkpoints for room", - room.roomId, backCheckpoint, forwardCheckpoint, e); + console.log( + "EventIndex: Error adding initial checkpoints for room", + room.roomId, + backCheckpoint, + forwardCheckpoint, + e, + ); } })); } @@ -142,7 +155,7 @@ export default class EventIndex extends EventEmitter { * - Every other sync, tell the event index to commit all the queued up * live events */ - onSync = async (state, prevState, data) => { + private onSync = async (state: string, prevState: string, data: object) => { const indexManager = PlatformPeg.get().getEventIndexingManager(); if (prevState === "PREPARED" && state === "SYNCING") { @@ -162,7 +175,7 @@ export default class EventIndex extends EventEmitter { await indexManager.commitLiveEvents(); return; } - } + }; /* * The Room.timeline listener. @@ -172,9 +185,19 @@ export default class EventIndex extends EventEmitter { * otherwise we save their event id and wait for them in the Event.decrypted * listener. */ - onRoomTimeline = async (ev, room, toStartOfTimeline, removed, data) => { + private onRoomTimeline = async ( + ev: MatrixEvent, + room: Room, + toStartOfTimeline: boolean, + removed: boolean, + data: { + liveEvent: boolean; + }, + ) => { + const client = MatrixClientPeg.get(); + // We only index encrypted rooms locally. - if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) return; + if (!client.isRoomEncrypted(room.roomId)) return; // If it isn't a live event or if it's redacted there's nothing to // do. @@ -183,26 +206,19 @@ export default class EventIndex extends EventEmitter { return; } - // If the event is not yet decrypted mark it for the - // Event.decrypted callback. - if (ev.isBeingDecrypted()) { - const eventId = ev.getId(); - this.liveEventsForIndex.add(eventId); - } else { - // If the event is decrypted or is unencrypted add it to the - // index now. - await this.addLiveEventToIndex(ev); - } - } + await client.decryptEventIfNeeded(ev); - onRoomStateEvent = async (ev, state) => { + await this.addLiveEventToIndex(ev); + }; + + private onRoomStateEvent = async (ev: MatrixEvent, state: RoomState) => { if (!MatrixClientPeg.get().isRoomEncrypted(state.roomId)) return; if (ev.getType() === "m.room.encryption" && !await this.isRoomIndexed(state.roomId)) { console.log("EventIndex: Adding a checkpoint for a newly encrypted room", state.roomId); this.addRoomCheckpoint(state.roomId, true); } - } + }; /* * The Event.decrypted listener. @@ -210,21 +226,18 @@ export default class EventIndex extends EventEmitter { * Checks if the event was marked for addition in the Room.timeline * listener, if so queues it up to be added to the index. */ - onEventDecrypted = async (ev, err) => { - const eventId = ev.getId(); - + private onEventDecrypted = async (ev: MatrixEvent, err: Error) => { // If the event isn't in our live event set, ignore it. - if (!this.liveEventsForIndex.delete(eventId)) return; if (err) return; await this.addLiveEventToIndex(ev); - } + }; /* * The Room.redaction listener. * * Removes a redacted event from our event index. */ - onRedaction = async (ev, room) => { + private onRedaction = async (ev: MatrixEvent, room: Room) => { // We only index encrypted rooms locally. if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) return; const indexManager = PlatformPeg.get().getEventIndexingManager(); @@ -234,7 +247,7 @@ export default class EventIndex extends EventEmitter { } catch (e) { console.log("EventIndex: Error deleting event from index", e); } - } + }; /* * The Room.timelineReset listener. @@ -242,7 +255,7 @@ export default class EventIndex extends EventEmitter { * Listens for timeline resets that are caused by a limited timeline to * re-add checkpoints for rooms that need to be crawled again. */ - onTimelineReset = async (room, timelineSet, resetAllTimelines) => { + private onTimelineReset = async (room: Room, timelineSet: EventTimelineSet, resetAllTimelines: boolean) => { if (room === null) return; if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) return; @@ -250,7 +263,7 @@ export default class EventIndex extends EventEmitter { room.roomId); this.addRoomCheckpoint(room.roomId, false); - } + }; /** * Check if an event should be added to the event index. @@ -262,7 +275,7 @@ export default class EventIndex extends EventEmitter { * @returns {bool} Returns true if the event can be indexed, false * otherwise. */ - isValidEvent(ev) { + private isValidEvent(ev: MatrixEvent) { const isUsefulType = ["m.room.message", "m.room.name", "m.room.topic"].includes(ev.getType()); const validEventType = isUsefulType && !ev.isRedacted() && !ev.isDecryptionFailure(); @@ -286,8 +299,8 @@ export default class EventIndex extends EventEmitter { return validEventType && validMsgType && hasContentValue; } - eventToJson(ev) { - const jsonEvent = ev.toJSON(); + private eventToJson(ev: MatrixEvent) { + const jsonEvent: any = ev.toJSON(); const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent; if (ev.isEncrypted()) { @@ -318,7 +331,7 @@ export default class EventIndex extends EventEmitter { * * @param {MatrixEvent} ev The event that should be added to the index. */ - async addLiveEventToIndex(ev) { + private async addLiveEventToIndex(ev: MatrixEvent) { const indexManager = PlatformPeg.get().getEventIndexingManager(); if (!this.isValidEvent(ev)) return; @@ -337,11 +350,11 @@ export default class EventIndex extends EventEmitter { * Emmit that the crawler has changed the checkpoint that it's currently * handling. */ - emitNewCheckpoint() { + private emitNewCheckpoint() { this.emit("changedCheckpoint", this.currentRoom()); } - async addEventsFromLiveTimeline(timeline) { + private async addEventsFromLiveTimeline(timeline: EventTimeline) { const events = timeline.getEvents(); for (let i = 0; i < events.length; i++) { @@ -350,7 +363,7 @@ export default class EventIndex extends EventEmitter { } } - async addRoomCheckpoint(roomId, fullCrawl = false) { + private async addRoomCheckpoint(roomId: string, fullCrawl = false) { const indexManager = PlatformPeg.get().getEventIndexingManager(); const client = MatrixClientPeg.get(); const room = client.getRoom(roomId); @@ -358,7 +371,7 @@ export default class EventIndex extends EventEmitter { if (!room) return; const timeline = room.getLiveTimeline(); - const token = timeline.getPaginationToken("b"); + const token = timeline.getPaginationToken(Direction.Backward); if (!token) { // The room doesn't contain any tokens, meaning the live timeline @@ -379,8 +392,12 @@ export default class EventIndex extends EventEmitter { try { await indexManager.addCrawlerCheckpoint(checkpoint); } catch (e) { - console.log("EventIndex: Error adding new checkpoint for room", - room.roomId, checkpoint, e); + console.log( + "EventIndex: Error adding new checkpoint for room", + room.roomId, + checkpoint, + e, + ); } this.crawlerCheckpoints.push(checkpoint); @@ -396,16 +413,16 @@ export default class EventIndex extends EventEmitter { * crawl, otherwise create a new checkpoint and push it to the * crawlerCheckpoints queue so we go through them in a round-robin way. */ - async crawlerFunc() { + private async crawlerFunc() { let cancelled = false; const client = MatrixClientPeg.get(); const indexManager = PlatformPeg.get().getEventIndexingManager(); - this._crawler = {}; - - this._crawler.cancel = () => { - cancelled = true; + this.crawler = { + cancel: () => { + cancelled = true; + }, }; let idle = false; @@ -417,11 +434,11 @@ export default class EventIndex extends EventEmitter { sleepTime = Math.max(sleepTime, 100); if (idle) { - sleepTime = this._crawlerIdleTime; + sleepTime = CRAWLER_IDLE_TIME; } - if (this._currentCheckpoint !== null) { - this._currentCheckpoint = null; + if (this.currentCheckpoint !== null) { + this.currentCheckpoint = null; this.emitNewCheckpoint(); } @@ -440,26 +457,29 @@ export default class EventIndex extends EventEmitter { continue; } - this._currentCheckpoint = checkpoint; + this.currentCheckpoint = checkpoint; this.emitNewCheckpoint(); idle = false; // We have a checkpoint, let us fetch some messages, again, very // conservatively to not bother our homeserver too much. - const eventMapper = client.getEventMapper({preventReEmit: true}); + const eventMapper = client.getEventMapper({ preventReEmit: true }); // TODO we need to ensure to use member lazy loading with this // request so we get the correct profiles. let res; try { - res = await client._createMessagesRequest( - checkpoint.roomId, checkpoint.token, this._eventsPerCrawl, - checkpoint.direction); + res = await client.createMessagesRequest( + checkpoint.roomId, + checkpoint.token, + EVENTS_PER_CRAWL, + checkpoint.direction, + ); } catch (e) { if (e.httpStatus === 403) { console.log("EventIndex: Removing checkpoint as we don't have ", - "permissions to fetch messages from this room.", checkpoint); + "permissions to fetch messages from this room.", checkpoint); try { await indexManager.removeCrawlerCheckpoint(checkpoint); } catch (e) { @@ -514,18 +534,14 @@ export default class EventIndex extends EventEmitter { } }); - const decryptionPromises = []; - - matrixEvents.forEach(ev => { - if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) { - // TODO the decryption promise is a private property, this - // should either be made public or we should convert the - // event that gets fired when decryption is done into a - // promise using the once event emitter method: - // https://nodejs.org/api/events.html#events_events_once_emitter_name - decryptionPromises.push(ev._decryptionPromise); - } - }); + const decryptionPromises = matrixEvents + .filter(event => event.isEncrypted()) + .map(event => { + return client.decryptEventIfNeeded(event, { + isRetry: true, + emit: false, + }); + }); // Let us wait for all the events to get decrypted. await Promise.all(decryptionPromises); @@ -589,7 +605,7 @@ export default class EventIndex extends EventEmitter { // to do here anymore. if (!newCheckpoint) { console.log("EventIndex: The server didn't return a valid ", - "new checkpoint, not continuing the crawl.", checkpoint); + "new checkpoint, not continuing the crawl.", checkpoint); continue; } @@ -599,12 +615,12 @@ export default class EventIndex extends EventEmitter { // the new checkpoint to be used by the crawler. if (eventsAlreadyAdded === true && newCheckpoint.fullCrawl !== true) { console.log("EventIndex: Checkpoint had already all events", - "added, stopping the crawl", checkpoint); + "added, stopping the crawl", checkpoint); await indexManager.removeCrawlerCheckpoint(newCheckpoint); } else { if (eventsAlreadyAdded === true) { console.log("EventIndex: Checkpoint had already all events", - "added, but continuing due to a full crawl", checkpoint); + "added, but continuing due to a full crawl", checkpoint); } this.crawlerCheckpoints.push(newCheckpoint); } @@ -616,23 +632,23 @@ export default class EventIndex extends EventEmitter { } } - this._crawler = null; + this.crawler = null; } /** * Start the crawler background task. */ - startCrawler() { - if (this._crawler !== null) return; + public startCrawler() { + if (this.crawler !== null) return; this.crawlerFunc(); } /** * Stop the crawler background task. */ - stopCrawler() { - if (this._crawler === null) return; - this._crawler.cancel(); + public stopCrawler() { + if (this.crawler === null) return; + this.crawler.cancel(); } /** @@ -641,7 +657,7 @@ export default class EventIndex extends EventEmitter { * This removes all the MatrixClient event listeners, stops the crawler * task, and closes the index. */ - async close() { + public async close() { const indexManager = PlatformPeg.get().getEventIndexingManager(); this.removeListeners(); this.stopCrawler(); @@ -652,13 +668,13 @@ export default class EventIndex extends EventEmitter { /** * Search the event index using the given term for matching events. * - * @param {SearchArgs} searchArgs The search configuration for the search, + * @param {ISearchArgs} searchArgs The search configuration for the search, * sets the search term and determines the search result contents. * * @return {Promise<[SearchResult]>} A promise that will resolve to an array * of search results once the search is done. */ - async search(searchArgs) { + public async search(searchArgs: ISearchArgs) { const indexManager = PlatformPeg.get().getEventIndexingManager(); return indexManager.searchEventIndex(searchArgs); } @@ -684,11 +700,16 @@ export default class EventIndex extends EventEmitter { * @returns {Promise} Resolves to an array of events that * contain URLs. */ - async loadFileEvents(room, limit = 10, fromEvent = null, direction = EventTimeline.BACKWARDS) { + public async loadFileEvents( + room: Room, + limit = 10, + fromEvent: string = null, + direction: string = EventTimeline.BACKWARDS, + ) { const client = MatrixClientPeg.get(); const indexManager = PlatformPeg.get().getEventIndexingManager(); - const loadArgs = { + const loadArgs: ILoadArgs = { roomId: room.roomId, limit: limit, }; @@ -776,8 +797,14 @@ export default class EventIndex extends EventEmitter { * @returns {Promise} Resolves to true if events were added to the * timeline, false otherwise. */ - async populateFileTimeline(timelineSet, timeline, room, limit = 10, - fromEvent = null, direction = EventTimeline.BACKWARDS) { + public async populateFileTimeline( + timelineSet: EventTimelineSet, + timeline: EventTimeline, + room: Room, + limit = 10, + fromEvent: string = null, + direction: string = EventTimeline.BACKWARDS, + ) { const matrixEvents = await this.loadFileEvents(room, limit, fromEvent, direction); // If this is a normal fill request, not a pagination request, we need @@ -807,7 +834,7 @@ export default class EventIndex extends EventEmitter { } console.log("EventIndex: Populating file panel with", matrixEvents.length, - "events and setting the pagination token to", paginationToken); + "events and setting the pagination token to", paginationToken); timeline.setPaginationToken(paginationToken, EventTimeline.BACKWARDS); return ret; @@ -835,7 +862,7 @@ export default class EventIndex extends EventEmitter { * @returns {Promise} Resolves to a boolean which is true if more * events were successfully retrieved. */ - paginateTimelineWindow(room, timelineWindow, direction, limit) { + public paginateTimelineWindow(room: Room, timelineWindow: TimelineWindow, direction: Direction, limit: number) { const tl = timelineWindow.getTimelineIndex(direction); if (!tl) return Promise.resolve(false); @@ -869,7 +896,7 @@ export default class EventIndex extends EventEmitter { * @return {Promise} A promise that will resolve to the index * statistics. */ - async getStats() { + public async getStats() { const indexManager = PlatformPeg.get().getEventIndexingManager(); return indexManager.getStats(); } @@ -883,7 +910,7 @@ export default class EventIndex extends EventEmitter { * @return {Promise} Returns true if the index contains events for * the given room, false otherwise. */ - async isRoomIndexed(roomId) { + public async isRoomIndexed(roomId) { const indexManager = PlatformPeg.get().getEventIndexingManager(); return indexManager.isRoomIndexed(roomId); } @@ -894,21 +921,21 @@ export default class EventIndex extends EventEmitter { * @returns {Room} A MatrixRoom that is being currently crawled, null * if no room is currently being crawled. */ - currentRoom() { - if (this._currentCheckpoint === null && this.crawlerCheckpoints.length === 0) { + public currentRoom() { + if (this.currentCheckpoint === null && this.crawlerCheckpoints.length === 0) { return null; } const client = MatrixClientPeg.get(); - if (this._currentCheckpoint !== null) { - return client.getRoom(this._currentCheckpoint.roomId); + if (this.currentCheckpoint !== null) { + return client.getRoom(this.currentCheckpoint.roomId); } else { return client.getRoom(this.crawlerCheckpoints[0].roomId); } } - crawlingRooms() { + public crawlingRooms() { const totalRooms = new Set(); const crawlingRooms = new Set(); @@ -916,14 +943,14 @@ export default class EventIndex extends EventEmitter { crawlingRooms.add(checkpoint.roomId); }); - if (this._currentCheckpoint !== null) { - crawlingRooms.add(this._currentCheckpoint.roomId); + if (this.currentCheckpoint !== null) { + crawlingRooms.add(this.currentCheckpoint.roomId); } const client = MatrixClientPeg.get(); const rooms = client.getRooms(); - const isRoomEncrypted = (room) => { + const isRoomEncrypted = (room: Room) => { return client.isRoomEncrypted(room.roomId); }; @@ -932,6 +959,6 @@ export default class EventIndex extends EventEmitter { totalRooms.add(room.roomId); }); - return {crawlingRooms, totalRooms}; + return { crawlingRooms, totalRooms }; } } diff --git a/src/indexing/EventIndexPeg.ts b/src/indexing/EventIndexPeg.ts index 4356d882d5..51ea84e7f6 100644 --- a/src/indexing/EventIndexPeg.ts +++ b/src/indexing/EventIndexPeg.ts @@ -21,9 +21,9 @@ limitations under the License. import PlatformPeg from "../PlatformPeg"; import EventIndex from "../indexing/EventIndex"; -import {MatrixClientPeg} from "../MatrixClientPeg"; +import { MatrixClientPeg } from "../MatrixClientPeg"; import SettingsStore from '../settings/SettingsStore'; -import {SettingLevel} from "../settings/SettingLevel"; +import { SettingLevel } from "../settings/SettingLevel"; const INDEX_VERSION = 1; diff --git a/src/integrations/IntegrationManagerInstance.ts b/src/integrations/IntegrationManagerInstance.ts index 85748db3c2..c3dcd41ee8 100644 --- a/src/integrations/IntegrationManagerInstance.ts +++ b/src/integrations/IntegrationManagerInstance.ts @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type {Room} from "matrix-js-sdk/src/models/room"; +import type { Room } from "matrix-js-sdk/src/models/room"; import ScalarAuthClient from "../ScalarAuthClient"; -import {dialogTermsInteractionCallback, TermsNotSignedError} from "../Terms"; +import { dialogTermsInteractionCallback, TermsNotSignedError } from "../Terms"; import Modal from '../Modal'; import url from 'url'; import SettingsStore from "../settings/SettingsStore"; import IntegrationManager from "../components/views/settings/IntegrationManager"; -import {IntegrationManagers} from "./IntegrationManagers"; +import { IntegrationManagers } from "./IntegrationManagers"; export enum Kind { Account = "account", @@ -67,7 +67,7 @@ export class IntegrationManagerInstance { const dialog = Modal.createTrackedDialog( 'Integration Manager', '', IntegrationManager, - {loading: true}, 'mx_IntegrationManager', + { loading: true }, 'mx_IntegrationManager', ); const client = this.getScalarClient(); diff --git a/src/integrations/IntegrationManagers.ts b/src/integrations/IntegrationManagers.ts index a29c74c5eb..e357a23af2 100644 --- a/src/integrations/IntegrationManagers.ts +++ b/src/integrations/IntegrationManagers.ts @@ -14,20 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type {MatrixClient} from "matrix-js-sdk/src/client"; -import type {MatrixEvent} from "matrix-js-sdk/src/models/event"; -import type {Room} from "matrix-js-sdk/src/models/room"; +import type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import type { Room } from "matrix-js-sdk/src/models/room"; import SdkConfig from '../SdkConfig'; import Modal from '../Modal'; -import {IntegrationManagerInstance, Kind} from "./IntegrationManagerInstance"; +import { IntegrationManagerInstance, Kind } from "./IntegrationManagerInstance"; import IntegrationsImpossibleDialog from "../components/views/dialogs/IntegrationsImpossibleDialog"; import TabbedIntegrationManagerDialog from "../components/views/dialogs/TabbedIntegrationManagerDialog"; import IntegrationsDisabledDialog from "../components/views/dialogs/IntegrationsDisabledDialog"; import WidgetUtils from "../utils/WidgetUtils"; -import {MatrixClientPeg} from "../MatrixClientPeg"; +import { MatrixClientPeg } from "../MatrixClientPeg"; import SettingsStore from "../settings/SettingsStore"; import url from 'url'; +import { compare } from "../utils/strings"; const KIND_PREFERENCE = [ // Ordered: first is most preferred, last is least preferred. @@ -152,7 +153,7 @@ export class IntegrationManagers { if (kind === Kind.Account) { // Order by state_keys (IDs) - managers.sort((a, b) => a.id.localeCompare(b.id)); + managers.sort((a, b) => compare(a.id, b.id)); } ordered.push(...managers); @@ -186,7 +187,7 @@ export class IntegrationManagers { Modal.createTrackedDialog( 'Tabbed Integration Manager', '', TabbedIntegrationManagerDialog, - {room, screen, integrationId}, 'mx_TabbedIntegrationManagerDialog', + { room, screen, integrationId }, 'mx_TabbedIntegrationManagerDialog', ); } diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index b61f57d4b3..a15eb4a4b7 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -27,7 +27,7 @@ import PlatformPeg from "./PlatformPeg"; // @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config import webpackLangJsonUrl from "$webapp/i18n/languages.json"; import { SettingLevel } from "./settings/SettingLevel"; -import {retry} from "./utils/promise"; +import { retry } from "./utils/promise"; const i18nFolder = 'i18n/'; @@ -56,6 +56,15 @@ export function newTranslatableError(message: string) { return error; } +export function getUserLanguage(): string { + const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true); + if (language) { + return language; + } else { + return normalizeLanguageKey(getLanguageFromBrowser()); + } +} + // Function which only purpose is to mark that a string is translatable // Does not actually do anything. It's helpful for automatic extraction of translatable strings export function _td(s: string): string { @@ -91,17 +100,19 @@ function safeCounterpartTranslate(text: string, options?: object) { if (translated === undefined && count !== undefined) { // counterpart does not do fallback if no pluralisation exists // in the preferred language, so do it here - translated = counterpart.translate(text, Object.assign({}, options, {locale: 'en'})); + translated = counterpart.translate(text, Object.assign({}, options, { locale: 'en' })); } return translated; } +type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode); + export interface IVariables { count?: number; - [key: string]: number | string; + [key: string]: SubstitutionValue; } -type Tags = Record React.ReactNode>; +export type Tags = Record; export type TranslatedString = string | React.ReactNode; @@ -238,7 +249,7 @@ export function replaceByRegexes(text: string, mapping: IVariables | Tags): stri let replaced; // If substitution is a function, call it if (mapping[regexpString] instanceof Function) { - replaced = (mapping as Tags)[regexpString].apply(null, capturedGroups); + replaced = ((mapping as Tags)[regexpString] as Function)(...capturedGroups); } else { replaced = mapping[regexpString]; } @@ -335,7 +346,10 @@ export function setLanguage(preferredLangs: string | string[]) { counterpart.registerTranslations(langToUse, langData); counterpart.setLocale(langToUse); SettingsStore.setValue("language", null, SettingLevel.DEVICE, langToUse); - console.log("set language to " + langToUse); + // Adds a lot of noise to test runs, so disable logging there. + if (process.env.NODE_ENV !== "test") { + console.log("set language to " + langToUse); + } // Set 'en' as fallback language: if (langToUse !== "en") { @@ -455,10 +469,14 @@ function getLangsJson(): Promise { request( { method: "GET", url }, (err, response, body) => { - if (err || response.status < 200 || response.status >= 300) { + if (err) { reject(err); return; } + if (response.status < 200 || response.status >= 300) { + reject(new Error(`Failed to load ${url}, got ${response.status}`)); + return; + } resolve(JSON.parse(body)); }, ); @@ -498,10 +516,14 @@ function getLanguage(langPath: string): Promise { request( { method: "GET", url: langPath }, (err, response, body) => { - if (err || response.status < 200 || response.status >= 300) { + if (err) { reject(err); return; } + if (response.status < 200 || response.status >= 300) { + reject(new Error(`Failed to load ${langPath}, got ${response.status}`)); + return; + } resolve(weblateToCounterpart(JSON.parse(body))); }, ); diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js index 84a131f23a..e670bfcdab 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {baseUrl} from "./utils/permalinks/SpecPermalinkConstructor"; +import { baseUrl } from "./utils/permalinks/SpecPermalinkConstructor"; import { parsePermalink, tryTransformEntityToPermalink, @@ -81,7 +81,6 @@ function matrixLinkify(linkify) { S_ROOMALIAS.on(TT.COLON, S_ROOMALIAS_COLON); // do not accept trailing `:` S_ROOMALIAS_COLON.on(TT.NUM, S_ROOMALIAS_COLON_NUM); // but do accept :NUM (port specifier) - const USERID = function(value) { MultiToken.call(this, value); this.type = 'userid'; @@ -127,7 +126,6 @@ function matrixLinkify(linkify) { S_USERID.on(TT.COLON, S_USERID_COLON); // do not accept trailing `:` S_USERID_COLON.on(TT.NUM, S_USERID_COLON_NUM); // but do accept :NUM (port specifier) - const GROUPID = function(value) { MultiToken.call(this, value); this.type = 'groupid'; @@ -254,11 +252,15 @@ matrixLinkify.options = { target: function(href, type) { if (type === 'url') { - const transformed = tryTransformPermalinkToLocalHref(href); - if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) { - return null; - } else { - return '_blank'; + try { + const transformed = tryTransformPermalinkToLocalHref(href); + if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) { + return null; + } else { + return '_blank'; + } + } catch (e) { + // malformed URI } } return null; diff --git a/src/mjolnir/BanList.ts b/src/mjolnir/BanList.ts index 21cd5d4cf7..8b466c8a06 100644 --- a/src/mjolnir/BanList.ts +++ b/src/mjolnir/BanList.ts @@ -16,8 +16,8 @@ limitations under the License. // Inspiration largely taken from Mjolnir itself -import {ListRule, RECOMMENDATION_BAN, recommendationToStable} from "./ListRule"; -import {MatrixClientPeg} from "../MatrixClientPeg"; +import { ListRule, RECOMMENDATION_BAN, recommendationToStable } from "./ListRule"; +import { MatrixClientPeg } from "../MatrixClientPeg"; export const RULE_USER = "m.room.rule.user"; export const RULE_ROOM = "m.room.rule.room"; @@ -92,7 +92,7 @@ export class BanList { if (!room) return; for (const eventType of ALL_RULE_TYPES) { - const events = room.currentState.getStateEvents(eventType, undefined); + const events = room.currentState.getStateEvents(eventType); for (const ev of events) { if (!ev.getStateKey()) continue; diff --git a/src/mjolnir/ListRule.ts b/src/mjolnir/ListRule.ts index 1d472e06d6..b585b2701f 100644 --- a/src/mjolnir/ListRule.ts +++ b/src/mjolnir/ListRule.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixGlob} from "../utils/MatrixGlob"; +import { MatrixGlob } from "../utils/MatrixGlob"; // Inspiration largely taken from Mjolnir itself diff --git a/src/mjolnir/Mjolnir.ts b/src/mjolnir/Mjolnir.ts index 891438bbb9..21616eece3 100644 --- a/src/mjolnir/Mjolnir.ts +++ b/src/mjolnir/Mjolnir.ts @@ -14,12 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from "../MatrixClientPeg"; -import {ALL_RULE_TYPES, BanList} from "./BanList"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { ALL_RULE_TYPES, BanList } from "./BanList"; import SettingsStore from "../settings/SettingsStore"; -import {_t} from "../languageHandler"; +import { _t } from "../languageHandler"; import dis from "../dispatcher/dispatcher"; -import {SettingLevel} from "../settings/SettingLevel"; +import { SettingLevel } from "../settings/SettingLevel"; +import { Preset } from "matrix-js-sdk/src/@types/partials"; // TODO: Move this and related files to the js-sdk or something once finalized. @@ -48,7 +49,7 @@ export class Mjolnir { this._dispatcherRef = dis.register(this._onAction); dis.dispatch({ action: 'do_after_sync_prepared', - deferred_action: {action: 'setup_mjolnir'}, + deferred_action: { action: 'setup_mjolnir' }, }); } @@ -86,7 +87,7 @@ export class Mjolnir { const resp = await MatrixClientPeg.get().createRoom({ name: _t("My Ban List"), topic: _t("This is your list of users/servers you have blocked - don't leave the room!"), - preset: "private_chat", + preset: Preset.PrivateChat, }); personalRoomId = resp['room_id']; await SettingsStore.setValue( diff --git a/src/notifications/ContentRules.ts b/src/notifications/ContentRules.ts index cd574e12b6..5f1281e58c 100644 --- a/src/notifications/ContentRules.ts +++ b/src/notifications/ContentRules.ts @@ -15,8 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {PushRuleVectorState, State} from "./PushRuleVectorState"; -import {IExtendedPushRule, IRuleSets} from "./types"; +import { PushRuleVectorState, State } from "./PushRuleVectorState"; +import { IExtendedPushRule, IRuleSets } from "./types"; export interface IContentRules { vectorState: State; diff --git a/src/notifications/NotificationUtils.ts b/src/notifications/NotificationUtils.ts index e3b7f66447..1d5356e16b 100644 --- a/src/notifications/NotificationUtils.ts +++ b/src/notifications/NotificationUtils.ts @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Action, Actions} from "./types"; +import { Action, Actions } from "./types"; interface IEncodedActions { notify: boolean; @@ -37,12 +37,12 @@ export class NotificationUtils { if (notify) { const actions: Action[] = [Actions.Notify]; if (sound) { - actions.push({"set_tweak": "sound", "value": sound}); + actions.push({ "set_tweak": "sound", "value": sound }); } if (highlight) { - actions.push({"set_tweak": "highlight"}); + actions.push({ "set_tweak": "highlight" }); } else { - actions.push({"set_tweak": "highlight", "value": false}); + actions.push({ "set_tweak": "highlight", "value": false }); } return actions; } else { diff --git a/src/notifications/PushRuleVectorState.ts b/src/notifications/PushRuleVectorState.ts index d33426cfc4..78c7e4b43b 100644 --- a/src/notifications/PushRuleVectorState.ts +++ b/src/notifications/PushRuleVectorState.ts @@ -15,9 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {StandardActions} from "./StandardActions"; -import {NotificationUtils} from "./NotificationUtils"; -import {IPushRule} from "./types"; +import { StandardActions } from "./StandardActions"; +import { NotificationUtils } from "./NotificationUtils"; +import { IPushRule } from "./types"; export enum State { /** The push rule is disabled */ diff --git a/src/notifications/StandardActions.ts b/src/notifications/StandardActions.ts index c17010af9a..9447f1077c 100644 --- a/src/notifications/StandardActions.ts +++ b/src/notifications/StandardActions.ts @@ -15,16 +15,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {NotificationUtils} from "./NotificationUtils"; +import { NotificationUtils } from "./NotificationUtils"; const encodeActions = NotificationUtils.encodeActions; export class StandardActions { - static ACTION_NOTIFY = encodeActions({notify: true}); - static ACTION_NOTIFY_DEFAULT_SOUND = encodeActions({notify: true, sound: "default"}); - static ACTION_NOTIFY_RING_SOUND = encodeActions({notify: true, sound: "ring"}); - static ACTION_HIGHLIGHT = encodeActions({notify: true, highlight: true}); - static ACTION_HIGHLIGHT_DEFAULT_SOUND = encodeActions({notify: true, sound: "default", highlight: true}); - static ACTION_DONT_NOTIFY = encodeActions({notify: false}); + static ACTION_NOTIFY = encodeActions({ notify: true }); + static ACTION_NOTIFY_DEFAULT_SOUND = encodeActions({ notify: true, sound: "default" }); + static ACTION_NOTIFY_RING_SOUND = encodeActions({ notify: true, sound: "ring" }); + static ACTION_HIGHLIGHT = encodeActions({ notify: true, highlight: true }); + static ACTION_HIGHLIGHT_DEFAULT_SOUND = encodeActions({ notify: true, sound: "default", highlight: true }); + static ACTION_DONT_NOTIFY = encodeActions({ notify: false }); static ACTION_DISABLED = null; } diff --git a/src/notifications/VectorPushRulesDefinitions.js b/src/notifications/VectorPushRulesDefinitions.ts similarity index 89% rename from src/notifications/VectorPushRulesDefinitions.js rename to src/notifications/VectorPushRulesDefinitions.ts index c049a81c15..38dd88e6c6 100644 --- a/src/notifications/VectorPushRulesDefinitions.js +++ b/src/notifications/VectorPushRulesDefinitions.ts @@ -16,19 +16,29 @@ limitations under the License. */ import { _td } from '../languageHandler'; -import {StandardActions} from "./StandardActions"; -import {PushRuleVectorState} from "./PushRuleVectorState"; -import {NotificationUtils} from "./NotificationUtils"; +import { StandardActions } from "./StandardActions"; +import { PushRuleVectorState } from "./PushRuleVectorState"; +import { NotificationUtils } from "./NotificationUtils"; + +interface IProps { + kind: Kind; + description: string; + vectorStateToActions: Action; +} class VectorPushRuleDefinition { - constructor(opts) { + private kind: Kind; + private description: string; + private vectorStateToActions: Action; + + constructor(opts: IProps) { this.kind = opts.kind; this.description = opts.description; this.vectorStateToActions = opts.vectorStateToActions; } // Translate the rule actions and its enabled value into vector state - ruleToVectorState(rule) { + public ruleToVectorState(rule): VectorPushRuleDefinition { let enabled = false; if (rule) { enabled = rule.enabled; @@ -63,13 +73,24 @@ class VectorPushRuleDefinition { } } +enum Kind { + Override = "override", + Underride = "underride", +} + +interface Action { + on: StandardActions; + loud: StandardActions; + off: StandardActions; +} + /** * The descriptions of rules managed by the Vector UI. */ export const VectorPushRulesDefinitions = { // Messages containing user's display name ".m.rule.contains_display_name": new VectorPushRuleDefinition({ - kind: "override", + kind: Kind.Override, description: _td("Messages containing my display name"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { // The actions for each vector state, or null to disable the rule. on: StandardActions.ACTION_NOTIFY, @@ -80,7 +101,7 @@ export const VectorPushRulesDefinitions = { // Messages containing user's username (localpart/MXID) ".m.rule.contains_user_name": new VectorPushRuleDefinition({ - kind: "override", + kind: Kind.Override, description: _td("Messages containing my username"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { // The actions for each vector state, or null to disable the rule. on: StandardActions.ACTION_NOTIFY, @@ -91,7 +112,7 @@ export const VectorPushRulesDefinitions = { // Messages containing @room ".m.rule.roomnotif": new VectorPushRuleDefinition({ - kind: "override", + kind: Kind.Override, description: _td("Messages containing @room"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { // The actions for each vector state, or null to disable the rule. on: StandardActions.ACTION_NOTIFY, @@ -102,7 +123,7 @@ export const VectorPushRulesDefinitions = { // Messages just sent to the user in a 1:1 room ".m.rule.room_one_to_one": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("Messages in one-to-one chats"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -113,7 +134,7 @@ export const VectorPushRulesDefinitions = { // Encrypted messages just sent to the user in a 1:1 room ".m.rule.encrypted_room_one_to_one": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("Encrypted messages in one-to-one chats"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -126,7 +147,7 @@ export const VectorPushRulesDefinitions = { // 1:1 room messages are catched by the .m.rule.room_one_to_one rule if any defined // By opposition, all other room messages are from group chat rooms. ".m.rule.message": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("Messages in group chats"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -139,7 +160,7 @@ export const VectorPushRulesDefinitions = { // Encrypted 1:1 room messages are catched by the .m.rule.encrypted_room_one_to_one rule if any defined // By opposition, all other room messages are from group chat rooms. ".m.rule.encrypted": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("Encrypted messages in group chats"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -150,7 +171,7 @@ export const VectorPushRulesDefinitions = { // Invitation for the user ".m.rule.invite_for_me": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("When I'm invited to a room"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -161,7 +182,7 @@ export const VectorPushRulesDefinitions = { // Incoming call ".m.rule.call": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("Call invitation"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -172,7 +193,7 @@ export const VectorPushRulesDefinitions = { // Notifications from bots ".m.rule.suppress_notices": new VectorPushRuleDefinition({ - kind: "override", + kind: Kind.Override, description: _td("Messages sent by bot"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { // .m.rule.suppress_notices is a "negative" rule, we have to invert its enabled value for vector UI @@ -184,7 +205,7 @@ export const VectorPushRulesDefinitions = { // Room upgrades (tombstones) ".m.rule.tombstone": new VectorPushRuleDefinition({ - kind: "override", + kind: Kind.Override, description: _td("When rooms are upgraded"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { // The actions for each vector state, or null to disable the rule. on: StandardActions.ACTION_NOTIFY, diff --git a/src/notifications/index.js b/src/notifications/index.ts similarity index 100% rename from src/notifications/index.js rename to src/notifications/index.ts diff --git a/src/performance/entry-names.ts b/src/performance/entry-names.ts new file mode 100644 index 0000000000..6cb193b1b1 --- /dev/null +++ b/src/performance/entry-names.ts @@ -0,0 +1,57 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export enum PerformanceEntryNames { + + /** + * Application wide + */ + + APP_STARTUP = "mx_AppStartup", + PAGE_CHANGE = "mx_PageChange", + + /** + * Events + */ + + RESEND_EVENT = "mx_ResendEvent", + SEND_E2EE_EVENT = "mx_SendE2EEEvent", + SEND_ATTACHMENT = "mx_SendAttachment", + + /** + * Rooms + */ + + SWITCH_ROOM = "mx_SwithRoom", + JUMP_TO_ROOM = "mx_JumpToRoom", + JOIN_ROOM = "mx_JoinRoom", // ✅ + CREATE_DM = "mx_CreateDM", // ✅ + PEEK_ROOM = "mx_PeekRoom", + + /** + * User + */ + + VERIFY_E2EE_USER = "mx_VerifyE2EEUser", // ✅ + LOGIN = "mx_Login", // ✅ + REGISTER = "mx_Register", // ✅ + + /** + * VoIP + */ + + SETUP_VOIP_CALL = "mx_SetupVoIPCall", +} diff --git a/src/performance/index.ts b/src/performance/index.ts new file mode 100644 index 0000000000..cb808f9173 --- /dev/null +++ b/src/performance/index.ts @@ -0,0 +1,177 @@ +/* +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 { PerformanceEntryNames } from "./entry-names"; + +interface GetEntriesOptions { + name?: string; + type?: string; +} + +type PerformanceCallbackFunction = (entry: PerformanceEntry[]) => void; + +interface PerformanceDataListener { + entryNames?: string[]; + callback: PerformanceCallbackFunction; +} + +export default class PerformanceMonitor { + static _instance: PerformanceMonitor; + + private START_PREFIX = "start:"; + private STOP_PREFIX = "stop:"; + + private listeners: PerformanceDataListener[] = []; + private entries: PerformanceEntry[] = []; + + public static get instance(): PerformanceMonitor { + if (!PerformanceMonitor._instance) { + PerformanceMonitor._instance = new PerformanceMonitor(); + } + return PerformanceMonitor._instance; + } + + /** + * Starts a performance recording + * @param name Name of the recording + * @param id Specify an identifier appended to the measurement name + * @returns {void} + */ + start(name: string, id?: string): void { + if (!this.supportsPerformanceApi()) { + return; + } + const key = this.buildKey(name, id); + + if (performance.getEntriesByName(this.START_PREFIX + key).length > 0) { + console.warn(`Recording already started for: ${name}`); + return; + } + + performance.mark(this.START_PREFIX + key); + } + + /** + * Stops a performance recording and stores delta duration + * with the start marker + * @param name Name of the recording + * @param id Specify an identifier appended to the measurement name + * @returns {void} + */ + stop(name: string, id?: string): PerformanceEntry { + if (!this.supportsPerformanceApi()) { + return; + } + const key = this.buildKey(name, id); + if (performance.getEntriesByName(this.START_PREFIX + key).length === 0) { + console.warn(`No recording started for: ${name}`); + return; + } + + performance.mark(this.STOP_PREFIX + key); + performance.measure( + key, + this.START_PREFIX + key, + this.STOP_PREFIX + key, + ); + + this.clear(name, id); + + const measurement = performance.getEntriesByName(key).pop(); + + // Keeping a reference to all PerformanceEntry created + // by this abstraction for historical events collection + // when adding a data callback + this.entries.push(measurement); + + this.listeners.forEach(listener => { + if (this.shouldEmit(listener, measurement)) { + listener.callback([measurement]); + } + }); + + return measurement; + } + + clear(name: string, id?: string): void { + if (!this.supportsPerformanceApi()) { + return; + } + const key = this.buildKey(name, id); + performance.clearMarks(this.START_PREFIX + key); + performance.clearMarks(this.STOP_PREFIX + key); + } + + getEntries({ name, type }: GetEntriesOptions = {}): PerformanceEntry[] { + return this.entries.filter(entry => { + const satisfiesName = !name || entry.name === name; + const satisfiedType = !type || entry.entryType === type; + return satisfiesName && satisfiedType; + }); + } + + addPerformanceDataCallback(listener: PerformanceDataListener, buffer = false) { + this.listeners.push(listener); + if (buffer) { + const toEmit = this.entries.filter(entry => this.shouldEmit(listener, entry)); + if (toEmit.length > 0) { + listener.callback(toEmit); + } + } + } + + removePerformanceDataCallback(callback?: PerformanceCallbackFunction) { + if (!callback) { + this.listeners = []; + } else { + this.listeners.splice( + this.listeners.findIndex(listener => listener.callback === callback), + 1, + ); + } + } + + /** + * Tor browser does not support the Performance API + * @returns {boolean} true if the Performance API is supported + */ + private supportsPerformanceApi(): boolean { + return performance !== undefined && performance.mark !== undefined; + } + + private shouldEmit(listener: PerformanceDataListener, entry: PerformanceEntry): boolean { + return !listener.entryNames || listener.entryNames.includes(entry.name); + } + + /** + * Internal utility to ensure consistent name for the recording + * @param name Name of the recording + * @param id Specify an identifier appended to the measurement name + * @returns {string} a compound of the name and identifier if present + */ + private buildKey(name: string, id?: string): string { + return `${name}${id ? `:${id}` : ''}`; + } +} + +// Convenience exports +export { + PerformanceEntryNames, +}; + +// Exposing those to the window object to bridge them from tests +window.mxPerformanceMonitor = PerformanceMonitor.instance; +window.mxPerformanceEntryNames = PerformanceEntryNames; diff --git a/src/rageshake/rageshake.js b/src/rageshake/rageshake.js index b886f369df..9512f62e42 100644 --- a/src/rageshake/rageshake.js +++ b/src/rageshake/rageshake.js @@ -73,7 +73,9 @@ class ConsoleLogger { // Convert objects and errors to helpful things args = args.map((arg) => { - if (arg instanceof Error) { + if (arg instanceof DOMException) { + return arg.message + ` (${arg.name} | ${arg.code}) ` + (arg.stack ? `\n${arg.stack}` : ''); + } else if (arg instanceof Error) { return arg.message + (arg.stack ? `\n${arg.stack}` : ''); } else if (typeof (arg) === 'object') { try { diff --git a/src/rageshake/submit-rageshake.ts b/src/rageshake/submit-rageshake.ts index 29856b1a86..b629ddafd8 100644 --- a/src/rageshake/submit-rageshake.ts +++ b/src/rageshake/submit-rageshake.ts @@ -18,20 +18,15 @@ limitations under the License. import pako from 'pako'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { MatrixClientPeg } from '../MatrixClientPeg'; import PlatformPeg from '../PlatformPeg'; import { _t } from '../languageHandler'; import Tar from "tar-js"; import * as rageshake from './rageshake'; -// polyfill textencoder if necessary -import * as TextEncodingUtf8 from 'text-encoding-utf-8'; import SettingsStore from "../settings/SettingsStore"; -let TextEncoder = window.TextEncoder; -if (!TextEncoder) { - TextEncoder = TextEncodingUtf8.TextEncoder; -} +import SdkConfig from "../SdkConfig"; interface IOpts { label?: string; @@ -91,29 +86,29 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true) { body.append('cross_signing_key', client.getCrossSigningId()); // add cross-signing status information - const crossSigning = client._crypto._crossSigningInfo; - const secretStorage = client._crypto._secretStorage; + const crossSigning = client.crypto.crossSigningInfo; + const secretStorage = client.crypto.secretStorage; body.append("cross_signing_ready", String(await client.isCrossSigningReady())); body.append("cross_signing_supported_by_hs", String(await client.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"))); body.append("cross_signing_key", crossSigning.getId()); - body.append("cross_signing_pk_in_secret_storage", + body.append("cross_signing_privkey_in_secret_storage", String(!!(await crossSigning.isStoredInSecretStorage(secretStorage)))); const pkCache = client.getCrossSigningCacheCallbacks(); - body.append("cross_signing_master_pk_cached", + body.append("cross_signing_master_privkey_cached", String(!!(pkCache && await pkCache.getCrossSigningKeyCache("master")))); - body.append("cross_signing_self_signing_pk_cached", + body.append("cross_signing_self_signing_privkey_cached", String(!!(pkCache && await pkCache.getCrossSigningKeyCache("self_signing")))); - body.append("cross_signing_user_signing_pk_cached", + body.append("cross_signing_user_signing_privkey_cached", String(!!(pkCache && await pkCache.getCrossSigningKeyCache("user_signing")))); body.append("secret_storage_ready", String(await client.isSecretStorageReady())); body.append("secret_storage_key_in_account", String(!!(await secretStorage.hasKey()))); body.append("session_backup_key_in_secret_storage", String(!!(await client.isKeyBackupKeyStored()))); - const sessionBackupKeyFromCache = await client._crypto.getSessionBackupPrivateKey(); + const sessionBackupKeyFromCache = await client.crypto.getSessionBackupPrivateKey(); body.append("session_backup_key_cached", String(!!sessionBackupKeyFromCache)); body.append("session_backup_key_well_formed", String(sessionBackupKeyFromCache instanceof Uint8Array)); } @@ -268,6 +263,35 @@ function uint8ToString(buf: Buffer) { return out; } +export async function submitFeedback( + endpoint: string, + label: string, + comment: string, + canContact = false, + extraData: Record = {}, +) { + let version = "UNKNOWN"; + try { + version = await PlatformPeg.get().getAppVersion(); + } catch (err) {} // PlatformPeg already logs this. + + const body = new FormData(); + body.append("label", label); + body.append("text", comment); + body.append("can_contact", canContact ? "yes" : "no"); + + body.append("app", "element-web"); + body.append("version", version); + body.append("platform", PlatformPeg.get().getHumanReadableName()); + body.append("user_id", MatrixClientPeg.get()?.getUserId()); + + for (const k in extraData) { + body.append(k, extraData[k]); + } + + await _submitReport(SdkConfig.get().bug_report_endpoint_url, body, () => {}); +} + function _submitReport(endpoint: string, body: FormData, progressCallback: (string) => void) { return new Promise((resolve, reject) => { const req = new XMLHttpRequest(); diff --git a/src/ratelimitedfunc.js b/src/ratelimitedfunc.js deleted file mode 100644 index 3df3db615e..0000000000 --- a/src/ratelimitedfunc.js +++ /dev/null @@ -1,47 +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. -*/ - -/** - * 'debounces' a function to only execute every n milliseconds. - * Useful when react-sdk gets many, many events but only wants - * to update the interface once for all of them. - * - * Note that the function must not take arguments, since the args - * could be different for each invocation of the function. - * - * The returned function has a 'cancelPendingCall' property which can be called - * on unmount or similar to cancel any pending update. - */ - -import {throttle} from "lodash"; - -export default function ratelimitedfunc(fn, time) { - const throttledFn = throttle(fn, time, { - leading: true, - trailing: true, - }); - const _bind = throttledFn.bind; - throttledFn.bind = function() { - const boundFn = _bind.apply(throttledFn, arguments); - boundFn.cancelPendingCall = throttledFn.cancelPendingCall; - return boundFn; - }; - - throttledFn.cancelPendingCall = function() { - throttledFn.cancel(); - }; - return throttledFn; -} diff --git a/src/resizer/distributors/collapse.ts b/src/resizer/distributors/collapse.ts index f8db0be52c..d39580667c 100644 --- a/src/resizer/distributors/collapse.ts +++ b/src/resizer/distributors/collapse.ts @@ -16,7 +16,7 @@ limitations under the License. import FixedDistributor from "./fixed"; import ResizeItem from "../item"; -import Resizer, {IConfig} from "../resizer"; +import Resizer, { IConfig } from "../resizer"; import Sizer from "../sizer"; export interface ICollapseConfig extends IConfig { diff --git a/src/resizer/distributors/fixed.ts b/src/resizer/distributors/fixed.ts index 64f1c5015b..bc2d876300 100644 --- a/src/resizer/distributors/fixed.ts +++ b/src/resizer/distributors/fixed.ts @@ -16,7 +16,7 @@ limitations under the License. import ResizeItem from "../item"; import Sizer from "../sizer"; -import Resizer, {IConfig} from "../resizer"; +import Resizer, { IConfig } from "../resizer"; /** distributors translate a moving cursor into diff --git a/src/resizer/distributors/percentage.ts b/src/resizer/distributors/percentage.ts index e9b1425414..be3f759ec8 100644 --- a/src/resizer/distributors/percentage.ts +++ b/src/resizer/distributors/percentage.ts @@ -16,7 +16,7 @@ limitations under the License. import Sizer from "../sizer"; import FixedDistributor from "./fixed"; -import {IConfig} from "../resizer"; +import { IConfig } from "../resizer"; class PercentageSizer extends Sizer { public start(item: HTMLElement) { diff --git a/src/resizer/index.ts b/src/resizer/index.ts index 9199fc2657..313579542f 100644 --- a/src/resizer/index.ts +++ b/src/resizer/index.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -export {default as FixedDistributor} from "./distributors/fixed"; -export {default as PercentageDistributor} from "./distributors/percentage"; -export {default as CollapseDistributor} from "./distributors/collapse"; -export {default as Resizer} from "./resizer"; +export { default as FixedDistributor } from "./distributors/fixed"; +export { default as PercentageDistributor } from "./distributors/percentage"; +export { default as CollapseDistributor } from "./distributors/collapse"; +export { default as Resizer } from "./resizer"; diff --git a/src/resizer/item.ts b/src/resizer/item.ts index 3be290f15e..66a0554d3d 100644 --- a/src/resizer/item.ts +++ b/src/resizer/item.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Resizer, {IConfig} from "./resizer"; +import Resizer, { IConfig } from "./resizer"; import Sizer from "./sizer"; export default class ResizeItem { diff --git a/src/resizer/resizer.ts b/src/resizer/resizer.ts index c7c7edcd11..e430c68e17 100644 --- a/src/resizer/resizer.ts +++ b/src/resizer/resizer.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {throttle} from "lodash"; +import { throttle } from "lodash"; import FixedDistributor from "./distributors/fixed"; import ResizeItem from "./item"; @@ -87,7 +87,7 @@ export default class Resizer { const handles = this.getResizeHandles(); const handle = handles[handleIndex]; if (handle) { - const {distributor} = this.createSizerAndDistributor(handle); + const { distributor } = this.createSizerAndDistributor(handle); return distributor; } } @@ -96,7 +96,7 @@ export default class Resizer { const handles = this.getResizeHandles(); const handle = handles.find((h) => h.getAttribute("data-id") === id); if (handle) { - const {distributor} = this.createSizerAndDistributor(handle); + const { distributor } = this.createSizerAndDistributor(handle); return distributor; } } @@ -127,7 +127,7 @@ export default class Resizer { this.config.onResizeStart(); } - const {sizer, distributor} = this.createSizerAndDistributor(resizeHandle); + const { sizer, distributor } = this.createSizerAndDistributor(resizeHandle); distributor.start(); const onMouseMove = (event) => { @@ -159,11 +159,11 @@ export default class Resizer { // relax all items if they had any overconstrained flexboxes distributors.forEach(d => d.start()); distributors.forEach(d => d.finish()); - }, 100, {trailing: true, leading: true}); + }, 100, { trailing: true, leading: true }); public getDistributors = () => { return this.getResizeHandles().map(handle => { - const {distributor} = this.createSizerAndDistributor(handle); + const { distributor } = this.createSizerAndDistributor(handle); return distributor; }); }; @@ -177,7 +177,7 @@ export default class Resizer { const sizer = Distributor.createSizer(this.container, vertical, reverse); const item = Distributor.createItem(resizeHandle, this, sizer); const distributor = new Distributor(item); - return {sizer, distributor}; + return { sizer, distributor }; } private getResizeHandles() { diff --git a/src/settings/Settings.ts b/src/settings/Settings.tsx similarity index 88% rename from src/settings/Settings.ts rename to src/settings/Settings.tsx index 2a26eeac13..1751eddb2c 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.tsx @@ -1,6 +1,6 @@ /* Copyright 2017 Travis Ralston -Copyright 2018, 2019, 2020 The Matrix.org Foundation C.I.C. +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. @@ -16,8 +16,9 @@ limitations under the License. */ import { MatrixClient } from 'matrix-js-sdk/src/client'; +import React, { ReactNode } from "react"; -import { _td } from '../languageHandler'; +import { _t, _td } from '../languageHandler'; import { NotificationBodyEnabledController, NotificationsEnabledController, @@ -39,6 +40,7 @@ import { OrderedMultiController } from "./controllers/OrderedMultiController"; import { Layout } from "./Layout"; import ReducedMotionController from './controllers/ReducedMotionController'; import IncompatibleController from "./controllers/IncompatibleController"; +import SdkConfig from "../SdkConfig"; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = [ @@ -92,6 +94,9 @@ export interface ISetting { [level: SettingLevel]: string; }; + // Optional description which will be shown as microCopy under SettingsFlags + description?: string; + // The supported levels are required. Preferably, use the preset arrays // at the top of this file to define this rather than a custom array. supportedLevels?: SettingLevel[]; @@ -117,9 +122,26 @@ export interface ISetting { // historical settings which we don't want existing user's values be wiped. Do // not use this for new settings. invertedSettingName?: string; + + betaInfo?: { + title: string; // _td + caption: string; // _td + disclaimer?: (enabled: boolean) => ReactNode; + image: string; // require(...) + feedbackSubheading?: string; + feedbackLabel?: string; + extraSettings?: string[]; + }; } export const SETTINGS: {[setting: string]: ISetting} = { + "feature_report_to_moderators": { + isFeature: true, + displayName: _td("Report to moderators prototype. " + + "In rooms that support moderation, the `report` button will let you report abuse to room moderators"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_spaces": { isFeature: true, displayName: _td("Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. " + @@ -127,6 +149,61 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, controller: new ReloadOnChangeController(), + betaInfo: { + title: _td("Spaces"), + caption: _td("Spaces are a new way to group rooms and people."), + disclaimer: (enabled) => { + if (enabled) { + return <> +

    { _t("If you leave, %(brand)s will reload with Spaces disabled. " + + "Communities and custom tags will be visible again.", { + brand: SdkConfig.get().brand, + }) }

    +

    { _t("Beta available for web, desktop and Android. Thank you for trying the beta.") }

    + ; + } + + return <> +

    { _t("%(brand)s will reload with Spaces enabled. " + + "Communities and custom tags will be hidden.", { + brand: SdkConfig.get().brand, + }) }

    + { _t("You can leave the beta any time from settings or tapping on a beta badge, " + + "like the one above.") } +

    { _t("Beta available for web, desktop and Android. " + + "Some features may be unavailable on your homeserver.") }

    + ; + }, + image: require("../../res/img/betas/spaces.png"), + feedbackSubheading: _td("Your feedback will help make spaces better. " + + "The more detail you can go into, the better."), + feedbackLabel: "spaces-feedback", + extraSettings: [ + "feature_spaces.all_rooms", + "feature_spaces.space_member_dms", + "feature_spaces.space_dm_badges", + ], + }, + }, + "feature_spaces.all_rooms": { + displayName: _td("Show all rooms in Home"), + supportedLevels: LEVELS_FEATURE, + default: true, + controller: new ReloadOnChangeController(), + }, + "feature_spaces.space_member_dms": { + displayName: _td("Show people in spaces"), + description: _td("If disabled, you can still add Direct Messages to Personal Spaces. " + + "If enabled, you'll automatically see everyone who is a member of the Space."), + supportedLevels: LEVELS_FEATURE, + default: true, + controller: new ReloadOnChangeController(), + }, + "feature_spaces.space_dm_badges": { + displayName: _td("Show notification badges for People in Spaces"), + supportedLevels: LEVELS_FEATURE, + default: false, + controller: new ReloadOnChangeController(), }, "feature_dnd": { isFeature: true, @@ -136,7 +213,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, "feature_voice_messages": { isFeature: true, - displayName: _td("Send and receive voice messages (in development)"), + displayName: _td("Send and receive voice messages"), supportedLevels: LEVELS_FEATURE, default: false, }, @@ -156,12 +233,6 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: false, controller: new IncompatibleController("feature_spaces"), }, - "feature_new_spinner": { - isFeature: true, - displayName: _td("New spinner design"), - supportedLevels: LEVELS_FEATURE, - default: false, - }, "feature_pinning": { isFeature: true, displayName: _td("Message Pinning"), @@ -384,7 +455,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, "ctrlFForSearch": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: isMac ? _td("Use Command + F to search") : _td("Use Ctrl + F to search"), + displayName: isMac ? _td("Use Command + F to search timeline") : _td("Use Ctrl + F to search timeline"), default: false, }, "MessageComposerInput.ctrlEnterToSend": { @@ -438,7 +509,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, "webRtcAllowPeerToPeer": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, - displayName: _td('Allow Peer-to-Peer for 1:1 calls'), + displayName: _td( + "Allow Peer-to-Peer for 1:1 calls " + + "(if you enable this, the other party might be able to see your IP address)", + ), default: true, invertedSettingName: 'webRtcForceTURN', }, @@ -532,14 +606,6 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: false, controller: new UIFeatureController(UIFeature.URLPreviews), }, - "roomColor": { - supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM, - displayName: _td("Room Colour"), - default: { - primary_color: null, // Hex string, eg: #000000 - secondary_color: null, // Hex string, eg: #000000 - }, - }, "notificationsEnabled": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, default: false, @@ -563,10 +629,6 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td('Enable widget screenshots on supported widgets'), default: false, }, - "PinnedEvents.isOpen": { - supportedLevels: [SettingLevel.ROOM_DEVICE], - default: false, - }, "promptBeforeInviteUnknownUsers": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td('Prompt before sending invites to potentially invalid matrix IDs'), @@ -686,7 +748,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: Layout.Group, }, "showChatEffects": { - supportedLevels: LEVELS_ACCOUNT_SETTINGS, + supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM, displayName: _td("Show chat effects (animations when receiving e.g. confetti)"), default: true, controller: new ReducedMotionController(), diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index c2675bd8f8..44f3d5d838 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -26,7 +26,7 @@ import { _t } from '../languageHandler'; import dis from '../dispatcher/dispatcher'; import { ISetting, SETTINGS } from "./Settings"; import LocalEchoWrapper from "./handlers/LocalEchoWrapper"; -import { WatchManager } from "./WatchManager"; +import { WatchManager, CallbackFn as WatchCallbackFn } from "./WatchManager"; import { SettingLevel } from "./SettingLevel"; import SettingsHandler from "./handlers/SettingsHandler"; @@ -117,8 +117,8 @@ export default class SettingsStore { // We also maintain a list of monitors which are special watchers: they cause dispatches // when the setting changes. We track which rooms we're monitoring though to ensure we // don't duplicate updates on the bus. - private static watchers = {}; // { callbackRef => { callbackFn } } - private static monitors = {}; // { settingName => { roomId => callbackRef } } + private static watchers = new Map(); + private static monitors = new Map>(); // { settingName => { roomId => callbackRef } } // Counter used for generation of watcher IDs private static watcherCount = 1; @@ -163,7 +163,7 @@ export default class SettingsStore { callbackFn(originalSettingName, changedInRoomId, atLevel, newValAtLevel, newValue); }; - SettingsStore.watchers[watcherId] = localizedCallback; + SettingsStore.watchers.set(watcherId, localizedCallback); defaultWatchManager.watchSetting(settingName, roomId, localizedCallback); return watcherId; @@ -176,13 +176,13 @@ export default class SettingsStore { * to cancel. */ public static unwatchSetting(watcherReference: string) { - if (!SettingsStore.watchers[watcherReference]) { + if (!SettingsStore.watchers.has(watcherReference)) { console.warn(`Ending non-existent watcher ID ${watcherReference}`); return; } - defaultWatchManager.unwatchSetting(SettingsStore.watchers[watcherReference]); - delete SettingsStore.watchers[watcherReference]; + defaultWatchManager.unwatchSetting(SettingsStore.watchers.get(watcherReference)); + SettingsStore.watchers.delete(watcherReference); } /** @@ -196,10 +196,10 @@ export default class SettingsStore { public static monitorSetting(settingName: string, roomId: string) { roomId = roomId || null; // the thing wants null specifically to work, so appease it. - if (!this.monitors[settingName]) this.monitors[settingName] = {}; + if (!this.monitors.has(settingName)) this.monitors.set(settingName, new Map()); const registerWatcher = () => { - this.monitors[settingName][roomId] = SettingsStore.watchSetting( + this.monitors.get(settingName).set(roomId, SettingsStore.watchSetting( settingName, roomId, (settingName, inRoomId, level, newValueAtLevel, newValue) => { dis.dispatch({ action: 'setting_updated', @@ -210,19 +210,20 @@ export default class SettingsStore { newValue, }); }, - ); + )); }; - const hasRoom = Object.keys(this.monitors[settingName]).find((r) => r === roomId || r === null); + const rooms = Array.from(this.monitors.get(settingName).keys()); + const hasRoom = rooms.find((r) => r === roomId || r === null); if (!hasRoom) { registerWatcher(); } else { if (roomId === null) { // Unregister all existing watchers and register the new one - for (const roomId of Object.keys(this.monitors[settingName])) { - SettingsStore.unwatchSetting(this.monitors[settingName][roomId]); - } - this.monitors[settingName] = {}; + rooms.forEach(roomId => { + SettingsStore.unwatchSetting(this.monitors.get(settingName).get(roomId)); + }); + this.monitors.get(settingName).clear(); registerWatcher(); } // else a watcher is already registered for the room, so don't bother registering it again } @@ -247,6 +248,16 @@ export default class SettingsStore { return _t(displayName as string); } + /** + * Gets the translated description for a given setting + * @param {string} settingName The setting to look up. + * @return {String} The description for the setting, or null if not found. + */ + public static getDescription(settingName: string) { + if (!SETTINGS[settingName]?.description) return null; + return _t(SETTINGS[settingName].description); + } + /** * Determines if a setting is also a feature. * @param {string} settingName The setting to look up. @@ -257,6 +268,15 @@ export default class SettingsStore { return SETTINGS[settingName].isFeature; } + public static getBetaInfo(settingName: string) { + // consider a beta disabled if the config is explicitly set to false, in which case treat as normal Labs flag + if (SettingsStore.isFeature(settingName) + && SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, null, true, true) !== false + ) { + return SETTINGS[settingName]?.betaInfo; + } + } + /** * Determines if a setting is enabled. * If a setting is disabled then it should be hidden from the user. @@ -445,8 +465,8 @@ export default class SettingsStore { throw new Error("Setting '" + settingName + "' does not appear to be a setting."); } - // When features are specified in the config.json, we force them as enabled or disabled. - if (SettingsStore.isFeature(settingName)) { + // When non-beta features are specified in the config.json, we force them as enabled or disabled. + if (SettingsStore.isFeature(settingName) && !SETTINGS[settingName]?.betaInfo) { const configVal = SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, roomId, true, true); if (configVal === true || configVal === false) return false; } diff --git a/src/settings/WatchManager.ts b/src/settings/WatchManager.ts index ea2f158ef6..744d75b136 100644 --- a/src/settings/WatchManager.ts +++ b/src/settings/WatchManager.ts @@ -18,11 +18,7 @@ import { SettingLevel } from "./SettingLevel"; export type CallbackFn = (changedInRoomId: string, atLevel: SettingLevel, newValAtLevel: any) => void; -const IRRELEVANT_ROOM: string = null; - -interface RoomWatcherMap { - [roomId: string]: CallbackFn[]; -} +const IRRELEVANT_ROOM = Symbol("irrelevant-room"); /** * Generalized management class for dealing with watchers on a per-handler (per-level) @@ -30,25 +26,25 @@ interface RoomWatcherMap { * class, which are then proxied outwards to any applicable watchers. */ export class WatchManager { - private watchers: {[settingName: string]: RoomWatcherMap} = {}; + private watchers = new Map>(); // settingName -> roomId -> CallbackFn[] // Proxy for handlers to delegate changes to this manager public watchSetting(settingName: string, roomId: string | null, cb: CallbackFn) { - if (!this.watchers[settingName]) this.watchers[settingName] = {}; - if (!this.watchers[settingName][roomId]) this.watchers[settingName][roomId] = []; - this.watchers[settingName][roomId].push(cb); + if (!this.watchers.has(settingName)) this.watchers.set(settingName, new Map()); + if (!this.watchers.get(settingName).has(roomId)) this.watchers.get(settingName).set(roomId, []); + this.watchers.get(settingName).get(roomId).push(cb); } // Proxy for handlers to delegate changes to this manager public unwatchSetting(cb: CallbackFn) { - for (const settingName of Object.keys(this.watchers)) { - for (const roomId of Object.keys(this.watchers[settingName])) { + this.watchers.forEach((map) => { + map.forEach((callbacks) => { let idx; - while ((idx = this.watchers[settingName][roomId].indexOf(cb)) !== -1) { - this.watchers[settingName][roomId].splice(idx, 1); + while ((idx = callbacks.indexOf(cb)) !== -1) { + callbacks.splice(idx, 1); } - } - } + }); + }); } public notifyUpdate(settingName: string, inRoomId: string | null, atLevel: SettingLevel, newValueAtLevel: any) { @@ -56,21 +52,20 @@ export class WatchManager { // we also don't have a reliable way to get the old value of a setting. Instead, we'll just // let it fall through regardless and let the receiver dedupe if they want to. - if (!this.watchers[settingName]) return; + if (!this.watchers.has(settingName)) return; - const roomWatchers = this.watchers[settingName]; + const roomWatchers = this.watchers.get(settingName); const callbacks = []; - if (inRoomId !== null && roomWatchers[inRoomId]) { - callbacks.push(...roomWatchers[inRoomId]); + if (inRoomId !== null && roomWatchers.has(inRoomId)) { + callbacks.push(...roomWatchers.get(inRoomId)); } if (!inRoomId) { - // Fire updates to all the individual room watchers too, as they probably - // care about the change higher up. - callbacks.push(...Object.values(roomWatchers).flat(1)); - } else if (roomWatchers[IRRELEVANT_ROOM]) { - callbacks.push(...roomWatchers[IRRELEVANT_ROOM]); + // Fire updates to all the individual room watchers too, as they probably care about the change higher up. + callbacks.push(...Array.from(roomWatchers.values()).flat(1)); + } else if (roomWatchers.has(IRRELEVANT_ROOM)) { + callbacks.push(...roomWatchers.get(IRRELEVANT_ROOM)); } for (const callback of callbacks) { diff --git a/src/settings/controllers/NotificationControllers.ts b/src/settings/controllers/NotificationControllers.ts index 323c64e9c3..cc5c040a89 100644 --- a/src/settings/controllers/NotificationControllers.ts +++ b/src/settings/controllers/NotificationControllers.ts @@ -16,11 +16,11 @@ limitations under the License. */ import SettingController from "./SettingController"; -import {MatrixClientPeg} from '../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../MatrixClientPeg'; import { SettingLevel } from "../SettingLevel"; // XXX: This feels wrong. -import {PushProcessor} from "matrix-js-sdk/src/pushprocessor"; +import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; // .m.rule.master being enabled means all events match that push rule // default action on this rule is dont_notify, but it could be something else diff --git a/src/settings/controllers/ThemeController.ts b/src/settings/controllers/ThemeController.ts index 15dd64c901..f564f56f54 100644 --- a/src/settings/controllers/ThemeController.ts +++ b/src/settings/controllers/ThemeController.ts @@ -16,7 +16,7 @@ limitations under the License. */ import SettingController from "./SettingController"; -import {DEFAULT_THEME, enumerateThemes} from "../../theme"; +import { DEFAULT_THEME, enumerateThemes } from "../../theme"; import { SettingLevel } from "../SettingLevel"; export default class ThemeController extends SettingController { diff --git a/src/settings/handlers/AccountSettingsHandler.ts b/src/settings/handlers/AccountSettingsHandler.ts index e75faa49c1..60ec849883 100644 --- a/src/settings/handlers/AccountSettingsHandler.ts +++ b/src/settings/handlers/AccountSettingsHandler.ts @@ -15,10 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from '../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../MatrixClientPeg'; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; -import {objectClone, objectKeyChanges} from "../../utils/objects"; -import {SettingLevel} from "../SettingLevel"; +import { objectClone, objectKeyChanges } from "../../utils/objects"; +import { SettingLevel } from "../SettingLevel"; import { WatchManager } from "../WatchManager"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; @@ -73,7 +73,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa const val = event.getContent()['enabled']; this.watchers.notifyUpdate("recent_emoji", null, SettingLevel.ACCOUNT, val); } - } + }; public getValue(settingName: string, roomId: string): any { // Special case URL previews diff --git a/src/settings/handlers/ConfigSettingsHandler.ts b/src/settings/handlers/ConfigSettingsHandler.ts index 791c655a0d..ceed3e856a 100644 --- a/src/settings/handlers/ConfigSettingsHandler.ts +++ b/src/settings/handlers/ConfigSettingsHandler.ts @@ -17,7 +17,7 @@ limitations under the License. import SettingsHandler from "./SettingsHandler"; import SdkConfig from "../../SdkConfig"; -import {isNullOrUndefined} from "matrix-js-sdk/src/utils"; +import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; /** * Gets and sets settings at the "config" level. This handler does not make use of the diff --git a/src/settings/handlers/DeviceSettingsHandler.ts b/src/settings/handlers/DeviceSettingsHandler.ts index 208a265e04..e4ad4cb51e 100644 --- a/src/settings/handlers/DeviceSettingsHandler.ts +++ b/src/settings/handlers/DeviceSettingsHandler.ts @@ -17,8 +17,8 @@ limitations under the License. */ import SettingsHandler from "./SettingsHandler"; -import {MatrixClientPeg} from "../../MatrixClientPeg"; -import {SettingLevel} from "../SettingLevel"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { SettingLevel } from "../SettingLevel"; import { CallbackFn, WatchManager } from "../WatchManager"; import { Layout } from "../Layout"; @@ -109,7 +109,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { "lastRightPanelPhaseForRoom", "lastRightPanelPhaseForGroup", ].includes(settingName)) { - localStorage.setItem(`mx_${settingName}`, JSON.stringify({value: newValue})); + localStorage.setItem(`mx_${settingName}`, JSON.stringify({ value: newValue })); this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); return Promise.resolve(); } diff --git a/src/settings/handlers/RoomAccountSettingsHandler.ts b/src/settings/handlers/RoomAccountSettingsHandler.ts index 416bdddb90..e0345fde8c 100644 --- a/src/settings/handlers/RoomAccountSettingsHandler.ts +++ b/src/settings/handlers/RoomAccountSettingsHandler.ts @@ -54,8 +54,6 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin } this.watchers.notifyUpdate("urlPreviewsEnabled", roomId, SettingLevel.ROOM_ACCOUNT, val); - } else if (event.getType() === "org.matrix.room.color_scheme") { - this.watchers.notifyUpdate("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent()); } else if (event.getType() === "im.vector.web.settings") { // Figure out what changed and fire those updates const prevContent = prevEvent ? prevEvent.getContent() : {}; @@ -79,14 +77,6 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin return !content['disable']; } - // Special case room color - if (settingName === "roomColor") { - // The event content should already be in an appropriate format, we just need - // to get the right value. - // don't fallback to {} because thats truthy and would imply there is an event specifying tint - return this.getSettings(roomId, "org.matrix.room.color_scheme"); - } - // Special case allowed widgets if (settingName === "allowedWidgets") { return this.getSettings(roomId, ALLOWED_WIDGETS_EVENT_TYPE); @@ -104,12 +94,6 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin return MatrixClientPeg.get().setRoomAccountData(roomId, "org.matrix.room.preview_urls", content); } - // Special case room color - if (settingName === "roomColor") { - // The new value should match our requirements, we just need to store it in the right place. - return MatrixClientPeg.get().setRoomAccountData(roomId, "org.matrix.room.color_scheme", newValue); - } - // Special case allowed widgets if (settingName === "allowedWidgets") { return MatrixClientPeg.get().setRoomAccountData(roomId, ALLOWED_WIDGETS_EVENT_TYPE, newValue); diff --git a/src/settings/handlers/RoomDeviceSettingsHandler.ts b/src/settings/handlers/RoomDeviceSettingsHandler.ts index 2fcd58c27c..2a068eb060 100644 --- a/src/settings/handlers/RoomDeviceSettingsHandler.ts +++ b/src/settings/handlers/RoomDeviceSettingsHandler.ts @@ -57,7 +57,7 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler { if (newValue === null) { localStorage.removeItem(this.getKey(settingName, roomId)); } else { - newValue = JSON.stringify({value: newValue}); + newValue = JSON.stringify({ value: newValue }); localStorage.setItem(this.getKey(settingName, roomId), newValue); } diff --git a/src/settings/watchers/FontWatcher.ts b/src/settings/watchers/FontWatcher.ts index 6a17bf2aa3..952a2eb9f3 100644 --- a/src/settings/watchers/FontWatcher.ts +++ b/src/settings/watchers/FontWatcher.ts @@ -63,7 +63,7 @@ export class FontWatcher implements IWatcher { (document.querySelector(":root")).style.fontSize = toPx(fontSize); }; - private setSystemFont = ({useSystemFont, font}) => { + private setSystemFont = ({ useSystemFont, font }) => { document.body.style.fontFamily = useSystemFont ? font : ""; }; } diff --git a/src/shouldHideEvent.ts b/src/shouldHideEvent.ts index 2a47b9c417..985eae85a0 100644 --- a/src/shouldHideEvent.ts +++ b/src/shouldHideEvent.ts @@ -14,9 +14,10 @@ limitations under the License. */ -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import SettingsStore from "./settings/SettingsStore"; +import { IState } from "./components/structures/RoomView"; interface IDiff { isMemberEvent: boolean; @@ -47,11 +48,18 @@ function memberEventDiff(ev: MatrixEvent): IDiff { return diff; } -export default function shouldHideEvent(ev: MatrixEvent): boolean { - // Wrap getValue() for readability. Calling the SettingsStore can be - // fairly resource heavy, so the checks below should avoid hitting it - // where possible. - const isEnabled = (name) => SettingsStore.getValue(name, ev.getRoomId()); +/** + * Determines whether the given event should be hidden from timelines. + * @param ev The event + * @param ctx An optional RoomContext to pull cached settings values from to avoid + * hitting the settings store + */ +export default function shouldHideEvent(ev: MatrixEvent, ctx?: IState): boolean { + // Accessing the settings store directly can be expensive if done frequently, + // so we should prefer using cached values if a RoomContext is available + const isEnabled = ctx ? + name => ctx[name] : + name => SettingsStore.getValue(name, ev.getRoomId()); // Hide redacted events if (ev.isRedacted() && !isEnabled('showRedactions')) return true; diff --git a/src/stores/ActiveWidgetStore.js b/src/stores/ActiveWidgetStore.js index 4ae8dfeddb..b270d99693 100644 --- a/src/stores/ActiveWidgetStore.js +++ b/src/stores/ActiveWidgetStore.js @@ -16,8 +16,8 @@ limitations under the License. import EventEmitter from 'events'; -import {MatrixClientPeg} from '../MatrixClientPeg'; -import {WidgetMessagingStore} from "./widgets/WidgetMessagingStore"; +import { MatrixClientPeg } from '../MatrixClientPeg'; +import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore"; /** * Stores information about the widgets active in the app right now: diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index 393f4f27a1..a3b07435c6 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -66,13 +66,13 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { if (payload.settingName === 'breadcrumb_rooms') { await this.updateRooms(); } else if (payload.settingName === 'breadcrumbs') { - await this.updateState({enabled: SettingsStore.getValue("breadcrumbs", null)}); + await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) }); } } else if (payload.action === 'view_room') { if (payload.auto_join && !this.matrixClient.getRoom(payload.room_id)) { // Queue the room instead of pushing it immediately. We're probably just // waiting for a room join to complete. - this.waitingRooms.push({roomId: payload.room_id, addedTs: Date.now()}); + this.waitingRooms.push({ roomId: payload.room_id, addedTs: Date.now() }); } else { // The tests might not result in a valid room object. const room = this.matrixClient.getRoom(payload.room_id); @@ -83,7 +83,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { protected async onReady() { await this.updateRooms(); - await this.updateState({enabled: SettingsStore.getValue("breadcrumbs", null)}); + await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) }); this.matrixClient.on("Room.myMembership", this.onMyMembership); this.matrixClient.on("Room", this.onRoom); @@ -118,11 +118,11 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { const rooms = roomIds.map(r => this.matrixClient.getRoom(r)).filter(r => !!r); const currentRooms = this.state.rooms || []; if (!arrayHasDiff(rooms, currentRooms)) return; // no change (probably echo) - await this.updateState({rooms}); + await this.updateState({ rooms }); } private async appendRoom(room: Room) { - if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) return; // hide space rooms + if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return; // hide space rooms let updated = false; const rooms = (this.state.rooms || []).slice(); // cheap clone @@ -163,10 +163,9 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { updated = true; } - if (updated) { // Update the breadcrumbs - await this.updateState({rooms}); + await this.updateState({ rooms }); const roomIds = rooms.map(r => r.roomId); if (roomIds.length > 0) { await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds); diff --git a/src/stores/CommunityPrototypeStore.ts b/src/stores/CommunityPrototypeStore.ts index 92e094c83b..0c364430ea 100644 --- a/src/stores/CommunityPrototypeStore.ts +++ b/src/stores/CommunityPrototypeStore.ts @@ -107,8 +107,9 @@ export class CommunityPrototypeStore extends AsyncStoreWithClient { const pl = generalChat.currentState.getStateEvents("m.room.power_levels", ""); if (!pl) return this.isAdminOf(communityId); + const plContent = pl.getContent(); - const invitePl = isNullOrUndefined(pl.invite) ? 50 : Number(pl.invite); + const invitePl = isNullOrUndefined(plContent.invite) ? 50 : Number(plContent.invite); return invitePl <= myMember.powerLevel; } @@ -125,11 +126,11 @@ export class CommunityPrototypeStore extends AsyncStoreWithClient { if (membership === EffectiveMembership.Invite) { try { - const path = utils.encodeUri("/rooms/$roomId/group_info", {$roomId: room.roomId}); - const profile = await this.matrixClient._http.authedRequest( + const path = utils.encodeUri("/rooms/$roomId/group_info", { $roomId: room.roomId }); + const profile = await this.matrixClient.http.authedRequest( undefined, "GET", path, undefined, undefined, - {prefix: "/_matrix/client/unstable/im.vector.custom"}); + { prefix: "/_matrix/client/unstable/im.vector.custom" }); // we use global account data because per-room account data on invites is unreliable await this.matrixClient.setAccountData("im.vector.group_info." + room.roomId, profile); } catch (e) { @@ -154,15 +155,21 @@ export class CommunityPrototypeStore extends AsyncStoreWithClient { } public getInviteProfile(roomId: string): IRoomProfile { - if (!this.matrixClient) return {displayName: null, avatarMxc: null}; + if (!this.matrixClient) return { displayName: null, avatarMxc: null }; const room = this.matrixClient.getRoom(roomId); if (SettingsStore.getValue("feature_communities_v2_prototypes")) { const data = this.matrixClient.getAccountData("im.vector.group_info." + roomId); if (data && data.getContent()) { - return {displayName: data.getContent().name, avatarMxc: data.getContent().avatar_url}; + return { + displayName: data.getContent().name, + avatarMxc: data.getContent().avatar_url, + }; } } - return {displayName: room.name, avatarMxc: room.avatar_url}; + return { + displayName: room.name, + avatarMxc: room.getMxcAvatarUrl(), + }; } protected async onReady(): Promise { diff --git a/src/stores/CustomRoomTagStore.js b/src/stores/CustomRoomTagStore.js index edfc0003cf..899018cf24 100644 --- a/src/stores/CustomRoomTagStore.js +++ b/src/stores/CustomRoomTagStore.js @@ -17,12 +17,12 @@ limitations under the License. import dis from '../dispatcher/dispatcher'; import EventEmitter from 'events'; -import {throttle} from "lodash"; +import { throttle } from "lodash"; import SettingsStore from "../settings/SettingsStore"; -import RoomListStore, {LISTS_UPDATE_EVENT} from "./room-list/RoomListStore"; -import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateStore"; -import {isCustomTag} from "./room-list/models"; -import {objectHasDiff} from "../utils/objects"; +import RoomListStore, { LISTS_UPDATE_EVENT } from "./room-list/RoomListStore"; +import { RoomNotificationStateStore } from "./notifications/RoomNotificationStateStore"; +import { isCustomTag } from "./room-list/models"; +import { objectHasDiff } from "../utils/objects"; function commonPrefix(a, b) { const len = Math.min(a.length, b.length); @@ -52,7 +52,7 @@ class CustomRoomTagStore extends EventEmitter { constructor() { super(); // Initialise state - this._state = {tags: {}}; + this._state = { tags: {} }; // as RoomListStore gets updated by every timeline event // throttle this to only run every 500ms @@ -103,14 +103,14 @@ class CustomRoomTagStore extends EventEmitter { } const avatarLetter = name.substr(prefixes[i].length, 1); const selected = this._state.tags[name]; - return {name, avatarLetter, badgeNotifState, selected}; + return { name, avatarLetter, badgeNotifState, selected }; }); } _onListsUpdated = () => { const newTags = this._getUpdatedTags(); if (!this._state.tags || objectHasDiff(this._state.tags, newTags)) { - this._setState({tags: newTags}); + this._setState({ tags: newTags }); } }; @@ -122,17 +122,17 @@ class CustomRoomTagStore extends EventEmitter { const tag = {}; tag[payload.tag] = !oldTags[payload.tag]; const tags = Object.assign({}, oldTags, tag); - this._setState({tags}); + this._setState({ tags }); } + break; } - break; case 'on_client_not_viable': case 'on_logged_out': { // we assume to always have a tags object in the state - this._state = {tags: {}}; + this._state = { tags: {} }; RoomListStore.instance.off(LISTS_UPDATE_EVENT, this._onListsUpdated); + break; } - break; } } diff --git a/src/stores/FlairStore.js b/src/stores/FlairStore.js index 53d07d0452..23254b98ab 100644 --- a/src/stores/FlairStore.js +++ b/src/stores/FlairStore.js @@ -65,6 +65,10 @@ class FlairStore extends EventEmitter { delete this._userGroups[userId]; } + cachedPublicisedGroups(userId) { + return this._userGroups[userId]; + } + getPublicisedGroupsCached(matrixClient, userId) { if (this._userGroups[userId]) { return Promise.resolve(this._userGroups[userId]); diff --git a/src/stores/GroupFilterOrderStore.js b/src/stores/GroupFilterOrderStore.js index 492322146e..e6401f21f8 100644 --- a/src/stores/GroupFilterOrderStore.js +++ b/src/stores/GroupFilterOrderStore.js @@ -13,12 +13,12 @@ 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 {Store} from 'flux/utils'; +import { Store } from 'flux/utils'; import dis from '../dispatcher/dispatcher'; import GroupStore from './GroupStore'; import Analytics from '../Analytics'; import * as RoomNotifs from "../RoomNotifs"; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { MatrixClientPeg } from '../MatrixClientPeg'; import SettingsStore from "../settings/SettingsStore"; const INITIAL_STATE = { @@ -168,7 +168,7 @@ class GroupFilterOrderStore extends Store { Analytics.trackEvent('FilterStore', 'select_tag'); } - break; + break; case 'deselect_tags': if (payload.tag) { // if a tag is passed, only deselect that tag @@ -181,7 +181,7 @@ class GroupFilterOrderStore extends Store { }); } Analytics.trackEvent('FilterStore', 'deselect_tags'); - break; + break; case 'on_client_not_viable': case 'on_logged_out': { // Reset state without pushing an update to the view, which generally assumes that @@ -207,13 +207,13 @@ class GroupFilterOrderStore extends Store { groupIds.forEach(groupId => { const rooms = GroupStore.getGroupRooms(groupId) - .map(r => client.getRoom(r.roomId)) // to Room objects - .filter(r => r !== null && r !== undefined); // filter out rooms we haven't joined from the group + .map(r => client.getRoom(r.roomId)) // to Room objects + .filter(r => r !== null && r !== undefined); // filter out rooms we haven't joined from the group const badge = rooms && RoomNotifs.aggregateNotificationCount(rooms); changedBadges[groupId] = (badge && badge.count !== 0) ? badge : undefined; }); const newBadges = Object.assign({}, this._state.badges, changedBadges); - this._setState({badges: newBadges}); + this._setState({ badges: newBadges }); } } @@ -231,7 +231,6 @@ class GroupFilterOrderStore extends Store { const tags = this._state.orderedTagsAccountData || []; const removedTags = new Set(this._state.removedTagsAccountData || []); - const tagsToKeep = tags.filter( (t) => (t[0] !== '+' || groupIds.includes(t)) && !removedTags.has(t), ); diff --git a/src/stores/GroupStore.js b/src/stores/GroupStore.js index d4097184a1..f1122cb945 100644 --- a/src/stores/GroupStore.js +++ b/src/stores/GroupStore.js @@ -17,7 +17,7 @@ limitations under the License. import EventEmitter from 'events'; import { groupMemberFromApiObject, groupRoomFromApiObject } from '../groups'; import FlairStore from './FlairStore'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { MatrixClientPeg } from '../MatrixClientPeg'; import dis from '../dispatcher/dispatcher'; function parseMembersResponse(response) { diff --git a/src/stores/HostSignupStore.ts b/src/stores/HostSignupStore.ts index d50a7f6b43..382e21c1be 100644 --- a/src/stores/HostSignupStore.ts +++ b/src/stores/HostSignupStore.ts @@ -15,8 +15,8 @@ limitations under the License. */ import defaultDispatcher from "../dispatcher/dispatcher"; -import {AsyncStore} from "./AsyncStore"; -import {ActionPayload} from "../dispatcher/payloads"; +import { AsyncStore } from "./AsyncStore"; +import { ActionPayload } from "../dispatcher/payloads"; interface IState { hostSignupActive?: boolean; @@ -26,7 +26,7 @@ export class HostSignupStore extends AsyncStore { private static internalInstance = new HostSignupStore(); private constructor() { - super(defaultDispatcher, {hostSignupActive: false}); + super(defaultDispatcher, { hostSignupActive: false }); } public static get instance(): HostSignupStore { diff --git a/src/stores/LifecycleStore.js b/src/stores/LifecycleStore.ts similarity index 62% rename from src/stores/LifecycleStore.js rename to src/stores/LifecycleStore.ts index a12bac7dd6..5381fc58ed 100644 --- a/src/stores/LifecycleStore.js +++ b/src/stores/LifecycleStore.ts @@ -1,7 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +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. @@ -15,11 +13,18 @@ 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 { Store } from 'flux/utils'; + import dis from '../dispatcher/dispatcher'; -import {Store} from 'flux/utils'; +import { ActionPayload } from "../dispatcher/payloads"; + +interface IState { + deferredAction: any; +} const INITIAL_STATE = { - deferred_action: null, + deferredAction: null, }; /** @@ -27,39 +32,38 @@ const INITIAL_STATE = { * store that listens for actions and updates its state accordingly, informing any * listeners (views) of state changes. */ -class LifecycleStore extends Store { +class LifecycleStore extends Store { + private state: IState = INITIAL_STATE; + constructor() { super(dis); - - // Initialise state - this._state = INITIAL_STATE; } - _setState(newState) { - this._state = Object.assign(this._state, newState); + private setState(newState: Partial) { + this.state = Object.assign(this.state, newState); this.__emitChange(); } - __onDispatch(payload) { + protected __onDispatch(payload: ActionPayload) { switch (payload.action) { case 'do_after_sync_prepared': - this._setState({ - deferred_action: payload.deferred_action, + this.setState({ + deferredAction: payload.deferred_action, }); break; case 'cancel_after_sync_prepared': - this._setState({ - deferred_action: null, + this.setState({ + deferredAction: null, }); break; - case 'sync_state': { + case 'syncstate': { if (payload.state !== 'PREPARED') { break; } - if (!this._state.deferred_action) break; - const deferredAction = Object.assign({}, this._state.deferred_action); - this._setState({ - deferred_action: null, + if (!this.state.deferredAction) break; + const deferredAction = Object.assign({}, this.state.deferredAction); + this.setState({ + deferredAction: null, }); dis.dispatch(deferredAction); break; @@ -71,8 +75,8 @@ class LifecycleStore extends Store { } } - reset() { - this._state = Object.assign({}, INITIAL_STATE); + private reset() { + this.state = Object.assign({}, INITIAL_STATE); } } diff --git a/src/stores/ModalWidgetStore.ts b/src/stores/ModalWidgetStore.ts index 9c0da49c8c..851cff3549 100644 --- a/src/stores/ModalWidgetStore.ts +++ b/src/stores/ModalWidgetStore.ts @@ -17,10 +17,10 @@ limitations under the License. import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; -import Modal, {IHandle, IModal} from "../Modal"; +import Modal, { IHandle, IModal } from "../Modal"; import ModalWidgetDialog from "../components/views/dialogs/ModalWidgetDialog"; -import {WidgetMessagingStore} from "./widgets/WidgetMessagingStore"; -import {IModalWidgetOpenRequestData, IModalWidgetReturnData, Widget} from "matrix-widget-api"; +import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore"; +import { IModalWidgetOpenRequestData, IModalWidgetReturnData, Widget } from "matrix-widget-api"; interface IState { modal?: IModal; @@ -56,7 +56,7 @@ export class ModalWidgetStore extends AsyncStoreWithClient { if (this.modalInstance) return; this.openSourceWidgetId = sourceWidget.id; this.modalInstance = Modal.createTrackedDialog('Modal Widget', '', ModalWidgetDialog, { - widgetDefinition: {...requestData}, + widgetDefinition: { ...requestData }, widgetRoomId, sourceWidgetId: sourceWidget.id, onFinished: (success: boolean, data?: IModalWidgetReturnData) => { diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts index bb45456f1e..fb2dd527cb 100644 --- a/src/stores/OwnProfileStore.ts +++ b/src/stores/OwnProfileStore.ts @@ -22,7 +22,7 @@ import { User } from "matrix-js-sdk/src/models/user"; import { throttle } from "lodash"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { _t } from "../languageHandler"; -import {mediaFromMxc} from "../customisations/Media"; +import { mediaFromMxc } from "../customisations/Media"; interface IState { displayName?: string; @@ -134,7 +134,7 @@ export class OwnProfileStore extends AsyncStoreWithClient { } else { window.localStorage.removeItem(KEY_AVATAR_URL); } - await this.updateState({displayName: profileInfo.displayname, avatarUrl: profileInfo.avatar_url}); + await this.updateState({ displayName: profileInfo.displayname, avatarUrl: profileInfo.avatar_url }); }; private onStateEvents = throttle(async (ev: MatrixEvent) => { @@ -142,5 +142,5 @@ export class OwnProfileStore extends AsyncStoreWithClient { if (ev.getType() === 'm.room.member' && ev.getSender() === myUserId && ev.getStateKey() === myUserId) { await this.onProfileUpdate(); } - }, 200, {trailing: true, leading: true}); + }, 200, { trailing: true, leading: true }); } diff --git a/src/stores/RightPanelStore.ts b/src/stores/RightPanelStore.ts index c539fcdb40..521d124bad 100644 --- a/src/stores/RightPanelStore.ts +++ b/src/stores/RightPanelStore.ts @@ -15,12 +15,12 @@ limitations under the License. */ import dis from '../dispatcher/dispatcher'; -import {pendingVerificationRequestForUser} from '../verification'; -import {Store} from 'flux/utils'; +import { pendingVerificationRequestForUser } from '../verification'; +import { Store } from 'flux/utils'; import SettingsStore from "../settings/SettingsStore"; -import {RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS} from "./RightPanelStorePhases"; -import {ActionPayload} from "../dispatcher/payloads"; -import {Action} from '../dispatcher/actions'; +import { RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS } from "./RightPanelStorePhases"; +import { ActionPayload } from "../dispatcher/payloads"; +import { Action } from '../dispatcher/actions'; import { SettingLevel } from "../settings/SettingLevel"; interface RightPanelStoreState { @@ -67,6 +67,7 @@ const MEMBER_INFO_PHASES = [ export default class RightPanelStore extends Store { private static instance: RightPanelStore; private state: RightPanelStoreState; + private lastRoomId: string; constructor() { super(dis); @@ -146,24 +147,29 @@ export default class RightPanelStore extends Store { __onDispatch(payload: ActionPayload) { switch (payload.action) { case 'view_room': + if (payload.room_id === this.lastRoomId) break; // skip this transition, probably a permalink + // fallthrough case 'view_group': + this.lastRoomId = payload.room_id; + // Reset to the member list if we're viewing member info if (MEMBER_INFO_PHASES.includes(this.state.lastRoomPhase)) { - this.setState({lastRoomPhase: RightPanelPhases.RoomMemberList, lastRoomPhaseParams: {}}); + this.setState({ lastRoomPhase: RightPanelPhases.RoomMemberList, lastRoomPhaseParams: {} }); } // Do the same for groups if (this.state.lastGroupPhase === RightPanelPhases.GroupMemberInfo) { - this.setState({lastGroupPhase: RightPanelPhases.GroupMemberList}); + this.setState({ lastGroupPhase: RightPanelPhases.GroupMemberList }); } break; case Action.SetRightPanelPhase: { let targetPhase = payload.phase; let refireParams = payload.refireParams; + const allowClose = payload.allowClose ?? true; // redirect to EncryptionPanel if there is an ongoing verification request if (targetPhase === RightPanelPhases.RoomMemberInfo && payload.refireParams) { - const {member} = payload.refireParams; + const { member } = payload.refireParams; const pendingRequest = pendingVerificationRequestForUser(member); if (pendingRequest) { targetPhase = RightPanelPhases.EncryptionPanel; @@ -192,7 +198,7 @@ export default class RightPanelStore extends Store { }); } } else { - if (targetPhase === this.state.lastRoomPhase && !refireParams) { + if (targetPhase === this.state.lastRoomPhase && !refireParams && allowClose) { this.setState({ showRoomPanel: !this.state.showRoomPanel, previousPhase: null, diff --git a/src/stores/RightPanelStorePhases.ts b/src/stores/RightPanelStorePhases.ts index aea78c7460..d62f6c6110 100644 --- a/src/stores/RightPanelStorePhases.ts +++ b/src/stores/RightPanelStorePhases.ts @@ -24,6 +24,7 @@ export enum RightPanelPhases { EncryptionPanel = 'EncryptionPanel', RoomSummary = 'RoomSummary', Widget = 'Widget', + PinnedMessages = "PinnedMessages", Room3pidMemberInfo = 'Room3pidMemberInfo', // Group stuff @@ -43,6 +44,7 @@ export enum RightPanelPhases { export const RIGHT_PANEL_PHASES_NO_ARGS = [ RightPanelPhases.RoomSummary, RightPanelPhases.NotificationPanel, + RightPanelPhases.PinnedMessages, RightPanelPhases.FilePanel, RightPanelPhases.RoomMemberList, RightPanelPhases.GroupMemberList, diff --git a/src/stores/RoomScrollStateStore.js b/src/stores/RoomScrollStateStore.js deleted file mode 100644 index 07848283d1..0000000000 --- a/src/stores/RoomScrollStateStore.js +++ /dev/null @@ -1,50 +0,0 @@ -/* -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. -*/ - -/** - * Stores where the user has scrolled to in each room - */ -class RoomScrollStateStore { - constructor() { - // A map from room id to scroll state. - // - // If there is no special scroll state (ie, we are following the live - // timeline), the scroll state is null. Otherwise, it is an object with - // the following properties: - // - // focussedEvent: the ID of the 'focussed' event. Typically this is - // the last event fully visible in the viewport, though if we - // have done an explicit scroll to an explicit event, it will be - // that event. - // - // pixelOffset: the number of pixels the window is scrolled down - // from the focussedEvent. - this._scrollStateMap = {}; - } - - getScrollState(roomId) { - return this._scrollStateMap[roomId]; - } - - setScrollState(roomId, scrollState) { - this._scrollStateMap[roomId] = scrollState; - } -} - -if (global.mx_RoomScrollStateStore === undefined) { - global.mx_RoomScrollStateStore = new RoomScrollStateStore(); -} -export default global.mx_RoomScrollStateStore; diff --git a/src/stores/RoomScrollStateStore.ts b/src/stores/RoomScrollStateStore.ts new file mode 100644 index 0000000000..1d8d4eb8fd --- /dev/null +++ b/src/stores/RoomScrollStateStore.ts @@ -0,0 +1,53 @@ +/* +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. +*/ + +export interface ScrollState { + focussedEvent: string; + pixelOffset: number; +} + +/** + * Stores where the user has scrolled to in each room + */ +export class RoomScrollStateStore { + // A map from room id to scroll state. + // + // If there is no special scroll state (ie, we are following the live + // timeline), the scroll state is null. Otherwise, it is an object with + // the following properties: + // + // focussedEvent: the ID of the 'focussed' event. Typically this is + // the last event fully visible in the viewport, though if we + // have done an explicit scroll to an explicit event, it will be + // that event. + // + // pixelOffset: the number of pixels the window is scrolled down + // from the focussedEvent. + private scrollStateMap = new Map(); + + public getScrollState(roomId: string): ScrollState { + return this.scrollStateMap.get(roomId); + } + + setScrollState(roomId: string, scrollState: ScrollState): void { + this.scrollStateMap.set(roomId, scrollState); + } +} + +if (window.mxRoomScrollStateStore === undefined) { + window.mxRoomScrollStateStore = new RoomScrollStateStore(); +} +export default window.mxRoomScrollStateStore; diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index a5bdb7ef33..10f42f3166 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -17,17 +17,18 @@ limitations under the License. */ import React from "react"; -import {Store} from 'flux/utils'; -import {MatrixError} from "matrix-js-sdk/src/http-api"; +import { Store } from 'flux/utils'; +import { MatrixError } from "matrix-js-sdk/src/http-api"; import dis from '../dispatcher/dispatcher'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { MatrixClientPeg } from '../MatrixClientPeg'; import * as sdk from '../index'; import Modal from '../Modal'; import { _t } from '../languageHandler'; import { getCachedRoomIDForAlias, storeRoomAliasInCache } from '../RoomAliasCache'; -import {ActionPayload} from "../dispatcher/payloads"; -import {retry} from "../utils/promise"; +import { ActionPayload } from "../dispatcher/payloads"; +import { Action } from "../dispatcher/actions"; +import { retry } from "../utils/promise"; import CountlyAnalytics from "../CountlyAnalytics"; const NUM_JOIN_RETRY = 5; @@ -53,8 +54,6 @@ const INITIAL_STATE = { // Any error that has occurred during loading roomLoadError: null, - forwardingEvent: null, - quotingEvent: null, replyingToEvent: null, @@ -62,6 +61,8 @@ const INITIAL_STATE = { shouldPeek: false, viaServers: [], + + wasContextSwitch: false, }; /** @@ -116,6 +117,7 @@ class RoomViewStore extends Store { roomId: null, roomAlias: null, viaServers: [], + wasContextSwitch: false, }); break; case 'view_room_error': @@ -133,24 +135,19 @@ class RoomViewStore extends Store { break; // join_room: // - opts: options for joinRoom - case 'join_room': + case Action.JoinRoom: this.joinRoom(payload); break; - case 'join_room_error': + case Action.JoinRoomError: this.joinRoomError(payload); break; - case 'join_room_ready': + case Action.JoinRoomReady: this.setState({ shouldPeek: false }); break; case 'on_client_not_viable': case 'on_logged_out': this.reset(); break; - case 'forward_event': - this.setState({ - forwardingEvent: payload.event, - }); - break; case 'reply_to_event': // If currently viewed room does not match the room in which we wish to reply then change rooms // this can happen when performing a search across all rooms @@ -167,9 +164,11 @@ class RoomViewStore extends Store { } break; case 'open_room_settings': { + // FIXME: Using an import will result in test failures const RoomSettingsDialog = sdk.getComponent("dialogs.RoomSettingsDialog"); Modal.createTrackedDialog('Room settings', '', RoomSettingsDialog, { roomId: payload.room_id || this.state.roomId, + initialTabId: payload.initial_tab_id, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); break; } @@ -183,7 +182,6 @@ class RoomViewStore extends Store { roomAlias: payload.room_alias, initialEventId: payload.event_id, isInitialEventHighlighted: payload.highlighted, - forwardingEvent: null, roomLoading: false, roomLoadError: null, // should peek by default @@ -195,6 +193,7 @@ class RoomViewStore extends Store { // pull the user out of Room Settings isEditingSettings: false, viaServers: payload.via_servers, + wasContextSwitch: payload.context_switch, }; // Allow being given an event to be replied to when switching rooms but sanity check its for this room @@ -202,18 +201,14 @@ class RoomViewStore extends Store { newState.replyingToEvent = payload.replyingToEvent; } - if (this.state.forwardingEvent) { - dis.dispatch({ - action: 'send_event', - room_id: newState.roomId, - event: this.state.forwardingEvent, - }); - } - this.setState(newState); if (payload.auto_join) { - this.joinRoom(payload); + dis.dispatch({ + ...payload, + action: Action.JoinRoom, + roomId: payload.room_id, + }); } } else if (payload.room_alias) { // Try the room alias to room ID navigation cache first to avoid @@ -231,6 +226,7 @@ class RoomViewStore extends Store { roomLoading: true, roomLoadError: null, viaServers: payload.via_servers, + wasContextSwitch: payload.context_switch, }); try { const result = await MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias); @@ -256,6 +252,8 @@ class RoomViewStore extends Store { room_alias: payload.room_alias, auto_join: payload.auto_join, oob_data: payload.oob_data, + viaServers: payload.via_servers, + wasContextSwitch: payload.context_switch, }); } } @@ -266,7 +264,6 @@ class RoomViewStore extends Store { roomAlias: payload.room_alias, roomLoading: false, roomLoadError: payload.err, - viaServers: [], }); } @@ -280,7 +277,7 @@ class RoomViewStore extends Store { const address = this.state.roomAlias || this.state.roomId; const viaServers = this.state.viaServers || []; try { - await retry(() => cli.joinRoom(address, { + await retry(() => cli.joinRoom(address, { viaServers, ...payload.opts, }), NUM_JOIN_RETRY, (err) => { @@ -292,41 +289,16 @@ class RoomViewStore extends Store { // We do *not* clear the 'joining' flag because the Room object and/or our 'joined' member event may not // have come down the sync stream yet, and that's the point at which we'd consider the user joined to the // room. - dis.dispatch({ action: 'join_room_ready' }); + dis.dispatch({ + action: Action.JoinRoomReady, + roomId: this.state.roomId, + }); } catch (err) { dis.dispatch({ - action: 'join_room_error', + action: Action.JoinRoomError, + roomId: this.state.roomId, err: err, }); - - let msg = err.message ? err.message : JSON.stringify(err); - console.log("Failed to join room:", msg); - - if (err.name === "ConnectionError") { - msg = _t("There was an error joining the room"); - } else if (err.errcode === 'M_INCOMPATIBLE_ROOM_VERSION') { - msg =
    - {_t("Sorry, your homeserver is too old to participate in this room.")}
    - {_t("Please contact your homeserver administrator.")} -
    ; - } else if (err.httpStatus === 404) { - const invitingUserId = this.getInvitingUserId(this.state.roomId); - // only provide a better error message for invites - if (invitingUserId) { - // if the inviting user is on the same HS, there can only be one cause: they left. - if (invitingUserId.endsWith(`:${MatrixClientPeg.get().getDomain()}`)) { - msg = _t("The person who invited you already left the room."); - } else { - msg = _t("The person who invited you already left the room, or their server is offline."); - } - } - } - - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to join room', '', ErrorDialog, { - title: _t("Failed to join room"), - description: msg, - }); } } @@ -345,6 +317,36 @@ class RoomViewStore extends Store { joining: false, joinError: payload.err, }); + const err = payload.err; + let msg = err.message ? err.message : JSON.stringify(err); + console.log("Failed to join room:", msg); + + if (err.name === "ConnectionError") { + msg = _t("There was an error joining the room"); + } else if (err.errcode === 'M_INCOMPATIBLE_ROOM_VERSION') { + msg =
    + {_t("Sorry, your homeserver is too old to participate in this room.")}
    + {_t("Please contact your homeserver administrator.")} +
    ; + } else if (err.httpStatus === 404) { + const invitingUserId = this.getInvitingUserId(this.state.roomId); + // only provide a better error message for invites + if (invitingUserId) { + // if the inviting user is on the same HS, there can only be one cause: they left. + if (invitingUserId.endsWith(`:${MatrixClientPeg.get().getDomain()}`)) { + msg = _t("The person who invited you already left the room."); + } else { + msg = _t("The person who invited you already left the room, or their server is offline."); + } + } + } + + // FIXME: Using an import will result in test failures + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to join room', '', ErrorDialog, { + title: _t("Failed to join room"), + description: msg, + }); } public reset() { @@ -413,11 +415,6 @@ class RoomViewStore extends Store { return this.state.joinError; } - // The mxEvent if one is about to be forwarded - public getForwardingEvent() { - return this.state.forwardingEvent; - } - // The mxEvent if one is currently being replied to/quoted public getQuotingEvent() { return this.state.replyingToEvent; @@ -426,6 +423,10 @@ class RoomViewStore extends Store { public shouldPeek() { return this.state.shouldPeek; } + + public getWasContextSwitch() { + return this.state.wasContextSwitch; + } } let singletonRoomViewStore = null; diff --git a/src/stores/SetupEncryptionStore.js b/src/stores/SetupEncryptionStore.ts similarity index 72% rename from src/stores/SetupEncryptionStore.js rename to src/stores/SetupEncryptionStore.ts index 5f0054ff24..7197374502 100644 --- a/src/stores/SetupEncryptionStore.js +++ b/src/stores/SetupEncryptionStore.ts @@ -15,29 +15,43 @@ limitations under the License. */ import EventEmitter from 'events'; -import { MatrixClientPeg } from '../MatrixClientPeg'; -import { accessSecretStorage, AccessCancelledError } from '../SecurityManager'; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; +import { ISecretStorageKeyInfo } from "matrix-js-sdk/src/crypto/api"; import { PHASE_DONE as VERIF_PHASE_DONE } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -export const PHASE_LOADING = 0; -export const PHASE_INTRO = 1; -export const PHASE_BUSY = 2; -export const PHASE_DONE = 3; //final done stage, but still showing UX -export const PHASE_CONFIRM_SKIP = 4; -export const PHASE_FINISHED = 5; //UX can be closed +import { MatrixClientPeg } from '../MatrixClientPeg'; +import { accessSecretStorage, AccessCancelledError } from '../SecurityManager'; + +export enum Phase { + Loading = 0, + Intro = 1, + Busy = 2, + Done = 3, // final done stage, but still showing UX + ConfirmSkip = 4, + Finished = 5, // UX can be closed +} export class SetupEncryptionStore extends EventEmitter { - static sharedInstance() { - if (!global.mx_SetupEncryptionStore) global.mx_SetupEncryptionStore = new SetupEncryptionStore(); - return global.mx_SetupEncryptionStore; + private started: boolean; + public phase: Phase; + public verificationRequest: VerificationRequest; + public backupInfo: IKeyBackupInfo; + public keyId: string; + public keyInfo: ISecretStorageKeyInfo; + public hasDevicesToVerifyAgainst: boolean; + + public static sharedInstance() { + if (!window.mxSetupEncryptionStore) window.mxSetupEncryptionStore = new SetupEncryptionStore(); + return window.mxSetupEncryptionStore; } - start() { - if (this._started) { + public start(): void { + if (this.started) { return; } - this._started = true; - this.phase = PHASE_LOADING; + this.started = true; + this.phase = Phase.Loading; this.verificationRequest = null; this.backupInfo = null; @@ -48,34 +62,34 @@ export class SetupEncryptionStore extends EventEmitter { const cli = MatrixClientPeg.get(); cli.on("crypto.verification.request", this.onVerificationRequest); - cli.on('userTrustStatusChanged', this._onUserTrustStatusChanged); + cli.on('userTrustStatusChanged', this.onUserTrustStatusChanged); const requestsInProgress = cli.getVerificationRequestsToDeviceInProgress(cli.getUserId()); if (requestsInProgress.length) { // If there are multiple, we take the most recent. Equally if the user sends another request from // another device after this screen has been shown, we'll switch to the new one, so this // generally doesn't support multiple requests. - this._setActiveVerificationRequest(requestsInProgress[requestsInProgress.length - 1]); + this.setActiveVerificationRequest(requestsInProgress[requestsInProgress.length - 1]); } this.fetchKeyInfo(); } - stop() { - if (!this._started) { + public stop(): void { + if (!this.started) { return; } - this._started = false; + this.started = false; if (this.verificationRequest) { this.verificationRequest.off("change", this.onVerificationRequestChange); } if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("crypto.verification.request", this.onVerificationRequest); - MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged); + MatrixClientPeg.get().removeListener('userTrustStatusChanged', this.onUserTrustStatusChanged); } } - async fetchKeyInfo() { + public async fetchKeyInfo(): Promise { const cli = MatrixClientPeg.get(); const keys = await cli.isSecretStored('m.cross_signing.master', false); if (keys === null || Object.keys(keys).length === 0) { @@ -97,15 +111,15 @@ export class SetupEncryptionStore extends EventEmitter { if (!this.hasDevicesToVerifyAgainst && !this.keyInfo) { // skip before we can even render anything. - this.phase = PHASE_FINISHED; + this.phase = Phase.Finished; } else { - this.phase = PHASE_INTRO; + this.phase = Phase.Intro; } this.emit("update"); } - async usePassPhrase() { - this.phase = PHASE_BUSY; + public async usePassPhrase(): Promise { + this.phase = Phase.Busy; this.emit("update"); const cli = MatrixClientPeg.get(); try { @@ -120,7 +134,7 @@ export class SetupEncryptionStore extends EventEmitter { // passphase cached for that work. This dialog itself will only wait // on the first trust check, and the key backup restore will happen // in the background. - await new Promise((resolve, reject) => { + await new Promise((resolve: (value?: unknown) => void, reject: (reason?: any) => void) => { accessSecretStorage(async () => { await cli.checkOwnCrossSigningTrust(); resolve(); @@ -134,7 +148,7 @@ export class SetupEncryptionStore extends EventEmitter { }); if (cli.getCrossSigningId()) { - this.phase = PHASE_DONE; + this.phase = Phase.Done; this.emit("update"); } } catch (e) { @@ -142,25 +156,25 @@ export class SetupEncryptionStore extends EventEmitter { console.log(e); } // this will throw if the user hits cancel, so ignore - this.phase = PHASE_INTRO; + this.phase = Phase.Intro; this.emit("update"); } } - _onUserTrustStatusChanged = (userId) => { + private onUserTrustStatusChanged = (userId: string) => { if (userId !== MatrixClientPeg.get().getUserId()) return; const publicKeysTrusted = MatrixClientPeg.get().getCrossSigningId(); if (publicKeysTrusted) { - this.phase = PHASE_DONE; + this.phase = Phase.Done; this.emit("update"); } - } + }; - onVerificationRequest = (request) => { - this._setActiveVerificationRequest(request); - } + public onVerificationRequest = (request: VerificationRequest): void => { + this.setActiveVerificationRequest(request); + }; - onVerificationRequestChange = () => { + public onVerificationRequestChange = (): void => { if (this.verificationRequest.cancelled) { this.verificationRequest.off("change", this.onVerificationRequestChange); this.verificationRequest = null; @@ -172,34 +186,34 @@ export class SetupEncryptionStore extends EventEmitter { // cross signing to be ready to use, so wait for the user trust status to // change (or change to DONE if it's already ready). const publicKeysTrusted = MatrixClientPeg.get().getCrossSigningId(); - this.phase = publicKeysTrusted ? PHASE_DONE : PHASE_BUSY; + this.phase = publicKeysTrusted ? Phase.Done : Phase.Busy; this.emit("update"); } - } + }; - skip() { - this.phase = PHASE_CONFIRM_SKIP; + public skip(): void { + this.phase = Phase.ConfirmSkip; this.emit("update"); } - skipConfirm() { - this.phase = PHASE_FINISHED; + public skipConfirm(): void { + this.phase = Phase.Finished; this.emit("update"); } - returnAfterSkip() { - this.phase = PHASE_INTRO; + public returnAfterSkip(): void { + this.phase = Phase.Intro; this.emit("update"); } - done() { - this.phase = PHASE_FINISHED; + public done(): void { + this.phase = Phase.Finished; this.emit("update"); // async - ask other clients for keys, if necessary - MatrixClientPeg.get()._crypto.cancelAndResendAllOutgoingKeyRequests(); + MatrixClientPeg.get().crypto.cancelAndResendAllOutgoingKeyRequests(); } - async _setActiveVerificationRequest(request) { + private async setActiveVerificationRequest(request: VerificationRequest): Promise { if (request.otherUserId !== MatrixClientPeg.get().getUserId()) return; if (this.verificationRequest) { diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index ec6227e45e..6300c1a936 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -14,27 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {sortBy, throttle} from "lodash"; -import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; -import {Room} from "matrix-js-sdk/src/models/room"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import { ListIteratee, Many, sortBy, throttle } from "lodash"; +import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import {AsyncStoreWithClient} from "./AsyncStoreWithClient"; +import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; -import {ActionPayload} from "../dispatcher/payloads"; +import { ActionPayload } from "../dispatcher/payloads"; import RoomListStore from "./room-list/RoomListStore"; import SettingsStore from "../settings/SettingsStore"; import DMRoomMap from "../utils/DMRoomMap"; -import {FetchRoomFn} from "./notifications/ListNotificationState"; -import {SpaceNotificationState} from "./notifications/SpaceNotificationState"; -import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateStore"; -import {DefaultTagID} from "./room-list/models"; -import {EnhancedMap, mapDiff} from "../utils/maps"; -import {setHasDiff} from "../utils/sets"; -import {objectDiff} from "../utils/objects"; -import {arrayHasDiff} from "../utils/arrays"; -import {ISpaceSummaryEvent, ISpaceSummaryRoom} from "../components/structures/SpaceRoomDirectory"; +import { FetchRoomFn } from "./notifications/ListNotificationState"; +import { SpaceNotificationState } from "./notifications/SpaceNotificationState"; +import { RoomNotificationStateStore } from "./notifications/RoomNotificationStateStore"; +import { DefaultTagID } from "./room-list/models"; +import { EnhancedMap, mapDiff } from "../utils/maps"; +import { setHasDiff } from "../utils/sets"; +import { ISpaceSummaryEvent, ISpaceSummaryRoom } from "../components/structures/SpaceRoomDirectory"; import RoomViewStore from "./RoomViewStore"; +import { Action } from "../dispatcher/actions"; +import { arrayHasDiff } from "../utils/arrays"; +import { objectDiff } from "../utils/objects"; +import { arrayHasOrderChange } from "../utils/arrays"; +import { reorderLexicographically } from "../utils/stringOrderField"; type SpaceKey = string | symbol; @@ -50,9 +53,14 @@ export const UPDATE_INVITED_SPACES = Symbol("invited-spaces"); export const UPDATE_SELECTED_SPACE = Symbol("selected-space"); // Space Room ID/HOME_SPACE will be emitted when a Space's children change +export interface ISuggestedRoom extends ISpaceSummaryRoom { + viaServers: string[]; +} + const MAX_SUGGESTED_ROOMS = 20; -const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "home_space"}`; +const homeSpaceKey = SettingsStore.getValue("feature_spaces.all_rooms") ? "ALL_ROOMS" : "HOME_SPACE"; +const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || homeSpaceKey}`; const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] return arr.reduce((result, room: Room) => { @@ -61,16 +69,19 @@ const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, }, [[], []]); }; -const getOrder = (ev: MatrixEvent): string | null => { - const content = ev.getContent(); - if (typeof content.order === "string" && Array.from(content.order).every((c: string) => { +const validOrder = (order: string): string | undefined => { + if (typeof order === "string" && order.length <= 50 && Array.from(order).every((c: string) => { const charCode = c.charCodeAt(0); - return charCode >= 0x20 && charCode <= 0x7F; + return charCode >= 0x20 && charCode <= 0x7E; })) { - return content.order; + return order; } - return null; -} +}; + +// For sorting space children using a validated `order`, `m.room.create`'s `origin_server_ts`, `room_id` +export const getChildOrder = (order: string, creationTs: number, roomId: string): Array>> => { + return [validOrder(order), creationTs, roomId]; +}; const getRoomFn: FetchRoomFn = (room: Room) => { return RoomNotificationStateStore.instance.getRoomState(room); @@ -87,14 +98,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private orphanedRooms = new Set(); // Map from room ID to set of spaces which list it as a child private parentMap = new EnhancedMap>(); - // Map from space key to SpaceNotificationState instance representing that space + // Map from SpaceKey to SpaceNotificationState instance representing that space private notificationStateMap = new Map(); // Map from space key to Set of room IDs that should be shown as part of that space's filter - private spaceFilteredRooms = new Map>(); - // The space currently selected in the Space Panel - if null then `Home` is selected + private spaceFilteredRooms = new Map>(); + // The space currently selected in the Space Panel - if null then Home is selected private _activeSpace?: Room = null; - private _suggestedRooms: ISpaceSummaryRoom[] = []; + private _suggestedRooms: ISuggestedRoom[] = []; private _invitedSpaces = new Set(); + private spaceOrderLocalEchoMap = new Map(); public get invitedSpaces(): Room[] { return Array.from(this._invitedSpaces); @@ -108,10 +120,17 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this._activeSpace || null; } - public get suggestedRooms(): ISpaceSummaryRoom[] { + public get suggestedRooms(): ISuggestedRoom[] { return this._suggestedRooms; } + /** + * Sets the active space, updates room list filters, + * optionally switches the user's room back to where they were when they last viewed that space. + * @param space which space to switch to. + * @param contextSwitch whether to switch the user's context, + * should not be done when the space switch is done implicitly due to another event like switching room. + */ public async setActiveSpace(space: Room | null, contextSwitch = true) { if (space === this.activeSpace || (space && !space?.isSpaceRoom())) return; @@ -126,7 +145,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // if the space being selected is an invite then always view that invite // else if the last viewed room in this space is joined then view that // else view space home or home depending on what is being clicked on - if (space?.getMyMembership !== "invite" && + if (space?.getMyMembership() !== "invite" && this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" ) { defaultDispatcher.dispatch({ @@ -155,31 +174,41 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } if (space) { - const data = await this.fetchSuggestedRooms(space); + const suggestedRooms = await this.fetchSuggestedRooms(space); if (this._activeSpace === space) { - this._suggestedRooms = data.rooms.filter(roomInfo => { - return roomInfo.room_type !== RoomType.Space - && this.matrixClient.getRoom(roomInfo.room_id)?.getMyMembership() !== "join"; - }); + this._suggestedRooms = suggestedRooms; this.emit(SUGGESTED_ROOMS, this._suggestedRooms); } } } - public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS) => { + public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS): Promise => { try { const data: { rooms: ISpaceSummaryRoom[]; events: ISpaceSummaryEvent[]; } = await this.matrixClient.getSpaceSummary(space.roomId, 0, true, false, limit); - return data; + + const viaMap = new EnhancedMap>(); + data.events.forEach(ev => { + if (ev.type === EventType.SpaceChild && ev.content.via?.length) { + ev.content.via.forEach(via => { + viaMap.getOrCreate(ev.state_key, new Set()).add(via); + }); + } + }); + + return data.rooms.filter(roomInfo => { + return roomInfo.room_type !== RoomType.Space + && this.matrixClient.getRoom(roomInfo.room_id)?.getMyMembership() !== "join"; + }).map(roomInfo => ({ + ...roomInfo, + viaServers: Array.from(viaMap.get(roomInfo.room_id) || []), + })); } catch (e) { console.error(e); } - return { - rooms: [], - events: [], - }; + return []; }; public addRoomToSpace(space: Room, roomId: string, via: string[], suggested = false, autoJoin = false) { @@ -193,9 +222,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private getChildren(spaceId: string): Room[] { const room = this.matrixClient?.getRoom(spaceId); const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via); - return sortBy(childEvents, getOrder) - .map(ev => this.matrixClient.getRoom(ev.getStateKey())) - .filter(room => room?.getMyMembership() === "join" || room?.getMyMembership() === "invite") || []; + return sortBy(childEvents, ev => { + const roomId = ev.getStateKey(); + const childRoom = this.matrixClient?.getRoom(roomId); + const createTs = childRoom?.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs(); + return getChildOrder(ev.getContent().order, createTs, roomId); + }).map(ev => { + return this.matrixClient.getRoom(ev.getStateKey()); + }).filter(room => { + return room?.getMyMembership() === "join" || room?.getMyMembership() === "invite"; + }) || []; } public getChildRooms(spaceId: string): Room[] { @@ -212,7 +248,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return room?.currentState.getStateEvents(EventType.SpaceParent) .filter(ev => { const content = ev.getContent(); - if (!content?.via) return false; + if (!content?.via?.length) return false; // TODO apply permissions check to verify that the parent mapping is valid if (canonicalOnly && !content?.canonical) return false; return true; @@ -227,6 +263,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public getSpaceFilteredRoomIds = (space: Room | null): Set => { + if (!space && SettingsStore.getValue("feature_spaces.all_rooms")) { + return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); + } return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); }; @@ -299,32 +338,33 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // rootSpaces.push(space); // }); - this.orphanedRooms = new Set(orphanedRooms); - this.rootSpaces = rootSpaces; + this.orphanedRooms = new Set(orphanedRooms.map(r => r.roomId)); + this.rootSpaces = this.sortRootSpaces(rootSpaces); this.parentMap = backrefs; // if the currently selected space no longer exists, remove its selection if (this._activeSpace && detachedNodes.has(this._activeSpace)) { - this.setActiveSpace(null); + this.setActiveSpace(null, false); } this.onRoomsUpdate(); // TODO only do this if a change has happened this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces); // build initial state of invited spaces as we would have missed the emitted events about the room at launch - this._invitedSpaces = new Set(invitedSpaces); + this._invitedSpaces = new Set(this.sortRootSpaces(invitedSpaces)); this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); - }, 100, {trailing: true, leading: true}); + }, 100, { trailing: true, leading: true }); - onSpaceUpdate = () => { + private onSpaceUpdate = () => { this.rebuild(); - } + }; private showInHomeSpace = (room: Room) => { + if (SettingsStore.getValue("feature_spaces.all_rooms")) return true; if (room.isSpaceRoom()) return false; return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space - || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite) // show all favourites + || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite); // show all favourites }; // Update a given room due to its tag changing (e.g DM-ness or Fav-ness) @@ -352,15 +392,17 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const oldFilteredRooms = this.spaceFilteredRooms; this.spaceFilteredRooms = new Map(); - // put all room invites in the Home Space - const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); - this.spaceFilteredRooms.set(HOME_SPACE, new Set(invites.map(room => room.roomId))); + if (!SettingsStore.getValue("feature_spaces.all_rooms")) { + // put all room invites in the Home Space + const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); + this.spaceFilteredRooms.set(HOME_SPACE, new Set(invites.map(room => room.roomId))); - visibleRooms.forEach(room => { - if (this.showInHomeSpace(room)) { - this.spaceFilteredRooms.get(HOME_SPACE).add(room.roomId); - } - }); + visibleRooms.forEach(room => { + if (this.showInHomeSpace(room)) { + this.spaceFilteredRooms.get(HOME_SPACE).add(room.roomId); + } + }); + } this.rootSpaces.forEach(s => { // traverse each space tree in DFS to build up the supersets as you go up, @@ -377,12 +419,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const roomIds = new Set(childRooms.map(r => r.roomId)); const space = this.matrixClient?.getRoom(spaceId); - // Add relevant DMs - space?.getJoinedMembers().forEach(member => { - DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => { - roomIds.add(roomId); + if (SettingsStore.getValue("feature_spaces.space_member_dms")) { + // Add relevant DMs + space?.getMembers().forEach(member => { + if (member.membership !== "join" && member.membership !== "invite") return; + DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => { + roomIds.add(roomId); + }); }); - }); + } const newPath = new Set(parentPath).add(spaceId); childSpaces.forEach(childSpace => { @@ -406,9 +451,40 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.spaceFilteredRooms.forEach((roomIds, s) => { // Update NotificationStates - this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => roomIds.has(room.roomId))); + this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => { + if (roomIds.has(room.roomId)) { + if (s !== HOME_SPACE && SettingsStore.getValue("feature_spaces.space_dm_badges")) return true; + + return !DMRoomMap.shared().getUserIdForRoomId(room.roomId) + || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite); + } + + return false; + })); }); - }, 100, {trailing: true, leading: true}); + }, 100, { trailing: true, leading: true }); + + private switchToRelatedSpace = (roomId: string) => { + if (this.suggestedRooms.find(r => r.room_id === roomId)) return; + + let parent = this.getCanonicalParent(roomId); + if (!parent) { + parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(roomId)); + } + if (!parent) { + const parentIds = Array.from(this.parentMap.get(roomId) || []); + for (const parentId of parentIds) { + const room = this.matrixClient.getRoom(parentId); + if (room) { + parent = room; + break; + } + } + } + + // don't trigger a context switch when we are switching a space to match the chosen room + this.setActiveSpace(parent || null, false); + }; private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => { const membership = newMembership || room.getMyMembership(); @@ -424,6 +500,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (numSuggestedRooms !== this._suggestedRooms.length) { this.emit(SUGGESTED_ROOMS, this._suggestedRooms); } + + // if the room currently being viewed was just joined then switch to its related space + if (newMembership === "join" && room.roomId === RoomViewStore.getRoomId()) { + this.switchToRelatedSpace(room.roomId); + } } return; } @@ -442,10 +523,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (membership === "join" && room.roomId === RoomViewStore.getRoomId()) { // if the user was looking at the space and then joined: select that space - this.setActiveSpace(room); + this.setActiveSpace(room, false); } }; + private notifyIfOrderChanged(): void { + const rootSpaces = this.sortRootSpaces(this.rootSpaces); + if (arrayHasOrderChange(this.rootSpaces, rootSpaces)) { + this.rootSpaces = rootSpaces; + this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces); + } + } + private onRoomState = (ev: MatrixEvent) => { const room = this.matrixClient.getRoom(ev.getRoomId()); if (!room) return; @@ -463,7 +552,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // TODO confirm this after implementing parenting behaviour if (room.isSpaceRoom()) { this.onSpaceUpdate(); - } else { + } else if (!SettingsStore.getValue("feature_spaces.all_rooms")) { this.onRoomUpdate(room); } this.emit(room.roomId); @@ -477,16 +566,25 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } }; - private onRoomAccountData = (ev: MatrixEvent, room: Room, lastEvent?: MatrixEvent) => { - if (ev.getType() === EventType.Tag && !room.isSpaceRoom()) { + private onRoomAccountData = (ev: MatrixEvent, room: Room, lastEv?: MatrixEvent) => { + if (!room.isSpaceRoom()) return; + + if (ev.getType() === EventType.SpaceOrder) { + this.spaceOrderLocalEchoMap.delete(room.roomId); // clear any local echo + const order = ev.getContent()?.order; + const lastOrder = lastEv?.getContent()?.order; + if (order !== lastOrder) { + this.notifyIfOrderChanged(); + } + } else if (ev.getType() === EventType.Tag && !SettingsStore.getValue("feature_spaces.all_rooms")) { // If the room was in favourites and now isn't or the opposite then update its position in the trees - const oldTags = lastEvent?.getContent()?.tags || {}; + const oldTags = lastEv?.getContent()?.tags || {}; const newTags = ev.getContent()?.tags || {}; if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) { this.onRoomUpdate(room); } } - } + }; private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => { if (ev.getType() === EventType.Direct) { @@ -522,9 +620,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (this.matrixClient) { this.matrixClient.removeListener("Room", this.onRoom); this.matrixClient.removeListener("Room.myMembership", this.onRoom); - this.matrixClient.removeListener("RoomState.events", this.onRoomState); this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); - this.matrixClient.removeListener("accountData", this.onAccountData); + this.matrixClient.removeListener("RoomState.events", this.onRoomState); + if (!SettingsStore.getValue("feature_spaces.all_rooms")) { + this.matrixClient.removeListener("accountData", this.onAccountData); + } } await this.reset(); } @@ -533,19 +633,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (!SettingsStore.getValue("feature_spaces")) return; this.matrixClient.on("Room", this.onRoom); this.matrixClient.on("Room.myMembership", this.onRoom); - this.matrixClient.on("RoomState.events", this.onRoomState); this.matrixClient.on("Room.accountData", this.onRoomAccountData); - this.matrixClient.on("accountData", this.onAccountData); + this.matrixClient.on("RoomState.events", this.onRoomState); + if (!SettingsStore.getValue("feature_spaces.all_rooms")) { + this.matrixClient.on("accountData", this.onAccountData); + } await this.onSpaceUpdate(); // trigger an initial update // restore selected state from last session if any and still valid const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY); if (lastSpaceId) { - const space = this.rootSpaces.find(s => s.roomId === lastSpaceId); - if (space) { - this.setActiveSpace(space); - } + this.setActiveSpace(this.matrixClient.getRoom(lastSpaceId)); } } @@ -553,27 +652,21 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (!SettingsStore.getValue("feature_spaces")) return; switch (payload.action) { case "view_room": { - const room = this.matrixClient?.getRoom(payload.room_id); - // Don't auto-switch rooms when reacting to a context-switch // as this is not helpful and can create loops of rooms/space switching - if (!room || payload.context_switch) break; + if (payload.context_switch) break; - if (room.isSpaceRoom()) { + const roomId = payload.room_id; + const room = this.matrixClient?.getRoom(roomId); + if (room?.isSpaceRoom()) { // Don't context switch when navigating to the space room // as it will cause you to end up in the wrong room this.setActiveSpace(room, false); - } else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(room.roomId)) { - let parent = this.getCanonicalParent(room.roomId); - if (!parent) { - parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId)); - } - if (!parent) { - const parents = Array.from(this.parentMap.get(room.roomId) || []); - parent = parents.find(p => this.matrixClient.getRoom(p)); - } - // don't trigger a context switch when we are switching a space to match the chosen room - this.setActiveSpace(parent || null, false); + } else if ( + (!SettingsStore.getValue("feature_spaces.all_rooms") || this.activeSpace) && + !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId) + ) { + this.switchToRelatedSpace(roomId); } // Persist last viewed room from a space @@ -584,9 +677,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } case "after_leave_room": if (this._activeSpace && payload.room_id === this._activeSpace.roomId) { - this.setActiveSpace(null); + this.setActiveSpace(null, false); } break; + case Action.SwitchSpace: + if (payload.num === 0) { + this.setActiveSpace(null); + } else if (this.spacePanelSpaces.length >= payload.num) { + this.setActiveSpace(this.spacePanelSpaces[payload.num - 1]); + } } } @@ -621,6 +720,38 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } childSpaces.forEach(s => this.traverseSpace(s.roomId, fn, includeRooms, newPath)); } + + private getSpaceTagOrdering = (space: Room): string | undefined => { + if (this.spaceOrderLocalEchoMap.has(space.roomId)) return this.spaceOrderLocalEchoMap.get(space.roomId); + return validOrder(space.getAccountData(EventType.SpaceOrder)?.getContent()?.order); + }; + + private sortRootSpaces(spaces: Room[]): Room[] { + return sortBy(spaces, [this.getSpaceTagOrdering, "roomId"]); + } + + private async setRootSpaceOrder(space: Room, order: string): Promise { + this.spaceOrderLocalEchoMap.set(space.roomId, order); + try { + await this.matrixClient.setRoomAccountData(space.roomId, EventType.SpaceOrder, { order }); + } catch (e) { + console.warn("Failed to set root space order", e); + if (this.spaceOrderLocalEchoMap.get(space.roomId) === order) { + this.spaceOrderLocalEchoMap.delete(space.roomId); + } + } + } + + public moveRootSpace(fromIndex: number, toIndex: number): void { + const currentOrders = this.rootSpaces.map(this.getSpaceTagOrdering); + const changes = reorderLexicographically(currentOrders, fromIndex, toIndex); + + changes.forEach(({ index, order }) => { + this.setRootSpaceOrder(this.rootSpaces[index], order); + }); + + this.notifyIfOrderChanged(); + } } export default class SpaceStore { diff --git a/src/stores/SpaceTreeLevelLayoutStore.ts b/src/stores/SpaceTreeLevelLayoutStore.ts new file mode 100644 index 0000000000..424e9f4012 --- /dev/null +++ b/src/stores/SpaceTreeLevelLayoutStore.ts @@ -0,0 +1,48 @@ +/* +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. +*/ + +const getSpaceCollapsedKey = (roomId: string, parents: Set): string => { + const separator = "/"; + let path = ""; + if (parents) { + for (const entry of parents.entries()) { + path += entry + separator; + } + } + return `mx_space_collapsed_${path + roomId}`; +}; + +export default class SpaceTreeLevelLayoutStore { + private static internalInstance: SpaceTreeLevelLayoutStore; + + public static get instance(): SpaceTreeLevelLayoutStore { + if (!SpaceTreeLevelLayoutStore.internalInstance) { + SpaceTreeLevelLayoutStore.internalInstance = new SpaceTreeLevelLayoutStore(); + } + return SpaceTreeLevelLayoutStore.internalInstance; + } + + public setSpaceCollapsedState(roomId: string, parents: Set, collapsed: boolean) { + // XXX: localStorage doesn't allow booleans + localStorage.setItem(getSpaceCollapsedKey(roomId, parents), collapsed.toString()); + } + + public getSpaceCollapsedState(roomId: string, parents: Set, fallback: boolean): boolean { + const collapsedLocalStorage = localStorage.getItem(getSpaceCollapsedKey(roomId, parents)); + // XXX: localStorage doesn't allow booleans + return collapsedLocalStorage ? collapsedLocalStorage === "true" : fallback; + } +} diff --git a/src/stores/ThreepidInviteStore.ts b/src/stores/ThreepidInviteStore.ts index 06cfad2c6b..d0cf40941c 100644 --- a/src/stores/ThreepidInviteStore.ts +++ b/src/stores/ThreepidInviteStore.ts @@ -45,6 +45,16 @@ export interface IThreepidInvite { inviterName: string; } +// 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). +export interface IOOBData { + 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 +} + const STORAGE_PREFIX = "mx_threepid_invite_"; export default class ThreepidInviteStore extends EventEmitter { @@ -58,7 +68,7 @@ export default class ThreepidInviteStore extends EventEmitter { } public storeInvite(roomId: string, wireInvite: IThreepidInviteWireFormat): IThreepidInvite { - const invite = {roomId, ...wireInvite}; + const invite = { roomId, ...wireInvite }; const id = this.generateIdOf(invite); localStorage.setItem(`${STORAGE_PREFIX}${id}`, JSON.stringify(invite)); return this.translateInvite(invite); diff --git a/src/stores/TypingStore.ts b/src/stores/TypingStore.ts index d5177a33a0..9781c93eb4 100644 --- a/src/stores/TypingStore.ts +++ b/src/stores/TypingStore.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 SettingsStore from "../settings/SettingsStore"; import Timer from "../utils/Timer"; @@ -27,10 +27,10 @@ const TYPING_SERVER_TIMEOUT = 30000; export default class TypingStore { private typingStates: { [roomId: string]: { - isTyping: boolean, - userTimer: Timer, - serverTimer: Timer, - }, + isTyping: boolean; + userTimer: Timer; + serverTimer: Timer; + }; }; constructor() { diff --git a/src/stores/UIStore.ts b/src/stores/UIStore.ts new file mode 100644 index 0000000000..86312f79d7 --- /dev/null +++ b/src/stores/UIStore.ts @@ -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 EventEmitter from "events"; +import ResizeObserver from 'resize-observer-polyfill'; +import ResizeObserverEntry from 'resize-observer-polyfill/src/ResizeObserverEntry'; + +export enum UI_EVENTS { + Resize = "resize" +} + +export type ResizeObserverCallbackFunction = (entries: ResizeObserverEntry[]) => void; + +export default class UIStore extends EventEmitter { + private static _instance: UIStore = null; + + private resizeObserver: ResizeObserver; + + private uiElementDimensions = new Map(); + private trackedUiElements = new Map(); + + public windowWidth: number; + public windowHeight: number; + + constructor() { + super(); + + // eslint-disable-next-line no-restricted-properties + this.windowWidth = window.innerWidth; + // eslint-disable-next-line no-restricted-properties + this.windowHeight = window.innerHeight; + + this.resizeObserver = new ResizeObserver(this.resizeObserverCallback); + this.resizeObserver.observe(document.body); + } + + public static get instance(): UIStore { + if (!UIStore._instance) { + UIStore._instance = new UIStore(); + } + return UIStore._instance; + } + + public static destroy(): void { + if (UIStore._instance) { + UIStore._instance.resizeObserver.disconnect(); + UIStore._instance.removeAllListeners(); + UIStore._instance = null; + } + } + + public getElementDimensions(name: string): DOMRectReadOnly { + return this.uiElementDimensions.get(name); + } + + public trackElementDimensions(name: string, element: Element): void { + this.trackedUiElements.set(element, name); + this.resizeObserver.observe(element); + } + + public stopTrackingElementDimensions(name: string): void { + let trackedElement: Element; + this.trackedUiElements.forEach((trackedElementName, element) => { + if (trackedElementName === name) { + trackedElement = element; + } + }); + if (trackedElement) { + this.resizeObserver.unobserve(trackedElement); + this.uiElementDimensions.delete(name); + this.trackedUiElements.delete(trackedElement); + } + } + + public isTrackingElementDimensions(name: string): boolean { + return this.uiElementDimensions.has(name); + } + + private resizeObserverCallback = (entries: ResizeObserverEntry[]) => { + const windowEntry = entries.find(entry => entry.target === document.body); + + if (windowEntry) { + this.windowWidth = windowEntry.contentRect.width; + this.windowHeight = windowEntry.contentRect.height; + } + + entries.forEach(entry => { + const trackedElementName = this.trackedUiElements.get(entry.target); + if (trackedElementName) { + this.uiElementDimensions.set(trackedElementName, entry.contentRect); + this.emit(trackedElementName, UI_EVENTS.Resize, entry); + } + }); + + this.emit(UI_EVENTS.Resize, entries); + }; +} + +window.mxUIStore = UIStore.instance; diff --git a/src/stores/VoiceRecordingStore.ts b/src/stores/VoiceRecordingStore.ts index 8ee44359fb..81c19e7e82 100644 --- a/src/stores/VoiceRecordingStore.ts +++ b/src/stores/VoiceRecordingStore.ts @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {AsyncStoreWithClient} from "./AsyncStoreWithClient"; +import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; -import {ActionPayload} from "../dispatcher/payloads"; -import {VoiceRecording} from "../voice/VoiceRecording"; +import { ActionPayload } from "../dispatcher/payloads"; +import { VoiceRecording } from "../voice/VoiceRecording"; interface IState { recording?: VoiceRecording; @@ -62,7 +62,7 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { const recording = new VoiceRecording(this.matrixClient); // noinspection JSIgnoredPromiseFromCall - we can safely run this async - this.updateState({recording}); + this.updateState({ recording }); return recording; } @@ -75,7 +75,7 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { if (this.state.recording) { this.state.recording.destroy(); // stops internally } - return this.updateState({recording: null}); + return this.updateState({ recording: null }); } } diff --git a/src/stores/WidgetEchoStore.ts b/src/stores/WidgetEchoStore.ts index 09120d6108..d3ef9df023 100644 --- a/src/stores/WidgetEchoStore.ts +++ b/src/stores/WidgetEchoStore.ts @@ -16,8 +16,8 @@ limitations under the License. import EventEmitter from 'events'; import { IWidget } from 'matrix-widget-api'; -import MatrixEvent from "matrix-js-sdk/src/models/event"; -import {WidgetType} from "../widgets/WidgetType"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { WidgetType } from "../widgets/WidgetType"; /** * Acts as a place to get & set widget state, storing local echo state and @@ -26,8 +26,8 @@ import {WidgetType} from "../widgets/WidgetType"; class WidgetEchoStore extends EventEmitter { private roomWidgetEcho: { [roomId: string]: { - [widgetId: string]: IWidget, - }, + [widgetId: string]: IWidget; + }; }; constructor() { diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index 8961581964..732428107f 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -24,8 +24,8 @@ import defaultDispatcher from "../dispatcher/dispatcher"; import WidgetEchoStore from "../stores/WidgetEchoStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import WidgetUtils from "../utils/WidgetUtils"; -import {WidgetType} from "../widgets/WidgetType"; -import {UPDATE_EVENT} from "./AsyncStore"; +import { WidgetType } from "../widgets/WidgetType"; +import { UPDATE_EVENT } from "./AsyncStore"; import { MatrixClientPeg } from "../MatrixClientPeg"; interface IState {} diff --git a/src/stores/local-echo/EchoStore.ts b/src/stores/local-echo/EchoStore.ts index 76e90be45e..9c038c5a4f 100644 --- a/src/stores/local-echo/EchoStore.ts +++ b/src/stores/local-echo/EchoStore.ts @@ -77,10 +77,10 @@ export class EchoStore extends AsyncStoreWithClient { if (hasOrHadError && !this.state.toastRef) { const ref = NonUrgentToastStore.instance.addToast(NonUrgentEchoFailureToast); - await this.updateState({toastRef: ref}); + await this.updateState({ toastRef: ref }); } else if (!hasOrHadError && this.state.toastRef) { NonUrgentToastStore.instance.removeToast(this.state.toastRef); - await this.updateState({toastRef: null}); + await this.updateState({ toastRef: null }); } } diff --git a/src/stores/local-echo/GenericEchoChamber.ts b/src/stores/local-echo/GenericEchoChamber.ts index 7a2173f702..6148098fbe 100644 --- a/src/stores/local-echo/GenericEchoChamber.ts +++ b/src/stores/local-echo/GenericEchoChamber.ts @@ -53,7 +53,7 @@ export abstract class GenericEchoChamber extends Ev } private cacheVal(key: K, val: V, txn: EchoTransaction) { - this.cache.set(key, {txn, val}); + this.cache.set(key, { txn, val }); this.emit(PROPERTY_UPDATED, key); } diff --git a/src/stores/notifications/NotificationState.ts b/src/stores/notifications/NotificationState.ts index c8ef0ba859..00dbb7856f 100644 --- a/src/stores/notifications/NotificationState.ts +++ b/src/stores/notifications/NotificationState.ts @@ -80,8 +80,8 @@ export class NotificationStateSnapshot { } public isDifferentFrom(other: NotificationState): boolean { - const before = {count: this.count, symbol: this.symbol, color: this.color}; - const after = {count: other.count, symbol: other.symbol, color: other.color}; + const before = { count: this.count, symbol: this.symbol, color: this.color }; + const after = { count: other.count, symbol: other.symbol, color: other.color }; return JSON.stringify(before) !== JSON.stringify(after); } } diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index 1da0e661e8..99f24cfbe7 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -94,10 +94,10 @@ export class MessagePreviewStore extends AsyncStoreWithClient { * @param inTagId The tag ID in which the room resides * @returns The preview, or null if none present. */ - public getPreviewForRoom(room: Room, inTagId: TagID): string { + public async getPreviewForRoom(room: Room, inTagId: TagID): Promise { if (!room) return null; // invalid room, just return nothing - if (!this.previews.has(room.roomId)) this.generatePreview(room, inTagId); + if (!this.previews.has(room.roomId)) await this.generatePreview(room, inTagId); const previews = this.previews.get(room.roomId); if (!previews) return null; @@ -108,7 +108,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient { return previews.get(inTagId); } - private generatePreview(room: Room, tagId?: TagID) { + private async generatePreview(room: Room, tagId?: TagID) { const events = room.timeline; if (!events) return; // should only happen in tests @@ -130,6 +130,9 @@ export class MessagePreviewStore extends AsyncStoreWithClient { } const event = events[i]; + + await this.matrixClient.decryptEventIfNeeded(event); + const previewDef = PREVIEWS[event.getType()]; if (!previewDef) continue; if (previewDef.isState && isNullOrUndefined(event.getStateKey())) continue; @@ -173,8 +176,9 @@ export class MessagePreviewStore extends AsyncStoreWithClient { if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { const event = payload.event; // TODO: Type out the dispatcher - if (!this.previews.has(event.getRoomId())) return; // not important - this.generatePreview(this.matrixClient.getRoom(event.getRoomId()), TAG_ANY); + const isHistoricalEvent = payload.hasOwnProperty("isLiveEvent") && !payload.isLiveEvent; + if (!this.previews.has(event.getRoomId()) || isHistoricalEvent) return; // not important + await this.generatePreview(this.matrixClient.getRoom(event.getRoomId()), TAG_ANY); } } } diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index caab46a0c2..e26c80bb2d 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -14,27 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClient} from "matrix-js-sdk/src/client"; +import { MatrixClient } from "matrix-js-sdk/src/client"; import SettingsStore from "../../settings/SettingsStore"; -import {DefaultTagID, isCustomTag, OrderedDefaultTagIDs, RoomUpdateCause, TagID} from "./models"; -import {Room} from "matrix-js-sdk/src/models/room"; -import {IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm} from "./algorithms/models"; -import {ActionPayload} from "../../dispatcher/payloads"; +import { DefaultTagID, isCustomTag, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; +import { ActionPayload } from "../../dispatcher/payloads"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import {readReceiptChangeIsFor} from "../../utils/read-receipts"; -import {FILTER_CHANGED, FilterKind, IFilterCondition} from "./filters/IFilterCondition"; -import {TagWatcher} from "./TagWatcher"; +import { readReceiptChangeIsFor } from "../../utils/read-receipts"; +import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./filters/IFilterCondition"; +import { TagWatcher } from "./TagWatcher"; import RoomViewStore from "../RoomViewStore"; -import {Algorithm, LIST_UPDATED_EVENT} from "./algorithms/Algorithm"; -import {EffectiveMembership, getEffectiveMembership} from "../../utils/membership"; -import {isNullOrUndefined} from "matrix-js-sdk/src/utils"; +import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; +import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; +import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import RoomListLayoutStore from "./RoomListLayoutStore"; -import {MarkedExecution} from "../../utils/MarkedExecution"; -import {AsyncStoreWithClient} from "../AsyncStoreWithClient"; -import {NameFilterCondition} from "./filters/NameFilterCondition"; -import {RoomNotificationStateStore} from "../notifications/RoomNotificationStateStore"; -import {VisibilityProvider} from "./filters/VisibilityProvider"; -import {SpaceWatcher} from "./SpaceWatcher"; +import { MarkedExecution } from "../../utils/MarkedExecution"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import { NameFilterCondition } from "./filters/NameFilterCondition"; +import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore"; +import { VisibilityProvider } from "./filters/VisibilityProvider"; +import { SpaceWatcher } from "./SpaceWatcher"; interface IState { tagsEnabled?: boolean; @@ -73,13 +73,6 @@ export class RoomListStoreClass extends AsyncStoreWithClient { constructor() { super(defaultDispatcher); - - this.checkLoggingEnabled(); - for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null); - RoomViewStore.addListener(() => this.handleRVSUpdate({})); - this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); - this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated); - this.setupWatchers(); } private setupWatchers() { @@ -127,12 +120,18 @@ export class RoomListStoreClass extends AsyncStoreWithClient { this.checkLoggingEnabled(); + for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null); + RoomViewStore.addListener(() => this.handleRVSUpdate({})); + this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); + this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated); + this.setupWatchers(); + // Update any settings here, as some may have happened before we were logically ready. // Update any settings here, as some may have happened before we were logically ready. console.log("Regenerating room lists: Startup"); await this.readAndCacheSettingsFromStore(); - await this.regenerateAllLists({trigger: false}); - await this.handleRVSUpdate({trigger: false}); // fake an RVS update to adjust sticky room, if needed + await this.regenerateAllLists({ trigger: false }); + await this.handleRVSUpdate({ trigger: false }); // fake an RVS update to adjust sticky room, if needed this.updateFn.mark(); // we almost certainly want to trigger an update. this.updateFn.trigger(); @@ -157,7 +156,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { * @param trigger Set to false to prevent a list update from being sent. Should only * be used if the calling code will manually trigger the update. */ - private async handleRVSUpdate({trigger = true}) { + private async handleRVSUpdate({ trigger = true }) { if (!this.matrixClient) return; // We assume there won't be RVS updates without a client const activeRoomId = RoomViewStore.getRoomId(); @@ -225,7 +224,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { console.log("Regenerating room lists: Settings changed"); await this.readAndCacheSettingsFromStore(); - await this.regenerateAllLists({trigger: false}); // regenerate the lists now + await this.regenerateAllLists({ trigger: false }); // regenerate the lists now this.updateFn.trigger(); } } @@ -426,6 +425,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient { return; // don't do anything on rooms that aren't visible } + if ((cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.PossibleTagChange) && + !this.prefilterConditions.every(c => c.isVisible(room)) + ) { + return; // don't do anything on new/moved rooms which ought not to be shown + } + const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); if (shouldUpdate) { if (SettingsStore.getValue("advancedRoomListLogging")) { @@ -601,7 +606,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient { let rooms = this.matrixClient.getVisibleRooms().filter(r => VisibilityProvider.instance.isRoomVisible(r)); - if (this.prefilterConditions.length > 0) { + // if spaces are enabled only consider the prefilter conditions when there are no runtime conditions + // for the search all spaces feature + if (this.prefilterConditions.length > 0 + && (!SettingsStore.getValue("feature_spaces") || !this.filterConditions.length) + ) { rooms = rooms.filter(r => { for (const filter of this.prefilterConditions) { if (!filter.isVisible(r)) { @@ -623,7 +632,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { * @param trigger Set to false to prevent a list update from being sent. Should only * be used if the calling code will manually trigger the update. */ - public async regenerateAllLists({trigger = true}) { + public async regenerateAllLists({ trigger = true }) { console.warn("Regenerating all room lists"); const rooms = this.getPlausibleRooms(); @@ -660,7 +669,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { * and thus might not cause an update to the store immediately. * @param {IFilterCondition} filter The filter condition to add. */ - public addFilter(filter: IFilterCondition): void { + public async addFilter(filter: IFilterCondition): Promise { if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 console.log("Adding filter condition:", filter); @@ -672,6 +681,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient { promise = this.recalculatePrefiltering(); } else { this.filterConditions.push(filter); + // Runtime filters with spaces disable prefiltering for the search all spaces feature + if (SettingsStore.getValue("feature_spaces")) { + // this has to be awaited so that `setKnownRooms` is called in time for the `addFilterCondition` below + // this way the runtime filters are only evaluated on one dataset and not both. + await this.recalculatePrefiltering(); + } if (this.algorithm) { this.algorithm.addFilterCondition(filter); } @@ -699,6 +714,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient { if (this.algorithm) { this.algorithm.removeFilterCondition(filter); } + // Runtime filters with spaces disable prefiltering for the search all spaces feature + if (SettingsStore.getValue("feature_spaces")) { + promise = this.recalculatePrefiltering(); + } } idx = this.prefilterConditions.indexOf(filter); if (idx >= 0) { diff --git a/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts index 13e1d83901..a1f7786578 100644 --- a/src/stores/room-list/SpaceWatcher.ts +++ b/src/stores/room-list/SpaceWatcher.ts @@ -19,23 +19,39 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { RoomListStoreClass } from "./RoomListStore"; import { SpaceFilterCondition } from "./filters/SpaceFilterCondition"; import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore"; +import SettingsStore from "../../settings/SettingsStore"; /** * Watches for changes in spaces to manage the filter on the provided RoomListStore */ export class SpaceWatcher { - private filter = new SpaceFilterCondition(); + private filter: SpaceFilterCondition; private activeSpace: Room = SpaceStore.instance.activeSpace; constructor(private store: RoomListStoreClass) { - this.updateFilter(); // get the filter into a consistent state - store.addFilter(this.filter); + if (!SettingsStore.getValue("feature_spaces.all_rooms")) { + this.filter = new SpaceFilterCondition(); + this.updateFilter(); + store.addFilter(this.filter); + } SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated); } - private onSelectedSpaceUpdated = (activeSpace: Room) => { + private onSelectedSpaceUpdated = (activeSpace?: Room) => { this.activeSpace = activeSpace; - this.updateFilter(); + + if (this.filter) { + if (activeSpace || !SettingsStore.getValue("feature_spaces.all_rooms")) { + this.updateFilter(); + } else { + this.store.removeFilter(this.filter); + this.filter = null; + } + } else if (activeSpace) { + this.filter = new SpaceFilterCondition(); + this.updateFilter(); + this.store.addFilter(this.filter); + } }; private updateFilter = () => { diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 83ee803115..024c484c41 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -199,8 +199,10 @@ export class Algorithm extends EventEmitter { } private async doUpdateStickyRoom(val: Room) { - // no-op sticky rooms for spaces - they're effectively virtual rooms - if (val?.isSpaceRoom() && val.getMyMembership() !== "invite") val = null; + if (SettingsStore.getValue("feature_spaces") && val?.isSpaceRoom() && val.getMyMembership() !== "invite") { + // no-op sticky rooms for spaces - they're effectively virtual rooms + val = null; + } // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing, // otherwise we risk duplicating rooms. @@ -577,9 +579,8 @@ export class Algorithm extends EventEmitter { await this.generateFreshTags(newTags); - this.cachedRooms = newTags; + this.cachedRooms = newTags; // this recalculates the filtered rooms for us this.updateTagsFromCache(); - this.recalculateFilteredRooms(); // Now that we've finished generation, we need to update the sticky room to what // it was. It's entirely possible that it changed lists though, so if it did then diff --git a/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts index d909fb6288..b016a4256c 100644 --- a/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts @@ -17,6 +17,7 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { TagID } from "../../models"; import { IAlgorithm } from "./IAlgorithm"; +import { compare } from "../../../../utils/strings"; /** * Sorts rooms according to the browser's determination of alphabetic. @@ -24,7 +25,7 @@ import { IAlgorithm } from "./IAlgorithm"; export class AlphabeticAlgorithm implements IAlgorithm { public async sortRooms(rooms: Room[], tagId: TagID): Promise { return rooms.sort((a, b) => { - return a.name.localeCompare(b.name); + return compare(a.name, b.name); }); } } diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts index 7c8c879cf6..49cfd9e520 100644 --- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts @@ -21,79 +21,83 @@ import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import * as Unread from "../../../../Unread"; import { EffectiveMembership, getEffectiveMembership } from "../../../../utils/membership"; +export const sortRooms = (rooms: Room[]): Room[] => { + // We cache the timestamp lookup to avoid iterating forever on the timeline + // of events. This cache only survives a single sort though. + // We wouldn't need this if `.sort()` didn't constantly try and compare all + // of the rooms to each other. + + // TODO: We could probably improve the sorting algorithm here by finding changes. + // See https://github.com/vector-im/element-web/issues/14459 + // For example, if we spent a little bit of time to determine which elements have + // actually changed (probably needs to be done higher up?) then we could do an + // insertion sort or similar on the limited set of changes. + + // TODO: Don't assume we're using the same client as the peg + // See https://github.com/vector-im/element-web/issues/14458 + let myUserId = ''; + if (MatrixClientPeg.get()) { + myUserId = MatrixClientPeg.get().getUserId(); + } + + const tsCache: { [roomId: string]: number } = {}; + const getLastTs = (r: Room) => { + if (tsCache[r.roomId]) { + return tsCache[r.roomId]; + } + + const ts = (() => { + // Apparently we can have rooms without timelines, at least under testing + // environments. Just return MAX_INT when this happens. + if (!r || !r.timeline) { + return Number.MAX_SAFE_INTEGER; + } + + // If the room hasn't been joined yet, it probably won't have a timeline to + // parse. We'll still fall back to the timeline if this fails, but chances + // are we'll at least have our own membership event to go off of. + const effectiveMembership = getEffectiveMembership(r.getMyMembership()); + if (effectiveMembership !== EffectiveMembership.Join) { + const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId); + if (membershipEvent && !Array.isArray(membershipEvent)) { + return membershipEvent.getTs(); + } + } + + for (let i = r.timeline.length - 1; i >= 0; --i) { + const ev = r.timeline[i]; + if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) + + if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) { + return ev.getTs(); + } + } + + // we might only have events that don't trigger the unread indicator, + // in which case use the oldest event even if normally it wouldn't count. + // This is better than just assuming the last event was forever ago. + if (r.timeline.length && r.timeline[0].getTs()) { + return r.timeline[0].getTs(); + } else { + return Number.MAX_SAFE_INTEGER; + } + })(); + + tsCache[r.roomId] = ts; + return ts; + }; + + return rooms.sort((a, b) => { + return getLastTs(b) - getLastTs(a); + }); +}; + /** * Sorts rooms according to the last event's timestamp in each room that seems * useful to the user. */ export class RecentAlgorithm implements IAlgorithm { public async sortRooms(rooms: Room[], tagId: TagID): Promise { - // We cache the timestamp lookup to avoid iterating forever on the timeline - // of events. This cache only survives a single sort though. - // We wouldn't need this if `.sort()` didn't constantly try and compare all - // of the rooms to each other. - - // TODO: We could probably improve the sorting algorithm here by finding changes. - // See https://github.com/vector-im/element-web/issues/14459 - // For example, if we spent a little bit of time to determine which elements have - // actually changed (probably needs to be done higher up?) then we could do an - // insertion sort or similar on the limited set of changes. - - // TODO: Don't assume we're using the same client as the peg - // See https://github.com/vector-im/element-web/issues/14458 - let myUserId = ''; - if (MatrixClientPeg.get()) { - myUserId = MatrixClientPeg.get().getUserId(); - } - - const tsCache: { [roomId: string]: number } = {}; - const getLastTs = (r: Room) => { - if (tsCache[r.roomId]) { - return tsCache[r.roomId]; - } - - const ts = (() => { - // Apparently we can have rooms without timelines, at least under testing - // environments. Just return MAX_INT when this happens. - if (!r || !r.timeline) { - return Number.MAX_SAFE_INTEGER; - } - - // If the room hasn't been joined yet, it probably won't have a timeline to - // parse. We'll still fall back to the timeline if this fails, but chances - // are we'll at least have our own membership event to go off of. - const effectiveMembership = getEffectiveMembership(r.getMyMembership()); - if (effectiveMembership !== EffectiveMembership.Join) { - const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId); - if (membershipEvent && !Array.isArray(membershipEvent)) { - return membershipEvent.getTs(); - } - } - - for (let i = r.timeline.length - 1; i >= 0; --i) { - const ev = r.timeline[i]; - if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) - - if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) { - return ev.getTs(); - } - } - - // we might only have events that don't trigger the unread indicator, - // in which case use the oldest event even if normally it wouldn't count. - // This is better than just assuming the last event was forever ago. - if (r.timeline.length && r.timeline[0].getTs()) { - return r.timeline[0].getTs(); - } else { - return Number.MAX_SAFE_INTEGER; - } - })(); - - tsCache[r.roomId] = ts; - return ts; - }; - - return rooms.sort((a, b) => { - return getLastTs(b) - getLastTs(a); - }); + return sortRooms(rooms); } } diff --git a/src/stores/room-list/filters/NameFilterCondition.ts b/src/stores/room-list/filters/NameFilterCondition.ts index 8e63c23131..e21d5ab718 100644 --- a/src/stores/room-list/filters/NameFilterCondition.ts +++ b/src/stores/room-list/filters/NameFilterCondition.ts @@ -17,7 +17,7 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./IFilterCondition"; import { EventEmitter } from "events"; -import { removeHiddenChars } from "matrix-js-sdk/src/utils"; +import { normalize } from "matrix-js-sdk/src/utils"; import { throttle } from "lodash"; /** @@ -46,7 +46,7 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio private callUpdate = throttle(() => { this.emit(FILTER_CHANGED); - }, 200, {trailing: true, leading: true}); + }, 200, { trailing: true, leading: true }); public isVisible(room: Room): boolean { const lcFilter = this.search.toLowerCase(); @@ -62,20 +62,10 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio if (!room.name) return false; // should realistically not happen: the js-sdk always calculates a name - return this.matches(room.name); + return this.matches(room.normalizedName); } - private normalize(val: string): string { - // Note: we have to match the filter with the removeHiddenChars() room name because the - // function strips spaces and other characters (M becomes RN for example, in lowercase). - return removeHiddenChars(val.toLowerCase()) - // Strip all punctuation - .replace(/[\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~\u2000-\u206f\u2e00-\u2e7f]/g, "") - // We also doubly convert to lowercase to work around oddities of the library. - .toLowerCase(); - } - - public matches(val: string): boolean { - return this.normalize(val).includes(this.normalize(this.search)); + public matches(normalizedName: string): boolean { + return normalizedName.includes(normalize(this.search)); } } diff --git a/src/stores/room-list/filters/SpaceFilterCondition.ts b/src/stores/room-list/filters/SpaceFilterCondition.ts index 43bdcb3879..0e6965d843 100644 --- a/src/stores/room-list/filters/SpaceFilterCondition.ts +++ b/src/stores/room-list/filters/SpaceFilterCondition.ts @@ -19,7 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./IFilterCondition"; import { IDestroyable } from "../../../utils/IDestroyable"; -import SpaceStore, {HOME_SPACE} from "../../SpaceStore"; +import SpaceStore, { HOME_SPACE } from "../../SpaceStore"; import { setHasDiff } from "../../../utils/sets"; /** @@ -29,7 +29,7 @@ import { setHasDiff } from "../../../utils/sets"; * + All DMs */ export class SpaceFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable { - private roomIds = new Set(); + private roomIds = new Set(); private space: Room = null; public get kind(): FilterKind { diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts index f212b1f9d9..a6c55226b0 100644 --- a/src/stores/room-list/filters/VisibilityProvider.ts +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import {Room} from "matrix-js-sdk/src/models/room"; +import { Room } from "matrix-js-sdk/src/models/room"; import CallHandler from "../../../CallHandler"; import { RoomListCustomisations } from "../../../customisations/RoomList"; import VoipUserMapper from "../../../VoipUserMapper"; @@ -50,7 +50,7 @@ export class VisibilityProvider { } // hide space rooms as they'll be shown in the SpacePanel - if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) { + if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) { return false; } diff --git a/src/stores/room-list/previews/CallAnswerEventPreview.ts b/src/stores/room-list/previews/CallAnswerEventPreview.ts index b7207307e2..7193154890 100644 --- a/src/stores/room-list/previews/CallAnswerEventPreview.ts +++ b/src/stores/room-list/previews/CallAnswerEventPreview.ts @@ -26,7 +26,7 @@ export class CallAnswerEventPreview implements IPreview { if (isSelf(event)) { return _t("You joined the call"); } else { - return _t("%(senderName)s joined the call", {senderName: getSenderName(event)}); + return _t("%(senderName)s joined the call", { senderName: getSenderName(event) }); } } else { return _t("Call in progress"); diff --git a/src/stores/room-list/previews/CallHangupEvent.ts b/src/stores/room-list/previews/CallHangupEvent.ts index fdab8a9de5..54d4d70fb0 100644 --- a/src/stores/room-list/previews/CallHangupEvent.ts +++ b/src/stores/room-list/previews/CallHangupEvent.ts @@ -26,7 +26,7 @@ export class CallHangupEvent implements IPreview { if (isSelf(event)) { return _t("You ended the call"); } else { - return _t("%(senderName)s ended the call", {senderName: getSenderName(event)}); + return _t("%(senderName)s ended the call", { senderName: getSenderName(event) }); } } else { return _t("Call ended"); diff --git a/src/stores/room-list/previews/CallInviteEventPreview.ts b/src/stores/room-list/previews/CallInviteEventPreview.ts index 47486e3701..3e6a89aa6d 100644 --- a/src/stores/room-list/previews/CallInviteEventPreview.ts +++ b/src/stores/room-list/previews/CallInviteEventPreview.ts @@ -26,13 +26,13 @@ export class CallInviteEventPreview implements IPreview { if (isSelf(event)) { return _t("You started a call"); } else { - return _t("%(senderName)s started a call", {senderName: getSenderName(event)}); + return _t("%(senderName)s started a call", { senderName: getSenderName(event) }); } } else { if (isSelf(event)) { return _t("Waiting for answer"); } else { - return _t("%(senderName)s is calling", {senderName: getSenderName(event)}); + return _t("%(senderName)s is calling", { senderName: getSenderName(event) }); } } } diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts index b900afc13f..04fb92f0c1 100644 --- a/src/stores/room-list/previews/MessageEventPreview.ts +++ b/src/stores/room-list/previews/MessageEventPreview.ts @@ -59,13 +59,13 @@ export class MessageEventPreview implements IPreview { } if (msgtype === 'm.emote') { - return _t("* %(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body}); + return _t("* %(senderName)s %(emote)s", { senderName: getSenderName(event), emote: body }); } if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { return body; } else { - return _t("%(senderName)s: %(message)s", {senderName: getSenderName(event), message: body}); + return _t("%(senderName)s: %(message)s", { senderName: getSenderName(event), message: body }); } } } diff --git a/src/stores/room-list/previews/ReactionEventPreview.ts b/src/stores/room-list/previews/ReactionEventPreview.ts index 0012cf2f75..25f8e0b61a 100644 --- a/src/stores/room-list/previews/ReactionEventPreview.ts +++ b/src/stores/room-list/previews/ReactionEventPreview.ts @@ -44,7 +44,7 @@ export class ReactionEventPreview implements IPreview { if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { return reaction; } else { - return _t("%(senderName)s: %(reaction)s", {senderName: getSenderName(event), reaction}); + return _t("%(senderName)s: %(reaction)s", { senderName: getSenderName(event), reaction }); } } } diff --git a/src/stores/room-list/previews/StickerEventPreview.ts b/src/stores/room-list/previews/StickerEventPreview.ts index f8263a4a45..56746568af 100644 --- a/src/stores/room-list/previews/StickerEventPreview.ts +++ b/src/stores/room-list/previews/StickerEventPreview.ts @@ -28,7 +28,7 @@ export class StickerEventPreview implements IPreview { if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { return stickerName; } else { - return _t("%(senderName)s: %(stickerName)s", {senderName: getSenderName(event), stickerName}); + return _t("%(senderName)s: %(stickerName)s", { senderName: getSenderName(event), stickerName }); } } } diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 17371d6d45..36791d3dd9 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020, 2021 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,12 +46,14 @@ import ActiveWidgetStore from "../ActiveWidgetStore"; import { objectShallowClone } from "../../utils/objects"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ElementWidgetActions, IViewRoomApiRequest } from "./ElementWidgetActions"; -import {ModalWidgetStore} from "../ModalWidgetStore"; +import { ModalWidgetStore } from "../ModalWidgetStore"; import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; -import {getCustomTheme} from "../../theme"; +import { getCustomTheme } from "../../theme"; import CountlyAnalytics from "../../CountlyAnalytics"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { MatrixEvent, IEvent } from "matrix-js-sdk/src/models/event"; +import { ELEMENT_CLIENT_ID } from "../../identifiers"; +import { getUserLanguage } from "../../languageHandler"; // TODO: Destroy all of this code @@ -178,22 +180,25 @@ export class StopGapWidget extends EventEmitter { * The URL to use in the iframe */ public get embedUrl(): string { - return this.runUrlTemplate({asPopout: false}); + return this.runUrlTemplate({ asPopout: false }); } /** * The URL to use in the popout */ public get popoutUrl(): string { - return this.runUrlTemplate({asPopout: true}); + return this.runUrlTemplate({ asPopout: true }); } - private runUrlTemplate(opts = {asPopout: false}): string { + private runUrlTemplate(opts = { asPopout: false }): string { const templated = this.mockWidget.getCompleteUrl({ widgetRoomId: this.roomId, currentUserId: MatrixClientPeg.get().getUserId(), userDisplayName: OwnProfileStore.instance.displayName, userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(), + clientId: ELEMENT_CLIENT_ID, + clientTheme: SettingsStore.getValue("theme"), + clientLanguage: getUserLanguage(), }, opts?.asPopout); const parsed = new URL(templated); @@ -239,7 +244,7 @@ export class StopGapWidget extends EventEmitter { error: { message: "Unable to open modal at this time", }, - }) + }); } }; @@ -265,14 +270,14 @@ export class StopGapWidget extends EventEmitter { const targetRoomId = (ev.detail.data || {}).room_id; if (!targetRoomId) { return this.messaging.transport.reply(ev.detail, { - error: {message: "Room ID not supplied."}, + error: { message: "Room ID not supplied." }, }); } // Check the widget's permission if (!this.messaging.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) { return this.messaging.transport.reply(ev.detail, { - error: {message: "This widget does not have permission for this action (denied)."}, + error: { message: "This widget does not have permission for this action (denied)." }, }); } @@ -330,12 +335,12 @@ export class StopGapWidget extends EventEmitter { this.messaging.transport.reply(ev.detail, {}); // First close the stickerpicker - defaultDispatcher.dispatch({action: "stickerpicker_close"}); + defaultDispatcher.dispatch({ action: "stickerpicker_close" }); // Now open the integration manager // TODO: Spec this interaction. const data = ev.detail.data; - const integType = data?.integType + const integType = data?.integType; const integId = data?.integId; // TODO: Open the right integration manager for the widget @@ -379,7 +384,7 @@ export class StopGapWidget extends EventEmitter { } } - public stop(opts = {forceDestroy: false}) { + public stop(opts = { forceDestroy: false }) { if (!opts?.forceDestroy && ActiveWidgetStore.getPersistentWidgetId() === this.mockWidget.id) { console.log("Skipping destroy - persistent widget"); return; @@ -395,6 +400,7 @@ export class StopGapWidget extends EventEmitter { } private onEvent = (ev: MatrixEvent) => { + MatrixClientPeg.get().decryptEventIfNeeded(ev); if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; if (ev.getRoomId() !== this.eventListenerRoomId) return; this.feedEvent(ev); @@ -409,7 +415,7 @@ export class StopGapWidget extends EventEmitter { private feedEvent(ev: MatrixEvent) { if (!this.messaging) return; - const raw = ev.event; + const raw = ev.event as IEvent; this.messaging.feedEvent(raw).catch(e => { console.error("Error sending event to widget: ", e); }); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 8a286d909b..fd064bae61 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -43,7 +43,8 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { CHAT_EFFECTS } from "../../effects"; import { containsEmoji } from "../../effects/utils"; import dis from "../../dispatcher/dispatcher"; -import {tryTransformPermalinkToLocalHref} from "../../utils/permalinks/Permalinks"; +import { tryTransformPermalinkToLocalHref } from "../../utils/permalinks/Permalinks"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; // TODO: Purge this from the universe @@ -135,13 +136,57 @@ export class StopGapWidgetDriver extends WidgetDriver { if (eventType === EventType.RoomMessage) { CHAT_EFFECTS.forEach((effect) => { if (containsEmoji(content, effect.emojis)) { - dis.dispatch({action: `effects.${effect.command}`}); + dis.dispatch({ action: `effects.${effect.command}` }); } }); } } - return {roomId, eventId: r.event_id}; + return { roomId, eventId: r.event_id }; + } + + public async readRoomEvents(eventType: string, msgtype: string | undefined, limit: number): Promise { + limit = limit > 0 ? Math.min(limit, 25) : 25; // arbitrary choice + + const client = MatrixClientPeg.get(); + const roomId = ActiveRoomObserver.activeRoomId; + const room = client.getRoom(roomId); + if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client"); + + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); // timelines are most recent last + for (let i = events.length - 1; i > 0; i--) { + if (results.length >= limit) break; + + const ev = events[i]; + if (ev.getType() !== eventType) continue; + if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue; + results.push(ev); + } + + return results.map(e => e.event); + } + + public async readStateEvents(eventType: string, stateKey: string | undefined, limit: number): Promise { + limit = limit > 0 ? Math.min(limit, 100) : 100; // arbitrary choice + + const client = MatrixClientPeg.get(); + const roomId = ActiveRoomObserver.activeRoomId; + const room = client.getRoom(roomId); + if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client"); + + const results: MatrixEvent[] = []; + const state: Map = room.currentState.events.get(eventType); + if (state) { + if (stateKey === "" || !!stateKey) { + const forKey = state.get(stateKey); + if (forKey) results.push(forKey); + } else { + results.push(...Array.from(state.values())); + } + } + + return results.slice(0, limit).map(e => e.event); } public async askOpenID(observer: SimpleObservable) { @@ -154,13 +199,13 @@ export class StopGapWidgetDriver extends WidgetDriver { }; if (oidcState === OIDCState.Denied) { - return observer.update({state: OpenIDRequestState.Blocked}); + return observer.update({ state: OpenIDRequestState.Blocked }); } if (oidcState === OIDCState.Allowed) { - return observer.update({state: OpenIDRequestState.Allowed, token: await getToken()}); + return observer.update({ state: OpenIDRequestState.Allowed, token: await getToken() }); } - observer.update({state: OpenIDRequestState.PendingUserConfirmation}); + observer.update({ state: OpenIDRequestState.PendingUserConfirmation }); Modal.createTrackedDialog("OpenID widget permissions", '', WidgetOpenIDPermissionsDialog, { widget: this.forWidget, @@ -169,10 +214,10 @@ export class StopGapWidgetDriver extends WidgetDriver { onFinished: async (confirm) => { if (!confirm) { - return observer.update({state: OpenIDRequestState.Blocked}); + return observer.update({ state: OpenIDRequestState.Blocked }); } - return observer.update({state: OpenIDRequestState.Allowed, token: await getToken()}); + return observer.update({ state: OpenIDRequestState.Allowed, token: await getToken() }); }, }); } diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index e6ef534202..5d427a0f1a 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -25,6 +25,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { SettingLevel } from "../../settings/SettingLevel"; import { arrayFastClone } from "../../utils/arrays"; import { UPDATE_EVENT } from "../AsyncStore"; +import { compare } from "../../utils/strings"; export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout"; @@ -240,7 +241,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { if (orderA === orderB) { // We just need a tiebreak - return a.id.localeCompare(b.id); + return compare(a.id, b.id); } return orderA - orderB; @@ -331,7 +332,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { } public getContainerWidgets(room: Room, container: Container): IApp[] { - return this.byRoom[room.roomId]?.[container]?.ordered || []; + return this.byRoom[room?.roomId]?.[container]?.ordered || []; } public isInContainer(room: Room, widget: IApp, container: Container): boolean { @@ -424,7 +425,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { const allWidgets = this.getAllWidgets(room); if (!allWidgets.some(([w])=> w.id === widget.id)) return; // invalid this.updateUserLayout(room, { - [widget.id]: {container: toContainer}, + [widget.id]: { container: toContainer }, }); } @@ -435,9 +436,9 @@ export class WidgetLayoutStore extends ReadyWatchingStore { public copyLayoutToRoom(room: Room) { const allWidgets = this.getAllWidgets(room); - const evContent: ILayoutStateEvent = {widgets: {}}; + const evContent: ILayoutStateEvent = { widgets: {} }; for (const [widget, container] of allWidgets) { - evContent.widgets[widget.id] = {container}; + evContent.widgets[widget.id] = { container }; if (container === Container.Top) { const containerWidgets = this.getContainerWidgets(room, container); const idx = containerWidgets.findIndex(w => w.id === widget.id); diff --git a/src/theme.js b/src/theme.js index 40fa291cfc..2caf48b65a 100644 --- a/src/theme.js +++ b/src/theme.js @@ -15,10 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {_t} from "./languageHandler"; +import { _t } from "./languageHandler"; export const DEFAULT_THEME = "light"; -import Tinter from "./Tinter"; import SettingsStore from "./settings/SettingsStore"; import ThemeWatcher from "./settings/watchers/ThemeWatcher"; @@ -29,7 +28,7 @@ export function enumerateThemes() { }; const customThemes = SettingsStore.getValue("custom_themes"); const customThemeNames = {}; - for (const {name} of customThemes) { + for (const { name } of customThemes) { customThemeNames[`custom-${name}`] = name; } return Object.assign({}, customThemeNames, BUILTIN_THEMES); @@ -93,7 +92,7 @@ function generateCustomFontFaceCSS(faces) { } function setCustomThemeVars(customTheme) { - const {style} = document.body; + const { style } = document.body; function setCSSColorVariable(name, hexColor, doPct = true) { style.setProperty(`--${name}`, hexColor); @@ -117,7 +116,7 @@ function setCustomThemeVars(customTheme) { } } if (customTheme.fonts) { - const {fonts} = customTheme; + const { fonts } = customTheme; if (fonts.faces) { const css = generateCustomFontFaceCSS(fonts.faces); const style = document.createElement("style"); @@ -214,7 +213,6 @@ export async function setTheme(theme) { if (bodyStyles.backgroundColor) { document.querySelector('meta[name="theme-color"]').content = bodyStyles.backgroundColor; } - Tinter.setTheme(theme); resolve(); }; diff --git a/src/toasts/ServerLimitToast.tsx b/src/toasts/ServerLimitToast.tsx index 36de6f04e0..f915077bf4 100644 --- a/src/toasts/ServerLimitToast.tsx +++ b/src/toasts/ServerLimitToast.tsx @@ -19,7 +19,7 @@ import React from "react"; import { _t, _td } from "../languageHandler"; import GenericToast from "../components/views/toasts/GenericToast"; import ToastStore from "../stores/ToastStore"; -import {messageForResourceLimitError} from "../utils/ErrorUtils"; +import { messageForResourceLimitError } from "../utils/ErrorUtils"; const TOAST_KEY = "serverlimit"; @@ -40,7 +40,7 @@ export const showToast = (limitType: string, onHideToast: () => void, adminConta description: {errorText} {contactText}, acceptLabel: _t("Ok"), onAccept: () => { - hideToast() + hideToast(); if (onHideToast) onHideToast(); }, }, diff --git a/src/toasts/SetupEncryptionToast.ts b/src/toasts/SetupEncryptionToast.ts index ade7dfe3f0..bdaeb5142f 100644 --- a/src/toasts/SetupEncryptionToast.ts +++ b/src/toasts/SetupEncryptionToast.ts @@ -15,7 +15,6 @@ limitations under the License. */ import Modal from "../Modal"; -import * as sdk from "../index"; import { _t } from "../languageHandler"; import DeviceListener from "../DeviceListener"; import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEncryptionDialog"; @@ -23,6 +22,7 @@ import { accessSecretStorage } from "../SecurityManager"; import ToastStore from "../stores/ToastStore"; import GenericToast from "../components/views/toasts/GenericToast"; import SecurityCustomisations from "../customisations/Security"; +import Spinner from "../components/views/elements/Spinner"; const TOAST_KEY = "setupencryption"; @@ -88,7 +88,6 @@ export const showToast = (kind: Kind) => { Modal.createTrackedDialog("Verify session", "Verify session", SetupEncryptionDialog, {}, null, /* priority = */ false, /* static = */ true); } else { - const Spinner = sdk.getComponent("elements.Spinner"); const modal = Modal.createDialog( Spinner, null, "mx_Dialog_spinner", /* priority */ false, /* static */ true, ); diff --git a/src/toasts/UnverifiedSessionToast.ts b/src/toasts/UnverifiedSessionToast.ts index c856d39d1f..05425b93c0 100644 --- a/src/toasts/UnverifiedSessionToast.ts +++ b/src/toasts/UnverifiedSessionToast.ts @@ -21,7 +21,7 @@ import DeviceListener from '../DeviceListener'; import ToastStore from "../stores/ToastStore"; import GenericToast from "../components/views/toasts/GenericToast"; import { Action } from "../dispatcher/actions"; -import { USER_SECURITY_TAB } from "../components/views/dialogs/UserSettingsDialog"; +import { UserTab } from "../components/views/dialogs/UserSettingsDialog"; function toastKey(deviceId: string) { return "unverified_session_" + deviceId; @@ -34,7 +34,7 @@ export const showToast = async (deviceId: string) => { DeviceListener.sharedInstance().dismissUnverifiedSessions([deviceId]); dis.dispatch({ action: Action.ViewUserSettings, - initialTabId: USER_SECURITY_TAB, + initialTabId: UserTab.Security, }); }; diff --git a/src/utils/AnimationUtils.ts b/src/utils/AnimationUtils.ts new file mode 100644 index 0000000000..61df52826d --- /dev/null +++ b/src/utils/AnimationUtils.ts @@ -0,0 +1,32 @@ +/* +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 { clamp } from "lodash"; + +/** + * This method linearly interpolates between two points (start, end). This is + * most commonly used to find a point some fraction of the way along a line + * between two endpoints (e.g. to move an object gradually between those + * points). + * @param {number} start the starting point + * @param {number} end the ending point + * @param {number} amt the interpolant + * @returns + */ +export function lerp(start: number, end: number, amt: number) { + amt = clamp(amt, 0, 1); + return (1 - amt) * start + amt * end; +} diff --git a/src/utils/AutoDiscoveryUtils.tsx b/src/utils/AutoDiscoveryUtils.tsx index e3a7fd2d0b..6c0c8b2e13 100644 --- a/src/utils/AutoDiscoveryUtils.tsx +++ b/src/utils/AutoDiscoveryUtils.tsx @@ -15,9 +15,9 @@ limitations under the License. */ import React, { ReactNode } from 'react'; -import {AutoDiscovery} from "matrix-js-sdk/src/autodiscovery"; -import {_t, _td, newTranslatableError} from "../languageHandler"; -import {makeType} from "./TypeUtils"; +import { AutoDiscovery } from "matrix-js-sdk/src/autodiscovery"; +import { _t, _td, newTranslatableError } from "../languageHandler"; +import { makeType } from "./TypeUtils"; import SdkConfig from '../SdkConfig'; const LIVELINESS_DISCOVERY_ERRORS: string[] = [ diff --git a/src/utils/DMRoomMap.ts b/src/utils/DMRoomMap.ts index b166674043..50f849b2ba 100644 --- a/src/utils/DMRoomMap.ts +++ b/src/utils/DMRoomMap.ts @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from '../MatrixClientPeg'; -import {uniq} from "lodash"; -import {Room} from "matrix-js-sdk/src/models/room"; -import {Event} from "matrix-js-sdk/src/models/event"; -import {MatrixClient} from "matrix-js-sdk/src/client"; +import { uniq } from "lodash"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + +import { MatrixClientPeg } from '../MatrixClientPeg'; /** * Class that takes a Matrix Client and flips the m.direct map @@ -30,15 +30,13 @@ import {MatrixClient} from "matrix-js-sdk/src/client"; export default class DMRoomMap { private static sharedInstance: DMRoomMap; - private matrixClient: MatrixClient; // TODO: convert these to maps private roomToUser: {[key: string]: string} = null; private userToRooms: {[key: string]: string[]} = null; private hasSentOutPatchDirectAccountDataPatch: boolean; - private mDirectEvent: Event; + private mDirectEvent: object; - constructor(matrixClient) { - this.matrixClient = matrixClient; + constructor(private readonly matrixClient: MatrixClient) { // see onAccountData this.hasSentOutPatchDirectAccountDataPatch = false; @@ -88,7 +86,7 @@ export default class DMRoomMap { this.userToRooms = null; this.roomToUser = null; } - } + }; /** * some client bug somewhere is causing some DMs to be marked @@ -105,7 +103,7 @@ export default class DMRoomMap { if (room) { const userId = room.guessDMUserId(); if (userId && userId !== myUserId) { - return {userId, roomId}; + return { userId, roomId }; } } }).filter((ids) => !!ids); //filter out @@ -118,7 +116,7 @@ export default class DMRoomMap { return !guessedUserIdsThatChanged .some((ids) => ids.roomId === roomId); }); - guessedUserIdsThatChanged.forEach(({userId, roomId}) => { + guessedUserIdsThatChanged.forEach(({ userId, roomId }) => { const roomIds = userToRooms[userId]; if (!roomIds) { userToRooms[userId] = [roomId]; @@ -183,7 +181,7 @@ export default class DMRoomMap { public getUniqueRoomsWithIndividuals(): {[userId: string]: Room} { if (!this.roomToUser) return {}; // No rooms means no map. return Object.keys(this.roomToUser) - .map(r => ({userId: this.getUserIdForRoomId(r), room: this.matrixClient.getRoom(r)})) + .map(r => ({ userId: this.getUserIdForRoomId(r), room: this.matrixClient.getRoom(r) })) .filter(r => r.userId && r.room && r.room.getInvitedAndJoinedMemberCount() === 2) .reduce((obj, r) => (obj[r.userId] = r.room) && obj, {}); } diff --git a/src/utils/DecryptFile.ts b/src/utils/DecryptFile.ts index 93cedbc707..e66db4ffb2 100644 --- a/src/utils/DecryptFile.ts +++ b/src/utils/DecryptFile.ts @@ -16,64 +16,9 @@ limitations under the License. // Pull in the encryption lib so that we can decrypt attachments. import encrypt from 'browser-encrypt-attachment'; -import {mediaFromContent} from "../customisations/Media"; -import {IEncryptedFile} from "../customisations/models/IMediaEventContent"; - -// WARNING: We have to be very careful about what mime-types we allow into blobs, -// as for performance reasons these are now rendered via URL.createObjectURL() -// rather than by converting into data: URIs. -// -// This means that the content is rendered using the origin of the script which -// called createObjectURL(), and so if the content contains any scripting then it -// will pose a XSS vulnerability when the browser renders it. This is particularly -// bad if the user right-clicks the URI and pastes it into a new window or tab, -// as the blob will then execute with access to Element's full JS environment(!) -// -// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647 -// for details. -// -// We mitigate this by only allowing mime-types into blobs which we know don't -// contain any scripting, and instantiate all others as application/octet-stream -// regardless of what mime-type the event claimed. Even if the payload itself -// is some malicious HTML, the fact we instantiate it with a media mimetype or -// application/octet-stream means the browser doesn't try to render it as such. -// -// One interesting edge case is image/svg+xml, which empirically *is* rendered -// correctly if the blob is set to the src attribute of an img tag (for thumbnails) -// *even if the mimetype is application/octet-stream*. However, empirically JS -// in the SVG isn't executed in this scenario, so we seem to be okay. -// -// Tested on Chrome 65 and Firefox 60 -// -// The list below is taken mainly from -// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats -// N.B. Matrix doesn't currently specify which mimetypes are valid in given -// events, so we pick the ones which HTML5 browsers should be able to display -// -// For the record, mime-types which must NEVER enter this list below include: -// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. - -const ALLOWED_BLOB_MIMETYPES = [ - 'image/jpeg', - 'image/gif', - 'image/png', - - 'video/mp4', - 'video/webm', - 'video/ogg', - - 'audio/mp4', - 'audio/webm', - 'audio/aac', - 'audio/mpeg', - 'audio/ogg', - 'audio/wave', - 'audio/wav', - 'audio/x-wav', - 'audio/x-pn-wav', - 'audio/flac', - 'audio/x-flac', -]; +import { mediaFromContent } from "../customisations/Media"; +import { IEncryptedFile } from "../customisations/models/IMediaEventContent"; +import { getBlobSafeMimeType } from "./blobs"; /** * Decrypt a file attached to a matrix event. @@ -84,7 +29,7 @@ const ALLOWED_BLOB_MIMETYPES = [ * @returns {Promise} Resolves to a Blob of the file. */ export function decryptFile(file: IEncryptedFile): Promise { - const media = mediaFromContent({file}); + const media = mediaFromContent({ file }); // Download the encrypted file as an array buffer. return media.downloadSource().then((response) => { return response.arrayBuffer(); @@ -100,10 +45,8 @@ export function decryptFile(file: IEncryptedFile): Promise { // browser (e.g. by copying the URI into a new tab or window.) // See warning at top of file. let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : ''; - if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { - mimetype = 'application/octet-stream'; - } + mimetype = getBlobSafeMimeType(mimetype); - return new Blob([dataArray], {type: mimetype}); + return new Blob([dataArray], { type: mimetype }); }); } diff --git a/src/utils/EditorStateTransfer.js b/src/utils/EditorStateTransfer.js deleted file mode 100644 index c7782a9ea8..0000000000 --- a/src/utils/EditorStateTransfer.js +++ /dev/null @@ -1,49 +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. -*/ - -/** - * Used while editing, to pass the event, and to preserve editor state - * from one editor instance to another when remounting the editor - * upon receiving the remote echo for an unsent event. - */ -export default class EditorStateTransfer { - constructor(event) { - this._event = event; - this._serializedParts = null; - this.caret = null; - } - - setEditorState(caret, serializedParts) { - this._caret = caret; - this._serializedParts = serializedParts; - } - - hasEditorState() { - return !!this._serializedParts; - } - - getSerializedParts() { - return this._serializedParts; - } - - getCaret() { - return this._caret; - } - - getEvent() { - return this._event; - } -} diff --git a/src/utils/EditorStateTransfer.ts b/src/utils/EditorStateTransfer.ts new file mode 100644 index 0000000000..d2ce58f7dc --- /dev/null +++ b/src/utils/EditorStateTransfer.ts @@ -0,0 +1,53 @@ +/* +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. +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 { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +import { SerializedPart } from "../editor/parts"; +import DocumentOffset from "../editor/offset"; + +/** + * Used while editing, to pass the event, and to preserve editor state + * from one editor instance to another when remounting the editor + * upon receiving the remote echo for an unsent event. + */ +export default class EditorStateTransfer { + private serializedParts: SerializedPart[] = null; + private caret: DocumentOffset = null; + + constructor(private readonly event: MatrixEvent) {} + + public setEditorState(caret: DocumentOffset, serializedParts: SerializedPart[]) { + this.caret = caret; + this.serializedParts = serializedParts; + } + + public hasEditorState(): boolean { + return !!this.serializedParts; + } + + public getSerializedParts(): SerializedPart[] { + return this.serializedParts; + } + + public getCaret(): DocumentOffset { + return this.caret; + } + + public getEvent(): MatrixEvent { + return this.event; + } +} diff --git a/src/utils/ErrorUtils.js b/src/utils/ErrorUtils.tsx similarity index 84% rename from src/utils/ErrorUtils.js rename to src/utils/ErrorUtils.tsx index b5bd5b0af0..c39ee21f09 100644 --- a/src/utils/ErrorUtils.js +++ b/src/utils/ErrorUtils.tsx @@ -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,7 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { _t, _td } from '../languageHandler'; +import React, { ReactNode } from "react"; +import { MatrixError } from "matrix-js-sdk/src/http-api"; + +import { _t, _td, Tags, TranslatedString } from '../languageHandler'; /** * Produce a translated error message for a @@ -30,7 +33,12 @@ import { _t, _td } from '../languageHandler'; * for any tags in the strings apart from 'a' * @returns {*} Translated string or react component */ -export function messageForResourceLimitError(limitType, adminContact, strings, extraTranslations) { +export function messageForResourceLimitError( + limitType: string, + adminContact: string, + strings: Record, + extraTranslations?: Tags, +): TranslatedString { let errString = strings[limitType]; if (errString === undefined) errString = strings['']; @@ -49,7 +57,7 @@ export function messageForResourceLimitError(limitType, adminContact, strings, e } } -export function messageForSyncError(err) { +export function messageForSyncError(err: MatrixError | Error): ReactNode { if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const limitError = messageForResourceLimitError( err.data.limit_type, diff --git a/src/utils/EventUtils.js b/src/utils/EventUtils.ts similarity index 84% rename from src/utils/EventUtils.js rename to src/utils/EventUtils.ts index be21896417..1a467b157f 100644 --- a/src/utils/EventUtils.js +++ b/src/utils/EventUtils.ts @@ -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,9 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventStatus } from 'matrix-js-sdk/src/models/event'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event'; + +import { MatrixClientPeg } from '../MatrixClientPeg'; import shouldHideEvent from "../shouldHideEvent"; + /** * Returns whether an event should allow actions like reply, reactions, edit, etc. * which effectively checks whether it's a regular message that has been sent and that we @@ -25,7 +28,7 @@ import shouldHideEvent from "../shouldHideEvent"; * @param {MatrixEvent} mxEvent The event to check * @returns {boolean} true if actionable */ -export function isContentActionable(mxEvent) { +export function isContentActionable(mxEvent: MatrixEvent): boolean { const { status: eventStatus } = mxEvent; // status is SENT before remote-echo, null after @@ -45,18 +48,18 @@ export function isContentActionable(mxEvent) { return false; } -export function canEditContent(mxEvent) { +export function canEditContent(mxEvent: MatrixEvent): boolean { if (mxEvent.status === EventStatus.CANCELLED || mxEvent.getType() !== "m.room.message" || mxEvent.isRedacted()) { return false; } const content = mxEvent.getOriginalContent(); - const {msgtype} = content; + const { msgtype } = content; return (msgtype === "m.text" || msgtype === "m.emote") && content.body && typeof content.body === 'string' && mxEvent.getSender() === MatrixClientPeg.get().getUserId(); } -export function canEditOwnEvent(mxEvent) { +export function canEditOwnEvent(mxEvent: MatrixEvent): boolean { // for now we only allow editing // your own events. So this just call through // In the future though, moderators will be able to @@ -67,7 +70,7 @@ export function canEditOwnEvent(mxEvent) { } const MAX_JUMP_DISTANCE = 100; -export function findEditableEvent(room, isForward, fromEventId = undefined) { +export function findEditableEvent(room: Room, isForward: boolean, fromEventId: string = undefined): MatrixEvent { const liveTimeline = room.getLiveTimeline(); const events = liveTimeline.getEvents().concat(room.getPendingEvents()); const maxIdx = events.length - 1; diff --git a/src/utils/FixedRollingArray.ts b/src/utils/FixedRollingArray.ts new file mode 100644 index 0000000000..0de532648e --- /dev/null +++ b/src/utils/FixedRollingArray.ts @@ -0,0 +1,54 @@ +/* +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 { arrayFastClone, arraySeed } from "./arrays"; + +/** + * An array which is of fixed length and accepts rolling values. Values will + * be inserted on the left, falling off the right. + */ +export class FixedRollingArray { + private samples: T[] = []; + + /** + * Creates a new fixed rolling array. + * @param width The width of the array. + * @param padValue The value to seed the array with. + */ + constructor(private width: number, padValue: T) { + this.samples = arraySeed(padValue, this.width); + } + + /** + * The array, as a fixed length. + */ + public get value(): T[] { + return this.samples; + } + + /** + * Pushes a value to the array. + * @param value The value to push. + */ + public pushValue(value: T) { + let swap = arrayFastClone(this.samples); + swap.splice(0, 0, value); + if (swap.length > this.width) { + swap = swap.slice(0, this.width); + } + this.samples = swap; + } +} diff --git a/src/utils/FontManager.js b/src/utils/FontManager.ts similarity index 95% rename from src/utils/FontManager.js rename to src/utils/FontManager.ts index df11b76ae4..deb0c1810c 100644 --- a/src/utils/FontManager.js +++ b/src/utils/FontManager.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,7 +21,7 @@ limitations under the License. * MIT license */ -function safariVersionCheck(ua) { +function safariVersionCheck(ua: string): boolean { console.log("Browser is Safari - checking version for COLR support"); try { const safariVersionMatch = ua.match(/Mac OS X ([\d|_]+).*Version\/([\d|.]+).*Safari/); @@ -44,10 +44,10 @@ function safariVersionCheck(ua) { return false; } -async function isColrFontSupported() { +async function isColrFontSupported(): Promise { console.log("Checking for COLR support"); - const {userAgent} = navigator; + const { userAgent } = navigator; // Firefox has supported COLR fonts since version 26 // but doesn't support the check below without // "Extract canvas data" permissions @@ -101,7 +101,7 @@ async function isColrFontSupported() { } let colrFontCheckStarted = false; -export async function fixupColorFonts() { +export async function fixupColorFonts(): Promise { if (colrFontCheckStarted) { return; } @@ -112,14 +112,14 @@ export async function fixupColorFonts() { document.fonts.add(new FontFace("Twemoji", path, {})); // For at least Chrome on Windows 10, we have to explictly add extra // weights for the emoji to appear in bold messages, etc. - document.fonts.add(new FontFace("Twemoji", path, { weight: 600 })); - document.fonts.add(new FontFace("Twemoji", path, { weight: 700 })); + document.fonts.add(new FontFace("Twemoji", path, { weight: "600" })); + document.fonts.add(new FontFace("Twemoji", path, { weight: "700" })); } else { // fall back to SBIX, generated via https://github.com/matrix-org/twemoji-colr/tree/matthew/sbix const path = `url('${require("../../res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2")}')`; document.fonts.add(new FontFace("Twemoji", path, {})); - document.fonts.add(new FontFace("Twemoji", path, { weight: 600 })); - document.fonts.add(new FontFace("Twemoji", path, { weight: 700 })); + document.fonts.add(new FontFace("Twemoji", path, { weight: "600" })); + document.fonts.add(new FontFace("Twemoji", path, { weight: "700" })); } // ...and if SBIX is not supported, the browser will fall back to one of the native fonts specified. } diff --git a/src/utils/HostingLink.js b/src/utils/HostingLink.js index 580ed00de5..ff7b0c221c 100644 --- a/src/utils/HostingLink.js +++ b/src/utils/HostingLink.js @@ -18,7 +18,7 @@ import url from 'url'; import qs from 'qs'; import SdkConfig from '../SdkConfig'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { MatrixClientPeg } from '../MatrixClientPeg'; export function getHostingLink(campaign) { const hostingLink = SdkConfig.get().hosting_signup_link; diff --git a/src/utils/IdentityServerUtils.js b/src/utils/IdentityServerUtils.ts similarity index 79% rename from src/utils/IdentityServerUtils.js rename to src/utils/IdentityServerUtils.ts index 5ece308954..47226a1727 100644 --- a/src/utils/IdentityServerUtils.js +++ b/src/utils/IdentityServerUtils.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,14 +15,15 @@ limitations under the License. */ import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; -import SdkConfig from '../SdkConfig'; -import {MatrixClientPeg} from '../MatrixClientPeg'; -export function getDefaultIdentityServerUrl() { +import SdkConfig from '../SdkConfig'; +import { MatrixClientPeg } from '../MatrixClientPeg'; + +export function getDefaultIdentityServerUrl(): string { return SdkConfig.get()['validated_server_config']['isUrl']; } -export function useDefaultIdentityServer() { +export function useDefaultIdentityServer(): void { const url = getDefaultIdentityServerUrl(); // Account data change will update localstorage, client, etc through dispatcher MatrixClientPeg.get().setAccountData("m.identity_server", { @@ -30,7 +31,7 @@ export function useDefaultIdentityServer() { }); } -export async function doesIdentityServerHaveTerms(fullUrl) { +export async function doesIdentityServerHaveTerms(fullUrl: string): Promise { let terms; try { terms = await MatrixClientPeg.get().getTerms(SERVICE_TYPES.IS, fullUrl); @@ -46,7 +47,7 @@ export async function doesIdentityServerHaveTerms(fullUrl) { return terms && terms["policies"] && (Object.keys(terms["policies"]).length > 0); } -export function doesAccountDataHaveIdentityServer() { +export function doesAccountDataHaveIdentityServer(): boolean { const event = MatrixClientPeg.get().getAccountData("m.identity_server"); return event && event.getContent() && event.getContent()['base_url']; } diff --git a/src/utils/KeyVerificationStateObserver.js b/src/utils/KeyVerificationStateObserver.js index a29d2ea1aa..023cdf3a75 100644 --- a/src/utils/KeyVerificationStateObserver.js +++ b/src/utils/KeyVerificationStateObserver.js @@ -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 { _t } from '../languageHandler'; export function getNameForEventRoom(userId, roomId) { @@ -27,7 +27,7 @@ export function getNameForEventRoom(userId, roomId) { export function userLabelForEventRoom(userId, roomId) { const name = getNameForEventRoom(userId, roomId); if (name !== userId) { - return _t("%(name)s (%(userId)s)", {name, userId}); + return _t("%(name)s (%(userId)s)", { name, userId }); } else { return userId; } diff --git a/src/utils/MarkedExecution.ts b/src/utils/MarkedExecution.ts index b0b8fdf63d..01cc91adce 100644 --- a/src/utils/MarkedExecution.ts +++ b/src/utils/MarkedExecution.ts @@ -26,9 +26,11 @@ export class MarkedExecution { /** * Creates a MarkedExecution for the provided function. - * @param fn The function to be called upon trigger if marked. + * @param {Function} fn The function to be called upon trigger if marked. + * @param {Function} onMarkCallback A function that is called when a new mark is made. Not + * called if a mark is already flagged. */ - constructor(private fn: () => void) { + constructor(private fn: () => void, private onMarkCallback?: () => void) { } /** @@ -42,6 +44,7 @@ export class MarkedExecution { * Marks the function to be called upon trigger(). */ public mark() { + if (!this.marked) this.onMarkCallback?.(); this.marked = true; } diff --git a/src/utils/MegolmExportEncryption.js b/src/utils/MegolmExportEncryption.js index be7472901a..06f09e7047 100644 --- a/src/utils/MegolmExportEncryption.js +++ b/src/utils/MegolmExportEncryption.js @@ -15,17 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// polyfill textencoder if necessary -import * as TextEncodingUtf8 from 'text-encoding-utf-8'; -let TextEncoder = window.TextEncoder; -if (!TextEncoder) { - TextEncoder = TextEncodingUtf8.TextEncoder; -} -let TextDecoder = window.TextDecoder; -if (!TextDecoder) { - TextDecoder = TextEncodingUtf8.TextDecoder; -} - import { _t } from '../languageHandler'; import SdkConfig from '../SdkConfig'; @@ -92,7 +81,7 @@ export async function decryptMegolmKeyFile(data, password) { let isValid; try { isValid = await subtleCrypto.verify( - {name: 'HMAC'}, + { name: 'HMAC' }, hmacKey, hmac, toVerify, @@ -123,7 +112,6 @@ export async function decryptMegolmKeyFile(data, password) { return new TextDecoder().decode(new Uint8Array(plaintext)); } - /** * Encrypt a megolm key file * @@ -185,7 +173,7 @@ export async function encryptMegolmKeyFile(data, password, options) { let hmac; try { hmac = await subtleCrypto.sign( - {name: 'HMAC'}, + { name: 'HMAC' }, hmacKey, toSign, ); @@ -193,7 +181,6 @@ export async function encryptMegolmKeyFile(data, password, options) { throw friendlyError('subtleCrypto.sign failed: ' + e, cryptoFailMsg()); } - const hmacArray = new Uint8Array(hmac); resultBuffer.set(hmacArray, idx); return packMegolmKeyFile(resultBuffer); @@ -215,7 +202,7 @@ async function deriveKeys(salt, iterations, password) { key = await subtleCrypto.importKey( 'raw', new TextEncoder().encode(password), - {name: 'PBKDF2'}, + { name: 'PBKDF2' }, false, ['deriveBits'], ); @@ -248,7 +235,7 @@ async function deriveKeys(salt, iterations, password) { const aesProm = subtleCrypto.importKey( 'raw', aesKey, - {name: 'AES-CTR'}, + { name: 'AES-CTR' }, false, ['encrypt', 'decrypt'], ).catch((e) => { @@ -260,7 +247,7 @@ async function deriveKeys(salt, iterations, password) { hmacKey, { name: 'HMAC', - hash: {name: 'SHA-256'}, + hash: { name: 'SHA-256' }, }, false, ['sign', 'verify'], @@ -310,8 +297,7 @@ function unpackMegolmKeyFile(data) { // look for the end line while (1) { const lineEnd = fileStr.indexOf('\n', lineStart); - const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd) - .trim(); + const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd).trim(); if (line === TRAILER_LINE) { break; } diff --git a/src/utils/MessageDiffUtils.js b/src/utils/MessageDiffUtils.tsx similarity index 86% rename from src/utils/MessageDiffUtils.js rename to src/utils/MessageDiffUtils.tsx index 7398173fdd..020a7763cf 100644 --- a/src/utils/MessageDiffUtils.js +++ b/src/utils/MessageDiffUtils.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,31 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import classNames from 'classnames'; -import DiffMatchPatch from 'diff-match-patch'; -import {DiffDOM} from "diff-dom"; -import { checkBlockNode, bodyToHtml } from "../HtmlUtils"; +import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch'; +import { DiffDOM, IDiff } from "diff-dom"; +import { IContent } from "matrix-js-sdk/src/models/event"; + +import { bodyToHtml, checkBlockNode, IOptsReturnString } from "../HtmlUtils"; const decodeEntities = (function() { let textarea = null; - return function(string) { + return function(str: string): string { if (!textarea) { textarea = document.createElement("textarea"); } - textarea.innerHTML = string; + textarea.innerHTML = str; return textarea.value; }; })(); -function textToHtml(text) { +function textToHtml(text: string): string { const container = document.createElement("div"); container.textContent = text; return container.innerHTML; } -function getSanitizedHtmlBody(content) { - const opts = { +function getSanitizedHtmlBody(content: IContent): string { + const opts: IOptsReturnString = { stripReplyFallback: true, returnString: true, }; @@ -57,21 +59,21 @@ function getSanitizedHtmlBody(content) { } } -function wrapInsertion(child) { +function wrapInsertion(child: Node): HTMLElement { const wrapper = document.createElement(checkBlockNode(child) ? "div" : "span"); wrapper.className = "mx_EditHistoryMessage_insertion"; wrapper.appendChild(child); return wrapper; } -function wrapDeletion(child) { +function wrapDeletion(child: Node): HTMLElement { const wrapper = document.createElement(checkBlockNode(child) ? "div" : "span"); wrapper.className = "mx_EditHistoryMessage_deletion"; wrapper.appendChild(child); return wrapper; } -function findRefNodes(root, route, isAddition) { +function findRefNodes(root: Node, route: number[], isAddition = false) { let refNode = root; let refParentNode; const end = isAddition ? route.length - 1 : route.length; @@ -79,7 +81,7 @@ function findRefNodes(root, route, isAddition) { refParentNode = refNode; refNode = refNode.childNodes[route[i]]; } - return {refNode, refParentNode}; + return { refNode, refParentNode }; } function diffTreeToDOM(desc) { @@ -101,7 +103,7 @@ function diffTreeToDOM(desc) { } } -function insertBefore(parent, nextSibling, child) { +function insertBefore(parent: Node, nextSibling: Node | null, child: Node): void { if (nextSibling) { parent.insertBefore(child, nextSibling); } else { @@ -109,7 +111,7 @@ function insertBefore(parent, nextSibling, child) { } } -function isRouteOfNextSibling(route1, route2) { +function isRouteOfNextSibling(route1: number[], route2: number[]): boolean { // routes are arrays with indices, // to be interpreted as a path in the dom tree @@ -127,7 +129,7 @@ function isRouteOfNextSibling(route1, route2) { return route2[lastD1Idx] >= route1[lastD1Idx]; } -function adjustRoutes(diff, remainingDiffs) { +function adjustRoutes(diff: IDiff, remainingDiffs: IDiff[]): void { if (diff.action === "removeTextElement" || diff.action === "removeElement") { // as removed text is not removed from the html, but marked as deleted, // we need to readjust indices that assume the current node has been removed. @@ -140,12 +142,12 @@ function adjustRoutes(diff, remainingDiffs) { } } -function stringAsTextNode(string) { +function stringAsTextNode(string: string): Text { return document.createTextNode(decodeEntities(string)); } -function renderDifferenceInDOM(originalRootNode, diff, diffMathPatch) { - const {refNode, refParentNode} = findRefNodes(originalRootNode, diff.route); +function renderDifferenceInDOM(originalRootNode: Node, diff: IDiff, diffMathPatch: DiffMatchPatch): void { + const { refNode, refParentNode } = findRefNodes(originalRootNode, diff.route); switch (diff.action) { case "replaceElement": { const container = document.createElement("span"); @@ -171,7 +173,7 @@ function renderDifferenceInDOM(originalRootNode, diff, diffMathPatch) { diffMathPatch.diff_cleanupSemantic(textDiffs); const container = document.createElement("span"); for (const [modifier, text] of textDiffs) { - let textDiffNode = stringAsTextNode(text); + let textDiffNode: Node = stringAsTextNode(text); if (modifier < 0) { textDiffNode = wrapDeletion(textDiffNode); } else if (modifier > 0) { @@ -201,7 +203,7 @@ function renderDifferenceInDOM(originalRootNode, diff, diffMathPatch) { case "addAttribute": case "modifyAttribute": { const delNode = wrapDeletion(refNode.cloneNode(true)); - const updatedNode = refNode.cloneNode(true); + const updatedNode = refNode.cloneNode(true) as HTMLElement; if (diff.action === "addAttribute" || diff.action === "modifyAttribute") { updatedNode.setAttribute(diff.name, diff.newValue); } else { @@ -220,12 +222,12 @@ function renderDifferenceInDOM(originalRootNode, diff, diffMathPatch) { } } -function routeIsEqual(r1, r2) { +function routeIsEqual(r1: number[], r2: number[]): boolean { return r1.length === r2.length && !r1.some((e, i) => e !== r2[i]); } // workaround for https://github.com/fiduswriter/diffDOM/issues/90 -function filterCancelingOutDiffs(originalDiffActions) { +function filterCancelingOutDiffs(originalDiffActions: IDiff[]): IDiff[] { const diffActions = originalDiffActions.slice(); for (let i = 0; i < diffActions.length; ++i) { @@ -252,7 +254,7 @@ function filterCancelingOutDiffs(originalDiffActions) { * @param {object} editContent the content for the edit message * @return {object} a react element similar to what `bodyToHtml` returns */ -export function editBodyDiffToHtml(originalContent, editContent) { +export function editBodyDiffToHtml(originalContent: IContent, editContent: IContent): ReactNode { // wrap the body in a div, DiffDOM needs a root element const originalBody = `
    ${getSanitizedHtmlBody(originalContent)}
    `; const editBody = `
    ${getSanitizedHtmlBody(editContent)}
    `; diff --git a/src/utils/MultiInviter.js b/src/utils/MultiInviter.js deleted file mode 100644 index 78f956b91b..0000000000 --- a/src/utils/MultiInviter.js +++ /dev/null @@ -1,263 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2017, 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import {MatrixClientPeg} from '../MatrixClientPeg'; -import {getAddressType} from '../UserAddress'; -import GroupStore from '../stores/GroupStore'; -import {_t} from "../languageHandler"; -import * as sdk from "../index"; -import Modal from "../Modal"; -import SettingsStore from "../settings/SettingsStore"; -import {defer} from "./promise"; - -/** - * Invites multiple addresses to a room or group, handling rate limiting from the server - */ -export default class MultiInviter { - /** - * @param {string} targetId The ID of the room or group to invite to - */ - constructor(targetId) { - if (targetId[0] === '+') { - this.roomId = null; - this.groupId = targetId; - } else { - this.roomId = targetId; - this.groupId = null; - } - - this.canceled = false; - this.addrs = []; - this.busy = false; - this.completionStates = {}; // State of each address (invited or error) - this.errors = {}; // { address: {errorText, errcode} } - this.deferred = null; - } - - /** - * Invite users to this room. This may only be called once per - * instance of the class. - * - * @param {array} addrs Array of addresses to invite - * @param {string} reason Reason for inviting (optional) - * @returns {Promise} Resolved when all invitations in the queue are complete - */ - invite(addrs, reason) { - if (this.addrs.length > 0) { - throw new Error("Already inviting/invited"); - } - this.addrs.push(...addrs); - this.reason = reason; - - for (const addr of this.addrs) { - if (getAddressType(addr) === null) { - this.completionStates[addr] = 'error'; - this.errors[addr] = { - errcode: 'M_INVALID', - errorText: _t('Unrecognised address'), - }; - } - } - this.deferred = defer(); - this._inviteMore(0); - - return this.deferred.promise; - } - - /** - * Stops inviting. Causes promises returned by invite() to be rejected. - */ - cancel() { - if (!this.busy) return; - - this._canceled = true; - this.deferred.reject(new Error('canceled')); - } - - getCompletionState(addr) { - return this.completionStates[addr]; - } - - getErrorText(addr) { - return this.errors[addr] ? this.errors[addr].errorText : null; - } - - async _inviteToRoom(roomId, addr, ignoreProfile) { - const addrType = getAddressType(addr); - - if (addrType === 'email') { - return MatrixClientPeg.get().inviteByEmail(roomId, addr); - } else if (addrType === 'mx-user-id') { - const room = MatrixClientPeg.get().getRoom(roomId); - if (!room) throw new Error("Room not found"); - - const member = room.getMember(addr); - if (member && ['join', 'invite'].includes(member.membership)) { - throw {errcode: "RIOT.ALREADY_IN_ROOM", error: "Member already invited"}; - } - - if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) { - const profile = await MatrixClientPeg.get().getProfileInfo(addr); - if (!profile) { - // noinspection ExceptionCaughtLocallyJS - throw new Error("User has no profile"); - } - } - - return MatrixClientPeg.get().invite(roomId, addr, undefined, this.reason); - } else { - throw new Error('Unsupported address'); - } - } - - _doInvite(address, ignoreProfile) { - return new Promise((resolve, reject) => { - console.log(`Inviting ${address}`); - - let doInvite; - if (this.groupId !== null) { - doInvite = GroupStore.inviteUserToGroup(this.groupId, address); - } else { - doInvite = this._inviteToRoom(this.roomId, address, ignoreProfile); - } - - doInvite.then(() => { - if (this._canceled) { - return; - } - - this.completionStates[address] = 'invited'; - delete this.errors[address]; - - resolve(); - }).catch((err) => { - if (this._canceled) { - return; - } - - console.error(err); - - let errorText; - let fatal = false; - if (err.errcode === 'M_FORBIDDEN') { - fatal = true; - errorText = _t('You do not have permission to invite people to this room.'); - } else if (err.errcode === "RIOT.ALREADY_IN_ROOM") { - errorText = _t("User %(userId)s is already in the room", {userId: address}); - } else if (err.errcode === 'M_LIMIT_EXCEEDED') { - // we're being throttled so wait a bit & try again - setTimeout(() => { - this._doInvite(address, ignoreProfile).then(resolve, reject); - }, 5000); - return; - } else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND'].includes(err.errcode)) { - errorText = _t("User %(user_id)s does not exist", {user_id: address}); - } else if (err.errcode === 'M_PROFILE_UNDISCLOSED') { - errorText = _t("User %(user_id)s may or may not exist", {user_id: address}); - } else if (err.errcode === 'M_PROFILE_NOT_FOUND' && !ignoreProfile) { - // Invite without the profile check - console.warn(`User ${address} does not have a profile - inviting anyways automatically`); - this._doInvite(address, true).then(resolve, reject); - } else if (err.errcode === "M_BAD_STATE") { - errorText = _t("The user must be unbanned before they can be invited."); - } else if (err.errcode === "M_UNSUPPORTED_ROOM_VERSION") { - errorText = _t("The user's homeserver does not support the version of the room."); - } else { - errorText = _t('Unknown server error'); - } - - this.completionStates[address] = 'error'; - this.errors[address] = {errorText, errcode: err.errcode}; - - this.busy = !fatal; - this.fatal = fatal; - - if (fatal) { - reject(); - } else { - resolve(); - } - }); - }); - } - - _inviteMore(nextIndex, ignoreProfile) { - if (this._canceled) { - return; - } - - if (nextIndex === this.addrs.length) { - this.busy = false; - if (Object.keys(this.errors).length > 0 && !this.groupId) { - // There were problems inviting some people - see if we can invite them - // without caring if they exist or not. - const unknownProfileErrors = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND']; - const unknownProfileUsers = Object.keys(this.errors).filter(a => unknownProfileErrors.includes(this.errors[a].errcode)); - - if (unknownProfileUsers.length > 0) { - const inviteUnknowns = () => { - const promises = unknownProfileUsers.map(u => this._doInvite(u, true)); - Promise.all(promises).then(() => this.deferred.resolve(this.completionStates)); - }; - - if (!SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) { - inviteUnknowns(); - return; - } - - const AskInviteAnywayDialog = sdk.getComponent("dialogs.AskInviteAnywayDialog"); - console.log("Showing failed to invite dialog..."); - Modal.createTrackedDialog('Failed to invite', '', AskInviteAnywayDialog, { - unknownProfileUsers: unknownProfileUsers.map(u => {return {userId: u, errorText: this.errors[u].errorText};}), - onInviteAnyways: () => inviteUnknowns(), - onGiveUp: () => { - // Fake all the completion states because we already warned the user - for (const addr of unknownProfileUsers) { - this.completionStates[addr] = 'invited'; - } - this.deferred.resolve(this.completionStates); - }, - }); - return; - } - } - this.deferred.resolve(this.completionStates); - return; - } - - const addr = this.addrs[nextIndex]; - - // don't try to invite it if it's an invalid address - // (it will already be marked as an error though, - // so no need to do so again) - if (getAddressType(addr) === null) { - this._inviteMore(nextIndex + 1); - return; - } - - // don't re-invite (there's no way in the UI to do this, but - // for sanity's sake) - if (this.completionStates[addr] === 'invited') { - this._inviteMore(nextIndex + 1); - return; - } - - this._doInvite(addr, ignoreProfile).then(() => { - this._inviteMore(nextIndex + 1, ignoreProfile); - }).catch(() => this.deferred.resolve(this.completionStates)); - } -} diff --git a/src/utils/MultiInviter.ts b/src/utils/MultiInviter.ts new file mode 100644 index 0000000000..0707a684eb --- /dev/null +++ b/src/utils/MultiInviter.ts @@ -0,0 +1,317 @@ +/* +Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixError } from "matrix-js-sdk/src/http-api"; +import { defer, IDeferred } from "matrix-js-sdk/src/utils"; + +import { MatrixClientPeg } from '../MatrixClientPeg'; +import { AddressType, getAddressType } from '../UserAddress'; +import GroupStore from '../stores/GroupStore'; +import { _t } from "../languageHandler"; +import Modal from "../Modal"; +import SettingsStore from "../settings/SettingsStore"; +import AskInviteAnywayDialog from "../components/views/dialogs/AskInviteAnywayDialog"; + +export enum InviteState { + Invited = "invited", + Error = "error", +} + +interface IError { + errorText: string; + errcode: string; +} + +const UNKNOWN_PROFILE_ERRORS = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND']; + +export type CompletionStates = Record; + +const USER_ALREADY_JOINED = "IO.ELEMENT.ALREADY_JOINED"; +const USER_ALREADY_INVITED = "IO.ELEMENT.ALREADY_INVITED"; + +/** + * Invites multiple addresses to a room or group, handling rate limiting from the server + */ +export default class MultiInviter { + private readonly roomId?: string; + private readonly groupId?: string; + + private canceled = false; + private addresses: string[] = []; + private busy = false; + private _fatal = false; + private completionStates: CompletionStates = {}; // State of each address (invited or error) + private errors: Record = {}; // { address: {errorText, errcode} } + private deferred: IDeferred = null; + private reason: string = null; + + /** + * @param {string} targetId The ID of the room or group to invite to + */ + constructor(targetId: string) { + if (targetId[0] === '+') { + this.roomId = null; + this.groupId = targetId; + } else { + this.roomId = targetId; + this.groupId = null; + } + } + + public get fatal() { + return this._fatal; + } + + /** + * Invite users to this room. This may only be called once per + * instance of the class. + * + * @param {array} addresses Array of addresses to invite + * @param {string} reason Reason for inviting (optional) + * @returns {Promise} Resolved when all invitations in the queue are complete + */ + public invite(addresses, reason?: string): Promise { + if (this.addresses.length > 0) { + throw new Error("Already inviting/invited"); + } + this.addresses.push(...addresses); + this.reason = reason; + + for (const addr of this.addresses) { + if (getAddressType(addr) === null) { + this.completionStates[addr] = InviteState.Error; + this.errors[addr] = { + errcode: 'M_INVALID', + errorText: _t('Unrecognised address'), + }; + } + } + this.deferred = defer(); + this.inviteMore(0); + + return this.deferred.promise; + } + + /** + * Stops inviting. Causes promises returned by invite() to be rejected. + */ + public cancel(): void { + if (!this.busy) return; + + this.canceled = true; + this.deferred.reject(new Error('canceled')); + } + + public getCompletionState(addr: string): InviteState { + return this.completionStates[addr]; + } + + public getErrorText(addr: string): string { + return this.errors[addr] ? this.errors[addr].errorText : null; + } + + private async inviteToRoom(roomId: string, addr: string, ignoreProfile = false): Promise<{}> { + const addrType = getAddressType(addr); + + if (addrType === AddressType.Email) { + return MatrixClientPeg.get().inviteByEmail(roomId, addr); + } else if (addrType === AddressType.MatrixUserId) { + const room = MatrixClientPeg.get().getRoom(roomId); + if (!room) throw new Error("Room not found"); + + const member = room.getMember(addr); + if (member?.membership === "join") { + throw new MatrixError({ + errcode: USER_ALREADY_JOINED, + error: "Member already joined", + }); + } else if (member?.membership === "invite") { + throw new MatrixError({ + errcode: USER_ALREADY_INVITED, + error: "Member already invited", + }); + } + + if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) { + const profile = await MatrixClientPeg.get().getProfileInfo(addr); + if (!profile) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("User has no profile"); + } + } + + return MatrixClientPeg.get().invite(roomId, addr, undefined, this.reason); + } else { + throw new Error('Unsupported address'); + } + } + + private doInvite(address: string, ignoreProfile = false): Promise { + return new Promise((resolve, reject) => { + console.log(`Inviting ${address}`); + + let doInvite; + if (this.groupId !== null) { + doInvite = GroupStore.inviteUserToGroup(this.groupId, address); + } else { + doInvite = this.inviteToRoom(this.roomId, address, ignoreProfile); + } + + doInvite.then(() => { + if (this.canceled) { + return; + } + + this.completionStates[address] = InviteState.Invited; + delete this.errors[address]; + + resolve(); + }).catch((err) => { + if (this.canceled) { + return; + } + + console.error(err); + + let errorText; + let fatal = false; + switch (err.errcode) { + case "M_FORBIDDEN": + errorText = _t('You do not have permission to invite people to this room.'); + fatal = true; + break; + case USER_ALREADY_INVITED: + errorText = _t("User %(userId)s is already invited to the room", { userId: address }); + break; + case USER_ALREADY_JOINED: + errorText = _t("User %(userId)s is already in the room", { userId: address }); + break; + case "M_LIMIT_EXCEEDED": + // we're being throttled so wait a bit & try again + setTimeout(() => { + this.doInvite(address, ignoreProfile).then(resolve, reject); + }, 5000); + return; + case "M_NOT_FOUND": + case "M_USER_NOT_FOUND": + errorText = _t("User %(user_id)s does not exist", { user_id: address }); + break; + case "M_PROFILE_UNDISCLOSED": + errorText = _t("User %(user_id)s may or may not exist", { user_id: address }); + break; + case "M_PROFILE_NOT_FOUND": + if (!ignoreProfile) { + // Invite without the profile check + console.warn(`User ${address} does not have a profile - inviting anyways automatically`); + this.doInvite(address, true).then(resolve, reject); + return; + } + break; + case "M_BAD_STATE": + errorText = _t("The user must be unbanned before they can be invited."); + break; + case "M_UNSUPPORTED_ROOM_VERSION": + errorText = _t("The user's homeserver does not support the version of the room."); + break; + } + + if (!errorText) { + errorText = _t('Unknown server error'); + } + + this.completionStates[address] = InviteState.Error; + this.errors[address] = { errorText, errcode: err.errcode }; + + this.busy = !fatal; + this._fatal = fatal; + + if (fatal) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + private inviteMore(nextIndex: number, ignoreProfile = false): void { + if (this.canceled) { + return; + } + + if (nextIndex === this.addresses.length) { + this.busy = false; + if (Object.keys(this.errors).length > 0 && !this.groupId) { + // There were problems inviting some people - see if we can invite them + // without caring if they exist or not. + const unknownProfileUsers = Object.keys(this.errors) + .filter(a => UNKNOWN_PROFILE_ERRORS.includes(this.errors[a].errcode)); + + if (unknownProfileUsers.length > 0) { + const inviteUnknowns = () => { + const promises = unknownProfileUsers.map(u => this.doInvite(u, true)); + Promise.all(promises).then(() => this.deferred.resolve(this.completionStates)); + }; + + if (!SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) { + inviteUnknowns(); + return; + } + + console.log("Showing failed to invite dialog..."); + Modal.createTrackedDialog('Failed to invite', '', AskInviteAnywayDialog, { + unknownProfileUsers: unknownProfileUsers.map(u => ({ + userId: u, + errorText: this.errors[u].errorText, + })), + onInviteAnyways: () => inviteUnknowns(), + onGiveUp: () => { + // Fake all the completion states because we already warned the user + for (const addr of unknownProfileUsers) { + this.completionStates[addr] = InviteState.Invited; + } + this.deferred.resolve(this.completionStates); + }, + }); + return; + } + } + this.deferred.resolve(this.completionStates); + return; + } + + const addr = this.addresses[nextIndex]; + + // don't try to invite it if it's an invalid address + // (it will already be marked as an error though, + // so no need to do so again) + if (getAddressType(addr) === null) { + this.inviteMore(nextIndex + 1); + return; + } + + // don't re-invite (there's no way in the UI to do this, but + // for sanity's sake) + if (this.completionStates[addr] === InviteState.Invited) { + this.inviteMore(nextIndex + 1); + return; + } + + this.doInvite(addr, ignoreProfile).then(() => { + this.inviteMore(nextIndex + 1, ignoreProfile); + }).catch(() => this.deferred.resolve(this.completionStates)); + } +} diff --git a/src/utils/PasswordScorer.ts b/src/utils/PasswordScorer.ts index d8f3b0fb96..e3c30c1680 100644 --- a/src/utils/PasswordScorer.ts +++ b/src/utils/PasswordScorer.ts @@ -16,7 +16,7 @@ limitations under the License. import zxcvbn from 'zxcvbn'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { MatrixClientPeg } from '../MatrixClientPeg'; import { _t, _td } from '../languageHandler'; const ZXCVBN_USER_INPUTS = [ diff --git a/src/utils/PinningUtils.js b/src/utils/PinningUtils.ts similarity index 89% rename from src/utils/PinningUtils.js rename to src/utils/PinningUtils.ts index 90d26cc988..ec1eeccf1f 100644 --- a/src/utils/PinningUtils.js +++ b/src/utils/PinningUtils.ts @@ -14,13 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + export default class PinningUtils { /** * Determines if the given event may be pinned. * @param {MatrixEvent} event The event to check. * @return {boolean} True if the event may be pinned, false otherwise. */ - static isPinnable(event) { + static isPinnable(event: MatrixEvent): boolean { if (!event) return false; if (event.getType() !== "m.room.message") return false; if (event.isRedacted()) return false; diff --git a/src/utils/Receipt.js b/src/utils/Receipt.ts similarity index 83% rename from src/utils/Receipt.js rename to src/utils/Receipt.ts index d88c67fb18..2a626decc4 100644 --- a/src/utils/Receipt.js +++ b/src/utils/Receipt.ts @@ -1,5 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd +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. @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + /** * Given MatrixEvent containing receipts, return the first * read receipt from the given user ID, or null if no such @@ -23,7 +25,7 @@ limitations under the License. * @param {string} userId A user ID * @returns {Object} Read receipt */ -export function findReadReceiptFromUserId(receiptEvent, userId) { +export function findReadReceiptFromUserId(receiptEvent: MatrixEvent, userId: string): object | null { const receiptKeys = Object.keys(receiptEvent.getContent()); for (let i = 0; i < receiptKeys.length; ++i) { const rcpt = receiptEvent.getContent()[receiptKeys[i]]; diff --git a/src/utils/ResizeNotifier.js b/src/utils/ResizeNotifier.ts similarity index 57% rename from src/utils/ResizeNotifier.js rename to src/utils/ResizeNotifier.ts index fd12a454f6..8bb7f52e57 100644 --- a/src/utils/ResizeNotifier.js +++ b/src/utils/ResizeNotifier.ts @@ -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. @@ -22,65 +22,58 @@ limitations under the License. * Fires when the middle panel has been resized by a pixel. * @event module:utils~ResizeNotifier#"middlePanelResizedNoisy" */ + import { EventEmitter } from "events"; import { throttle } from "lodash"; export default class ResizeNotifier extends EventEmitter { - constructor() { - super(); - // with default options, will call fn once at first call, and then every x ms - // if there was another call in that timespan - this._throttledMiddlePanel = throttle(() => this.emit("middlePanelResized"), 200); - this._isResizing = false; - } + private _isResizing = false; - get isResizing() { + // with default options, will call fn once at first call, and then every x ms + // if there was another call in that timespan + private throttledMiddlePanel = throttle(() => this.emit("middlePanelResized"), 200); + + public get isResizing() { return this._isResizing; } - startResizing() { + public startResizing() { this._isResizing = true; this.emit("isResizing", true); } - stopResizing() { + public stopResizing() { this._isResizing = false; this.emit("isResizing", false); } - _noisyMiddlePanel() { + private noisyMiddlePanel() { this.emit("middlePanelResizedNoisy"); } - _updateMiddlePanel() { - this._throttledMiddlePanel(); - this._noisyMiddlePanel(); + private updateMiddlePanel() { + this.throttledMiddlePanel(); + this.noisyMiddlePanel(); } // can be called in quick succession - notifyLeftHandleResized() { + public notifyLeftHandleResized() { // don't emit event for own region - this._updateMiddlePanel(); + this.updateMiddlePanel(); } // can be called in quick succession - notifyRightHandleResized() { - this._updateMiddlePanel(); + public notifyRightHandleResized() { + this.updateMiddlePanel(); } - notifyTimelineHeightChanged() { - this._updateMiddlePanel(); + public notifyTimelineHeightChanged() { + this.updateMiddlePanel(); } // can be called in quick succession - notifyWindowResized() { - // no need to throttle this one, - // also it could make scrollbars appear for - // a split second when the room list manual layout is now - // taller than the available space - this.emit("leftPanelResized"); - - this._updateMiddlePanel(); + public notifyWindowResized() { + this.updateMiddlePanel(); } } diff --git a/src/utils/ShieldUtils.ts b/src/utils/ShieldUtils.ts index 5fe653fed0..296c68fa09 100644 --- a/src/utils/ShieldUtils.ts +++ b/src/utils/ShieldUtils.ts @@ -1,31 +1,32 @@ +/* +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 { MatrixClient } from "matrix-js-sdk/src/client"; +import { Room } from "matrix-js-sdk/src/models/room"; + import DMRoomMap from './DMRoomMap'; -/* For now, a cut-down type spec for the client */ -interface Client { - getUserId: () => string; - checkUserTrust: (userId: string) => { - isCrossSigningVerified: () => boolean - wasCrossSigningVerified: () => boolean - }; - getStoredDevicesForUser: (userId: string) => [{ deviceId: string }]; - checkDeviceTrust: (userId: string, deviceId: string) => { - isVerified: () => boolean - }; -} - -interface Room { - getEncryptionTargetMembers: () => Promise<[{userId: string}]>; - roomId: string; -} - export enum E2EStatus { Warning = "warning", Verified = "verified", Normal = "normal" } -export async function shieldStatusForRoom(client: Client, room: Room): Promise { - const members = (await room.getEncryptionTargetMembers()).map(({userId}) => userId); +export async function shieldStatusForRoom(client: MatrixClient, room: Room): Promise { + const members = (await room.getEncryptionTargetMembers()).map(({ userId }) => userId); const inDMMap = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); const verified: string[] = []; @@ -52,7 +53,7 @@ export async function shieldStatusForRoom(client: Client, room: Room): Promise { + const anyDeviceNotVerified = devices.some(({ deviceId }) => { return !client.checkDeviceTrust(userId, deviceId).isVerified(); }); if (anyDeviceNotVerified) { diff --git a/src/utils/Singleflight.ts b/src/utils/Singleflight.ts index c2a564ea3e..07d82efa3e 100644 --- a/src/utils/Singleflight.ts +++ b/src/utils/Singleflight.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {EnhancedMap} from "./maps"; +import { EnhancedMap } from "./maps"; // Inspired by https://pkg.go.dev/golang.org/x/sync/singleflight diff --git a/src/utils/StorageManager.ts b/src/utils/StorageManager.ts index 883c032771..89f463de57 100644 --- a/src/utils/StorageManager.ts +++ b/src/utils/StorageManager.ts @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {LocalStorageCryptoStore} from 'matrix-js-sdk/src/crypto/store/localStorage-crypto-store'; +import { LocalStorageCryptoStore } from 'matrix-js-sdk/src/crypto/store/localStorage-crypto-store'; import Analytics from '../Analytics'; -import {IndexedDBStore} from "matrix-js-sdk/src/store/indexeddb"; -import {IndexedDBCryptoStore} from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store"; +import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb"; +import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store"; const localStorage = window.localStorage; diff --git a/src/utils/Timer.ts b/src/utils/Timer.ts index 9760631d09..2317ed934b 100644 --- a/src/utils/Timer.ts +++ b/src/utils/Timer.ts @@ -57,7 +57,7 @@ export default class Timer { const delta = this.timeout - elapsed; this.timerHandle = setTimeout(this.onTimeout, delta); } - } + }; changeTimeout(timeout: number) { if (timeout === this.timeout) { diff --git a/src/utils/UrlUtils.js b/src/utils/UrlUtils.ts similarity index 88% rename from src/utils/UrlUtils.js rename to src/utils/UrlUtils.ts index 7b207c128e..6f441ff98e 100644 --- a/src/utils/UrlUtils.js +++ b/src/utils/UrlUtils.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,7 +22,7 @@ import url from "url"; * @param {string} u The url to be abbreviated * @returns {string} The abbreviated url */ -export function abbreviateUrl(u) { +export function abbreviateUrl(u: string): string { if (!u) return ''; const parsedUrl = url.parse(u); @@ -37,7 +37,7 @@ export function abbreviateUrl(u) { return u; } -export function unabbreviateUrl(u) { +export function unabbreviateUrl(u: string): string { if (!u) return ''; let longUrl = u; diff --git a/src/utils/WellKnownUtils.ts b/src/utils/WellKnownUtils.ts index 30759cafc1..59307d642e 100644 --- a/src/utils/WellKnownUtils.ts +++ b/src/utils/WellKnownUtils.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'; const CALL_BEHAVIOUR_WK_KEY = "io.element.call_behaviour"; const E2EE_WK_KEY = "io.element.e2ee"; @@ -43,7 +43,7 @@ export function getE2EEWellKnown(): IE2EEWellKnown { return clientWellKnown[E2EE_WK_KEY]; } if (clientWellKnown && clientWellKnown[E2EE_WK_KEY_DEPRECATED]) { - return clientWellKnown[E2EE_WK_KEY_DEPRECATED] + return clientWellKnown[E2EE_WK_KEY_DEPRECATED]; } return null; } diff --git a/src/utils/Whenable.ts b/src/utils/Whenable.ts index 49f2571965..a83d7ca00c 100644 --- a/src/utils/Whenable.ts +++ b/src/utils/Whenable.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ - import { IDestroyable } from "./IDestroyable"; import { arrayFastClone } from "./arrays"; @@ -36,7 +35,7 @@ export abstract class Whenable implements IDestroyable { * @returns This. */ public when(condition: T, fn: WhenFn): Whenable { - this.listeners.push({condition, fn}); + this.listeners.push({ condition, fn }); return this; } @@ -59,7 +58,7 @@ export abstract class Whenable implements IDestroyable { * @returns This. */ public whenAnything(fn: WhenFn): Whenable { - this.listeners.push({condition: null, fn}); + this.listeners.push({ condition: null, fn }); return this; } diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index c67f3bad13..222837511d 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -16,19 +16,20 @@ limitations under the License. */ import * as url from "url"; +import { Capability, IWidget, IWidgetData, MatrixCapabilities } from "matrix-widget-api"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { MatrixClientPeg } from '../MatrixClientPeg'; import SdkConfig from "../SdkConfig"; import dis from '../dispatcher/dispatcher'; import WidgetEchoStore from '../stores/WidgetEchoStore'; import SettingsStore from "../settings/SettingsStore"; -import {IntegrationManagers} from "../integrations/IntegrationManagers"; -import {Room} from "matrix-js-sdk/src/models/room"; -import {WidgetType} from "../widgets/WidgetType"; -import {objectClone} from "./objects"; -import {_t} from "../languageHandler"; -import {Capability, IWidget, IWidgetData, MatrixCapabilities} from "matrix-widget-api"; -import {IApp} from "../stores/WidgetStore"; +import { IntegrationManagers } from "../integrations/IntegrationManagers"; +import { WidgetType } from "../widgets/WidgetType"; +import { objectClone } from "./objects"; +import { _t } from "../languageHandler"; +import { IApp } from "../stores/WidgetStore"; // How long we wait for the state event echo to come back from the server // before waitFor[Room/User]Widget rejects its promise @@ -377,9 +378,9 @@ export default class WidgetUtils { return widgets.filter(w => w.content && w.content.type === "m.integration_manager"); } - static getRoomWidgetsOfType(room: Room, type: WidgetType): IWidgetEvent[] { - const widgets = WidgetUtils.getRoomWidgets(room); - return (widgets || []).filter(w => { + static getRoomWidgetsOfType(room: Room, type: WidgetType): MatrixEvent[] { + const widgets = WidgetUtils.getRoomWidgets(room) || []; + return widgets.filter(w => { const content = w.getContent(); return content.url && type.matches(content.type); }); @@ -392,7 +393,7 @@ export default class WidgetUtils { } const widgets = client.getAccountData('m.widgets'); if (!widgets) return; - const userWidgets: IWidgetEvent[] = widgets.getContent() || {}; + const userWidgets: Record = widgets.getContent() || {}; Object.entries(userWidgets).forEach(([key, widget]) => { if (widget.content && widget.content.type === "m.integration_manager") { delete userWidgets[key]; @@ -407,7 +408,7 @@ export default class WidgetUtils { WidgetType.INTEGRATION_MANAGER, uiUrl, "Integration Manager: " + name, - {"api_url": apiUrl}, + { "api_url": apiUrl }, ); } diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index f7e693452b..3f9dcbc34b 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { percentageOf, percentageWithin } from "./numbers"; + /** * Quickly resample an array to have less/more data points. If an input which is larger * than the desired size is provided, it will be downsampled. Similarly, if the input @@ -27,7 +29,7 @@ export function arrayFastResample(input: number[], points: number): number[] { // Heavily inspired by matrix-media-repo (used with permission) // https://github.com/turt2live/matrix-media-repo/blob/abe72c87d2e29/util/util_audio/fastsample.go#L10 - let samples: number[] = []; + const samples: number[] = []; if (input.length > points) { // Danger: this loop can cause out of memory conditions if the input is too small. const everyNth = Math.round(input.length / points); @@ -36,27 +38,71 @@ export function arrayFastResample(input: number[], points: number): number[] { } } else { // Smaller inputs mean we have to spread the values over the desired length. We - // end up overshooting the target length in doing this, so we'll resample down - // before returning. This recursion is risky, but mathematically should not go - // further than 1 level deep. + // end up overshooting the target length in doing this, but we're not looking to + // be super accurate so we'll let the sanity trims do their job. const spreadFactor = Math.ceil(points / input.length); for (const val of input) { samples.push(...arraySeed(val, spreadFactor)); } - samples = arrayFastResample(samples, points); } - // Sanity fill, just in case - while (samples.length < points) { - samples.push(input[input.length - 1]); - } + // Trim to size & return + return arrayTrimFill(samples, points, arraySeed(input[input.length - 1], points)); +} - // Sanity trim, just in case - if (samples.length > points) { - samples = samples.slice(0, points); - } +/** + * Attempts a smooth resample of the given array. This is functionally similar to arrayFastResample + * though can take longer due to the smoothing of data. + * @param {number[]} input The input array to resample. + * @param {number} points The number of samples to end up with. + * @returns {number[]} The resampled array. + */ +export function arraySmoothingResample(input: number[], points: number): number[] { + if (input.length === points) return input; // short-circuit a complicated call - return samples; + let samples: number[] = []; + if (input.length > points) { + // We're downsampling. To preserve the curve we'll actually reduce our sample + // selection and average some points between them. + + // All we're doing here is repeatedly averaging the waveform down to near our + // target value. We don't average down to exactly our target as the loop might + // never end, and we can over-average the data. Instead, we'll get as far as + // we can and do a followup fast resample (the neighbouring points will be close + // to the actual waveform, so we can get away with this safely). + while (samples.length > (points * 2) || samples.length === 0) { + samples = []; + for (let i = 1; i < input.length - 1; i += 2) { + const prevPoint = input[i - 1]; + const nextPoint = input[i + 1]; + const currPoint = input[i]; + const average = (prevPoint + nextPoint + currPoint) / 3; + samples.push(average); + } + input = samples; + } + + return arrayFastResample(samples, points); + } else { + // In practice there's not much purpose in burning CPU for short arrays only to + // end up with a result that can't possibly look much different than the fast + // resample, so just skip ahead to the fast resample. + return arrayFastResample(input, points); + } +} + +/** + * Rescales the input array to have values that are inclusively within the provided + * minimum and maximum. + * @param {number[]} input The array to rescale. + * @param {number} newMin The minimum value to scale to. + * @param {number} newMax The maximum value to scale to. + * @returns {number[]} The rescaled array. + */ +export function arrayRescale(input: number[], newMin: number, newMax: number): number[] { + const min: number = Math.min(...input); + const max: number = Math.max(...input); + return input.map(v => percentageWithin(percentageOf(v, min, max), newMin, newMax)); } /** @@ -66,11 +112,9 @@ export function arrayFastResample(input: number[], points: number): number[] { * @returns {T[]} The array. */ export function arraySeed(val: T, length: number): T[] { - const a: T[] = []; - for (let i = 0; i < length; i++) { - a.push(val); - } - return a; + // Size the array up front for performance, and use `fill` to let the browser + // optimize the operation better than we can with a `for` loop, if it wants. + return new Array(length).fill(val); } /** @@ -177,6 +221,21 @@ export function arrayMerge(...a: T[][]): T[] { }, new Set())); } +/** + * Moves a single element from fromIndex to toIndex. + * @param {array} list the list from which to construct the new list. + * @param {number} fromIndex the index of the element to move. + * @param {number} toIndex the index of where to put the element. + * @returns {array} A new array with the requested value moved. + */ +export function moveElement(list: T[], fromIndex: number, toIndex: number): T[] { + const result = Array.from(list); + const [removed] = result.splice(fromIndex, 1); + result.splice(toIndex, 0, removed); + + return result; +} + /** * Helper functions to perform LINQ-like queries on arrays. */ diff --git a/src/utils/blobs.ts b/src/utils/blobs.ts new file mode 100644 index 0000000000..4e073a3936 --- /dev/null +++ b/src/utils/blobs.ts @@ -0,0 +1,78 @@ +/* +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. +*/ + +// WARNING: We have to be very careful about what mime-types we allow into blobs, +// as for performance reasons these are now rendered via URL.createObjectURL() +// rather than by converting into data: URIs. +// +// This means that the content is rendered using the origin of the script which +// called createObjectURL(), and so if the content contains any scripting then it +// will pose a XSS vulnerability when the browser renders it. This is particularly +// bad if the user right-clicks the URI and pastes it into a new window or tab, +// as the blob will then execute with access to Element's full JS environment(!) +// +// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647 +// for details. +// +// We mitigate this by only allowing mime-types into blobs which we know don't +// contain any scripting, and instantiate all others as application/octet-stream +// regardless of what mime-type the event claimed. Even if the payload itself +// is some malicious HTML, the fact we instantiate it with a media mimetype or +// application/octet-stream means the browser doesn't try to render it as such. +// +// One interesting edge case is image/svg+xml, which empirically *is* rendered +// correctly if the blob is set to the src attribute of an img tag (for thumbnails) +// *even if the mimetype is application/octet-stream*. However, empirically JS +// in the SVG isn't executed in this scenario, so we seem to be okay. +// +// Tested on Chrome 65 and Firefox 60 +// +// The list below is taken mainly from +// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats +// N.B. Matrix doesn't currently specify which mimetypes are valid in given +// events, so we pick the ones which HTML5 browsers should be able to display +// +// For the record, mime-types which must NEVER enter this list below include: +// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. + +const ALLOWED_BLOB_MIMETYPES = [ + 'image/jpeg', + 'image/gif', + 'image/png', + + 'video/mp4', + 'video/webm', + 'video/ogg', + + 'audio/mp4', + 'audio/webm', + 'audio/aac', + 'audio/mpeg', + 'audio/ogg', + 'audio/wave', + 'audio/wav', + 'audio/x-wav', + 'audio/x-pn-wav', + 'audio/flac', + 'audio/x-flac', +]; + +export function getBlobSafeMimeType(mimetype: string): string { + if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { + return 'application/octet-stream'; + } + return mimetype; +} diff --git a/src/utils/createMatrixClient.js b/src/utils/createMatrixClient.ts similarity index 76% rename from src/utils/createMatrixClient.js rename to src/utils/createMatrixClient.ts index f5e196d846..caaf75616d 100644 --- a/src/utils/createMatrixClient.js +++ b/src/utils/createMatrixClient.ts @@ -1,5 +1,5 @@ /* -Copyright 2017 Vector Creations 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,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {createClient} from "matrix-js-sdk/src/matrix"; -import {IndexedDBCryptoStore} from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store"; -import {WebStorageSessionStore} from "matrix-js-sdk/src/store/session/webstorage"; -import {IndexedDBStore} from "matrix-js-sdk/src/store/indexeddb"; +import { createClient, ICreateClientOpts } from "matrix-js-sdk/src/matrix"; +import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store"; +import { WebStorageSessionStore } from "matrix-js-sdk/src/store/session/webstorage"; +import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb"; const localStorage = window.localStorage; @@ -41,8 +41,8 @@ try { * * @returns {MatrixClient} the newly-created MatrixClient */ -export default function createMatrixClient(opts) { - const storeOpts = { +export default function createMatrixClient(opts: ICreateClientOpts) { + const storeOpts: Partial = { useAuthorizationHeader: true, }; @@ -65,9 +65,10 @@ export default function createMatrixClient(opts) { ); } - opts = Object.assign(storeOpts, opts); - - return createClient(opts); + return createClient({ + ...storeOpts, + ...opts, + }); } createMatrixClient.indexedDbWorkerScript = null; diff --git a/src/utils/humanize.js b/src/utils/humanize.ts similarity index 74% rename from src/utils/humanize.js rename to src/utils/humanize.ts index 6e6f17f8f4..978d17424b 100644 --- a/src/utils/humanize.js +++ b/src/utils/humanize.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,22 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {_t} from "../languageHandler"; +import { _t } from "../languageHandler"; + +// These are the constants we use for when to break the text +const MILLISECONDS_RECENT = 15000; +const MILLISECONDS_1_MIN = 75000; +const MINUTES_UNDER_1_HOUR = 45; +const MINUTES_1_HOUR = 75; +const HOURS_UNDER_1_DAY = 23; +const HOURS_1_DAY = 26; /** * Converts a timestamp into human-readable, translated, text. * @param {number} timeMillis The time in millis to compare against. * @returns {string} The humanized time. */ -export function humanizeTime(timeMillis) { - // These are the constants we use for when to break the text - const MILLISECONDS_RECENT = 15000; - const MILLISECONDS_1_MIN = 75000; - const MINUTES_UNDER_1_HOUR = 45; - const MINUTES_1_HOUR = 75; - const HOURS_UNDER_1_DAY = 23; - const HOURS_1_DAY = 26; - +export function humanizeTime(timeMillis: number): string { const now = (new Date()).getTime(); let msAgo = now - timeMillis; const minutes = Math.abs(Math.ceil(msAgo / 60000)); @@ -39,19 +39,19 @@ export function humanizeTime(timeMillis) { if (msAgo >= 0) { // Past if (msAgo <= MILLISECONDS_RECENT) return _t("a few seconds ago"); if (msAgo <= MILLISECONDS_1_MIN) return _t("about a minute ago"); - if (minutes <= MINUTES_UNDER_1_HOUR) return _t("%(num)s minutes ago", {num: minutes}); + if (minutes <= MINUTES_UNDER_1_HOUR) return _t("%(num)s minutes ago", { num: minutes }); if (minutes <= MINUTES_1_HOUR) return _t("about an hour ago"); - if (hours <= HOURS_UNDER_1_DAY) return _t("%(num)s hours ago", {num: hours}); + if (hours <= HOURS_UNDER_1_DAY) return _t("%(num)s hours ago", { num: hours }); if (hours <= HOURS_1_DAY) return _t("about a day ago"); - return _t("%(num)s days ago", {num: days}); + return _t("%(num)s days ago", { num: days }); } else { // Future msAgo = Math.abs(msAgo); if (msAgo <= MILLISECONDS_RECENT) return _t("a few seconds from now"); if (msAgo <= MILLISECONDS_1_MIN) return _t("about a minute from now"); - if (minutes <= MINUTES_UNDER_1_HOUR) return _t("%(num)s minutes from now", {num: minutes}); + if (minutes <= MINUTES_UNDER_1_HOUR) return _t("%(num)s minutes from now", { num: minutes }); if (minutes <= MINUTES_1_HOUR) return _t("about an hour from now"); - if (hours <= HOURS_UNDER_1_DAY) return _t("%(num)s hours from now", {num: hours}); + if (hours <= HOURS_UNDER_1_DAY) return _t("%(num)s hours from now", { num: hours }); if (hours <= HOURS_1_DAY) return _t("about a day from now"); - return _t("%(num)s days from now", {num: days}); + return _t("%(num)s days from now", { num: days }); } } diff --git a/src/utils/maps.ts b/src/utils/maps.ts index 57d84bd33f..542e99a64d 100644 --- a/src/utils/maps.ts +++ b/src/utils/maps.ts @@ -30,7 +30,7 @@ export function mapDiff(a: Map, b: Map): { changed: K[], added const possibleChanges = arrayUnion(aKeys, bKeys); const changes = possibleChanges.filter(k => a.get(k) !== b.get(k)); - return {changed: changes, added: keyDiff.added, removed: keyDiff.removed}; + return { changed: changes, added: keyDiff.added, removed: keyDiff.removed }; } /** diff --git a/src/utils/membership.ts b/src/utils/membership.ts index 80f04dfe76..f980d1dcd6 100644 --- a/src/utils/membership.ts +++ b/src/utils/membership.ts @@ -102,7 +102,7 @@ export async function leaveRoomBehaviour(roomId: string) { } catch (e) { if (e && e.data && e.data.errcode) { const message = e.data.error || _t("Unexpected server error trying to leave the room"); - results[roomId] = Object.assign(new Error(message), {errcode: e.data.errcode}); + results[roomId] = Object.assign(new Error(message), { errcode: e.data.errcode }); } else { results[roomId] = e || new Error("Failed to leave room for unknown causes"); } @@ -140,6 +140,6 @@ export async function leaveRoomBehaviour(roomId: string) { } if (RoomViewStore.getRoomId() === roomId) { - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({ action: 'view_home_page' }); } } diff --git a/src/utils/objects.ts b/src/utils/objects.ts index 2c9361beba..c2ee6ce100 100644 --- a/src/utils/objects.ts +++ b/src/utils/objects.ts @@ -114,7 +114,7 @@ export function objectDiff(a: O, b: O): Diff { const possibleChanges = arrayUnion(aKeys, bKeys); const changes = possibleChanges.filter(k => a[k] !== b[k]); - return {changed: changes, added: keyDiff.added, removed: keyDiff.removed}; + return { changed: changes, added: keyDiff.added, removed: keyDiff.removed }; } /** @@ -152,7 +152,7 @@ export function objectClone(obj: O): O { export function objectFromEntries(entries: Iterable<[K, V]>): {[k: K]: V} { const obj: { // @ts-ignore - same as return type - [k: K]: V} = {}; + [k: K]: V;} = {}; for (const e of entries) { // @ts-ignore - same as return type obj[e[0]] = e[1]; diff --git a/src/utils/permalinks/ElementPermalinkConstructor.ts b/src/utils/permalinks/ElementPermalinkConstructor.ts index cd7f2b9d2c..2068a905c3 100644 --- a/src/utils/permalinks/ElementPermalinkConstructor.ts +++ b/src/utils/permalinks/ElementPermalinkConstructor.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor"; +import PermalinkConstructor, { PermalinkParts } from "./PermalinkConstructor"; /** * Generates permalinks that self-reference the running webapp diff --git a/src/utils/permalinks/PermalinkConstructor.ts b/src/utils/permalinks/PermalinkConstructor.ts index 25855eb024..986d20d4d1 100644 --- a/src/utils/permalinks/PermalinkConstructor.ts +++ b/src/utils/permalinks/PermalinkConstructor.ts @@ -19,11 +19,11 @@ limitations under the License. * TODO: Convert this to a real TypeScript interface */ export default class PermalinkConstructor { - forEvent(roomId: string, eventId: string, serverCandidates: string[]): string { + forEvent(roomId: string, eventId: string, serverCandidates: string[] = []): string { throw new Error("Not implemented"); } - forRoom(roomIdOrAlias: string, serverCandidates: string[]): string { + forRoom(roomIdOrAlias: string, serverCandidates: string[] = []): string { throw new Error("Not implemented"); } @@ -73,12 +73,12 @@ export class PermalinkParts { return new PermalinkParts(null, null, null, groupId, null); } - static forRoom(roomIdOrAlias: string, viaServers: string[]): PermalinkParts { - return new PermalinkParts(roomIdOrAlias, null, null, null, viaServers || []); + static forRoom(roomIdOrAlias: string, viaServers: string[] = []): PermalinkParts { + return new PermalinkParts(roomIdOrAlias, null, null, null, viaServers); } - static forEvent(roomId: string, eventId: string, viaServers: string[]): PermalinkParts { - return new PermalinkParts(roomId, eventId, null, null, viaServers || []); + static forEvent(roomId: string, eventId: string, viaServers: string[] = []): PermalinkParts { + return new PermalinkParts(roomId, eventId, null, null, viaServers); } get primaryEntityId(): string { diff --git a/src/utils/permalinks/Permalinks.ts b/src/utils/permalinks/Permalinks.ts index 015ecca22e..cff7ebc08b 100644 --- a/src/utils/permalinks/Permalinks.ts +++ b/src/utils/permalinks/Permalinks.ts @@ -16,14 +16,14 @@ limitations under the License. import isIp from "is-ip"; import * as utils from "matrix-js-sdk/src/utils"; -import {Room} from "matrix-js-sdk/src/models/room"; -import {EventType} from "matrix-js-sdk/src/@types/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EventType } from "matrix-js-sdk/src/@types/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import {MatrixClientPeg} from "../../MatrixClientPeg"; -import SpecPermalinkConstructor, {baseUrl as matrixtoBaseUrl} from "./SpecPermalinkConstructor"; -import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import SpecPermalinkConstructor, { baseUrl as matrixtoBaseUrl } from "./SpecPermalinkConstructor"; +import PermalinkConstructor, { PermalinkParts } from "./PermalinkConstructor"; import ElementPermalinkConstructor from "./ElementPermalinkConstructor"; import matrixLinkify from "../../linkify-matrix"; import SdkConfig from "../../SdkConfig"; @@ -32,7 +32,6 @@ import SdkConfig from "../../SdkConfig"; // to add to permalinks. The servers are appended as ?via=example.org const MAX_SERVER_CANDIDATES = 3; - // Permalinks can have servers appended to them so that the user // receiving them can have a fighting chance at joining the room. // These servers are called "candidates" at this point because @@ -149,7 +148,7 @@ export class RoomPermalinkCreator { // Prefer to use canonical alias for permalink if possible const alias = this.room.getCanonicalAlias(); if (alias) { - return getPermalinkConstructor().forRoom(alias, this._serverCandidates); + return getPermalinkConstructor().forRoom(alias); } } return getPermalinkConstructor().forRoom(this.roomId, this._serverCandidates); @@ -172,7 +171,7 @@ export class RoomPermalinkCreator { this.updateServerCandidates(); return; } - } + }; private onMembership = (evt: MatrixEvent, member: RoomMember, oldMembership: string) => { const userId = member.userId; @@ -189,7 +188,7 @@ export class RoomPermalinkCreator { this.updateHighestPlUser(); this.updateServerCandidates(); - } + }; private updateHighestPlUser() { const plEvent = this.room.currentState.getStateEvents("m.room.power_levels", ""); @@ -302,7 +301,7 @@ export function makeRoomPermalink(roomId: string): string { } const permalinkCreator = new RoomPermalinkCreator(room); permalinkCreator.load(); - return permalinkCreator.forRoom(); + return permalinkCreator.forShareableRoom(); } export function makeGroupPermalink(groupId: string): string { @@ -346,9 +345,14 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string { return permalink; } - const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN); - if (m) { - return m[1]; + try { + const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN); + if (m) { + return m[1]; + } + } catch (e) { + // Not a valid URI + return permalink; } // A bit of a hack to convert permalinks of unknown origin to Element links diff --git a/src/utils/permalinks/SpecPermalinkConstructor.ts b/src/utils/permalinks/SpecPermalinkConstructor.ts index d8b3633bb3..a24df40f16 100644 --- a/src/utils/permalinks/SpecPermalinkConstructor.ts +++ b/src/utils/permalinks/SpecPermalinkConstructor.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor"; +import PermalinkConstructor, { PermalinkParts } from "./PermalinkConstructor"; export const host = "matrix.to"; export const baseUrl = `https://${host}`; diff --git a/src/utils/pillify.js b/src/utils/pillify.tsx similarity index 89% rename from src/utils/pillify.js rename to src/utils/pillify.tsx index 72e0e5a4db..22240fcda5 100644 --- a/src/utils/pillify.js +++ b/src/utils/pillify.tsx @@ -16,9 +16,11 @@ limitations under the License. import React from "react"; import ReactDOM from 'react-dom'; -import {MatrixClientPeg} from '../MatrixClientPeg'; +import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +import { MatrixClientPeg } from '../MatrixClientPeg'; import SettingsStore from "../settings/SettingsStore"; -import {PushProcessor} from 'matrix-js-sdk/src/pushprocessor'; import Pill from "../components/views/elements/Pill"; import { parseAppLocalLink } from "./permalinks/Permalinks"; @@ -27,15 +29,15 @@ import { parseAppLocalLink } from "./permalinks/Permalinks"; * into pills based on the context of a given room. Returns a list of * the resulting React nodes so they can be unmounted rather than leaking. * - * @param {Node[]} nodes - a list of sibling DOM nodes to traverse to try + * @param {Element[]} nodes - a list of sibling DOM nodes to traverse to try * to turn into pills. * @param {MatrixEvent} mxEvent - the matrix event which the DOM nodes are * part of representing. - * @param {Node[]} pills: an accumulator of the DOM nodes which contain + * @param {Element[]} pills: an accumulator of the DOM nodes which contain * React components which have been mounted as part of this. * The initial caller should pass in an empty array to seed the accumulator. */ -export function pillifyLinks(nodes, mxEvent, pills) { +export function pillifyLinks(nodes: ArrayLike, mxEvent: MatrixEvent, pills: Element[]) { const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId()); const shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar"); let node = nodes[0]; @@ -73,7 +75,7 @@ export function pillifyLinks(nodes, mxEvent, pills) { // to clear the pills from the last run of pillifyLinks !node.parentElement.classList.contains("mx_AtRoomPill") ) { - let currentTextNode = node; + let currentTextNode = node as Node as Text; const roomNotifTextNodes = []; // Take a textNode and break it up to make all the instances of @room their @@ -125,10 +127,10 @@ export function pillifyLinks(nodes, mxEvent, pills) { } if (node.childNodes && node.childNodes.length && !pillified) { - pillifyLinks(node.childNodes, mxEvent, pills); + pillifyLinks(node.childNodes as NodeListOf, mxEvent, pills); } - node = node.nextSibling; + node = node.nextSibling as Element; } } @@ -140,10 +142,10 @@ export function pillifyLinks(nodes, mxEvent, pills) { * emitter on BaseAvatar as per * https://github.com/vector-im/element-web/issues/12417 * - * @param {Node[]} pills - array of pill containers whose React + * @param {Element[]} pills - array of pill containers whose React * components should be unmounted. */ -export function unmountPills(pills) { +export function unmountPills(pills: Element[]) { for (const pillContainer of pills) { ReactDOM.unmountComponentAtNode(pillContainer); } diff --git a/src/utils/promise.ts b/src/utils/promise.ts index f828ddfdaf..853c172269 100644 --- a/src/utils/promise.ts +++ b/src/utils/promise.ts @@ -14,11 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Returns a promise which resolves with a given value after the given number of ms -export function sleep(ms: number, value?: T): Promise { - return new Promise((resolve => { setTimeout(resolve, ms, value); })); -} - // Returns a promise which resolves when the input promise resolves with its value // or when the timeout of ms is reached with the value of given timeoutValue export async function timeout(promise: Promise, timeoutValue: T, ms: number): Promise { @@ -32,43 +27,6 @@ export async function timeout(promise: Promise, timeoutValue: T, ms: numbe return Promise.race([promise, timeoutPromise]); } -export interface IDeferred { - resolve: (value: T) => void; - reject: (any) => void; - promise: Promise; -} - -// Returns a Deferred -export function defer(): IDeferred { - let resolve; - let reject; - - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - - return {resolve, reject, promise}; -} - -// Promise.allSettled polyfill until browser support is stable in Firefox -export function allSettled(promises: Promise[]): Promise | ISettledRejected>> { - if (Promise.allSettled) { - return Promise.allSettled(promises); - } - - // @ts-ignore - typescript isn't smart enough to see the disjoint here - return Promise.all(promises.map((promise) => { - return promise.then(value => ({ - status: "fulfilled", - value, - })).catch(reason => ({ - status: "rejected", - reason, - })); - })); -} - // Helper method to retry a Promise a given number of times or until a predicate fails export async function retry(fn: () => Promise, num: number, predicate?: (e: E) => boolean) { let lastErr: E; diff --git a/src/utils/space.tsx b/src/utils/space.tsx index 3f2b6f9bb4..38f6e348d7 100644 --- a/src/utils/space.tsx +++ b/src/utils/space.tsx @@ -15,17 +15,17 @@ limitations under the License. */ import React 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 { 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 {calculateRoomVia} from "../utils/permalinks/Permalinks"; +import { calculateRoomVia } from "../utils/permalinks/Permalinks"; import Modal from "../Modal"; import SpaceSettingsDialog from "../components/views/dialogs/SpaceSettingsDialog"; import AddExistingToSpaceDialog from "../components/views/dialogs/AddExistingToSpaceDialog"; import CreateRoomDialog from "../components/views/dialogs/CreateRoomDialog"; -import createRoom, {IOpts} from "../createRoom"; -import {_t} from "../languageHandler"; +import createRoom, { IOpts } from "../createRoom"; +import { _t } from "../languageHandler"; import SpacePublicShare from "../components/views/spaces/SpacePublicShare"; import InfoDialog from "../components/views/dialogs/InfoDialog"; import { showRoomInviteDialog } from "../RoomInvite"; @@ -83,6 +83,7 @@ export const showCreateNewRoom = async (cli: MatrixClient, space: Room) => { if (shouldCreate) { await createRoom(opts); } + return shouldCreate; }; export const showSpaceInvite = (space: Room, initialText = "") => { diff --git a/src/utils/stringOrderField.ts b/src/utils/stringOrderField.ts new file mode 100644 index 0000000000..da840792ee --- /dev/null +++ b/src/utils/stringOrderField.ts @@ -0,0 +1,148 @@ +/* +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 { alphabetPad, baseToString, stringToBase, DEFAULT_ALPHABET } from "matrix-js-sdk/src/utils"; + +import { moveElement } from "./arrays"; + +export function midPointsBetweenStrings( + a: string, + b: string, + count: number, + maxLen: number, + alphabet = DEFAULT_ALPHABET, +): string[] { + const padN = Math.min(Math.max(a.length, b.length), maxLen); + const padA = alphabetPad(a, padN, alphabet); + const padB = alphabetPad(b, padN, alphabet); + const baseA = stringToBase(padA, alphabet); + const baseB = stringToBase(padB, alphabet); + + if (baseB - baseA - BigInt(1) < count) { + if (padN < maxLen) { + // this recurses once at most due to the new limit of n+1 + return midPointsBetweenStrings( + alphabetPad(padA, padN + 1, alphabet), + alphabetPad(padB, padN + 1, alphabet), + count, + padN + 1, + alphabet, + ); + } + return []; + } + + const step = (baseB - baseA) / BigInt(count + 1); + const start = BigInt(baseA + step); + return Array(count).fill(undefined).map((_, i) => baseToString(start + (BigInt(i) * step), alphabet)); +} + +interface IEntry { + index: number; + order: string; +} + +export const reorderLexicographically = ( + orders: Array, + fromIndex: number, + toIndex: number, + maxLen = 50, +): IEntry[] => { + // sanity check inputs + if ( + fromIndex < 0 || toIndex < 0 || + fromIndex > orders.length || toIndex > orders.length || + fromIndex === toIndex + ) { + return []; + } + + // zip orders with their indices to simplify later index wrangling + const ordersWithIndices: IEntry[] = orders.map((order, index) => ({ index, order })); + // apply the fundamental order update to the zipped array + const newOrder = moveElement(ordersWithIndices, fromIndex, toIndex); + + // check if we have to fill undefined orders to complete placement + const orderToLeftUndefined = newOrder[toIndex - 1]?.order === undefined; + + let leftBoundIdx = toIndex; + let rightBoundIdx = toIndex; + + let canMoveLeft = true; + const nextBase = newOrder[toIndex + 1]?.order !== undefined + ? stringToBase(newOrder[toIndex + 1].order) + : BigInt(Number.MAX_VALUE); + + // check how far left we would have to mutate to fit in that direction + for (let i = toIndex - 1, j = 1; i >= 0; i--, j++) { + if (newOrder[i]?.order !== undefined && nextBase - stringToBase(newOrder[i].order) > j) break; + leftBoundIdx = i; + } + + // verify the left move would be sufficient + const firstOrderBase = newOrder[0].order === undefined ? undefined : stringToBase(newOrder[0].order); + const bigToIndex = BigInt(toIndex); + if (leftBoundIdx === 0 && + firstOrderBase !== undefined && + nextBase - firstOrderBase <= bigToIndex && + firstOrderBase <= bigToIndex + ) { + canMoveLeft = false; + } + + const canDisplaceRight = !orderToLeftUndefined; + let canMoveRight = canDisplaceRight; + if (canDisplaceRight) { + const prevBase = newOrder[toIndex - 1]?.order !== undefined + ? stringToBase(newOrder[toIndex - 1]?.order) + : BigInt(Number.MIN_VALUE); + + // check how far right we would have to mutate to fit in that direction + for (let i = toIndex + 1, j = 1; i < newOrder.length; i++, j++) { + if (newOrder[i]?.order === undefined || stringToBase(newOrder[i].order) - prevBase > j) break; + rightBoundIdx = i; + } + + // verify the right move would be sufficient + if (rightBoundIdx === newOrder.length - 1 && + (newOrder[rightBoundIdx] + ? stringToBase(newOrder[rightBoundIdx].order) + : BigInt(Number.MAX_VALUE)) - prevBase <= (rightBoundIdx - toIndex) + ) { + canMoveRight = false; + } + } + + // pick the cheaper direction + const leftDiff = canMoveLeft ? toIndex - leftBoundIdx : Number.MAX_SAFE_INTEGER; + const rightDiff = canMoveRight ? rightBoundIdx - toIndex : Number.MAX_SAFE_INTEGER; + if (orderToLeftUndefined || leftDiff < rightDiff) { + rightBoundIdx = toIndex; + } else { + leftBoundIdx = toIndex; + } + + const prevOrder = newOrder[leftBoundIdx - 1]?.order ?? ""; + const nextOrder = newOrder[rightBoundIdx + 1]?.order + ?? DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 1).repeat(prevOrder.length || 1); + + const changes = midPointsBetweenStrings(prevOrder, nextOrder, 1 + rightBoundIdx - leftBoundIdx, maxLen); + + return changes.map((order, i) => ({ + index: newOrder[leftBoundIdx + i].index, + order, + })); +}; diff --git a/src/utils/strings.ts b/src/utils/strings.ts index 5856682445..6d12c88940 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -73,3 +73,13 @@ export function copyNode(ref: Element): boolean { selectText(ref); return document.execCommand('copy'); } + +const collator = new Intl.Collator(); +/** + * Performant language-sensitive string comparison + * @param a the first string to compare + * @param b the second string to compare + */ +export function compare(a: string, b: string): number { + return collator.compare(a, b); +} diff --git a/src/verification.js b/src/verification.ts similarity index 54% rename from src/verification.js rename to src/verification.ts index 74e3897d3a..719c0ec5b3 100644 --- a/src/verification.js +++ b/src/verification.ts @@ -1,5 +1,5 @@ /* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,16 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { User } from "matrix-js-sdk/src/models/user"; + +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from "./dispatcher/dispatcher"; import Modal from './Modal'; -import * as sdk from './index'; -import { _t } from './languageHandler'; -import {RightPanelPhases} from "./stores/RightPanelStorePhases"; -import {findDMForUser} from './createRoom'; -import {accessSecretStorage} from './SecurityManager'; -import {verificationMethods} from 'matrix-js-sdk/src/crypto'; -import {Action} from './dispatcher/actions'; +import { RightPanelPhases } from "./stores/RightPanelStorePhases"; +import { findDMForUser } from './createRoom'; +import { accessSecretStorage } from './SecurityManager'; +import { verificationMethods } from 'matrix-js-sdk/src/crypto'; +import { Action } from './dispatcher/actions'; +import UntrustedDeviceDialog from "./components/views/dialogs/UntrustedDeviceDialog"; +import { IDevice } from "./components/views/right_panel/UserInfo"; +import ManualDeviceKeyVerificationDialog from "./components/views/dialogs/ManualDeviceKeyVerificationDialog"; async function enable4SIfNeeded() { const cli = MatrixClientPeg.get(); @@ -39,43 +42,10 @@ async function enable4SIfNeeded() { return true; } -function UntrustedDeviceDialog(props) { - const {device, user, onFinished} = props; - const BaseDialog = sdk.getComponent("dialogs.BaseDialog"); - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); - let askToVerifyText; - let newSessionText; - - if (MatrixClientPeg.get().getUserId() === user.userId) { - newSessionText = _t("You signed in to a new session without verifying it:"); - 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}); - askToVerifyText = _t("Ask this user to verify their session, or manually verify it below."); - } - - return -
    -

    {newSessionText}

    -

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

    -

    {askToVerifyText}

    -
    -
    - onFinished("legacy")}>{_t("Manually Verify by Text")} - onFinished("sas")}>{_t("Interactively verify by Emoji")} - onFinished()}>{_t("Done")} -
    -
    ; -} - -export async function verifyDevice(user, device) { +export async function verifyDevice(user: User, device: IDevice) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); return; } // if cross-signing is not explicitly disabled, check if it should be enabled first. @@ -98,11 +68,9 @@ export async function verifyDevice(user, device) { dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.EncryptionPanel, - refireParams: {member: user, verificationRequestPromise}, + refireParams: { member: user, verificationRequestPromise }, }); } else if (action === "legacy") { - const ManualDeviceKeyVerificationDialog = - sdk.getComponent("dialogs.ManualDeviceKeyVerificationDialog"); Modal.createTrackedDialog("Legacy verify session", "legacy verify session", ManualDeviceKeyVerificationDialog, { @@ -115,10 +83,10 @@ export async function verifyDevice(user, device) { }); } -export async function legacyVerifyUser(user) { +export async function legacyVerifyUser(user: User) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); return; } // if cross-signing is not explicitly disabled, check if it should be enabled first. @@ -131,14 +99,14 @@ export async function legacyVerifyUser(user) { dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.EncryptionPanel, - refireParams: {member: user, verificationRequestPromise}, + refireParams: { member: user, verificationRequestPromise }, }); } -export async function verifyUser(user) { +export async function verifyUser(user: User) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { - dis.dispatch({action: 'require_registration'}); + dis.dispatch({ action: 'require_registration' }); return; } if (!await enable4SIfNeeded()) { @@ -155,7 +123,7 @@ export async function verifyUser(user) { }); } -export function pendingVerificationRequestForUser(user) { +export function pendingVerificationRequestForUser(user: User) { const cli = MatrixClientPeg.get(); const dmRoom = findDMForUser(cli, user.userId); if (dmRoom) { diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts index 99b1f62866..1a1ee54466 100644 --- a/src/voice/Playback.ts +++ b/src/voice/Playback.ts @@ -15,11 +15,13 @@ limitations under the License. */ import EventEmitter from "events"; -import {UPDATE_EVENT} from "../stores/AsyncStore"; -import {arrayFastResample, arraySeed} from "../utils/arrays"; -import {SimpleObservable} from "matrix-widget-api"; -import {IDestroyable} from "../utils/IDestroyable"; -import {PlaybackClock} from "./PlaybackClock"; +import { UPDATE_EVENT } from "../stores/AsyncStore"; +import { arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample } from "../utils/arrays"; +import { SimpleObservable } from "matrix-widget-api"; +import { IDestroyable } from "../utils/IDestroyable"; +import { PlaybackClock } from "./PlaybackClock"; +import { createAudioContext, decodeOgg } from "./compat"; +import { clamp } from "../utils/numbers"; export enum PlaybackState { Decoding = "decoding", @@ -28,10 +30,34 @@ export enum PlaybackState { Playing = "playing", // active progress through timeline } -export const PLAYBACK_WAVEFORM_SAMPLES = 35; +export const PLAYBACK_WAVEFORM_SAMPLES = 39; +const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120] const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); +function makePlaybackWaveform(input: number[]): number[] { + // First, convert negative amplitudes to positive so we don't detect zero as "noisy". + const noiseWaveform = input.map(v => Math.abs(v)); + + // Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape. + // We also rescale the waveform to be 0-1 for the remaining function logic. + const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); + + // Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled + // waveform. Most speech happens below the 0.5 mark. + const filtered = resampled.map(v => clamp(v, 0.1, 0.5)); + + // Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something + // sensible. This is what we return to keep our contract of "values between zero and one". + return arrayRescale(filtered, 0, 1); +} + export class Playback extends EventEmitter implements IDestroyable { + /** + * Stable waveform for representing a thumbnail of the media. Values are + * guaranteed to be between zero and one, inclusive. + */ + public readonly thumbnailWaveform: number[]; + private readonly context: AudioContext; private source: AudioBufferSourceNode; private state = PlaybackState.Decoding; @@ -39,6 +65,7 @@ export class Playback extends EventEmitter implements IDestroyable { private resampledWaveform: number[]; private waveformObservable = new SimpleObservable(); private readonly clock: PlaybackClock; + private readonly fileSize: number; /** * Creates a new playback instance from a buffer. @@ -48,14 +75,27 @@ export class Playback extends EventEmitter implements IDestroyable { */ constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) { super(); - this.context = new AudioContext(); - this.resampledWaveform = arrayFastResample(seedWaveform, PLAYBACK_WAVEFORM_SAMPLES); + // Capture the file size early as reading the buffer will result in a 0-length buffer left behind + this.fileSize = this.buf.byteLength; + this.context = createAudioContext(); + this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES); + this.thumbnailWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, THUMBNAIL_WAVEFORM_SAMPLES); this.waveformObservable.update(this.resampledWaveform); this.clock = new PlaybackClock(this.context); - - // TODO: @@ TR: Calculate real waveform } + /** + * Size of the audio clip in bytes. May be zero if unknown. This is updated + * when the playback goes through phase changes. + */ + public get sizeBytes(): number { + return this.fileSize; + } + + /** + * Stable waveform for the playback. Values are guaranteed to be between + * zero and one, inclusive. + */ public get waveform(): number[] { return this.resampledWaveform; } @@ -92,8 +132,32 @@ export class Playback extends EventEmitter implements IDestroyable { } public async prepare() { - this.audioBuf = await this.context.decodeAudioData(this.buf); + // Safari compat: promise API not supported on this function + this.audioBuf = await new Promise((resolve, reject) => { + this.context.decodeAudioData(this.buf, b => resolve(b), async e => { + // This error handler is largely for Safari as well, which doesn't support Opus/Ogg + // very well. + console.error("Error decoding recording: ", e); + console.warn("Trying to re-encode to WAV instead..."); + + const wav = await decodeOgg(this.buf); + + // noinspection ES6MissingAwait - not needed when using callbacks + this.context.decodeAudioData(wav, b => resolve(b), e => { + console.error("Still failed to decode recording: ", e); + reject(e); + }); + }); + }); + + // Update the waveform to the real waveform once we have channel data to use. We don't + // exactly trust the user-provided waveform to be accurate... + const waveform = Array.from(this.audioBuf.getChannelData(0)); + this.resampledWaveform = makePlaybackWaveform(waveform); + this.waveformObservable.update(this.resampledWaveform); + this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore + this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update this.clock.durationSeconds = this.audioBuf.duration; } @@ -105,16 +169,9 @@ export class Playback extends EventEmitter implements IDestroyable { public async play() { // We can't restart a buffer source, so we need to create a new one if we hit the end if (this.state === PlaybackState.Stopped) { - if (this.source) { - this.source.disconnect(); - this.source.removeEventListener("ended", this.onPlaybackEnd); - } - - this.source = this.context.createBufferSource(); - this.source.connect(this.context.destination); - this.source.buffer = this.audioBuf; - this.source.start(); // start immediately - this.source.addEventListener("ended", this.onPlaybackEnd); + this.disconnectSource(); + this.makeNewSourceBuffer(); + this.source.start(); } // We use the context suspend/resume functions because it allows us to pause a source @@ -124,6 +181,18 @@ export class Playback extends EventEmitter implements IDestroyable { this.emit(PlaybackState.Playing); } + private disconnectSource() { + this.source?.disconnect(); + this.source?.removeEventListener("ended", this.onPlaybackEnd); + } + + private makeNewSourceBuffer() { + this.source = this.context.createBufferSource(); + this.source.buffer = this.audioBuf; + this.source.addEventListener("ended", this.onPlaybackEnd); + this.source.connect(this.context.destination); + } + public async pause() { await this.context.suspend(); this.emit(PlaybackState.Paused); @@ -138,4 +207,60 @@ export class Playback extends EventEmitter implements IDestroyable { if (this.isPlaying) await this.pause(); else await this.play(); } + + public async skipTo(timeSeconds: number) { + // Dev note: this function talks a lot about clock desyncs. There is a clock running + // independently to the audio context and buffer so that accurate human-perceptible + // time can be exposed. The PlaybackClock class has more information, but the short + // version is that we need to line up the useful time (clip position) with the context + // time, and avoid as many deviations as possible as otherwise the user could see the + // wrong time, and we stop playback at the wrong time, etc. + + timeSeconds = clamp(timeSeconds, 0, this.clock.durationSeconds); + + // Track playing state so we don't cause seeking to start playing the track. + const isPlaying = this.isPlaying; + + if (isPlaying) { + // Pause first so we can get an accurate measurement of time + await this.context.suspend(); + } + + // We can't simply tell the context/buffer to jump to a time, so we have to + // start a whole new buffer and start it from the new time offset. + const now = this.context.currentTime; + this.disconnectSource(); + this.makeNewSourceBuffer(); + + // We have to resync the clock because it can get confused about where we're + // at in the audio clip. + this.clock.syncTo(now, timeSeconds); + + // Always start the source to queue it up. We have to do this now (and pause + // quickly if we're not supposed to be playing) as otherwise the clock can desync + // when it comes time to the user hitting play. After a couple jumps, the user + // will have desynced the clock enough to be about 10-15 seconds off, while this + // keeps it as close to perfect as humans can perceive. + this.source.start(now, timeSeconds); + + // Dev note: it's critical that the code gap between `this.source.start()` and + // `this.pause()` is as small as possible: we do not want to delay *anything* + // as that could cause a clock desync, or a buggy feeling as a single note plays + // during seeking. + + if (isPlaying) { + // If we were playing before, continue the context so the clock doesn't desync. + await this.context.resume(); + } else { + // As mentioned above, we'll have to pause the clip if we weren't supposed to + // be playing it just yet. If we didn't have this, the audio clip plays but all + // the states will be wrong: clock won't advance, pause state doesn't match the + // blaring noise leaving the user's speakers, etc. + // + // Also as mentioned, if the code gap is small enough then this should be + // executed immediately after the start time, leaving no feasible time for the + // user's speakers to play any sound. + await this.pause(); + } + } } diff --git a/src/voice/PlaybackClock.ts b/src/voice/PlaybackClock.ts index 06d6381691..e3f41930de 100644 --- a/src/voice/PlaybackClock.ts +++ b/src/voice/PlaybackClock.ts @@ -14,10 +14,46 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {SimpleObservable} from "matrix-widget-api"; -import {IDestroyable} from "../utils/IDestroyable"; +import { SimpleObservable } from "matrix-widget-api"; +import { IDestroyable } from "../utils/IDestroyable"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -// Because keeping track of time is sufficiently complicated... +/** + * Tracks accurate human-perceptible time for an audio clip, as informed + * by managed playback. This clock is tightly coupled with the operation + * of the Playback class, making assumptions about how the provided + * AudioContext will be used (suspended/resumed to preserve time, etc). + * + * But why do we need a clock? The AudioContext exposes time information, + * and so does the audio buffer, but not in a way that is useful for humans + * to perceive. The audio buffer time is often lagged behind the context + * time due to internal processing delays of the audio API. Additionally, + * the context's time is tracked from when it was first initialized/started, + * not related to positioning within the clip. However, the context time + * is the most accurate time we can use to determine position within the + * clip if we're fast enough to track the pauses and stops. + * + * As a result, we track every play, pause, stop, and seek event from the + * Playback class (kinda: it calls us, which is close enough to the same + * thing). These events are then tracked on the AudioContext time scale, + * with assumptions that code execution will result in negligible desync + * of the clock, or at least no perceptible difference in time. It's + * extremely important that the calling code, and the clock's own code, + * is extremely fast between the event happening and the clock time being + * tracked - anything more than a dozen milliseconds is likely to stack up + * poorly, leading to clock desync. + * + * Clock desync can be dangerous for the stability of the playback controls: + * if the clock thinks the user is somewhere else in the clip, it could + * inform the playback of the wrong place in time, leading to dead air in + * the output or, if severe enough, a clock that won't stop running while + * the audio is paused/stopped. Other examples include the clip stopping at + * 90% time due to playback ending, the clip playing from the wrong spot + * relative to the time, and negative clock time. + * + * Note that the clip duration is fed to the clock: this is to ensure that + * we have the most accurate time possible to present. + */ export class PlaybackClock implements IDestroyable { private clipStart = 0; private stopped = true; @@ -25,12 +61,13 @@ export class PlaybackClock implements IDestroyable { private observable = new SimpleObservable(); private timerId: number; private clipDuration = 0; + private placeholderDuration = 0; public constructor(private context: AudioContext) { } public get durationSeconds(): number { - return this.clipDuration; + return this.clipDuration || this.placeholderDuration; } public set durationSeconds(val: number) { @@ -39,6 +76,12 @@ export class PlaybackClock implements IDestroyable { } public get timeSeconds(): number { + // The modulo is to ensure that we're only looking at the most recent clip + // time, as the context is long-running and multiple plays might not be + // informed to us (if the control is looping, for example). By taking the + // remainder of the division operation, we're assuming that playback is + // incomplete or stopped, thus giving an accurate position within the active + // clip segment. return (this.context.currentTime - this.clipStart) % this.clipDuration; } @@ -47,13 +90,32 @@ export class PlaybackClock implements IDestroyable { } private checkTime = () => { - const now = this.timeSeconds; + const now = this.timeSeconds; // calculated dynamically if (this.lastCheck !== now) { this.observable.update([now, this.durationSeconds]); this.lastCheck = now; } }; + /** + * Populates default information about the audio clip from the event body. + * The placeholders will be overridden once known. + * @param {MatrixEvent} event The event to use for placeholders. + */ + public populatePlaceholdersFrom(event: MatrixEvent) { + const durationSeconds = Number(event.getContent()['info']?.['duration']); + if (Number.isFinite(durationSeconds)) this.placeholderDuration = durationSeconds; + } + + /** + * Mark the time in the audio context where the clip starts/has been loaded. + * This is to ensure the clock isn't skewed into thinking it is ~0.5s into + * a clip when the duration is set. + */ + public flagLoadTime() { + this.clipStart = this.context.currentTime; + } + public flagStart() { if (this.stopped) { this.clipStart = this.context.currentTime; @@ -61,8 +123,9 @@ export class PlaybackClock implements IDestroyable { } if (!this.timerId) { - // case to number because the types are wrong - // 100ms interval to make sure the time is as accurate as possible + // cast to number because the types are wrong + // 100ms interval to make sure the time is as accurate as possible without + // being overly insane this.timerId = setInterval(this.checkTime, 100); } } @@ -71,6 +134,12 @@ export class PlaybackClock implements IDestroyable { this.stopped = true; } + public syncTo(contextTime: number, clipTime: number) { + this.clipStart = contextTime - clipTime; + this.stopped = false; // count as a mid-stream pause (if we were stopped) + this.checkTime(); + } + public destroy() { this.observable.close(); if (this.timerId) clearInterval(this.timerId); diff --git a/src/voice/RecorderWorklet.ts b/src/voice/RecorderWorklet.ts index 7343d37066..2d1bb0bcd2 100644 --- a/src/voice/RecorderWorklet.ts +++ b/src/voice/RecorderWorklet.ts @@ -14,22 +14,37 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {IAmplitudePayload, ITimingPayload, PayloadEvent, WORKLET_NAME} from "./consts"; -import {percentageOf} from "../utils/numbers"; +import { IAmplitudePayload, ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts"; +import { percentageOf } from "../utils/numbers"; // from AudioWorkletGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletGlobalScope declare const currentTime: number; // declare const currentFrame: number; // declare const sampleRate: number; +// We rate limit here to avoid overloading downstream consumers with amplitude information. +// The two major consumers are the voice message waveform thumbnail (resampled down to an +// appropriate length) and the live waveform shown to the user. Effectively, this controls +// the refresh rate of that live waveform and the number of samples the thumbnail has to +// work with. +const TARGET_AMPLITUDE_FREQUENCY = 16; // Hz + +function roundTimeToTargetFreq(seconds: number): number { + // Epsilon helps avoid floating point rounding issues (1 + 1 = 1.999999, etc) + return Math.round((seconds + Number.EPSILON) * TARGET_AMPLITUDE_FREQUENCY) / TARGET_AMPLITUDE_FREQUENCY; +} + +function nextTimeForTargetFreq(roundedSeconds: number): number { + // The extra round is just to make sure we cut off any floating point issues + return roundTimeToTargetFreq(roundedSeconds + (1 / TARGET_AMPLITUDE_FREQUENCY)); +} + class MxVoiceWorklet extends AudioWorkletProcessor { private nextAmplitudeSecond = 0; + private amplitudeIndex = 0; process(inputs, outputs, parameters) { - // We only fire amplitude updates once a second to avoid flooding the recording instance - // with useless data. Much of the data would end up discarded, so we ratelimit ourselves - // here. - const currentSecond = Math.round(currentTime); + const currentSecond = roundTimeToTargetFreq(currentTime); if (currentSecond === this.nextAmplitudeSecond) { // We're expecting exactly one mono input source, so just grab the very first frame of // samples for the analysis. @@ -47,13 +62,13 @@ class MxVoiceWorklet extends AudioWorkletProcessor { this.port.postMessage({ ev: PayloadEvent.AmplitudeMark, amplitude: amplitude, - forSecond: currentSecond, + forIndex: this.amplitudeIndex++, }); - this.nextAmplitudeSecond++; + this.nextAmplitudeSecond = nextTimeForTargetFreq(currentSecond); } // We mostly use this worklet to fire regular clock updates through to components - this.port.postMessage({ev: PayloadEvent.Timekeep, timeSeconds: currentTime}); + this.port.postMessage({ ev: PayloadEvent.Timekeep, timeSeconds: currentTime }); // We're supposed to return false when we're "done" with the audio clip, but seeing as // we are acting as a passive processor we are never truly "done". The browser will clean diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts index eb705200ca..536283689a 100644 --- a/src/voice/VoiceRecording.ts +++ b/src/voice/VoiceRecording.ts @@ -16,23 +16,29 @@ limitations under the License. import * as Recorder from 'opus-recorder'; import encoderPath from 'opus-recorder/dist/encoderWorker.min.js'; -import {MatrixClient} from "matrix-js-sdk/src/client"; -import CallMediaHandler from "../CallMediaHandler"; -import {SimpleObservable} from "matrix-widget-api"; -import {clamp} from "../utils/numbers"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import MediaDeviceHandler from "../MediaDeviceHandler"; +import { SimpleObservable } from "matrix-widget-api"; import EventEmitter from "events"; -import {IDestroyable} from "../utils/IDestroyable"; -import {Singleflight} from "../utils/Singleflight"; -import {PayloadEvent, WORKLET_NAME} from "./consts"; -import {UPDATE_EVENT} from "../stores/AsyncStore"; -import {Playback} from "./Playback"; +import { IDestroyable } from "../utils/IDestroyable"; +import { Singleflight } from "../utils/Singleflight"; +import { PayloadEvent, WORKLET_NAME } from "./consts"; +import { UPDATE_EVENT } from "../stores/AsyncStore"; +import { Playback } from "./Playback"; +import { createAudioContext } from "./compat"; +import { IEncryptedFile } from "matrix-js-sdk/src/@types/event"; +import { uploadFile } from "../ContentMessages"; +import { FixedRollingArray } from "../utils/FixedRollingArray"; +import { clamp } from "../utils/numbers"; const CHANNELS = 1; // stereo isn't important -const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. +export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. const BITRATE = 24000; // 24kbps is pretty high quality for our use case in opus. const TARGET_MAX_LENGTH = 120; // 2 minutes in seconds. Somewhat arbitrary, though longer == larger files. const TARGET_WARN_TIME_LEFT = 10; // 10 seconds, also somewhat arbitrary. +export const RECORDING_PLAYBACK_SAMPLES = 44; + export interface IRecordingUpdate { waveform: number[]; // floating points between 0 (low) and 1 (high). timeSeconds: number; // float @@ -46,19 +52,25 @@ export enum RecordingState { Uploaded = "uploaded", } +export interface IUpload { + mxc?: string; // for unencrypted uploads + encrypted?: IEncryptedFile; +} + export class VoiceRecording extends EventEmitter implements IDestroyable { private recorder: Recorder; private recorderContext: AudioContext; private recorderSource: MediaStreamAudioSourceNode; private recorderStream: MediaStream; - private recorderFFT: AnalyserNode; private recorderWorklet: AudioWorkletNode; + private recorderProcessor: ScriptProcessorNode; private buffer = new Uint8Array(0); // use this.audioBuffer to access - private mxc: string; + private lastUpload: IUpload; private recording = false; private observable: SimpleObservable; private amplitudes: number[] = []; // at each second mark, generated private playback: Playback; + private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0); public constructor(private client: MatrixClient) { super(); @@ -88,78 +100,98 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } private async makeRecorder() { - this.recorderStream = await navigator.mediaDevices.getUserMedia({ - audio: { - channelCount: CHANNELS, - noiseSuppression: true, // browsers ignore constraints they can't honour - deviceId: CallMediaHandler.getAudioInput(), - }, - }); - this.recorderContext = new AudioContext({ - // latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing) - }); - this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); - this.recorderFFT = this.recorderContext.createAnalyser(); + try { + this.recorderStream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: CHANNELS, + noiseSuppression: true, // browsers ignore constraints they can't honour + deviceId: MediaDeviceHandler.getAudioInput(), + }, + }); + this.recorderContext = createAudioContext({ + // latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing) + }); + this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); - // Bring the FFT time domain down a bit. The default is 2048, and this must be a power - // of two. We use 64 points because we happen to know down the line we need less than - // that, but 32 would be too few. Large numbers are not helpful here and do not add - // precision: they introduce higher precision outputs of the FFT (frequency data), but - // it makes the time domain less than helpful. - this.recorderFFT.fftSize = 64; - - // Set up our worklet. We use this for timing information and waveform analysis: the - // web audio API prefers this be done async to avoid holding the main thread with math. - const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript; - if (!mxRecorderWorkletPath) { - throw new Error("Unable to create recorder: no worklet script registered"); - } - await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath); - this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME); - - // Connect our inputs and outputs - this.recorderSource.connect(this.recorderFFT); - this.recorderSource.connect(this.recorderWorklet); - this.recorderWorklet.connect(this.recorderContext.destination); - - // Dev note: we can't use `addEventListener` for some reason. It just doesn't work. - this.recorderWorklet.port.onmessage = (ev) => { - switch (ev.data['ev']) { - case PayloadEvent.Timekeep: - this.processAudioUpdate(ev.data['timeSeconds']); - break; - case PayloadEvent.AmplitudeMark: - // Sanity check to make sure we're adding about one sample per second - if (ev.data['forSecond'] === this.amplitudes.length) { - this.amplitudes.push(ev.data['amplitude']); - } - break; + // Set up our worklet. We use this for timing information and waveform analysis: the + // web audio API prefers this be done async to avoid holding the main thread with math. + const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript; + if (!mxRecorderWorkletPath) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("Unable to create recorder: no worklet script registered"); } - }; - this.recorder = new Recorder({ - encoderPath, // magic from webpack - encoderSampleRate: SAMPLE_RATE, - encoderApplication: 2048, // voice (default is "audio") - streamPages: true, // this speeds up the encoding process by using CPU over time - encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder - numberOfChannels: CHANNELS, - sourceNode: this.recorderSource, - encoderBitRate: BITRATE, + // Connect our inputs and outputs + if (this.recorderContext.audioWorklet) { + await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath); + this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME); + this.recorderSource.connect(this.recorderWorklet); + this.recorderWorklet.connect(this.recorderContext.destination); - // We use low values for the following to ease CPU usage - the resulting waveform - // is indistinguishable for a voice message. Note that the underlying library will - // pick defaults which prefer the highest possible quality, CPU be damned. - encoderComplexity: 3, // 0-10, 10 is slow and high quality. - resampleQuality: 3, // 0-10, 10 is slow and high quality - }); - this.recorder.ondataavailable = (a: ArrayBuffer) => { - const buf = new Uint8Array(a); - const newBuf = new Uint8Array(this.buffer.length + buf.length); - newBuf.set(this.buffer, 0); - newBuf.set(buf, this.buffer.length); - this.buffer = newBuf; - }; + // Dev note: we can't use `addEventListener` for some reason. It just doesn't work. + this.recorderWorklet.port.onmessage = (ev) => { + switch (ev.data['ev']) { + case PayloadEvent.Timekeep: + this.processAudioUpdate(ev.data['timeSeconds']); + break; + case PayloadEvent.AmplitudeMark: + // Sanity check to make sure we're adding about one sample per second + if (ev.data['forIndex'] === this.amplitudes.length) { + this.amplitudes.push(ev.data['amplitude']); + this.liveWaveform.pushValue(ev.data['amplitude']); + } + break; + } + }; + } else { + // Safari fallback: use a processor node instead, buffered to 1024 bytes of data + // like the worklet is. + this.recorderProcessor = this.recorderContext.createScriptProcessor(1024, CHANNELS, CHANNELS); + this.recorderSource.connect(this.recorderProcessor); + this.recorderProcessor.connect(this.recorderContext.destination); + this.recorderProcessor.addEventListener("audioprocess", this.onAudioProcess); + } + + this.recorder = new Recorder({ + encoderPath, // magic from webpack + encoderSampleRate: SAMPLE_RATE, + encoderApplication: 2048, // voice (default is "audio") + streamPages: true, // this speeds up the encoding process by using CPU over time + encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder + numberOfChannels: CHANNELS, + sourceNode: this.recorderSource, + encoderBitRate: BITRATE, + + // We use low values for the following to ease CPU usage - the resulting waveform + // is indistinguishable for a voice message. Note that the underlying library will + // pick defaults which prefer the highest possible quality, CPU be damned. + encoderComplexity: 3, // 0-10, 10 is slow and high quality. + resampleQuality: 3, // 0-10, 10 is slow and high quality + }); + this.recorder.ondataavailable = (a: ArrayBuffer) => { + const buf = new Uint8Array(a); + const newBuf = new Uint8Array(this.buffer.length + buf.length); + newBuf.set(this.buffer, 0); + newBuf.set(buf, this.buffer.length); + this.buffer = newBuf; + }; + } catch (e) { + console.error("Error starting recording: ", e); + if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely + console.error(`${e.name} (${e.code}): ${e.message}`); + } + + // Clean up as best as possible + if (this.recorderStream) this.recorderStream.getTracks().forEach(t => t.stop()); + if (this.recorderSource) this.recorderSource.disconnect(); + if (this.recorder) this.recorder.close(); + if (this.recorderContext) { + // noinspection ES6MissingAwait - not important that we wait + this.recorderContext.close(); + } + + throw e; // rethrow so upstream can handle it + } } private get audioBuffer(): Uint8Array { @@ -181,37 +213,18 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return this.buffer.length > 0; } - public get mxcUri(): string { - if (!this.mxc) { - throw new Error("Recording has not been uploaded yet"); - } - return this.mxc; - } + private onAudioProcess = (ev: AudioProcessingEvent) => { + this.processAudioUpdate(ev.playbackTime); + + // We skip the functionality of the worklet regarding waveform calculations: we + // should get that information pretty quick during the playback info. + }; private processAudioUpdate = (timeSeconds: number) => { if (!this.recording) return; - // The time domain is the input to the FFT, which means we use an array of the same - // size. The time domain is also known as the audio waveform. We're ignoring the - // output of the FFT here (frequency data) because we're not interested in it. - const data = new Float32Array(this.recorderFFT.fftSize); - this.recorderFFT.getFloatTimeDomainData(data); - - // We can't just `Array.from()` the array because we're dealing with 32bit floats - // and the built-in function won't consider that when converting between numbers. - // However, the runtime will convert the float32 to a float64 during the math operations - // which is why the loop works below. Note that a `.map()` call also doesn't work - // and will instead return a Float32Array still. - const translatedData: number[] = []; - for (let i = 0; i < data.length; i++) { - // We're clamping the values so we can do that math operation mentioned above, - // and to ensure that we produce consistent data (it's possible for the array - // to exceed the specified range with some audio input devices). - translatedData.push(clamp(data[i], 0, 1)); - } - this.observable.update({ - waveform: translatedData, + waveform: this.liveWaveform.value.map(v => clamp(v, 0, 1)), timeSeconds: timeSeconds, }); @@ -234,14 +247,14 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { this.stop(); } else if (secondsLeft <= TARGET_WARN_TIME_LEFT) { Singleflight.for(this, "ending_soon").do(() => { - this.emit(RecordingState.EndingSoon, {secondsLeft}); + this.emit(RecordingState.EndingSoon, { secondsLeft }); return Singleflight.Void; }); } }; public async start(): Promise { - if (this.mxc || this.hasRecording) { + if (this.lastUpload || this.hasRecording) { throw new Error("Recording already prepared"); } if (this.recording) { @@ -266,7 +279,11 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // Disconnect the source early to start shutting down resources await this.recorder.stop(); // stop first to flush the last frame this.recorderSource.disconnect(); - this.recorderWorklet.disconnect(); + if (this.recorderWorklet) this.recorderWorklet.disconnect(); + if (this.recorderProcessor) { + this.recorderProcessor.disconnect(); + this.recorderProcessor.removeEventListener("audioprocess", this.onAudioProcess); + } // close the context after the recorder so the recorder doesn't try to // connect anything to the context (this would generate a warning) @@ -309,20 +326,19 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { this.observable.close(); } - public async upload(): Promise { + public async upload(inRoomId: string): Promise { if (!this.hasRecording) { throw new Error("No recording available to upload"); } - if (this.mxc) return this.mxc; + if (this.lastUpload) return this.lastUpload; this.emit(RecordingState.Uploading); - this.mxc = await this.client.uploadContent(new Blob([this.audioBuffer], { + const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], { type: this.contentType, - }), { - onlyContentUri: false, // to stop the warnings in the console - }).then(r => r['content_uri']); + })); + this.lastUpload = { mxc, encrypted }; this.emit(RecordingState.Uploaded); - return this.mxc; + return this.lastUpload; } } diff --git a/src/voice/compat.ts b/src/voice/compat.ts new file mode 100644 index 0000000000..bd702c798a --- /dev/null +++ b/src/voice/compat.ts @@ -0,0 +1,82 @@ +/* +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 { SAMPLE_RATE } from "./VoiceRecording"; + +// @ts-ignore - we know that this is not a module. We're looking for a path. +import decoderWasmPath from 'opus-recorder/dist/decoderWorker.min.wasm'; +import wavEncoderPath from 'opus-recorder/dist/waveWorker.min.js'; +import decoderPath from 'opus-recorder/dist/decoderWorker.min.js'; + +export function createAudioContext(opts?: AudioContextOptions): AudioContext { + if (window.AudioContext) { + return new AudioContext(opts); + } else if (window.webkitAudioContext) { + // While the linter is correct that "a constructor name should not start with + // a lowercase letter", it's also wrong to think that we have control over this. + // eslint-disable-next-line new-cap + return new window.webkitAudioContext(opts); + } else { + throw new Error("Unsupported browser"); + } +} + +export function decodeOgg(audioBuffer: ArrayBuffer): Promise { + // Condensed version of decoder example, using a promise: + // https://github.com/chris-rudmin/opus-recorder/blob/master/example/decoder.html + return new Promise((resolve) => { // no reject because the workers don't seem to have a fail path + console.log("Decoder WASM path: " + decoderWasmPath); // so we use the variable (avoid tree shake) + const typedArray = new Uint8Array(audioBuffer); + const decoderWorker = new Worker(decoderPath); + const wavWorker = new Worker(wavEncoderPath); + + decoderWorker.postMessage({ + command: 'init', + decoderSampleRate: SAMPLE_RATE, + outputBufferSampleRate: SAMPLE_RATE, + }); + + wavWorker.postMessage({ + command: 'init', + wavBitDepth: 24, // standard for 48khz (SAMPLE_RATE) + wavSampleRate: SAMPLE_RATE, + }); + + decoderWorker.onmessage = (ev) => { + if (ev.data === null) { // null == done + wavWorker.postMessage({ command: 'done' }); + return; + } + + wavWorker.postMessage({ + command: 'encode', + buffers: ev.data, + }, ev.data.map(b => b.buffer)); + }; + + wavWorker.onmessage = (ev) => { + if (ev.data.message === 'page') { + // The encoding comes through as a single page + resolve(new Blob([ev.data.page], { type: "audio/wav" }).arrayBuffer()); + } + }; + + decoderWorker.postMessage({ + command: 'decode', + pages: typedArray, + }, [typedArray.buffer]); + }); +} diff --git a/src/voice/consts.ts b/src/voice/consts.ts index c530c60f0b..39e9b30904 100644 --- a/src/voice/consts.ts +++ b/src/voice/consts.ts @@ -32,6 +32,6 @@ export interface ITimingPayload extends IPayload { export interface IAmplitudePayload extends IPayload { ev: PayloadEvent.AmplitudeMark; - forSecond: number; + forIndex: number; amplitude: number; } diff --git a/src/widgets/CapabilityText.tsx b/src/widgets/CapabilityText.tsx index 273d22dc81..304a340247 100644 --- a/src/widgets/CapabilityText.tsx +++ b/src/widgets/CapabilityText.tsx @@ -96,6 +96,16 @@ export class CapabilityText { [EventDirection.Receive]: _td("See when the avatar changes in your active room"), }, }, + [EventType.RoomMember]: { + [WidgetKind.Room]: { + [EventDirection.Send]: _td("Kick, ban, or invite people to this room, and make you leave"), + [EventDirection.Receive]: _td("See when people join, leave, or are invited to this room"), + }, + [GENERIC_WIDGET_KIND]: { + [EventDirection.Send]: _td("Kick, ban, or invite people to your active room, and make you leave"), + [EventDirection.Receive]: _td("See when people join, leave, or are invited to your active room"), + }, + }, }; private static nonStateSendRecvCaps: ISendRecvStaticCapText = { @@ -115,7 +125,7 @@ export class CapabilityText { if (eventCap.isState) { return !eventCap.keyStr ? _t("with an empty state key") - : _t("with state key %(stateKey)s", {stateKey: eventCap.keyStr}); + : _t("with state key %(stateKey)s", { stateKey: eventCap.keyStr }); } return null; // room messages are handled specially } @@ -124,8 +134,8 @@ export class CapabilityText { // First see if we have a super simple line of text to provide back if (CapabilityText.simpleCaps[capability]) { const textForKind = CapabilityText.simpleCaps[capability]; - if (textForKind[kind]) return {primary: _t(textForKind[kind])}; - if (textForKind[GENERIC_WIDGET_KIND]) return {primary: _t(textForKind[GENERIC_WIDGET_KIND])}; + if (textForKind[kind]) return { primary: _t(textForKind[kind]) }; + if (textForKind[GENERIC_WIDGET_KIND]) return { primary: _t(textForKind[GENERIC_WIDGET_KIND]) }; // ... we'll fall through to the generic capability processing at the end of this // function if we fail to locate a simple string and the capability isn't for an @@ -205,7 +215,7 @@ export class CapabilityText { // We don't have enough context to render this capability specially, so we'll present it as-is return { - primary: _t("The %(capability)s capability", {capability}, { + primary: _t("The %(capability)s capability", { capability }, { b: sub => {sub}, }), }; @@ -338,7 +348,7 @@ export class CapabilityText { }); } } - return {primary}; + return { primary }; } } } diff --git a/src/widgets/Jitsi.ts b/src/widgets/Jitsi.ts index ca8de4468a..e86fd41ed9 100644 --- a/src/widgets/Jitsi.ts +++ b/src/widgets/Jitsi.ts @@ -15,7 +15,7 @@ limitations under the License. */ import SdkConfig from "../SdkConfig"; -import {MatrixClientPeg} from "../MatrixClientPeg"; +import { MatrixClientPeg } from "../MatrixClientPeg"; const JITSI_WK_PROPERTY = "im.vector.riot.jitsi"; diff --git a/src/widgets/ManagedHybrid.ts b/src/widgets/ManagedHybrid.ts index f05df0a6ce..2adb11da62 100644 --- a/src/widgets/ManagedHybrid.ts +++ b/src/widgets/ManagedHybrid.ts @@ -36,7 +36,7 @@ function getWidgetBuildUrl(): string { return SdkConfig.get().widget_build_url; } /* eslint-disable-next-line camelcase */ - return getCallBehaviourWellKnown()?.widget_build_url + return getCallBehaviourWellKnown()?.widget_build_url; } export function isManagedHybridWidgetEnabled(): boolean { diff --git a/test/.eslintrc.js b/test/.eslintrc.js index 537f989b62..c46b24aa57 100644 --- a/test/.eslintrc.js +++ b/test/.eslintrc.js @@ -5,6 +5,6 @@ module.exports = { // mocha defines a 'this' rules: { - "babel/no-invalid-this": "off", + "@babel/no-invalid-this": "off", }, }; diff --git a/test/CallHandler-test.ts b/test/CallHandler-test.ts index 754610b223..4c3dd25fb4 100644 --- a/test/CallHandler-test.ts +++ b/test/CallHandler-test.ts @@ -16,15 +16,16 @@ limitations under the License. import './skinned-sdk'; -import CallHandler, { PlaceCallType } from '../src/CallHandler'; +import CallHandler, { PlaceCallType, CallHandlerEvent } from '../src/CallHandler'; import { stubClient, mkStubRoom } from './test-utils'; import { MatrixClientPeg } from '../src/MatrixClientPeg'; import dis from '../src/dispatcher/dispatcher'; import { CallEvent, CallState } from 'matrix-js-sdk/src/webrtc/call'; import DMRoomMap from '../src/utils/DMRoomMap'; import EventEmitter from 'events'; -import { Action } from '../src/dispatcher/actions'; import SdkConfig from '../src/SdkConfig'; +import { ActionPayload } from '../src/dispatcher/payloads'; +import { Action } from '../src/dispatcher/actions'; const REAL_ROOM_ID = '$room1:example.org'; const MAPPED_ROOM_ID = '$room2:example.org'; @@ -75,6 +76,18 @@ class FakeCall extends EventEmitter { } } +function untilDispatch(waitForAction: string): Promise { + let dispatchHandle; + return new Promise(resolve => { + dispatchHandle = dis.register(payload => { + if (payload.action === waitForAction) { + dis.unregister(dispatchHandle); + resolve(payload); + } + }); + }); +} + describe('CallHandler', () => { let dmRoomMap; let callHandler; @@ -94,6 +107,21 @@ describe('CallHandler', () => { callHandler = new CallHandler(); callHandler.start(); + const realRoom = mkStubDM(REAL_ROOM_ID, '@user1:example.org'); + const mappedRoom = mkStubDM(MAPPED_ROOM_ID, '@user2:example.org'); + const mappedRoom2 = mkStubDM(MAPPED_ROOM_ID_2, '@user3:example.org'); + + MatrixClientPeg.get().getRoom = roomId => { + switch (roomId) { + case REAL_ROOM_ID: + return realRoom; + case MAPPED_ROOM_ID: + return mappedRoom; + case MAPPED_ROOM_ID_2: + return mappedRoom2; + } + }; + dmRoomMap = { getUserIdForRoomId: roomId => { if (roomId === REAL_ROOM_ID) { @@ -134,49 +162,43 @@ describe('CallHandler', () => { SdkConfig.unset(); }); + it('should look up the correct user and open the room when a phone number is dialled', async () => { + MatrixClientPeg.get().getThirdpartyUser = jest.fn().mockResolvedValue([{ + userid: '@user2:example.org', + protocol: "im.vector.protocol.sip_native", + fields: { + is_native: true, + lookup_success: true, + }, + }]); + + dis.dispatch({ + action: Action.DialNumber, + number: '01818118181', + }, true); + + const viewRoomPayload = await untilDispatch('view_room'); + expect(viewRoomPayload.room_id).toEqual(MAPPED_ROOM_ID); + }); + it('should move calls between rooms when remote asserted identity changes', async () => { - const realRoom = mkStubDM(REAL_ROOM_ID, '@user1:example.org'); - const mappedRoom = mkStubDM(MAPPED_ROOM_ID, '@user2:example.org'); - const mappedRoom2 = mkStubDM(MAPPED_ROOM_ID_2, '@user3:example.org'); - - MatrixClientPeg.get().getRoom = roomId => { - switch (roomId) { - case REAL_ROOM_ID: - return realRoom; - case MAPPED_ROOM_ID: - return mappedRoom; - case MAPPED_ROOM_ID_2: - return mappedRoom2; - } - }; - dis.dispatch({ action: 'place_call', type: PlaceCallType.Voice, room_id: REAL_ROOM_ID, }, true); - let dispatchHandle; // wait for the call to be set up - await new Promise(resolve => { - dispatchHandle = dis.register(payload => { - if (payload.action === 'call_state') { - resolve(); - } - }); - }); - dis.unregister(dispatchHandle); + await untilDispatch('call_state'); // should start off in the actual room ID it's in at the protocol level expect(callHandler.getCallForRoom(REAL_ROOM_ID)).toBe(fakeCall); let callRoomChangeEventCount = 0; const roomChangePromise = new Promise(resolve => { - dispatchHandle = dis.register(payload => { - if (payload.action === Action.CallChangeRoom) { - ++callRoomChangeEventCount; - resolve(); - } + callHandler.addListener(CallHandlerEvent.CallChangeRoom, () => { + ++callRoomChangeEventCount; + resolve(); }); }); @@ -201,7 +223,7 @@ describe('CallHandler', () => { fakeCall.emit(CallEvent.AssertedIdentityChanged); await roomChangePromise; - dis.unregister(dispatchHandle); + callHandler.removeAllListeners(); // If everything's gone well, we should have seen only one room change // event and the call should now be in user 3's room. diff --git a/test/DecryptionFailureTracker-test.js b/test/DecryptionFailureTracker-test.js index 7a6a42ef55..bc751ba44e 100644 --- a/test/DecryptionFailureTracker-test.js +++ b/test/DecryptionFailureTracker-test.js @@ -30,9 +30,7 @@ function createFailedDecryptionEvent() { const event = new MatrixEvent({ event_id: "event-id-" + Math.random().toString(16).slice(2), }); - event._setClearData( - event._badEncryptedMessage(":("), - ); + event.setClearData(event.badEncryptedMessage(":(")); return event; } @@ -67,7 +65,7 @@ describe('DecryptionFailureTracker', function() { tracker.eventDecrypted(decryptedEvent, err); // Indicate successful decryption: clear data can be anything where the msgtype is not m.bad.encrypted - decryptedEvent._setClearData({}); + decryptedEvent.setClearData({}); tracker.eventDecrypted(decryptedEvent, null); // Pretend "now" is Infinity diff --git a/test/KeyBindingsManager-test.ts b/test/KeyBindingsManager-test.ts index 41614b61fa..eab1bea2b0 100644 --- a/test/KeyBindingsManager-test.ts +++ b/test/KeyBindingsManager-test.ts @@ -15,20 +15,19 @@ limitations under the License. */ import { isKeyComboMatch, KeyCombo } from '../src/KeyBindingsManager'; -const assert = require('assert'); function mockKeyEvent(key: string, modifiers?: { - ctrlKey?: boolean, - altKey?: boolean, - shiftKey?: boolean, - metaKey?: boolean + ctrlKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + metaKey?: boolean; }): KeyboardEvent { return { key, ctrlKey: modifiers?.ctrlKey ?? false, altKey: modifiers?.altKey ?? false, shiftKey: modifiers?.shiftKey ?? false, - metaKey: modifiers?.metaKey ?? false + metaKey: modifiers?.metaKey ?? false, } as KeyboardEvent; } @@ -37,9 +36,8 @@ describe('KeyBindingsManager', () => { const combo1: KeyCombo = { key: 'k', }; - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k'), combo1, false), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('n'), combo1, false), false); - + expect(isKeyComboMatch(mockKeyEvent('k'), combo1, false)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('n'), combo1, false)).toBe(false); }); it('should match key + modifier key combo', () => { @@ -47,38 +45,38 @@ describe('KeyBindingsManager', () => { key: 'k', ctrlKey: true, }; - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true }), combo, false), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true }), combo, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k'), combo, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true }), combo, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true, metaKey: true }), combo, false), false); + expect(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true }), combo, false)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true }), combo, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k'), combo, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true }), combo, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true, metaKey: true }), combo, false)).toBe(false); const combo2: KeyCombo = { key: 'k', metaKey: true, }; - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { metaKey: true }), combo2, false), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { metaKey: true }), combo2, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k'), combo2, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { altKey: true, metaKey: true }), combo2, false), false); + expect(isKeyComboMatch(mockKeyEvent('k', { metaKey: true }), combo2, false)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('n', { metaKey: true }), combo2, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k'), combo2, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k', { altKey: true, metaKey: true }), combo2, false)).toBe(false); const combo3: KeyCombo = { key: 'k', altKey: true, }; - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { altKey: true }), combo3, false), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { altKey: true }), combo3, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k'), combo3, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true }), combo3, false), false); + expect(isKeyComboMatch(mockKeyEvent('k', { altKey: true }), combo3, false)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('n', { altKey: true }), combo3, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k'), combo3, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true }), combo3, false)).toBe(false); const combo4: KeyCombo = { key: 'k', shiftKey: true, }; - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true }), combo4, false), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { shiftKey: true }), combo4, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k'), combo4, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true, ctrlKey: true }), combo4, false), false); + expect(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true }), combo4, false)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('n', { shiftKey: true }), combo4, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k'), combo4, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k', { shiftKey: true, ctrlKey: true }), combo4, false)).toBe(false); }); it('should match key + multiple modifiers key combo', () => { @@ -87,11 +85,11 @@ describe('KeyBindingsManager', () => { ctrlKey: true, altKey: true, }; - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, altKey: true }), combo, false), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true, altKey: true }), combo, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true }), combo, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true, shiftKey: true }), combo, - false), false); + expect(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, altKey: true }), combo, false)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true, altKey: true }), combo, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true }), combo, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true, shiftKey: true }), combo, + false)).toBe(false); const combo2: KeyCombo = { key: 'k', @@ -99,13 +97,13 @@ describe('KeyBindingsManager', () => { shiftKey: true, altKey: true, }; - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, shiftKey: true, altKey: true }), combo2, - false), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true, shiftKey: true, altKey: true }), combo2, - false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true }), combo2, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', - { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }), combo2, false), false); + expect(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, shiftKey: true, altKey: true }), combo2, + false)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true, shiftKey: true, altKey: true }), combo2, + false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, metaKey: true }), combo2, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k', + { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }), combo2, false)).toBe(false); const combo3: KeyCombo = { key: 'k', @@ -114,12 +112,12 @@ describe('KeyBindingsManager', () => { altKey: true, metaKey: true, }; - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', - { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }), combo3, false), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', - { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }), combo3, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', - { ctrlKey: true, shiftKey: true, altKey: true }), combo3, false), false); + expect(isKeyComboMatch(mockKeyEvent('k', + { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }), combo3, false)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('n', + { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }), combo3, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('k', + { ctrlKey: true, shiftKey: true, altKey: true }), combo3, false)).toBe(false); }); it('should match ctrlOrMeta key combo', () => { @@ -128,13 +126,13 @@ describe('KeyBindingsManager', () => { ctrlOrCmd: true, }; // PC: - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true }), combo, false), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { metaKey: true }), combo, false), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true }), combo, false), false); + expect(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true }), combo, false)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('k', { metaKey: true }), combo, false)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true }), combo, false)).toBe(false); // MAC: - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { metaKey: true }), combo, true), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true }), combo, true), false); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true }), combo, true), false); + expect(isKeyComboMatch(mockKeyEvent('k', { metaKey: true }), combo, true)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true }), combo, true)).toBe(false); + expect(isKeyComboMatch(mockKeyEvent('n', { ctrlKey: true }), combo, true)).toBe(false); }); it('should match advanced ctrlOrMeta key combo', () => { @@ -144,10 +142,10 @@ describe('KeyBindingsManager', () => { altKey: true, }; // PC: - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, altKey: true }), combo, false), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { metaKey: true, altKey: true }), combo, false), false); + expect(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, altKey: true }), combo, false)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('k', { metaKey: true, altKey: true }), combo, false)).toBe(false); // MAC: - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { metaKey: true, altKey: true }), combo, true), true); - assert.strictEqual(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, altKey: true }), combo, true), false); + expect(isKeyComboMatch(mockKeyEvent('k', { metaKey: true, altKey: true }), combo, true)).toBe(true); + expect(isKeyComboMatch(mockKeyEvent('k', { ctrlKey: true, altKey: true }), combo, true)).toBe(false); }); }); diff --git a/test/ScalarAuthClient-test.js b/test/ScalarAuthClient-test.js index 3435f70932..a597c2cb2e 100644 --- a/test/ScalarAuthClient-test.js +++ b/test/ScalarAuthClient-test.js @@ -15,7 +15,7 @@ limitations under the License. */ import ScalarAuthClient from '../src/ScalarAuthClient'; -import {MatrixClientPeg} from '../src/MatrixClientPeg'; +import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { stubClient } from './test-utils'; describe('ScalarAuthClient', function() { diff --git a/test/Terms-test.js b/test/Terms-test.js index 3835af3ede..58ba688395 100644 --- a/test/Terms-test.js +++ b/test/Terms-test.js @@ -18,7 +18,7 @@ import * as Matrix from 'matrix-js-sdk'; import { startTermsFlow, Service } from '../src/Terms'; import { stubClient } from './test-utils'; -import {MatrixClientPeg} from '../src/MatrixClientPeg'; +import { MatrixClientPeg } from '../src/MatrixClientPeg'; const POLICY_ONE = { version: "six", diff --git a/test/accessibility/RovingTabIndex-test.js b/test/accessibility/RovingTabIndex-test.js index 5aa93f99f3..bead5c3158 100644 --- a/test/accessibility/RovingTabIndex-test.js +++ b/test/accessibility/RovingTabIndex-test.js @@ -16,7 +16,7 @@ limitations under the License. import '../skinned-sdk'; // Must be first for skinning to work import React from "react"; -import Adapter from "enzyme-adapter-react-16"; +import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { configure, mount } from "enzyme"; import { @@ -102,7 +102,7 @@ describe("RovingTabIndex", () => { { button1 } { button2 } - {({onFocus, isActive, ref}) => + {({ onFocus, isActive, ref }) => } @@ -119,4 +119,3 @@ describe("RovingTabIndex", () => { }); }); - diff --git a/test/autocomplete/QueryMatcher-test.js b/test/autocomplete/QueryMatcher-test.js index 3d383f08d7..72f894c678 100644 --- a/test/autocomplete/QueryMatcher-test.js +++ b/test/autocomplete/QueryMatcher-test.js @@ -31,7 +31,7 @@ const NONWORDOBJECTS = [ describe('QueryMatcher', function() { it('Returns results by key', function() { - const qm = new QueryMatcher(OBJECTS, {keys: ["name"]}); + const qm = new QueryMatcher(OBJECTS, { keys: ["name"] }); const results = qm.match('Geri'); expect(results.length).toBe(1); @@ -39,7 +39,7 @@ describe('QueryMatcher', function() { }); it('Returns results by prefix', function() { - const qm = new QueryMatcher(OBJECTS, {keys: ["name"]}); + const qm = new QueryMatcher(OBJECTS, { keys: ["name"] }); const results = qm.match('Ge'); expect(results.length).toBe(1); @@ -47,7 +47,7 @@ describe('QueryMatcher', function() { }); it('Matches case-insensitive', function() { - const qm = new QueryMatcher(OBJECTS, {keys: ["name"]}); + const qm = new QueryMatcher(OBJECTS, { keys: ["name"] }); const results = qm.match('geri'); expect(results.length).toBe(1); @@ -55,7 +55,7 @@ describe('QueryMatcher', function() { }); it('Matches ignoring accents', function() { - const qm = new QueryMatcher([{name: "Gëri", foo: 46}], {keys: ["name"]}); + const qm = new QueryMatcher([{ name: "Gëri", foo: 46 }], { keys: ["name"] }); const results = qm.match('geri'); expect(results.length).toBe(1); @@ -63,14 +63,13 @@ describe('QueryMatcher', function() { }); it('Returns multiple results in order of search string appearance', function() { - const qm = new QueryMatcher(OBJECTS, {keys: ["name", "nick"]}); + const qm = new QueryMatcher(OBJECTS, { keys: ["name", "nick"] }); const results = qm.match('or'); expect(results.length).toBe(2); expect(results[0].name).toBe('Mel C'); expect(results[1].name).toBe('Victoria'); - qm.setObjects(OBJECTS.slice().reverse()); const reverseResults = qm.match('or'); @@ -87,7 +86,7 @@ describe('QueryMatcher', function() { { name: "b", first: "miss", second: "hit", third: "miss" }, { name: "c", first: "miss", second: "miss", third: "hit" }, ]; - const qm = new QueryMatcher(objects, {keys: ["second", "first", "third"]}); + const qm = new QueryMatcher(objects, { keys: ["second", "first", "third"] }); const results = qm.match('hit'); expect(results.length).toBe(3); @@ -95,7 +94,6 @@ describe('QueryMatcher', function() { expect(results[1].name).toBe('a'); expect(results[2].name).toBe('c'); - qm.setObjects(objects.slice().reverse()); const reverseResults = qm.match('hit'); @@ -109,14 +107,13 @@ describe('QueryMatcher', function() { }); it('Returns results with search string in same place and key in same place in insertion order', function() { - const qm = new QueryMatcher(OBJECTS, {keys: ["name"]}); + const qm = new QueryMatcher(OBJECTS, { keys: ["name"] }); const results = qm.match('Mel'); expect(results.length).toBe(2); expect(results[0].name).toBe('Mel B'); expect(results[1].name).toBe('Mel C'); - qm.setObjects(OBJECTS.slice().reverse()); const reverseResults = qm.match('Mel'); @@ -129,9 +126,9 @@ describe('QueryMatcher', function() { it('Returns numeric results in correct order (input pos)', function() { // regression test for depending on object iteration order const qm = new QueryMatcher([ - {name: "123456badger"}, - {name: "123456"}, - ], {keys: ["name"]}); + { name: "123456badger" }, + { name: "123456" }, + ], { keys: ["name"] }); const results = qm.match('123456'); expect(results.length).toBe(2); @@ -141,9 +138,9 @@ describe('QueryMatcher', function() { it('Returns numeric results in correct order (query pos)', function() { const qm = new QueryMatcher([ - {name: "999999123456"}, - {name: "123456badger"}, - ], {keys: ["name"]}); + { name: "999999123456" }, + { name: "123456badger" }, + ], { keys: ["name"] }); const results = qm.match('123456'); expect(results.length).toBe(2); @@ -177,7 +174,7 @@ describe('QueryMatcher', function() { const qm = new QueryMatcher(NONWORDOBJECTS, { keys: ["name"], shouldMatchWordsOnly: false, - }); + }); const results = qm.match('bob'); expect(results.length).toBe(1); diff --git a/test/components/structures/GroupView-test.js b/test/components/structures/GroupView-test.js index ee5d1b6912..2ff1257d32 100644 --- a/test/components/structures/GroupView-test.js +++ b/test/components/structures/GroupView-test.js @@ -19,7 +19,7 @@ import ReactDOM from 'react-dom'; import ReactTestUtils from 'react-dom/test-utils'; import MockHttpBackend from 'matrix-mock-request'; -import {MatrixClientPeg} from '../../../src/MatrixClientPeg'; +import { MatrixClientPeg } from '../../../src/MatrixClientPeg'; import sdk from '../../skinned-sdk'; import Matrix from 'matrix-js-sdk'; diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index 7347ff2658..f415b85105 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket 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. @@ -26,13 +26,13 @@ import { EventEmitter } from "events"; import sdk from '../../skinned-sdk'; const MessagePanel = sdk.getComponent('structures.MessagePanel'); -import {MatrixClientPeg} from '../../../src/MatrixClientPeg'; +import { MatrixClientPeg } from '../../../src/MatrixClientPeg'; import Matrix from 'matrix-js-sdk'; -const test_utils = require('../../test-utils'); -const mockclock = require('../../mock-clock'); +const TestUtilsMatrix = require('../../test-utils'); +import FakeTimers from '@sinonjs/fake-timers'; -import Adapter from "enzyme-adapter-react-16"; +import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { configure, mount } from "enzyme"; import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; @@ -42,7 +42,7 @@ import DMRoomMap from "../../../src/utils/DMRoomMap"; configure({ adapter: new Adapter() }); let client; -const room = new Matrix.Room(); +const room = new Matrix.Room("!roomId:server_name"); // wrap MessagePanel with a component which provides the MatrixClient in the context. class WrappedMessagePanel extends React.Component { @@ -51,8 +51,20 @@ class WrappedMessagePanel extends React.Component { }; render() { + const roomContext = { + room, + roomId: room.roomId, + canReact: true, + canReply: true, + showReadReceipts: true, + showRedactions: false, + showJoinLeaves: false, + showAvatarChanges: false, + showDisplaynameChanges: true, + }; + return - + ; @@ -60,14 +72,14 @@ class WrappedMessagePanel extends React.Component { } describe('MessagePanel', function() { - const clock = mockclock.clock(); + let clock = null; const realSetTimeout = window.setTimeout; const events = mkEvents(); beforeEach(function() { - test_utils.stubClient(); + TestUtilsMatrix.stubClient(); client = MatrixClientPeg.get(); - client.credentials = {userId: '@me:here'}; + client.credentials = { userId: '@me:here' }; // HACK: We assume all settings want to be disabled SettingsStore.getValue = jest.fn((arg) => { @@ -78,22 +90,38 @@ describe('MessagePanel', function() { }); afterEach(function() { - clock.uninstall(); + if (clock) { + clock.uninstall(); + clock = null; + } }); function mkEvents() { const events = []; const ts0 = Date.now(); for (let i = 0; i < 10; i++) { - events.push(test_utils.mkMessage( + events.push(TestUtilsMatrix.mkMessage( { event: true, room: "!room:id", user: "@user:id", - ts: ts0 + i*1000, + ts: ts0 + i * 1000, })); } return events; } + // Just to avoid breaking Dateseparator tests that might run at 00hrs + function mkOneDayEvents() { + const events = []; + const ts0 = Date.parse('09 May 2004 00:12:00 GMT'); + for (let i = 0; i < 10; i++) { + events.push(TestUtilsMatrix.mkMessage( + { + event: true, room: "!room:id", user: "@user:id", + ts: ts0 + i * 1000, + })); + } + return events; + } // make a collection of events with some member events that should be collapsed // with a MemberEventListSummary @@ -102,13 +130,13 @@ describe('MessagePanel', function() { const ts0 = Date.now(); let i = 0; - events.push(test_utils.mkMessage({ + events.push(TestUtilsMatrix.mkMessage({ event: true, room: "!room:id", user: "@user:id", - ts: ts0 + ++i*1000, + ts: ts0 + ++i * 1000, })); for (i = 0; i < 10; i++) { - events.push(test_utils.mkMembership({ + events.push(TestUtilsMatrix.mkMembership({ event: true, room: "!room:id", user: "@user:id", target: { userId: "@user:id", @@ -125,7 +153,7 @@ describe('MessagePanel', function() { })); } - events.push(test_utils.mkMessage({ + events.push(TestUtilsMatrix.mkMessage({ event: true, room: "!room:id", user: "@user:id", ts: ts0 + ++i*1000, })); @@ -141,7 +169,7 @@ describe('MessagePanel', function() { let i = 0; for (i = 0; i < 10; i++) { - events.push(test_utils.mkMembership({ + events.push(TestUtilsMatrix.mkMembership({ event: true, room: "!room:id", user: "@user:id", target: { userId: "@user:id", @@ -151,7 +179,7 @@ describe('MessagePanel', function() { }, getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, - ts: ts0 + i*1000, + ts: ts0 + i * 1000, mship: 'join', prevMship: 'join', name: 'A user', @@ -163,8 +191,8 @@ describe('MessagePanel', function() { // A list of room creation, encryption, and invite events. function mkCreationEvents() { - const mkEvent = test_utils.mkEvent; - const mkMembership = test_utils.mkMembership; + const mkEvent = TestUtilsMatrix.mkEvent; + const mkMembership = TestUtilsMatrix.mkMembership; const roomId = "!someroom"; const alice = "@alice:example.org"; const ts0 = Date.now(); @@ -250,14 +278,13 @@ describe('MessagePanel', function() { }), ]; } - function isReadMarkerVisible(rmContainer) { return rmContainer && rmContainer.children.length > 0; } it('should show the events', function() { const res = TestUtils.renderIntoDocument( - , + , ); // just check we have the right number of tiles for now @@ -285,8 +312,8 @@ describe('MessagePanel', function() { it('should insert the read-marker in the right place', function() { const res = TestUtils.renderIntoDocument( - , + , ); const tiles = TestUtils.scryRenderedComponentsWithType( @@ -296,15 +323,15 @@ describe('MessagePanel', function() { const rm = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_RoomView_myReadMarker_container'); // it should follow the
  • which wraps the event tile for event 4 - const eventContainer = ReactDOM.findDOMNode(tiles[4]).parentNode; + const eventContainer = ReactDOM.findDOMNode(tiles[4]); expect(rm.previousSibling).toEqual(eventContainer); }); it('should show the read-marker that fall in summarised events after the summary', function() { const melsEvents = mkMelsEvents(); const res = TestUtils.renderIntoDocument( - , + , ); const summary = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_EventListSummary'); @@ -321,8 +348,8 @@ describe('MessagePanel', function() { it('should hide the read-marker at the end of summarised events', function() { const melsEvents = mkMelsEventsOnly(); const res = TestUtils.renderIntoDocument( - , + , ); const summary = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_EventListSummary'); @@ -338,21 +365,20 @@ describe('MessagePanel', function() { it('shows a ghost read-marker when the read-marker moves', function(done) { // fake the clock so that we can test the velocity animation. - clock.install(); - clock.mockDate(); + clock = FakeTimers.install(); const parentDiv = document.createElement('div'); // first render with the RM in one place let mp = ReactDOM.render( - , parentDiv); + , parentDiv); const tiles = TestUtils.scryRenderedComponentsWithType( mp, sdk.getComponent('rooms.EventTile')); const tileContainers = tiles.map(function(t) { - return ReactDOM.findDOMNode(t).parentNode; + return ReactDOM.findDOMNode(t); }); // find the
  • which wraps the read marker @@ -361,9 +387,9 @@ describe('MessagePanel', function() { // now move the RM mp = ReactDOM.render( - , parentDiv); + , parentDiv); // now there should be two RM containers const found = TestUtils.scryRenderedDOMComponentsWithClass(mp, 'mx_RoomView_myReadMarker_container'); @@ -437,4 +463,17 @@ describe('MessagePanel', function() { // read marker should be hidden given props and at the last event expect(isReadMarkerVisible(rm)).toBeFalsy(); }); + + it('should render Date separators for the events', function() { + const events = mkOneDayEvents(); + const res = mount( + , + ); + const Dates = res.find(sdk.getComponent('messages.DateSeparator')); + + expect(Dates.length).toEqual(1); + }); }); diff --git a/test/components/structures/auth/Login-test.js b/test/components/structures/auth/Login-test.js index 9b73137936..9def0c8d95 100644 --- a/test/components/structures/auth/Login-test.js +++ b/test/components/structures/auth/Login-test.js @@ -19,7 +19,7 @@ import ReactDOM from 'react-dom'; import ReactTestUtils from 'react-dom/test-utils'; import sdk from '../../../skinned-sdk'; import SdkConfig from '../../../../src/SdkConfig'; -import {mkServerConfig} from "../../../test-utils"; +import { mkServerConfig } from "../../../test-utils"; const Login = sdk.getComponent( 'structures.auth.Login', @@ -133,7 +133,7 @@ describe('Login', function() { root.setState({ flows: [{ "type": "m.login.sso", - "org.matrix.msc2858.identity_providers": [{ + "identity_providers": [{ id: "a", name: "Provider 1", }, { diff --git a/test/components/structures/auth/Registration-test.js b/test/components/structures/auth/Registration-test.js index 3e8e887329..e09304962f 100644 --- a/test/components/structures/auth/Registration-test.js +++ b/test/components/structures/auth/Registration-test.js @@ -19,7 +19,7 @@ import ReactDOM from 'react-dom'; import ReactTestUtils from 'react-dom/test-utils'; import sdk from '../../../skinned-sdk'; import SdkConfig from '../../../../src/SdkConfig'; -import {mkServerConfig} from "../../../test-utils"; +import { mkServerConfig } from "../../../test-utils"; const Registration = sdk.getComponent( 'structures.auth.Registration', diff --git a/test/components/stub-component.js b/test/components/stub-component.js index e242c06707..f98959a573 100644 --- a/test/components/stub-component.js +++ b/test/components/stub-component.js @@ -3,7 +3,7 @@ import React from 'react'; -export default function({displayName = "StubComponent", render} = {}) { +export default function({ displayName = "StubComponent", render } = {}) { if (!render) { render = function() { return
    { displayName }
    ; diff --git a/test/components/views/dialogs/AccessSecretStorageDialog-test.js b/test/components/views/dialogs/AccessSecretStorageDialog-test.js index 13b39ab0d0..8c16e7f104 100644 --- a/test/components/views/dialogs/AccessSecretStorageDialog-test.js +++ b/test/components/views/dialogs/AccessSecretStorageDialog-test.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import TestRenderer from 'react-test-renderer'; import sdk from '../../../skinned-sdk'; -import {MatrixClientPeg} from '../../../../src/MatrixClientPeg'; +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; import { stubClient } from '../../../test-utils'; const AccessSecretStorageDialog = sdk.getComponent("dialogs.security.AccessSecretStorageDialog"); @@ -26,9 +26,9 @@ describe("AccessSecretStorageDialog", function() { it("Closes the dialog if _onRecoveryKeyNext is called with a valid key", (done) => { const testInstance = TestRenderer.create( p && p.recoveryKey && p.recoveryKey == "a"} - onFinished={(v) => { - if (v) { done(); } + checkPrivateKey={(p) => p && p.recoveryKey && p.recoveryKey == "a"} + onFinished={(v) => { + if (v) { done(); } }} />, ); @@ -43,7 +43,7 @@ describe("AccessSecretStorageDialog", function() { it("Considers a valid key to be valid", async function() { const testInstance = TestRenderer.create( true} + checkPrivateKey={() => true} />, ); const v = "asdf"; @@ -61,7 +61,7 @@ describe("AccessSecretStorageDialog", function() { it("Notifies the user if they input an invalid Security Key", async function(done) { const testInstance = TestRenderer.create( false} + checkPrivateKey={async () => false} />, ); const e = { target: { value: "a" } }; @@ -87,12 +87,14 @@ describe("AccessSecretStorageDialog", function() { it("Notifies the user if they input an invalid passphrase", async function(done) { const testInstance = TestRenderer.create( false} - onFinished={() => {}} - keyInfo={ { passphrase: { - salt: 'nonempty', - iterations: 2, - } } } + checkPrivateKey={() => false} + onFinished={() => {}} + keyInfo={{ + passphrase: { + salt: 'nonempty', + iterations: 2, + }, + }} />, ); const e = { target: { value: "a" } }; diff --git a/test/components/views/dialogs/ForwardDialog-test.js b/test/components/views/dialogs/ForwardDialog-test.js new file mode 100644 index 0000000000..c5ce330fec --- /dev/null +++ b/test/components/views/dialogs/ForwardDialog-test.js @@ -0,0 +1,163 @@ +/* +Copyright 2021 Robin Townsend + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import "../../../skinned-sdk"; + +import React from "react"; +import { configure, mount } from "enzyme"; +import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; +import { act } from "react-dom/test-utils"; + +import * as TestUtils from "../../../test-utils"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; +import ForwardDialog from "../../../../src/components/views/dialogs/ForwardDialog"; + +configure({ adapter: new Adapter() }); + +describe("ForwardDialog", () => { + const sourceRoom = "!111111111111111111:example.org"; + const defaultMessage = TestUtils.mkMessage({ + room: sourceRoom, + user: "@alice:example.org", + msg: "Hello world!", + event: true, + }); + const defaultRooms = ["a", "A", "b"].map(name => TestUtils.mkStubRoom(name, name)); + + const mountForwardDialog = async (message = defaultMessage, rooms = defaultRooms) => { + const client = MatrixClientPeg.get(); + client.getVisibleRooms = jest.fn().mockReturnValue(rooms); + + let wrapper; + await act(async () => { + wrapper = mount( + , + ); + // Wait one tick for our profile data to load so the state update happens within act + await new Promise(resolve => setImmediate(resolve)); + }); + + return wrapper; + }; + + beforeEach(() => { + TestUtils.stubClient(); + DMRoomMap.makeShared(); + MatrixClientPeg.get().getUserId = jest.fn().mockReturnValue("@bob:example.org"); + }); + + it("shows a preview with us as the sender", async () => { + const wrapper = await mountForwardDialog(); + + const previewBody = wrapper.find(".mx_EventTile_body"); + expect(previewBody.text()).toBe("Hello world!"); + + // We would just test SenderProfile for the user ID, but it's stubbed + const previewAvatar = wrapper.find(".mx_EventTile_avatar .mx_BaseAvatar_image"); + expect(previewAvatar.prop("title")).toBe("@bob:example.org"); + }); + + it("filters the rooms", async () => { + const wrapper = await mountForwardDialog(); + + expect(wrapper.find("Entry")).toHaveLength(3); + + const searchInput = wrapper.find("SearchBox input"); + searchInput.instance().value = "a"; + searchInput.simulate("change"); + + expect(wrapper.find("Entry")).toHaveLength(2); + }); + + it("tracks message sending progress across multiple rooms", async () => { + const wrapper = await mountForwardDialog(); + + // Make sendEvent require manual resolution so we can see the sending state + let finishSend; + let cancelSend; + MatrixClientPeg.get().sendEvent = jest.fn(() => new Promise((resolve, reject) => { + finishSend = resolve; + cancelSend = reject; + })); + + const firstButton = wrapper.find("AccessibleButton.mx_ForwardList_sendButton").first(); + expect(firstButton.render().is(".mx_ForwardList_canSend")).toBe(true); + + act(() => { firstButton.simulate("click"); }); + expect(firstButton.render().is(".mx_ForwardList_sending")).toBe(true); + + await act(async () => { + cancelSend(); + // Wait one tick for the button to realize the send failed + await new Promise(resolve => setImmediate(resolve)); + }); + expect(firstButton.render().is(".mx_ForwardList_sendFailed")).toBe(true); + + const secondButton = wrapper.find("AccessibleButton.mx_ForwardList_sendButton").at(1); + expect(secondButton.render().is(".mx_ForwardList_canSend")).toBe(true); + + act(() => { secondButton.simulate("click"); }); + expect(secondButton.render().is(".mx_ForwardList_sending")).toBe(true); + + await act(async () => { + finishSend(); + // Wait one tick for the button to realize the send succeeded + await new Promise(resolve => setImmediate(resolve)); + }); + expect(secondButton.render().is(".mx_ForwardList_sent")).toBe(true); + }); + + it("can render replies", async () => { + const replyMessage = TestUtils.mkEvent({ + type: "m.room.message", + room: "!111111111111111111:example.org", + user: "@alice:example.org", + content: { + "msgtype": "m.text", + "body": "> <@bob:example.org> Hi Alice!\n\nHi Bob!", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$2222222222222222222222222222222222222222222", + }, + }, + }, + event: true, + }); + + const wrapper = await mountForwardDialog(replyMessage); + expect(wrapper.find("ReplyThread")).toBeTruthy(); + }); + + it("disables buttons for rooms without send permissions", async () => { + const readOnlyRoom = TestUtils.mkStubRoom("a", "a"); + readOnlyRoom.maySendMessage = jest.fn().mockReturnValue(false); + const rooms = [readOnlyRoom, TestUtils.mkStubRoom("b", "b")]; + + const wrapper = await mountForwardDialog(undefined, rooms); + + const firstButton = wrapper.find("AccessibleButton.mx_ForwardList_sendButton").first(); + expect(firstButton.prop("disabled")).toBe(true); + const secondButton = wrapper.find("AccessibleButton.mx_ForwardList_sendButton").last(); + expect(secondButton.prop("disabled")).toBe(false); + }); +}); diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.js b/test/components/views/dialogs/InteractiveAuthDialog-test.js index fa44fc8d92..dcf89afb4d 100644 --- a/test/components/views/dialogs/InteractiveAuthDialog-test.js +++ b/test/components/views/dialogs/InteractiveAuthDialog-test.js @@ -18,12 +18,12 @@ import React from 'react'; import ReactDOM from 'react-dom'; import ReactTestUtils from 'react-dom/test-utils'; import MatrixReactTestUtils from 'matrix-react-test-utils'; +import { sleep } from "matrix-js-sdk/src/utils"; import sdk from '../../../skinned-sdk'; -import {MatrixClientPeg} from '../../../../src/MatrixClientPeg'; +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; -import * as test_utils from '../../../test-utils'; -import {sleep} from "../../../../src/utils/promise"; +import * as TestUtilsMatrix from '../../../test-utils'; const InteractiveAuthDialog = sdk.getComponent( 'views.dialogs.InteractiveAuthDialog', @@ -33,7 +33,7 @@ describe('InteractiveAuthDialog', function() { let parentDiv; beforeEach(function() { - test_utils.stubClient(); + TestUtilsMatrix.stubClient(); parentDiv = document.createElement('div'); document.body.appendChild(parentDiv); }); @@ -45,11 +45,11 @@ describe('InteractiveAuthDialog', function() { it('Should successfully complete a password flow', function() { const onFinished = jest.fn(); - const doRequest = jest.fn().mockResolvedValue({a: 1}); + const doRequest = jest.fn().mockResolvedValue({ a: 1 }); // tell the stub matrixclient to return a real userid const client = MatrixClientPeg.get(); - client.credentials = {userId: "@user:id"}; + client.credentials = { userId: "@user:id" }; const dlg = ReactDOM.render( { expect(onFinished).toBeCalledTimes(1); - expect(onFinished).toBeCalledWith(true, {a: 1}); + expect(onFinished).toBeCalledWith(true, { a: 1 }); }); }); }); diff --git a/test/components/views/elements/MemberEventListSummary-test.js b/test/components/views/elements/MemberEventListSummary-test.js index dd6febc7d7..e2d51f13a4 100644 --- a/test/components/views/elements/MemberEventListSummary-test.js +++ b/test/components/views/elements/MemberEventListSummary-test.js @@ -90,7 +90,7 @@ describe('MemberEventListSummary', function() { it('renders expanded events if there are less than props.threshold', function() { const events = generateEvents([ - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, ]); const props = { events: events, @@ -112,8 +112,8 @@ describe('MemberEventListSummary', function() { it('renders expanded events if there are less than props.threshold', function() { const events = generateEvents([ - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, ]); const props = { events: events, @@ -136,9 +136,9 @@ describe('MemberEventListSummary', function() { it('renders collapsed events if events.length = props.threshold', function() { const events = generateEvents([ - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, ]); const props = { events: events, @@ -161,20 +161,20 @@ describe('MemberEventListSummary', function() { it('truncates long join,leave repetitions', function() { const events = generateEvents([ - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, ]); const props = { events: events, @@ -203,20 +203,20 @@ describe('MemberEventListSummary', function() { membership: "leave", senderId: "@some_other_user:some.domain", }, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, { userId: "@user_1:some.domain", prevMembership: "leave", @@ -245,8 +245,7 @@ describe('MemberEventListSummary', function() { ); }); - it('truncates multiple sequences of repetitions with other events between', - function() { + it('truncates multiple sequences of repetitions with other events between', function() { const events = generateEvents([ { userId: "@user_1:some.domain", @@ -254,22 +253,22 @@ describe('MemberEventListSummary', function() { membership: "leave", senderId: "@some_other_user:some.domain", }, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, { userId: "@user_1:some.domain", prevMembership: "leave", membership: "ban", senderId: "@some_other_user:some.domain", }, - {userId: "@user_1:some.domain", prevMembership: "ban", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { userId: "@user_1:some.domain", prevMembership: "ban", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, { userId: "@user_1:some.domain", prevMembership: "leave", @@ -308,10 +307,10 @@ describe('MemberEventListSummary', function() { membership: "leave", senderId: "@some_other_user:some.domain", }, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, { userId: "@user_1:some.domain", prevMembership: "leave", @@ -325,10 +324,10 @@ describe('MemberEventListSummary', function() { membership: "leave", senderId: "@some_other_user:some.domain", }, - {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + { userId: "@user_2:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_2:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_2:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_2:some.domain", prevMembership: "join", membership: "leave" }, { userId: "@user_2:some.domain", prevMembership: "leave", @@ -364,10 +363,10 @@ describe('MemberEventListSummary', function() { membership: "leave", senderId: "@some_other_user:some.domain", }, - {prevMembership: "leave", membership: "join"}, - {prevMembership: "join", membership: "leave"}, - {prevMembership: "leave", membership: "join"}, - {prevMembership: "join", membership: "leave"}, + { prevMembership: "leave", membership: "join" }, + { prevMembership: "join", membership: "leave" }, + { prevMembership: "leave", membership: "join" }, + { prevMembership: "join", membership: "leave" }, { prevMembership: "leave", membership: "ban", @@ -395,8 +394,7 @@ describe('MemberEventListSummary', function() { ); }); - it('correctly orders sequences of transitions by the order of their first event', - function() { + it('correctly orders sequences of transitions by the order of their first event', function() { const events = generateEvents([ { userId: "@user_2:some.domain", @@ -410,20 +408,20 @@ describe('MemberEventListSummary', function() { membership: "leave", senderId: "@some_other_user:some.domain", }, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_1:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, { userId: "@user_1:some.domain", prevMembership: "leave", membership: "ban", senderId: "@some_other_user:some.domain", }, - {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, - {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, - {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + { userId: "@user_2:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_2:some.domain", prevMembership: "join", membership: "leave" }, + { userId: "@user_2:some.domain", prevMembership: "leave", membership: "join" }, + { userId: "@user_2:some.domain", prevMembership: "join", membership: "leave" }, ]); const props = { events: events, @@ -450,11 +448,11 @@ describe('MemberEventListSummary', function() { it('correctly identifies transitions', function() { const events = generateEvents([ // invited - {userId: "@user_1:some.domain", membership: "invite"}, + { userId: "@user_1:some.domain", membership: "invite" }, // banned - {userId: "@user_1:some.domain", membership: "ban"}, + { userId: "@user_1:some.domain", membership: "ban" }, // joined - {userId: "@user_1:some.domain", membership: "join"}, + { userId: "@user_1:some.domain", membership: "join" }, // invite_reject { userId: "@user_1:some.domain", @@ -462,7 +460,7 @@ describe('MemberEventListSummary', function() { membership: "leave", }, // left - {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { userId: "@user_1:some.domain", prevMembership: "join", membership: "leave" }, // invite_withdrawal { userId: "@user_1:some.domain", @@ -568,8 +566,7 @@ describe('MemberEventListSummary', function() { ); }); - it('handles invitation plurals correctly when there are multiple invites', - function() { + it('handles invitation plurals correctly when there are multiple invites', function() { const events = generateEvents([ { userId: "@user_1:some.domain", @@ -605,10 +602,10 @@ describe('MemberEventListSummary', function() { it('handles a summary length = 2, with no "others"', function() { const events = generateEvents([ - {userId: "@user_1:some.domain", membership: "join"}, - {userId: "@user_1:some.domain", membership: "join"}, - {userId: "@user_2:some.domain", membership: "join"}, - {userId: "@user_2:some.domain", membership: "join"}, + { userId: "@user_1:some.domain", membership: "join" }, + { userId: "@user_1:some.domain", membership: "join" }, + { userId: "@user_2:some.domain", membership: "join" }, + { userId: "@user_2:some.domain", membership: "join" }, ]); const props = { events: events, @@ -633,9 +630,9 @@ describe('MemberEventListSummary', function() { it('handles a summary length = 2, with 1 "other"', function() { const events = generateEvents([ - {userId: "@user_1:some.domain", membership: "join"}, - {userId: "@user_2:some.domain", membership: "join"}, - {userId: "@user_3:some.domain", membership: "join"}, + { userId: "@user_1:some.domain", membership: "join" }, + { userId: "@user_2:some.domain", membership: "join" }, + { userId: "@user_3:some.domain", membership: "join" }, ]); const props = { events: events, @@ -660,7 +657,7 @@ describe('MemberEventListSummary', function() { it('handles a summary length = 2, with many "others"', function() { const events = generateEventsForUsers("@user_$:some.domain", 20, [ - {membership: "join"}, + { membership: "join" }, ]); const props = { events: events, diff --git a/test/components/views/groups/GroupMemberList-test.js b/test/components/views/groups/GroupMemberList-test.js index 39720177cc..7e01540d38 100644 --- a/test/components/views/groups/GroupMemberList-test.js +++ b/test/components/views/groups/GroupMemberList-test.js @@ -19,7 +19,7 @@ import ReactDOM from "react-dom"; import ReactTestUtils from "react-dom/test-utils"; import MockHttpBackend from "matrix-mock-request"; -import {MatrixClientPeg} from "../../../../src/MatrixClientPeg"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import sdk from "../../../skinned-sdk"; import Matrix from "matrix-js-sdk"; diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js index 0a6d47a72b..fd11a9d46b 100644 --- a/test/components/views/messages/TextualBody-test.js +++ b/test/components/views/messages/TextualBody-test.js @@ -15,15 +15,17 @@ limitations under the License. */ import React from "react"; -import Adapter from "enzyme-adapter-react-16"; +import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { configure, mount } from "enzyme"; import sdk from "../../../skinned-sdk"; -import {mkEvent, mkStubRoom} from "../../../test-utils"; -import {MatrixClientPeg} from "../../../../src/MatrixClientPeg"; +import { mkEvent, mkStubRoom } from "../../../test-utils"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import * as languageHandler from "../../../../src/languageHandler"; +import * as TestUtils from "../../../test-utils"; -const TextualBody = sdk.getComponent("views.messages.TextualBody"); +const _TextualBody = sdk.getComponent("views.messages.TextualBody"); +const TextualBody = TestUtils.wrapInMatrixClientContext(_TextualBody); configure({ adapter: new Adapter() }); @@ -302,13 +304,12 @@ describe("", () => { event: true, }); - const wrapper = mount(); + const wrapper = mount( {}} />); expect(wrapper.text()).toBe(ev.getContent().body); - let widgets = wrapper.find("LinkPreviewWidget"); - // at this point we should have exactly one widget - expect(widgets.length).toBe(1); - expect(widgets.at(0).prop("link")).toBe("https://matrix.org/"); + let widgets = wrapper.find("LinkPreviewGroup"); + // at this point we should have exactly one link + expect(widgets.at(0).prop("links")).toEqual(["https://matrix.org/"]); // simulate an event edit and check the transition from the old URL preview to the new one const ev2 = mkEvent({ @@ -333,14 +334,11 @@ describe("", () => { // XXX: this is to give TextualBody enough time for state to settle wrapper.setState({}, () => { - widgets = wrapper.find("LinkPreviewWidget"); - // at this point we should have exactly two widgets (not the matrix.org one anymore) - expect(widgets.length).toBe(2); - expect(widgets.at(0).prop("link")).toBe("https://vector.im/"); - expect(widgets.at(1).prop("link")).toBe("https://riot.im/"); + widgets = wrapper.find("LinkPreviewGroup"); + // at this point we should have exactly two links (not the matrix.org one anymore) + expect(widgets.at(0).prop("links")).toEqual(["https://vector.im/", "https://riot.im/"]); }); }); }); }); - diff --git a/test/components/views/rooms/MemberList-test.js b/test/components/views/rooms/MemberList-test.tsx similarity index 86% rename from test/components/views/rooms/MemberList-test.js rename to test/components/views/rooms/MemberList-test.tsx index 068d358dcd..f720bc7a6d 100644 --- a/test/components/views/rooms/MemberList-test.js +++ b/test/components/views/rooms/MemberList-test.tsx @@ -1,19 +1,38 @@ +/* +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 "../../../skinned-sdk"; + import React from 'react'; import ReactTestUtils from 'react-dom/test-utils'; import ReactDOM from 'react-dom'; import * as TestUtils from '../../../test-utils'; - -import {MatrixClientPeg} from '../../../../src/MatrixClientPeg'; -import sdk from '../../../skinned-sdk'; - -import {Room, RoomMember, User} from 'matrix-js-sdk'; +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; +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 { compare } from "../../../../src/utils/strings"; +import MemberList from "../../../../src/components/views/rooms/MemberList"; +import MemberTile from '../../../../src/components/views/rooms/MemberTile'; function generateRoomId() { return '!' + Math.random().toString().slice(2, 10) + ':domain'; } - describe('MemberList', () => { function createRoom(opts) { const room = new Room(generateRoomId(), null, client.getUserId()); @@ -88,19 +107,26 @@ describe('MemberList', () => { }; memberListRoom.currentState = { members: {}, + getMember: jest.fn(), getStateEvents: (eventType, stateKey) => stateKey === undefined ? [] : null, // ignore 3pid invites }; for (const member of [...adminUsers, ...moderatorUsers, ...defaultUsers]) { memberListRoom.currentState.members[member.userId] = member; } - const MemberList = sdk.getComponent('views.rooms.MemberList'); const WrappedMemberList = TestUtils.wrapInMatrixClientContext(MemberList); const gatherWrappedRef = (r) => { memberList = r; }; - root = ReactDOM.render(, parentDiv); + root = ReactDOM.render( + ( + + ), + parentDiv, + ); }); afterEach((done) => { @@ -172,7 +198,7 @@ describe('MemberList', () => { if (!groupChange) { const nameA = memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name; const nameB = memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name; - const nameCompare = nameB.localeCompare(nameA); + const nameCompare = compare(nameB, nameA); console.log("Comparing name"); expect(nameCompare).toBeGreaterThanOrEqual(0); } else { @@ -182,7 +208,6 @@ describe('MemberList', () => { } function itDoesOrderMembersCorrectly(enablePresence) { - const MemberTile = sdk.getComponent("rooms.MemberTile"); describe('does order members correctly', () => { // Note: even if presence is disabled, we still expect that the presence // tests will pass. All expectOrderedByPresenceAndPowerLevel does is ensure @@ -210,8 +235,8 @@ describe('MemberList', () => { }); // Bypass all the event listeners and skip to the good part - memberList._showPresence = enablePresence; - memberList._updateListNow(); + memberList.showPresence = enablePresence; + memberList.updateListNow(); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); @@ -222,7 +247,7 @@ describe('MemberList', () => { // Bypass all the event listeners and skip to the good part memberList._showPresence = enablePresence; - memberList._updateListNow(); + memberList.updateListNow(); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); @@ -251,8 +276,8 @@ describe('MemberList', () => { }); // Bypass all the event listeners and skip to the good part - memberList._showPresence = enablePresence; - memberList._updateListNow(); + memberList.showPresence = enablePresence; + memberList.updateListNow(); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); @@ -270,8 +295,8 @@ describe('MemberList', () => { }); // Bypass all the event listeners and skip to the good part - memberList._showPresence = enablePresence; - memberList._updateListNow(); + memberList.showPresence = enablePresence; + memberList.updateListNow(); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); @@ -288,4 +313,3 @@ describe('MemberList', () => { }); }); - diff --git a/test/components/views/rooms/RoomList-test.js b/test/components/views/rooms/RoomList-test.js index d3211f564c..cee0fe5a47 100644 --- a/test/components/views/rooms/RoomList-test.js +++ b/test/components/views/rooms/RoomList-test.js @@ -4,16 +4,15 @@ import ReactDOM from 'react-dom'; import * as TestUtils from '../../../test-utils'; -import {MatrixClientPeg} from '../../../../src/MatrixClientPeg'; +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; import sdk from '../../../skinned-sdk'; -import { DragDropContext } from 'react-beautiful-dnd'; import dis from '../../../../src/dispatcher/dispatcher'; import DMRoomMap from '../../../../src/utils/DMRoomMap'; import GroupStore from '../../../../src/stores/GroupStore'; import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; -import {DefaultTagID} from "../../../../src/stores/room-list/models"; +import { DefaultTagID } from "../../../../src/stores/room-list/models"; import RoomListStore, { LISTS_UPDATE_EVENT, RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore"; import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore"; @@ -56,7 +55,7 @@ describe('RoomList', () => { TestUtils.stubClient(); client = MatrixClientPeg.get(); - client.credentials = {userId: myUserId}; + client.credentials = { userId: myUserId }; //revert this to prototype method as the test-utils monkey-patches this to return a hardcoded value client.getUserId = MatrixClient.prototype.getUserId; @@ -68,13 +67,12 @@ describe('RoomList', () => { const RoomList = sdk.getComponent('views.rooms.RoomList'); const WrappedRoomList = TestUtils.wrapInMatrixClientContext(RoomList); root = ReactDOM.render( - - {}} /> - - , parentDiv); + {}} />, + parentDiv, + ); ReactTestUtils.findRenderedComponentWithType(root, RoomList); - movingRoom = createRoom({name: 'Moving room'}); + movingRoom = createRoom({ name: 'Moving room' }); expect(movingRoom.roomId).not.toBe(null); // Mock joined member @@ -85,7 +83,7 @@ describe('RoomList', () => { [client.credentials.userId]: myMember, }[userId]); - otherRoom = createRoom({name: 'Other room'}); + otherRoom = createRoom({ name: 'Other room' }); myOtherMember = new RoomMember(otherRoom.roomId, myUserId); myOtherMember.membership = 'join'; otherRoom.updateMyMembership('join'); @@ -97,10 +95,10 @@ describe('RoomList', () => { client.getRooms = () => [ movingRoom, otherRoom, - createRoom({tags: {'m.favourite': {order: 0.1}}, name: 'Some other room'}), - createRoom({tags: {'m.favourite': {order: 0.2}}, name: 'Some other room 2'}), - createRoom({tags: {'m.lowpriority': {}}, name: 'Some unimportant room'}), - createRoom({tags: {'custom.tag': {}}, name: 'Some room customly tagged'}), + createRoom({ tags: { 'm.favourite': { order: 0.1 } }, name: 'Some other room' }), + createRoom({ tags: { 'm.favourite': { order: 0.2 } }, name: 'Some other room 2' }), + createRoom({ tags: { 'm.lowpriority': {} }, name: 'Some unimportant room' }), + createRoom({ tags: { 'custom.tag': {} }, name: 'Some room customly tagged' }), ]; client.getVisibleRooms = client.getRooms; @@ -140,7 +138,7 @@ describe('RoomList', () => { let expectedRoomTile; try { const roomTiles = ReactTestUtils.scryRenderedComponentsWithType(containingSubList, RoomTile); - console.info({roomTiles: roomTiles.length}); + console.info({ roomTiles: roomTiles.length }); expectedRoomTile = roomTiles.find((tile) => tile.props.room === room); } catch (err) { // truncate the error message because it's spammy @@ -166,7 +164,7 @@ describe('RoomList', () => { // Set up the room that will be moved such that it has the correct state for a room in // the section for oldTagId if (oldTagId === DefaultTagID.Favourite || oldTagId === DefaultTagID.LowPriority) { - movingRoom.tags = {[oldTagId]: {}}; + movingRoom.tags = { [oldTagId]: {} }; } else if (oldTagId === DefaultTagID.DM) { // Mock inverse m.direct DMRoomMap.shared().roomToUser = { @@ -174,13 +172,13 @@ describe('RoomList', () => { }; } - dis.dispatch({action: 'MatrixActions.sync', prevState: null, state: 'PREPARED', matrixClient: client}); + dis.dispatch({ action: 'MatrixActions.sync', prevState: null, state: 'PREPARED', matrixClient: client }); expectRoomInSubList(movingRoom, srcSubListTest); - dis.dispatch({action: 'RoomListActions.tagRoom.pending', request: { + dis.dispatch({ action: 'RoomListActions.tagRoom.pending', request: { oldTagId, newTagId, room: movingRoom, - }}); + } }); expectRoomInSubList(movingRoom, destSubListTest); } @@ -281,11 +279,11 @@ describe('RoomList', () => { // We also have to mock the client's getGroup function for the room list to filter it. // It's not smart enough to tell the difference between a real group and a template though. client.getGroup = (groupId) => { - return {groupId}; + return { groupId }; }; // Select tag - dis.dispatch({action: 'select_tag', tag: '+group:domain'}, true); + dis.dispatch({ action: 'select_tag', tag: '+group:domain' }, true); } beforeEach(() => { @@ -311,4 +309,3 @@ describe('RoomList', () => { }); }); - diff --git a/test/components/views/rooms/RoomSettings-test.js b/test/components/views/rooms/RoomSettings-test.js index 3b4989ccab..cd70b81cd7 100644 --- a/test/components/views/rooms/RoomSettings-test.js +++ b/test/components/views/rooms/RoomSettings-test.js @@ -3,10 +3,9 @@ import React from 'react'; import ReactDOM from 'react-dom'; import * as testUtils from '../../../test-utils'; import sdk from '../../../skinned-sdk'; -import {MatrixClientPeg} from '../../../../src/MatrixClientPeg'; +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; import SettingsStore from '../../../../src/settings/SettingsStore'; - describe.skip('RoomSettings', () => { const WrappedRoomSettings = testUtils.wrapInMatrixClientContext(sdk.getComponent('views.rooms.RoomSettings')); @@ -36,7 +35,7 @@ describe.skip('RoomSettings', () => { beforeEach(function(done) { testUtils.stubClient(); client = MatrixClientPeg.get(); - client.credentials = {userId: '@me:domain.com'}; + client.credentials = { userId: '@me:domain.com' }; client.setRoomName = jest.fn().mockReturnValue(Promise.resolve()); client.setRoomTopic = jest.fn().mockReturnValue(Promise.resolve()); @@ -128,7 +127,7 @@ describe.skip('RoomSettings', () => { roomSettings.save().then(() => { expectSentStateEvent( "!DdJkzRliezrwpNebLk:matrix.org", - "m.room.history_visibility", {history_visibility: historyVisibility}, + "m.room.history_visibility", { history_visibility: historyVisibility }, ); done(); }); diff --git a/test/components/views/rooms/SendMessageComposer-test.js b/test/components/views/rooms/SendMessageComposer-test.js index 64a90eee81..0c4bde76a8 100644 --- a/test/components/views/rooms/SendMessageComposer-test.js +++ b/test/components/views/rooms/SendMessageComposer-test.js @@ -15,21 +15,22 @@ limitations under the License. */ import '../../../skinned-sdk'; // Must be first for skinning to work -import Adapter from "enzyme-adapter-react-16"; +import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { configure, mount } from "enzyme"; import React from "react"; -import {act} from "react-dom/test-utils"; +import { act } from "react-dom/test-utils"; +import { sleep } from "matrix-js-sdk/src/utils"; + import SendMessageComposer, { createMessageContent, isQuickReaction, } from "../../../../src/components/views/rooms/SendMessageComposer"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import EditorModel from "../../../../src/editor/model"; -import {createPartCreator, createRenderer} from "../../../editor/mock"; -import {createTestClient, mkEvent, mkStubRoom} from "../../../test-utils"; +import { createPartCreator, createRenderer } from "../../../editor/mock"; +import { createTestClient, mkEvent, mkStubRoom } from "../../../test-utils"; import BasicMessageComposer from "../../../../src/components/views/rooms/BasicMessageComposer"; -import {MatrixClientPeg} from "../../../../src/MatrixClientPeg"; -import {sleep} from "../../../../src/utils/promise"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import SpecPermalinkConstructor from "../../../../src/utils/permalinks/SpecPermalinkConstructor"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; @@ -43,7 +44,7 @@ describe('', () => { it("sends plaintext messages correctly", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("hello world", "insertText", {offset: 11, atNodeEnd: true}); + model.update("hello world", "insertText", { offset: 11, atNodeEnd: true }); const content = createMessageContent(model, permalinkCreator); @@ -55,7 +56,7 @@ describe('', () => { it("sends markdown messages correctly", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("hello *world*", "insertText", {offset: 13, atNodeEnd: true}); + model.update("hello *world*", "insertText", { offset: 13, atNodeEnd: true }); const content = createMessageContent(model, permalinkCreator); @@ -69,7 +70,7 @@ describe('', () => { it("strips /me from messages and marks them as m.emote accordingly", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("/me blinks __quickly__", "insertText", {offset: 22, atNodeEnd: true}); + model.update("/me blinks __quickly__", "insertText", { offset: 22, atNodeEnd: true }); const content = createMessageContent(model, permalinkCreator); @@ -83,7 +84,7 @@ describe('', () => { it("allows sending double-slash escaped slash commands correctly", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("//dev/null is my favourite place", "insertText", {offset: 32, atNodeEnd: true}); + model.update("//dev/null is my favourite place", "insertText", { offset: 32, atNodeEnd: true }); const content = createMessageContent(model, permalinkCreator); @@ -147,7 +148,7 @@ describe('', () => { wrapper.update(); }); - const key = wrapper.find(SendMessageComposer).instance()._editorStateKey; + const key = wrapper.find(SendMessageComposer).instance().editorStateKey; expect(wrapper.text()).toBe("Test Text"); expect(localStorage.getItem(key)).toBeNull(); @@ -155,7 +156,7 @@ describe('', () => { // ensure the right state was persisted to localStorage wrapper.unmount(); expect(JSON.parse(localStorage.getItem(key))).toStrictEqual({ - parts: [{"type": "plain", "text": "Test Text"}], + parts: [{ "type": "plain", "text": "Test Text" }], replyEventId: mockEvent.getId(), }); @@ -188,7 +189,7 @@ describe('', () => { wrapper.update(); }); - const key = wrapper.find(SendMessageComposer).instance()._editorStateKey; + const key = wrapper.find(SendMessageComposer).instance().editorStateKey; expect(wrapper.text()).toBe("Hello World"); expect(localStorage.getItem(key)).toBeNull(); @@ -196,7 +197,7 @@ describe('', () => { // ensure the right state was persisted to localStorage window.dispatchEvent(new Event('beforeunload')); expect(JSON.parse(localStorage.getItem(key))).toStrictEqual({ - parts: [{"type": "plain", "text": "Hello World"}], + parts: [{ "type": "plain", "text": "Hello World" }], }); }); @@ -225,7 +226,7 @@ describe('', () => { expect(wrapper.text()).toBe(""); const str = sessionStorage.getItem(`mx_cider_history_${mockRoom.roomId}[0]`); expect(JSON.parse(str)).toStrictEqual({ - parts: [{"type": "plain", "text": "This is a message"}], + parts: [{ "type": "plain", "text": "This is a message" }], replyEventId: mockEvent.getId(), }); }); @@ -234,7 +235,7 @@ describe('', () => { describe("isQuickReaction", () => { it("correctly detects quick reaction", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("+😊", "insertText", {offset: 3, atNodeEnd: true}); + model.update("+😊", "insertText", { offset: 3, atNodeEnd: true }); const isReaction = isQuickReaction(model); @@ -243,7 +244,7 @@ describe('', () => { it("correctly detects quick reaction with space", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("+ 😊", "insertText", {offset: 4, atNodeEnd: true}); + model.update("+ 😊", "insertText", { offset: 4, atNodeEnd: true }); const isReaction = isQuickReaction(model); @@ -255,10 +256,10 @@ describe('', () => { const model2 = new EditorModel([], createPartCreator(), createRenderer()); const model3 = new EditorModel([], createPartCreator(), createRenderer()); const model4 = new EditorModel([], createPartCreator(), createRenderer()); - model.update("+😊hello", "insertText", {offset: 8, atNodeEnd: true}); - model2.update(" +😊", "insertText", {offset: 4, atNodeEnd: true}); - model3.update("+ 😊😊", "insertText", {offset: 6, atNodeEnd: true}); - model4.update("+smiley", "insertText", {offset: 7, atNodeEnd: true}); + model.update("+😊hello", "insertText", { offset: 8, atNodeEnd: true }); + model2.update(" +😊", "insertText", { offset: 4, atNodeEnd: true }); + model3.update("+ 😊😊", "insertText", { offset: 6, atNodeEnd: true }); + model4.update("+smiley", "insertText", { offset: 7, atNodeEnd: true }); expect(isQuickReaction(model)).toBeFalsy(); expect(isQuickReaction(model2)).toBeFalsy(); @@ -268,4 +269,3 @@ describe('', () => { }); }); - diff --git a/test/createRoom-test.js b/test/createRoom-test.js index ed8f9779f7..b9b7e7df01 100644 --- a/test/createRoom-test.js +++ b/test/createRoom-test.js @@ -1,6 +1,6 @@ import './skinned-sdk'; // Must be first for skinning to work -import {_waitForMember, canEncryptToAllUsers} from '../src/createRoom'; -import {EventEmitter} from 'events'; +import { _waitForMember, canEncryptToAllUsers } from '../src/createRoom'; +import { EventEmitter } from 'events'; /* Shorter timeout, we've got tests to run */ const timeout = 30; @@ -61,10 +61,9 @@ describe("canEncryptToAllUsers", () => { done(); }); - it("returns false if not all users have crypto", async (done) => { const client = { - downloadKeys: async function(userIds) { return {...trueUser, ...falseUser}; }, + downloadKeys: async function(userIds) { return { ...trueUser, ...falseUser }; }, }; const response = await canEncryptToAllUsers(client, ["@goodUser:localhost", "@badUser:localhost"]); expect(response).toBe(false); diff --git a/test/editor/caret-test.js b/test/editor/caret-test.js index f0c171c7c9..e1a66a4431 100644 --- a/test/editor/caret-test.js +++ b/test/editor/caret-test.js @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {getLineAndNodePosition} from "../../src/editor/caret"; +import { getLineAndNodePosition } from "../../src/editor/caret"; import EditorModel from "../../src/editor/model"; -import {createPartCreator} from "./mock"; +import { createPartCreator } from "./mock"; describe('editor/caret: DOM position for caret', function() { describe('basic text handling', function() { @@ -25,8 +25,8 @@ describe('editor/caret: DOM position for caret', function() { const model = new EditorModel([ pc.plain("hello"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 0, offset: 5}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 0, offset: 5 }); expect(lineIndex).toBe(0); expect(nodeIndex).toBe(0); expect(offset).toBe(5); @@ -36,8 +36,8 @@ describe('editor/caret: DOM position for caret', function() { const model = new EditorModel([ pc.plain("hello"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 0, offset: 0}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 0, offset: 0 }); expect(lineIndex).toBe(0); expect(nodeIndex).toBe(0); expect(offset).toBe(0); @@ -47,8 +47,8 @@ describe('editor/caret: DOM position for caret', function() { const model = new EditorModel([ pc.plain("hello"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 0, offset: 2}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 0, offset: 2 }); expect(lineIndex).toBe(0); expect(nodeIndex).toBe(0); expect(offset).toBe(2); @@ -62,8 +62,8 @@ describe('editor/caret: DOM position for caret', function() { pc.newline(), pc.plain("world"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 2, offset: 5}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 2, offset: 5 }); expect(lineIndex).toBe(1); expect(nodeIndex).toBe(0); expect(offset).toBe(5); @@ -75,8 +75,8 @@ describe('editor/caret: DOM position for caret', function() { pc.newline(), pc.plain("world"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 2, offset: 0}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 2, offset: 0 }); expect(lineIndex).toBe(1); expect(nodeIndex).toBe(0); expect(offset).toBe(0); @@ -89,8 +89,8 @@ describe('editor/caret: DOM position for caret', function() { pc.newline(), pc.plain("world"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 1, offset: 1}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 1, offset: 1 }); expect(lineIndex).toBe(1); expect(nodeIndex).toBe(-1); expect(offset).toBe(0); @@ -103,8 +103,8 @@ describe('editor/caret: DOM position for caret', function() { pc.newline(), pc.plain("world"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 3, offset: 0}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 3, offset: 0 }); expect(lineIndex).toBe(2); expect(nodeIndex).toBe(0); expect(offset).toBe(0); @@ -118,8 +118,8 @@ describe('editor/caret: DOM position for caret', function() { pc.userPill("Alice", "@alice:hs.tld"), pc.plain("!"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 1, offset: 0}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 1, offset: 0 }); expect(lineIndex).toBe(0); expect(nodeIndex).toBe(0); expect(offset).toBe(5); @@ -131,8 +131,8 @@ describe('editor/caret: DOM position for caret', function() { pc.userPill("Alice", "@alice:hs.tld"), pc.plain("!"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 1, offset: 2}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 1, offset: 2 }); expect(lineIndex).toBe(0); expect(nodeIndex).toBe(2); expect(offset).toBe(0); @@ -142,8 +142,8 @@ describe('editor/caret: DOM position for caret', function() { const model = new EditorModel([ pc.userPill("Alice", "@alice:hs.tld"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 0, offset: 0}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 0, offset: 0 }); expect(lineIndex).toBe(0); //presumed nodes on line are (caret, pill, caret) expect(nodeIndex).toBe(0); @@ -154,8 +154,8 @@ describe('editor/caret: DOM position for caret', function() { const model = new EditorModel([ pc.userPill("Alice", "@alice:hs.tld"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 0, offset: 1}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 0, offset: 1 }); expect(lineIndex).toBe(0); //presumed nodes on line are (caret, pill, caret) expect(nodeIndex).toBe(2); @@ -167,8 +167,8 @@ describe('editor/caret: DOM position for caret', function() { pc.userPill("Alice", "@alice:hs.tld"), pc.userPill("Bob", "@bob:hs.tld"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 0, offset: 1}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 0, offset: 1 }); expect(lineIndex).toBe(0); //presumed nodes on line are (caret, pill, caret, pill, caret) expect(nodeIndex).toBe(2); @@ -180,8 +180,8 @@ describe('editor/caret: DOM position for caret', function() { pc.userPill("Alice", "@alice:hs.tld"), pc.userPill("Bob", "@bob:hs.tld"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 1, offset: 0}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 1, offset: 0 }); expect(lineIndex).toBe(0); //presumed nodes on line are (caret, pill, caret, pill, caret) expect(nodeIndex).toBe(2); @@ -193,8 +193,8 @@ describe('editor/caret: DOM position for caret', function() { pc.userPill("Alice", "@alice:hs.tld"), pc.userPill("Bob", "@bob:hs.tld"), ]); - const {offset, lineIndex, nodeIndex} = - getLineAndNodePosition(model, {index: 1, offset: 1}); + const { offset, lineIndex, nodeIndex } = + getLineAndNodePosition(model, { index: 1, offset: 1 }); expect(lineIndex).toBe(0); //presumed nodes on line are (caret, pill, caret, pill, caret) expect(nodeIndex).toBe(4); diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js index 07b75aaae5..17e8a31780 100644 --- a/test/editor/deserialize-test.js +++ b/test/editor/deserialize-test.js @@ -15,8 +15,8 @@ limitations under the License. */ import '../skinned-sdk'; // Must be first for skinning to work -import {parseEvent} from "../../src/editor/deserialize"; -import {createPartCreator} from "./mock"; +import { parseEvent } from "../../src/editor/deserialize"; +import { createPartCreator } from "./mock"; function htmlMessage(formattedBody, msgtype = "m.text") { return { @@ -71,22 +71,22 @@ describe('editor/deserialize', function() { describe('text messages', function() { it('test with newlines', function() { const parts = normalize(parseEvent(textMessage("hello\nworld"), createPartCreator())); - expect(parts[0]).toStrictEqual({type: "plain", text: "hello"}); - expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "world"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "hello" }); + expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "world" }); expect(parts.length).toBe(3); }); it('@room pill', function() { const parts = normalize(parseEvent(textMessage("text message for @room"), createPartCreator())); expect(parts.length).toBe(2); - expect(parts[0]).toStrictEqual({type: "plain", text: "text message for "}); - expect(parts[1]).toStrictEqual({type: "at-room-pill", text: "@room"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "text message for " }); + expect(parts[1]).toStrictEqual({ type: "at-room-pill", text: "@room" }); }); it('emote', function() { const text = "says DON'T SHOUT!"; const parts = normalize(parseEvent(textMessage(text, "m.emote"), createPartCreator())); expect(parts.length).toBe(1); - expect(parts[0]).toStrictEqual({type: "plain", text: "/me says DON'T SHOUT!"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "/me says DON'T SHOUT!" }); }); }); describe('html messages', function() { @@ -94,159 +94,159 @@ describe('editor/deserialize', function() { const html = "bold and emphasized text"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(1); - expect(parts[0]).toStrictEqual({type: "plain", text: "**bold** and _emphasized_ text"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "**bold** and _emphasized_ text" }); }); it('hyperlink', function() { const html = 'click this!'; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(1); - expect(parts[0]).toStrictEqual({type: "plain", text: "click [this](http://example.com/)!"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "click [this](http://example.com/)!" }); }); it('multiple lines with paragraphs', function() { const html = '

    hello

    world

    '; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(4); - expect(parts[0]).toStrictEqual({type: "plain", text: "hello"}); - expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[2]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[3]).toStrictEqual({type: "plain", text: "world"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "hello" }); + expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[2]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[3]).toStrictEqual({ type: "plain", text: "world" }); }); it('multiple lines with line breaks', function() { const html = 'hello
    world'; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); - expect(parts[0]).toStrictEqual({type: "plain", text: "hello"}); - expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "world"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "hello" }); + expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "world" }); }); it('multiple lines mixing paragraphs and line breaks', function() { const html = '

    hello
    warm

    world

    '; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(6); - expect(parts[0]).toStrictEqual({type: "plain", text: "hello"}); - expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "warm"}); - expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[4]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[5]).toStrictEqual({type: "plain", text: "world"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "hello" }); + expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "warm" }); + expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[4]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[5]).toStrictEqual({ type: "plain", text: "world" }); }); it('quote', function() { const html = '

    wise
    words

    indeed

    '; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(6); - expect(parts[0]).toStrictEqual({type: "plain", text: "> _wise_"}); - expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "> **words**"}); - expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[4]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[5]).toStrictEqual({type: "plain", text: "indeed"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "> _wise_" }); + expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "> **words**" }); + expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[4]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[5]).toStrictEqual({ type: "plain", text: "indeed" }); }); it('user pill', function() { const html = "Hi Alice!"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); - expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); - expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice", resourceId: "@alice:hs.tld"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "Hi " }); + expect(parts[1]).toStrictEqual({ type: "user-pill", text: "Alice", resourceId: "@alice:hs.tld" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "!" }); }); it('user pill with displayname containing backslash', function() { const html = "Hi Alice\\!"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); - expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); - expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice\\", resourceId: "@alice:hs.tld"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "Hi " }); + expect(parts[1]).toStrictEqual({ type: "user-pill", text: "Alice\\", resourceId: "@alice:hs.tld" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "!" }); }); it('user pill with displayname containing opening square bracket', function() { const html = "Hi Alice[[!"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); - expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); - expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice[[", resourceId: "@alice:hs.tld"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "Hi " }); + expect(parts[1]).toStrictEqual({ type: "user-pill", text: "Alice[[", resourceId: "@alice:hs.tld" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "!" }); }); it('user pill with displayname containing closing square bracket', function() { const html = "Hi Alice]!"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); - expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); - expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice]", resourceId: "@alice:hs.tld"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "Hi " }); + expect(parts[1]).toStrictEqual({ type: "user-pill", text: "Alice]", resourceId: "@alice:hs.tld" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "!" }); }); it('room pill', function() { const html = "Try #room:hs.tld?"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); - expect(parts[0]).toStrictEqual({type: "plain", text: "Try "}); - expect(parts[1]).toStrictEqual({type: "room-pill", text: "#room:hs.tld"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "?"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "Try " }); + expect(parts[1]).toStrictEqual({ type: "room-pill", text: "#room:hs.tld", resourceId: "#room:hs.tld" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "?" }); }); it('@room pill', function() { const html = "formatted message for @room"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(2); - expect(parts[0]).toStrictEqual({type: "plain", text: "_formatted_ message for "}); - expect(parts[1]).toStrictEqual({type: "at-room-pill", text: "@room"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "_formatted_ message for " }); + expect(parts[1]).toStrictEqual({ type: "at-room-pill", text: "@room" }); }); it('inline code', function() { const html = "there is no place like 127.0.0.1!"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(1); - expect(parts[0]).toStrictEqual({type: "plain", text: "there is no place like `127.0.0.1`!"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "there is no place like `127.0.0.1`!" }); }); it('code block with no trailing text', function() { const html = "
    0xDEADBEEF\n
    \n"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); console.log(parts); expect(parts.length).toBe(5); - expect(parts[0]).toStrictEqual({type: "plain", text: "```"}); - expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "0xDEADBEEF"}); - expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[4]).toStrictEqual({type: "plain", text: "```"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "```" }); + expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "0xDEADBEEF" }); + expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[4]).toStrictEqual({ type: "plain", text: "```" }); }); // failing likely because of https://github.com/vector-im/element-web/issues/10316 xit('code block with no trailing text and no newlines', function() { const html = "
    0xDEADBEEF
    "; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(5); - expect(parts[0]).toStrictEqual({type: "plain", text: "```"}); - expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "0xDEADBEEF"}); - expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[4]).toStrictEqual({type: "plain", text: "```"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "```" }); + expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "0xDEADBEEF" }); + expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[4]).toStrictEqual({ type: "plain", text: "```" }); }); it('unordered lists', function() { const html = "
    • Oak
    • Spruce
    • Birch
    "; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(5); - expect(parts[0]).toStrictEqual({type: "plain", text: "- Oak"}); - expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "- Spruce"}); - expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[4]).toStrictEqual({type: "plain", text: "- Birch"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "- Oak" }); + expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "- Spruce" }); + expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[4]).toStrictEqual({ type: "plain", text: "- Birch" }); }); it('ordered lists', function() { const html = "
    1. Start
    2. Continue
    3. Finish
    "; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(5); - expect(parts[0]).toStrictEqual({type: "plain", text: "1. Start"}); - expect(parts[1]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[2]).toStrictEqual({type: "plain", text: "2. Continue"}); - expect(parts[3]).toStrictEqual({type: "newline", text: "\n"}); - expect(parts[4]).toStrictEqual({type: "plain", text: "3. Finish"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "1. Start" }); + expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[2]).toStrictEqual({ type: "plain", text: "2. Continue" }); + expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); + expect(parts[4]).toStrictEqual({ type: "plain", text: "3. Finish" }); }); it('mx-reply is stripped', function() { const html = "foobar"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(1); - expect(parts[0]).toStrictEqual({type: "plain", text: "bar"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "bar" }); }); it('emote', function() { const html = "says DON'T SHOUT!"; const parts = normalize(parseEvent(htmlMessage(html, "m.emote"), createPartCreator())); expect(parts.length).toBe(1); - expect(parts[0]).toStrictEqual({type: "plain", text: "/me says _DON'T SHOUT_!"}); + expect(parts[0]).toStrictEqual({ type: "plain", text: "/me says _DON'T SHOUT_!" }); }); }); }); diff --git a/test/editor/diff-test.js b/test/editor/diff-test.js index 4637206b27..e525731340 100644 --- a/test/editor/diff-test.js +++ b/test/editor/diff-test.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {diffDeletion, diffAtCaret} from "../../src/editor/diff"; +import { diffDeletion, diffAtCaret } from "../../src/editor/diff"; describe('editor/diff', function() { describe('diffDeletion', function() { diff --git a/test/editor/history-test.js b/test/editor/history-test.js index e54c1e7ea9..e3ae3e16f6 100644 --- a/test/editor/history-test.js +++ b/test/editor/history-test.js @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import HistoryManager, {MAX_STEP_LENGTH} from "../../src/editor/history"; +import HistoryManager, { MAX_STEP_LENGTH } from "../../src/editor/history"; describe('editor/history', function() { it('push, then undo', function() { const history = new HistoryManager(); const parts = ["hello"]; - const model = {serializeParts: () => parts.slice()}; + const model = { serializeParts: () => parts.slice() }; const caret1 = {}; const result1 = history.tryPush(model, caret1); expect(result1).toEqual(true); @@ -35,7 +35,7 @@ describe('editor/history', function() { it('push, undo, then redo', function() { const history = new HistoryManager(); const parts = ["hello"]; - const model = {serializeParts: () => parts.slice()}; + const model = { serializeParts: () => parts.slice() }; history.tryPush(model, {}); parts[0] = "hello world"; const caret2 = {}; @@ -51,7 +51,7 @@ describe('editor/history', function() { it('push, undo, push, ensure you can`t redo', function() { const history = new HistoryManager(); const parts = ["hello"]; - const model = {serializeParts: () => parts.slice()}; + const model = { serializeParts: () => parts.slice() }; history.tryPush(model, {}); parts[0] = "hello world"; history.tryPush(model, {}); @@ -63,10 +63,10 @@ describe('editor/history', function() { it('not every keystroke stores a history step', function() { const history = new HistoryManager(); const parts = ["hello"]; - const model = {serializeParts: () => parts.slice()}; + const model = { serializeParts: () => parts.slice() }; const firstCaret = {}; history.tryPush(model, firstCaret); - const diff = {added: "o"}; + const diff = { added: "o" }; let keystrokeCount = 0; do { parts[0] = parts[0] + diff.added; @@ -80,24 +80,24 @@ describe('editor/history', function() { }); it('history step is added at word boundary', function() { const history = new HistoryManager(); - const model = {serializeParts: () => parts.slice()}; + const model = { serializeParts: () => parts.slice() }; const parts = ["h"]; - let diff = {added: "h"}; + let diff = { added: "h" }; expect(history.tryPush(model, {}, "insertText", diff)).toEqual(false); - diff = {added: "i"}; + diff = { added: "i" }; parts[0] = "hi"; expect(history.tryPush(model, {}, "insertText", diff)).toEqual(false); - diff = {added: " "}; + diff = { added: " " }; parts[0] = "hi "; const spaceCaret = {}; expect(history.tryPush(model, spaceCaret, "insertText", diff)).toEqual(true); - diff = {added: "y"}; + diff = { added: "y" }; parts[0] = "hi y"; expect(history.tryPush(model, {}, "insertText", diff)).toEqual(false); - diff = {added: "o"}; + diff = { added: "o" }; parts[0] = "hi yo"; expect(history.tryPush(model, {}, "insertText", diff)).toEqual(false); - diff = {added: "u"}; + diff = { added: "u" }; parts[0] = "hi you"; expect(history.canUndo()).toEqual(true); @@ -108,11 +108,11 @@ describe('editor/history', function() { it('keystroke that didn\'t add a step can undo', function() { const history = new HistoryManager(); const parts = ["hello"]; - const model = {serializeParts: () => parts.slice()}; + const model = { serializeParts: () => parts.slice() }; const firstCaret = {}; history.tryPush(model, {}); parts[0] = "helloo"; - const result = history.tryPush(model, {}, "insertText", {added: "o"}); + const result = history.tryPush(model, {}, "insertText", { added: "o" }); expect(result).toEqual(false); expect(history.canUndo()).toEqual(true); const undoState = history.undo(model); @@ -122,11 +122,11 @@ describe('editor/history', function() { it('undo after keystroke that didn\'t add a step is able to redo', function() { const history = new HistoryManager(); const parts = ["hello"]; - const model = {serializeParts: () => parts.slice()}; + const model = { serializeParts: () => parts.slice() }; history.tryPush(model, {}); parts[0] = "helloo"; - const caret = {last: true}; - history.tryPush(model, caret, "insertText", {added: "o"}); + const caret = { last: true }; + history.tryPush(model, caret, "insertText", { added: "o" }); history.undo(model); expect(history.canRedo()).toEqual(true); const redoState = history.redo(); @@ -136,10 +136,10 @@ describe('editor/history', function() { it('overwriting text always stores a step', function() { const history = new HistoryManager(); const parts = ["hello"]; - const model = {serializeParts: () => parts.slice()}; + const model = { serializeParts: () => parts.slice() }; const firstCaret = {}; history.tryPush(model, firstCaret); - const diff = {at: 1, added: "a", removed: "e"}; + const diff = { at: 1, added: "a", removed: "e" }; const result = history.tryPush(model, {}, "insertText", diff); expect(result).toEqual(true); }); diff --git a/test/editor/mock.js b/test/editor/mock.js index 6de65cf23d..2d82d22033 100644 --- a/test/editor/mock.js +++ b/test/editor/mock.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {PartCreator} from "../../src/editor/parts"; +import { PartCreator } from "../../src/editor/parts"; class MockAutoComplete { constructor(updateCallback, partCreator, completions) { @@ -25,7 +25,7 @@ class MockAutoComplete { } close() { - this._updateCallback({close: true}); + this._updateCallback({ close: true }); } tryComplete(close = true) { @@ -40,7 +40,7 @@ class MockAutoComplete { } else { pill = this._partCreator.roomPill(match.resourceId); } - this._updateCallback({replaceParts: [pill], close}); + this._updateCallback({ replaceParts: [pill], close }); } } diff --git a/test/editor/model-test.js b/test/editor/model-test.js index 2df9fdd573..35bd4143a7 100644 --- a/test/editor/model-test.js +++ b/test/editor/model-test.js @@ -15,14 +15,14 @@ limitations under the License. */ import EditorModel from "../../src/editor/model"; -import {createPartCreator, createRenderer} from "./mock"; +import { createPartCreator, createRenderer } from "./mock"; describe('editor/model', function() { describe('plain text manipulation', function() { it('insert text into empty document', function() { const renderer = createRenderer(); const model = new EditorModel([], createPartCreator(), renderer); - model.update("hello", "insertText", {offset: 5, atNodeEnd: true}); + model.update("hello", "insertText", { offset: 5, atNodeEnd: true }); expect(renderer.count).toBe(1); expect(renderer.caret.index).toBe(0); expect(renderer.caret.offset).toBe(5); @@ -34,7 +34,7 @@ describe('editor/model', function() { const renderer = createRenderer(); const pc = createPartCreator(); const model = new EditorModel([pc.plain("hello")], pc, renderer); - model.update("hello world", "insertText", {offset: 11, atNodeEnd: true}); + model.update("hello world", "insertText", { offset: 11, atNodeEnd: true }); expect(renderer.count).toBe(1); expect(renderer.caret.index).toBe(0); expect(renderer.caret.offset).toBe(11); @@ -46,7 +46,7 @@ describe('editor/model', function() { const renderer = createRenderer(); const pc = createPartCreator(); const model = new EditorModel([pc.plain("world")], pc, renderer); - model.update("hello world", "insertText", {offset: 6, atNodeEnd: false}); + model.update("hello world", "insertText", { offset: 6, atNodeEnd: false }); expect(renderer.count).toBe(1); expect(renderer.caret.index).toBe(0); expect(renderer.caret.offset).toBe(6); @@ -60,7 +60,7 @@ describe('editor/model', function() { const renderer = createRenderer(); const pc = createPartCreator(); const model = new EditorModel([pc.plain("hello")], pc, renderer); - model.update("hello\n", "insertText", {offset: 6, atNodeEnd: true}); + model.update("hello\n", "insertText", { offset: 6, atNodeEnd: true }); expect(renderer.count).toBe(1); expect(renderer.caret.index).toBe(1); expect(renderer.caret.offset).toBe(1); @@ -74,7 +74,7 @@ describe('editor/model', function() { const renderer = createRenderer(); const pc = createPartCreator(); const model = new EditorModel([pc.plain("hello")], pc, renderer); - model.update("hello\n\n\nworld!", "insertText", {offset: 14, atNodeEnd: true}); + model.update("hello\n\n\nworld!", "insertText", { offset: 14, atNodeEnd: true }); expect(renderer.count).toBe(1); expect(renderer.caret.index).toBe(4); expect(renderer.caret.offset).toBe(6); @@ -99,7 +99,7 @@ describe('editor/model', function() { pc.newline(), pc.plain("world"), ], pc, renderer); - model.update("hello\nwarm\nworld", "insertText", {offset: 10, atNodeEnd: true}); + model.update("hello\nwarm\nworld", "insertText", { offset: 10, atNodeEnd: true }); console.log(model.serializeParts()); expect(renderer.count).toBe(1); expect(renderer.caret.index).toBe(2); @@ -125,7 +125,7 @@ describe('editor/model', function() { pc.plain("try "), pc.roomPill("#someroom"), ], pc, renderer); - model.update("try foo#someroom", "insertText", {offset: 7, atNodeEnd: false}); + model.update("try foo#someroom", "insertText", { offset: 7, atNodeEnd: false }); expect(renderer.caret.index).toBe(0); expect(renderer.caret.offset).toBe(7); expect(model.parts.length).toBe(2); @@ -142,7 +142,7 @@ describe('editor/model', function() { pc.roomPill("#someroom"), pc.plain("?"), ], pc, renderer); - model.update("try #some perhapsroom?", "insertText", {offset: 17, atNodeEnd: false}); + model.update("try #some perhapsroom?", "insertText", { offset: 17, atNodeEnd: false }); expect(renderer.caret.index).toBe(2); expect(renderer.caret.offset).toBe(8); expect(model.parts.length).toBe(3); @@ -157,7 +157,7 @@ describe('editor/model', function() { const renderer = createRenderer(); const pc = createPartCreator(); const model = new EditorModel([pc.roomPill("#someroom")], pc, renderer); - model.update("#someroo", "deleteContentBackward", {offset: 8, atNodeEnd: true}); + model.update("#someroo", "deleteContentBackward", { offset: 8, atNodeEnd: true }); expect(renderer.count).toBe(1); expect(renderer.caret.index).toBe(-1); expect(renderer.caret.offset).toBe(0); @@ -167,7 +167,7 @@ describe('editor/model', function() { const renderer = createRenderer(); const pc = createPartCreator(); const model = new EditorModel([pc.roomPill("#someroom")], pc, renderer); - model.update("someroom", "deleteContentForward", {offset: 0, atNodeEnd: false}); + model.update("someroom", "deleteContentForward", { offset: 0, atNodeEnd: false }); expect(renderer.count).toBe(1); expect(renderer.caret.index).toBe(-1); expect(renderer.caret.offset).toBe(0); @@ -177,10 +177,10 @@ describe('editor/model', function() { describe('auto-complete', function() { it('insert user pill', function() { const renderer = createRenderer(); - const pc = createPartCreator([{resourceId: "@alice", label: "Alice"}]); + const pc = createPartCreator([{ resourceId: "@alice", label: "Alice" }]); const model = new EditorModel([pc.plain("hello ")], pc, renderer); - model.update("hello @a", "insertText", {offset: 8, atNodeEnd: true}); + model.update("hello @a", "insertText", { offset: 8, atNodeEnd: true }); expect(renderer.count).toBe(1); expect(renderer.caret.index).toBe(1); @@ -205,10 +205,10 @@ describe('editor/model', function() { it('insert room pill', function() { const renderer = createRenderer(); - const pc = createPartCreator([{resourceId: "#riot-dev"}]); + const pc = createPartCreator([{ resourceId: "#riot-dev" }]); const model = new EditorModel([pc.plain("hello ")], pc, renderer); - model.update("hello #r", "insertText", {offset: 8, atNodeEnd: true}); + model.update("hello #r", "insertText", { offset: 8, atNodeEnd: true }); expect(renderer.count).toBe(1); expect(renderer.caret.index).toBe(1); @@ -233,12 +233,12 @@ describe('editor/model', function() { it('type after inserting pill', function() { const renderer = createRenderer(); - const pc = createPartCreator([{resourceId: "#riot-dev"}]); + const pc = createPartCreator([{ resourceId: "#riot-dev" }]); const model = new EditorModel([pc.plain("hello ")], pc, renderer); - model.update("hello #r", "insertText", {offset: 8, atNodeEnd: true}); + model.update("hello #r", "insertText", { offset: 8, atNodeEnd: true }); model.autoComplete.tryComplete(); // see MockAutoComplete - model.update("hello #riot-dev!!", "insertText", {offset: 17, atNodeEnd: true}); + model.update("hello #riot-dev!!", "insertText", { offset: 17, atNodeEnd: true }); expect(renderer.count).toBe(3); expect(renderer.caret.index).toBe(2); @@ -254,10 +254,10 @@ describe('editor/model', function() { it('pasting text does not trigger auto-complete', function() { const renderer = createRenderer(); - const pc = createPartCreator([{resourceId: "#define-room"}]); + const pc = createPartCreator([{ resourceId: "#define-room" }]); const model = new EditorModel([pc.plain("try ")], pc, renderer); - model.update("try #define", "insertFromPaste", {offset: 11, atNodeEnd: true}); + model.update("try #define", "insertFromPaste", { offset: 11, atNodeEnd: true }); expect(model.autoComplete).toBeFalsy(); expect(renderer.caret.index).toBe(0); @@ -269,10 +269,10 @@ describe('editor/model', function() { it('dropping text does not trigger auto-complete', function() { const renderer = createRenderer(); - const pc = createPartCreator([{resourceId: "#define-room"}]); + const pc = createPartCreator([{ resourceId: "#define-room" }]); const model = new EditorModel([pc.plain("try ")], pc, renderer); - model.update("try #define", "insertFromDrop", {offset: 11, atNodeEnd: true}); + model.update("try #define", "insertFromDrop", { offset: 11, atNodeEnd: true }); expect(model.autoComplete).toBeFalsy(); expect(renderer.caret.index).toBe(0); @@ -284,17 +284,17 @@ describe('editor/model', function() { it('insert room pill without splitting at the colon', () => { const renderer = createRenderer(); - const pc = createPartCreator([{resourceId: "#room:server"}]); + const pc = createPartCreator([{ resourceId: "#room:server" }]); const model = new EditorModel([], pc, renderer); - model.update("#roo", "insertText", {offset: 4, atNodeEnd: true}); + model.update("#roo", "insertText", { offset: 4, atNodeEnd: true }); expect(renderer.count).toBe(1); expect(model.parts.length).toBe(1); expect(model.parts[0].type).toBe("pill-candidate"); expect(model.parts[0].text).toBe("#roo"); - model.update("#room:s", "insertText", {offset: 7, atNodeEnd: true}); + model.update("#room:s", "insertText", { offset: 7, atNodeEnd: true }); expect(renderer.count).toBe(2); expect(model.parts.length).toBe(1); @@ -304,10 +304,10 @@ describe('editor/model', function() { it('allow typing e-mail addresses without splitting at the @', () => { const renderer = createRenderer(); - const pc = createPartCreator([{resourceId: "@alice", label: "Alice"}]); + const pc = createPartCreator([{ resourceId: "@alice", label: "Alice" }]); const model = new EditorModel([], pc, renderer); - model.update("foo@a", "insertText", {offset: 5, atNodeEnd: true}); + model.update("foo@a", "insertText", { offset: 5, atNodeEnd: true }); expect(renderer.count).toBe(1); expect(model.parts.length).toBe(1); diff --git a/test/editor/operations-test.js b/test/editor/operations-test.js index 90a9812306..32ccaa5440 100644 --- a/test/editor/operations-test.js +++ b/test/editor/operations-test.js @@ -15,10 +15,10 @@ limitations under the License. */ import EditorModel from "../../src/editor/model"; -import {createPartCreator, createRenderer} from "./mock"; -import {toggleInlineFormat} from "../../src/editor/operations"; +import { createPartCreator, createRenderer } from "./mock"; +import { toggleInlineFormat } from "../../src/editor/operations"; -const SERIALIZED_NEWLINE = {"text": "\n", "type": "newline"}; +const SERIALIZED_NEWLINE = { "text": "\n", "type": "newline" }; describe('editor/operations: formatting operations', () => { describe('toggleInlineFormat', () => { @@ -33,9 +33,9 @@ describe('editor/operations: formatting operations', () => { model.positionForOffset(11, false)); // around "world" expect(range.parts[0].text).toBe("world"); - expect(model.serializeParts()).toEqual([{"text": "hello world!", "type": "plain"}]); + expect(model.serializeParts()).toEqual([{ "text": "hello world!", "type": "plain" }]); toggleInlineFormat(range, "_"); - expect(model.serializeParts()).toEqual([{"text": "hello _world_!", "type": "plain"}]); + expect(model.serializeParts()).toEqual([{ "text": "hello _world_!", "type": "plain" }]); }); it('works for parts of words', () => { @@ -49,9 +49,9 @@ describe('editor/operations: formatting operations', () => { model.positionForOffset(10, false)); // around "orl" expect(range.parts[0].text).toBe("orl"); - expect(model.serializeParts()).toEqual([{"text": "hello world!", "type": "plain"}]); + expect(model.serializeParts()).toEqual([{ "text": "hello world!", "type": "plain" }]); toggleInlineFormat(range, "*"); - expect(model.serializeParts()).toEqual([{"text": "hello w*orl*d!", "type": "plain"}]); + expect(model.serializeParts()).toEqual([{ "text": "hello w*orl*d!", "type": "plain" }]); }); it('works for around pills', () => { @@ -68,15 +68,15 @@ describe('editor/operations: formatting operations', () => { expect(range.parts.map(p => p.text).join("")).toBe("there @room, how are you"); expect(model.serializeParts()).toEqual([ - {"text": "hello there ", "type": "plain"}, - {"text": "@room", "type": "at-room-pill"}, - {"text": ", how are you doing?", "type": "plain"}, + { "text": "hello there ", "type": "plain" }, + { "text": "@room", "type": "at-room-pill" }, + { "text": ", how are you doing?", "type": "plain" }, ]); toggleInlineFormat(range, "_"); expect(model.serializeParts()).toEqual([ - {"text": "hello _there ", "type": "plain"}, - {"text": "@room", "type": "at-room-pill"}, - {"text": ", how are you_ doing?", "type": "plain"}, + { "text": "hello _there ", "type": "plain" }, + { "text": "@room", "type": "at-room-pill" }, + { "text": ", how are you_ doing?", "type": "plain" }, ]); }); @@ -94,15 +94,15 @@ describe('editor/operations: formatting operations', () => { expect(range.parts.map(p => p.text).join("")).toBe("world,\nhow"); expect(model.serializeParts()).toEqual([ - {"text": "hello world,", "type": "plain"}, + { "text": "hello world,", "type": "plain" }, SERIALIZED_NEWLINE, - {"text": "how are you doing?", "type": "plain"}, + { "text": "how are you doing?", "type": "plain" }, ]); toggleInlineFormat(range, "**"); expect(model.serializeParts()).toEqual([ - {"text": "hello **world,", "type": "plain"}, + { "text": "hello **world,", "type": "plain" }, SERIALIZED_NEWLINE, - {"text": "how** are you doing?", "type": "plain"}, + { "text": "how** are you doing?", "type": "plain" }, ]); }); @@ -125,9 +125,9 @@ describe('editor/operations: formatting operations', () => { expect(model.serializeParts()).toEqual([ SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, - {"text": "hello world,", "type": "plain"}, + { "text": "hello world,", "type": "plain" }, SERIALIZED_NEWLINE, - {"text": "how are you doing?", "type": "plain"}, + { "text": "how are you doing?", "type": "plain" }, SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, ]); @@ -135,9 +135,9 @@ describe('editor/operations: formatting operations', () => { expect(model.serializeParts()).toEqual([ SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, - {"text": "**hello world,", "type": "plain"}, + { "text": "**hello world,", "type": "plain" }, SERIALIZED_NEWLINE, - {"text": "how are you doing?**", "type": "plain"}, + { "text": "how are you doing?**", "type": "plain" }, SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, ]); @@ -158,32 +158,32 @@ describe('editor/operations: formatting operations', () => { let range = model.startRange(model.positionForOffset(0, true), model.getPositionAtEnd()); // select-all expect(model.serializeParts()).toEqual([ - {"text": "hello world,", "type": "plain"}, + { "text": "hello world,", "type": "plain" }, SERIALIZED_NEWLINE, - {"text": "how are you doing?", "type": "plain"}, + { "text": "how are you doing?", "type": "plain" }, SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, - {"text": "new paragraph", "type": "plain"}, + { "text": "new paragraph", "type": "plain" }, ]); toggleInlineFormat(range, "__"); expect(model.serializeParts()).toEqual([ - {"text": "__hello world,", "type": "plain"}, + { "text": "__hello world,", "type": "plain" }, SERIALIZED_NEWLINE, - {"text": "how are you doing?__", "type": "plain"}, + { "text": "how are you doing?__", "type": "plain" }, SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, - {"text": "__new paragraph__", "type": "plain"}, + { "text": "__new paragraph__", "type": "plain" }, ]); range = model.startRange(model.positionForOffset(0, true), model.getPositionAtEnd()); // select-all console.log("RANGE", range.parts); toggleInlineFormat(range, "__"); expect(model.serializeParts()).toEqual([ - {"text": "hello world,", "type": "plain"}, + { "text": "hello world,", "type": "plain" }, SERIALIZED_NEWLINE, - {"text": "how are you doing?", "type": "plain"}, + { "text": "how are you doing?", "type": "plain" }, SERIALIZED_NEWLINE, SERIALIZED_NEWLINE, - {"text": "new paragraph", "type": "plain"}, + { "text": "new paragraph", "type": "plain" }, ]); }); }); diff --git a/test/editor/position-test.js b/test/editor/position-test.js index 90f40c21a7..813a8e9f7f 100644 --- a/test/editor/position-test.js +++ b/test/editor/position-test.js @@ -15,7 +15,7 @@ limitations under the License. */ import EditorModel from "../../src/editor/model"; -import {createPartCreator} from "./mock"; +import { createPartCreator } from "./mock"; function createRenderer() { const render = (c) => { diff --git a/test/editor/range-test.js b/test/editor/range-test.js index 60055af824..d411a0d911 100644 --- a/test/editor/range-test.js +++ b/test/editor/range-test.js @@ -15,7 +15,7 @@ limitations under the License. */ import EditorModel from "../../src/editor/model"; -import {createPartCreator, createRenderer} from "./mock"; +import { createPartCreator, createRenderer } from "./mock"; const pillChannel = "#riot-dev:matrix.org"; diff --git a/test/editor/serialize-test.js b/test/editor/serialize-test.js index f0d577ea21..691130bd34 100644 --- a/test/editor/serialize-test.js +++ b/test/editor/serialize-test.js @@ -15,8 +15,8 @@ limitations under the License. */ import EditorModel from "../../src/editor/model"; -import {htmlSerializeIfNeeded} from "../../src/editor/serialize"; -import {createPartCreator} from "./mock"; +import { htmlSerializeIfNeeded } from "../../src/editor/serialize"; +import { createPartCreator } from "./mock"; describe('editor/serialize', function() { it('user pill turns message into html', function() { diff --git a/test/end-to-end-tests/.gitignore b/test/end-to-end-tests/.gitignore index 61f9012393..9180d32e90 100644 --- a/test/end-to-end-tests/.gitignore +++ b/test/end-to-end-tests/.gitignore @@ -1,3 +1,4 @@ node_modules *.png element/env +performance-entries.json diff --git a/test/end-to-end-tests/element/install.sh b/test/end-to-end-tests/element/install.sh index e38f795df1..e1df709c68 100755 --- a/test/end-to-end-tests/element/install.sh +++ b/test/end-to-end-tests/element/install.sh @@ -12,5 +12,5 @@ unzip -q element.zip rm element.zip mv element-web-${ELEMENT_BRANCH} element-web cd element-web -yarn install +yarn install --pure-lockfile yarn run build diff --git a/test/end-to-end-tests/install.sh b/test/end-to-end-tests/install.sh index bbe7a24c9b..b2254a4ea7 100755 --- a/test/end-to-end-tests/install.sh +++ b/test/end-to-end-tests/install.sh @@ -4,4 +4,4 @@ set -e ./synapse/install.sh # local testing doesn't need a Element fetched from master, # so not installing that by default -yarn install +yarn install --pure-lockfile diff --git a/test/end-to-end-tests/package.json b/test/end-to-end-tests/package.json index 8372039258..6f0c12e195 100644 --- a/test/end-to-end-tests/package.json +++ b/test/end-to-end-tests/package.json @@ -11,7 +11,7 @@ "dependencies": { "cheerio": "^1.0.0-rc.2", "commander": "^2.19.0", - "puppeteer": "^1.14.0", + "puppeteer": "10.0.0", "request": "^2.88.0", "request-promise-native": "^1.0.7", "uuid": "^3.3.2" diff --git a/test/end-to-end-tests/src/rest/consent.js b/test/end-to-end-tests/src/rest/consent.js index 956441571b..8be27258d0 100644 --- a/test/end-to-end-tests/src/rest/consent.js +++ b/test/end-to-end-tests/src/rest/consent.js @@ -27,5 +27,5 @@ module.exports.approveConsent = async function(consentUrl) { const h = doc("input[name=h]").val(); const formAction = doc("form").attr("action"); const absAction = url.resolve(consentUrl, formAction); - await request.post(absAction).form({v, u, h}); + await request.post(absAction).form({ v, u, h }); }; diff --git a/test/end-to-end-tests/src/rest/creator.js b/test/end-to-end-tests/src/rest/creator.js index 03b2e099bc..f01a325a71 100644 --- a/test/end-to-end-tests/src/rest/creator.js +++ b/test/end-to-end-tests/src/rest/creator.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -const {exec} = require('child_process'); +const { exec } = require('child_process'); const request = require('request-promise-native'); const RestSession = require('./session'); const RestMultiSession = require('./multi'); @@ -26,7 +26,7 @@ function execAsync(command, options) { if (error) { reject(error); } else { - resolve({stdout, stderr}); + resolve({ stdout, stderr }); } }); }); @@ -67,7 +67,7 @@ module.exports = class RestSessionCreator { registerCmd, ].join(' && '); - await execAsync(allCmds, {cwd: this.cwd, encoding: 'utf-8'}); + await execAsync(allCmds, { cwd: this.cwd, encoding: 'utf-8' }); } async _authenticate(username, password) { @@ -80,7 +80,7 @@ module.exports = class RestSessionCreator { "password": password, }; const url = `${this.hsUrl}/_matrix/client/r0/login`; - const responseBody = await request.post({url, json: true, body: requestBody}); + const responseBody = await request.post({ url, json: true, body: requestBody }); return { accessToken: responseBody.access_token, homeServer: responseBody.home_server, diff --git a/test/end-to-end-tests/src/rest/session.js b/test/end-to-end-tests/src/rest/session.js index 5b97824f5c..3c04592d02 100644 --- a/test/end-to-end-tests/src/rest/session.js +++ b/test/end-to-end-tests/src/rest/session.js @@ -17,7 +17,7 @@ limitations under the License. const request = require('request-promise-native'); const Logger = require('../logger'); const RestRoom = require('./room'); -const {approveConsent} = require('./consent'); +const { approveConsent } = require('./consent'); module.exports = class RestSession { constructor(credentials) { diff --git a/test/end-to-end-tests/src/scenario.js b/test/end-to-end-tests/src/scenario.js index 2191d630ac..c44f209bf3 100644 --- a/test/end-to-end-tests/src/scenario.js +++ b/test/end-to-end-tests/src/scenario.js @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ - -const {range} = require('./util'); +const { range } = require('./util'); const signup = require('./usecases/signup'); const toastScenarios = require('./scenarios/toast'); const roomDirectoryScenarios = require('./scenarios/directory'); diff --git a/test/end-to-end-tests/src/scenarios/directory.js b/test/end-to-end-tests/src/scenarios/directory.js index b5be9ed4f4..53b790c174 100644 --- a/test/end-to-end-tests/src/scenarios/directory.js +++ b/test/end-to-end-tests/src/scenarios/directory.js @@ -15,23 +15,22 @@ See the License for the specific language governing permissions and limitations under the License. */ - const join = require('../usecases/join'); const sendMessage = require('../usecases/send-message'); -const {receiveMessage} = require('../usecases/timeline'); -const {createRoom} = require('../usecases/create-room'); -const {changeRoomSettings} = require('../usecases/room-settings'); +const { receiveMessage } = require('../usecases/timeline'); +const { createRoom } = require('../usecases/create-room'); +const { changeRoomSettings } = require('../usecases/room-settings'); module.exports = async function roomDirectoryScenarios(alice, bob) { console.log(" creating a public room and join through directory:"); const room = 'test'; await createRoom(alice, room); - await changeRoomSettings(alice, {directory: true, visibility: "public_no_guests", alias: "#test"}); + await changeRoomSettings(alice, { directory: true, visibility: "public_no_guests", alias: "#test" }); await join(bob, room); //looks up room in directory const bobMessage = "hi Alice!"; await sendMessage(bob, bobMessage); - await receiveMessage(alice, {sender: "bob", body: bobMessage}); + await receiveMessage(alice, { sender: "bob", body: bobMessage }); const aliceMessage = "hi Bob, welcome!"; await sendMessage(alice, aliceMessage); - await receiveMessage(bob, {sender: "alice", body: aliceMessage}); + await receiveMessage(bob, { sender: "alice", body: aliceMessage }); }; diff --git a/test/end-to-end-tests/src/scenarios/e2e-encryption.js b/test/end-to-end-tests/src/scenarios/e2e-encryption.js index 20e8af2947..23234b85ce 100644 --- a/test/end-to-end-tests/src/scenarios/e2e-encryption.js +++ b/test/end-to-end-tests/src/scenarios/e2e-encryption.js @@ -17,32 +17,35 @@ limitations under the License. const sendMessage = require('../usecases/send-message'); const acceptInvite = require('../usecases/accept-invite'); -const {receiveMessage} = require('../usecases/timeline'); -const {createDm} = require('../usecases/create-room'); -const {checkRoomSettings} = require('../usecases/room-settings'); -const {startSasVerifcation, acceptSasVerification} = require('../usecases/verify'); +const { receiveMessage } = require('../usecases/timeline'); +const { createDm } = require('../usecases/create-room'); +const { checkRoomSettings } = require('../usecases/room-settings'); +const { startSasVerification, acceptSasVerification } = require('../usecases/verify'); const { setupSecureBackup } = require('../usecases/security'); const assert = require('assert'); +const { measureStart, measureStop } = require('../util'); module.exports = async function e2eEncryptionScenarios(alice, bob) { console.log(" creating an e2e encrypted DM and join through invite:"); await createDm(bob, ['@alice:localhost']); - await checkRoomSettings(bob, {encryption: true}); // for sanity, should be e2e-by-default + await checkRoomSettings(bob, { encryption: true }); // for sanity, should be e2e-by-default await acceptInvite(alice, 'bob'); // do sas verifcation bob.log.step(`starts SAS verification with ${alice.username}`); - const bobSasPromise = startSasVerifcation(bob, alice.username); + await measureStart(bob, "mx_VerifyE2EEUser"); + const bobSasPromise = startSasVerification(bob, alice.username); const aliceSasPromise = acceptSasVerification(alice, bob.username); // wait in parallel, so they don't deadlock on each other // the logs get a bit messy here, but that's fine enough for debugging (hopefully) const [bobSas, aliceSas] = await Promise.all([bobSasPromise, aliceSasPromise]); assert.deepEqual(bobSas, aliceSas); + await measureStop(bob, "mx_VerifyE2EEUser"); bob.log.done(`done (match for ${bobSas.join(", ")})`); const aliceMessage = "Guess what I just heard?!"; await sendMessage(alice, aliceMessage); - await receiveMessage(bob, {sender: "alice", body: aliceMessage, encrypted: true}); + await receiveMessage(bob, { sender: "alice", body: aliceMessage, encrypted: true }); const bobMessage = "You've got to tell me!"; await sendMessage(bob, bobMessage); - await receiveMessage(alice, {sender: "bob", body: bobMessage, encrypted: true}); + await receiveMessage(alice, { sender: "bob", body: bobMessage, encrypted: true }); await setupSecureBackup(alice); }; diff --git a/test/end-to-end-tests/src/scenarios/lazy-loading.js b/test/end-to-end-tests/src/scenarios/lazy-loading.js index 6d321dc737..1b5d449af9 100644 --- a/test/end-to-end-tests/src/scenarios/lazy-loading.js +++ b/test/end-to-end-tests/src/scenarios/lazy-loading.js @@ -15,17 +15,16 @@ See the License for the specific language governing permissions and limitations under the License. */ - -const {delay} = require('../util'); +const { delay } = require('../util'); const join = require('../usecases/join'); const sendMessage = require('../usecases/send-message'); const { checkTimelineContains, scrollToTimelineTop, } = require('../usecases/timeline'); -const {createRoom} = require('../usecases/create-room'); -const {getMembersInMemberlist} = require('../usecases/memberlist'); -const {changeRoomSettings} = require('../usecases/room-settings'); +const { createRoom } = require('../usecases/create-room'); +const { getMembersInMemberlist } = require('../usecases/memberlist'); +const { changeRoomSettings } = require('../usecases/room-settings'); const assert = require('assert'); module.exports = async function lazyLoadingScenarios(alice, bob, charlies) { @@ -52,7 +51,7 @@ const charlyMsg2 = "how's it going??"; async function setupRoomWithBobAliceAndCharlies(alice, bob, charlies) { await createRoom(bob, room); - await changeRoomSettings(bob, {directory: true, visibility: "public_no_guests", alias}); + await changeRoomSettings(bob, { directory: true, visibility: "public_no_guests", alias }); // wait for alias to be set by server after clicking "save" // so the charlies can join it. await bob.delay(500); diff --git a/test/end-to-end-tests/src/scenarios/toast.js b/test/end-to-end-tests/src/scenarios/toast.js index 8b23dbcabc..40b480c3fa 100644 --- a/test/end-to-end-tests/src/scenarios/toast.js +++ b/test/end-to-end-tests/src/scenarios/toast.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -const {assertNoToasts, acceptToast, rejectToast} = require("../usecases/toasts"); +const { assertNoToasts, acceptToast, rejectToast } = require("../usecases/toasts"); module.exports = async function toastScenarios(alice, bob) { console.log(" checking and clearing toasts:"); diff --git a/test/end-to-end-tests/src/session.js b/test/end-to-end-tests/src/session.js index 433baa5e48..f5d20fde28 100644 --- a/test/end-to-end-tests/src/session.js +++ b/test/end-to-end-tests/src/session.js @@ -18,7 +18,7 @@ limitations under the License. const puppeteer = require('puppeteer'); const Logger = require('./logger'); const LogBuffer = require('./logbuffer'); -const {delay} = require('./util'); +const { delay } = require('./util'); const DEFAULT_TIMEOUT = 20000; @@ -93,10 +93,10 @@ module.exports = class ElementSession { const type = req.resourceType(); const response = await req.response(); //if (type === 'xhr' || type === 'fetch') { - buffer += `${type} ${response.status()} ${req.method()} ${req.url()} \n`; - // if (req.method() === "POST") { - // buffer += " Post data: " + req.postData(); - // } + buffer += `${type} ${response.status()} ${req.method()} ${req.url()} \n`; + // if (req.method() === "POST") { + // buffer += " Post data: " + req.postData(); + // } //} }); return { @@ -112,7 +112,7 @@ module.exports = class ElementSession { async replaceInputText(input, text) { // click 3 times to select all text - await input.click({clickCount: 3}); + await input.click({ clickCount: 3 }); // waiting here solves not having selected all the text by the 3x click above, // presumably because of the Field label animation. await this.delay(300); @@ -123,7 +123,7 @@ module.exports = class ElementSession { } query(selector, timeout = DEFAULT_TIMEOUT, hidden = false) { - return this.page.waitForSelector(selector, {visible: true, timeout, hidden}); + return this.page.waitForSelector(selector, { visible: true, timeout, hidden }); } async queryAll(selector) { @@ -208,7 +208,7 @@ module.exports = class ElementSession { this.log.done(); } - close() { + async close() { return this.browser.close(); } diff --git a/test/end-to-end-tests/src/usecases/accept-invite.js b/test/end-to-end-tests/src/usecases/accept-invite.js index d7c024ac4b..50d685dc74 100644 --- a/test/end-to-end-tests/src/usecases/accept-invite.js +++ b/test/end-to-end-tests/src/usecases/accept-invite.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -const {findSublist} = require("./create-room"); +const { findSublist } = require("./create-room"); module.exports = async function acceptInvite(session, name) { session.log.step(`accepts "${name}" invite`); @@ -23,9 +23,9 @@ module.exports = async function acceptInvite(session, name) { const invitesHandles = await inviteSublist.$$(".mx_RoomTile_name"); const invitesWithText = await Promise.all(invitesHandles.map(async (inviteHandle) => { const text = await session.innerText(inviteHandle); - return {inviteHandle, text}; + return { inviteHandle, text }; })); - const inviteHandle = invitesWithText.find(({inviteHandle, text}) => { + const inviteHandle = invitesWithText.find(({ inviteHandle, text }) => { return text.trim() === name; }).inviteHandle; diff --git a/test/end-to-end-tests/src/usecases/create-room.js b/test/end-to-end-tests/src/usecases/create-room.js index 3830e3e0da..b644ad0309 100644 --- a/test/end-to-end-tests/src/usecases/create-room.js +++ b/test/end-to-end-tests/src/usecases/create-room.js @@ -15,6 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +const { measureStart, measureStop } = require('../util'); + async function openRoomDirectory(session) { const roomDirectoryButton = await session.query('.mx_LeftPanel_exploreButton'); await roomDirectoryButton.click(); @@ -52,6 +54,8 @@ async function createRoom(session, roomName, encrypted=false) { async function createDm(session, invitees) { session.log.step(`creates DM with ${JSON.stringify(invitees)}`); + await measureStart(session, "mx_CreateDM"); + const dmsSublist = await findSublist(session, "people"); const startChatButton = await dmsSublist.$(".mx_RoomSublist_auxButton"); await startChatButton.click(); @@ -76,6 +80,8 @@ async function createDm(session, invitees) { await session.query('.mx_MessageComposer'); session.log.done(); + + await measureStop(session, "mx_CreateDM"); } -module.exports = {openRoomDirectory, findSublist, createRoom, createDm}; +module.exports = { openRoomDirectory, findSublist, createRoom, createDm }; diff --git a/test/end-to-end-tests/src/usecases/join.js b/test/end-to-end-tests/src/usecases/join.js index 655c0be686..1dff5fc45b 100644 --- a/test/end-to-end-tests/src/usecases/join.js +++ b/test/end-to-end-tests/src/usecases/join.js @@ -15,10 +15,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -const {openRoomDirectory} = require('./create-room'); +const { openRoomDirectory } = require('./create-room'); +const { measureStart, measureStop } = require('../util'); module.exports = async function join(session, roomName) { session.log.step(`joins room "${roomName}"`); + await measureStart(session, "mx_JoinRoom"); await openRoomDirectory(session); const roomInput = await session.query('.mx_DirectorySearchBox input'); await session.replaceInputText(roomInput, roomName); @@ -26,5 +28,6 @@ module.exports = async function join(session, roomName) { const joinFirstLink = await session.query('.mx_RoomDirectory_table .mx_RoomDirectory_join .mx_AccessibleButton'); await joinFirstLink.click(); await session.query('.mx_MessageComposer'); + await measureStop(session, "mx_JoinRoom"); session.log.done(); }; diff --git a/test/end-to-end-tests/src/usecases/memberlist.js b/test/end-to-end-tests/src/usecases/memberlist.js index ed7f0e389b..26c0c39755 100644 --- a/test/end-to-end-tests/src/usecases/memberlist.js +++ b/test/end-to-end-tests/src/usecases/memberlist.js @@ -16,7 +16,7 @@ limitations under the License. */ const assert = require('assert'); -const {openRoomSummaryCard} = require("./rightpanel"); +const { openRoomSummaryCard } = require("./rightpanel"); async function openMemberInfo(session, name) { const membersAndNames = await getMembersInMemberlist(session); @@ -49,7 +49,6 @@ module.exports.verifyDeviceForUser = async function(session, name, expectedDevic const sasLabels = await Promise.all(sasLabelElements.map(e => session.innerText(e))); console.log("my sas labels", sasLabels); - const dialogCodeFields = await session.queryAll(".mx_QuestionDialog code"); assert.equal(dialogCodeFields.length, 2); const deviceId = await session.innerText(dialogCodeFields[0]); @@ -71,7 +70,7 @@ async function getMembersInMemberlist(session) { const memberNameElements = await session.queryAll(".mx_MemberList .mx_EntityTile_name"); return Promise.all(memberNameElements.map(async (el) => { - return {label: el, displayName: await session.innerText(el)}; + return { label: el, displayName: await session.innerText(el) }; })); } diff --git a/test/end-to-end-tests/src/usecases/rightpanel.js b/test/end-to-end-tests/src/usecases/rightpanel.js index ae6bb2c771..00a8cde5a6 100644 --- a/test/end-to-end-tests/src/usecases/rightpanel.js +++ b/test/end-to-end-tests/src/usecases/rightpanel.js @@ -32,7 +32,11 @@ module.exports.goBackToRoomSummaryCard = async function(session) { // Sometimes our tests have this opened to MemberInfo await backButton.click(); } catch (e) { - break; // stop trying to go further back + // explicitly check for TimeoutError as this sometimes returned + // `Error: Node is detached from document` due to a re-render race or similar + if (e.name === "TimeoutError") { + break; // stop trying to go further back + } } } }; diff --git a/test/end-to-end-tests/src/usecases/room-settings.js b/test/end-to-end-tests/src/usecases/room-settings.js index abd4488db2..b40afe76bf 100644 --- a/test/end-to-end-tests/src/usecases/room-settings.js +++ b/test/end-to-end-tests/src/usecases/room-settings.js @@ -16,8 +16,8 @@ limitations under the License. */ const assert = require('assert'); -const {openRoomSummaryCard} = require("./rightpanel"); -const {acceptDialog} = require('./dialog'); +const { openRoomSummaryCard } = require("./rightpanel"); +const { acceptDialog } = require('./dialog'); async function setSettingsToggle(session, toggle, enabled) { const className = await session.getElementProperty(toggle, "className"); @@ -57,13 +57,13 @@ async function findTabs(session) { const tabLabels = await Promise.all(tabButtons.map(t => session.innerText(t))); const securityTabButton = tabButtons[tabLabels.findIndex(l => l.toLowerCase().includes("security"))]; - return {securityTabButton}; + return { securityTabButton }; } async function checkRoomSettings(session, expectedSettings) { session.log.startGroup(`checks the room settings`); - const {securityTabButton} = await findTabs(session); + const { securityTabButton } = await findTabs(session); const generalSwitches = await session.queryAll(".mx_RoomSettingsDialog .mx_ToggleSwitch"); const isDirectory = generalSwitches[0]; @@ -129,7 +129,7 @@ async function checkRoomSettings(session, expectedSettings) { async function changeRoomSettings(session, settings) { session.log.startGroup(`changes the room settings`); - const {securityTabButton} = await findTabs(session); + const { securityTabButton } = await findTabs(session); const generalSwitches = await session.queryAll(".mx_RoomSettingsDialog .mx_ToggleSwitch"); const isDirectory = generalSwitches[0]; @@ -140,8 +140,6 @@ async function changeRoomSettings(session, settings) { if (settings.alias) { session.log.step(`sets alias to ${settings.alias}`); - const summary = await session.query(".mx_RoomSettingsDialog .mx_AliasSettings summary"); - await summary.click(); const aliasField = await session.query(".mx_RoomSettingsDialog .mx_AliasSettings details input[type=text]"); await session.replaceInputText(aliasField, settings.alias.substring(1, settings.alias.lastIndexOf(":"))); const addButton = await session.query(".mx_RoomSettingsDialog .mx_AliasSettings details .mx_AccessibleButton"); @@ -190,4 +188,4 @@ async function changeRoomSettings(session, settings) { session.log.endGroup(); } -module.exports = {checkRoomSettings, changeRoomSettings}; +module.exports = { checkRoomSettings, changeRoomSettings }; diff --git a/test/end-to-end-tests/src/usecases/settings.js b/test/end-to-end-tests/src/usecases/settings.js index 52e4bb7e0a..509e0b4a07 100644 --- a/test/end-to-end-tests/src/usecases/settings.js +++ b/test/end-to-end-tests/src/usecases/settings.js @@ -51,5 +51,5 @@ module.exports.getE2EDeviceFromSettings = async function(session) { const closeButton = await session.query(".mx_UserSettingsDialog .mx_Dialog_cancelButton"); await closeButton.click(); session.log.done(); - return {id, key}; + return { id, key }; }; diff --git a/test/end-to-end-tests/src/usecases/timeline.js b/test/end-to-end-tests/src/usecases/timeline.js index 3889dce108..f9d7300ff1 100644 --- a/test/end-to-end-tests/src/usecases/timeline.js +++ b/test/end-to-end-tests/src/usecases/timeline.js @@ -69,7 +69,6 @@ module.exports.receiveMessage = async function(session, expectedMessage) { session.log.done(); }; - module.exports.checkTimelineContains = async function(session, expectedMessages, sendersDescription) { session.log.step(`checks timeline contains ${expectedMessages.length} ` + `given messages${sendersDescription ? ` from ${sendersDescription}`:""}`); @@ -122,7 +121,7 @@ function getAllEventTiles(session) { } async function getMessageFromEventTile(eventTile) { - const senderElement = await eventTile.$(".mx_SenderProfile_name"); + const senderElement = await eventTile.$(".mx_SenderProfile_displayName"); const className = await (await eventTile.getProperty("className")).jsonValue(); const classNames = className.split(" "); const bodyElement = await eventTile.$(".mx_EventTile_body"); diff --git a/test/end-to-end-tests/src/usecases/toasts.js b/test/end-to-end-tests/src/usecases/toasts.js index db78352f2b..8650ff1400 100644 --- a/test/end-to-end-tests/src/usecases/toasts.js +++ b/test/end-to-end-tests/src/usecases/toasts.js @@ -40,8 +40,8 @@ async function acceptToast(session, expectedTitle) { async function rejectToast(session, expectedTitle) { await assertToast(session, expectedTitle); - const btn = await session.query('.mx_Toast_buttons .mx_AccessibleButton_kind_danger'); + const btn = await session.query('.mx_Toast_buttons .mx_AccessibleButton_kind_danger_outline'); await btn.click(); } -module.exports = {assertNoToasts, assertToast, acceptToast, rejectToast}; +module.exports = { assertNoToasts, assertToast, acceptToast, rejectToast }; diff --git a/test/end-to-end-tests/src/usecases/verify.js b/test/end-to-end-tests/src/usecases/verify.js index ea5b9961a4..a1f600833d 100644 --- a/test/end-to-end-tests/src/usecases/verify.js +++ b/test/end-to-end-tests/src/usecases/verify.js @@ -16,7 +16,7 @@ limitations under the License. */ const assert = require('assert'); -const {openMemberInfo} = require("./memberlist"); +const { openMemberInfo } = require("./memberlist"); async function startVerification(session, name) { session.log.step("opens their opponent's profile and starts verification"); @@ -74,7 +74,7 @@ async function doSasVerification(session) { return sasCodes; } -module.exports.startSasVerifcation = async function(session, name) { +module.exports.startSasVerification = async function(session, name) { session.log.startGroup("starts verification"); await startVerification(session, name); diff --git a/test/end-to-end-tests/src/util.js b/test/end-to-end-tests/src/util.js index cc7391fa9f..5abb110df4 100644 --- a/test/end-to-end-tests/src/util.js +++ b/test/end-to-end-tests/src/util.js @@ -26,3 +26,15 @@ module.exports.range = function(start, amount, step = 1) { module.exports.delay = function(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }; + +module.exports.measureStart = function(session, name) { + return session.page.evaluate(_name => { + window.mxPerformanceMonitor.start(_name); + }, name); +}; + +module.exports.measureStop = function(session, name) { + return session.page.evaluate(_name => { + window.mxPerformanceMonitor.stop(_name); + }, name); +}; diff --git a/test/end-to-end-tests/start.js b/test/end-to-end-tests/start.js index 234d60da9f..f65d6137f4 100644 --- a/test/end-to-end-tests/start.js +++ b/test/end-to-end-tests/start.js @@ -79,8 +79,34 @@ async function runTests() { await new Promise((resolve) => setTimeout(resolve, 5 * 60 * 1000)); } - await Promise.all(sessions.map((session) => session.close())); + let performanceEntries; + await Promise.all(sessions.map(async (session) => { + // Collecting all performance monitoring data before closing the session + const measurements = await session.page.evaluate(() => { + let measurements = []; + window.mxPerformanceMonitor.addPerformanceDataCallback({ + entryNames: [ + window.mxPerformanceEntryNames.REGISTER, + window.mxPerformanceEntryNames.LOGIN, + window.mxPerformanceEntryNames.JOIN_ROOM, + window.mxPerformanceEntryNames.CREATE_DM, + window.mxPerformanceEntryNames.VERIFY_E2EE_USER, + ], + callback: (events) => { + measurements = JSON.stringify(events); + }, + }, true); + return measurements; + }); + + /** + * TODO: temporary only use one user session data + */ + performanceEntries = JSON.parse(measurements); + return session.close(); + })); + fs.writeFileSync(`performance-entries.json`, JSON.stringify(performanceEntries)); if (failure) { process.exit(-1); } else { @@ -107,7 +133,7 @@ async function writeLogs(sessions, dir) { fs.writeFileSync(appHtmlName, documentHtml); fs.writeFileSync(networkLogName, session.networkLogs()); fs.writeFileSync(consoleLogName, session.consoleLogs()); - await session.page.screenshot({path: `${userLogDir}/screenshot.png`}); + await session.page.screenshot({ path: `${userLogDir}/screenshot.png` }); } return logs; } diff --git a/test/end-to-end-tests/yarn.lock b/test/end-to-end-tests/yarn.lock index 7f2cefb92e..985c883ad6 100644 --- a/test/end-to-end-tests/yarn.lock +++ b/test/end-to-end-tests/yarn.lock @@ -7,12 +7,19 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-11.12.1.tgz#d90123f6c61fdf2f7cddd286ddae891586dd3488" integrity sha512-sKDlqv6COJrR7ar0+GqqhrXQDzQlMcqMnF2iEU6m9hLo8kxozoAGUazwPyELHlRVmjsbvlnGXjnzyptSXVmceA== -agent-base@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" - integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== +"@types/yauzl@^2.9.1": + version "2.9.1" + resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.9.1.tgz#d10f69f9f522eef3cf98e30afb684a1e1ec923af" + integrity sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA== dependencies: - es6-promisify "^5.0.0" + "@types/node" "*" + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" ajv@^6.5.5: version "6.10.0" @@ -36,11 +43,6 @@ assert-plus@1.0.0, assert-plus@^1.0.0: resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= -async-limiter@~1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" - integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== - asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -61,6 +63,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + bcrypt-pbkdf@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" @@ -68,6 +75,15 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + boolbase@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -86,10 +102,13 @@ buffer-crc32@~0.2.3: resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= -buffer-from@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" - integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== +buffer@^5.2.1, buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" caseless@~0.12.0: version "0.12.0" @@ -108,6 +127,11 @@ cheerio@^1.0.0-rc.2: lodash "^4.15.0" parse5 "^3.0.1" +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828" @@ -125,17 +149,7 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@^1.6.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" - integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== - dependencies: - buffer-from "^1.0.0" - inherits "^2.0.3" - readable-stream "^2.2.2" - typedarray "^0.0.6" - -core-util-is@1.0.2, core-util-is@~1.0.0: +core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= @@ -162,21 +176,7 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" -debug@^2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@^3.1.0: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - -debug@^4.1.0: +debug@4, debug@4.3.1, debug@^4.1.1: version "4.3.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== @@ -188,6 +188,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= +devtools-protocol@0.0.883894: + version "0.0.883894" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.883894.tgz#d403f2c75cd6d71c916aee8dde9258da988a4da9" + integrity sha512-33idhm54QJzf3Q7QofMgCvIVSd2o9H3kQPWaKT/fhoZh+digc+WSiMhbkeG3iN79WY4Hwr9G05NpbhEVrsOYAg== + dom-serializer@0, dom-serializer@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" @@ -232,37 +237,33 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + entities@^1.1.1, entities@~1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== -es6-promise@^4.0.3: - version "4.2.8" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" - integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== - -es6-promisify@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" - integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM= - dependencies: - es6-promise "^4.0.3" - extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -extract-zip@^1.6.6: - version "1.7.0" - resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" - integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA== +extract-zip@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" + integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== dependencies: - concat-stream "^1.6.2" - debug "^2.6.9" - mkdirp "^0.5.4" + debug "^4.1.1" + get-stream "^5.1.0" yauzl "^2.10.0" + optionalDependencies: + "@types/yauzl" "^2.9.1" extsprintf@1.3.0: version "1.3.0" @@ -291,6 +292,14 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" +find-up@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" @@ -305,11 +314,23 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + getpass@^0.1.1: version "0.1.7" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" @@ -363,13 +384,18 @@ http-signature@~1.2.0: jsprim "^1.2.2" sshpk "^1.7.0" -https-proxy-agent@^2.2.1: - version "2.2.4" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b" - integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg== +https-proxy-agent@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== dependencies: - agent-base "^4.3.0" - debug "^3.1.0" + agent-base "6" + debug "4" + +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== inflight@^1.0.4: version "1.0.6" @@ -379,7 +405,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@~2.0.3: +inherits@2, inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -394,11 +420,6 @@ is-typedarray@~1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= -isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= - isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -434,10 +455,17 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + lodash@^4.15.0, lodash@^4.17.11: - version "4.17.19" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" - integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== mime-db@~1.38.0: version "1.38.0" @@ -451,11 +479,6 @@ mime-types@^2.1.12, mime-types@~2.1.19: dependencies: mime-db "~1.38.0" -mime@^2.0.3: - version "2.5.2" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" - integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== - minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" @@ -468,27 +491,22 @@ minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== -mkdirp@^0.5.4: +mkdirp@^0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== dependencies: minimist "^1.2.5" -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +node-fetch@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== nth-check@~1.0.1: version "1.0.2" @@ -502,13 +520,32 @@ oauth-sign@~0.9.0: resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== -once@^1.3.0: +once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= dependencies: wrappy "1" +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + parse5@^3.0.1: version "3.0.3" resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.3.tgz#042f792ffdd36851551cf4e9e066b3874ab45b5c" @@ -516,6 +553,11 @@ parse5@^3.0.1: dependencies: "@types/node" "*" +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -531,17 +573,19 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= -process-nextick-args@~2.0.0: +pkg-dir@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +progress@2.0.1: version "2.0.1" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" - integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.1.tgz#c9242169342b1c29d275889c95734621b1952e31" + integrity sha512-OE+a6vzqazc+K6LxJrX5UPyKFvGnL5CYmq2jFGNIBWHpc4QyE49/YOumcrpQFJpfejmvRtbJzgO1zPmMCqlbBg== -progress@^2.0.1: - version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== - -proxy-from-env@^1.0.0: +proxy-from-env@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== @@ -551,6 +595,14 @@ psl@^1.1.24, psl@^1.1.28: resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184" integrity sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw== +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" @@ -561,38 +613,29 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -puppeteer@^1.14.0: - version "1.20.0" - resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-1.20.0.tgz#e3d267786f74e1d87cf2d15acc59177f471bbe38" - integrity sha512-bt48RDBy2eIwZPrkgbcwHtb51mj2nKvHOPMaSH2IsWiv7lOG9k9zhaRzpDZafrk05ajMc3cu+lSQYYOfH2DkVQ== +puppeteer@10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-10.0.0.tgz#1b597c956103e2d989ca17f41ba4693b20a3640c" + integrity sha512-AxHvCb9IWmmP3gMW+epxdj92Gglii+6Z4sb+W+zc2hTTu10HF0yg6hGXot5O74uYkVqG3lfDRLfnRpi6WOwi5A== dependencies: - debug "^4.1.0" - extract-zip "^1.6.6" - https-proxy-agent "^2.2.1" - mime "^2.0.3" - progress "^2.0.1" - proxy-from-env "^1.0.0" - rimraf "^2.6.1" - ws "^6.1.0" + debug "4.3.1" + devtools-protocol "0.0.883894" + extract-zip "2.0.1" + https-proxy-agent "5.0.0" + node-fetch "2.6.1" + pkg-dir "4.2.0" + progress "2.0.1" + proxy-from-env "1.1.0" + rimraf "3.0.2" + tar-fs "2.0.0" + unbzip2-stream "1.3.3" + ws "7.4.6" qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== -readable-stream@^2.2.2: - version "2.3.7" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" - integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - readable-stream@^3.1.1: version "3.2.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.2.0.tgz#de17f229864c120a9f56945756e4f32c4045245d" @@ -602,6 +645,15 @@ readable-stream@^3.1.1: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^3.4.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + request-promise-core@1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.2.tgz#339f6aababcafdb31c799ff158700336301d3346" @@ -644,14 +696,14 @@ request@^2.88.0: tunnel-agent "^0.6.0" uuid "^3.3.2" -rimraf@^2.6.1: - version "2.7.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== +rimraf@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== dependencies: glob "^7.1.3" -safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: +safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== @@ -688,12 +740,31 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.1.0" -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== +tar-fs@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.0.0.tgz#677700fc0c8b337a78bee3623fdc235f21d7afad" + integrity sha512-vaY0obB6Om/fso8a8vakQBzwholQ7v5+uy+tF3Ozvxv1KNezmVQAiWtcNmMHFSFPqL3dJA8ha6gdtFbfX9mcxA== dependencies: - safe-buffer "~5.1.0" + chownr "^1.1.1" + mkdirp "^0.5.1" + pump "^3.0.0" + tar-stream "^2.0.0" + +tar-stream@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +through@^2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= tough-cookie@^2.3.3: version "2.5.0" @@ -723,10 +794,13 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0: resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= -typedarray@^0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" - integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +unbzip2-stream@1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.3.3.tgz#d156d205e670d8d8c393e1c02ebd506422873f6a" + integrity sha512-fUlAF7U9Ah1Q6EieQ4x4zLNejrRvDWUYmxXUpN3uziFYCHapjWFaCAnreY9bGgxzaMCFAPPpYNng57CypwJVhg== + dependencies: + buffer "^5.2.1" + through "^2.3.8" uri-js@^4.2.2: version "4.2.2" @@ -735,7 +809,7 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -util-deprecate@^1.0.1, util-deprecate@~1.0.1: +util-deprecate@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= @@ -759,12 +833,10 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= -ws@^6.1.0: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" - integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== - dependencies: - async-limiter "~1.0.0" +ws@7.4.6: + version "7.4.6" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" + integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== yauzl@^2.10.0: version "2.10.0" diff --git a/test/mock-clock.js b/test/mock-clock.js deleted file mode 100644 index 1a4d6086de..0000000000 --- a/test/mock-clock.js +++ /dev/null @@ -1,421 +0,0 @@ -/* -Copyright (c) 2008-2015 Pivotal Labs -Copyright 2019 The Matrix.org Foundation C.I.C. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -/* This is jasmine's implementation of a mock clock, lifted from the depths of - * jasmine-core and exposed as a standalone module. The interface is just the - * same as that of jasmine.clock. For example: - * - * var mock_clock = require("../../mock-clock").clock(); - * mock_clock.install(); - * setTimeout(function() { - * timerCallback(); - * }, 100); - * - * expect(timerCallback).not.toHaveBeenCalled(); - * mock_clock.tick(101); - * expect(timerCallback).toHaveBeenCalled(); - * - * mock_clock.uninstall(); - * - * - * The reason for C&Ping jasmine's clock here is that jasmine itself is - * difficult to webpack, and we don't really want all of it. Sinon also has a - * mock-clock implementation, but again, it is difficult to webpack. - */ - -const j$ = {}; - -j$.Clock = function() { - function Clock(global, delayedFunctionSchedulerFactory, mockDate) { - let self = this, - realTimingFunctions = { - setTimeout: global.setTimeout, - clearTimeout: global.clearTimeout, - setInterval: global.setInterval, - clearInterval: global.clearInterval, - }, - fakeTimingFunctions = { - setTimeout: setTimeout, - clearTimeout: clearTimeout, - setInterval: setInterval, - clearInterval: clearInterval, - }, - installed = false, - delayedFunctionScheduler, - timer; - - - self.install = function() { - if(!originalTimingFunctionsIntact()) { - throw new Error('Jasmine Clock was unable to install over custom global timer functions. Is the clock already installed?'); - } - replace(global, fakeTimingFunctions); - timer = fakeTimingFunctions; - delayedFunctionScheduler = delayedFunctionSchedulerFactory(); - installed = true; - - return self; - }; - - self.uninstall = function() { - delayedFunctionScheduler = null; - mockDate.uninstall(); - replace(global, realTimingFunctions); - - timer = realTimingFunctions; - installed = false; - }; - - self.withMock = function(closure) { - this.install(); - try { - closure(); - } finally { - this.uninstall(); - } - }; - - self.mockDate = function(initialDate) { - mockDate.install(initialDate); - }; - - self.setTimeout = function(fn, delay, params) { - if (legacyIE()) { - if (arguments.length > 2) { - throw new Error('IE < 9 cannot support extra params to setTimeout without a polyfill'); - } - return timer.setTimeout(fn, delay); - } - return Function.prototype.apply.apply(timer.setTimeout, [global, arguments]); - }; - - self.setInterval = function(fn, delay, params) { - if (legacyIE()) { - if (arguments.length > 2) { - throw new Error('IE < 9 cannot support extra params to setInterval without a polyfill'); - } - return timer.setInterval(fn, delay); - } - return Function.prototype.apply.apply(timer.setInterval, [global, arguments]); - }; - - self.clearTimeout = function(id) { - return Function.prototype.call.apply(timer.clearTimeout, [global, id]); - }; - - self.clearInterval = function(id) { - return Function.prototype.call.apply(timer.clearInterval, [global, id]); - }; - - self.tick = function(millis) { - if (installed) { - mockDate.tick(millis); - delayedFunctionScheduler.tick(millis); - } else { - throw new Error('Mock clock is not installed, use jasmine.clock().install()'); - } - }; - - return self; - - function originalTimingFunctionsIntact() { - return global.setTimeout === realTimingFunctions.setTimeout && - global.clearTimeout === realTimingFunctions.clearTimeout && - global.setInterval === realTimingFunctions.setInterval && - global.clearInterval === realTimingFunctions.clearInterval; - } - - function legacyIE() { - //if these methods are polyfilled, apply will be present - return !(realTimingFunctions.setTimeout || realTimingFunctions.setInterval).apply; - } - - function replace(dest, source) { - for (const prop in source) { - dest[prop] = source[prop]; - } - } - - function setTimeout(fn, delay) { - return delayedFunctionScheduler.scheduleFunction(fn, delay, argSlice(arguments, 2)); - } - - function clearTimeout(id) { - return delayedFunctionScheduler.removeFunctionWithId(id); - } - - function setInterval(fn, interval) { - return delayedFunctionScheduler.scheduleFunction(fn, interval, argSlice(arguments, 2), true); - } - - function clearInterval(id) { - return delayedFunctionScheduler.removeFunctionWithId(id); - } - - function argSlice(argsObj, n) { - return Array.prototype.slice.call(argsObj, n); - } - } - - return Clock; -}(); - - -j$.DelayedFunctionScheduler = function() { - function DelayedFunctionScheduler() { - const self = this; - const scheduledLookup = []; - const scheduledFunctions = {}; - let currentTime = 0; - let delayedFnCount = 0; - - self.tick = function(millis) { - millis = millis || 0; - const endTime = currentTime + millis; - - runScheduledFunctions(endTime); - currentTime = endTime; - }; - - self.scheduleFunction = function(funcToCall, millis, params, recurring, timeoutKey, runAtMillis) { - let f; - if (typeof(funcToCall) === 'string') { - /* jshint evil: true */ - f = function() { return eval(funcToCall); }; - /* jshint evil: false */ - } else { - f = funcToCall; - } - - millis = millis || 0; - timeoutKey = timeoutKey || ++delayedFnCount; - runAtMillis = runAtMillis || (currentTime + millis); - - const funcToSchedule = { - runAtMillis: runAtMillis, - funcToCall: f, - recurring: recurring, - params: params, - timeoutKey: timeoutKey, - millis: millis, - }; - - if (runAtMillis in scheduledFunctions) { - scheduledFunctions[runAtMillis].push(funcToSchedule); - } else { - scheduledFunctions[runAtMillis] = [funcToSchedule]; - scheduledLookup.push(runAtMillis); - scheduledLookup.sort(function(a, b) { - return a - b; - }); - } - - return timeoutKey; - }; - - self.removeFunctionWithId = function(timeoutKey) { - for (const runAtMillis in scheduledFunctions) { - const funcs = scheduledFunctions[runAtMillis]; - const i = indexOfFirstToPass(funcs, function(func) { - return func.timeoutKey === timeoutKey; - }); - - if (i > -1) { - if (funcs.length === 1) { - delete scheduledFunctions[runAtMillis]; - deleteFromLookup(runAtMillis); - } else { - funcs.splice(i, 1); - } - - // intervals get rescheduled when executed, so there's never more - // than a single scheduled function with a given timeoutKey - break; - } - } - }; - - return self; - - function indexOfFirstToPass(array, testFn) { - let index = -1; - - for (let i = 0; i < array.length; ++i) { - if (testFn(array[i])) { - index = i; - break; - } - } - - return index; - } - - function deleteFromLookup(key) { - const value = Number(key); - const i = indexOfFirstToPass(scheduledLookup, function(millis) { - return millis === value; - }); - - if (i > -1) { - scheduledLookup.splice(i, 1); - } - } - - function reschedule(scheduledFn) { - self.scheduleFunction(scheduledFn.funcToCall, - scheduledFn.millis, - scheduledFn.params, - true, - scheduledFn.timeoutKey, - scheduledFn.runAtMillis + scheduledFn.millis); - } - - function forEachFunction(funcsToRun, callback) { - for (let i = 0; i < funcsToRun.length; ++i) { - callback(funcsToRun[i]); - } - } - - function runScheduledFunctions(endTime) { - if (scheduledLookup.length === 0 || scheduledLookup[0] > endTime) { - return; - } - - do { - currentTime = scheduledLookup.shift(); - - const funcsToRun = scheduledFunctions[currentTime]; - delete scheduledFunctions[currentTime]; - - forEachFunction(funcsToRun, function(funcToRun) { - if (funcToRun.recurring) { - reschedule(funcToRun); - } - }); - - forEachFunction(funcsToRun, function(funcToRun) { - funcToRun.funcToCall.apply(null, funcToRun.params || []); - }); - } while (scheduledLookup.length > 0 && - // checking first if we're out of time prevents setTimeout(0) - // scheduled in a funcToRun from forcing an extra iteration - currentTime !== endTime && - scheduledLookup[0] <= endTime); - } - } - - return DelayedFunctionScheduler; -}(); - - -j$.MockDate = function() { - function MockDate(global) { - const self = this; - let currentTime = 0; - - if (!global || !global.Date) { - self.install = function() {}; - self.tick = function() {}; - self.uninstall = function() {}; - return self; - } - - const GlobalDate = global.Date; - - self.install = function(mockDate) { - if (mockDate instanceof GlobalDate) { - currentTime = mockDate.getTime(); - } else { - currentTime = new GlobalDate().getTime(); - } - - global.Date = FakeDate; - }; - - self.tick = function(millis) { - millis = millis || 0; - currentTime = currentTime + millis; - }; - - self.uninstall = function() { - currentTime = 0; - global.Date = GlobalDate; - }; - - createDateProperties(); - - return self; - - function FakeDate() { - switch(arguments.length) { - case 0: - return new GlobalDate(currentTime); - case 1: - return new GlobalDate(arguments[0]); - case 2: - return new GlobalDate(arguments[0], arguments[1]); - case 3: - return new GlobalDate(arguments[0], arguments[1], arguments[2]); - case 4: - return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3]); - case 5: - return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3], - arguments[4]); - case 6: - return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3], - arguments[4], arguments[5]); - default: - return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3], - arguments[4], arguments[5], arguments[6]); - } - } - - function createDateProperties() { - FakeDate.prototype = GlobalDate.prototype; - - FakeDate.now = function() { - if (GlobalDate.now) { - return currentTime; - } else { - throw new Error('Browser does not support Date.now()'); - } - }; - - FakeDate.toSource = GlobalDate.toSource; - FakeDate.toString = GlobalDate.toString; - FakeDate.parse = GlobalDate.parse; - FakeDate.UTC = GlobalDate.UTC; - } - } - - return MockDate; -}(); - -const _clock = new j$.Clock(global, function() { return new j$.DelayedFunctionScheduler(); }, new j$.MockDate(global)); - -export function clock() { - return _clock; -} - - diff --git a/test/notifications/ContentRules-test.js b/test/notifications/ContentRules-test.js index ca25edf5b1..9c21c05da7 100644 --- a/test/notifications/ContentRules-test.js +++ b/test/notifications/ContentRules-test.js @@ -52,13 +52,12 @@ const USERNAME_RULE = { rule_id: ".m.rule.contains_user_name", }; - describe("ContentRules", function() { describe("parseContentRules", function() { it("should handle there being no keyword rules", function() { const rules = { 'global': { 'content': [ USERNAME_RULE, - ]}}; + ] } }; const parsed = ContentRules.parseContentRules(rules); expect(parsed.rules).toEqual([]); expect(parsed.vectorState).toEqual(PushRuleVectorState.ON); @@ -69,7 +68,7 @@ describe("ContentRules", function() { const rules = { 'global': { 'content': [ NORMAL_RULE, USERNAME_RULE, - ]}}; + ] } }; const parsed = ContentRules.parseContentRules(rules); expect(parsed.rules.length).toEqual(1); @@ -82,7 +81,7 @@ describe("ContentRules", function() { const rules = { 'global': { 'content': [ LOUD_RULE, USERNAME_RULE, - ]}}; + ] } }; const parsed = ContentRules.parseContentRules(rules); expect(parsed.rules.length).toEqual(1); @@ -96,7 +95,7 @@ describe("ContentRules", function() { LOUD_RULE, NORMAL_RULE, USERNAME_RULE, - ]}}; + ] } }; const parsed = ContentRules.parseContentRules(rules); expect(parsed.rules.length).toEqual(1); diff --git a/test/setupTests.js b/test/setupTests.js index 6d37d48987..9da412a7e8 100644 --- a/test/setupTests.js +++ b/test/setupTests.js @@ -1,6 +1,12 @@ import * as languageHandler from "../src/languageHandler"; +import { TextEncoder, TextDecoder } from 'util'; languageHandler.setLanguage('en'); languageHandler.setMissingEntryGenerator(key => key.split("|", 2)[1]); require('jest-fetch-mock').enableMocks(); + +// polyfilling TextEncoder as it is not available on JSDOM +// view https://github.com/facebook/jest/issues/9983 +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder; diff --git a/test/skinned-sdk.js b/test/skinned-sdk.js index 876a188cc0..9de13d20a1 100644 --- a/test/skinned-sdk.js +++ b/test/skinned-sdk.js @@ -18,12 +18,12 @@ components['structures.RightPanel'] = stubComponent(); components['structures.RoomDirectory'] = stubComponent(); components['views.globals.GuestWarningBar'] = stubComponent(); components['views.globals.NewVersionBar'] = stubComponent(); -components['views.elements.Spinner'] = stubComponent({displayName: 'Spinner'}); -components['views.messages.DateSeparator'] = stubComponent({displayName: 'DateSeparator'}); -components['views.messages.MessageTimestamp'] = stubComponent({displayName: 'MessageTimestamp'}); -components['views.messages.SenderProfile'] = stubComponent({displayName: 'SenderProfile'}); +components['views.elements.Spinner'] = stubComponent({ displayName: 'Spinner' }); +components['views.messages.DateSeparator'] = stubComponent({ displayName: 'DateSeparator' }); +components['views.messages.MessageTimestamp'] = stubComponent({ displayName: 'MessageTimestamp' }); +components['views.messages.SenderProfile'] = stubComponent({ displayName: 'SenderProfile' }); components['views.rooms.SearchBar'] = stubComponent(); -sdk.loadSkin({components}); +sdk.loadSkin({ components }); export default sdk; diff --git a/test/stores/RoomViewStore-test.js b/test/stores/RoomViewStore-test.js index 41252103e7..cb5d7d21f0 100644 --- a/test/stores/RoomViewStore-test.js +++ b/test/stores/RoomViewStore-test.js @@ -1,6 +1,6 @@ import RoomViewStore from '../../src/stores/RoomViewStore'; -import {MatrixClientPeg as peg} from '../../src/MatrixClientPeg'; +import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg'; import * as testUtils from '../test-utils'; @@ -36,7 +36,7 @@ describe('RoomViewStore', function() { } }); - peg.get().getRoomIdForAlias.mockResolvedValue({room_id: "!randomcharacters:aser.ver"}); + peg.get().getRoomIdForAlias.mockResolvedValue({ room_id: "!randomcharacters:aser.ver" }); peg.get().joinRoom = async (roomAddress) => { token.remove(); // stop RVS listener expect(roomAddress).toBe("#somealias2:aser.ver"); diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index aef788647d..4cbd9f43c8 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -21,7 +21,7 @@ import "../skinned-sdk"; // Must be first for skinning to work import SpaceStore, { UPDATE_INVITED_SPACES, UPDATE_SELECTED_SPACE, - UPDATE_TOP_LEVEL_SPACES + UPDATE_TOP_LEVEL_SPACES, } from "../../src/stores/SpaceStore"; import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../utils/test-utils"; import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; @@ -101,6 +101,7 @@ const invite1 = "!invite1:server"; const invite2 = "!invite2:server"; const room1 = "!room1:server"; const room2 = "!room2:server"; +const room3 = "!room3:server"; const space1 = "!space1:server"; const space2 = "!space2:server"; const space3 = "!space3:server"; @@ -122,8 +123,15 @@ describe("SpaceStore", () => { jest.runAllTimers(); client.getVisibleRooms.mockReturnValue(rooms = []); getValue.mockImplementation(settingName => { - if (settingName === "feature_spaces") { - return true; + switch (settingName) { + case "feature_spaces": + return true; + case "feature_spaces.all_rooms": + return true; + case "feature_spaces.space_member_dms": + return true; + case "feature_spaces.space_dm_badges": + return false; } }); }); @@ -361,8 +369,8 @@ describe("SpaceStore", () => { expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy(); }); - it("home space does not contain rooms/low priority from rooms within spaces", () => { - expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeFalsy(); + it("home space does contain rooms/low priority even if they are also shown in a space", () => { + expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeTruthy(); }); it("space contains child rooms", () => { @@ -614,8 +622,8 @@ describe("SpaceStore", () => { describe("space auto switching tests", () => { beforeEach(async () => { - [room1, room2, orphan1].forEach(mkRoom); - mkSpace(space1, [room1, room2]); + [room1, room2, room3, orphan1].forEach(mkRoom); + mkSpace(space1, [room1, room2, room3]); mkSpace(space2, [room1, room2]); client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([ @@ -641,15 +649,15 @@ describe("SpaceStore", () => { it("switch to canonical parent space for room", async () => { viewRoom(room1); - await store.setActiveSpace(null, false); + await store.setActiveSpace(client.getRoom(space2), false); viewRoom(room2); expect(store.activeSpace).toBe(client.getRoom(space2)); }); it("switch to first containing space for room", async () => { viewRoom(room2); - await store.setActiveSpace(null, false); - viewRoom(room1); + await store.setActiveSpace(client.getRoom(space2), false); + viewRoom(room3); expect(store.activeSpace).toBe(client.getRoom(space1)); }); @@ -659,6 +667,13 @@ describe("SpaceStore", () => { viewRoom(orphan1); expect(store.activeSpace).toBeNull(); }); + + it("when switching rooms in the all rooms home space don't switch to related space", async () => { + viewRoom(room2); + await store.setActiveSpace(null, false); + viewRoom(room1); + expect(store.activeSpace).toBeNull(); + }); }); describe("traverseSpace", () => { diff --git a/test/test-utils.js b/test/test-utils.js index 6dc02463a5..ad56522965 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -1,11 +1,11 @@ import React from 'react'; -import {MatrixClientPeg as peg} from '../src/MatrixClientPeg'; +import { MatrixClientPeg as peg } from '../src/MatrixClientPeg'; import dis from '../src/dispatcher/dispatcher'; -import {makeType} from "../src/utils/TypeUtils"; -import {ValidatedServerConfig} from "../src/utils/AutoDiscoveryUtils"; +import { makeType } from "../src/utils/TypeUtils"; +import { ValidatedServerConfig } from "../src/utils/AutoDiscoveryUtils"; import ShallowRenderer from 'react-test-renderer/shallow'; import MatrixClientContext from "../src/contexts/MatrixClientContext"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; export function getRenderer() { // Old: ReactTestUtils.createRenderer(); @@ -90,11 +90,12 @@ export function createTestClient() { }), // Used by various internal bits we aren't concerned with (yet) - _sessionStore: { + sessionStore: { store: { getItem: jest.fn(), }, }, + decryptEventIfNeeded: () => Promise.resolve(), }; } @@ -218,7 +219,7 @@ export function mkMessage(opts) { return mkEvent(opts); } -export function mkStubRoom(roomId = null) { +export function mkStubRoom(roomId = null, name) { const stubTimeline = { getEvents: () => [] }; return { roomId, @@ -233,9 +234,11 @@ export function mkStubRoom(roomId = null) { }), getMembersWithMembership: jest.fn().mockReturnValue([]), getJoinedMembers: jest.fn().mockReturnValue([]), + getMembers: jest.fn().mockReturnValue([]), getPendingEvents: () => [], getLiveTimeline: () => stubTimeline, getUnfilteredTimelineSet: () => null, + findEventById: () => null, getAccountData: () => null, hasMembershipState: () => null, getVersion: () => '1', @@ -244,6 +247,7 @@ export function mkStubRoom(roomId = null) { maySendMessage: jest.fn().mockReturnValue(true), currentState: { getStateEvents: jest.fn(), + getMember: jest.fn(), mayClientSendStateEvent: jest.fn().mockReturnValue(true), maySendStateEvent: jest.fn().mockReturnValue(true), maySendEvent: jest.fn().mockReturnValue(true), @@ -252,13 +256,17 @@ export function mkStubRoom(roomId = null) { tags: {}, setBlacklistUnverifiedDevices: jest.fn(), on: jest.fn(), + off: jest.fn(), removeListener: jest.fn(), getDMInviter: jest.fn(), + name, getAvatarUrl: () => 'mxc://avatar.url/room.png', getMxcAvatarUrl: () => 'mxc://avatar.url/room.png', isSpaceRoom: jest.fn(() => false), getUnreadNotificationCount: jest.fn(() => 0), getEventReadUpTo: jest.fn(() => null), + getCanonicalAlias: jest.fn(), + getAltAliases: jest.fn().mockReturnValue([]), timeline: [], }; } diff --git a/test/utils/AnimationUtils-test.ts b/test/utils/AnimationUtils-test.ts new file mode 100644 index 0000000000..b6d75a706f --- /dev/null +++ b/test/utils/AnimationUtils-test.ts @@ -0,0 +1,35 @@ +/* +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 { lerp } from "../../src/utils/AnimationUtils"; + +describe("lerp", () => { + it("correctly interpolates", () => { + expect(lerp(0, 100, 0.5)).toBe(50); + expect(lerp(50, 100, 0.5)).toBe(75); + expect(lerp(0, 1, 0.1)).toBe(0.1); + }); + + it("clamps the interpolant", () => { + expect(lerp(0, 100, 50)).toBe(100); + expect(lerp(0, 100, -50)).toBe(0); + }); + + it("handles negative numbers", () => { + expect(lerp(-100, 0, 0.5)).toBe(-50); + expect(lerp(100, -100, 0.5)).toBe(0); + }); +}); diff --git a/test/utils/FixedRollingArray-test.ts b/test/utils/FixedRollingArray-test.ts new file mode 100644 index 0000000000..732a4f175e --- /dev/null +++ b/test/utils/FixedRollingArray-test.ts @@ -0,0 +1,65 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { FixedRollingArray } from "../../src/utils/FixedRollingArray"; + +describe('FixedRollingArray', () => { + it('should seed the array with the given value', () => { + const seed = "test"; + const width = 24; + const array = new FixedRollingArray(width, seed); + + expect(array.value.length).toBe(width); + expect(array.value.every(v => v === seed)).toBe(true); + }); + + it('should insert at the correct end', () => { + const seed = "test"; + const value = "changed"; + const width = 24; + const array = new FixedRollingArray(width, seed); + array.pushValue(value); + + expect(array.value.length).toBe(width); + expect(array.value[0]).toBe(value); + }); + + it('should roll over', () => { + const seed = -1; + const width = 24; + const array = new FixedRollingArray(width, seed); + + const maxValue = width * 2; + const minValue = width; // because we're forcing a rollover + for (let i = 0; i <= maxValue; i++) { + array.pushValue(i); + } + + expect(array.value.length).toBe(width); + + for (let i = 1; i < width; i++) { + const current = array.value[i]; + const previous = array.value[i - 1]; + expect(previous - current).toBe(1); + + if (i === 1) { + expect(previous).toBe(maxValue); + } else if (i === width) { + expect(current).toBe(minValue); + } + } + }); +}); diff --git a/test/utils/MegolmExportEncryption-test.js b/test/utils/MegolmExportEncryption-test.js index e0ed5ba26a..39ffed25f1 100644 --- a/test/utils/MegolmExportEncryption-test.js +++ b/test/utils/MegolmExportEncryption-test.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TextEncoder} from "util"; +import { TextEncoder } from "util"; import nodeCrypto from "crypto"; import { Crypto } from "@peculiar/webcrypto"; @@ -84,22 +84,22 @@ describe('MegolmExportEncryption', function() { it('should handle missing header', function() { const input=stringToArray(`-----`); return MegolmExportEncryption.decryptMegolmKeyFile(input, '') - .then((res) => { - throw new Error('expected to throw'); - }, (error) => { - expect(error.message).toEqual('Header line not found'); - }); + .then((res) => { + throw new Error('expected to throw'); + }, (error) => { + expect(error.message).toEqual('Header line not found'); + }); }); it('should handle missing trailer', function() { const input=stringToArray(`-----BEGIN MEGOLM SESSION DATA----- -----`); return MegolmExportEncryption.decryptMegolmKeyFile(input, '') - .then((res) => { - throw new Error('expected to throw'); - }, (error) => { - expect(error.message).toEqual('Trailer line not found'); - }); + .then((res) => { + throw new Error('expected to throw'); + }, (error) => { + expect(error.message).toEqual('Trailer line not found'); + }); }); it('should handle a too-short body', function() { @@ -109,11 +109,11 @@ cissyYBxjsfsAn -----END MEGOLM SESSION DATA----- `); return MegolmExportEncryption.decryptMegolmKeyFile(input, '') - .then((res) => { - throw new Error('expected to throw'); - }, (error) => { - expect(error.message).toEqual('Invalid file: too short'); - }); + .then((res) => { + throw new Error('expected to throw'); + }, (error) => { + expect(error.message).toEqual('Invalid file: too short'); + }); }); // TODO find a subtlecrypto shim which doesn't break this test @@ -144,7 +144,7 @@ cissyYBxjsfsAn const password = 'my super secret passphrase'; return MegolmExportEncryption.encryptMegolmKeyFile( - input, password, {kdf_rounds: 1000}, + input, password, { kdf_rounds: 1000 }, ).then((ciphertext) => { return MegolmExportEncryption.decryptMegolmKeyFile( ciphertext, password, diff --git a/test/utils/ShieldUtils-test.js b/test/utils/ShieldUtils-test.js index bea3d26565..b70031dc21 100644 --- a/test/utils/ShieldUtils-test.js +++ b/test/utils/ShieldUtils-test.js @@ -26,7 +26,7 @@ describe("mkClient self-test", function() { ["@TF:h", true], ["@FT:h", false], ["@FF:h", false]], - )("behaves well for user trust %s", (userId, trust) => { + )("behaves well for user trust %s", (userId, trust) => { expect(mkClient().checkUserTrust(userId).isCrossSigningVerified()).toBe(trust); }); @@ -35,7 +35,7 @@ describe("mkClient self-test", function() { ["@TF:h", false], ["@FT:h", true], ["@FF:h", false]], - )("behaves well for device trust %s", (userId, trust) => { + )("behaves well for device trust %s", (userId, trust) => { expect(mkClient().checkDeviceTrust(userId, "device").isVerified()).toBe(trust); }); }); @@ -54,7 +54,7 @@ describe("shieldStatusForMembership self-trust behaviour", function() { const client = mkClient(trusted); const room = { roomId: dm ? "DM" : "other", - getEncryptionTargetMembers: () => ["@self:localhost", "@FF1:h", "@FF2:h"].map((userId) => ({userId})), + getEncryptionTargetMembers: () => ["@self:localhost", "@FF1:h", "@FF2:h"].map((userId) => ({ userId })), }; const status = await shieldStatusForRoom(client, room); expect(status).toEqual("normal"); @@ -67,7 +67,7 @@ describe("shieldStatusForMembership self-trust behaviour", function() { const client = mkClient(trusted); const room = { roomId: dm ? "DM" : "other", - getEncryptionTargetMembers: () => ["@self:localhost", "@TT1:h", "@TT2:h"].map((userId) => ({userId})), + getEncryptionTargetMembers: () => ["@self:localhost", "@TT1:h", "@TT2:h"].map((userId) => ({ userId })), }; const status = await shieldStatusForRoom(client, room); expect(status).toEqual(result); @@ -80,7 +80,7 @@ describe("shieldStatusForMembership self-trust behaviour", function() { const client = mkClient(trusted); const room = { roomId: dm ? "DM" : "other", - getEncryptionTargetMembers: () => ["@self:localhost", "@TT1:h", "@FF2:h"].map((userId) => ({userId})), + getEncryptionTargetMembers: () => ["@self:localhost", "@TT1:h", "@FF2:h"].map((userId) => ({ userId })), }; const status = await shieldStatusForRoom(client, room); expect(status).toEqual(result); @@ -93,7 +93,7 @@ describe("shieldStatusForMembership self-trust behaviour", function() { const client = mkClient(trusted); const room = { roomId: dm ? "DM" : "other", - getEncryptionTargetMembers: () => ["@self:localhost"].map((userId) => ({userId})), + getEncryptionTargetMembers: () => ["@self:localhost"].map((userId) => ({ userId })), }; const status = await shieldStatusForRoom(client, room); expect(status).toEqual(result); @@ -106,7 +106,7 @@ describe("shieldStatusForMembership self-trust behaviour", function() { const client = mkClient(trusted); const room = { roomId: dm ? "DM" : "other", - getEncryptionTargetMembers: () => ["@self:localhost", "@TT:h"].map((userId) => ({userId})), + getEncryptionTargetMembers: () => ["@self:localhost", "@TT:h"].map((userId) => ({ userId })), }; const status = await shieldStatusForRoom(client, room); expect(status).toEqual(result); @@ -119,7 +119,7 @@ describe("shieldStatusForMembership self-trust behaviour", function() { const client = mkClient(trusted); const room = { roomId: dm ? "DM" : "other", - getEncryptionTargetMembers: () => ["@self:localhost", "@FF:h"].map((userId) => ({userId})), + getEncryptionTargetMembers: () => ["@self:localhost", "@FF:h"].map((userId) => ({ userId })), }; const status = await shieldStatusForRoom(client, room); expect(status).toEqual(result); @@ -139,7 +139,7 @@ describe("shieldStatusForMembership other-trust behaviour", function() { const client = mkClient(true); const room = { roomId: dm ? "DM" : "other", - getEncryptionTargetMembers: () => ["@self:localhost", "@TF:h"].map((userId) => ({userId})), + getEncryptionTargetMembers: () => ["@self:localhost", "@TF:h"].map((userId) => ({ userId })), }; const status = await shieldStatusForRoom(client, room); expect(status).toEqual(result); @@ -151,7 +151,7 @@ describe("shieldStatusForMembership other-trust behaviour", function() { const client = mkClient(true); const room = { roomId: dm ? "DM" : "other", - getEncryptionTargetMembers: () => ["@self:localhost", "@TF:h", "@TT:h"].map((userId) => ({userId})), + getEncryptionTargetMembers: () => ["@self:localhost", "@TF:h", "@TT:h"].map((userId) => ({ userId })), }; const status = await shieldStatusForRoom(client, room); expect(status).toEqual(result); @@ -163,7 +163,7 @@ describe("shieldStatusForMembership other-trust behaviour", function() { const client = mkClient(true); const room = { roomId: dm ? "DM" : "other", - getEncryptionTargetMembers: () => ["@self:localhost", "@FF:h", "@FT:h"].map((userId) => ({userId})), + getEncryptionTargetMembers: () => ["@self:localhost", "@FF:h", "@FT:h"].map((userId) => ({ userId })), }; const status = await shieldStatusForRoom(client, room); expect(status).toEqual(result); @@ -175,7 +175,7 @@ describe("shieldStatusForMembership other-trust behaviour", function() { const client = mkClient(true); const room = { roomId: dm ? "DM" : "other", - getEncryptionTargetMembers: () => ["@self:localhost", "@WF:h", "@FT:h"].map((userId) => ({userId})), + getEncryptionTargetMembers: () => ["@self:localhost", "@WF:h", "@FT:h"].map((userId) => ({ userId })), }; const status = await shieldStatusForRoom(client, room); expect(status).toEqual(result); diff --git a/test/utils/Singleflight-test.ts b/test/utils/Singleflight-test.ts index 80258701bb..148388dc71 100644 --- a/test/utils/Singleflight-test.ts +++ b/test/utils/Singleflight-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Singleflight} from "../../src/utils/Singleflight"; +import { Singleflight } from "../../src/utils/Singleflight"; describe('Singleflight', () => { afterEach(() => { diff --git a/test/utils/arrays-test.ts b/test/utils/arrays-test.ts index c5be59ab43..cf9a5f0089 100644 --- a/test/utils/arrays-test.ts +++ b/test/utils/arrays-test.ts @@ -21,17 +21,19 @@ import { arrayHasDiff, arrayHasOrderChange, arrayMerge, + arrayRescale, arraySeed, + arraySmoothingResample, arrayTrimFill, arrayUnion, ArrayUtil, GroupedArray, } from "../../src/utils/arrays"; -import {objectFromEntries} from "../../src/utils/objects"; +import { objectFromEntries } from "../../src/utils/objects"; -function expectSample(i: number, input: number[], expected: number[]) { +function expectSample(i: number, input: number[], expected: number[], smooth = false) { console.log(`Resample case index: ${i}`); // for debugging test failures - const result = arrayFastResample(input, expected.length); + const result = (smooth ? arraySmoothingResample : arrayFastResample)(input, expected.length); expect(result).toBeDefined(); expect(result).toHaveLength(expected.length); expect(result).toEqual(expected); @@ -41,30 +43,71 @@ describe('arrays', () => { describe('arrayFastResample', () => { it('should downsample', () => { [ - {input: [1, 2, 3, 4, 5], output: [1, 4]}, // Odd -> Even - {input: [1, 2, 3, 4, 5], output: [1, 3, 5]}, // Odd -> Odd - {input: [1, 2, 3, 4], output: [1, 2, 3]}, // Even -> Odd - {input: [1, 2, 3, 4], output: [1, 3]}, // Even -> Even + { input: [1, 2, 3, 4, 5], output: [1, 4] }, // Odd -> Even + { input: [1, 2, 3, 4, 5], output: [1, 3, 5] }, // Odd -> Odd + { input: [1, 2, 3, 4], output: [1, 2, 3] }, // Even -> Odd + { input: [1, 2, 3, 4], output: [1, 3] }, // Even -> Even ].forEach((c, i) => expectSample(i, c.input, c.output)); }); it('should upsample', () => { [ - {input: [1, 2, 3], output: [1, 1, 2, 2, 3, 3]}, // Odd -> Even - {input: [1, 2, 3], output: [1, 1, 2, 2, 3]}, // Odd -> Odd - {input: [1, 2], output: [1, 1, 1, 2, 2]}, // Even -> Odd - {input: [1, 2], output: [1, 1, 1, 2, 2, 2]}, // Even -> Even + { input: [1, 2, 3], output: [1, 1, 2, 2, 3, 3] }, // Odd -> Even + { input: [1, 2, 3], output: [1, 1, 2, 2, 3] }, // Odd -> Odd + { input: [1, 2], output: [1, 1, 1, 2, 2] }, // Even -> Odd + { input: [1, 2], output: [1, 1, 1, 2, 2, 2] }, // Even -> Even ].forEach((c, i) => expectSample(i, c.input, c.output)); }); it('should maintain sample', () => { [ - {input: [1, 2, 3], output: [1, 2, 3]}, // Odd - {input: [1, 2], output: [1, 2]}, // Even + { input: [1, 2, 3], output: [1, 2, 3] }, // Odd + { input: [1, 2], output: [1, 2] }, // Even ].forEach((c, i) => expectSample(i, c.input, c.output)); }); }); + describe('arraySmoothingResample', () => { + it('should downsample', () => { + // Dev note: these aren't great samples, but they demonstrate the bare minimum. Ideally + // we'd be feeding a thousand values in and seeing what a curve of 250 values looks like, + // but that's not really feasible to manually verify accuracy. + [ + { input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3, 3] }, // Odd -> Even + { input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3] }, // Odd -> Odd + { input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3, 3] }, // Even -> Odd + { input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3] }, // Even -> Even + ].forEach((c, i) => expectSample(i, c.input, c.output, true)); + }); + + it('should upsample', () => { + [ + { input: [2, 0, 2], output: [2, 2, 0, 0, 2, 2] }, // Odd -> Even + { input: [2, 0, 2], output: [2, 2, 0, 0, 2] }, // Odd -> Odd + { input: [2, 0], output: [2, 2, 2, 0, 0] }, // Even -> Odd + { input: [2, 0], output: [2, 2, 2, 0, 0, 0] }, // Even -> Even + ].forEach((c, i) => expectSample(i, c.input, c.output, true)); + }); + + it('should maintain sample', () => { + [ + { input: [2, 0, 2], output: [2, 0, 2] }, // Odd + { input: [2, 0], output: [2, 0] }, // Even + ].forEach((c, i) => expectSample(i, c.input, c.output, true)); + }); + }); + + describe('arrayRescale', () => { + it('should rescale', () => { + const input = [8, 9, 1, 0, 2, 7, 10]; + const output = [80, 90, 10, 0, 20, 70, 100]; + const result = arrayRescale(input, 0, 100); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + }); + describe('arrayTrimFill', () => { it('should shrink arrays', () => { const input = [1, 2, 3]; diff --git a/test/utils/enums-test.ts b/test/utils/enums-test.ts index 423b135f77..e1d34ee688 100644 --- a/test/utils/enums-test.ts +++ b/test/utils/enums-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {getEnumValues, isEnumValue} from "../../src/utils/enums"; +import { getEnumValues, isEnumValue } from "../../src/utils/enums"; enum TestStringEnum { First = "__first__", diff --git a/test/utils/iterables-test.ts b/test/utils/iterables-test.ts index 9b30b6241c..5c1e2241f4 100644 --- a/test/utils/iterables-test.ts +++ b/test/utils/iterables-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {iterableDiff, iterableUnion} from "../../src/utils/iterables"; +import { iterableDiff, iterableUnion } from "../../src/utils/iterables"; describe('iterables', () => { describe('iterableUnion', () => { diff --git a/test/utils/maps-test.ts b/test/utils/maps-test.ts index 8764a8f2cf..097f3ef9c9 100644 --- a/test/utils/maps-test.ts +++ b/test/utils/maps-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {EnhancedMap, mapDiff, mapKeyChanges} from "../../src/utils/maps"; +import { EnhancedMap, mapDiff, mapKeyChanges } from "../../src/utils/maps"; describe('maps', () => { describe('mapDiff', () => { @@ -187,7 +187,7 @@ describe('maps', () => { }); it('should use the provided entries', () => { - const obj = {a: 1, b: 2}; + const obj = { a: 1, b: 2 }; const result = new EnhancedMap(Object.entries(obj)); expect(result.size).toBe(2); expect(result.get('a')).toBe(1); diff --git a/test/utils/numbers-test.ts b/test/utils/numbers-test.ts index 36e7d4f7e7..340ffe3302 100644 --- a/test/utils/numbers-test.ts +++ b/test/utils/numbers-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {clamp, defaultNumber, percentageOf, percentageWithin, sum} from "../../src/utils/numbers"; +import { clamp, defaultNumber, percentageOf, percentageWithin, sum } from "../../src/utils/numbers"; describe('numbers', () => { describe('defaultNumber', () => { diff --git a/test/utils/objects-test.ts b/test/utils/objects-test.ts index b7a80e6761..154fa3604f 100644 --- a/test/utils/objects-test.ts +++ b/test/utils/objects-test.ts @@ -28,8 +28,8 @@ import { describe('objects', () => { describe('objectExcluding', () => { it('should exclude the given properties', () => { - const input = {hello: "world", test: true}; - const output = {hello: "world"}; + const input = { hello: "world", test: true }; + const output = { hello: "world" }; const props = ["test", "doesnotexist"]; // we also make sure it doesn't explode on missing props const result = objectExcluding(input, props); // any is to test the missing prop expect(result).toBeDefined(); @@ -39,8 +39,8 @@ describe('objects', () => { describe('objectWithOnly', () => { it('should exclusively use the given properties', () => { - const input = {hello: "world", test: true}; - const output = {hello: "world"}; + const input = { hello: "world", test: true }; + const output = { hello: "world" }; const props = ["hello", "doesnotexist"]; // we also make sure it doesn't explode on missing props const result = objectWithOnly(input, props); // any is to test the missing prop expect(result).toBeDefined(); @@ -50,7 +50,7 @@ describe('objects', () => { describe('objectShallowClone', () => { it('should create a new object', () => { - const input = {test: 1}; + const input = { test: 1 }; const result = objectShallowClone(input); expect(result).toBeDefined(); expect(result).not.toBe(input); @@ -58,7 +58,7 @@ describe('objects', () => { }); it('should only clone the top level properties', () => { - const input = {a: 1, b: {c: 2}}; + const input = { a: 1, b: { c: 2 } }; const result = objectShallowClone(input); expect(result).toBeDefined(); expect(result).toMatchObject(input); @@ -66,8 +66,8 @@ describe('objects', () => { }); it('should support custom clone functions', () => { - const input = {a: 1, b: 2}; - const output = {a: 4, b: 8}; + const input = { a: 1, b: 2 }; + const output = { a: 4, b: 8 }; const result = objectShallowClone(input, (k, v) => { // XXX: inverted expectation for ease of assertion expect(Object.keys(input)).toContain(k); @@ -87,29 +87,29 @@ describe('objects', () => { }); it('should return true if keys for A > keys for B', () => { - const a = {a: 1, b: 2}; - const b = {a: 1}; + const a = { a: 1, b: 2 }; + const b = { a: 1 }; const result = objectHasDiff(a, b); expect(result).toBe(true); }); it('should return true if keys for A < keys for B', () => { - const a = {a: 1}; - const b = {a: 1, b: 2}; + const a = { a: 1 }; + const b = { a: 1, b: 2 }; const result = objectHasDiff(a, b); expect(result).toBe(true); }); it('should return false if the objects are the same but different pointers', () => { - const a = {a: 1, b: 2}; - const b = {a: 1, b: 2}; + const a = { a: 1, b: 2 }; + const b = { a: 1, b: 2 }; const result = objectHasDiff(a, b); expect(result).toBe(false); }); it('should consider pointers when testing values', () => { - const a = {a: {}, b: 2}; // `{}` is shorthand for `new Object()` - const b = {a: {}, b: 2}; + const a = { a: {}, b: 2 }; // `{}` is shorthand for `new Object()` + const b = { a: {}, b: 2 }; const result = objectHasDiff(a, b); expect(result).toBe(true); // even though the keys are the same, the value pointers vary }); @@ -117,8 +117,8 @@ describe('objects', () => { describe('objectDiff', () => { it('should return empty sets for the same object', () => { - const a = {a: 1, b: 2}; - const b = {a: 1, b: 2}; + const a = { a: 1, b: 2 }; + const b = { a: 1, b: 2 }; const result = objectDiff(a, b); expect(result).toBeDefined(); expect(result.changed).toBeDefined(); @@ -130,7 +130,7 @@ describe('objects', () => { }); it('should return empty sets for the same object pointer', () => { - const a = {a: 1, b: 2}; + const a = { a: 1, b: 2 }; const result = objectDiff(a, a); expect(result).toBeDefined(); expect(result.changed).toBeDefined(); @@ -142,8 +142,8 @@ describe('objects', () => { }); it('should indicate when property changes are made', () => { - const a = {a: 1, b: 2}; - const b = {a: 11, b: 2}; + const a = { a: 1, b: 2 }; + const b = { a: 11, b: 2 }; const result = objectDiff(a, b); expect(result.changed).toBeDefined(); expect(result.added).toBeDefined(); @@ -155,8 +155,8 @@ describe('objects', () => { }); it('should indicate when properties are added', () => { - const a = {a: 1, b: 2}; - const b = {a: 1, b: 2, c: 3}; + const a = { a: 1, b: 2 }; + const b = { a: 1, b: 2, c: 3 }; const result = objectDiff(a, b); expect(result.changed).toBeDefined(); expect(result.added).toBeDefined(); @@ -168,8 +168,8 @@ describe('objects', () => { }); it('should indicate when properties are removed', () => { - const a = {a: 1, b: 2}; - const b = {a: 1}; + const a = { a: 1, b: 2 }; + const b = { a: 1 }; const result = objectDiff(a, b); expect(result.changed).toBeDefined(); expect(result.added).toBeDefined(); @@ -181,8 +181,8 @@ describe('objects', () => { }); it('should indicate when multiple aspects change', () => { - const a = {a: 1, b: 2, c: 3}; - const b: (typeof a | {d: number}) = {a: 1, b: 22, d: 4}; + const a = { a: 1, b: 2, c: 3 }; + const b: (typeof a | {d: number}) = { a: 1, b: 22, d: 4 }; const result = objectDiff(a, b); expect(result.changed).toBeDefined(); expect(result.added).toBeDefined(); @@ -198,23 +198,23 @@ describe('objects', () => { describe('objectKeyChanges', () => { it('should return an empty set if no properties changed', () => { - const a = {a: 1, b: 2}; - const b = {a: 1, b: 2}; + const a = { a: 1, b: 2 }; + const b = { a: 1, b: 2 }; const result = objectKeyChanges(a, b); expect(result).toBeDefined(); expect(result).toHaveLength(0); }); it('should return an empty set if no properties changed for the same pointer', () => { - const a = {a: 1, b: 2}; + const a = { a: 1, b: 2 }; const result = objectKeyChanges(a, a); expect(result).toBeDefined(); expect(result).toHaveLength(0); }); it('should return properties which were changed, added, or removed', () => { - const a = {a: 1, b: 2, c: 3}; - const b: (typeof a | {d: number}) = {a: 1, b: 22, d: 4}; + const a = { a: 1, b: 2, c: 3 }; + const b: (typeof a | {d: number}) = { a: 1, b: 22, d: 4 }; const result = objectKeyChanges(a, b); expect(result).toBeDefined(); expect(result).toHaveLength(3); @@ -245,14 +245,14 @@ describe('objects', () => { describe('objectFromEntries', () => { it('should create an object from an array of entries', () => { - const output = {a: 1, b: 2, c: 3}; + const output = { a: 1, b: 2, c: 3 }; const result = objectFromEntries(Object.entries(output)); expect(result).toBeDefined(); expect(result).toMatchObject(output); }); it('should maintain pointers in values', () => { - const output = {a: {}, b: 2, c: 3}; + const output = { a: {}, b: 2, c: 3 }; const result = objectFromEntries(Object.entries(output)); expect(result).toBeDefined(); expect(result).toMatchObject(output); diff --git a/test/utils/permalinks/Permalinks-test.js b/test/utils/permalinks/Permalinks-test.js index 0bd4466d97..41cce7f98d 100644 --- a/test/utils/permalinks/Permalinks-test.js +++ b/test/utils/permalinks/Permalinks-test.js @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg as peg} from '../../../src/MatrixClientPeg'; +import { MatrixClientPeg as peg } from '../../../src/MatrixClientPeg'; import { makeGroupPermalink, makeRoomPermalink, @@ -34,7 +34,7 @@ function mockRoom(roomId, members, serverACL) { return { roomId, - getCanonicalAlias: () => roomId, + getCanonicalAlias: () => null, getJoinedMembers: () => members, getMember: (userId) => members.find(m => m.userId === userId), currentState: { @@ -48,7 +48,7 @@ function mockRoom(roomId, members, serverACL) { content = serverACL; break; case "m.room.power_levels": - content = {users: powerLevelsUsers, users_default: 0}; + content = { users: powerLevelsUsers, users_default: 0 }; break; } if (content) { diff --git a/test/utils/sets-test.ts b/test/utils/sets-test.ts index 98dc218309..fec6cab0f3 100644 --- a/test/utils/sets-test.ts +++ b/test/utils/sets-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {setHasDiff} from "../../src/utils/sets"; +import { setHasDiff } from "../../src/utils/sets"; describe('sets', () => { describe('setHasDiff', () => { diff --git a/test/utils/stringOrderField-test.ts b/test/utils/stringOrderField-test.ts new file mode 100644 index 0000000000..331627dfc0 --- /dev/null +++ b/test/utils/stringOrderField-test.ts @@ -0,0 +1,291 @@ +/* +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 { sortBy } from "lodash"; +import { averageBetweenStrings, DEFAULT_ALPHABET } from "matrix-js-sdk/src/utils"; + +import { midPointsBetweenStrings, reorderLexicographically } from "../../src/utils/stringOrderField"; + +const moveLexicographicallyTest = ( + orders: Array, + fromIndex: number, + toIndex: number, + expectedChanges: number, + maxLength?: number, +): void => { + const ops = reorderLexicographically(orders, fromIndex, toIndex, maxLength); + + const zipped: Array<[number, string | undefined]> = orders.map((o, i) => [i, o]); + ops.forEach(({ index, order }) => { + zipped[index][1] = order; + }); + + const newOrders = sortBy(zipped, i => i[1]); + expect(newOrders[toIndex][0]).toBe(fromIndex); + expect(ops).toHaveLength(expectedChanges); +}; + +describe("stringOrderField", () => { + describe("midPointsBetweenStrings", () => { + it("should work", () => { + expect(averageBetweenStrings("!!", "##")).toBe('""'); + const midpoints = ["a", ...midPointsBetweenStrings("a", "e", 3, 1), "e"].sort(); + expect(midpoints[0]).toBe("a"); + expect(midpoints[4]).toBe("e"); + expect(midPointsBetweenStrings(" ", "!'Tu:}", 1, 50)).toStrictEqual([" S:J\\~"]); + }); + + it("should return empty array when the request is not possible", () => { + expect(midPointsBetweenStrings("a", "e", 0, 1)).toStrictEqual([]); + expect(midPointsBetweenStrings("a", "e", 4, 1)).toStrictEqual([]); + }); + }); + + describe("reorderLexicographically", () => { + it("should work when moving left", () => { + moveLexicographicallyTest(["a", "c", "e", "g", "i"], 2, 1, 1); + }); + + it("should work when moving right", () => { + moveLexicographicallyTest(["a", "c", "e", "g", "i"], 1, 2, 1); + }); + + it("should work when all orders are undefined", () => { + moveLexicographicallyTest( + [undefined, undefined, undefined, undefined, undefined, undefined], + 4, + 1, + 2, + ); + }); + + it("should work when moving to end and all orders are undefined", () => { + moveLexicographicallyTest( + [undefined, undefined, undefined, undefined, undefined, undefined], + 1, + 4, + 5, + ); + }); + + it("should work when moving left and some orders are undefined", () => { + moveLexicographicallyTest( + ["a", "c", "e", undefined, undefined, undefined], + 5, + 2, + 1, + ); + + moveLexicographicallyTest( + ["a", "a", "e", undefined, undefined, undefined], + 5, + 1, + 2, + ); + }); + + it("should work moving to the start when all is undefined", () => { + moveLexicographicallyTest( + [undefined, undefined, undefined, undefined], + 2, + 0, + 1, + ); + }); + + it("should work moving to the end when all is undefined", () => { + moveLexicographicallyTest( + [undefined, undefined, undefined, undefined], + 1, + 3, + 4, + ); + }); + + it("should work moving left when all is undefined", () => { + moveLexicographicallyTest( + [undefined, undefined, undefined, undefined, undefined, undefined], + 4, + 1, + 2, + ); + }); + + it("should work moving right when all is undefined", () => { + moveLexicographicallyTest( + [undefined, undefined, undefined, undefined], + 1, + 2, + 3, + ); + }); + + it("should work moving more right when all is undefined", () => { + moveLexicographicallyTest( + [undefined, undefined, undefined, undefined, undefined, /**/ undefined, undefined], + 1, + 4, + 5, + ); + }); + + it("should work moving left when right is undefined", () => { + moveLexicographicallyTest( + ["20", undefined, undefined, undefined, undefined, undefined], + 4, + 2, + 2, + ); + }); + + it("should work moving right when right is undefined", () => { + moveLexicographicallyTest( + ["50", undefined, undefined, undefined, undefined, /**/ undefined, undefined], + 1, + 4, + 4, + ); + }); + + it("should work moving left when right is defined", () => { + moveLexicographicallyTest( + ["10", "20", "30", "40", undefined, undefined], + 3, + 1, + 1, + ); + }); + + it("should work moving right when right is defined", () => { + moveLexicographicallyTest( + ["10", "20", "30", "40", "50", undefined], + 1, + 3, + 1, + ); + }); + + it("should work moving left when all is defined", () => { + moveLexicographicallyTest( + ["11", "13", "15", "17", "19"], + 2, + 1, + 1, + ); + }); + + it("should work moving right when all is defined", () => { + moveLexicographicallyTest( + ["11", "13", "15", "17", "19"], + 1, + 2, + 1, + ); + }); + + it("should work moving left into no left space", () => { + moveLexicographicallyTest( + ["11", "12", "13", "14", "19"], + 3, + 1, + 2, + 2, + ); + + moveLexicographicallyTest( + [ + DEFAULT_ALPHABET.charAt(0), + // Target + DEFAULT_ALPHABET.charAt(1), + DEFAULT_ALPHABET.charAt(2), + DEFAULT_ALPHABET.charAt(3), + DEFAULT_ALPHABET.charAt(4), + DEFAULT_ALPHABET.charAt(5), + ], + 5, + 1, + 5, + 1, + ); + }); + + it("should work moving right into no right space", () => { + moveLexicographicallyTest( + ["15", "16", "17", "18", "19"], + 1, + 3, + 3, + 2, + ); + + moveLexicographicallyTest( + [ + DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 5), + DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 4), + DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 3), + DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 2), + DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 1), + ], + 1, + 3, + 3, + 1, + ); + }); + + it("should work moving right into no left space", () => { + moveLexicographicallyTest( + ["11", "12", "13", "14", "15", "16", undefined], + 1, + 3, + 3, + ); + + moveLexicographicallyTest( + ["0", "1", "2", "3", "4", "5"], + 1, + 3, + 3, + 1, + ); + }); + + it("should work moving left into no right space", () => { + moveLexicographicallyTest( + ["15", "16", "17", "18", "19"], + 4, + 3, + 4, + 2, + ); + + moveLexicographicallyTest( + [ + DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 5), + DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 4), + DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 3), + DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 2), + DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 1), + ], + 4, + 3, + 4, + 1, + ); + }); + }); +}); + diff --git a/yarn.lock b/yarn.lock index acdca26e55..96c02681fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26,12 +26,12 @@ dependencies: "@babel/highlight" "^7.10.4" -"@babel/code-frame@^7.12.13": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" - integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== +"@babel/code-frame@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb" + integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw== dependencies: - "@babel/highlight" "^7.12.13" + "@babel/highlight" "^7.14.5" "@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": version "7.12.7" @@ -59,7 +59,23 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.12.1", "@babel/generator@^7.12.10", "@babel/generator@^7.12.11": +"@babel/eslint-parser@^7.12.10": + version "7.13.14" + resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.13.14.tgz#f80fd23bdd839537221914cb5d17720a5ea6ba3a" + integrity sha512-I0HweR36D73Ibn/FfrRDMKlMqJHFwidIUgYdMpH+aXYuQC+waq59YaJ6t9e9N36axJ82v1jR041wwqDrDXEwRA== + dependencies: + eslint-scope "^5.1.0" + eslint-visitor-keys "^1.3.0" + semver "^6.3.0" + +"@babel/eslint-plugin@^7.12.10": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/eslint-plugin/-/eslint-plugin-7.13.10.tgz#6720c32d52a4fef817796c7bb55a87b80320bbe7" + integrity sha512-xsNxo099fKnJ2rArkuuMOTPxxTLZSXwbFXdH4GjqQRKTOr6S1odQlE+R3Leid56VFQ3KVAR295vVNG9fqNQVvQ== + dependencies: + eslint-rule-composer "^0.3.0" + +"@babel/generator@^7.12.10", "@babel/generator@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.11.tgz#98a7df7b8c358c9a37ab07a24056853016aba3af" integrity sha512-Ggg6WPOJtSi8yYQvLVjG8F/TlpWDlKx0OpS4Kt+xMQPs5OaGYWy+v1A+1TvxI6sAMGZpKWWoAQ1DaeQbImlItA== @@ -68,12 +84,12 @@ jsesc "^2.5.1" source-map "^0.5.0" -"@babel/generator@^7.13.16": - version "7.13.16" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.13.16.tgz#0befc287031a201d84cdfc173b46b320ae472d14" - integrity sha512-grBBR75UnKOcUWMp8WoDxNsWCFl//XCK6HWTrBQKTr5SV9f5g0pNOjdyzi/DTBv12S9GnYPInIXQBTky7OXEMg== +"@babel/generator@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.14.5.tgz#848d7b9f031caca9d0cd0af01b063f226f52d785" + integrity sha512-y3rlP+/G25OIX3mYKKIOlQRcqj7YgrvHxOLbVmyLJ9bPmi5ttvUmpydVjcFjZphOktWuA7ovbx91ECloWTfjIA== dependencies: - "@babel/types" "^7.13.16" + "@babel/types" "^7.14.5" jsesc "^2.5.1" source-map "^0.5.0" @@ -146,14 +162,14 @@ "@babel/template" "^7.12.7" "@babel/types" "^7.12.11" -"@babel/helper-function-name@^7.12.13": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz#93ad656db3c3c2232559fd7b2c3dbdcbe0eb377a" - integrity sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA== +"@babel/helper-function-name@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz#89e2c474972f15d8e233b52ee8c480e2cfcd50c4" + integrity sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ== dependencies: - "@babel/helper-get-function-arity" "^7.12.13" - "@babel/template" "^7.12.13" - "@babel/types" "^7.12.13" + "@babel/helper-get-function-arity" "^7.14.5" + "@babel/template" "^7.14.5" + "@babel/types" "^7.14.5" "@babel/helper-get-function-arity@^7.12.10": version "7.12.10" @@ -162,12 +178,12 @@ dependencies: "@babel/types" "^7.12.10" -"@babel/helper-get-function-arity@^7.12.13": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz#bc63451d403a3b3082b97e1d8b3fe5bd4091e583" - integrity sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg== +"@babel/helper-get-function-arity@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz#25fbfa579b0937eee1f3b805ece4ce398c431815" + integrity sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg== dependencies: - "@babel/types" "^7.12.13" + "@babel/types" "^7.14.5" "@babel/helper-hoist-variables@^7.10.4": version "7.10.4" @@ -176,6 +192,13 @@ dependencies: "@babel/types" "^7.10.4" +"@babel/helper-hoist-variables@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz#e0dd27c33a78e577d7c8884916a3e7ef1f7c7f8d" + integrity sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ== + dependencies: + "@babel/types" "^7.14.5" + "@babel/helper-member-expression-to-functions@^7.12.1", "@babel/helper-member-expression-to-functions@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855" @@ -257,18 +280,23 @@ dependencies: "@babel/types" "^7.12.11" -"@babel/helper-split-export-declaration@^7.12.13": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz#e9430be00baf3e88b0e13e6f9d4eaf2136372b05" - integrity sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg== +"@babel/helper-split-export-declaration@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz#22b23a54ef51c2b7605d851930c1976dd0bc693a" + integrity sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA== dependencies: - "@babel/types" "^7.12.13" + "@babel/types" "^7.14.5" "@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== +"@babel/helper-validator-identifier@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz#d0f0e277c512e0c938277faa85a3968c9a44c0e8" + integrity sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg== + "@babel/helper-validator-option@^7.12.1", "@babel/helper-validator-option@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.11.tgz#d66cb8b7a3e7fe4c6962b32020a131ecf0847f4f" @@ -302,24 +330,24 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/highlight@^7.12.13": - version "7.13.10" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1" - integrity sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg== +"@babel/highlight@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9" + integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg== dependencies: - "@babel/helper-validator-identifier" "^7.12.11" + "@babel/helper-validator-identifier" "^7.14.5" chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.7.0": +"@babel/parser@^7.1.0", "@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79" integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg== -"@babel/parser@^7.12.13", "@babel/parser@^7.13.16": - version "7.13.16" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.16.tgz#0f18179b0448e6939b1f3f5c4c355a3a9bcdfd37" - integrity sha512-6bAg36mCwuqLO0hbR+z7PHuqWiCeP7Dzg73OpQwsAB1Eb8HnGEz5xYBzCfbu+YjoaJsJs+qheDxVAuqbt3ILEw== +"@babel/parser@^7.13.16", "@babel/parser@^7.14.5", "@babel/parser@^7.14.7": + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.14.7.tgz#6099720c8839ca865a2637e6c85852ead0bdb595" + integrity sha512-X67Z5y+VBJuHB/RjwECp8kSl5uYi0BvRbNeWqkaJCVh+LiTPl19WBUfG627psSgp9rSf6ojuXghQM3ha6qHHdA== "@babel/plugin-proposal-async-generator-functions@^7.12.1": version "7.12.12" @@ -494,13 +522,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.3" -"@babel/plugin-syntax-flow@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.12.1.tgz#a77670d9abe6d63e8acadf4c31bb1eb5a506bbdd" - integrity sha512-1lBLLmtxrwpm4VKmtVFselI/P3pX+G63fAtUUt6b2Nzgao77KNDwyuRt90Mj2/9pKobtt68FdvjfqohZjg/FCA== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-import-meta@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" @@ -659,23 +680,6 @@ "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-flow-comments@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-comments/-/plugin-transform-flow-comments-7.12.1.tgz#92fe5a27e635bd6d2e46f616094d60edff895a0b" - integrity sha512-jQd0UQUg98j95JJVQL74mOrzES7dYyd8hy+ZDiMj1wjHw1Nj0Pzkk7tKZdqqAoSZV/yTYLp0lJ0/PTozy2cNDg== - dependencies: - "@babel/generator" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-flow" "^7.12.1" - -"@babel/plugin-transform-flow-strip-types@^7.12.1": - version "7.12.10" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.12.10.tgz#d85e30ecfa68093825773b7b857e5085bbd32c95" - integrity sha512-0ti12wLTLeUIzu9U7kjqIn4MyOL7+Wibc7avsHhj4o1l5C0ATs8p2IMHrVYjm9t9wzhfEO6S3kxax0Rpdo8LTg== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-flow" "^7.12.1" - "@babel/plugin-transform-for-of@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.12.1.tgz#07640f28867ed16f9511c99c888291f560921cfa" @@ -967,14 +971,6 @@ core-js-compat "^3.8.0" semver "^5.5.0" -"@babel/preset-flow@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/preset-flow/-/preset-flow-7.12.1.tgz#1a81d376c5a9549e75352a3888f8c273455ae940" - integrity sha512-UAoyMdioAhM6H99qPoKvpHMzxmNVXno8GYU/7vZmGaHk6/KqfDYL1W0NxszVbJ2EP271b7e6Ox+Vk2A9QsB3Sw== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-transform-flow-strip-types" "^7.12.1" - "@babel/preset-modules@^0.1.3": version "0.1.4" resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.4.tgz#362f2b68c662842970fdb5e254ffc8fc1c2e415e" @@ -1017,13 +1013,20 @@ pirates "^4.0.0" source-map-support "^0.5.16" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.12.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.1", "@babel/runtime@^7.9.2": + version "7.14.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.6.tgz#535203bc0892efc7dec60bdc27b2ecf6e409062d" + integrity sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.10.4", "@babel/template@^7.12.7", "@babel/template@^7.3.3": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" @@ -1033,16 +1036,16 @@ "@babel/parser" "^7.12.7" "@babel/types" "^7.12.7" -"@babel/template@^7.12.13": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" - integrity sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA== +"@babel/template@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4" + integrity sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g== dependencies: - "@babel/code-frame" "^7.12.13" - "@babel/parser" "^7.12.13" - "@babel/types" "^7.12.13" + "@babel/code-frame" "^7.14.5" + "@babel/parser" "^7.14.5" + "@babel/types" "^7.14.5" -"@babel/traverse@^7.1.0", "@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.12", "@babel/traverse@^7.12.5", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.4": +"@babel/traverse@^7.1.0", "@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.12", "@babel/traverse@^7.12.5": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376" integrity sha512-s88i0X0lPy45RrLM8b9mz8RPH5FqO9G9p7ti59cToE44xFm1Q+Pjh5Gq4SXBbtb88X7Uy7pexeqRIQDDMNkL0w== @@ -1058,20 +1061,21 @@ lodash "^4.17.19" "@babel/traverse@^7.13.17": - version "7.13.17" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.17.tgz#c85415e0c7d50ac053d758baec98b28b2ecfeea3" - integrity sha512-BMnZn0R+X6ayqm3C3To7o1j7Q020gWdqdyP50KEoVqaCO2c/Im7sYZSmVgvefp8TTMQ+9CtwuBp0Z1CZ8V3Pvg== + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.14.7.tgz#64007c9774cfdc3abd23b0780bc18a3ce3631753" + integrity sha512-9vDr5NzHu27wgwejuKL7kIOm4bwEtaPQ4Z6cpCmjSuaRqpH/7xc4qcGEscwMqlkwgcXl6MvqoAjZkQ24uSdIZQ== dependencies: - "@babel/code-frame" "^7.12.13" - "@babel/generator" "^7.13.16" - "@babel/helper-function-name" "^7.12.13" - "@babel/helper-split-export-declaration" "^7.12.13" - "@babel/parser" "^7.13.16" - "@babel/types" "^7.13.17" + "@babel/code-frame" "^7.14.5" + "@babel/generator" "^7.14.5" + "@babel/helper-function-name" "^7.14.5" + "@babel/helper-hoist-variables" "^7.14.5" + "@babel/helper-split-export-declaration" "^7.14.5" + "@babel/parser" "^7.14.7" + "@babel/types" "^7.14.5" debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.7", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": +"@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.7", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299" integrity sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ== @@ -1080,12 +1084,12 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" -"@babel/types@^7.12.13", "@babel/types@^7.13.16", "@babel/types@^7.13.17": - version "7.13.17" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.17.tgz#48010a115c9fba7588b4437dd68c9469012b38b4" - integrity sha512-RawydLgxbOPDlTLJNtoIypwdmAy//uQIzlKt2+iBiJaRlVuI6QLUxVAyWGNfOzp8Yu4L4lLIacoCyTNtpb4wiA== +"@babel/types@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.14.5.tgz#3bb997ba829a2104cedb20689c4a5b8121d383ff" + integrity sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg== dependencies: - "@babel/helper-validator-identifier" "^7.12.11" + "@babel/helper-validator-identifier" "^7.14.5" to-fast-properties "^2.0.0" "@bcoe/v8-coverage@^0.2.3": @@ -1325,6 +1329,10 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz": + version "3.2.3" + resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" + "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents": version "2.1.8-no-fsevents" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.tgz#da7c3996b8e6e19ebd14d82eaced2313e7769f9b" @@ -1470,11 +1478,26 @@ resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.11.tgz#2521cc86f69d15c5b90664e4829d84566052c1cf" integrity sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw== +"@types/commonmark@^0.27.4": + version "0.27.4" + resolved "https://registry.yarnpkg.com/@types/commonmark/-/commonmark-0.27.4.tgz#8f42990e5cf3b6b95bd99eaa452e157aab679b82" + integrity sha512-7koSjp08QxKoS1/+3T15+kD7+vqOUvZRHvM8PutF3Xsk5aAEkdlIGRsHJ3/XsC3izoqTwBdRW/vH7rzCKkIicA== + "@types/counterpart@^0.18.1": version "0.18.1" resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8" integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ== +"@types/css-font-loading-module@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.6.tgz#1ac3417ed31eeb953134d29b56bca921644b87c0" + integrity sha512-MBvSMSxXFtIukyXRU3HhzL369rIWaqMVQD5kmDCYIFFD6Fe3lJ4c9UnLD02MLdTp7Z6ti7rO3SQtuDo7C80mmw== + +"@types/diff-match-patch@^1.0.32": + version "1.0.32" + resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz#d9c3b8c914aa8229485351db4865328337a3d09f" + integrity sha512-bPYT5ECFiblzsVzyURaNhljBH2Gh1t9LowgUwciMrNAhFewLkHT2H0Mto07Y4/3KCOGZHRQll3CTtQZ0X11D/A== + "@types/events@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" @@ -1500,6 +1523,14 @@ dependencies: "@types/node" "*" +"@types/hoist-non-react-statics@^3.3.0": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -1540,11 +1571,6 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== -"@types/json5@^0.0.29": - version "0.0.29" - resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" - integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= - "@types/linkifyjs@^2.1.3": version "2.1.3" resolved "https://registry.yarnpkg.com/@types/linkifyjs/-/linkifyjs-2.1.3.tgz#80195c3c88c5e75d9f660e3046ce4a42be2c2fa4" @@ -1594,6 +1620,11 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/parse5@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.0.tgz#38590dc2c3cf5717154064e3ee9b6947ee21b299" + integrity sha512-oPwPSj4a1wu9rsXTEGIJz91ISU725t0BmSnUhb57sI+M8XEmvUop84lzuiYdq0Y5M6xLY8DBPg0C2xEQKLyvBA== + "@types/prettier@^2.0.0": version "2.1.6" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.1.6.tgz#f4b1efa784e8db479cdb8b14403e2144b1e9ff03" @@ -1611,12 +1642,29 @@ dependencies: "@types/node" "*" -"@types/react-dom@^16.9.10": - version "16.9.10" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.10.tgz#4485b0bec3d41f856181b717f45fd7831101156f" - integrity sha512-ItatOrnXDMAYpv6G8UCk2VhbYVTjZT9aorLtA/OzDN9XJ2GKcfam68jutoAcILdRjsRUO8qb7AmyObF77Q8QFw== +"@types/react-beautiful-dnd@^13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz#e60d3d965312fcf1516894af92dc3e9249587db4" + integrity sha512-by80tJ8aTTDXT256Gl+RfLRtFjYbUWOnZuEigJgNsJrSEGxvFe5eY6k3g4VIvf0M/6+xoLgfYWoWonlOo6Wqdg== dependencies: - "@types/react" "^16" + "@types/react" "*" + +"@types/react-dom@^17.0.2": + version "17.0.8" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.8.tgz#3180de6d79bf53762001ad854e3ce49f36dd71fc" + integrity sha512-0ohAiJAx1DAUEcY9UopnfwCE9sSMDGnY/oXjWMax6g3RpzmTt2GMyMVAXcbn0mo8XAff0SbQJl2/SBU+hjSZ1A== + dependencies: + "@types/react" "*" + +"@types/react-redux@^7.1.16": + version "7.1.16" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.16.tgz#0fbd04c2500c12105494c83d4a3e45c084e3cb21" + integrity sha512-f/FKzIrZwZk7YEO9E1yoxIuDNRiDducxkFlkw/GNMGEnK9n4K8wJzlJBghpSuOVDgEUHoDkDF7Gi9lHNQR4siw== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" "@types/react-transition-group@^4.4.0": version "4.4.0" @@ -1625,20 +1673,31 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16", "@types/react@^16.14", "@types/react@^16.9": - version "16.14.2" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.2.tgz#85dcc0947d0645349923c04ccef6018a1ab7538c" - integrity sha512-BzzcAlyDxXl2nANlabtT4thtvbbnhee8hMmH/CcJrISDBVcJS1iOsP1f0OAgSdGE0MsY9tqcrb9YoZcOFv9dbQ== +"@types/react@*", "@types/react@^17.0.2": + version "17.0.11" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.11.tgz#67fcd0ddbf5a0b083a0f94e926c7d63f3b836451" + integrity sha512-yFRQbD+whVonItSk7ZzP/L+gPTJVBkL/7shLEF+i9GC/1cV3JmUxEQz6+9ylhUpWSDuqo1N9qEvqS6vTj4USUA== dependencies: "@types/prop-types" "*" + "@types/scheduler" "*" csstype "^3.0.2" -"@types/sanitize-html@^1.27.0": - version "1.27.0" - resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-1.27.0.tgz#77702dc856f16efecc005014c1d2e45b1f2cbc56" - integrity sha512-j7Vnh3P7W4ZcoRsHNO2HpwA2m1d0c2+l39xqSQqH0+WlfcvKypgZp45eCC7NJ75ZyXPxNb2PSbIL6LtZ6E0Qbw== +"@types/retry@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + +"@types/sanitize-html@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.3.1.tgz#094d696b83b7394b016e96342bbffa6a028795ce" + integrity sha512-+UT/XRluJuCunRftwO6OzG6WOBgJ+J3sROIoSJWX+7PB2FtTJTEJLrHCcNwzCQc0r60bej3WAbaigK+VZtZCGw== dependencies: - htmlparser2 "^4.1.0" + htmlparser2 "^6.0.0" + +"@types/scheduler@*": + version "0.16.1" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275" + integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== "@types/stack-utils@^1.0.1": version "1.0.1" @@ -1672,13 +1731,13 @@ resolved "https://registry.yarnpkg.com/@types/zxcvbn/-/zxcvbn-4.4.0.tgz#fbc1d941cc6d9d37d18405c513ba6b294f89b609" integrity sha512-GQLOT+SN20a+AI51y3fAimhyTF4Y0RG+YP3gf91OibIZ7CJmPFgoZi+ZR5a+vRbS01LbQosITWum4ATmJ1Z6Pg== -"@typescript-eslint/eslint-plugin@^4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.14.0.tgz#92db8e7c357ed7d69632d6843ca70b71be3a721d" - integrity sha512-IJ5e2W7uFNfg4qh9eHkHRUCbgZ8VKtGwD07kannJvM5t/GU8P8+24NX8gi3Hf5jST5oWPY8kyV1s/WtfiZ4+Ww== +"@typescript-eslint/eslint-plugin@^4.17.0": + version "4.20.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.20.0.tgz#9d8794bd99aad9153092ad13c96164e3082e9a92" + integrity sha512-sw+3HO5aehYqn5w177z2D82ZQlqHCwcKSMboueo7oE4KU9QiC0SAgfS/D4z9xXvpTc8Bt41Raa9fBR8T2tIhoQ== dependencies: - "@typescript-eslint/experimental-utils" "4.14.0" - "@typescript-eslint/scope-manager" "4.14.0" + "@typescript-eslint/experimental-utils" "4.20.0" + "@typescript-eslint/scope-manager" "4.20.0" debug "^4.1.1" functional-red-black-tree "^1.0.1" lodash "^4.17.15" @@ -1686,63 +1745,87 @@ semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/experimental-utils@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.14.0.tgz#5aa7b006736634f588a69ee343ca959cd09988df" - integrity sha512-6i6eAoiPlXMKRbXzvoQD5Yn9L7k9ezzGRvzC/x1V3650rUk3c3AOjQyGYyF9BDxQQDK2ElmKOZRD0CbtdkMzQQ== +"@typescript-eslint/experimental-utils@4.20.0": + version "4.20.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.20.0.tgz#a8ab2d7b61924f99042b7d77372996d5f41dc44b" + integrity sha512-sQNlf6rjLq2yB5lELl3gOE7OuoA/6IVXJUJ+Vs7emrQMva14CkOwyQwD7CW+TkmOJ4Q/YGmoDLmbfFrpGmbKng== dependencies: "@types/json-schema" "^7.0.3" - "@typescript-eslint/scope-manager" "4.14.0" - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/typescript-estree" "4.14.0" + "@typescript-eslint/scope-manager" "4.20.0" + "@typescript-eslint/types" "4.20.0" + "@typescript-eslint/typescript-estree" "4.20.0" eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@^4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.14.0.tgz#62d4cd2079d5c06683e9bfb200c758f292c4dee7" - integrity sha512-sUDeuCjBU+ZF3Lzw0hphTyScmDDJ5QVkyE21pRoBo8iDl7WBtVFS+WDN3blY1CH3SBt7EmYCw6wfmJjF0l/uYg== +"@typescript-eslint/parser@^4.17.0": + version "4.20.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.20.0.tgz#8dd403c8b4258b99194972d9799e201b8d083bdd" + integrity sha512-m6vDtgL9EABdjMtKVw5rr6DdeMCH3OA1vFb0dAyuZSa3e5yw1YRzlwFnm9knma9Lz6b2GPvoNSa8vOXrqsaglA== dependencies: - "@typescript-eslint/scope-manager" "4.14.0" - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/typescript-estree" "4.14.0" + "@typescript-eslint/scope-manager" "4.20.0" + "@typescript-eslint/types" "4.20.0" + "@typescript-eslint/typescript-estree" "4.20.0" debug "^4.1.1" -"@typescript-eslint/scope-manager@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.14.0.tgz#55a4743095d684e1f7b7180c4bac2a0a3727f517" - integrity sha512-/J+LlRMdbPh4RdL4hfP1eCwHN5bAhFAGOTsvE6SxsrM/47XQiPSgF5MDgLyp/i9kbZV9Lx80DW0OpPkzL+uf8Q== +"@typescript-eslint/scope-manager@4.20.0": + version "4.20.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.20.0.tgz#953ecbf3b00845ece7be66246608be9d126d05ca" + integrity sha512-/zm6WR6iclD5HhGpcwl/GOYDTzrTHmvf8LLLkwKqqPKG6+KZt/CfSgPCiybshmck66M2L5fWSF/MKNuCwtKQSQ== dependencies: - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/visitor-keys" "4.14.0" + "@typescript-eslint/types" "4.20.0" + "@typescript-eslint/visitor-keys" "4.20.0" -"@typescript-eslint/types@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.14.0.tgz#d8a8202d9b58831d6fd9cee2ba12f8a5a5dd44b6" - integrity sha512-VsQE4VvpldHrTFuVPY1ZnHn/Txw6cZGjL48e+iBxTi2ksa9DmebKjAeFmTVAYoSkTk7gjA7UqJ7pIsyifTsI4A== +"@typescript-eslint/types@4.20.0": + version "4.20.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.20.0.tgz#c6cf5ef3c9b1c8f699a9bbdafb7a1da1ca781225" + integrity sha512-cYY+1PIjei1nk49JAPnH1VEnu7OYdWRdJhYI5wiKOUMhLTG1qsx5cQxCUTuwWCmQoyriadz3Ni8HZmGSofeC+w== -"@typescript-eslint/typescript-estree@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.14.0.tgz#4bcd67486e9acafc3d0c982b23a9ab8ac8911ed7" - integrity sha512-wRjZ5qLao+bvS2F7pX4qi2oLcOONIB+ru8RGBieDptq/SudYwshveORwCVU4/yMAd4GK7Fsf8Uq1tjV838erag== +"@typescript-eslint/typescript-estree@4.20.0": + version "4.20.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.20.0.tgz#8b3b08f85f18a8da5d88f65cb400f013e88ab7be" + integrity sha512-Knpp0reOd4ZsyoEJdW8i/sK3mtZ47Ls7ZHvD8WVABNx5Xnn7KhenMTRGegoyMTx6TiXlOVgMz9r0pDgXTEEIHA== dependencies: - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/visitor-keys" "4.14.0" + "@typescript-eslint/types" "4.20.0" + "@typescript-eslint/visitor-keys" "4.20.0" debug "^4.1.1" globby "^11.0.1" is-glob "^4.0.1" - lodash "^4.17.15" semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/visitor-keys@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.14.0.tgz#b1090d9d2955b044b2ea2904a22496849acbdf54" - integrity sha512-MeHHzUyRI50DuiPgV9+LxcM52FCJFYjJiWHtXlbyC27b80mfOwKeiKI+MHOTEpcpfmoPFm/vvQS88bYIx6PZTA== +"@typescript-eslint/visitor-keys@4.20.0": + version "4.20.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.20.0.tgz#1e84db034da13f208325e6bfc995c3b75f7dbd62" + integrity sha512-NXKRM3oOVQL8yNFDNCZuieRIwZ5UtjNLYtmMx2PacEAGmbaEYtGgVHUHVyZvU/0rYZcizdrWjDo+WBtRPSgq+A== dependencies: - "@typescript-eslint/types" "4.14.0" + "@typescript-eslint/types" "4.20.0" eslint-visitor-keys "^2.0.0" +"@wojtekmaj/enzyme-adapter-react-17@^0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@wojtekmaj/enzyme-adapter-react-17/-/enzyme-adapter-react-17-0.6.1.tgz#28caa37118c183e5f13c4dfb68cc32cde828ecbc" + integrity sha512-xgPfzLVpN0epIHeZofahwr5qwpukEDNAbrufgeDWN6vZPtfblGCC+OZG5TlfK+A6ePVy8sBkD8S2X4tO17JKjg== + dependencies: + "@wojtekmaj/enzyme-adapter-utils" "^0.1.0" + enzyme-shallow-equal "^1.0.0" + has "^1.0.0" + object.assign "^4.1.0" + object.values "^1.1.0" + prop-types "^15.7.0" + react-is "^17.0.0" + react-test-renderer "^17.0.0" + +"@wojtekmaj/enzyme-adapter-utils@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@wojtekmaj/enzyme-adapter-utils/-/enzyme-adapter-utils-0.1.0.tgz#3a2a3db756111d53357e2f119a1612a969ab8c38" + integrity sha512-EYK/Vy0Y1ap0jH2UNQjOKtR/7HWkbEq8N+cwC5+yDf+Mwp5uu7j4Qg70RmWuzsA35DGGwgkop6m4pQsGwNOF2A== + dependencies: + function.prototype.name "^1.1.0" + has "^1.0.0" + object.assign "^4.1.0" + object.fromentries "^2.0.0" + prop-types "^15.7.0" + abab@^2.0.3: version "2.0.5" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" @@ -1756,7 +1839,7 @@ acorn-globals@^6.0.0: acorn "^7.1.1" acorn-walk "^7.1.1" -acorn-jsx@^5.2.0, acorn-jsx@^5.3.1: +acorn-jsx@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b" integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng== @@ -1771,22 +1854,7 @@ acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -airbnb-prop-types@^2.16.0: - version "2.16.0" - resolved "https://registry.yarnpkg.com/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz#b96274cefa1abb14f623f804173ee97c13971dc2" - integrity sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg== - dependencies: - array.prototype.find "^2.1.1" - function.prototype.name "^1.1.2" - is-regex "^1.1.0" - object-is "^1.1.2" - object.assign "^4.1.0" - object.entries "^1.1.2" - prop-types "^15.7.2" - prop-types-exact "^1.2.0" - react-is "^16.13.1" - -ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4: +ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -1911,14 +1979,6 @@ array-unique@^0.3.2: resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= -array.prototype.find@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/array.prototype.find/-/array.prototype.find-2.1.1.tgz#3baca26108ca7affb08db06bf0be6cb3115a969c" - integrity sha512-mi+MYNJYLTx2eNYy+Yh6raoQacCsNeeMUaspFPh9Y141lFSsWxxB8V9mM2ye+eqiRs917J6/pJ4M9ZPzenWckA== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.17.4" - array.prototype.flat@^1.2.3: version "1.2.4" resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123" @@ -1972,11 +2032,6 @@ assign-symbols@^1.0.0: resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= -astral-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" - integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== - astral-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" @@ -2025,18 +2080,6 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== -babel-eslint@^10.0.1, babel-eslint@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232" - integrity sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg== - dependencies: - "@babel/code-frame" "^7.0.0" - "@babel/parser" "^7.7.0" - "@babel/traverse" "^7.7.0" - "@babel/types" "^7.7.0" - eslint-visitor-keys "^1.0.0" - resolve "^1.12.0" - babel-jest@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.6.3.tgz#d87d25cb0037577a0c89f82e5755c5d293c01056" @@ -2105,14 +2148,6 @@ babel-preset-jest@^26.6.2: babel-plugin-jest-hoist "^26.6.2" babel-preset-current-node-syntax "^1.0.0" -babel-runtime@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" - integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.11.0" - bail@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" @@ -2170,10 +2205,10 @@ bluebird@^3.5.0: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== -blueimp-canvas-to-blob@^3.28.0: - version "3.28.0" - resolved "https://registry.yarnpkg.com/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.28.0.tgz#c8ab4dc6bb08774a7f273798cdf94b0776adf6c8" - integrity sha512-5q+YHzgGsuHQ01iouGgJaPJXod2AzTxJXmVv90PpGrRxU7G7IqgPqWXz+PBmt3520jKKi6irWbNV87DicEa7wg== +blurhash@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.1.3.tgz#dc325af7da836d07a0861d830bdd63694382483e" + integrity sha512-yUhPJvXexbqbyijCIE/T2NCXcj9iNPhWmOKbPTuR/cm7Q5snXYIfnVnz6m7MWOXxODMz/Cr3UcVkRdHiuDVRDw== boolbase@^1.0.0: version "1.0.0" @@ -2330,9 +2365,9 @@ camelcase@^6.0.0: integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001173: - version "1.0.30001178" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001178.tgz#3ad813b2b2c7d585b0be0a2440e1e233c6eabdbc" - integrity sha512-VtdZLC0vsXykKni8Uztx45xynytOi71Ufx9T8kHptSw9AL4dpqailUJJHavttuzUe1KYuBYtChiWv+BAb7mPmQ== + version "1.0.30001241" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001241.tgz" + integrity sha512-1uoSZ1Pq1VpH0WerIMqwptXHNNGfdl7d1cJUFs80CwQ/lVzdhTvsFZCeNFslze7AjsQnb4C85tzclPa1VShbeQ== capture-exit@^2.0.0: version "2.0.0" @@ -2346,7 +2381,7 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= -chalk@^2.0.0, chalk@^2.1.0, chalk@^2.4.2: +chalk@^2.0.0, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -2391,34 +2426,29 @@ character-reference-invalid@^1.0.0: resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg== -chardet@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" - integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== - -cheerio-select-tmp@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/cheerio-select-tmp/-/cheerio-select-tmp-0.1.1.tgz#55bbef02a4771710195ad736d5e346763ca4e646" - integrity sha512-YYs5JvbpU19VYJyj+F7oYrIE2BOll1/hRU7rEy/5+v9BzkSo3bK81iAeeQEMI92vRIxz677m72UmJUiVwwgjfQ== +cheerio-select@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-1.4.0.tgz#3a16f21e37a2ef0f211d6d1aa4eff054bb22cdc9" + integrity sha512-sobR3Yqz27L553Qa7cK6rtJlMDbiKPdNywtR95Sj/YgfpLfy0u6CGJuaBKe5YE/vTc23SCRKxWSdlon/w6I/Ew== dependencies: - css-select "^3.1.2" - css-what "^4.0.0" - domelementtype "^2.1.0" - domhandler "^4.0.0" - domutils "^2.4.4" + css-select "^4.1.2" + css-what "^5.0.0" + domelementtype "^2.2.0" + domhandler "^4.2.0" + domutils "^2.6.0" -cheerio@^1.0.0-rc.3, cheerio@^1.0.0-rc.5: - version "1.0.0-rc.5" - resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.5.tgz#88907e1828674e8f9fee375188b27dadd4f0fa2f" - integrity sha512-yoqps/VCaZgN4pfXtenwHROTp8NG6/Hlt4Jpz2FEP0ZJQ+ZUkVDd0hAPDNKhj3nakpfPt/CNs57yEtxD1bXQiw== +cheerio@^1.0.0-rc.3, cheerio@^1.0.0-rc.9: + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.9.tgz#a3ae6b7ce7af80675302ff836f628e7cb786a67f" + integrity sha512-QF6XVdrLONO6DXRF5iaolY+odmhj2CLj+xzNod7INPWMi/x9X4SOylH0S/vaPpX+AUU6t04s34SQNh7DbkuCng== dependencies: - cheerio-select-tmp "^0.1.0" - dom-serializer "~1.2.0" - domhandler "^4.0.0" - entities "~2.1.0" - htmlparser2 "^6.0.0" - parse5 "^6.0.0" - parse5-htmlparser2-tree-adapter "^6.0.0" + cheerio-select "^1.4.0" + dom-serializer "^1.3.1" + domhandler "^4.2.0" + htmlparser2 "^6.1.0" + parse5 "^6.0.1" + parse5-htmlparser2-tree-adapter "^6.0.1" + tslib "^2.2.0" chokidar@^3.4.0, chokidar@^3.5.1: version "3.5.1" @@ -2460,18 +2490,6 @@ classnames@^2.2.6: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== -cli-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" - integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== - dependencies: - restore-cursor "^3.1.0" - -cli-width@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" - integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== - cliui@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" @@ -2601,11 +2619,6 @@ concurrently@^5.3.0: tree-kill "^1.2.2" yargs "^13.3.0" -contains-path@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" - integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= - content-type@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" @@ -2636,11 +2649,6 @@ core-js@^1.0.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" integrity sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY= -core-js@^2.4.0: - version "2.6.12" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" - integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== - core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -2680,7 +2688,7 @@ cross-fetch@^3.0.4: dependencies: node-fetch "2.6.1" -cross-spawn@^6.0.0, cross-spawn@^6.0.5: +cross-spawn@^6.0.0: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== @@ -2700,21 +2708,28 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -css-select@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-3.1.2.tgz#d52cbdc6fee379fba97fb0d3925abbd18af2d9d8" - integrity sha512-qmss1EihSuBNWNNhHjxzxSfJoFBM/lERB/Q4EnsJQQC62R2evJDW481091oAdOr9uh46/0n4nrg0It5cAnj1RA== +css-box-model@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" + integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== + dependencies: + tiny-invariant "^1.0.6" + +css-select@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.1.2.tgz#8b52b6714ed3a80d8221ec971c543f3b12653286" + integrity sha512-nu5ye2Hg/4ISq4XqdLY2bEatAcLIdt3OYGFc9Tm9n7VSlFBcfRv0gBNksHRgSdUDQGtN3XrZ94ztW+NfzkFSUw== dependencies: boolbase "^1.0.0" - css-what "^4.0.0" - domhandler "^4.0.0" - domutils "^2.4.3" + css-what "^5.0.0" + domhandler "^4.2.0" + domutils "^2.6.0" nth-check "^2.0.0" -css-what@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-4.0.0.tgz#35e73761cab2eeb3d3661126b23d7aa0e8432233" - integrity sha512-teijzG7kwYfNVsUh2H/YN62xW3KK9YhXEgSlbxMlcyjPNvdKJqFx5lrwlJgoFP1ZHlB89iGDlo/JyshKeRhv5A== +css-what@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.1.tgz#3efa820131f4669a8ac2408f9c32e7c7de9f4cad" + integrity sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg== cssesc@^3.0.0: version "3.0.0" @@ -2774,7 +2789,7 @@ date-names@^0.1.11: resolved "https://registry.yarnpkg.com/date-names/-/date-names-0.1.13.tgz#c4358f6f77c8056e2f5ea68fdbb05f0bf1e53bd0" integrity sha512-IxxoeD9tdx8pXVcmqaRlPvrXIsSrSrIZzfzlOkm9u+hyzKp5Wk/odt9O/gd7Ockzy8n/WHeEpTVJ2bF3mMV4LA== -debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: +debug@^2.2.0, debug@^2.3.3: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -2897,14 +2912,6 @@ discontinuous-range@1.0.0: resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" integrity sha1-44Mx8IRLukm5qctxx3FYWqsbxlo= -doctrine@1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" - integrity sha1-N53Ocw9hZvds76TmcHoVmwLFpvo= - dependencies: - esutils "^2.0.2" - isarray "^1.0.0" - doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -2920,9 +2927,9 @@ doctrine@^3.0.0: esutils "^2.0.2" dom-helpers@^5.0.1: - version "5.2.0" - resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.0.tgz#57fd054c5f8f34c52a3eeffdb7e7e93cd357d95b" - integrity sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ== + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== dependencies: "@babel/runtime" "^7.8.7" csstype "^3.0.2" @@ -2935,10 +2942,10 @@ dom-serializer@0: domelementtype "^2.0.1" entities "^2.0.0" -dom-serializer@^1.0.1, dom-serializer@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.2.0.tgz#3433d9136aeb3c627981daa385fc7f32d27c48f1" - integrity sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA== +dom-serializer@^1.0.1, dom-serializer@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.1.tgz#d845a1565d7c041a95e5dab62184ab41e3a519be" + integrity sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q== dependencies: domelementtype "^2.0.1" domhandler "^4.0.0" @@ -2949,10 +2956,10 @@ domelementtype@1, domelementtype@^1.3.1: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== -domelementtype@^2.0.1, domelementtype@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e" - integrity sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w== +domelementtype@^2.0.1, domelementtype@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" + integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== domexception@^2.0.1: version "2.0.1" @@ -2968,19 +2975,12 @@ domhandler@^2.3.0: dependencies: domelementtype "1" -domhandler@^3.0.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a" - integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA== +domhandler@^4.0.0, domhandler@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.0.tgz#f9768a5f034be60a89a27c2e4d0f74eba0d8b059" + integrity sha512-zk7sgt970kzPks2Bf+dwT/PLzghLnsivb9CcxkvR8Mzr66Olr0Ofd8neSbglHJHaHa2MadfoSdNlKYAaafmWfA== dependencies: - domelementtype "^2.0.1" - -domhandler@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.0.0.tgz#01ea7821de996d85f69029e81fa873c21833098e" - integrity sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA== - dependencies: - domelementtype "^2.1.0" + domelementtype "^2.2.0" domutils@^1.5.1: version "1.7.0" @@ -2990,14 +2990,14 @@ domutils@^1.5.1: dom-serializer "0" domelementtype "1" -domutils@^2.0.0, domutils@^2.4.3, domutils@^2.4.4: - version "2.4.4" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.4.4.tgz#282739c4b150d022d34699797369aad8d19bbbd3" - integrity sha512-jBC0vOsECI4OMdD0GC9mGn7NXPLb+Qt6KW1YDQzeQYRUFKmNG8lh7mO5HiELfr+lLQE7loDVI4QcAxV80HS+RA== +domutils@^2.4.4, domutils@^2.5.2, domutils@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.6.0.tgz#2e15c04185d43fb16ae7057cb76433c6edb938b7" + integrity sha512-y0BezHuy4MDYxh6OvolXYsH+1EMGmFbwv5FKW7ovwMG6zTPWqNPq3WF9ayZssFq+UlKdffGLbOEaghNdaOm1WA== dependencies: dom-serializer "^1.0.1" - domelementtype "^2.0.1" - domhandler "^4.0.0" + domelementtype "^2.2.0" + domhandler "^4.2.0" ecc-jsbn@~0.1.1: version "0.1.2" @@ -3063,7 +3063,7 @@ entities@^1.1.1: resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== -entities@^2.0.0, entities@~2.1.0: +entities@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== @@ -3073,35 +3073,7 @@ entities@~2.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f" integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== -enzyme-adapter-react-16@^1.15.6: - version "1.15.6" - resolved "https://registry.yarnpkg.com/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.6.tgz#fd677a658d62661ac5afd7f7f541f141f8085901" - integrity sha512-yFlVJCXh8T+mcQo8M6my9sPgeGzj85HSHi6Apgf1Cvq/7EL/J9+1JoJmJsRxZgyTvPMAqOEpRSu/Ii/ZpyOk0g== - dependencies: - enzyme-adapter-utils "^1.14.0" - enzyme-shallow-equal "^1.0.4" - has "^1.0.3" - object.assign "^4.1.2" - object.values "^1.1.2" - prop-types "^15.7.2" - react-is "^16.13.1" - react-test-renderer "^16.0.0-0" - semver "^5.7.0" - -enzyme-adapter-utils@^1.14.0: - version "1.14.0" - resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.0.tgz#afbb0485e8033aa50c744efb5f5711e64fbf1ad0" - integrity sha512-F/z/7SeLt+reKFcb7597IThpDp0bmzcH1E9Oabqv+o01cID2/YInlqHbFl7HzWBl4h3OdZYedtwNDOmSKkk0bg== - dependencies: - airbnb-prop-types "^2.16.0" - function.prototype.name "^1.1.3" - has "^1.0.3" - object.assign "^4.1.2" - object.fromentries "^2.0.3" - prop-types "^15.7.2" - semver "^5.7.1" - -enzyme-shallow-equal@^1.0.1, enzyme-shallow-equal@^1.0.4: +enzyme-shallow-equal@^1.0.0, enzyme-shallow-equal@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.4.tgz#b9256cb25a5f430f9bfe073a84808c1d74fced2e" integrity sha512-MttIwB8kKxypwHvRynuC3ahyNc+cFbR8mjVIltnmzQ0uKGqmsfO4bfBuLxb0beLNPhjblUEYvEbsg+VSygvF1Q== @@ -3137,14 +3109,14 @@ enzyme@^3.11.0: rst-selector-parser "^2.2.3" string.prototype.trim "^1.2.1" -error-ex@^1.2.0, error-ex@^1.3.1: +error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== dependencies: is-arrayish "^0.2.1" -es-abstract@^1.17.0-next.1, es-abstract@^1.17.4: +es-abstract@^1.17.0-next.1: version "1.17.7" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== @@ -3181,6 +3153,28 @@ es-abstract@^1.18.0-next.1: string.prototype.trimend "^1.0.3" string.prototype.trimstart "^1.0.3" +es-abstract@^1.18.0-next.2, es-abstract@^1.18.2: + version "1.18.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.3.tgz#25c4c3380a27aa203c44b2b685bba94da31b63e0" + integrity sha512-nQIr12dxV7SSxE6r6f1l3DtAeEYdsGpps13dR0TwJg1S8gyp4ZPgy3FZcHBgbiQqnoqSTb+oC+kO4UQ0C/J8vw== + dependencies: + call-bind "^1.0.2" + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + get-intrinsic "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.2" + is-callable "^1.2.3" + is-negative-zero "^2.0.1" + is-regex "^1.1.3" + is-string "^1.0.6" + object-inspect "^1.10.3" + object-keys "^1.1.1" + object.assign "^4.1.2" + string.prototype.trimend "^1.0.4" + string.prototype.trimstart "^1.0.4" + unbox-primitive "^1.0.1" + es-get-iterator@^1.0.1: version "1.1.1" resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.1.tgz#b93ddd867af16d5118e00881396533c1c6647ad9" @@ -3241,131 +3235,21 @@ escodegen@^1.14.1: optionalDependencies: source-map "~0.6.1" -eslint-config-esnext@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-esnext/-/eslint-config-esnext-4.1.0.tgz#8695b858fcf40d28c1aedca181f700528c7b60c6" - integrity sha512-GhfVEXdqYKEIIj7j+Fw2SQdL9qyZMekgXfq6PyXM66cQw0B435ddjz3P3kxOBVihMRJ0xGYjosaveQz5Y6z0uA== - dependencies: - babel-eslint "^10.0.1" - eslint "^6.8.0" - eslint-plugin-babel "^5.2.1" - eslint-plugin-import "^2.14.0" - eslint-config-google@^0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.14.0.tgz#4f5f8759ba6e11b424294a219dbfa18c508bcc1a" integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw== -eslint-config-matrix-org@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/eslint-config-matrix-org/-/eslint-config-matrix-org-0.2.0.tgz#27571c7c3a9ab6cc1e9d0f3d53a3739d1b004a7f" - integrity sha512-Hwgm1Qk0atjWgj5SWxpFPvxy6k6bDlPb9Pqjvb1i59nx8Zm6jDPy1C5vKQpkFpc0ntr+gJHa+ejYhuhaofu6Mw== - dependencies: - "@typescript-eslint/eslint-plugin" "^4.14.0" - "@typescript-eslint/parser" "^4.14.0" - babel-eslint "^10.1.0" - eslint-config-google "^0.14.0" - eslint-config-recommended "^4.1.0" - eslint-plugin-babel "^5.3.1" - typescript "^4.1.3" - -eslint-config-node@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-node/-/eslint-config-node-4.1.0.tgz#fc1f13946d83766d6b83b0e67699e2071a56f417" - integrity sha512-Wz17xV5O2WFG8fGdMYEBdbiL6TL7YNJSJvSX9V4sXQownewfYmoqlly7wxqLkOUv/57pq6LnnotMiQQrrPjCqQ== - dependencies: - eslint "^6.8.0" - eslint-config-esnext "^4.1.0" - -eslint-config-react-native@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-react-native/-/eslint-config-react-native-4.1.0.tgz#63e9401c7fac146804785f609e7df8f15b3e04eb" - integrity sha512-kNND+cs+ztawH7wgajf/K6FfNshjlDsFDAkkFZF9HAXDgH1w1sNMIfTfwzufg0hOcSK7rbiL4qbG/gg/oR507Q== - dependencies: - eslint "^6.8.0" - eslint-config-esnext "^4.1.0" - eslint-plugin-react "^7.19.0" - eslint-plugin-react-native "^3.8.1" - -eslint-config-recommended@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-recommended/-/eslint-config-recommended-4.1.0.tgz#1adff90e0716d439be471d192977f233de171a46" - integrity sha512-2evA0SX1VqtyFiExmBI2WAO4XQCKlr7wmNELE8rcT5PyZY2ixsY881ofVZWKuI/dywpgLiES1gR/XUQcnVLRzQ== - dependencies: - eslint "^6.8.0" - eslint-config-esnext "^4.1.0" - eslint-config-node "^4.1.0" - eslint-config-react-native "^4.1.0" - -eslint-import-resolver-node@^0.3.4: - version "0.3.4" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717" - integrity sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA== - dependencies: - debug "^2.6.9" - resolve "^1.13.1" - -eslint-module-utils@^2.6.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz#579ebd094f56af7797d19c9866c9c9486629bfa6" - integrity sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA== - dependencies: - debug "^2.6.9" - pkg-dir "^2.0.0" - -eslint-plugin-babel@^5.2.1, eslint-plugin-babel@^5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-babel/-/eslint-plugin-babel-5.3.1.tgz#75a2413ffbf17e7be57458301c60291f2cfbf560" - integrity sha512-VsQEr6NH3dj664+EyxJwO4FCYm/00JhYb3Sk3ft8o+fpKuIfQ9TaW6uVUfvwMXHcf/lsnRIoyFPsLMyiWCSL/g== - dependencies: - eslint-rule-composer "^0.3.0" - -eslint-plugin-flowtype@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-5.2.0.tgz#a4bef5dc18f9b2bdb41569a4ab05d73805a3d261" - integrity sha512-z7ULdTxuhlRJcEe1MVljePXricuPOrsWfScRXFhNzVD5dmTHWjIF57AxD0e7AbEoLSbjSsaA5S+hCg43WvpXJQ== - dependencies: - lodash "^4.17.15" - string-natural-compare "^3.0.1" - -eslint-plugin-import@^2.14.0: - version "2.22.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz#0896c7e6a0cf44109a2d97b95903c2bb689d7702" - integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw== - dependencies: - array-includes "^3.1.1" - array.prototype.flat "^1.2.3" - contains-path "^0.1.0" - debug "^2.6.9" - doctrine "1.5.0" - eslint-import-resolver-node "^0.3.4" - eslint-module-utils "^2.6.0" - has "^1.0.3" - minimatch "^3.0.4" - object.values "^1.1.1" - read-pkg-up "^2.0.0" - resolve "^1.17.0" - tsconfig-paths "^3.9.0" +"eslint-plugin-matrix-org@github:matrix-org/eslint-plugin-matrix-org#main": + version "0.3.2" + resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/e8197938dca66849ffdac4baca7c05275df12835" eslint-plugin-react-hooks@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.2.0.tgz#8c229c268d468956334c943bb45fc860280f5556" integrity sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ== -eslint-plugin-react-native-globals@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-react-native-globals/-/eslint-plugin-react-native-globals-0.1.2.tgz#ee1348bc2ceb912303ce6bdbd22e2f045ea86ea2" - integrity sha512-9aEPf1JEpiTjcFAmmyw8eiIXmcNZOqaZyHO77wgm0/dWfT/oxC1SrIq8ET38pMxHYrcB6Uew+TzUVsBeczF88g== - -eslint-plugin-react-native@^3.8.1: - version "3.10.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-react-native/-/eslint-plugin-react-native-3.10.0.tgz#240f7e6979a908af3dfd9ba9652434c33f4d64cd" - integrity sha512-4f5+hHYYq5wFhB5eptkPEAR7FfvqbS7AzScUOANfAMZtYw5qgnCxRq45bpfBaQF+iyPMim5Q8pubcpvLv75NAg== - dependencies: - "@babel/traverse" "^7.7.4" - eslint-plugin-react-native-globals "^0.1.1" - -eslint-plugin-react@^7.19.0, eslint-plugin-react@^7.22.0: +eslint-plugin-react@^7.22.0: version "7.22.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.22.0.tgz#3d1c542d1d3169c45421c1215d9470e341707269" integrity sha512-p30tuX3VS+NWv9nQot9xIGAHBXR0+xJVaZriEsHoJrASGCJZDJ8JLNM0YqKqI0AKm6Uxaa1VUHoNEibxRCMQHA== @@ -3387,7 +3271,7 @@ eslint-rule-composer@^0.3.0: resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9" integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== -eslint-scope@^5.0.0, eslint-scope@^5.1.1: +eslint-scope@^5.0.0, eslint-scope@^5.1.0, eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== @@ -3395,13 +3279,6 @@ eslint-scope@^5.0.0, eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-utils@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" - integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q== - dependencies: - eslint-visitor-keys "^1.1.0" - eslint-utils@^2.0.0, eslint-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" @@ -3409,7 +3286,7 @@ eslint-utils@^2.0.0, eslint-utils@^2.1.0: dependencies: eslint-visitor-keys "^1.1.0" -eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: +eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== @@ -3462,58 +3339,6 @@ eslint@7.18.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -eslint@^6.8.0: - version "6.8.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.8.0.tgz#62262d6729739f9275723824302fb227c8c93ffb" - integrity sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig== - dependencies: - "@babel/code-frame" "^7.0.0" - ajv "^6.10.0" - chalk "^2.1.0" - cross-spawn "^6.0.5" - debug "^4.0.1" - doctrine "^3.0.0" - eslint-scope "^5.0.0" - eslint-utils "^1.4.3" - eslint-visitor-keys "^1.1.0" - espree "^6.1.2" - esquery "^1.0.1" - esutils "^2.0.2" - file-entry-cache "^5.0.1" - functional-red-black-tree "^1.0.1" - glob-parent "^5.0.0" - globals "^12.1.0" - ignore "^4.0.6" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - inquirer "^7.0.0" - is-glob "^4.0.0" - js-yaml "^3.13.1" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.3.0" - lodash "^4.17.14" - minimatch "^3.0.4" - mkdirp "^0.5.1" - natural-compare "^1.4.0" - optionator "^0.8.3" - progress "^2.0.0" - regexpp "^2.0.1" - semver "^6.1.2" - strip-ansi "^5.2.0" - strip-json-comments "^3.0.1" - table "^5.2.3" - text-table "^0.2.0" - v8-compile-cache "^2.0.3" - -espree@^6.1.2: - version "6.2.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a" - integrity sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw== - dependencies: - acorn "^7.1.1" - acorn-jsx "^5.2.0" - eslint-visitor-keys "^1.1.0" - espree@^7.3.0, espree@^7.3.1: version "7.3.1" resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" @@ -3528,7 +3353,7 @@ esprima@^4.0.0, esprima@^4.0.1: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.0.1, esquery@^1.2.0: +esquery@^1.2.0: version "1.3.1" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.3.1.tgz#b78b5828aa8e214e29fb74c4d5b752e1c033da57" integrity sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ== @@ -3672,15 +3497,6 @@ extend@^3.0.0, extend@~3.0.2: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -external-editor@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" - integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== - dependencies: - chardet "^0.7.0" - iconv-lite "^0.4.24" - tmp "^0.0.33" - extglob@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" @@ -3785,20 +3601,6 @@ fbjs@^0.8.4: setimmediate "^1.0.5" ua-parser-js "^0.7.18" -figures@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" - integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== - dependencies: - escape-string-regexp "^1.0.5" - -file-entry-cache@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c" - integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g== - dependencies: - flat-cache "^2.0.1" - file-entry-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.0.tgz#7921a89c391c6d93efec2169ac6bf300c527ea0a" @@ -3842,13 +3644,6 @@ find-cache-dir@^2.0.0: make-dir "^2.0.0" pkg-dir "^3.0.0" -find-up@^2.0.0, find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= - dependencies: - locate-path "^2.0.0" - find-up@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" @@ -3864,15 +3659,6 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" -flat-cache@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" - integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA== - dependencies: - flatted "^2.0.0" - rimraf "2.6.3" - write "1.0.3" - flat-cache@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" @@ -3881,11 +3667,6 @@ flat-cache@^3.0.4: flatted "^3.1.0" rimraf "^3.0.2" -flatted@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" - integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== - flatted@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" @@ -3963,7 +3744,17 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -function.prototype.name@^1.1.2, function.prototype.name@^1.1.3: +function.prototype.name@^1.1.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.4.tgz#e4ea839b9d3672ae99d0efd9f38d9191c5eaac83" + integrity sha512-iqy1pIotY/RmhdFZygSSlW0wko2yxkSCKqsuv4pr8QESohpYyG/Z7B/XXvPRKTJS//960rgguE5mSRUsDdaJrQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.18.0-next.2" + functions-have-names "^1.2.2" + +function.prototype.name@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.3.tgz#0bb034bb308e7682826f215eb6b2ae64918847fe" integrity sha512-H51qkbNSp8mtkJt+nyW1gyStBiKZxfRqySNUR99ylq6BPXHKI4SEvIlTKp4odLfjRKJV04DFWMU3G/YRlQOsag== @@ -3978,7 +3769,7 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= -functions-have-names@^1.2.0, functions-have-names@^1.2.1: +functions-have-names@^1.2.0, functions-have-names@^1.2.1, functions-have-names@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.2.tgz#98d93991c39da9361f8e50b337c4f6e41f120e21" integrity sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA== @@ -4002,6 +3793,15 @@ get-intrinsic@^1.0.1, get-intrinsic@^1.0.2: has "^1.0.3" has-symbols "^1.0.1" +get-intrinsic@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" + integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + get-package-type@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" @@ -4103,7 +3903,19 @@ globals@^12.1.0: dependencies: type-fest "^0.8.1" -globby@^11.0.1, globby@^11.0.2: +globby@^11.0.1: + version "11.0.3" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.3.tgz#9b1f0cb523e171dd1ad8c7b2a9fb4b644b9593cb" + integrity sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.1.1" + ignore "^5.1.4" + merge2 "^1.3.0" + slash "^3.0.0" + +globby@^11.0.2: version "11.0.2" resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.2.tgz#1af538b766a3b540ebfb58a32b2e2d5897321d83" integrity sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og== @@ -4127,7 +3939,7 @@ gonzales-pe@^4.3.0: dependencies: minimist "^1.2.5" -graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.2.4: +graceful-fs@^4.1.11, graceful-fs@^4.2.4: version "4.2.4" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== @@ -4155,6 +3967,11 @@ hard-rejection@^2.1.0: resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA== +has-bigints@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" + integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -4170,6 +3987,11 @@ has-symbols@^1.0.1: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== +has-symbols@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" + integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== + has-value@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" @@ -4201,7 +4023,7 @@ has-values@^1.0.0: is-number "^3.0.0" kind-of "^4.0.0" -has@^1.0.1, has@^1.0.3: +has@^1.0.0, has@^1.0.1, has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== @@ -4213,7 +4035,7 @@ highlight.js@^10.5.0: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.5.0.tgz#3f09fede6a865757378f2d9ebdcbc15ba268f98f" integrity sha512-xTmvd9HiIHR6L53TMC7TKolEj65zG1XU+Onr8oi86mYa+nLcIbxTTWkpW7CsEwv/vK7u1zb8alZIMLDqqN6KTw== -hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -4221,9 +4043,9 @@ hoist-non-react-statics@^3.3.0: react-is "^16.7.0" hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== hosted-git-info@^3.0.6: version "3.0.7" @@ -4273,16 +4095,6 @@ htmlparser2@^3.10.0: inherits "^2.0.1" readable-stream "^3.1.1" -htmlparser2@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78" - integrity sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q== - dependencies: - domelementtype "^2.0.1" - domhandler "^3.0.0" - domutils "^2.0.0" - entities "^2.0.0" - htmlparser2@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.0.0.tgz#c2da005030390908ca4c91e5629e418e0665ac01" @@ -4293,6 +4105,16 @@ htmlparser2@^6.0.0: domutils "^2.4.4" entities "^2.0.0" +htmlparser2@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" + integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.0.0" + domutils "^2.5.2" + entities "^2.0.0" + http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" @@ -4307,7 +4129,7 @@ human-signals@^1.1.1: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== -iconv-lite@0.4.24, iconv-lite@^0.4.24: +iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -4400,25 +4222,6 @@ ini@^1.3.5: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== -inquirer@^7.0.0: - version "7.3.3" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003" - integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA== - dependencies: - ansi-escapes "^4.2.1" - chalk "^4.1.0" - cli-cursor "^3.1.0" - cli-width "^3.0.0" - external-editor "^3.0.3" - figures "^3.0.0" - lodash "^4.17.19" - mute-stream "0.0.8" - run-async "^2.4.0" - rxjs "^6.6.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - through "^2.3.6" - internal-slot@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.2.tgz#9c2e9fb3cd8e5e4256c6f45fe310067fcfa378a3" @@ -4428,13 +4231,6 @@ internal-slot@^1.0.2: has "^1.0.3" side-channel "^1.0.2" -invariant@^2.2.2, invariant@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" @@ -4532,6 +4328,11 @@ is-callable@^1.0.4, is-callable@^1.1.4, is-callable@^1.1.5, is-callable@^1.2.2: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== +is-callable@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.3.tgz#8b1e0500b73a1d76c70487636f368e519de8db8e" + integrity sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ== + is-ci@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" @@ -4734,13 +4535,21 @@ is-potential-custom-element-name@^1.0.0: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397" integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c= -is-regex@^1.0.3, is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.0, is-regex@^1.1.1: +is-regex@^1.0.3, is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== dependencies: has-symbols "^1.0.1" +is-regex@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.3.tgz#d029f9aff6448b93ebbe3f33dac71511fdcbef9f" + integrity sha512-qSVXFz28HM7y+IWX6vLCsexdlvzT1PJNFSBuaQLQ5o0IEw8UDYW6/2+eCMVyIsbM8CNLX2a/QWmSpyxYEHY7CQ== + dependencies: + call-bind "^1.0.2" + has-symbols "^1.0.2" + is-regexp@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-2.1.0.tgz#cd734a56864e23b956bf4e7c66c396a4c0b22c2d" @@ -4766,6 +4575,11 @@ is-string@^1.0.4, is-string@^1.0.5: resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6" integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ== +is-string@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.6.tgz#3fe5d5992fb0d93404f32584d4b0179a71b54a5f" + integrity sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w== + is-subset@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6" @@ -4805,7 +4619,7 @@ is-wsl@^2.2.0: dependencies: is-docker "^2.0.0" -isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: +isarray@1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= @@ -5412,13 +5226,6 @@ json-stringify-safe@~5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= -json5@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" - integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== - dependencies: - minimist "^1.2.0" - json5@^2.1.2: version "2.1.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" @@ -5495,14 +5302,6 @@ leven@^3.1.0: resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== -levn@^0.3.0, levn@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= - dependencies: - prelude-ls "~1.1.2" - type-check "~0.3.2" - levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -5511,6 +5310,14 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -5521,24 +5328,6 @@ linkifyjs@^2.1.9: resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-2.1.9.tgz#af06e45a2866ff06c4766582590d098a4d584702" integrity sha512-74ivurkK6WHvHFozVaGtQWV38FzBwSTGNmJolEgFp7QgR2bl6ArUWlvT4GcHKbPe1z3nWYi+VUdDZk16zDOVug== -load-json-file@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" - integrity sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg= - dependencies: - graceful-fs "^4.1.2" - parse-json "^2.2.0" - pify "^2.0.0" - strip-bom "^3.0.0" - -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= - dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" - locate-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" @@ -5554,11 +5343,6 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" -lodash-es@^4.2.1: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.20.tgz#29f6332eefc60e849f869c264bc71126ad61e8f7" - integrity sha512-JD1COMZsq8maT6mnuz1UMV0jvYD0E0aUsSOdrr1/nAG3dhqQXwRRgeW0cSqH1U43INKcqxaiVIQNOUDld7gRDA== - lodash.escape@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-4.0.1.tgz#c9044690c21e04294beaa517712fded1fa88de98" @@ -5579,10 +5363,10 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.2.1: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== log-symbols@^4.0.0: version "4.0.0" @@ -5671,9 +5455,10 @@ mathml-tag-names@^2.1.3: resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "10.0.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/c8f69c0b7937b9064938c134d708c4d064b71315" +matrix-js-sdk@12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-12.0.1.tgz#3a63881f743420a4d39474daa39bd0fb90930d43" + integrity sha512-HkOWv8QHojceo3kPbC+vAIFUjsRAig6MBvEY35UygS3g2dL0UcJ5Qx09/2wcXtu6dowlDnWsz2HHk62tS2cklA== dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -5681,6 +5466,7 @@ mathml-tag-names@^2.1.3: bs58 "^4.0.1" content-type "^1.0.4" loglevel "^1.7.1" + p-retry "^4.5.0" qs "^6.9.6" request "^2.88.2" unhomoglyph "^1.0.6" @@ -5693,10 +5479,10 @@ matrix-mock-request@^1.2.3: bluebird "^3.5.0" expect "^1.20.2" -matrix-react-test-utils@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853" - integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ== +matrix-react-test-utils@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.3.tgz#27653f9d6bbfddd1856e51860fad1503b039d617" + integrity sha512-NKZDlMEQzDZDQhBYyKBUtqidRvpkww3n9/GmGICkxtU2D6NetyBIfvm1Lf9o7167KSkPHJUVvDS9dzaS55jUnA== "matrix-web-i18n@github:matrix-org/matrix-web-i18n": version "1.1.2" @@ -5706,10 +5492,10 @@ matrix-react-test-utils@^0.2.2: "@babel/traverse" "^7.13.17" walk "^2.3.14" -matrix-widget-api@^0.1.0-beta.13: - version "0.1.0-beta.13" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.13.tgz#ebddc83eaef39bbb87b621a02a35902e1a29b9ef" - integrity sha512-DJAvuX2E7gxc/a9rtJPDh17ba9xGIOAoBHcWirNTN3KGodzsrZ+Ns+M/BREFWMwGS5yEBZko5eq7uhXStEbnyQ== +matrix-widget-api@^0.1.0-beta.15: + version "0.1.0-beta.15" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.15.tgz#b02511f93fe1a3634868b6e246d736107f182745" + integrity sha512-sWmtb8ZarSbHVbk5ni7IHBR9jOh7m1+5R4soky0fEO9VKl+MN7skT0+qNux3J9WuUAu2D80dZW9xPUT9cxfxbg== dependencies: "@types/events" "^3.0.0" events "^3.2.0" @@ -5747,10 +5533,10 @@ mdurl@~1.0.1: resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4= -memoize-one@^3.0.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-3.1.1.tgz#ef609811e3bc28970eac2884eece64d167830d17" - integrity sha512-YqVh744GsMlZu6xkhGslPSqSurOv6P+kLN2J3ysBZfagLcL5FdRK/0UpgLoL8hwjjEvvAVkjJZyFP+1T6p1vgA== +memoize-one@^5.1.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== meow@^9.0.0: version "9.0.0" @@ -5866,13 +5652,6 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" -mkdirp@^0.5.1: - version "0.5.5" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" - integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== - dependencies: - minimist "^1.2.5" - moo-color@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.2.tgz#837c40758d2d58763825d1359a84e330531eca64" @@ -5895,11 +5674,6 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -mute-stream@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" - integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== - nanoid@^3.1.20: version "3.1.20" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788" @@ -6079,6 +5853,11 @@ object-inspect@^1.1.0, object-inspect@^1.7.0, object-inspect@^1.8.0, object-insp resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw== +object-inspect@^1.10.3: + version "1.10.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.10.3.tgz#c2aa7d2d09f50c99375704f7a0adf24c5782d369" + integrity sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw== + object-is@^1.0.2, object-is@^1.1.2: version "1.1.4" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.4.tgz#63d6c83c00a43f4cbc9434eb9757c8a5b8565068" @@ -6119,7 +5898,17 @@ object.entries@^1.1.0, object.entries@^1.1.1, object.entries@^1.1.2: es-abstract "^1.18.0-next.1" has "^1.0.3" -object.fromentries@^2.0.2, object.fromentries@^2.0.3: +object.fromentries@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.4.tgz#26e1ba5c4571c5c6f0890cef4473066456a120b8" + integrity sha512-EsFBshs5RUUpQEY1D4q/m59kMfz4YJvxuNCJcv/jWwOJr34EaVnG11ZrZa0UHB3wnzV1wx8m58T4hQL8IuNXlQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.18.0-next.2" + has "^1.0.3" + +object.fromentries@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.3.tgz#13cefcffa702dc67750314a3305e8cb3fad1d072" integrity sha512-IDUSMXs6LOSJBWE++L0lzIbSqHl9KDCfff2x/JSEIDtEUavUnyMYC2ZGay/04Zq4UT8lvd4xNhU4/YHKibAOlw== @@ -6136,7 +5925,16 @@ object.pick@^1.3.0: dependencies: isobject "^3.0.1" -object.values@^1.1.1, object.values@^1.1.2: +object.values@^1.1.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.4.tgz#0d273762833e816b693a637d30073e7051535b30" + integrity sha512-TnGo7j4XSnKQoK3MfvkzqKCi0nVe/D9I9IjwTNYdb/fxYHpjrluHVOgw0AF6jrRFGMPHdfuidR09tIDiIvnaSg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.18.2" + +object.values@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.2.tgz#7a2015e06fcb0f546bd652486ce8583a4731c731" integrity sha512-MYC0jvJopr8EK6dPBiO8Nb9mvjdypOachO5REGk6MXzujbBrAisKo3HmdEI6kZDL6fC31Mwee/5YbtMebixeag== @@ -6146,10 +5944,6 @@ object.values@^1.1.1, object.values@^1.1.2: es-abstract "^1.18.0-next.1" has "^1.0.3" -"olm@https://packages.matrix.org/npm/olm/olm-3.2.1.tgz": - version "3.2.1" - resolved "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz#d623d76f99c3518dde68be8c86618d68bc7b004a" - once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -6164,7 +5958,7 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" -optionator@^0.8.1, optionator@^0.8.3: +optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== @@ -6193,11 +5987,6 @@ opus-recorder@^8.0.3: resolved "https://registry.yarnpkg.com/opus-recorder/-/opus-recorder-8.0.3.tgz#f7b44f8f68500c9b96a15042a69f915fd9c1716d" integrity sha512-8vXGiRwlJAavT9D3yYzukNVXQ8vEcKHcsQL/zXO24DQtJ0PLXvoPHNQPJrbMCdB4ypJgWDExvHF4JitQDL7dng== -os-tmpdir@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= - p-each-series@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.2.0.tgz#105ab0357ce72b202a8a8b94933672657b5e2a9a" @@ -6208,13 +5997,6 @@ p-finally@^1.0.0: resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= -p-limit@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" - integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== - dependencies: - p-try "^1.0.0" - p-limit@^2.0.0, p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -6222,13 +6004,6 @@ p-limit@^2.0.0, p-limit@^2.2.0: dependencies: p-try "^2.0.0" -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= - dependencies: - p-limit "^1.1.0" - p-locate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" @@ -6243,10 +6018,13 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" -p-try@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" - integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= +p-retry@^4.5.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.0.tgz#9de15ae696278cffe86fce2d8f73b7f894f8bc9e" + integrity sha512-SAHbQEwg3X5DRNaLmWjT+DlGc93ba5i+aP3QLfVNDncQEQO4xjbYW4N/lcVTSuP0aJietGfx2t94dJLzfBMpXw== + dependencies: + "@types/retry" "^0.12.0" + retry "^0.13.1" p-try@^2.0.0: version "2.2.0" @@ -6277,13 +6055,6 @@ parse-entities@^2.0.0: is-decimal "^1.0.0" is-hexadecimal "^1.0.0" -parse-json@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" - integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck= - dependencies: - error-ex "^1.2.0" - parse-json@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" @@ -6302,7 +6073,12 @@ parse-json@^5.0.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse5-htmlparser2-tree-adapter@^6.0.0: +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha1-8r0iH2zJcKk42IVWq8WJyqqiveE= + +parse5-htmlparser2-tree-adapter@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== @@ -6314,7 +6090,7 @@ parse5@5.1.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== -parse5@^6.0.0, parse5@^6.0.1: +parse5@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== @@ -6359,23 +6135,11 @@ path-parse@^1.0.6: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== -path-type@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" - integrity sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM= - dependencies: - pify "^2.0.0" - path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -performance-now@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5" - integrity sha1-M+8wxcd9TqIcWlOGnZG1bY8lVeU= - performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" @@ -6386,11 +6150,6 @@ picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== -pify@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" - integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= - pify@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" @@ -6408,13 +6167,6 @@ pirates@^4.0.0, pirates@^4.0.1: dependencies: node-modules-regexp "^1.0.0" -pkg-dir@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" - integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s= - dependencies: - find-up "^2.1.0" - pkg-dir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" @@ -6518,9 +6270,9 @@ postcss-value-parser@^4.1.0: integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== postcss@^7.0.14, postcss@^7.0.2, postcss@^7.0.21, postcss@^7.0.26, postcss@^7.0.32, postcss@^7.0.35, postcss@^7.0.6: - version "7.0.35" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" - integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== + version "7.0.36" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.36.tgz#056f8cffa939662a8f5905950c07d5285644dfcb" + integrity sha512-BebJSIUMwJHRH0HAQoxN4u1CN86glsrwsW0q7T+/m44eXOUAxSNdHRkNZPYz5vVUbg17hFgOQDE7fZk7li3pZw== dependencies: chalk "^2.4.2" source-map "^0.6.1" @@ -6585,16 +6337,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types-exact@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/prop-types-exact/-/prop-types-exact-1.2.0.tgz#825d6be46094663848237e3925a98c6e944e9869" - integrity sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA== - dependencies: - has "^1.0.3" - object.assign "^4.1.0" - reflect.ownkeys "^0.2.0" - -prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.6.2, prop-types@^15.7.0, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -6671,12 +6414,12 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== -raf-schd@^2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-2.1.2.tgz#ec622b5167f2912089f054dc03ebd5bcf33c8f62" - integrity sha512-Orl0IEvMtUCgPddgSxtxreK77UiQz4nPYJy9RggVzu4mKsZkQWiAaG1y9HlYWdvm9xtN348xRaT37qkvL/+A+g== +raf-schd@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a" + integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ== -raf@^3.1.0, raf@^3.4.1: +raf@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== @@ -6703,21 +6446,23 @@ re-resizable@^6.9.0: dependencies: fast-memoize "^2.5.1" -react-beautiful-dnd@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-4.0.1.tgz#3b0a49bf6be75af351176c904f012611dd292b81" - integrity sha512-d73RMu4QOFCyjUELLWFyY/EuclnfqulI9pECx+2gIuJvV0ycf1uR88o+1x0RSB9ILD70inHMzCBKNkWVbbt+vA== +react-beautiful-dnd@^13.1.0: + version "13.1.0" + resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz#ec97c81093593526454b0de69852ae433783844d" + integrity sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA== dependencies: - babel-runtime "^6.26.0" - invariant "^2.2.2" - memoize-one "^3.0.1" - prop-types "^15.6.0" - raf-schd "^2.1.0" - react-motion "^0.5.2" - react-redux "^5.0.6" - redux "^3.7.2" - redux-thunk "^2.2.0" - reselect "^3.0.1" + "@babel/runtime" "^7.9.2" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.2.0" + redux "^4.0.4" + use-memo-one "^1.1.1" + +react-blurhash@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/react-blurhash/-/react-blurhash-0.1.3.tgz#735f28f8f07fb358d7efe7e7e6dc65a7272bf89e" + integrity sha512-Q9lqbXg92NU6/2DoIl/cBM8YWL+Z4X66OiG4aT9ozOgjBwx104LHFCH5stf6aF+s0Q9Wf310Ul+dG+VXJltmPg== react-clientside-effect@^1.2.2: version "1.2.3" @@ -6726,15 +6471,14 @@ react-clientside-effect@^1.2.2: dependencies: "@babel/runtime" "^7.0.0" -react-dom@^16.14.0: - version "16.14.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" - integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== +react-dom@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" + integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" - scheduler "^0.19.1" + scheduler "^0.20.2" react-focus-lock@^2.5.0: version "2.5.0" @@ -6748,7 +6492,12 @@ react-focus-lock@^2.5.0: use-callback-ref "^1.2.1" use-sidecar "^1.0.1" -react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6: +"react-is@^16.12.0 || ^17.0.0", react-is@^17.0.0, react-is@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + +react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -6758,42 +6507,35 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== -react-lifecycles-compat@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" - integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== - -react-motion@^0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316" - integrity sha512-9q3YAvHoUiWlP3cK0v+w1N5Z23HXMj4IF4YuvjvWegWqNPfLXsOBE/V7UvQGpXxHFKRQQcNcVQE31g9SB/6qgQ== +react-redux@^7.2.0: + version "7.2.4" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.4.tgz#1ebb474032b72d806de2e0519cd07761e222e225" + integrity sha512-hOQ5eOSkEJEXdpIKbnRyl04LhaWabkDPV+Ix97wqQX3T3d2NQ8DUblNXXtNMavc7DpswyQM6xfaN4HQDKNY2JA== dependencies: - performance-now "^0.2.0" - prop-types "^15.5.8" - raf "^3.1.0" + "@babel/runtime" "^7.12.1" + "@types/react-redux" "^7.1.16" + hoist-non-react-statics "^3.3.2" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^16.13.1" -react-redux@^5.0.6: - version "5.1.2" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.1.2.tgz#b19cf9e21d694422727bf798e934a916c4080f57" - integrity sha512-Ns1G0XXc8hDyH/OcBHOxNgQx9ayH3SPxBnFCOidGKSle8pKihysQw2rG/PmciUQRoclhVBO8HMhiRmGXnDja9Q== - dependencies: - "@babel/runtime" "^7.1.2" - hoist-non-react-statics "^3.3.0" - invariant "^2.2.4" - loose-envify "^1.1.0" - prop-types "^15.6.1" - react-is "^16.6.0" - react-lifecycles-compat "^3.0.0" - -react-test-renderer@^16.0.0-0, react-test-renderer@^16.14.0: - version "16.14.0" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.14.0.tgz#e98360087348e260c56d4fe2315e970480c228ae" - integrity sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg== +react-shallow-renderer@^16.13.1: + version "16.14.1" + resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.14.1.tgz#bf0d02df8a519a558fd9b8215442efa5c840e124" + integrity sha512-rkIMcQi01/+kxiTE9D3fdS959U1g7gs+/rborw++42m1O9FAQiNI/UNRZExVUoAOprn4umcXf+pFRou8i4zuBg== dependencies: object-assign "^4.1.1" - prop-types "^15.6.2" - react-is "^16.8.6" - scheduler "^0.19.1" + react-is "^16.12.0 || ^17.0.0" + +react-test-renderer@^17.0.0, react-test-renderer@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-17.0.2.tgz#4cd4ae5ef1ad5670fc0ef776e8cc7e1231d9866c" + integrity sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ== + dependencies: + object-assign "^4.1.1" + react-is "^17.0.2" + react-shallow-renderer "^16.13.1" + scheduler "^0.20.2" react-transition-group@^4.4.1: version "4.4.1" @@ -6805,22 +6547,13 @@ react-transition-group@^4.4.1: loose-envify "^1.4.0" prop-types "^15.6.2" -react@^16.14.0: - version "16.14.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" - integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== +react@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" - -read-pkg-up@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" - integrity sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4= - dependencies: - find-up "^2.0.0" - read-pkg "^2.0.0" read-pkg-up@^7.0.1: version "7.0.1" @@ -6831,15 +6564,6 @@ read-pkg-up@^7.0.1: read-pkg "^5.2.0" type-fest "^0.8.1" -read-pkg@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" - integrity sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg= - dependencies: - load-json-file "^2.0.0" - normalize-package-data "^2.3.2" - path-type "^2.0.0" - read-pkg@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-4.0.1.tgz#963625378f3e1c4d48c85872b5a6ec7d5d093237" @@ -6905,25 +6629,12 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" -redux-thunk@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" - integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== - -redux@^3.7.2: - version "3.7.2" - resolved "https://registry.yarnpkg.com/redux/-/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b" - integrity sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A== +redux@^4.0.0, redux@^4.0.4: + version "4.1.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.0.tgz#eb049679f2f523c379f1aff345c8612f294c88d4" + integrity sha512-uI2dQN43zqLWCt6B/BMGRMY6db7TTY4qeHHfGeKb3EOhmOKjU3KdWvNLJyqaHRksv/ErdNH7cFZWg9jXtewy4g== dependencies: - lodash "^4.2.1" - lodash-es "^4.2.1" - loose-envify "^1.1.0" - symbol-observable "^1.0.3" - -reflect.ownkeys@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" - integrity sha1-dJrO7H8/34tj+SegSAnpDFwLNGA= + "@babel/runtime" "^7.9.2" regenerate-unicode-properties@^8.2.0: version "8.2.0" @@ -6937,11 +6648,6 @@ regenerate@^1.4.0: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== -regenerator-runtime@^0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" - integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== - regenerator-runtime@^0.13.4: version "0.13.7" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" @@ -6970,11 +6676,6 @@ regexp.prototype.flags@^1.3.0: call-bind "^1.0.2" define-properties "^1.1.3" -regexpp@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" - integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw== - regexpp@^3.0.0, regexpp@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2" @@ -7099,11 +6800,6 @@ require-main-filename@^2.0.0: resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== -reselect@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/reselect/-/reselect-3.0.1.tgz#efdaa98ea7451324d092b2b2163a6a1d7a9a2147" - integrity sha1-79qpjqdFEyTQkrKyFjpqHXqaIUc= - resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" @@ -7131,7 +6827,7 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.18.1: +resolve@^1.10.0, resolve@^1.17.0, resolve@^1.18.1: version "1.19.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg== @@ -7139,19 +6835,16 @@ resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.1 is-core-module "^2.1.0" path-parse "^1.0.6" -restore-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" - integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== - dependencies: - onetime "^5.1.0" - signal-exit "^3.0.2" - ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -7162,13 +6855,6 @@ rfc4648@^1.4.0: resolved "https://registry.yarnpkg.com/rfc4648/-/rfc4648-1.4.0.tgz#c75b2856ad2e2d588b6ddb985d556f1f7f2a2abd" integrity sha512-3qIzGhHlMHA6PoT6+cdPKZ+ZqtxkIvg8DZGKA5z6PQ33/uuhoJ+Ws/D/J9rXW6gXodgH8QYlz2UCl+sdUDmNIg== -rimraf@2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" - integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== - dependencies: - glob "^7.1.3" - rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" @@ -7189,17 +6875,12 @@ rsvp@^4.8.4: resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== -run-async@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" - integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== - run-parallel@^1.1.9: version "1.1.10" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.10.tgz#60a51b2ae836636c81377df16cb107351bcd13ef" integrity sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw== -rxjs@^6.5.2, rxjs@^6.6.0: +rxjs@^6.5.2: version "6.6.3" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== @@ -7243,17 +6924,18 @@ sane@^4.0.3: minimist "^1.1.1" walker "~1.0.5" -"sanitize-html@github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db": - version "2.0.0-rc.3" - resolved "https://codeload.github.com/apostrophecms/sanitize-html/tar.gz/3c7f93f2058f696f5359e3e58d464161647226db" +sanitize-html@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.3.3.tgz#3db382c9a621cce4c46d90f10c64f1e9da9e8353" + integrity sha512-DCFXPt7Di0c6JUnlT90eIgrjs6TsJl/8HYU3KLdmrVclFN4O0heTcVbJiMa23OKVr6aR051XYtsgd8EWwEBwUA== dependencies: deepmerge "^4.2.2" escape-string-regexp "^4.0.0" - htmlparser2 "^4.1.0" + htmlparser2 "^6.0.0" is-plain-object "^5.0.0" klona "^2.0.3" + parse-srcset "^1.0.2" postcss "^8.0.2" - srcset "^3.0.0" saxes@^5.0.0: version "5.0.1" @@ -7262,15 +6944,15 @@ saxes@^5.0.0: dependencies: xmlchars "^2.2.0" -scheduler@^0.19.1: - version "0.19.1" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" - integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== +scheduler@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" -"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: +"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -7280,7 +6962,7 @@ semver@7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^6.0.0, semver@^6.1.2, semver@^6.3.0: +semver@^6.0.0, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== @@ -7370,15 +7052,6 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -slice-ansi@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636" - integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== - dependencies: - ansi-styles "^3.2.0" - astral-regex "^1.0.0" - is-fullwidth-code-point "^2.0.0" - slice-ansi@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" @@ -7510,11 +7183,6 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -srcset@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/srcset/-/srcset-3.0.0.tgz#8afd8b971362dfc129ae9c1a99b3897301ce6441" - integrity sha512-D59vF08Qzu/C4GAOXVgMTLfgryt5fyWo93FZyhEWANo0PokFz/iWdDe13mX3O5TRf6l8vMTqckAfR4zPiaH0yQ== - sshpk@^1.7.0: version "1.16.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" @@ -7565,11 +7233,6 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -string-natural-compare@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" - integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== - string-width@^3.0.0, string-width@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" @@ -7623,6 +7286,14 @@ string.prototype.trimend@^1.0.1, string.prototype.trimend@^1.0.3: call-bind "^1.0.0" define-properties "^1.1.3" +string.prototype.trimend@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80" + integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + string.prototype.trimstart@^1.0.1, string.prototype.trimstart@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.3.tgz#9b4cb590e123bb36564401d59824298de50fd5aa" @@ -7631,6 +7302,14 @@ string.prototype.trimstart@^1.0.1, string.prototype.trimstart@^1.0.3: call-bind "^1.0.0" define-properties "^1.1.3" +string.prototype.trimstart@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed" + integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" @@ -7659,11 +7338,6 @@ strip-ansi@^6.0.0: dependencies: ansi-regex "^5.0.0" -strip-bom@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" - integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= - strip-bom@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" @@ -7686,7 +7360,7 @@ strip-indent@^3.0.0: dependencies: min-indent "^1.0.0" -strip-json-comments@^3.0.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: +strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== @@ -7814,26 +7488,11 @@ svg-tags@^1.0.0: resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764" integrity sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q= -symbol-observable@^1.0.3: - version "1.2.0" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" - integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== - symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== -table@^5.2.3: - version "5.4.6" - resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" - integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug== - dependencies: - ajv "^6.10.2" - lodash "^4.17.14" - slice-ansi "^2.1.0" - string-width "^3.0.0" - table@^6.0.4, table@^6.0.7: version "6.0.7" resolved "https://registry.yarnpkg.com/table/-/table-6.0.7.tgz#e45897ffbcc1bcf9e8a87bf420f2c9e5a7a52a34" @@ -7866,11 +7525,6 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -text-encoding-utf-8@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz#585b62197b0ae437e3c7b5d0af27ac1021e10d13" - integrity sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg== - text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -7881,23 +7535,16 @@ throat@^5.0.0: resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== -through@^2.3.6: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= +tiny-invariant@^1.0.6: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" + integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== tmatch@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/tmatch/-/tmatch-2.0.1.tgz#0c56246f33f30da1b8d3d72895abaf16660f38cf" integrity sha1-DFYkbzPzDaG409colauvFmYPOM8= -tmp@^0.0.33: - version "0.0.33" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" - integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== - dependencies: - os-tmpdir "~1.0.2" - tmpl@1.0.x: version "1.0.4" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" @@ -7970,25 +7617,15 @@ tree-kill@^1.2.2: integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== trim-newlines@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.0.tgz#79726304a6a898aa8373427298d54c2ee8b1cb30" - integrity sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA== + version "3.0.1" + resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" + integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw== trough@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406" integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA== -tsconfig-paths@^3.9.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b" - integrity sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw== - dependencies: - "@types/json5" "^0.0.29" - json5 "^1.0.1" - minimist "^1.2.0" - strip-bom "^3.0.0" - tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -7999,10 +7636,15 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== +tslib@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" + integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w== + tsutils@^3.17.1: - version "3.19.1" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.19.1.tgz#d8566e0c51c82f32f9c25a4d367cd62409a547a9" - integrity sha512-GEdoBf5XI324lu7ycad7s6laADfnAqCw6wLGI+knxvw9vsIYBaJfYdmeCEG3FMMUiSm3OGgNb+m6utsWf5h9Vw== + version "3.21.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" + integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== dependencies: tslib "^1.8.1" @@ -8070,9 +7712,19 @@ typescript@^4.1.3: integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== ua-parser-js@^0.7.18: - version "0.7.23" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.23.tgz#704d67f951e13195fbcd3d78818577f5bc1d547b" - integrity sha512-m4hvMLxgGHXG3O3fQVAyyAQpZzDOvwnhOTjYz5Xmr7r/+LpkNy3vJXdVRWgd1TkAb7NGROZuSy96CrlNVjA7KA== + version "0.7.28" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31" + integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g== + +unbox-primitive@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" + integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw== + dependencies: + function-bind "^1.1.1" + has-bigints "^1.0.1" + has-symbols "^1.0.2" + which-boxed-primitive "^1.0.2" unhomoglyph@^1.0.6: version "1.0.6" @@ -8186,6 +7838,11 @@ use-callback-ref@^1.2.1: resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.2.5.tgz#6115ed242cfbaed5915499c0a9842ca2912f38a5" integrity sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg== +use-memo-one@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.2.tgz#0c8203a329f76e040047a35a1197defe342fab20" + integrity sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ== + use-sidecar@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.0.4.tgz#38398c3723727f9f924bed2343dfa3db6aaaee46" @@ -8348,7 +8005,7 @@ whatwg-url@^8.0.0: tr46 "^2.0.2" webidl-conversions "^6.1.0" -which-boxed-primitive@^1.0.1: +which-boxed-primitive@^1.0.1, which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== @@ -8426,17 +8083,10 @@ write-file-atomic@^3.0.0, write-file-atomic@^3.0.3: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -write@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3" - integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig== - dependencies: - mkdirp "^0.5.1" - ws@^7.2.3: - version "7.4.2" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.2.tgz#782100048e54eb36fe9843363ab1c68672b261dd" - integrity sha512-T4tewALS3+qsrpGI/8dqNMLIVdq/g/85U98HPMa6F0m6xTbvhXU6RCQLqPH3+SlomNV/LdY6RXEbBpMH6EOJnA== + version "7.4.6" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" + integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== xml-name-validator@^3.0.0: version "3.0.0"