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 bf6e245b93..827b373949 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,6 +24,18 @@ module.exports = { // 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: [ @@ -49,21 +61,16 @@ module.exports = { "@typescript-eslint/no-explicit-any": "off", // We'd rather not do this but we do "@typescript-eslint/ban-ts-comment": "off", - - "no-restricted-properties": [ - "error", - ...buildRestrictedPropertiesOptions( - ["window.innerHeight", "window.innerWidth", "window.visualViewport"], - "Use UIStore to access window dimensions instead", - ), - ], }, }], }; function buildRestrictedPropertiesOptions(properties, message) { return properties.map(prop => { - const [object, property] = prop.split("."); + let [object, property] = prop.split("."); + if (object === "*") { + object = undefined; + } return { object, property, diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c9d11f02c8..e9ede862d2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,15 @@ - + - + + + diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 3c3807e33b..0ae59da09a 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -1,5 +1,8 @@ name: Develop on: + # These tests won't work for non-develop branches at the moment as they + # won't pull in the right versions of other repos, so they're only enabled + # on develop. push: branches: [develop] pull_request: 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 0f979b4802..73b383d76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,323 @@ +Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0) + + * Fix 'User' type import + [\#6376](https://github.com/matrix-org/matrix-react-sdk/pull/6376) + +Changes in [3.26.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0-rc.1) (2021-07-14) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0...v3.26.0-rc.1) + + * Fix voice messages in right panels + [\#6370](https://github.com/matrix-org/matrix-react-sdk/pull/6370) + * Use TileShape enum more universally + [\#6369](https://github.com/matrix-org/matrix-react-sdk/pull/6369) + * Translations update from Weblate + [\#6373](https://github.com/matrix-org/matrix-react-sdk/pull/6373) + * Hide world readable history option in encrypted rooms + [\#5947](https://github.com/matrix-org/matrix-react-sdk/pull/5947) + * Make the Image View buttons easier to hit + [\#6372](https://github.com/matrix-org/matrix-react-sdk/pull/6372) + * Reorder buttons in the Image View + [\#6368](https://github.com/matrix-org/matrix-react-sdk/pull/6368) + * Add VS Code to gitignore + [\#6367](https://github.com/matrix-org/matrix-react-sdk/pull/6367) + * Fix inviter exploding due to member being null + [\#6362](https://github.com/matrix-org/matrix-react-sdk/pull/6362) + * Increase sample count in voice message thumbnail + [\#6359](https://github.com/matrix-org/matrix-react-sdk/pull/6359) + * Improve arraySeed utility + [\#6360](https://github.com/matrix-org/matrix-react-sdk/pull/6360) + * Convert FontManager to TS and stub it out for tests + [\#6358](https://github.com/matrix-org/matrix-react-sdk/pull/6358) + * Adjust recording waveform behaviour for voice messages + [\#6357](https://github.com/matrix-org/matrix-react-sdk/pull/6357) + * Do not honor string power levels + [\#6245](https://github.com/matrix-org/matrix-react-sdk/pull/6245) + * Add alias and directory customisation points + [\#6343](https://github.com/matrix-org/matrix-react-sdk/pull/6343) + * Fix multiinviter user already in room and clean up code + [\#6354](https://github.com/matrix-org/matrix-react-sdk/pull/6354) + * Fix right panel not closing user info when changing rooms + [\#6341](https://github.com/matrix-org/matrix-react-sdk/pull/6341) + * Quit sticker picker on m.sticker + [\#5679](https://github.com/matrix-org/matrix-react-sdk/pull/5679) + * Don't autodetect language in inline code blocks + [\#6350](https://github.com/matrix-org/matrix-react-sdk/pull/6350) + * Make ghost button background transparent + [\#6331](https://github.com/matrix-org/matrix-react-sdk/pull/6331) + * only consider valid & loaded url previews for show N more prompt + [\#6346](https://github.com/matrix-org/matrix-react-sdk/pull/6346) + * Extract MXCs from _matrix/media/r0/ URLs for inline images in messages + [\#6335](https://github.com/matrix-org/matrix-react-sdk/pull/6335) + * Fix small visual regression with the site name on url previews + [\#6342](https://github.com/matrix-org/matrix-react-sdk/pull/6342) + * Make PIP CallView draggable/movable + [\#5952](https://github.com/matrix-org/matrix-react-sdk/pull/5952) + * Convert VoiceUserSettingsTab to TS + [\#6340](https://github.com/matrix-org/matrix-react-sdk/pull/6340) + * Simplify typescript definition for Modernizr + [\#6339](https://github.com/matrix-org/matrix-react-sdk/pull/6339) + * Remember the last used server for room directory searches + [\#6322](https://github.com/matrix-org/matrix-react-sdk/pull/6322) + * Focus composer after reacting + [\#6332](https://github.com/matrix-org/matrix-react-sdk/pull/6332) + * Fix bug which prevented more than one event getting pinned + [\#6336](https://github.com/matrix-org/matrix-react-sdk/pull/6336) + * Make DeviceListener also update on megolm key in SSSS + [\#6337](https://github.com/matrix-org/matrix-react-sdk/pull/6337) + * Improve URL previews + [\#6326](https://github.com/matrix-org/matrix-react-sdk/pull/6326) + * Don't close settings dialog when opening spaces feedback prompt + [\#6334](https://github.com/matrix-org/matrix-react-sdk/pull/6334) + * Update import location for types + [\#6330](https://github.com/matrix-org/matrix-react-sdk/pull/6330) + * Improve blurhash rendering performance + [\#6329](https://github.com/matrix-org/matrix-react-sdk/pull/6329) + * Use a proper color scheme for codeblocks + [\#6320](https://github.com/matrix-org/matrix-react-sdk/pull/6320) + * Burn `sdk.getComponent()` with 🔥 + [\#6308](https://github.com/matrix-org/matrix-react-sdk/pull/6308) + * Fix instances of the Edit Message Composer's save button being wrongly + disabled + [\#6307](https://github.com/matrix-org/matrix-react-sdk/pull/6307) + * Do not generate a lockfile when running in CI + [\#6327](https://github.com/matrix-org/matrix-react-sdk/pull/6327) + * Update lockfile with correct dependencies + [\#6324](https://github.com/matrix-org/matrix-react-sdk/pull/6324) + * Clarify the keys we use when submitting rageshakes + [\#6321](https://github.com/matrix-org/matrix-react-sdk/pull/6321) + * Fix ImageView context menu + [\#6318](https://github.com/matrix-org/matrix-react-sdk/pull/6318) + * TypeScript migration + [\#6315](https://github.com/matrix-org/matrix-react-sdk/pull/6315) + * Move animation to compositor + [\#6310](https://github.com/matrix-org/matrix-react-sdk/pull/6310) + * Reorganize preferences + [\#5742](https://github.com/matrix-org/matrix-react-sdk/pull/5742) + * Fix being able to un-rotate images + [\#6313](https://github.com/matrix-org/matrix-react-sdk/pull/6313) + * Fix icon size in passphrase prompt + [\#6312](https://github.com/matrix-org/matrix-react-sdk/pull/6312) + * Use sleep & defer from js-sdk instead of duplicating it + [\#6305](https://github.com/matrix-org/matrix-react-sdk/pull/6305) + * Convert EventTimeline, EventTimelineSet and TimelineWindow to TS + [\#6295](https://github.com/matrix-org/matrix-react-sdk/pull/6295) + * Comply with new member-delimiter-style rule + [\#6306](https://github.com/matrix-org/matrix-react-sdk/pull/6306) + * Fix Test Linting + [\#6304](https://github.com/matrix-org/matrix-react-sdk/pull/6304) + * Convert Markdown to TypeScript + [\#6303](https://github.com/matrix-org/matrix-react-sdk/pull/6303) + * Convert RoomHeader to TS + [\#6302](https://github.com/matrix-org/matrix-react-sdk/pull/6302) + * Prevent RoomDirectory from exploding when filterString is wrongly nulled + [\#6296](https://github.com/matrix-org/matrix-react-sdk/pull/6296) + * Add support for blurhash (MSC2448) + [\#5099](https://github.com/matrix-org/matrix-react-sdk/pull/5099) + * Remove rateLimitedFunc + [\#6300](https://github.com/matrix-org/matrix-react-sdk/pull/6300) + * Convert some Key Verification classes to TypeScript + [\#6299](https://github.com/matrix-org/matrix-react-sdk/pull/6299) + * Typescript conversion of Composer components and more + [\#6292](https://github.com/matrix-org/matrix-react-sdk/pull/6292) + * Upgrade browserlist target versions + [\#6298](https://github.com/matrix-org/matrix-react-sdk/pull/6298) + * Fix browser crashing when searching for a malformed HTML tag + [\#6297](https://github.com/matrix-org/matrix-react-sdk/pull/6297) + * Add custom audio player + [\#6264](https://github.com/matrix-org/matrix-react-sdk/pull/6264) + * Lint MXC APIs to centralise access + [\#6293](https://github.com/matrix-org/matrix-react-sdk/pull/6293) + * Remove reminescent references to the tinter + [\#6290](https://github.com/matrix-org/matrix-react-sdk/pull/6290) + * More js-sdk type consolidation + [\#6263](https://github.com/matrix-org/matrix-react-sdk/pull/6263) + * Convert MessagePanel, TimelinePanel, ScrollPanel, and more to Typescript + [\#6243](https://github.com/matrix-org/matrix-react-sdk/pull/6243) + * Migrate to `eslint-plugin-matrix-org` + [\#6285](https://github.com/matrix-org/matrix-react-sdk/pull/6285) + * Avoid cyclic dependencies by moving watchers out of constructor + [\#6287](https://github.com/matrix-org/matrix-react-sdk/pull/6287) + * Add spacing between toast buttons with cross browser support in mind + [\#6284](https://github.com/matrix-org/matrix-react-sdk/pull/6284) + * Deprecate Tinter and TintableSVG + [\#6279](https://github.com/matrix-org/matrix-react-sdk/pull/6279) + * Migrate FilePanel to TypeScript + [\#6283](https://github.com/matrix-org/matrix-react-sdk/pull/6283) + +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) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.rst rename to CONTRIBUTING.md 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__/workerMock.js b/__mocks__/workerMock.js new file mode 100644 index 0000000000..6ee585673e --- /dev/null +++ b/__mocks__/workerMock.js @@ -0,0 +1 @@ +module.exports = jest.fn(); diff --git a/package.json b/package.json index 4ad585ba7d..b73462d188 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.24.0", + "version": "3.26.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -45,7 +45,8 @@ "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:js-fix": "eslint --fix src test", "lint:types": "tsc --noEmit --jsx react", "lint:style": "stylelint 'res/css/**/*.scss'", "test": "jest", @@ -55,6 +56,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "await-lock": "^2.1.0", + "blurhash": "^1.1.3", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", "cheerio": "^1.0.0-rc.9", @@ -63,8 +65,8 @@ "counterpart": "^0.18.6", "diff-dom": "^4.2.2", "diff-match-patch": "^1.0.5", - "emojibase-data": "^5.1.1", - "emojibase-regex": "^4.1.1", + "emojibase-data": "^6.2.0", + "emojibase-regex": "^5.1.3", "escape-html": "^1.0.3", "file-saver": "^2.0.5", "filesize": "6.1.0", @@ -78,7 +80,7 @@ "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", - "matrix-js-sdk": "12.0.0", + "matrix-js-sdk": "12.1.0", "matrix-widget-api": "^0.1.0-beta.15", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", @@ -90,6 +92,7 @@ "re-resizable": "^6.9.0", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.0", + "react-blurhash": "^0.1.3", "react-dom": "^17.0.2", "react-focus-lock": "^2.5.0", "react-transition-group": "^4.4.1", @@ -122,7 +125,9 @@ "@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", @@ -183,7 +188,8 @@ "\\$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" + "waveWorker\\.min\\.js": "/__mocks__/empty.js", + "workers/(.+)\\.worker\\.ts": "/__mocks__/workerMock.js" }, "transformIgnorePatterns": [ "/node_modules/(?!matrix-js-sdk).+$" diff --git a/res/css/_components.scss b/res/css/_components.scss index ec3af8655e..bc7d4fc85f 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -37,6 +37,11 @@ @import "./structures/_ViewSource.scss"; @import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; +@import "./views/audio_messages/_AudioPlayer.scss"; +@import "./views/audio_messages/_PlayPauseButton.scss"; +@import "./views/audio_messages/_PlaybackContainer.scss"; +@import "./views/audio_messages/_SeekBar.scss"; +@import "./views/audio_messages/_Waveform.scss"; @import "./views/auth/_AuthBody.scss"; @import "./views/auth/_AuthButtons.scss"; @import "./views/auth/_AuthFooter.scss"; @@ -52,7 +57,6 @@ @import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss"; -@import "./views/avatars/_PulsedAvatar.scss"; @import "./views/avatars/_WidgetAvatar.scss"; @import "./views/beta/_BetaCard.scss"; @import "./views/context_menus/_CallContextMenu.scss"; @@ -116,6 +120,7 @@ @import "./views/elements/_AddressTile.scss"; @import "./views/elements/_DesktopBuildsNotice.scss"; @import "./views/elements/_DesktopCapturerSourcePicker.scss"; +@import "./views/elements/_DialPadBackspaceButton.scss"; @import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_Dropdown.scss"; @import "./views/elements/_EditableItemList.scss"; @@ -144,6 +149,7 @@ @import "./views/elements/_StyledCheckbox.scss"; @import "./views/elements/_StyledRadioButton.scss"; @import "./views/elements/_SyntaxHighlight.scss"; +@import "./views/elements/_TagComposer.scss"; @import "./views/elements/_TextWithTooltip.scss"; @import "./views/elements/_ToggleSwitch.scss"; @import "./views/elements/_Tooltip.scss"; @@ -156,15 +162,18 @@ @import "./views/messages/_CreateEvent.scss"; @import "./views/messages/_DateSeparator.scss"; @import "./views/messages/_EventTileBubble.scss"; +@import "./views/messages/_CallEvent.scss"; @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; +@import "./views/messages/_MImageReplyBody.scss"; @import "./views/messages/_MJitsiWidgetEvent.scss"; @import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MVideoBody.scss"; @import "./views/messages/_MVoiceMessageBody.scss"; +@import "./views/messages/_MediaBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MjolnirBody.scss"; @@ -193,9 +202,11 @@ @import "./views/rooms/_EditMessageComposer.scss"; @import "./views/rooms/_EntityTile.scss"; @import "./views/rooms/_EventTile.scss"; +@import "./views/rooms/_EventBubbleTile.scss"; @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"; @@ -206,6 +217,7 @@ @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_ReplyPreview.scss"; +@import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomList.scss"; @@ -253,10 +265,8 @@ @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/_CallPreview.scss"; @import "./views/voip/_CallView.scss"; @import "./views/voip/_CallViewForRoom.scss"; @import "./views/voip/_DialPad.scss"; diff --git a/res/css/structures/_FilePanel.scss b/res/css/structures/_FilePanel.scss index 7b975110e1..1a02c0d5ac 100644 --- a/res/css/structures/_FilePanel.scss +++ b/res/css/structures/_FilePanel.scss @@ -118,10 +118,6 @@ limitations under the License. padding-left: 0px; } -.mx_FilePanel .mx_EventTile:hover .mx_EventTile_line { - background-color: $primary-bg-color; -} - .mx_FilePanel_empty::before { mask-image: url('$(res)/img/element-icons/room/files.svg'); } 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/_RightPanel.scss b/res/css/structures/_RightPanel.scss index 52a2a68b6a..3222fe936c 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -121,23 +121,51 @@ $pulse-color: $pinned-unread-color; box-shadow: 0 0 0 0 rgba($pulse-color, 1); animation: mx_RightPanel_indicator_pulse 2s infinite; animation-iteration-count: 1; + + &::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + top: 0; + left: 0; + transform: scale(1); + transform-origin: center center; + animation-name: mx_RightPanel_indicator_pulse_shadow; + animation-duration: inherit; + animation-iteration-count: inherit; + border-radius: 50%; + background: rgba($pulse-color, 1); + } } } @keyframes mx_RightPanel_indicator_pulse { 0% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0.7); } 70% { transform: scale(1); - box-shadow: 0 0 0 10px rgba($pulse-color, 0); } 100% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0); + } +} + +@keyframes mx_RightPanel_indicator_pulse_shadow { + 0% { + opacity: 0.7; + } + + 70% { + transform: scale(2.2); + opacity: 0; + } + + 100% { + opacity: 0; } } diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 0efa2d01a1..831f186ed4 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -57,14 +57,15 @@ limitations under the License. @keyframes mx_RoomView_fileDropTarget_image_animation { from { - width: 0px; + transform: scaleX(0); } to { - width: 32px; + transform: scaleX(1); } } .mx_RoomView_fileDropTarget_image { + width: 32px; animation: mx_RoomView_fileDropTarget_image_animation; animation-duration: 0.5s; margin-bottom: 16px; diff --git a/res/css/structures/_TabbedView.scss b/res/css/structures/_TabbedView.scss index 39a8ebed32..833450a25b 100644 --- a/res/css/structures/_TabbedView.scss +++ b/res/css/structures/_TabbedView.scss @@ -1,6 +1,7 @@ /* Copyright 2017 Travis Ralston Copyright 2019 New Vector Ltd +Copyright 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,7 +21,6 @@ limitations under the License. padding: 0 0 0 16px; display: flex; flex-direction: column; - position: absolute; top: 0; bottom: 0; left: 0; @@ -28,11 +28,93 @@ limitations under the License. margin-top: 8px; } +.mx_TabbedView_tabsOnLeft { + flex-direction: column; + position: absolute; + + .mx_TabbedView_tabLabels { + width: 170px; + max-width: 170px; + position: fixed; + } + + .mx_TabbedView_tabPanel { + margin-left: 240px; // 170px sidebar + 70px padding + flex-direction: column; + } + + .mx_TabbedView_tabLabel_active { + background-color: $tab-label-active-bg-color; + color: $tab-label-active-fg-color; + } + + .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { + background-color: $tab-label-active-icon-bg-color; + } + + .mx_TabbedView_maskedIcon { + width: 16px; + height: 16px; + margin-left: 8px; + margin-right: 16px; + } + + .mx_TabbedView_maskedIcon::before { + mask-size: 16px; + width: 16px; + height: 16px; + } +} + +.mx_TabbedView_tabsOnTop { + flex-direction: column; + + .mx_TabbedView_tabLabels { + display: flex; + margin-bottom: 8px; + } + + .mx_TabbedView_tabLabel { + padding-left: 0px; + padding-right: 52px; + + .mx_TabbedView_tabLabel_text { + font-size: 15px; + color: $tertiary-fg-color; + } + } + + .mx_TabbedView_tabPanel { + flex-direction: row; + } + + .mx_TabbedView_tabLabel_active { + color: $accent-color; + .mx_TabbedView_tabLabel_text { + color: $accent-color; + } + } + + .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { + background-color: $accent-color; + } + + .mx_TabbedView_maskedIcon { + width: 22px; + height: 22px; + margin-left: 0px; + margin-right: 8px; + } + + .mx_TabbedView_maskedIcon::before { + mask-size: 22px; + width: inherit; + height: inherit; + } +} + .mx_TabbedView_tabLabels { - width: 170px; - max-width: 170px; color: $tab-label-fg-color; - position: fixed; } .mx_TabbedView_tabLabel { @@ -46,43 +128,25 @@ limitations under the License. position: relative; } -.mx_TabbedView_tabLabel_active { - background-color: $tab-label-active-bg-color; - color: $tab-label-active-fg-color; -} - .mx_TabbedView_maskedIcon { - margin-left: 8px; - margin-right: 16px; - width: 16px; - height: 16px; display: inline-block; } .mx_TabbedView_maskedIcon::before { display: inline-block; - background-color: $tab-label-icon-bg-color; + background-color: $icon-button-color; mask-repeat: no-repeat; - mask-size: 16px; - width: 16px; - height: 16px; mask-position: center; content: ''; } -.mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { - background-color: $tab-label-active-icon-bg-color; -} - .mx_TabbedView_tabLabel_text { vertical-align: middle; } .mx_TabbedView_tabPanel { - margin-left: 240px; // 170px sidebar + 70px padding flex-grow: 1; display: flex; - flex-direction: column; min-height: 0; // firefox } diff --git a/res/css/views/audio_messages/_AudioPlayer.scss b/res/css/views/audio_messages/_AudioPlayer.scss new file mode 100644 index 0000000000..9a65ad008f --- /dev/null +++ b/res/css/views/audio_messages/_AudioPlayer.scss @@ -0,0 +1,68 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_AudioPlayer_container { + padding: 16px 12px 12px 12px; + max-width: 267px; // use max to make the control fit in the files/pinned panels + + .mx_AudioPlayer_primaryContainer { + display: flex; + + .mx_PlayPauseButton { + margin-right: 8px; + } + + .mx_AudioPlayer_mediaInfo { + flex: 1; + overflow: hidden; // makes the ellipsis on the file name work + + & > * { + display: block; + } + + .mx_AudioPlayer_mediaName { + color: $primary-fg-color; + font-size: $font-15px; + line-height: $font-15px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + padding-bottom: 4px; // mimics the line-height differences in the Figma + } + + .mx_AudioPlayer_byline { + font-size: $font-12px; + line-height: $font-12px; + } + } + } + + .mx_AudioPlayer_seek { + display: flex; + align-items: center; + + .mx_SeekBar { + flex: 1; + } + + .mx_Clock { + width: $font-42px; // we're not using a monospace font, so fake it + min-width: $font-42px; // for flexbox + padding-left: 4px; // isolate from seek bar + text-align: right; + } + } +} diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/audio_messages/_PlayPauseButton.scss similarity index 91% rename from res/css/views/voice_messages/_PlayPauseButton.scss rename to res/css/views/audio_messages/_PlayPauseButton.scss index 6caedafa29..714da3e605 100644 --- a/res/css/views/voice_messages/_PlayPauseButton.scss +++ b/res/css/views/audio_messages/_PlayPauseButton.scss @@ -18,6 +18,8 @@ limitations under the License. position: relative; width: 32px; height: 32px; + min-width: 32px; // for when the button is used in a flexbox + min-height: 32px; // for when the button is used in a flexbox border-radius: 32px; background-color: $voice-playback-button-bg-color; diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/audio_messages/_PlaybackContainer.scss similarity index 88% rename from res/css/views/voice_messages/_PlaybackContainer.scss rename to res/css/views/audio_messages/_PlaybackContainer.scss index f0e29900ab..5548f6198e 100644 --- a/res/css/views/voice_messages/_PlaybackContainer.scss +++ b/res/css/views/audio_messages/_PlaybackContainer.scss @@ -22,17 +22,11 @@ limitations under the License. // 7px top and bottom for visual design. 12px left & right, but the waveform (right) // has a 1px padding on it that we want to account for. padding: 7px 12px 7px 11px; - background-color: $voice-record-waveform-bg-color; - border-radius: 12px; // Cheat at alignment a bit display: flex; align-items: center; - color: $voice-record-waveform-fg-color; - font-size: $font-14px; - line-height: $font-24px; - contain: content; .mx_Waveform { @@ -45,7 +39,7 @@ limitations under the License. &.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; } } } @@ -55,4 +49,8 @@ limitations under the License. padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended. padding-left: 8px; // isolate from recording circle / play control } + + &.mx_VoiceMessagePrimaryContainer_noWaveform { + max-width: 162px; // with all the padding this results in 185px wide + } } diff --git a/res/css/views/audio_messages/_SeekBar.scss b/res/css/views/audio_messages/_SeekBar.scss new file mode 100644 index 0000000000..d13fe4ac6a --- /dev/null +++ b/res/css/views/audio_messages/_SeekBar.scss @@ -0,0 +1,103 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// CSS inspiration from: +// * https://www.w3schools.com/howto/howto_js_rangeslider.asp +// * https://stackoverflow.com/a/28283806 +// * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ + +.mx_SeekBar { + // Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't + // need to support IE. + + appearance: none; // default style override + + width: 100%; + height: 1px; + background: $quaternary-fg-color; + outline: none; // remove blue selection border + position: relative; // for before+after pseudo elements later on + + cursor: pointer; + + &::-webkit-slider-thumb { + appearance: none; // default style override + + // Dev note: This needs to be duplicated with the -moz-range-thumb selector + // because otherwise Edge (webkit) will fail to see the styles and just refuse + // to apply them. + width: 8px; + height: 8px; + border-radius: 8px; + background-color: $tertiary-fg-color; + cursor: pointer; + } + + &::-moz-range-thumb { + width: 8px; + height: 8px; + border-radius: 8px; + background-color: $tertiary-fg-color; + cursor: pointer; + + // Firefox adds a border on the thumb + border: none; + } + + // This is for webkit support, but we can't limit the functionality of it to just webkit + // browsers. Firefox responds to webkit-prefixed values now, which means we can't use media + // or support queries to selectively apply the rule. An upside is that this CSS doesn't work + // in firefox, so it's just wasted CPU/GPU time. + &::before { // ::before to ensure it ends up under the thumb + content: ''; + background-color: $tertiary-fg-color; + + // Absolute positioning to ensure it overlaps with the existing bar + position: absolute; + top: 0; + left: 0; + + // Sizing to match the bar + width: 100%; + height: 1px; + + // And finally dynamic width without overly hurting the rendering engine. + transform-origin: 0 100%; + transform: scaleX(var(--fillTo)); + } + + // This is firefox's built-in support for the above, with 100% less hacks. + &::-moz-range-progress { + background-color: $tertiary-fg-color; + height: 1px; + } + + &:disabled { + opacity: 0.5; + } + + // Increase clickable area for the slider (approximately same size as browser default) + // We do it this way to keep the same padding and margins of the element, avoiding margin math. + // Source: https://front-back.com/expand-clickable-areas-for-a-better-touch-experience/ + &::after { + content: ''; + position: absolute; + top: -6px; + bottom: -6px; + left: 0; + right: 0; + } +} diff --git a/res/css/views/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/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index cbddd97e18..65e4493f19 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -27,6 +27,7 @@ limitations under the License. // https://bugzilla.mozilla.org/show_bug.cgi?id=255139 display: inline-block; user-select: none; + line-height: 1; } .mx_BaseAvatar_initial { diff --git a/res/css/views/beta/_BetaCard.scss b/res/css/views/beta/_BetaCard.scss index 1a8241b65f..2af4e79ecd 100644 --- a/res/css/views/beta/_BetaCard.scss +++ b/res/css/views/beta/_BetaCard.scss @@ -110,24 +110,52 @@ $dot-size: 12px; width: $dot-size; transform: scale(1); background: rgba($pulse-color, 1); - box-shadow: 0 0 0 0 rgba($pulse-color, 1); animation: mx_Beta_bluePulse 2s infinite; animation-iteration-count: 20; + position: relative; + + &::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + top: 0; + left: 0; + transform: scale(1); + transform-origin: center center; + animation-name: mx_Beta_bluePulse_shadow; + animation-duration: inherit; + animation-iteration-count: inherit; + border-radius: 50%; + background: rgba($pulse-color, 1); + } } @keyframes mx_Beta_bluePulse { 0% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0.7); } 70% { transform: scale(1); - box-shadow: 0 0 0 10px rgba($pulse-color, 0); } 100% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0); + } +} + +@keyframes mx_Beta_bluePulse_shadow { + 0% { + opacity: 0.7; + } + + 70% { + transform: scale(2.2); + opacity: 0; + } + + 100% { + opacity: 0; } } diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss index c01b43c1c4..9fc4b7a15c 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_InviteDialog_transferWrapper .mx_Dialog { + padding-bottom: 16px; +} + .mx_InviteDialog_addressBar { display: flex; flex-direction: row; @@ -286,16 +290,41 @@ limitations under the License. } } -.mx_InviteDialog { +.mx_InviteDialog_other { // Prevent the dialog from jumping around randomly when elements change. height: 600px; padding-left: 20px; // the design wants some padding on the left - display: flex; + + .mx_InviteDialog_userSections { + height: calc(100% - 115px); // mx_InviteDialog's height minus some for the upper and lower elements + } +} + +.mx_InviteDialog_content { + height: calc(100% - 36px); // full height minus the size of the header + overflow: hidden; +} + +.mx_InviteDialog_transfer { + width: 496px; + height: 466px; flex-direction: column; .mx_InviteDialog_content { - overflow: hidden; - height: 100%; + flex-direction: column; + + .mx_TabbedView { + height: calc(100% - 60px); + } + overflow: visible; + } + + .mx_InviteDialog_addressBar { + margin-top: 8px; + } + + input[type="checkbox"] { + margin-right: 8px; } } @@ -303,7 +332,6 @@ limitations under the License. margin-top: 4px; overflow-y: auto; padding: 0 45px 4px 0; - height: calc(100% - 115px); // mx_InviteDialog's height minus some for the upper and lower elements } .mx_InviteDialog_hasFooter .mx_InviteDialog_userSections { @@ -318,6 +346,74 @@ limitations under the License. padding: 0; } +.mx_InviteDialog_dialPad .mx_InviteDialog_dialPadField { + border-top: 0; + border-left: 0; + border-right: 0; + border-radius: 0; + margin-top: 0; + border-color: $quaternary-fg-color; + + input { + font-size: 18px; + font-weight: 600; + padding-top: 0; + } +} + +.mx_InviteDialog_dialPad .mx_InviteDialog_dialPadField:focus-within { + border-color: $accent-color; +} + +.mx_InviteDialog_dialPadField .mx_Field_postfix { + /* Remove border separator between postfix and field content */ + border-left: none; +} + +.mx_InviteDialog_dialPad { + width: 224px; + margin-top: 16px; + margin-left: auto; + margin-right: auto; +} + +.mx_InviteDialog_dialPad .mx_DialPad { + row-gap: 16px; + column-gap: 48px; + + margin-left: auto; + margin-right: auto; +} + +.mx_InviteDialog_transferConsultConnect { + padding-top: 16px; + /* This wants a drop shadow the full width of the dialog, so relative-position it + * and make it wider, then compensate with padding + */ + position: relative; + width: 496px; + left: -24px; + padding-left: 24px; + padding-right: 24px; + border-top: 1px solid $message-body-panel-bg-color; + + display: flex; + flex-direction: row; + align-items: center; +} + +.mx_InviteDialog_transferConsultConnect_pushRight { + margin-left: auto; +} + +.mx_InviteDialog_userDirectoryIcon::before { + mask-image: url('$(res)/img/voip/tab-userdirectory.svg'); +} + +.mx_InviteDialog_dialPadIcon::before { + mask-image: url('$(res)/img/voip/tab-dialpad.svg'); +} + .mx_InviteDialog_multiInviterError { > h4 { font-size: $font-15px; 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 2997c83cfd..7bc47a3c98 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -72,7 +72,7 @@ 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; } diff --git a/res/css/views/elements/_DialPadBackspaceButton.scss b/res/css/views/elements/_DialPadBackspaceButton.scss new file mode 100644 index 0000000000..40e4af7025 --- /dev/null +++ b/res/css/views/elements/_DialPadBackspaceButton.scss @@ -0,0 +1,40 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_DialPadBackspaceButton { + position: relative; + height: 28px; + width: 28px; + + &::before { + /* force this element to appear on the DOM */ + content: ""; + + background-color: #8D97A5; + width: inherit; + height: inherit; + top: 0px; + left: 0px; + position: absolute; + display: inline-block; + vertical-align: middle; + + mask-image: url('$(res)/img/element-icons/call/delete.svg'); + mask-position: 8px; + mask-size: 20px; + mask-repeat: no-repeat; + } +} diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index da23957b36..cf92ffec64 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +$button-size: 32px; +$icon-size: 22px; +$button-gap: 24px; + .mx_ImageView { display: flex; width: 100%; @@ -66,16 +70,17 @@ limitations under the License. pointer-events: initial; display: flex; align-items: center; + gap: calc($button-gap - ($button-size - $icon-size)); } .mx_ImageView_button { - margin-left: 24px; + padding: calc(($button-size - $icon-size) / 2); display: block; &::before { content: ''; - height: 22px; - width: 22px; + height: $icon-size; + width: $icon-size; mask-repeat: no-repeat; mask-size: contain; mask-position: center; @@ -109,11 +114,12 @@ limitations under the License. } .mx_ImageView_button_close { + padding: calc($button-size - $button-size); border-radius: 100%; background: #21262c; // same on all themes &::before { - width: 32px; - height: 32px; + width: $button-size; + height: $button-size; mask-image: url('$(res)/img/image-view/close.svg'); mask-size: 40%; } diff --git a/res/css/views/elements/_InfoTooltip.scss b/res/css/views/elements/_InfoTooltip.scss index 5858a60629..5329e7f1f8 100644 --- a/res/css/views/elements/_InfoTooltip.scss +++ b/res/css/views/elements/_InfoTooltip.scss @@ -30,5 +30,12 @@ limitations under the License. mask-position: center; content: ''; vertical-align: middle; +} + +.mx_InfoTooltip_icon_info::before { mask-image: url('$(res)/img/element-icons/info.svg'); } + +.mx_InfoTooltip_icon_warning::before { + mask-image: url('$(res)/img/element-icons/warning.svg'); +} diff --git a/res/css/views/elements/_ReplyThread.scss b/res/css/views/elements/_ReplyThread.scss index bf44a11728..44532ea6a7 100644 --- a/res/css/views/elements/_ReplyThread.scss +++ b/res/css/views/elements/_ReplyThread.scss @@ -16,22 +16,45 @@ limitations under the License. .mx_ReplyThread { margin-top: 0; -} - -.mx_ReplyThread .mx_DateSeparator { - font-size: 1em !important; - margin-top: 0; - margin-bottom: 0; - padding-bottom: 1px; - bottom: -5px; -} - -.mx_ReplyThread_show { - cursor: pointer; -} - -blockquote.mx_ReplyThread { margin-left: 0; + margin-right: 0; + margin-bottom: 8px; padding-left: 10px; - border-left: 4px solid $blockquote-bar-color; + border-left: 4px solid $button-bg-color; + + .mx_ReplyThread_show { + cursor: pointer; + } + + &.mx_ReplyThread_color1 { + border-left-color: $username-variant1-color; + } + + &.mx_ReplyThread_color2 { + border-left-color: $username-variant2-color; + } + + &.mx_ReplyThread_color3 { + border-left-color: $username-variant3-color; + } + + &.mx_ReplyThread_color4 { + border-left-color: $username-variant4-color; + } + + &.mx_ReplyThread_color5 { + border-left-color: $username-variant5-color; + } + + &.mx_ReplyThread_color6 { + border-left-color: $username-variant6-color; + } + + &.mx_ReplyThread_color7 { + border-left-color: $username-variant7-color; + } + + &.mx_ReplyThread_color8 { + border-left-color: $username-variant8-color; + } } diff --git a/res/css/views/elements/_StyledRadioButton.scss b/res/css/views/elements/_StyledRadioButton.scss index 62fb5c5512..1ae787dfc2 100644 --- a/res/css/views/elements/_StyledRadioButton.scss +++ b/res/css/views/elements/_StyledRadioButton.scss @@ -46,7 +46,7 @@ limitations under the License. width: $font-16px; } - > input[type=radio] { + input[type=radio] { // Remove the OS's representation margin: 0; padding: 0; @@ -112,6 +112,12 @@ limitations under the License. } } } + + .mx_RadioButton_innerLabel { + display: flex; + position: relative; + top: 4px; + } } .mx_RadioButton_outlined { diff --git a/res/css/views/elements/_TagComposer.scss b/res/css/views/elements/_TagComposer.scss new file mode 100644 index 0000000000..2ffd601765 --- /dev/null +++ b/res/css/views/elements/_TagComposer.scss @@ -0,0 +1,77 @@ +/* +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_TagComposer { + .mx_TagComposer_input { + display: flex; + + .mx_Field { + flex: 1; + margin: 0; // override from field styles + } + + .mx_AccessibleButton { + min-width: 70px; + padding: 0; // override from button styles + margin-left: 16px; // distance from + } + + .mx_Field, .mx_Field input, .mx_AccessibleButton { + // So they look related to each other by feeling the same + border-radius: 8px; + } + } + + .mx_TagComposer_tags { + display: flex; + flex-wrap: wrap; + margin-top: 12px; // this plus 12px from the tags makes 24px from the input + + .mx_TagComposer_tag { + padding: 6px 8px 8px 12px; + position: relative; + margin-right: 12px; + margin-top: 12px; + + // Cheaty way to get an opacified variable colour background + &::before { + content: ''; + border-radius: 20px; + background-color: $tertiary-fg-color; + opacity: 0.15; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + // Pass through the pointer otherwise we have effectively put a whole div + // on top of the component, which makes it hard to interact with buttons. + pointer-events: none; + } + } + + .mx_AccessibleButton { + background-image: url('$(res)/img/subtract.svg'); + width: 16px; + height: 16px; + margin-left: 8px; + display: inline-block; + vertical-align: middle; + cursor: pointer; + } + } +} diff --git a/res/css/views/messages/_CallEvent.scss b/res/css/views/messages/_CallEvent.scss new file mode 100644 index 0000000000..54c7df3e0b --- /dev/null +++ b/res/css/views/messages/_CallEvent.scss @@ -0,0 +1,154 @@ +/* +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_CallEvent { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + background-color: $dark-panel-bg-color; + border-radius: 8px; + margin: 10px auto; + max-width: 75%; + box-sizing: border-box; + height: 60px; + + &.mx_CallEvent_voice { + .mx_CallEvent_type_icon::before, + .mx_CallEvent_content_button_callBack span::before, + .mx_CallEvent_content_button_answer span::before { + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); + } + } + + &.mx_CallEvent_video { + .mx_CallEvent_type_icon::before, + .mx_CallEvent_content_button_callBack span::before, + .mx_CallEvent_content_button_answer span::before { + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + } + + .mx_CallEvent_info { + display: flex; + flex-direction: row; + align-items: center; + margin-left: 12px; + + .mx_CallEvent_info_basic { + display: flex; + flex-direction: column; + margin-left: 10px; // To match mx_CallEvent + + .mx_CallEvent_sender { + font-weight: 600; + font-size: 1.5rem; + line-height: 1.8rem; + margin-bottom: 3px; + } + + .mx_CallEvent_type { + font-weight: 400; + color: $secondary-fg-color; + font-size: 1.2rem; + line-height: $font-13px; + display: flex; + align-items: center; + + .mx_CallEvent_type_icon { + height: 13px; + width: 13px; + margin-right: 5px; + + &::before { + content: ''; + position: absolute; + height: 13px; + width: 13px; + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + } + } + } + } + } + + .mx_CallEvent_content { + display: flex; + flex-direction: row; + align-items: center; + color: $secondary-fg-color; + margin-right: 16px; + + .mx_CallEvent_content_button { + height: 24px; + padding: 0px 12px; + margin-left: 8px; + + span { + padding: 8px 0; + display: flex; + align-items: center; + + &::before { + content: ''; + display: inline-block; + background-color: $button-fg-color; + mask-position: center; + mask-repeat: no-repeat; + mask-size: 16px; + width: 16px; + height: 16px; + margin-right: 8px; + } + } + } + + .mx_CallEvent_content_button_reject span::before { + mask-image: url('$(res)/img/element-icons/call/hangup.svg'); + } + + .mx_CallEvent_content_tooltip { + margin-right: 5px; + } + + .mx_CallEvent_iconButton { + display: inline-flex; + margin-right: 8px; + + &::before { + content: ''; + + height: 16px; + width: 16px; + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + } + + .mx_CallEvent_silence::before { + mask-image: url('$(res)/img/voip/silence.svg'); + } + + .mx_CallEvent_unSilence::before { + mask-image: url('$(res)/img/voip/un-silence.svg'); + } + } +} diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index c215d69ec2..b91c461ce5 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -83,12 +83,12 @@ limitations under the License. mask-size: cover; mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); background-color: $message-body-panel-icon-fg-color; - width: 13px; + width: 15px; height: 15px; position: absolute; top: 8px; - left: 9px; + left: 8px; } } diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss index 1c773c2f06..f5d8131e6e 100644 --- a/res/css/views/messages/_MImageBody.scss +++ b/res/css/views/messages/_MImageBody.scss @@ -14,18 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ +$timelineImageBorderRadius: 4px; + .mx_MImageBody { display: block; - margin-right: 34px; } .mx_MImageBody_thumbnail { - position: absolute; - width: 100%; - height: 100%; - left: 0; - top: 0; - border-radius: 4px; + object-fit: contain; + border-radius: $timelineImageBorderRadius; + + display: flex; + justify-content: center; + align-items: center; + + > canvas { + border-radius: $timelineImageBorderRadius; + } } .mx_MImageBody_thumbnail_container { @@ -37,17 +42,6 @@ limitations under the License. position: relative; } -.mx_MImageBody_thumbnail_spinner { - position: absolute; - left: 50%; - top: 50%; -} - -// Inner img and TintableSvg should be centered around 0, 0 -.mx_MImageBody_thumbnail_spinner > * { - transform: translate(-50%, -50%); -} - .mx_MImageBody_gifLabel { position: absolute; display: block; diff --git a/res/css/views/messages/_MImageReplyBody.scss b/res/css/views/messages/_MImageReplyBody.scss new file mode 100644 index 0000000000..70c53f8c9c --- /dev/null +++ b/res/css/views/messages/_MImageReplyBody.scss @@ -0,0 +1,37 @@ +/* +Copyright 2020 Tulir Asokan + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_MImageReplyBody { + display: flex; + + .mx_MImageBody_thumbnail_container { + flex: 1; + margin-right: 4px; + } + + .mx_MImageReplyBody_info { + flex: 1; + + .mx_MImageReplyBody_sender { + grid-area: sender; + } + + .mx_MImageReplyBody_filename { + grid-area: filename; + } + } +} + diff --git a/res/css/views/messages/_MediaBody.scss b/res/css/views/messages/_MediaBody.scss new file mode 100644 index 0000000000..12e441750c --- /dev/null +++ b/res/css/views/messages/_MediaBody.scss @@ -0,0 +1,28 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// A "media body" is any file upload looking thing, apart from images and videos (they +// have unique styles). + +.mx_MediaBody { + background-color: $message-body-panel-bg-color; + border-radius: 12px; + + color: $message-body-panel-fg-color; + font-size: $font-14px; + line-height: $font-24px; +} + diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index e2fafe6c62..69f3c672b7 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -107,3 +107,12 @@ limitations under the License. .mx_MessageActionBar_cancelButton::after { mask-image: url('$(res)/img/element-icons/trashcan.svg'); } + +.mx_MessageActionBar_downloadButton::after { + mask-size: 14px; + mask-image: url('$(res)/img/download.svg'); +} + +.mx_MessageActionBar_downloadButton.mx_MessageActionBar_downloadSpinnerButton::after { + background-color: transparent; // hide the download icon mask +} diff --git a/res/css/views/messages/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss index e05065eb02..b2bca6dfb3 100644 --- a/res/css/views/messages/_ReactionsRow.scss +++ b/res/css/views/messages/_ReactionsRow.scss @@ -26,6 +26,7 @@ limitations under the License. height: 24px; vertical-align: middle; margin-left: 4px; + margin-right: 4px; &::before { content: ''; diff --git a/res/css/views/messages/_common_CryptoEvent.scss b/res/css/views/messages/_common_CryptoEvent.scss index bcc40f1181..afaed50fa4 100644 --- a/res/css/views/messages/_common_CryptoEvent.scss +++ b/res/css/views/messages/_common_CryptoEvent.scss @@ -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/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss new file mode 100644 index 0000000000..c66f635ffe --- /dev/null +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -0,0 +1,323 @@ +/* +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_EventTile[data-layout=bubble], +.mx_EventTile[data-layout=bubble] ~ .mx_EventListSummary { + --avatarSize: 32px; + --gutterSize: 11px; + --cornerRadius: 12px; + --maxWidth: 70%; +} + +.mx_EventTile[data-layout=bubble] { + + position: relative; + margin-top: var(--gutterSize); + margin-left: 50px; + margin-right: 100px; + + &.mx_EventTile_continuation { + margin-top: 2px; + } + + /* For replies */ + .mx_EventTile { + padding-top: 0; + } + + &:hover { + &::before { + content: ''; + position: absolute; + top: -1px; + bottom: -1px; + left: -60px; + right: -60px; + z-index: -1; + background: $eventbubble-bg-hover; + border-radius: 4px; + } + + .mx_EventTile_avatar { + img { + box-shadow: 0 0 0 3px $eventbubble-bg-hover; + } + } + } + + .mx_SenderProfile, + .mx_EventTile_line { + width: fit-content; + max-width: 70%; + } + + .mx_SenderProfile { + position: relative; + top: -2px; + left: 2px; + } + + &[data-self=false] { + .mx_EventTile_line { + border-bottom-right-radius: var(--cornerRadius); + } + .mx_EventTile_avatar { + left: -34px; + } + + .mx_MessageActionBar { + right: 0; + transform: translate3d(50%, 50%, 0); + } + + --backgroundColor: $eventbubble-others-bg; + } + &[data-self=true] { + .mx_EventTile_line { + border-bottom-left-radius: var(--cornerRadius); + float: right; + > a { + left: auto; + right: -48px; + } + } + .mx_SenderProfile { + display: none; + } + .mx_ReactionsRow { + float: right; + clear: right; + display: flex; + + /* Moving the "add reaction button" before the reactions */ + > :last-child { + order: -1; + } + } + .mx_EventTile_avatar { + top: -19px; // height of the sender block + right: -35px; + } + + --backgroundColor: $eventbubble-self-bg; + } + + .mx_EventTile_line { + position: relative; + padding: var(--gutterSize); + border-top-left-radius: var(--cornerRadius); + border-top-right-radius: var(--cornerRadius); + background: var(--backgroundColor); + display: flex; + gap: 5px; + margin: 0 -12px 0 -9px; + > a { + position: absolute; + left: -48px; + } + } + + &.mx_EventTile_continuation[data-self=false] .mx_EventTile_line { + border-top-left-radius: 0; + } + &.mx_EventTile_lastInSection[data-self=false] .mx_EventTile_line { + border-bottom-left-radius: var(--cornerRadius); + } + + &.mx_EventTile_continuation[data-self=true] .mx_EventTile_line { + border-top-right-radius: 0; + } + &.mx_EventTile_lastInSection[data-self=true] .mx_EventTile_line { + border-bottom-right-radius: var(--cornerRadius); + } + + .mx_EventTile_avatar { + position: absolute; + top: 0; + line-height: 1; + img { + box-shadow: 0 0 0 3px $eventbubble-avatar-outline; + border-radius: 50%; + } + } + + &[data-has-reply=true] { + > .mx_EventTile_line { + flex-direction: column; + } + + .mx_ReplyThread_show { + order: 99999; + } + + .mx_ReplyThread { + margin: 0 calc(-1 * var(--gutterSize)); + + .mx_EventTile_reply { + max-width: 90%; + padding: 0; + > a { + display: none !important; + } + } + + .mx_EventTile { + display: flex; + gap: var(--gutterSize); + .mx_EventTile_avatar { + position: static; + } + .mx_SenderProfile { + display: none; + } + } + } + } + + .mx_EditMessageComposer_buttons { + position: static; + padding: 0; + margin: 0; + background: transparent; + } + + .mx_ReactionsRow { + margin-right: -18px; + margin-left: -9px; + } + + .mx_ReplyThread { + border-left-width: 2px; + border-left-color: $eventbubble-reply-color; + } + + &.mx_EventTile_bubbleContainer, + &.mx_EventTile_info, + & ~ .mx_EventListSummary[data-expanded=false] { + --backgroundColor: transparent; + --gutterSize: 0; + + display: flex; + align-items: center; + justify-content: center; + + .mx_EventTile_avatar { + position: static; + order: -1; + margin-right: 5px; + } + } + + & ~ .mx_EventListSummary { + --maxWidth: 80%; + margin-left: calc(var(--avatarSize) + var(--gutterSize)); + margin-right: calc(var(--gutterSize) + var(--avatarSize)); + .mx_EventListSummary_toggle { + float: none; + margin: 0; + order: 9; + margin-left: 5px; + } + .mx_EventListSummary_avatars { + padding-top: 0; + } + + &::after { + content: ""; + clear: both; + } + + .mx_EventTile { + margin: 0 6px; + } + + .mx_EventTile_line { + margin: 0 5px; + > a { + left: auto; + right: 0; + transform: translateX(calc(100% + 5px)); + } + } + + .mx_MessageActionBar { + transform: translate3d(50%, 0, 0); + } + } + + & ~ .mx_EventListSummary[data-expanded=false] { + padding: 0 34px; + } + + /* events that do not require bubble layout */ + & ~ .mx_EventListSummary, + &.mx_EventTile_bad { + .mx_EventTile_line { + background: transparent; + } + + &:hover { + &::before { + background: transparent; + } + } + } + + & + .mx_EventListSummary { + .mx_EventTile { + margin-top: 0; + padding: 0; + } + } + + .mx_EventListSummary_toggle { + margin-right: 55px; + } + + /* Special layout scenario for "Unable To Decrypt (UTD)" events */ + &.mx_EventTile_bad > .mx_EventTile_line { + display: grid; + grid-template: + "reply reply" auto + "shield body" auto + "shield link" auto + / auto 1fr; + .mx_EventTile_e2eIcon { + grid-area: shield; + } + .mx_UnknownBody { + grid-area: body; + } + .mx_EventTile_keyRequestInfo { + grid-area: link; + } + .mx_ReplyThread_wrapper { + grid-area: reply; + } + } + + + .mx_EventTile_readAvatars { + position: absolute; + right: -110px; + bottom: 0; + top: auto; + } + + .mx_MTextBody { + max-width: 100%; + } +} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 27a83e58f8..ab4fb28791 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -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. @@ -18,102 +18,307 @@ limitations under the License. $left-gutter: 64px; $hover-select-border: 4px; -.mx_EventTile { +.mx_EventTile:not([data-layout=bubble]) { max-width: 100%; clear: both; padding-top: 18px; font-size: $font-14px; position: relative; -} -.mx_EventTile.mx_EventTile_info { - padding-top: 1px; -} + &.mx_EventTile_info { + padding-top: 1px; + } -.mx_EventTile_avatar { - top: 14px; - left: 8px; - cursor: pointer; - user-select: none; -} + .mx_EventTile_avatar { + top: 14px; + left: 8px; + cursor: pointer; + user-select: none; + } -.mx_EventTile.mx_EventTile_info .mx_EventTile_avatar { - top: $font-6px; - left: $left-gutter; -} + &.mx_EventTile_info .mx_EventTile_avatar { + top: $font-6px; + left: $left-gutter; + } -.mx_EventTile_continuation { - padding-top: 0px !important; + &.mx_EventTile_continuation { + padding-top: 0px !important; + + &.mx_EventTile_isEditing { + padding-top: 5px !important; + margin-top: -5px; + } + } &.mx_EventTile_isEditing { - padding-top: 5px !important; - margin-top: -5px; + background-color: $header-panel-bg-color; } -} -.mx_EventTile_isEditing { - background-color: $header-panel-bg-color; -} + .mx_SenderProfile { + color: $primary-fg-color; + font-size: $font-14px; + display: inline-block; /* anti-zalgo, with overflow hidden */ + overflow: hidden; + cursor: pointer; + padding-bottom: 0px; + padding-top: 0px; + margin: 0px; + /* the next three lines, along with overflow hidden, truncate long display names */ + white-space: nowrap; + text-overflow: ellipsis; + max-width: calc(100% - $left-gutter); + } -.mx_EventTile .mx_SenderProfile { - color: $primary-fg-color; - font-size: $font-14px; - display: inline-block; /* anti-zalgo, with overflow hidden */ - overflow: hidden; - cursor: pointer; - padding-bottom: 0px; - padding-top: 0px; - margin: 0px; - /* the next three lines, along with overflow hidden, truncate long display names */ - white-space: nowrap; - text-overflow: ellipsis; - max-width: calc(100% - $left-gutter); -} + .mx_SenderProfile .mx_Flair { + opacity: 0.7; + margin-left: 5px; + display: inline-block; + vertical-align: top; + overflow: hidden; + user-select: none; -.mx_EventTile .mx_SenderProfile .mx_Flair { - opacity: 0.7; - margin-left: 5px; - display: inline-block; - vertical-align: top; - overflow: hidden; - user-select: none; + img { + vertical-align: -2px; + margin-right: 2px; + border-radius: 8px; + } + } - img { - vertical-align: -2px; - margin-right: 2px; + &.mx_EventTile_isEditing .mx_MessageTimestamp { + visibility: hidden; + } + + .mx_MessageTimestamp { + display: block; + white-space: nowrap; + left: 0px; + text-align: center; + user-select: none; + } + + &.mx_EventTile_continuation .mx_EventTile_line { + clear: both; + } + + .mx_EventTile_line, .mx_EventTile_reply { + position: relative; + padding-left: $left-gutter; border-radius: 8px; } -} -.mx_EventTile_isEditing .mx_MessageTimestamp { - visibility: hidden; -} - -.mx_EventTile .mx_MessageTimestamp { - display: block; - white-space: nowrap; - left: 0px; - text-align: center; - user-select: none; -} - -.mx_EventTile_continuation .mx_EventTile_line { - clear: both; -} - -.mx_EventTile_line, .mx_EventTile_reply { - position: relative; - padding-left: $left-gutter; - border-radius: 8px; -} - -.mx_RoomView_timeline_rr_enabled, -// on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter -.mx_EventListSummary { - .mx_EventTile_line { - /* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */ - margin-right: 110px; + .mx_EventTile_reply { + margin-right: 10px; } + + &.mx_EventTile_selected > div > a > .mx_MessageTimestamp { + left: calc(-$hover-select-border); + } + + /* this is used for the tile for the event which is selected via the URL. + * TODO: ultimately we probably want some transition on here. + */ + &.mx_EventTile_selected > .mx_EventTile_line { + border-left: $accent-color 4px solid; + padding-left: calc($left-gutter - $hover-select-border); + background-color: $event-selected-color; + } + + &.mx_EventTile_highlight, + &.mx_EventTile_highlight .markdown-body { + color: $event-highlight-fg-color; + + .mx_EventTile_line { + background-color: $event-highlight-bg-color; + } + } + + &.mx_EventTile_info .mx_EventTile_line { + padding-left: calc($left-gutter + 18px); + } + + & ~ .mx_EventListSummary .mx_EventTile_line { + padding-left: calc($left-gutter); + } + + &.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { + padding-left: calc($left-gutter + 18px - $hover-select-border); + } + + &.mx_EventTile:hover .mx_EventTile_line, + &.mx_EventTile.mx_EventTile_actionBarFocused .mx_EventTile_line, + &.mx_EventTile.focus-visible:focus-within .mx_EventTile_line { + background-color: $event-selected-color; + } + + .mx_EventTile_searchHighlight { + background-color: $accent-color; + color: $accent-fg-color; + border-radius: 5px; + padding-left: 2px; + padding-right: 2px; + cursor: pointer; + } + + .mx_EventTile_searchHighlight a { + background-color: $accent-color; + color: $accent-fg-color; + } + + .mx_EventTile_receiptSent, + .mx_EventTile_receiptSending { + // We don't use `position: relative` on the element because then it won't line + // up with the other read receipts + + &::before { + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 14px; + width: 14px; + height: 14px; + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + } + } + .mx_EventTile_receiptSent::before { + mask-image: url('$(res)/img/element-icons/circle-sent.svg'); + } + .mx_EventTile_receiptSending::before { + mask-image: url('$(res)/img/element-icons/circle-sending.svg'); + } + + &.mx_EventTile_contextual { + opacity: 0.4; + } + + .mx_EventTile_msgOption { + float: right; + text-align: right; + position: relative; + width: 90px; + + /* Hack to stop the height of this pushing the messages apart. + Replaces margin-top: -6px. This interacts better with a read + marker being in between. Content overflows. */ + height: 1px; + + margin-right: 10px; + } + + .mx_EventTile_msgOption a { + text-decoration: none; + } + + /* De-zalgoing */ + .mx_EventTile_body { + overflow-y: hidden; + } + + &:hover.mx_EventTile_verified .mx_EventTile_line, + &:hover.mx_EventTile_unverified .mx_EventTile_line, + &:hover.mx_EventTile_unknown .mx_EventTile_line { + padding-left: calc($left-gutter - $hover-select-border); + } + + &:hover.mx_EventTile_verified .mx_EventTile_line { + border-left: $e2e-verified-color $EventTile_e2e_state_indicator_width solid; + } + + &:hover.mx_EventTile_unverified .mx_EventTile_line { + border-left: $e2e-unverified-color $EventTile_e2e_state_indicator_width solid; + } + + &:hover.mx_EventTile_unknown .mx_EventTile_line { + border-left: $e2e-unknown-color $EventTile_e2e_state_indicator_width solid; + } + + &:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line, + &:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line, + &:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line { + padding-left: calc($left-gutter + 18px - $hover-select-border); + } + + /* End to end encryption stuff */ + &:hover .mx_EventTile_e2eIcon { + opacity: 1; + } + + // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) + &:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp, + &:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp, + &:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp { + left: calc(-$hover-select-border); + } + + // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) + &:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon, + &:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon, + &:hover.mx_EventTile_unknown .mx_EventTile_line > .mx_EventTile_e2eIcon { + display: block; + left: 41px; + } + + .mx_MImageBody { + margin-right: 34px; + } + + .mx_EventTile_e2eIcon { + position: absolute; + top: 6px; + left: 44px; + bottom: 0; + right: 0; + } + + .mx_ReactionsRow { + margin: 0; + padding: 6px 60px; + } +} + +/* all the overflow-y: hidden; are to trap Zalgos - + but they introduce an implicit overflow-x: auto. + so make that explicitly hidden too to avoid random + horizontal scrollbars occasionally appearing, like in + https://github.com/vector-im/vector-web/issues/1154 */ +.mx_EventTile_content { + overflow-y: hidden; + overflow-x: hidden; + margin-right: 34px; +} + +/* Spoiler stuff */ +.mx_EventTile_spoiler { + cursor: pointer; +} + +.mx_EventTile_spoiler_reason { + color: $event-timestamp-color; + font-size: $font-11px; +} + +.mx_EventTile_spoiler_content { + filter: blur(5px) saturate(0.1) sepia(1); + transition-duration: 0.5s; +} + +.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content { + filter: none; +} + +.mx_RoomView_timeline_rr_enabled { + + .mx_EventTile:not([data-layout=bubble]) { + .mx_EventTile_line { + /* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */ + margin-right: 110px; + } + } + + // on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter } .mx_EventTile_bubbleContainer { @@ -130,123 +335,15 @@ $hover-select-border: 4px; .mx_EventTile_msgOption { grid-column: 2; } -} -.mx_EventTile_reply { - margin-right: 10px; -} - -/* HACK to override line-height which is already marked important elsewhere */ -.mx_EventTile_bigEmoji.mx_EventTile_bigEmoji { - font-size: 48px !important; - line-height: 57px !important; -} - -.mx_EventTile_selected > div > a > .mx_MessageTimestamp { - left: calc(-$hover-select-border); -} - -.mx_EventTile:hover .mx_MessageActionBar, -.mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar, -[data-whatinput='keyboard'] .mx_EventTile:focus-within .mx_MessageActionBar, -.mx_EventTile.focus-visible:focus-within .mx_MessageActionBar { - visibility: visible; -} - -/* this is used for the tile for the event which is selected via the URL. - * TODO: ultimately we probably want some transition on here. - */ -.mx_EventTile_selected > .mx_EventTile_line { - border-left: $accent-color 4px solid; - padding-left: calc($left-gutter - $hover-select-border); - background-color: $event-selected-color; -} - -.mx_EventTile_highlight, -.mx_EventTile_highlight .markdown-body { - color: $event-highlight-fg-color; - - .mx_EventTile_line { - background-color: $event-highlight-bg-color; + &:hover { + .mx_EventTile_line { + // To avoid bubble events being highlighted + background-color: inherit !important; + } } } -.mx_EventTile_info .mx_EventTile_line { - padding-left: calc($left-gutter + 18px); -} - -.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { - padding-left: calc($left-gutter + 18px - $hover-select-border); -} - -.mx_EventTile:hover .mx_EventTile_line, -.mx_EventTile.mx_EventTile_actionBarFocused .mx_EventTile_line, -.mx_EventTile.focus-visible:focus-within .mx_EventTile_line { - background-color: $event-selected-color; -} - -.mx_EventTile_searchHighlight { - background-color: $accent-color; - color: $accent-fg-color; - border-radius: 5px; - padding-left: 2px; - padding-right: 2px; - cursor: pointer; -} - -.mx_EventTile_searchHighlight a { - background-color: $accent-color; - color: $accent-fg-color; -} - -.mx_EventTile_receiptSent, -.mx_EventTile_receiptSending { - // We don't use `position: relative` on the element because then it won't line - // up with the other read receipts - - &::before { - background-color: $tertiary-fg-color; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 14px; - width: 14px; - height: 14px; - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - } -} -.mx_EventTile_receiptSent::before { - mask-image: url('$(res)/img/element-icons/circle-sent.svg'); -} -.mx_EventTile_receiptSending::before { - mask-image: url('$(res)/img/element-icons/circle-sending.svg'); -} - -.mx_EventTile_contextual { - opacity: 0.4; -} - -.mx_EventTile_msgOption { - float: right; - text-align: right; - position: relative; - width: 90px; - - /* Hack to stop the height of this pushing the messages apart. - Replaces margin-top: -6px. This interacts better with a read - marker being in between. Content overflows. */ - height: 1px; - - margin-right: 10px; -} - -.mx_EventTile_msgOption a { - text-decoration: none; -} - .mx_EventTile_readAvatars { position: relative; display: inline-block; @@ -277,52 +374,27 @@ $hover-select-border: 4px; position: absolute; } -/* all the overflow-y: hidden; are to trap Zalgos - - but they introduce an implicit overflow-x: auto. - so make that explicitly hidden too to avoid random - horizontal scrollbars occasionally appearing, like in - https://github.com/vector-im/vector-web/issues/1154 - */ -.mx_EventTile_content { - display: block; - overflow-y: hidden; - overflow-x: hidden; - margin-right: 34px; +/* HACK to override line-height which is already marked important elsewhere */ +.mx_EventTile_bigEmoji.mx_EventTile_bigEmoji { + font-size: 48px !important; + line-height: 57px !important; } -/* De-zalgoing */ -.mx_EventTile_body { - overflow-y: hidden; -} - -/* Spoiler stuff */ -.mx_EventTile_spoiler { +.mx_EventTile_content .mx_EventTile_edited { + user-select: none; + font-size: $font-12px; + color: $roomtopic-color; + display: inline-block; + margin-left: 9px; cursor: pointer; } -.mx_EventTile_spoiler_reason { - color: $event-timestamp-color; - font-size: $font-11px; -} - -.mx_EventTile_spoiler_content { - filter: blur(5px) saturate(0.1) sepia(1); - transition-duration: 0.5s; -} - -.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content { - filter: none; -} .mx_EventTile_e2eIcon { - position: absolute; - top: 6px; - left: 44px; + position: relative; width: 14px; height: 14px; display: block; - bottom: 0; - right: 0; opacity: 0.2; background-repeat: no-repeat; background-size: contain; @@ -381,87 +453,6 @@ $hover-select-border: 4px; opacity: 1; } -.mx_EventTile_keyRequestInfo { - font-size: $font-12px; -} - -.mx_EventTile_keyRequestInfo_text { - opacity: 0.5; -} - -.mx_EventTile_keyRequestInfo_text a { - color: $primary-fg-color; - text-decoration: underline; - cursor: pointer; -} - -.mx_EventTile_keyRequestInfo_tooltip_contents p { - text-align: auto; - margin-left: 3px; - margin-right: 3px; -} - -.mx_EventTile_keyRequestInfo_tooltip_contents p:first-child { - margin-top: 0px; -} - -.mx_EventTile_keyRequestInfo_tooltip_contents p:last-child { - margin-bottom: 0px; -} - -.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: calc($left-gutter - $hover-select-border); -} - -.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line { - border-left: $e2e-verified-color $EventTile_e2e_state_indicator_width solid; -} - -.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line { - border-left: $e2e-unverified-color $EventTile_e2e_state_indicator_width solid; -} - -.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line { - border-left: $e2e-unknown-color $EventTile_e2e_state_indicator_width solid; -} - -.mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line, -.mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line, -.mx_EventTile:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line { - padding-left: calc($left-gutter + 18px - $hover-select-border); -} - -/* End to end encryption stuff */ -.mx_EventTile:hover .mx_EventTile_e2eIcon { - opacity: 1; -} - -// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) -.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp, -.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp, -.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp { - left: calc(-$hover-select-border); -} - -// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) -.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon, -.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon, -.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > .mx_EventTile_e2eIcon { - display: block; - left: 41px; -} - -.mx_EventTile_content .mx_EventTile_edited { - user-select: none; - font-size: $font-12px; - color: $roomtopic-color; - display: inline-block; - margin-left: 9px; - cursor: pointer; -} - /* Various markdown overrides */ .mx_EventTile_body pre { @@ -477,8 +468,12 @@ $hover-select-border: 4px; 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 code > * { + display: inline-block; + width: 100%; } pre { @@ -488,11 +483,6 @@ $hover-select-border: 4px; overflow-x: overlay; overflow-y: visible; } - - code { - // deliberate constants as we're behind an invert filter - background-color: #f8f8f8; - } } .mx_EventTile_lineNumbers { @@ -601,6 +591,35 @@ $hover-select-border: 4px; /* end of overrides */ + +.mx_EventTile_keyRequestInfo { + font-size: $font-12px; +} + +.mx_EventTile_keyRequestInfo_text { + opacity: 0.5; +} + +.mx_EventTile_keyRequestInfo_text a { + color: $primary-fg-color; + text-decoration: underline; + cursor: pointer; +} + +.mx_EventTile_keyRequestInfo_tooltip_contents p { + text-align: auto; + margin-left: 3px; + margin-right: 3px; +} + +.mx_EventTile_keyRequestInfo_tooltip_contents p:first-child { + margin-top: 0px; +} + +.mx_EventTile_keyRequestInfo_tooltip_contents p:last-child { + margin-bottom: 0px; +} + .mx_EventTile_tileError { color: red; text-align: center; @@ -621,6 +640,13 @@ $hover-select-border: 4px; } } +.mx_EventTile:hover .mx_MessageActionBar, +.mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar, +[data-whatinput='keyboard'] .mx_EventTile:focus-within .mx_MessageActionBar, +.mx_EventTile.focus-visible:focus-within .mx_MessageActionBar { + visibility: visible; +} + @media only screen and (max-width: 480px) { .mx_EventTile_line, .mx_EventTile_reply { padding-left: 0; diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index 5e61c3b8a3..97190807ca 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -198,8 +198,9 @@ $irc-line-height: $font-18px; .mx_ReplyThread { margin: 0; .mx_SenderProfile { + order: unset; + max-width: unset; width: unset; - max-width: var(--name-width); background: transparent; } 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/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss index 10f8e21e43..60feb39d11 100644 --- a/res/css/views/rooms/_ReplyPreview.scss +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -22,28 +22,34 @@ limitations under the License. max-height: 50vh; overflow: auto; box-shadow: 0px -16px 32px $composer-shadow-color; + + .mx_ReplyPreview_section { + border-bottom: 1px solid $primary-hairline-color; + + .mx_ReplyPreview_header { + margin: 8px; + color: $primary-fg-color; + font-weight: 400; + opacity: 0.4; + } + + .mx_ReplyPreview_tile { + margin: 0 8px; + } + + .mx_ReplyPreview_title { + float: left; + } + + .mx_ReplyPreview_cancel { + float: right; + cursor: pointer; + display: flex; + } + + .mx_ReplyPreview_clear { + clear: both; + } + } } -.mx_ReplyPreview_section { - border-bottom: 1px solid $primary-hairline-color; -} - -.mx_ReplyPreview_header { - margin: 12px; - color: $primary-fg-color; - font-weight: 400; - opacity: 0.4; -} - -.mx_ReplyPreview_title { - float: left; -} - -.mx_ReplyPreview_cancel { - float: right; - cursor: pointer; -} - -.mx_ReplyPreview_clear { - clear: both; -} diff --git a/res/css/views/rooms/_ReplyTile.scss b/res/css/views/rooms/_ReplyTile.scss new file mode 100644 index 0000000000..f3e204e415 --- /dev/null +++ b/res/css/views/rooms/_ReplyTile.scss @@ -0,0 +1,119 @@ +/* +Copyright 2020 Tulir Asokan + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ReplyTile { + position: relative; + padding: 2px 0; + font-size: $font-14px; + line-height: $font-16px; + + &.mx_ReplyTile_audio .mx_MFileBody_info_icon::before { + mask-image: url("$(res)/img/element-icons/speaker.svg"); + } + + &.mx_ReplyTile_video .mx_MFileBody_info_icon::before { + mask-image: url("$(res)/img/element-icons/call/video-call.svg"); + } + + .mx_MFileBody { + .mx_MFileBody_info { + margin: 5px 0; + } + + .mx_MFileBody_download { + display: none; + } + } + + > a { + display: flex; + flex-direction: column; + text-decoration: none; + color: $primary-fg-color; + } + + .mx_RedactedBody { + padding: 4px 0 2px 20px; + + &::before { + height: 13px; + width: 13px; + top: 5px; + } + } + + // We do reply size limiting with CSS to avoid duplicating the TextualBody component. + .mx_EventTile_content { + $reply-lines: 2; + $line-height: $font-22px; + + pointer-events: none; + + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: $reply-lines; + line-height: $line-height; + + .mx_EventTile_body.mx_EventTile_bigEmoji { + line-height: $line-height !important; + font-size: $font-14px !important; // Override the big emoji override + } + + // Hide line numbers + .mx_EventTile_lineNumbers { + display: none; + } + + // Hack to cut content in
 tags too
+        .mx_EventTile_pre_container > pre {
+            overflow: hidden;
+            text-overflow: ellipsis;
+            display: -webkit-box;
+            -webkit-box-orient: vertical;
+            -webkit-line-clamp: $reply-lines;
+            padding: 4px;
+        }
+
+        .markdown-body blockquote,
+        .markdown-body dl,
+        .markdown-body ol,
+        .markdown-body p,
+        .markdown-body pre,
+        .markdown-body table,
+        .markdown-body ul {
+            margin-bottom: 4px;
+        }
+    }
+
+    &.mx_ReplyTile_info {
+        padding-top: 0;
+    }
+
+    .mx_SenderProfile {
+        font-size: $font-14px;
+        line-height: $font-17px;
+
+        display: inline-block; // anti-zalgo, with overflow hidden
+        padding: 0;
+        margin: 0;
+
+        // truncate long display names
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+    }
+}
diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss
index 03146e0325..b8f4aeb6e7 100644
--- a/res/css/views/rooms/_RoomTile.scss
+++ b/res/css/views/rooms/_RoomTile.scss
@@ -193,6 +193,10 @@ limitations under the License.
         mask-image: url('$(res)/img/element-icons/settings.svg');
     }
 
+    .mx_RoomTile_iconCopyLink::before {
+        mask-image: url('$(res)/img/element-icons/link.svg');
+    }
+
     .mx_RoomTile_iconInvite::before {
         mask-image: url('$(res)/img/element-icons/room/invite.svg');
     }
diff --git a/res/css/views/settings/_Notifications.scss b/res/css/views/settings/_Notifications.scss
index 77a7bc5b68..f93e0a53a8 100644
--- a/res/css/views/settings/_Notifications.scss
+++ b/res/css/views/settings/_Notifications.scss
@@ -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,82 +14,79 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_UserNotifSettings_tableRow {
-    display: table-row;
-}
+.mx_UserNotifSettings {
+    color: $primary-fg-color; // override from default settings page styles
 
-.mx_UserNotifSettings_inputCell {
-    display: table-cell;
-    padding-bottom: 8px;
-    padding-right: 8px;
-    width: 16px;
-}
+    .mx_UserNotifSettings_pushRulesTable {
+        width: calc(100% + 12px); // +12px to line up center of 'Noisy' column with toggle switches
+        table-layout: fixed;
+        border-collapse: collapse;
+        border-spacing: 0;
+        margin-top: 40px;
 
-.mx_UserNotifSettings_labelCell {
-    padding-bottom: 8px;
-    width: 400px;
-    display: table-cell;
-}
+        tr > th {
+            font-weight: $font-semi-bold;
+        }
 
-.mx_UserNotifSettings_pushRulesTableWrapper {
-    padding-bottom: 8px;
-}
+        tr > th:first-child {
+            text-align: left;
+            font-size: $font-18px;
+        }
 
-.mx_UserNotifSettings_pushRulesTable {
-    width: 100%;
-    table-layout: fixed;
-}
+        tr > th:nth-child(n + 2) {
+            color: $secondary-fg-color;
+            font-size: $font-12px;
+            vertical-align: middle;
+            width: 66px;
+        }
 
-.mx_UserNotifSettings_pushRulesTable thead {
-    font-weight: bold;
-}
+        tr > td:nth-child(n + 2) {
+            text-align: center;
+        }
 
-.mx_UserNotifSettings_pushRulesTable tbody th {
-    font-weight: 400;
-}
+        tr > td {
+            padding-top: 8px;
+        }
 
-.mx_UserNotifSettings_pushRulesTable tbody th:first-child {
-    text-align: left;
-}
+        // Override StyledRadioButton default styles
+        .mx_RadioButton {
+            justify-content: center;
 
-.mx_UserNotifSettings_keywords {
-    cursor: pointer;
-    color: $accent-color;
-}
+            .mx_RadioButton_content {
+                display: none;
+            }
 
-.mx_UserNotifSettings_devicesTable td {
-    padding-left: 20px;
-    padding-right: 20px;
-}
+            .mx_RadioButton_spacer {
+                display: none;
+            }
+        }
+    }
 
-.mx_UserNotifSettings_notifTable {
-    display: table;
-    position: relative;
-}
+    .mx_UserNotifSettings_floatingSection {
+        margin-top: 40px;
 
-.mx_UserNotifSettings_notifTable .mx_Spinner {
-    position: absolute;
-}
+        & > div:first-child { // section header
+            font-size: $font-18px;
+            font-weight: $font-semi-bold;
+        }
 
-.mx_NotificationSound_soundUpload {
-    display: none;
-}
+        > table {
+            border-collapse: collapse;
+            border-spacing: 0;
+            margin-top: 8px;
 
-.mx_NotificationSound_browse {
-    color: $accent-color;
-    border: 1px solid $accent-color;
-    background-color: transparent;
-}
+            tr > td:first-child {
+                // Just for a bit of spacing
+                padding-right: 8px;
+            }
+        }
+    }
 
-.mx_NotificationSound_save {
-    margin-left: 5px;
-    color: white;
-    background-color: $accent-color;
-}
+    .mx_UserNotifSettings_clearNotifsButton {
+        margin-top: 8px;
+    }
 
-.mx_NotificationSound_resetSound {
-    margin-top: 5px;
-    color: white;
-    border: $warning-color;
-    background-color: $warning-color;
+    .mx_TagComposer {
+        margin-top: 35px; // lots of distance from the last line of the table
+    }
 }
diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss
index 94983a60bf..ca5a6f0a66 100644
--- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss
+++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss
@@ -15,8 +15,7 @@ limitations under the License.
 */
 
 .mx_AppearanceUserSettingsTab_fontSlider,
-.mx_AppearanceUserSettingsTab_fontSlider_preview,
-.mx_AppearanceUserSettingsTab_Layout {
+.mx_AppearanceUserSettingsTab_fontSlider_preview {
     @mixin mx_Settings_fullWidthField;
 }
 
@@ -45,6 +44,11 @@ limitations under the License.
     border-radius: 10px;
     padding: 0 16px 9px 16px;
     pointer-events: none;
+    display: flow-root;
+
+    .mx_EventTile[data-layout=bubble] {
+        margin-top: 30px;
+    }
 
     .mx_EventTile_msgOption {
         display: none;
@@ -154,13 +158,10 @@ limitations under the License.
 .mx_AppearanceUserSettingsTab_Layout_RadioButtons {
     display: flex;
     flex-direction: row;
+    gap: 24px;
 
     color: $primary-fg-color;
 
-    .mx_AppearanceUserSettingsTab_spacer {
-        width: 24px;
-    }
-
     > .mx_AppearanceUserSettingsTab_Layout_RadioButton {
         flex-grow: 0;
         flex-shrink: 1;
@@ -210,6 +211,21 @@ limitations under the License.
     .mx_RadioButton_checked {
         background-color: rgba($accent-color, 0.08);
     }
+
+    .mx_EventTile {
+        margin: 0;
+        &[data-layout=bubble] {
+            margin-right: 40px;
+        }
+        &[data-layout=irc] {
+            > a {
+                display: none;
+            }
+        }
+        .mx_EventTile_line {
+            max-width: 90%;
+        }
+    }
 }
 
 .mx_AppearanceUserSettingsTab_Advanced {
diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss
index 168a8bb74b..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 {
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 0be75be28c..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;
diff --git a/res/css/views/voip/_DialPad.scss b/res/css/views/voip/_DialPad.scss
index 483b131bfe..eefd2e9ba5 100644
--- a/res/css/views/voip/_DialPad.scss
+++ b/res/css/views/voip/_DialPad.scss
@@ -16,11 +16,21 @@ limitations under the License.
 
 .mx_DialPad {
     display: grid;
+    row-gap: 16px;
+    column-gap: 0px;
+    margin-top: 24px;
+    margin-left: auto;
+    margin-right: auto;
+
+    /* squeeze the dial pad buttons together horizontally */
     grid-template-columns: repeat(3, 1fr);
-    gap: 16px;
 }
 
 .mx_DialPad_button {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+
     width: 40px;
     height: 40px;
     background-color: $dialpad-button-bg-color;
@@ -29,10 +39,19 @@ limitations under the License.
     font-weight: 600;
     text-align: center;
     vertical-align: middle;
-    line-height: 40px;
+    margin-left: auto;
+    margin-right: auto;
 }
 
-.mx_DialPad_deleteButton, .mx_DialPad_dialButton {
+.mx_DialPad_button .mx_DialPad_buttonSubText {
+    font-size: 8px;
+}
+
+.mx_DialPad_dialButton {
+    /* Always show the dial button in the center grid column */
+    grid-column: 2;
+    background-color: $accent-color;
+
     &::before {
         content: '';
         display: inline-block;
@@ -42,21 +61,7 @@ limitations under the License.
         mask-repeat: no-repeat;
         mask-size: 20px;
         mask-position: center;
-        background-color: $primary-bg-color;
-    }
-}
-
-.mx_DialPad_deleteButton {
-    background-color: $notice-primary-color;
-    &::before {
-        mask-image: url('$(res)/img/element-icons/call/delete.svg');
-        mask-position: 9px; // delete icon is right-heavy so have to be slightly to the left to look centered
-    }
-}
-
-.mx_DialPad_dialButton {
-    background-color: $accent-color;
-    &::before {
+        background-color: #FFF; // on all themes
         mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
     }
 }
diff --git a/res/css/views/voip/_DialPadContextMenu.scss b/res/css/views/voip/_DialPadContextMenu.scss
index 31327113cf..0019994e72 100644
--- a/res/css/views/voip/_DialPadContextMenu.scss
+++ b/res/css/views/voip/_DialPadContextMenu.scss
@@ -14,10 +14,40 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+.mx_DialPadContextMenu_dialPad .mx_DialPad {
+    row-gap: 16px;
+    column-gap: 32px;
+}
+
+.mx_DialPadContextMenuWrapper {
+    padding: 15px;
+}
+
 .mx_DialPadContextMenu_header {
-    margin-top: 12px;
-    margin-left: 12px;
-    margin-right: 12px;
+    border: none;
+    margin-top: 32px;
+    margin-left: 20px;
+    margin-right: 20px;
+
+    /* a separator between the input line and the dial buttons */
+    border-bottom: 1px solid $quaternary-fg-color;
+    transition: border-bottom 0.25s;
+}
+
+.mx_DialPadContextMenu_cancel {
+    float: right;
+    mask: url('$(res)/img/feather-customised/cancel.svg');
+    mask-repeat: no-repeat;
+    mask-position: center;
+    mask-size: cover;
+    width: 14px;
+    height: 14px;
+    background-color: $dialog-close-fg-color;
+    cursor: pointer;
+}
+
+.mx_DialPadContextMenu_header:focus-within {
+    border-bottom: 1px solid $accent-color;
 }
 
 .mx_DialPadContextMenu_title {
@@ -30,7 +60,6 @@ limitations under the License.
     height: 1.5em;
     font-size: 18px;
     font-weight: 600;
-    max-width: 150px;
     border: none;
     margin: 0px;
 }
@@ -38,7 +67,7 @@ limitations under the License.
     font-size: 18px;
     font-weight: 600;
     overflow: hidden;
-    max-width: 150px;
+    max-width: 185px;
     text-align: left;
     direction: rtl;
     padding: 8px 0px;
@@ -48,13 +77,3 @@ limitations under the License.
 .mx_DialPadContextMenu_dialPad {
     margin: 16px;
 }
-
-.mx_DialPadContextMenu_horizSep {
-    position: relative;
-    &::before {
-        content: '';
-        position: absolute;
-        width: 100%;
-        border-bottom: 1px solid $input-darker-bg-color;
-    }
-}
diff --git a/res/css/views/voip/_DialPadModal.scss b/res/css/views/voip/_DialPadModal.scss
index f9d7673a38..b8042f77ae 100644
--- a/res/css/views/voip/_DialPadModal.scss
+++ b/res/css/views/voip/_DialPadModal.scss
@@ -19,14 +19,23 @@ limitations under the License.
 }
 
 .mx_DialPadModal {
-    width: 192px;
-    height: 368px;
+    width: 292px;
+    height: 370px;
+    padding: 16px 0px 0px 0px;
 }
 
 .mx_DialPadModal_header {
-    margin-top: 12px;
-    margin-left: 12px;
-    margin-right: 12px;
+    margin-top: 32px;
+    margin-left: 40px;
+    margin-right: 40px;
+
+    /* a separator between the input line and the dial buttons */
+    border-bottom: 1px solid $quaternary-fg-color;
+    transition: border-bottom 0.25s;
+}
+
+.mx_DialPadModal_header:focus-within {
+    border-bottom: 1px solid $accent-color;
 }
 
 .mx_DialPadModal_title {
@@ -45,11 +54,18 @@ limitations under the License.
     height: 14px;
     background-color: $dialog-close-fg-color;
     cursor: pointer;
+    margin-right: 16px;
 }
 
 .mx_DialPadModal_field {
     border: none;
     margin: 0px;
+    height: 30px;
+}
+
+.mx_DialPadModal_field .mx_Field_postfix {
+    /* Remove border separator between postfix and field content */
+    border-left: none;
 }
 
 .mx_DialPadModal_field input {
@@ -62,13 +78,3 @@ limitations under the License.
     margin-right: 16px;
     margin-top: 16px;
 }
-
-.mx_DialPadModal_horizSep {
-    position: relative;
-    &::before {
-        content: '';
-        position: absolute;
-        width: 100%;
-        border-bottom: 1px solid $input-darker-bg-color;
-    }
-}
diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss
index 7d85ac264e..4a3fbdf597 100644
--- a/res/css/views/voip/_VideoFeed.scss
+++ b/res/css/views/voip/_VideoFeed.scss
@@ -15,8 +15,6 @@ limitations under the License.
 */
 
 .mx_VideoFeed_voice {
-    // We don't want to collide with the call controls that have 52px of height
-    padding-bottom: 52px;
     background-color: $inverted-bg-color;
 }
 
diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2
index a52e5a3800..128aac8139 100644
Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 differ
diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2
index 660a93193d..a95e89c094 100644
Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 differ
diff --git a/res/img/element-icons/speaker.svg b/res/img/element-icons/speaker.svg
new file mode 100644
index 0000000000..fd811d2cda
--- /dev/null
+++ b/res/img/element-icons/speaker.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/res/img/element-icons/warning.svg b/res/img/element-icons/warning.svg
new file mode 100644
index 0000000000..eef5193140
--- /dev/null
+++ b/res/img/element-icons/warning.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/subtract.svg b/res/img/subtract.svg
new file mode 100644
index 0000000000..55e25831ef
--- /dev/null
+++ b/res/img/subtract.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/tab-dialpad.svg b/res/img/voip/tab-dialpad.svg
new file mode 100644
index 0000000000..b7add0addb
--- /dev/null
+++ b/res/img/voip/tab-dialpad.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/tab-userdirectory.svg b/res/img/voip/tab-userdirectory.svg
new file mode 100644
index 0000000000..792ded7be4
--- /dev/null
+++ b/res/img/voip/tab-userdirectory.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss
index 8b5fde3bd1..6a17f3ac16 100644
--- a/res/themes/dark/css/_dark.scss
+++ b/res/themes/dark/css/_dark.scss
@@ -118,9 +118,7 @@ $voipcall-plinth-color: #394049;
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
-$dialpad-button-bg-color: #6F7882;
-;
-
+$dialpad-button-bg-color: #394049;
 
 $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
 $roomlist-filter-active-bg-color: $bg-color;
@@ -215,8 +213,6 @@ $message-body-panel-icon-fg-color: #21262C; // "Separator"
 $message-body-panel-icon-bg-color: $tertiary-fg-color;
 
 $voice-record-stop-border-color: $quaternary-fg-color;
-$voice-record-waveform-bg-color: $message-body-panel-bg-color;
-$voice-record-waveform-fg-color: $message-body-panel-fg-color;
 $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
 $voice-record-icon-color: $quaternary-fg-color;
 $voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
@@ -231,6 +227,13 @@ $groupFilterPanel-background-blur-amount: 30px;
 
 $composer-shadow-color: rgba(0, 0, 0, 0.28);
 
+// Bubble tiles
+$eventbubble-self-bg: #143A34;
+$eventbubble-others-bg: #394049;
+$eventbubble-bg-hover: #433C23;
+$eventbubble-avatar-outline: $bg-color;
+$eventbubble-reply-color: #C1C6CD;
+
 // ***** Mixins! *****
 
 @define-mixin mx_DialogButton {
@@ -276,24 +279,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;
@@ -303,18 +289,17 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
             background-color: #080808;
         }
     }
-
-    blockquote {
-        color: #919191;
-    }
 }
 
-// diff highlight colors
-// intentionally swapped to avoid inversion
+// highlight.js overrides
+.hljs-tag {
+    color: inherit; // Without this they'd be weirdly blue which doesn't match the theme
+}
+
 .hljs-addition {
-    background: #fdd;
+    background: #1a4b59;
 }
 
 .hljs-deletion {
-    background: #dfd;
+    background: #53232a;
 }
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 eb6dc40599..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;
 
@@ -115,7 +118,7 @@ $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;
@@ -209,8 +212,6 @@ $message-body-panel-icon-bg-color: $secondary-fg-color;
 
 // See non-legacy dark for variable information
 $voice-record-stop-border-color: #6F7882;
-$voice-record-waveform-bg-color: $message-body-panel-bg-color;
-$voice-record-waveform-fg-color: $message-body-panel-fg-color;
 $voice-record-waveform-incomplete-fg-color: #6F7882;
 $voice-record-icon-color: #6F7882;
 $voice-playback-button-bg-color: $tertiary-fg-color;
@@ -248,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;
 }
@@ -266,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;
@@ -289,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 a6b180bab4..f349a804a8 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;
 
@@ -334,8 +337,6 @@ $message-body-panel-icon-bg-color: $primary-bg-color;
 $voice-record-stop-symbol-color: #ff4b55;
 $voice-record-live-circle-color: #ff4b55;
 $voice-record-stop-border-color: #E3E8F0;
-$voice-record-waveform-bg-color: $message-body-panel-bg-color;
-$voice-record-waveform-fg-color: $message-body-panel-fg-color;
 $voice-record-waveform-incomplete-fg-color: #C1C6CD;
 $voice-record-icon-color: $tertiary-fg-color;
 $voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
@@ -346,6 +347,13 @@ $appearance-tab-border-color: $input-darker-bg-color;
 
 $composer-shadow-color: tranparent;
 
+// Bubble tiles
+$eventbubble-self-bg: #F8FDFC;
+$eventbubble-others-bg: #F7F8F9;
+$eventbubble-bg-hover: rgb(242, 242, 242);
+$eventbubble-avatar-outline: #fff;
+$eventbubble-reply-color: #C1C6CD;
+
 // ***** Mixins! *****
 
 @define-mixin mx_DialogButton {
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 d8dab9c9c4..ef5f4d8c86 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -335,8 +335,6 @@ $voice-record-stop-symbol-color: #ff4b55;
 $voice-record-live-circle-color: #ff4b55;
 
 $voice-record-stop-border-color: #E3E8F0; // "Separator"
-$voice-record-waveform-bg-color: $message-body-panel-bg-color;
-$voice-record-waveform-fg-color: $message-body-panel-fg-color;
 $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
 $voice-record-icon-color: $tertiary-fg-color;
 $voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
@@ -351,6 +349,13 @@ $groupFilterPanel-background-blur-amount: 20px;
 
 $composer-shadow-color: rgba(0, 0, 0, 0.04);
 
+// Bubble tiles
+$eventbubble-self-bg: #F8FDFC;
+$eventbubble-others-bg: #F7F8F9;
+$eventbubble-bg-hover: #FEFCF5;
+$eventbubble-avatar-outline: $primary-bg-color;
+$eventbubble-reply-color: #C1C6CD;
+
 // ***** Mixins! *****
 
 @define-mixin mx_DialogButton {
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/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/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 1fb9ba4303..36ef7a9ace 100644
--- a/src/@types/common.ts
+++ b/src/@types/common.ts
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { JSXElementConstructor } from "react";
+import React, { JSXElementConstructor } from "react";
 
 // Based on https://stackoverflow.com/a/53229857/3532235
 export type Without = {[P in Exclude]?: never};
@@ -22,3 +22,4 @@ export type XOR = (T | U) extends object ? (Without & U) | (Without<
 export type Writeable = { -readonly [P in keyof T]: T[P] };
 
 export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor;
+export type ReactAnyComponent = React.Component | React.ExoticComponent;
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index f75c17aaf4..9d6bc2c6fb 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -15,7 +15,9 @@ 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";
@@ -46,10 +48,12 @@ import { VoiceRecordingStore } from "../stores/VoiceRecordingStore";
 import PerformanceMonitor from "../performance";
 import UIStore from "../stores/UIStore";
 import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
+import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
+
+/* eslint-disable @typescript-eslint/naming-convention */
 
 declare global {
     interface Window {
-        Modernizr: ModernizrStatic;
         matrixChat: ReturnType;
         mxMatrixClientPeg: IMatrixClientPeg;
         Olm: {
@@ -87,6 +91,8 @@ declare global {
         mxPerformanceEntryNames: any;
         mxUIStore: UIStore;
         mxSetupEncryptionStore?: SetupEncryptionStore;
+        mxRoomScrollStateStore?: RoomScrollStateStore;
+        mxOnRecaptchaLoaded?: () => void;
     }
 
     interface Document {
@@ -111,7 +117,7 @@ declare global {
     }
 
     interface StorageEstimate {
-        usageDetails?: {[key: string]: number};
+        usageDetails?: { [key: string]: number };
     }
 
     interface HTMLAudioElement {
@@ -182,4 +188,21 @@ declare global {
             parameterDescriptors?: AudioParamDescriptor[];
         }
     );
+
+    // eslint-disable-next-line no-var
+    var grecaptcha:
+        | undefined
+        | {
+              reset: (id: string) => void;
+              render: (
+                  divId: string,
+                  options: {
+                      sitekey: string;
+                      callback: (response: string) => void;
+                  },
+              ) => string;
+              isReady: () => boolean;
+          };
 }
+
+/* eslint-enable @typescript-eslint/naming-convention */
diff --git a/res/css/views/avatars/_PulsedAvatar.scss b/src/@types/worker-loader.d.ts
similarity index 62%
rename from res/css/views/avatars/_PulsedAvatar.scss
rename to src/@types/worker-loader.d.ts
index ce9e3382ab..a8f5d8e9a4 100644
--- a/res/css/views/avatars/_PulsedAvatar.scss
+++ b/src/@types/worker-loader.d.ts
@@ -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,10 @@ 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);
-        }
+declare module "*.worker.ts" {
+    class WebpackWorker extends Worker {
+        constructor();
     }
 
-    img {
-        animation: shadow-pulse 1s infinite;
-    }
+    export default WebpackWorker;
 }
diff --git a/src/ActiveRoomObserver.ts b/src/ActiveRoomObserver.ts
index 1126dc9496..0be49a24ea 100644
--- a/src/ActiveRoomObserver.ts
+++ b/src/ActiveRoomObserver.ts
@@ -15,6 +15,7 @@ limitations under the License.
 */
 
 import RoomViewStore from './stores/RoomViewStore';
+import { EventSubscription } from 'fbemitter';
 
 type Listener = (isActive: boolean) => void;
 
@@ -30,7 +31,7 @@ type Listener = (isActive: boolean) => void;
 export class ActiveRoomObserver {
     private listeners: {[key: string]: Listener[]} = {};
     private _activeRoomId = RoomViewStore.getRoomId();
-    private readonly roomStoreToken: string;
+    private readonly roomStoreToken: EventSubscription;
 
     constructor() {
         // TODO: We could self-destruct when the last listener goes away, or at least stop listening.
diff --git a/src/AddThreepid.js b/src/AddThreepid.js
index eb822c6d75..ab291128a7 100644
--- a/src/AddThreepid.js
+++ b/src/AddThreepid.js
@@ -248,7 +248,7 @@ export default class AddThreepid {
 
     /**
      * Takes a phone number verification code as entered by the user and validates
-     * it with the ID server, then if successful, adds the phone number.
+     * it with the identity server, then if successful, adds the phone number.
      * @param {string} msisdnToken phone number verification code as entered by the user
      * @return {Promise} Resolves if the phone number was added. Rejects with an object
      * with a "message" property which contains a human-readable message detailing why
diff --git a/src/Analytics.tsx b/src/Analytics.tsx
index 8c82639b5f..fc4664039f 100644
--- a/src/Analytics.tsx
+++ b/src/Analytics.tsx
@@ -270,7 +270,7 @@ export class Analytics {
         localStorage.removeItem(LAST_VISIT_TS_KEY);
     }
 
-    private async _track(data: IData) {
+    private async track(data: IData) {
         if (this.disabled) return;
 
         const now = new Date();
@@ -304,7 +304,7 @@ export class Analytics {
     }
 
     public ping() {
-        this._track({
+        this.track({
             ping: "1",
         });
         localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts
@@ -324,14 +324,14 @@ export class Analytics {
             // But continue anyway because we still want to track the change
         }
 
-        this._track({
+        this.track({
             gt_ms: String(generationTimeMs),
         });
     }
 
     public trackEvent(category: string, action: string, name?: string, value?: string) {
         if (this.disabled) return;
-        this._track({
+        this.track({
             e_c: category,
             e_a: action,
             e_n: name,
@@ -390,21 +390,22 @@ 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'),
             description: 
-
{_t('The information being sent to us to help make %(brand)s better includes:', { +
{ _t('The information being sent to us to help make %(brand)s better includes:', { brand: SdkConfig.get().brand, - })}
+ }) }
{ rows.map((row) => - + ) } { row[1] !== undefined && } ) } { otherVariables.map((item, index) => diff --git a/src/AsyncWrapper.tsx b/src/AsyncWrapper.tsx index 3bbef71093..ef8924add8 100644 --- a/src/AsyncWrapper.tsx +++ b/src/AsyncWrapper.tsx @@ -77,6 +77,7 @@ 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 diff --git a/src/Avatar.ts b/src/Avatar.ts index 4c4bd1c265..c0ecb19eaf 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -18,10 +18,11 @@ 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 { split } from "lodash"; import DMRoomMap from './utils/DMRoomMap'; import { mediaFromMxc } from "./customisations/Media"; -import SettingsStore from "./settings/SettingsStore"; +import SpaceStore from "./stores/SpaceStore"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already export function avatarUrlForMember( @@ -122,27 +123,13 @@ export function getInitialLetter(name: string): string { return undefined; } - let idx = 0; const initial = name[0]; if ((initial === '@' || initial === '#' || initial === '+') && name[1]) { - idx++; + name = name.substring(1); } - // string.codePointAt(0) would do this, but that isn't supported by - // some browsers (notably PhantomJS). - let chars = 1; - const first = name.charCodeAt(idx); - - // check if it’s the start of a surrogate pair - if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) { - const second = name.charCodeAt(idx+1); - if (second >= 0xDC00 && second <= 0xDFFF) { - chars++; - } - } - - const firstChar = name.substring(idx, idx+chars); - return firstChar.toUpperCase(); + // rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis + return split(name, "", 1)[0].toUpperCase(); } export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) { @@ -153,7 +140,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi } // space rooms cannot be DMs so skip the rest - if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null; + if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null; let otherMember = null; const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); diff --git a/src/BlurhashEncoder.ts b/src/BlurhashEncoder.ts new file mode 100644 index 0000000000..2aee370fe9 --- /dev/null +++ b/src/BlurhashEncoder.ts @@ -0,0 +1,60 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { defer, IDeferred } from "matrix-js-sdk/src/utils"; + +// @ts-ignore - `.ts` is needed here to make TS happy +import BlurhashWorker from "./workers/blurhash.worker.ts"; + +interface IBlurhashWorkerResponse { + seq: number; + blurhash: string; +} + +export class BlurhashEncoder { + private static internalInstance = new BlurhashEncoder(); + + public static get instance(): BlurhashEncoder { + return BlurhashEncoder.internalInstance; + } + + private readonly worker: Worker; + private seq = 0; + private pendingDeferredMap = new Map>(); + + constructor() { + this.worker = new BlurhashWorker(); + this.worker.onmessage = this.onMessage; + } + + private onMessage = (ev: MessageEvent) => { + const { seq, blurhash } = ev.data; + const deferred = this.pendingDeferredMap.get(seq); + if (deferred) { + this.pendingDeferredMap.delete(seq); + deferred.resolve(blurhash); + } + }; + + public getBlurhash(imageData: ImageData): Promise { + const seq = this.seq++; + const deferred = defer(); + this.pendingDeferredMap.set(seq, deferred); + this.worker.postMessage({ seq, imageData }); + return deferred.promise; + } +} + diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index cb54db3f8a..e7ba1aa9fb 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -99,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'; -export enum AudioID { +enum AudioID { Ring = 'ringAudio', Ringback = 'ringbackAudio', CallEnd = 'callendAudio', @@ -124,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 @@ -142,6 +142,7 @@ export enum PlaceCallType { export enum CallHandlerEvent { CallsChanged = "calls_changed", CallChangeRoom = "call_change_room", + SilencedCallsChanged = "silenced_calls_changed", } export default class CallHandler extends EventEmitter { @@ -154,7 +155,7 @@ export default class CallHandler extends EventEmitter { private supportsPstnProtocol = null; private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native - private pstnSupportCheckTimer: NodeJS.Timeout; // number actually because we're in the browser + private pstnSupportCheckTimer: number; // For rooms we've been invited to, true if they're from virtual user, false if we've checked and they aren't. private invitedRoomsAreVirtual = new Map(); private invitedRoomCheckInProgress = false; @@ -164,6 +165,8 @@ export default class CallHandler extends EventEmitter { // do the async lookup when we get new information and then store these mappings here private assertedIdentityNativeUsers = new Map(); + private silencedCalls = new Set(); // callIds + static sharedInstance() { if (!window.mxCallHandler) { window.mxCallHandler = new CallHandler(); @@ -224,6 +227,33 @@ export default class CallHandler extends EventEmitter { } } + public silenceCall(callId: string) { + this.silencedCalls.add(callId); + this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls); + + // Don't pause audio if we have calls which are still ringing + if (this.areAnyCallsUnsilenced()) return; + this.pause(AudioID.Ring); + } + + public unSilenceCall(callId: string) { + this.silencedCalls.delete(callId); + this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls); + this.play(AudioID.Ring); + } + + public isCallSilenced(callId: string): boolean { + return this.silencedCalls.has(callId); + } + + /** + * Returns true if there is at least one unsilenced call + * @returns {boolean} + */ + private areAnyCallsUnsilenced(): boolean { + return this.calls.size > this.silencedCalls.size; + } + private async checkProtocols(maxTries) { try { const protocols = await MatrixClientPeg.get().getThirdpartyProtocols(); @@ -301,6 +331,13 @@ export default class CallHandler extends EventEmitter { }, true); }; + public getCallById(callId: string): MatrixCall { + for (const call of this.calls.values()) { + if (call.callId === callId) return call; + } + return null; + } + getCallForRoom(roomId: string): MatrixCall { return this.calls.get(roomId) || null; } @@ -394,7 +431,7 @@ export default class CallHandler extends EventEmitter { } private setCallListeners(call: MatrixCall) { - let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); + let mappedRoomId = this.roomIdForCall(call); call.on(CallEvent.Error, (err: CallError) => { if (!this.matchesCallForThisRoom(call)) return; @@ -441,6 +478,10 @@ export default class CallHandler extends EventEmitter { break; } + if (newState !== CallState.Ringing) { + this.silencedCalls.delete(call.callId); + } + switch (newState) { case CallState.Ringing: this.play(AudioID.Ring); @@ -615,23 +656,23 @@ export default class CallHandler extends EventEmitter { private showICEFallbackPrompt() { const cli = MatrixClientPeg.get(); - const code = sub => {sub}; + const code = sub => { sub }; Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, { title: _t("Call failed due to misconfigured server"), description:
-

{_t( +

{ _t( "Please ask the administrator of your homeserver " + "(%(homeserverDomain)s) to configure a TURN server in " + "order for calls to work reliably.", { homeserverDomain: cli.getDomain() }, { code }, - )}

-

{_t( + ) }

+

{ _t( "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.", null, { code }, - )}

+ ) }

, button: _t('Try using turn.matrix.org'), cancelButton: _t('OK'), @@ -649,19 +690,19 @@ export default class CallHandler extends EventEmitter { if (call.type === CallType.Voice) { title = _t("Unable to access microphone"); description =
- {_t( + { _t( "Call failed because microphone could not be accessed. " + "Check that a microphone is plugged in and set up correctly.", - )} + ) }
; } else if (call.type === CallType.Video) { title = _t("Unable to access webcam / microphone"); description =
- {_t("Call failed because webcam or microphone could not be accessed. Check that:")} + { _t("Call failed because webcam or microphone could not be accessed. Check that:") }
    -
  • {_t("A microphone and webcam are plugged in and set up correctly")}
  • -
  • {_t("Permission is granted to use the webcam")}
  • -
  • {_t("No other application is using the webcam")}
  • +
  • { _t("A microphone and webcam are plugged in and set up correctly") }
  • +
  • { _t("Permission is granted to use the webcam") }
  • +
  • { _t("No other application is using the webcam") }
; } @@ -871,6 +912,12 @@ export default class CallHandler extends EventEmitter { case Action.DialNumber: this.dialNumber(payload.number); break; + case Action.TransferCallToMatrixID: + this.startTransferToMatrixID(payload.call, payload.destination, payload.consultFirst); + break; + case Action.TransferCallToPhoneNumber: + this.startTransferToPhoneNumber(payload.call, payload.destination, payload.consultFirst); + break; } }; @@ -905,6 +952,48 @@ export default class CallHandler extends EventEmitter { }); } + private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) { + const results = await this.pstnLookup(destination); + if (!results || results.length === 0 || !results[0].userid) { + Modal.createTrackedDialog('', '', ErrorDialog, { + title: _t("Unable to transfer call"), + description: _t("There was an error looking up the phone number"), + }); + return; + } + + await this.startTransferToMatrixID(call, results[0].userid, consultFirst); + } + + private async startTransferToMatrixID(call: MatrixCall, destination: string, consultFirst: boolean) { + if (consultFirst) { + const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), destination); + + dis.dispatch({ + action: 'place_call', + type: call.type, + room_id: dmRoomId, + transferee: call, + }); + dis.dispatch({ + action: 'view_room', + room_id: dmRoomId, + should_peek: false, + joining: false, + }); + } else { + try { + await call.transfer(destination); + } catch (e) { + console.log("Failed to transfer call", e); + Modal.createTrackedDialog('Failed to transfer call', '', ErrorDialog, { + title: _t('Transfer Failed'), + description: _t('Failed to transfer call'), + }); + } + } + } + setActiveCallRoomId(activeCallRoomId: string) { logger.info("Setting call in room " + activeCallRoomId + " active"); diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index ef0a89a690..c5bcb226ff 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -17,9 +17,9 @@ limitations under the License. */ import React from "react"; -import dis from './dispatcher/dispatcher'; -import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClient } from "matrix-js-sdk/src/client"; + +import dis from './dispatcher/dispatcher'; import * as sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; @@ -27,7 +27,6 @@ import RoomViewStore from './stores/RoomViewStore'; import encrypt from "browser-encrypt-attachment"; import extractPngChunks from "png-chunks-extract"; import Spinner from "./components/views/elements/Spinner"; - import { Action } from "./dispatcher/actions"; import CountlyAnalytics from "./CountlyAnalytics"; import { @@ -38,7 +37,8 @@ import { UploadStartedPayload, } from "./dispatcher/payloads/UploadPayload"; import { IUpload } from "./models/IUpload"; -import { IImageInfo } from "matrix-js-sdk/src/@types/partials"; +import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials"; +import { BlurhashEncoder } from "./BlurhashEncoder"; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; @@ -47,6 +47,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; @@ -77,14 +79,11 @@ interface IThumbnail { }; w: number; h: number; + [BLURHASH_FIELD]: string; }; thumbnail: Blob; } -interface IAbortablePromise extends Promise { - abort(): void; -} - /** * Create a thumbnail for a image DOM element. * The image will be smaller than MAX_WIDTH and MAX_HEIGHT. @@ -103,44 +102,62 @@ interface IAbortablePromise extends Promise { * @return {Promise} A promise that resolves with an object with an info key * and a thumbnail key. */ -function createThumbnail( +async function createThumbnail( element: ThumbnailableElement, inputWidth: number, inputHeight: number, mimeType: string, ): Promise { - return new Promise((resolve) => { - let targetWidth = inputWidth; - let targetHeight = inputHeight; - if (targetHeight > MAX_HEIGHT) { - targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); - targetHeight = MAX_HEIGHT; - } - if (targetWidth > MAX_WIDTH) { - targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); - targetWidth = MAX_WIDTH; - } + let targetWidth = inputWidth; + let targetHeight = inputHeight; + if (targetHeight > MAX_HEIGHT) { + targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); + targetHeight = MAX_HEIGHT; + } + if (targetWidth > MAX_WIDTH) { + targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); + targetWidth = MAX_WIDTH; + } - const canvas = document.createElement("canvas"); + let canvas: HTMLCanvasElement | OffscreenCanvas; + if (window.OffscreenCanvas) { + canvas = new window.OffscreenCanvas(targetWidth, targetHeight); + } else { + canvas = document.createElement("canvas"); canvas.width = targetWidth; canvas.height = targetHeight; - canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); - canvas.toBlob(function(thumbnail) { - resolve({ - info: { - thumbnail_info: { - w: targetWidth, - h: targetHeight, - mimetype: thumbnail.type, - size: thumbnail.size, - }, - w: inputWidth, - h: inputHeight, - }, - thumbnail: thumbnail, - }); - }, mimeType); - }); + } + + const context = canvas.getContext("2d"); + context.drawImage(element, 0, 0, targetWidth, targetHeight); + + let thumbnailPromise: Promise; + + if (window.OffscreenCanvas) { + thumbnailPromise = (canvas as OffscreenCanvas).convertToBlob({ type: mimeType }); + } else { + thumbnailPromise = new Promise(resolve => (canvas as HTMLCanvasElement).toBlob(resolve, mimeType)); + } + + const imageData = context.getImageData(0, 0, targetWidth, targetHeight); + // thumbnailPromise and blurhash promise are being awaited concurrently + const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData); + const thumbnail = await thumbnailPromise; + + return { + info: { + thumbnail_info: { + w: targetWidth, + h: targetHeight, + mimetype: thumbnail.type, + size: thumbnail.size, + }, + w: inputWidth, + h: inputHeight, + [BLURHASH_FIELD]: blurhash, + }, + thumbnail, + }; } /** @@ -220,7 +237,8 @@ function infoForImageFile(matrixClient, roomId, imageFile) { } /** - * Load a file into a newly created video element. + * Load a file into a newly created video element and pull some strings + * in an attempt to guarantee the first frame will be showing. * * @param {File} videoFile The file to load in an video element. * @return {Promise} A promise that resolves with the video image element. @@ -229,20 +247,25 @@ function loadVideoElement(videoFile): Promise { return new Promise((resolve, reject) => { // Load the file into an html element const video = document.createElement("video"); + video.preload = "metadata"; + video.playsInline = true; + video.muted = true; const reader = new FileReader(); reader.onload = function(ev) { - video.src = ev.target.result as string; - - // Once ready, returns its size // Wait until we have enough data to thumbnail the first frame. - video.onloadeddata = function() { + video.onloadeddata = async function() { resolve(video); + video.pause(); }; video.onerror = function(e) { reject(e); }; + + video.src = ev.target.result as string; + video.load(); + video.play(); }; reader.onerror = function(e) { reject(e); @@ -312,7 +335,7 @@ export function uploadFile( roomId: string, file: File | Blob, progressHandler?: any, // TODO: Types -): Promise<{url?: string, file?: any}> { // TODO: Types +): IAbortablePromise<{url?: string, file?: any}> { // TODO: Types let canceled = false; if (matrixClient.isRoomEncrypted(roomId)) { // If the room is encrypted then encrypt the file before uploading it. @@ -344,10 +367,10 @@ export function uploadFile( encryptInfo.mimetype = file.type; } return { "file": encryptInfo }; - }); - (prom as IAbortablePromise).abort = () => { + }) as IAbortablePromise<{ file: any }>; + prom.abort = () => { canceled = true; - if (uploadPromise) MatrixClientPeg.get().cancelUpload(uploadPromise); + if (uploadPromise) matrixClient.cancelUpload(uploadPromise); }; return prom; } else { @@ -357,11 +380,11 @@ export function uploadFile( const promise1 = basePromise.then(function(url) { if (canceled) throw new UploadCanceledError(); // If the attachment isn't encrypted then include the URL directly. - return { "url": url }; - }); - (promise1 as any).abort = () => { + return { url }; + }) as IAbortablePromise<{ url: string }>; + promise1.abort = () => { canceled = true; - MatrixClientPeg.get().cancelUpload(basePromise); + matrixClient.cancelUpload(basePromise); }; return promise1; } @@ -373,7 +396,7 @@ export default class ContentMessages { sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) { const startTime = CountlyAnalytics.getTimestamp(); - const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { + const prom = matrixClient.sendStickerMessage(roomId, url, info, text).catch((e) => { console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); throw e; }); @@ -397,14 +420,15 @@ export default class ContentMessages { 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, { title: _t('Replying With Files'), description: ( -
{_t( +
{ _t( 'At this time it is not possible to reply with a file. ' + 'Would you like to upload this file without replying?', - )}
+ ) }
), hasCancelButton: true, button: _t("Continue"), @@ -415,7 +439,7 @@ export default class ContentMessages { if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); - await this.ensureMediaConfigFetched(); + await this.ensureMediaConfigFetched(matrixClient); modal.close(); } @@ -431,6 +455,7 @@ 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, { badFiles: tooBigFiles, @@ -441,7 +466,6 @@ 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 @@ -449,6 +473,8 @@ export default class ContentMessages { for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; if (!uploadAll) { + // 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, @@ -470,7 +496,7 @@ export default class ContentMessages { return this.inprogress.filter(u => !u.canceled); } - cancelUpload(promise: Promise) { + cancelUpload(promise: Promise, matrixClient: MatrixClient) { let upload: IUpload; for (let i = 0; i < this.inprogress.length; ++i) { if (this.inprogress[i].promise === promise) { @@ -480,7 +506,7 @@ export default class ContentMessages { } if (upload) { upload.canceled = true; - MatrixClientPeg.get().cancelUpload(upload.promise); + matrixClient.cancelUpload(upload.promise); dis.dispatch({ action: Action.UploadCanceled, upload }); } } @@ -527,10 +553,10 @@ export default class ContentMessages { content.msgtype = 'm.file'; resolve(); } - }); + }) as IAbortablePromise; // create temporary abort handler for before the actual upload gets passed off to js-sdk - (prom as IAbortablePromise).abort = () => { + prom.abort = () => { upload.canceled = true; }; @@ -545,7 +571,7 @@ export default class ContentMessages { 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; @@ -559,9 +585,7 @@ export default class ContentMessages { // XXX: upload.promise must be the promise that // is returned by uploadFile as it has an abort() // method hacked onto it. - upload.promise = uploadFile( - matrixClient, roomId, file, onProgress, - ); + upload.promise = uploadFile(matrixClient, roomId, file, onProgress); return upload.promise.then(function(result) { content.file = result.file; content.url = result.url; @@ -584,6 +608,7 @@ export default class ContentMessages { { 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'), @@ -621,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 39dcac4048..72b0462bcd 100644 --- a/src/CountlyAnalytics.ts +++ b/src/CountlyAnalytics.ts @@ -15,12 +15,13 @@ limitations under the License. */ 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 PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; import { MatrixClientPeg } from "./MatrixClientPeg"; -import { sleep } from "./utils/promise"; import RoomViewStore from "./stores/RoomViewStore"; import { Action } from "./dispatcher/actions"; @@ -255,7 +256,7 @@ interface ICreateRoomEvent extends IEvent { num_users: number; is_encrypted: boolean; is_public: boolean; - } + }; } interface IJoinRoomEvent extends IEvent { @@ -363,8 +364,8 @@ export default class CountlyAnalytics { private initTime = CountlyAnalytics.getTimestamp(); private firstPage = true; - private heartbeatIntervalId: NodeJS.Timeout; - private activityIntervalId: NodeJS.Timeout; + private heartbeatIntervalId: number; + private activityIntervalId: number; private trackTime = true; private lastBeat: number; private storedDuration = 0; @@ -868,7 +869,7 @@ export default class CountlyAnalytics { roomId: string, isEdit: boolean, isReply: boolean, - content: {format?: string, msgtype: string}, + content: IContent, ) { if (this.disabled) return; const cli = MatrixClientPeg.get(); diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts index d40574a6db..df306a54f5 100644 --- a/src/DecryptionFailureTracker.ts +++ b/src/DecryptionFailureTracker.ts @@ -46,8 +46,8 @@ export class DecryptionFailureTracker { }; // Set to an interval ID when `start` is called - public checkInterval: NodeJS.Timeout = null; - public trackInterval: NodeJS.Timeout = null; + public checkInterval: number = null; + public trackInterval: number = null; // Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`. static TRACK_INTERVAL_MS = 60000; diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index d70585e5ec..51c624e3c3 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -33,6 +33,7 @@ import { isSecretStorageBeingAccessed, accessSecretStorage } from "./SecurityMan import { isSecureBackupRequired } from './utils/WellKnownUtils'; import { isLoggedIn } from './components/structures/MatrixChat'; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { ActionPayload } from "./dispatcher/payloads"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -58,28 +59,28 @@ export default class DeviceListener { } start() { - MatrixClientPeg.get().on('crypto.willUpdateDevices', this._onWillUpdateDevices); - MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated); - MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged); - MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged); - MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged); - MatrixClientPeg.get().on('accountData', this._onAccountData); - MatrixClientPeg.get().on('sync', this._onSync); - MatrixClientPeg.get().on('RoomState.events', this._onRoomStateEvents); - this.dispatcherRef = dis.register(this._onAction); - this._recheck(); + MatrixClientPeg.get().on('crypto.willUpdateDevices', this.onWillUpdateDevices); + MatrixClientPeg.get().on('crypto.devicesUpdated', this.onDevicesUpdated); + MatrixClientPeg.get().on('deviceVerificationChanged', this.onDeviceVerificationChanged); + MatrixClientPeg.get().on('userTrustStatusChanged', this.onUserTrustStatusChanged); + MatrixClientPeg.get().on('crossSigning.keysChanged', this.onCrossSingingKeysChanged); + MatrixClientPeg.get().on('accountData', this.onAccountData); + MatrixClientPeg.get().on('sync', this.onSync); + MatrixClientPeg.get().on('RoomState.events', this.onRoomStateEvents); + this.dispatcherRef = dis.register(this.onAction); + this.recheck(); } stop() { if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this._onWillUpdateDevices); - MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated); - MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged); - MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged); - MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged); - MatrixClientPeg.get().removeListener('accountData', this._onAccountData); - MatrixClientPeg.get().removeListener('sync', this._onSync); - MatrixClientPeg.get().removeListener('RoomState.events', this._onRoomStateEvents); + MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this.onWillUpdateDevices); + MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this.onDevicesUpdated); + MatrixClientPeg.get().removeListener('deviceVerificationChanged', this.onDeviceVerificationChanged); + MatrixClientPeg.get().removeListener('userTrustStatusChanged', this.onUserTrustStatusChanged); + MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this.onCrossSingingKeysChanged); + MatrixClientPeg.get().removeListener('accountData', this.onAccountData); + MatrixClientPeg.get().removeListener('sync', this.onSync); + MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents); } if (this.dispatcherRef) { dis.unregister(this.dispatcherRef); @@ -103,15 +104,15 @@ export default class DeviceListener { this.dismissed.add(d); } - this._recheck(); + this.recheck(); } dismissEncryptionSetup() { this.dismissedThisDeviceToast = true; - this._recheck(); + this.recheck(); } - _ensureDeviceIdsAtStartPopulated() { + private ensureDeviceIdsAtStartPopulated() { if (this.ourDeviceIdsAtStart === null) { const cli = MatrixClientPeg.get(); this.ourDeviceIdsAtStart = new Set( @@ -120,39 +121,39 @@ export default class DeviceListener { } } - _onWillUpdateDevices = async (users: string[], initialFetch?: boolean) => { + private onWillUpdateDevices = async (users: string[], initialFetch?: boolean) => { // If we didn't know about *any* devices before (ie. it's fresh login), // then they are all pre-existing devices, so ignore this and set the // devicesAtStart list to the devices that we see after the fetch. if (initialFetch) return; const myUserId = MatrixClientPeg.get().getUserId(); - if (users.includes(myUserId)) this._ensureDeviceIdsAtStartPopulated(); + if (users.includes(myUserId)) this.ensureDeviceIdsAtStartPopulated(); // No need to do a recheck here: we just need to get a snapshot of our devices // before we download any new ones. }; - _onDevicesUpdated = (users: string[]) => { + private onDevicesUpdated = (users: string[]) => { if (!users.includes(MatrixClientPeg.get().getUserId())) return; - this._recheck(); + this.recheck(); }; - _onDeviceVerificationChanged = (userId: string) => { + private onDeviceVerificationChanged = (userId: string) => { if (userId !== MatrixClientPeg.get().getUserId()) return; - this._recheck(); + this.recheck(); }; - _onUserTrustStatusChanged = (userId: string) => { + private onUserTrustStatusChanged = (userId: string) => { if (userId !== MatrixClientPeg.get().getUserId()) return; - this._recheck(); + this.recheck(); }; - _onCrossSingingKeysChanged = () => { - this._recheck(); + private onCrossSingingKeysChanged = () => { + this.recheck(); }; - _onAccountData = (ev) => { + private onAccountData = (ev: MatrixEvent) => { // User may have: // * migrated SSSS to symmetric // * uploaded keys to secret storage @@ -160,34 +161,35 @@ 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(); + this.recheck(); } }; - _onSync = (state, prevState) => { - if (state === 'PREPARED' && prevState === null) this._recheck(); + private onSync = (state, prevState) => { + if (state === 'PREPARED' && prevState === null) this.recheck(); }; - _onRoomStateEvents = (ev: MatrixEvent) => { + private onRoomStateEvents = (ev: MatrixEvent) => { if (ev.getType() !== "m.room.encryption") { return; } // If a room changes to encrypted, re-check as it may be our first // encrypted room. This also catches encrypted room creation as well. - this._recheck(); + this.recheck(); }; - _onAction = ({ action }) => { + private onAction = ({ action }: ActionPayload) => { if (action !== "on_logged_in") return; - this._recheck(); + this.recheck(); }; // The server doesn't tell us when key backup is set up, so we poll // & cache the result - async _getKeyBackupInfo() { + private async getKeyBackupInfo() { const now = (new Date()).getTime(); if (!this.keyBackupInfo || this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) { this.keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); @@ -205,7 +207,7 @@ export default class DeviceListener { return cli && cli.getRooms().some(r => cli.isRoomEncrypted(r.roomId)); } - async _recheck() { + private async recheck() { const cli = MatrixClientPeg.get(); if (!await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) return; @@ -234,7 +236,7 @@ export default class DeviceListener { // Cross-signing on account but this device doesn't trust the master key (verify this session) showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); } else { - const backupInfo = await this._getKeyBackupInfo(); + const backupInfo = await this.getKeyBackupInfo(); if (backupInfo) { // No cross-signing on account but key backup available (upgrade encryption) showSetupEncryptionToast(SetupKind.UPGRADE_ENCRYPTION); @@ -255,7 +257,7 @@ export default class DeviceListener { // This needs to be done after awaiting on downloadKeys() above, so // we make sure we get the devices after the fetch is done. - this._ensureDeviceIdsAtStartPopulated(); + this.ensureDeviceIdsAtStartPopulated(); // Unverified devices that were there last time the app ran // (technically could just be a boolean: we don't actually diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index c80b50c566..af5d2b3019 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -25,7 +25,6 @@ import _linkifyElement from 'linkifyjs/element'; import _linkifyString from 'linkifyjs/string'; import classNames from 'classnames'; import EMOJIBASE_REGEX from 'emojibase-regex'; -import url from 'url'; import katex from 'katex'; import { AllHtmlEntities } from 'html-entities'; import { IContent } from 'matrix-js-sdk/src/models/event'; @@ -34,7 +33,7 @@ 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 { getEmojiFromUnicode } from "./emoji"; import ReplyThread from "./components/views/elements/ReplyThread"; import { mediaFromMxc } from "./customisations/Media"; @@ -58,7 +57,9 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; -export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; +export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet', 'matrix']; + +const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; /* * Return true if the given string contains emoji @@ -78,20 +79,8 @@ function mightContainEmoji(str: string): boolean { * @return {String} The shortcode (such as :thumbup:) */ export function unicodeToShortcode(char: string): string { - const data = getEmojiFromUnicode(char); - return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); -} - -/** - * Returns the unicode character for an emoji shortcode - * - * @param {String} shortcode The shortcode (such as :thumbup:) - * @return {String} The emoji character; null if none exists - */ -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; + const shortcodes = getEmojiFromUnicode(char)?.shortcodes; + return shortcodes?.length ? `:${shortcodes[0]}:` : ''; } export function processHtmlForSending(html: string): string { @@ -151,10 +140,8 @@ export function getHtmlText(insaneHtml: string): string { */ export function isUrlPermitted(inputUrl: string): boolean { try { - const parsed = url.parse(inputUrl); - if (!parsed.protocol) return false; // URL parser protocol includes the trailing colon - return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1)); + return PERMITTED_URL_SCHEMES.includes(new URL(inputUrl).protocol.slice(0, -1)); } catch (e) { return false; } @@ -176,18 +163,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")) { + 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) { @@ -358,11 +358,11 @@ interface IOpts { stripReplyFallback?: boolean; returnString?: boolean; forComposerQuote?: boolean; - ref?: React.Ref; + ref?: React.Ref; } export interface IOptsReturnNode extends IOpts { - returnString: false; + returnString: false | undefined; } export interface IOptsReturnString extends IOpts { @@ -403,9 +403,14 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts try { if (highlights && highlights.length > 0) { const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); - const safeHighlights = highlights.map(function(highlight) { - return sanitizeHtml(highlight, sanitizeParams); - }); + const safeHighlights = highlights + // sanitizeHtml can hang if an unclosed HTML tag is thrown at it + // A search for ` !highlight.includes("<")) + .map((highlight: string): string => sanitizeHtml(highlight, sanitizeParams)); // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. sanitizeParams.textFilter = function(safeText) { return highlighter.applyHighlights(safeText, safeHighlights).join(''); diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index 31a5021317..e91e1d72cf 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -127,7 +127,7 @@ export default class IdentityAuthClient { await this._matrixClient.getIdentityAccount(token); } catch (e) { if (e.errcode === "M_TERMS_NOT_SIGNED") { - console.log("Identity Server requires new terms to be agreed to"); + console.log("Identity server requires new terms to be agreed to"); await startTermsFlow([new Service( SERVICE_TYPES.IS, identityServerUrl, @@ -149,17 +149,17 @@ export default class IdentityAuthClient { title: _t("Identity server has no terms of service"), description: (
-

{_t( +

{ _t( "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.", {}, { - server: () => {abbreviateUrl(identityServerUrl)}, + server: () => { abbreviateUrl(identityServerUrl) }, }, - )}

-

{_t( + ) }

+

{ _t( "Only continue if you trust the owner of the server.", - )}

+ ) }

), button: _t("Trust"), diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 76dee5ab55..410124a637 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -21,6 +21,7 @@ import { createClient } from 'matrix-js-sdk/src/matrix'; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; +import { QueryDict } from 'matrix-js-sdk/src/utils'; import { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg'; import SecurityCustomisations from "./customisations/Security"; @@ -33,7 +34,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"; @@ -52,6 +52,10 @@ import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import { _t } from "./languageHandler"; +import LazyLoadingResyncDialog from "./components/views/dialogs/LazyLoadingResyncDialog"; +import LazyLoadingDisabledDialog from "./components/views/dialogs/LazyLoadingDisabledDialog"; +import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog"; +import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -62,7 +66,7 @@ interface ILoadSessionOpts { guestIsUrl?: string; ignoreGuest?: boolean; defaultDeviceDisplayName?: string; - fragmentQueryParams?: Record; + fragmentQueryParams?: QueryDict; } /** @@ -115,8 +119,8 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise ) { console.log("Using guest access credentials"); return doSetLoggedIn({ - userId: fragmentQueryParams.guest_user_id, - accessToken: fragmentQueryParams.guest_access_token, + userId: fragmentQueryParams.guest_user_id as string, + accessToken: fragmentQueryParams.guest_access_token as string, homeserverUrl: guestHsUrl, identityServerUrl: guestIsUrl, guest: true, @@ -170,7 +174,7 @@ export async function getStoredSessionOwner(): Promise<[string, boolean]> { * login, else false */ export function attemptTokenLogin( - queryParams: Record, + queryParams: QueryDict, defaultDeviceDisplayName?: string, fragmentAfterLogin?: string, ): Promise { @@ -195,7 +199,7 @@ export function attemptTokenLogin( homeserver, identityServer, "m.login.token", { - token: queryParams.loginToken, + token: queryParams.loginToken as string, initial_device_display_name: defaultDeviceDisplayName, }, ).then(function(creds) { @@ -238,8 +242,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 +252,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, @@ -451,9 +451,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, }); @@ -612,7 +609,6 @@ async function doSetLoggedIn( } function showStorageEvictedDialog(): Promise { - const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog'); return new Promise(resolve => { Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, { onFinished: resolve, 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 cb6cb5c65c..f43351aab2 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -17,8 +17,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix'; -import { MatrixClient } from 'matrix-js-sdk/src/client'; +import { ICreateClientOpts, PendingEventOrdering } from 'matrix-js-sdk/src/matrix'; +import { IStartClientOpts, MatrixClient } from 'matrix-js-sdk/src/client'; import { MemoryStore } from 'matrix-js-sdk/src/store/memory'; import * as utils from 'matrix-js-sdk/src/utils'; import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; @@ -47,25 +47,8 @@ export interface IMatrixClientCreds { freshLogin?: boolean; } -// TODO: Move this to the js-sdk -export interface IOpts { - initialSyncLimit?: number; - pendingEventOrdering?: "detached" | "chronological"; - lazyLoadMembers?: boolean; - clientWellKnownPollPeriod?: number; -} - export interface IMatrixClientPeg { - opts: IOpts; - - /** - * Sets the script href passed to the IndexedDB web worker - * If set, a separate web worker will be started to run the IndexedDB - * queries on. - * - * @param {string} script href to the script to be passed to the web worker - */ - setIndexedDbWorkerScript(script: string): void; + opts: IStartClientOpts; /** * Return the server name of the user's homeserver @@ -122,12 +105,12 @@ export interface IMatrixClientPeg { * This module provides a singleton instance of this class so the 'current' * Matrix Client object is available easily. */ -class _MatrixClientPeg implements IMatrixClientPeg { +class MatrixClientPegClass implements IMatrixClientPeg { // These are the default options used when when the // client is started in 'start'. These can be altered // at any time up to after the 'will_start_client' // event is finished processing. - public opts: IOpts = { + public opts: IStartClientOpts = { initialSyncLimit: 20, }; @@ -141,10 +124,6 @@ class _MatrixClientPeg implements IMatrixClientPeg { constructor() { } - public setIndexedDbWorkerScript(script: string): void { - createMatrixClient.indexedDbWorkerScript = script; - } - public get(): MatrixClient { return this.matrixClient; } @@ -219,6 +198,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } catch (e) { if (e && e.name === 'InvalidCryptoStoreError') { // The js-sdk found a crypto DB too new for it to use + // FIXME: Using an import will result in test failures const CryptoStoreTooNewDialog = sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog"); Modal.createDialog(CryptoStoreTooNewDialog); @@ -230,7 +210,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { const opts = utils.deepCopy(this.opts); // the react sdk doesn't work without this, so don't allow - opts.pendingEventOrdering = "detached"; + opts.pendingEventOrdering = PendingEventOrdering.Detached; opts.lazyLoadMembers = true; opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours @@ -320,7 +300,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } if (!window.mxMatrixClientPeg) { - window.mxMatrixClientPeg = new _MatrixClientPeg(); + window.mxMatrixClientPeg = new MatrixClientPegClass(); } export const MatrixClientPeg = window.mxMatrixClientPeg; diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts index 49ef123def..073f24523d 100644 --- a/src/MediaDeviceHandler.ts +++ b/src/MediaDeviceHandler.ts @@ -20,12 +20,15 @@ import { SettingLevel } from "./settings/SettingLevel"; import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix"; import EventEmitter from 'events'; -interface IMediaDevices { - audioOutput: Array; - audioInput: Array; - videoInput: Array; +// 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", } @@ -51,20 +54,14 @@ export default class MediaDeviceHandler extends EventEmitter { try { const devices = await navigator.mediaDevices.enumerateDevices(); + const output = { + [MediaDeviceKindEnum.AudioOutput]: [], + [MediaDeviceKindEnum.AudioInput]: [], + [MediaDeviceKindEnum.VideoInput]: [], + }; - const audioOutput = []; - const audioInput = []; - const videoInput = []; - - devices.forEach((device) => { - switch (device.kind) { - case 'audiooutput': audioOutput.push(device); break; - case 'audioinput': audioInput.push(device); break; - case 'videoinput': videoInput.push(device); break; - } - }); - - return { audioOutput, audioInput, videoInput }; + devices.forEach((device) => output[device.kind].push(device)); + return output; } catch (error) { console.warn('Unable to refresh WebRTC Devices: ', error); } @@ -106,6 +103,14 @@ export default class MediaDeviceHandler extends EventEmitter { 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"); } diff --git a/src/Modal.tsx b/src/Modal.tsx index 0b0e621e89..1e84078ddb 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -18,10 +18,10 @@ 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"; @@ -378,7 +378,7 @@ export class ModalManager { const dialog = (

- {modal.elem} + { modal.elem }
diff --git a/src/Notifier.ts b/src/Notifier.ts index 2335dc59ac..1137e44aec 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -27,7 +27,6 @@ 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"; @@ -37,6 +36,7 @@ import { isPushNotifyDisabled } from "./settings/controllers/NotificationControl import RoomViewStore from "./stores/RoomViewStore"; import UserActivity from "./UserActivity"; import { mediaFromMxc } from "./customisations/Media"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; /* * Dispatches: @@ -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, @@ -329,7 +328,7 @@ export const Notifier = { onEvent: function(ev: MatrixEvent) { if (!this.isSyncing) return; // don't alert for any messages initially - if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return; + if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return; MatrixClientPeg.get().decryptEventIfNeeded(ev); diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx index c7f377b6e8..7d093f4092 100644 --- a/src/RoomInvite.tsx +++ b/src/RoomInvite.tsx @@ -22,13 +22,13 @@ 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, Member } from "./components/views/dialogs/InviteDialog"; import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; import { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore"; import BaseAvatar from "./components/views/avatars/BaseAvatar"; import { mediaFromMxc } from "./customisations/Media"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; export interface IInviteResult { states: CompletionStates; @@ -51,7 +51,6 @@ export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promi 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 }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, @@ -111,7 +110,6 @@ export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise { 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")), @@ -131,7 +129,6 @@ export function showAnyInviteErrors( // Just get the first message because there was a fatal problem on the first // user. This usually means that no other users were attempted, making it // pointless for us to list who failed exactly. - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite users to the room', '', ErrorDialog, { title: _t("Failed to invite users to the room:", { roomName: room.name }), description: inviter.getErrorText(failedUsers[0]), @@ -178,7 +175,6 @@ export function showAnyInviteErrors(
; - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog("Some invites could not be sent", "", ErrorDialog, { title: _t("Some invites couldn't be sent"), description, diff --git a/src/Rooms.ts b/src/Rooms.ts index 4d1682660b..6e2fd4d3a2 100644 --- a/src/Rooms.ts +++ b/src/Rooms.ts @@ -17,6 +17,7 @@ limitations under the License. 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, @@ -28,7 +29,18 @@ import { MatrixClientPeg } from './MatrixClientPeg'; * @returns {string} A display alias for the given room */ export function getDisplayAliasForRoom(room: Room): string { - return room.getCanonicalAlias() || room.getAltAliases()[0]; + return getDisplayAliasForAliasSet( + room.getCanonicalAlias(), room.getAltAliases(), + ); +} + +// 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 { @@ -72,10 +84,8 @@ export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise this room as a DM room * @returns {object} A promise */ -export function setDMRoom(roomId: string, userId: string): Promise { - if (MatrixClientPeg.get().isGuest()) { - return Promise.resolve(); - } +export async function setDMRoom(roomId: string, userId: string): Promise { + if (MatrixClientPeg.get().isGuest()) return; const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct'); let dmRoomMap = {}; @@ -104,7 +114,7 @@ export function setDMRoom(roomId: string, userId: string): Promise { dmRoomMap[userId] = roomList; } - return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap); + await MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap); } /** diff --git a/src/Searching.js b/src/Searching.ts similarity index 79% rename from src/Searching.js rename to src/Searching.ts index d0666b1760..37f85efa77 100644 --- a/src/Searching.js +++ b/src/Searching.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,26 +14,42 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { + IResultRoomEvents, + ISearchRequestBody, + ISearchResponse, + ISearchResult, + ISearchResults, + SearchOrderBy, +} from "matrix-js-sdk/src/@types/search"; +import { IRoomEventFilter } from "matrix-js-sdk/src/filter"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { ISearchArgs } from "./indexing/BaseEventIndexManager"; import EventIndexPeg from "./indexing/EventIndexPeg"; import { MatrixClientPeg } from "./MatrixClientPeg"; +import { SearchResult } from "matrix-js-sdk/src/models/search-result"; const SEARCH_LIMIT = 10; -async function serverSideSearch(term, roomId = undefined) { +async function serverSideSearch( + term: string, + roomId: string = undefined, +): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> { const client = MatrixClientPeg.get(); - const filter = { + const filter: IRoomEventFilter = { limit: SEARCH_LIMIT, }; if (roomId !== undefined) filter.rooms = [roomId]; - const body = { + const body: ISearchRequestBody = { search_categories: { room_events: { search_term: term, filter: filter, - order_by: "recent", + order_by: SearchOrderBy.Recent, event_context: { before_limit: 1, after_limit: 1, @@ -45,31 +61,26 @@ async function serverSideSearch(term, roomId = undefined) { const response = await client.search({ body: body }); - const result = { - response: response, - query: body, - }; - - return result; + return { response, query: body }; } -async function serverSideSearchProcess(term, roomId = undefined) { +async function serverSideSearchProcess(term: string, roomId: string = undefined): Promise { const client = MatrixClientPeg.get(); const result = await serverSideSearch(term, roomId); // The js-sdk method backPaginateRoomEventsSearch() uses _query internally - // so we're reusing the concept here since we wan't to delegate the + // so we're reusing the concept here since we want to delegate the // pagination back to backPaginateRoomEventsSearch() in some cases. - const searchResult = { + const searchResults: ISearchResults = { _query: result.query, results: [], highlights: [], }; - return client.processRoomEventsSearch(searchResult, result.response); + return client.processRoomEventsSearch(searchResults, result.response); } -function compareEvents(a, b) { +function compareEvents(a: ISearchResult, b: ISearchResult): number { const aEvent = a.result; const bEvent = b.result; @@ -79,7 +90,7 @@ function compareEvents(a, b) { return 0; } -async function combinedSearch(searchTerm) { +async function combinedSearch(searchTerm: string): Promise { const client = MatrixClientPeg.get(); // Create two promises, one for the local search, one for the @@ -111,10 +122,10 @@ async function combinedSearch(searchTerm) { // returns since that one can be either a server-side one, a local one or a // fake one to fetch the remaining cached events. See the docs for // combineEvents() for an explanation why we need to cache events. - const emptyResult = { + const emptyResult: ISeshatSearchResults = { seshatQuery: localQuery, _query: serverQuery, - serverSideNextBatch: serverResponse.next_batch, + serverSideNextBatch: serverResponse.search_categories.room_events.next_batch, cachedEvents: [], oldestEventFrom: "server", results: [], @@ -125,7 +136,7 @@ async function combinedSearch(searchTerm) { const combinedResult = combineResponses(emptyResult, localResponse, serverResponse.search_categories.room_events); // Let the client process the combined result. - const response = { + const response: ISearchResponse = { search_categories: { room_events: combinedResult, }, @@ -139,10 +150,14 @@ async function combinedSearch(searchTerm) { return result; } -async function localSearch(searchTerm, roomId = undefined, processResult = true) { +async function localSearch( + searchTerm: string, + roomId: string = undefined, + processResult = true, +): Promise<{ response: IResultRoomEvents, query: ISearchArgs }> { const eventIndex = EventIndexPeg.get(); - const searchArgs = { + const searchArgs: ISearchArgs = { search_term: searchTerm, before_limit: 1, after_limit: 1, @@ -167,11 +182,18 @@ async function localSearch(searchTerm, roomId = undefined, processResult = true) return result; } -async function localSearchProcess(searchTerm, roomId = undefined) { +export interface ISeshatSearchResults extends ISearchResults { + seshatQuery?: ISearchArgs; + cachedEvents?: ISearchResult[]; + oldestEventFrom?: "local" | "server"; + serverSideNextBatch?: string; +} + +async function localSearchProcess(searchTerm: string, roomId: string = undefined): Promise { const emptyResult = { results: [], highlights: [], - }; + } as ISeshatSearchResults; if (searchTerm === "") return emptyResult; @@ -179,7 +201,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) { emptyResult.seshatQuery = result.query; - const response = { + const response: ISearchResponse = { search_categories: { room_events: result.response, }, @@ -192,7 +214,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) { return processedResult; } -async function localPagination(searchResult) { +async function localPagination(searchResult: ISeshatSearchResults): Promise { const eventIndex = EventIndexPeg.get(); const searchArgs = searchResult.seshatQuery; @@ -221,10 +243,10 @@ async function localPagination(searchResult) { return result; } -function compareOldestEvents(firstResults, secondResults) { +function compareOldestEvents(firstResults: ISearchResult[], secondResults: ISearchResult[]): number { try { - const oldestFirstEvent = firstResults.results[firstResults.results.length - 1].result; - const oldestSecondEvent = secondResults.results[secondResults.results.length - 1].result; + const oldestFirstEvent = firstResults[firstResults.length - 1].result; + const oldestSecondEvent = secondResults[secondResults.length - 1].result; if (oldestFirstEvent.origin_server_ts <= oldestSecondEvent.origin_server_ts) { return -1; @@ -236,7 +258,12 @@ function compareOldestEvents(firstResults, secondResults) { } } -function combineEventSources(previousSearchResult, response, a, b) { +function combineEventSources( + previousSearchResult: ISeshatSearchResults, + response: IResultRoomEvents, + a: ISearchResult[], + b: ISearchResult[], +): void { // Merge event sources and sort the events. const combinedEvents = a.concat(b).sort(compareEvents); // Put half of the events in the response, and cache the other half. @@ -353,8 +380,12 @@ function combineEventSources(previousSearchResult, response, a, b) { * different event sources. * */ -function combineEvents(previousSearchResult, localEvents = undefined, serverEvents = undefined) { - const response = {}; +function combineEvents( + previousSearchResult: ISeshatSearchResults, + localEvents: IResultRoomEvents = undefined, + serverEvents: IResultRoomEvents = undefined, +): IResultRoomEvents { + const response = {} as IResultRoomEvents; const cachedEvents = previousSearchResult.cachedEvents; let oldestEventFrom = previousSearchResult.oldestEventFrom; @@ -364,7 +395,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven // This is a first search call, combine the events from the server and // the local index. Note where our oldest event came from, we shall // fetch the next batch of events from the other source. - if (compareOldestEvents(localEvents, serverEvents) < 0) { + if (compareOldestEvents(localEvents.results, serverEvents.results) < 0) { oldestEventFrom = "local"; } @@ -375,7 +406,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven // meaning that our oldest event was on the server. // Change the source of the oldest event if our local event is older // than the cached one. - if (compareOldestEvents(localEvents, cachedEvents) < 0) { + if (compareOldestEvents(localEvents.results, cachedEvents) < 0) { oldestEventFrom = "local"; } combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents); @@ -384,7 +415,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven // meaning that our oldest event was in the local index. // Change the source of the oldest event if our server event is older // than the cached one. - if (compareOldestEvents(serverEvents, cachedEvents) < 0) { + if (compareOldestEvents(serverEvents.results, cachedEvents) < 0) { oldestEventFrom = "server"; } combineEventSources(previousSearchResult, response, serverEvents.results, cachedEvents); @@ -412,7 +443,11 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven * @return {object} A response object that combines the events from the * different event sources. */ -function combineResponses(previousSearchResult, localEvents = undefined, serverEvents = undefined) { +function combineResponses( + previousSearchResult: ISeshatSearchResults, + localEvents: IResultRoomEvents = undefined, + serverEvents: IResultRoomEvents = undefined, +): IResultRoomEvents { // Combine our events first. const response = combineEvents(previousSearchResult, localEvents, serverEvents); @@ -454,42 +489,51 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE return response; } -function restoreEncryptionInfo(searchResultSlice = []) { +interface IEncryptedSeshatEvent { + curve25519Key: string; + ed25519Key: string; + algorithm: string; + forwardingCurve25519KeyChain: string[]; +} + +function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void { for (let i = 0; i < searchResultSlice.length; i++) { const timeline = searchResultSlice[i].context.getTimeline(); for (let j = 0; j < timeline.length; j++) { - const ev = timeline[j]; + const mxEv = timeline[j]; + const ev = mxEv.event as IEncryptedSeshatEvent; - if (ev.event.curve25519Key) { - ev.makeEncrypted( - "m.room.encrypted", - { algorithm: ev.event.algorithm }, - ev.event.curve25519Key, - ev.event.ed25519Key, + if (ev.curve25519Key) { + mxEv.makeEncrypted( + EventType.RoomMessageEncrypted, + { algorithm: ev.algorithm }, + ev.curve25519Key, + ev.ed25519Key, ); - ev.forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain; + // @ts-ignore + mxEv.forwardingCurve25519KeyChain = ev.forwardingCurve25519KeyChain; - delete ev.event.curve25519Key; - delete ev.event.ed25519Key; - delete ev.event.algorithm; - delete ev.event.forwardingCurve25519KeyChain; + delete ev.curve25519Key; + delete ev.ed25519Key; + delete ev.algorithm; + delete ev.forwardingCurve25519KeyChain; } } } } -async function combinedPagination(searchResult) { +async function combinedPagination(searchResult: ISeshatSearchResults): Promise { const eventIndex = EventIndexPeg.get(); const client = MatrixClientPeg.get(); const searchArgs = searchResult.seshatQuery; const oldestEventFrom = searchResult.oldestEventFrom; - let localResult; - let serverSideResult; + let localResult: IResultRoomEvents; + let serverSideResult: ISearchResponse; - // Fetch events from the local index if we have a token for itand if it's + // Fetch events from the local index if we have a token for it and if it's // the local indexes turn or the server has exhausted its results. if (searchArgs.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) { localResult = await eventIndex.search(searchArgs); @@ -502,7 +546,7 @@ async function combinedPagination(searchResult) { serverSideResult = await client.search(body); } - let serverEvents; + let serverEvents: IResultRoomEvents; if (serverSideResult) { serverEvents = serverSideResult.search_categories.room_events; @@ -532,8 +576,8 @@ async function combinedPagination(searchResult) { return result; } -function eventIndexSearch(term, roomId = undefined) { - let searchPromise; +function eventIndexSearch(term: string, roomId: string = undefined): Promise { + let searchPromise: Promise; if (roomId !== undefined) { if (MatrixClientPeg.get().isRoomEncrypted(roomId)) { @@ -554,7 +598,7 @@ function eventIndexSearch(term, roomId = undefined) { return searchPromise; } -function eventIndexSearchPagination(searchResult) { +function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise { const client = MatrixClientPeg.get(); const seshatQuery = searchResult.seshatQuery; @@ -580,7 +624,7 @@ function eventIndexSearchPagination(searchResult) { } } -export function searchPagination(searchResult) { +export function searchPagination(searchResult: ISearchResults): Promise { const eventIndex = EventIndexPeg.get(); const client = MatrixClientPeg.get(); @@ -590,7 +634,7 @@ export function searchPagination(searchResult) { else return eventIndexSearchPagination(searchResult); } -export default function eventSearch(term, roomId = undefined) { +export default function eventSearch(term: string, roomId: string = undefined): Promise { const eventIndex = EventIndexPeg.get(); if (eventIndex === null) return serverSideSearchProcess(term, roomId); diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 6b372bba28..370b21b396 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ICryptoCallbacks, 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'; @@ -42,8 +43,8 @@ let secretStorageBeingAccessed = false; let nonInteractive = false; let dehydrationCache: { - key?: Uint8Array, - keyInfo?: ISecretStorageKeyInfo, + key?: Uint8Array; + keyInfo?: ISecretStorageKeyInfo; } = {}; function isCachingAllowed(): boolean { @@ -354,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/SlashCommands.tsx b/src/SlashCommands.tsx index 0f38c5fffc..9f5ac83a56 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -23,7 +23,6 @@ import { User } from "matrix-js-sdk/src/models/user"; import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; -import * as sdk from './index'; import { _t, _td } from './languageHandler'; import Modal from './Modal'; import MultiInviter from './utils/MultiInviter'; @@ -50,6 +49,12 @@ import { UIFeature } from "./settings/UIFeature"; import { CHAT_EFFECTS } from "./effects"; import CallHandler from "./CallHandler"; import { guessAndSetDMRoom } from "./Rooms"; +import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog'; +import ErrorDialog from './components/views/dialogs/ErrorDialog'; +import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog'; +import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog"; +import InfoDialog from "./components/views/dialogs/InfoDialog"; +import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -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) => { @@ -246,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'), @@ -269,8 +272,6 @@ 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, /*isPriority=*/false, /*isStatic=*/true); @@ -314,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( @@ -434,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:
, @@ -481,14 +480,14 @@ export const Commands = [ 'Identity server', QuestionDialog, { title: _t("Use an identity server"), - description:

{_t( + description:

{ _t( "Use an identity server to invite by email. " + "Click continue to use the default identity server " + "(%(defaultIdentityServerName)s) or manage in Settings.", { defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl), }, - )}

, + ) }

, button: _t("Continue"), }, ); @@ -523,7 +522,7 @@ export const Commands = [ aliases: ['j', 'goto'], args: '', description: _td('Joins room with given address'), - runFn: function(_, args) { + runFn: function(roomId, args) { if (args) { // Note: we support 2 versions of this command. The first is // the public-facing one for most users and the other is a @@ -737,7 +736,6 @@ 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:
@@ -768,7 +766,6 @@ 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:
@@ -838,7 +835,6 @@ 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 }); return success(); }, @@ -943,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:
@@ -1000,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(); }, @@ -1076,7 +1069,7 @@ export const Commands = [ command: "msg", description: _td("Sends a message to the given user"), args: " ", - runFn: function(_, args) { + runFn: function(roomId, args) { if (args) { // matches the first whitespace delimited group and then the rest of the string const matches = args.match(/^(\S+?)(?: +(.*))?$/s); @@ -1181,7 +1174,7 @@ export const Commands = [ ]; // 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 => { @@ -1189,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]; @@ -1208,6 +1201,11 @@ export function parseCommandString(input: string) { return { cmd, args }; } +interface ICmd { + cmd?: Command; + args?: string; +} + /** * Process the given text for /commands and return a bound method to perform them. * @param {string} roomId The room in which the command was performed. @@ -1216,7 +1214,7 @@ 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) { +export function getCommand(input: string): ICmd { const { cmd, args } = parseCommandString(input); if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) { diff --git a/src/Terms.ts b/src/Terms.ts index 3859cc1c73..351d1c0951 100644 --- a/src/Terms.ts +++ b/src/Terms.ts @@ -15,6 +15,7 @@ limitations under the License. */ import classNames from 'classnames'; +import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; import { MatrixClientPeg } from './MatrixClientPeg'; import * as sdk from '.'; @@ -32,7 +33,7 @@ export class Service { * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') * @param {string} accessToken The user's access token for the service */ - constructor(public serviceType: string, public baseUrl: string, public accessToken: string) { + constructor(public serviceType: SERVICE_TYPES, public baseUrl: string, public accessToken: string) { } } @@ -48,13 +49,13 @@ export interface Policy { } export type Policies = { - [policy: string]: Policy, + [policy: string]: Policy; }; export type TermsInteractionCallback = ( policiesAndServicePairs: { - service: Service, - policies: Policies, + service: Service; + policies: Policies; }[], agreedUrls: string[], extraClassNames?: string, @@ -180,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.tsx b/src/TextForEvent.tsx index 844c79fbae..7bad8eb50e 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -13,9 +13,7 @@ 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"; @@ -32,7 +30,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; // 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 { +function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => 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(); @@ -84,7 +82,7 @@ function textForMemberEvent(ev): () => string | null { 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")) { + } else if (showHiddenEvents ?? 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 { @@ -127,7 +125,7 @@ function textForMemberEvent(ev): () => string | null { } } -function textForTopicEvent(ev): () => string | null { +function textForTopicEvent(ev: MatrixEvent): () => string | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', { senderDisplayName, @@ -135,7 +133,7 @@ function textForTopicEvent(ev): () => string | null { }); } -function textForRoomNameEvent(ev): () => string | null { +function textForRoomNameEvent(ev: MatrixEvent): () => string | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { @@ -154,12 +152,12 @@ function textForRoomNameEvent(ev): () => string | null { }); } -function textForTombstoneEvent(ev): () => string | null { +function textForTombstoneEvent(ev: MatrixEvent): () => string | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName }); } -function textForJoinRulesEvent(ev): () => string | null { +function textForJoinRulesEvent(ev: MatrixEvent): () => string | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); switch (ev.getContent().join_rule) { case "public": @@ -179,7 +177,7 @@ function textForJoinRulesEvent(ev): () => string | null { } } -function textForGuestAccessEvent(ev): () => string | null { +function textForGuestAccessEvent(ev: MatrixEvent): () => string | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); switch (ev.getContent().guest_access) { case "can_join": @@ -195,7 +193,7 @@ function textForGuestAccessEvent(ev): () => string | null { } } -function textForRelatedGroupsEvent(ev): () => string | null { +function textForRelatedGroupsEvent(ev: MatrixEvent): () => string | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const groups = ev.getContent().groups || []; const prevGroups = ev.getPrevContent().groups || []; @@ -225,7 +223,7 @@ function textForRelatedGroupsEvent(ev): () => string | null { } } -function textForServerACLEvent(ev): () => string | null { +function textForServerACLEvent(ev: MatrixEvent): () => string | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const prevContent = ev.getPrevContent(); const current = ev.getContent(); @@ -255,7 +253,7 @@ function textForServerACLEvent(ev): () => string | null { return getText; } -function textForMessageEvent(ev): () => string | null { +function textForMessageEvent(ev: MatrixEvent): () => string | null { return () => { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); let message = senderDisplayName + ': ' + ev.getContent().body; @@ -268,7 +266,7 @@ function textForMessageEvent(ev): () => string | null { }; } -function textForCanonicalAliasEvent(ev): () => string | null { +function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null { const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const oldAlias = ev.getPrevContent().alias; const oldAltAliases = ev.getPrevContent().alt_aliases || []; @@ -319,91 +317,7 @@ function textForCanonicalAliasEvent(ev): () => string | null { }); } -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 { +function textForThreePidInviteEvent(event: MatrixEvent): () => string | null { const senderName = event.sender ? event.sender.name : event.getSender(); if (!isValid3pidInvite(event)) { @@ -419,7 +333,7 @@ function textForThreePidInviteEvent(event): () => string | null { }); } -function textForHistoryVisibilityEvent(event): () => string | null { +function textForHistoryVisibilityEvent(event: MatrixEvent): () => string | null { const senderName = event.sender ? event.sender.name : event.getSender(); switch (event.getContent().history_visibility) { case 'invited': @@ -441,13 +355,14 @@ function textForHistoryVisibilityEvent(event): () => string | null { } // Currently will only display a change if a user's power level is changed -function textForPowerEvent(event): () => string | null { +function textForPowerEvent(event: MatrixEvent): () => 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 userDefault = event.getContent().users_default || 0; + 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( @@ -463,9 +378,16 @@ function textForPowerEvent(event): () => string | null { const diffs = []; users.forEach((userId) => { // Previous power level - const from = event.getPrevContent().users[userId]; + let from = event.getPrevContent().users[userId]; + if (!Number.isInteger(from)) { + from = previousUserDefault; + } // Current power level - const to = event.getContent().users[userId]; + 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 }); } @@ -479,8 +401,8 @@ function textForPowerEvent(event): () => string | null { powerLevelDiffText: diffs.map(diff => _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { userId: diff.userId, - fromPowerLevel: Roles.textualPowerLevel(diff.from, userDefault), - toPowerLevel: Roles.textualPowerLevel(diff.to, userDefault), + fromPowerLevel: Roles.textualPowerLevel(diff.from, previousUserDefault), + toPowerLevel: Roles.textualPowerLevel(diff.to, currentUserDefault), }), ).join(", "), }); @@ -515,7 +437,7 @@ function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName }); } -function textForWidgetEvent(event): () => string | null { +function textForWidgetEvent(event: MatrixEvent): () => string | null { const senderName = event.getSender(); const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent(); const { name, type, url } = event.getContent() || {}; @@ -545,12 +467,12 @@ function textForWidgetEvent(event): () => string | null { } } -function textForWidgetLayoutEvent(event): () => string | null { +function textForWidgetLayoutEvent(event: MatrixEvent): () => string | null { const senderName = event.sender?.name || event.getSender(); return () => _t("%(senderName)s has updated the widget layout", { senderName }); } -function textForMjolnirEvent(event): () => string | null { +function textForMjolnirEvent(event: MatrixEvent): () => string | null { const senderName = event.getSender(); const { entity: prevEntity } = event.getPrevContent(); const { entity, recommendation, reason } = event.getContent(); @@ -638,15 +560,13 @@ function textForMjolnirEvent(event): () => string | null { } interface IHandlers { - [type: string]: (ev: MatrixEvent, allowJSX?: boolean) => (() => string | JSX.Element | null); + [type: string]: + (ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: 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 = { @@ -674,14 +594,27 @@ for (const evType of ALL_RULE_TYPES) { stateHandlers[evType] = textForMjolnirEvent; } -export function hasText(ev): boolean { +/** + * Determines whether the given event has text to display. + * @param ev The event + * @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline + * to avoid hitting the settings store + */ +export function hasText(ev: MatrixEvent, showHiddenEvents?: boolean): boolean { const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; - return Boolean(handler?.(ev)); + return Boolean(handler?.(ev, false, showHiddenEvents)); } +/** + * Gets the textual content of the given event. + * @param ev The event + * @param allowJSX Whether to output rich JSX content + * @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline + * to avoid hitting the settings store + */ 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 { +export function textForEvent(ev: MatrixEvent, allowJSX: true, showHiddenEvents?: boolean): string | JSX.Element; +export function textForEvent(ev: MatrixEvent, allowJSX = false, showHiddenEvents?: boolean): string | JSX.Element { const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; - return handler?.(ev, allowJSX)?.() || ''; + return handler?.(ev, allowJSX, showHiddenEvents)?.() || ''; } diff --git a/src/Unread.ts b/src/Unread.ts index 72f0bb4642..da5b883f92 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -30,7 +30,7 @@ import { haveTileForEvent } from "./components/views/rooms/EventTile"; * @returns {boolean} True if the given event should affect the unread message count */ export function eventTriggersUnreadCount(ev: MatrixEvent): boolean { - if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) { + if (ev.getSender() === MatrixClientPeg.get().credentials.userId) { return false; } @@ -63,9 +63,7 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean { // https://github.com/vector-im/element-web/issues/2427 // ...and possibly some of the others at // https://github.com/vector-im/element-web/issues/3363 - if (room.timeline.length && - room.timeline[room.timeline.length - 1].sender && - room.timeline[room.timeline.length - 1].sender.userId === myUserId) { + if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) { return false; } diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index 25c41f9db5..9cc7b60c99 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 InfoDialog from "../components/views/dialogs/InfoDialog"; // TS: once languageHandler is TS we can probably inline this into the enum _td("Navigation"); @@ -370,12 +370,11 @@ export const toggleDialog = () => { const sections = categoryOrder.map(category => { const list = shortcuts[category]; return
-

{_t(category)}

-
{list.map(shortcut => )}
+

{ _t(category) }

+
{ list.map(shortcut => ) }
; }); - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, { className: "mx_KeyboardShortcutsDialog", title: _t("Keyboard Shortcuts"), diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx index 8d882fadea..90538760bb 100644 --- a/src/accessibility/Toolbar.tsx +++ b/src/accessibility/Toolbar.tsx @@ -62,9 +62,9 @@ const Toolbar: React.FC = ({ children, ...props }) => { }; return - {({ onKeyDownHandler }) =>
+ { ({ onKeyDownHandler }) =>
{ children } -
} +
}
; }; diff --git a/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index 81e05f8678..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,7 +108,6 @@ 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 }), @@ -129,7 +127,6 @@ 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 }), diff --git a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.tsx similarity index 71% rename from src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js rename to src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.tsx index a19494c753..4d8f5e5663 100644 --- a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.tsx @@ -15,8 +15,10 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../../index'; -import PropTypes from 'prop-types'; + +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import Spinner from "../../../../components/views/elements/Spinner"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from '../../../../languageHandler'; @@ -24,46 +26,44 @@ import SettingsStore from "../../../../settings/SettingsStore"; import EventIndexPeg from "../../../../indexing/EventIndexPeg"; import { Action } from "../../../../dispatcher/actions"; import { SettingLevel } from "../../../../settings/SettingLevel"; +interface IProps { + onFinished: (success: boolean) => void; +} + +interface IState { + disabling: boolean; +} /* * Allows the user to disable the Event Index. */ -export default class DisableEventIndexDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - } - - constructor(props) { +export default class DisableEventIndexDialog extends React.Component { + constructor(props: IProps) { super(props); - this.state = { disabling: false, }; } - _onDisable = async () => { + private onDisable = async (): Promise => { this.setState({ disabling: true, }); await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false); await EventIndexPeg.deleteEventIndex(); - this.props.onFinished(); + this.props.onFinished(true); dis.fire(Action.ViewUserSettings); - } - - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const Spinner = sdk.getComponent('elements.Spinner'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + }; + public render(): React.ReactNode { return ( - {_t("If disabled, messages from encrypted rooms won't appear in search results.")} - {this.state.disabling ? :
} + { _t("If disabled, messages from encrypted rooms won't appear in search results.") } + { this.state.disabling ? :
} void; @@ -132,8 +134,9 @@ export default class ManageEventIndexDialog extends React.Component { - Modal.createTrackedDialogAsync("Disable message search", "Disable message search", - import("./DisableEventIndexDialog"), + const DisableEventIndexDialog = (await import("./DisableEventIndexDialog")).default; + Modal.createTrackedDialog("Disable message search", "Disable message search", + DisableEventIndexDialog, null, null, /* priority = */ false, /* static = */ true, ); }; @@ -145,7 +148,6 @@ export default class ManageEventIndexDialog extends React.Component - {_t( + { _t( "%(brand)s is securely caching encrypted messages locally for them " + "to appear in search results:", { brand }, - )} + ) }
- {crawlerState}
- {_t("Space used:")} {formatBytes(this.state.eventIndexSize, 0)}
- {_t("Indexed messages:")} {formatCountLong(this.state.eventCount)}
- {_t("Indexed rooms:")} {_t("%(doneRooms)s out of %(totalRooms)s", { + { crawlerState }
+ { _t("Space used:") } { formatBytes(this.state.eventIndexSize, 0) }
+ { _t("Indexed messages:") } { formatCountLong(this.state.eventCount) }
+ { _t("Indexed rooms:") } { _t("%(doneRooms)s out of %(totalRooms)s", { doneRooms: formatCountLong(doneRooms), totalRooms: formatCountLong(this.state.roomCount), - })}
+ }) }
); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return ( - {eventIndexingSettings} + { eventIndexingSettings } -

{_t( +

{ _t( "Warning: You should only set up key backup from a trusted computer.", {}, - { b: sub => {sub} }, - )}

-

{_t( + { b: sub => { sub } }, + ) }

+

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

-

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

+ ) }

+

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

@@ -268,9 +268,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent { />
- {_t("Advanced")} + { _t("Advanced") } - {_t("Set up with a Security Key")} + { _t("Set up with a Security Key") }
; @@ -299,19 +299,19 @@ export default class CreateKeyBackupDialog extends React.PureComponent { let passPhraseMatch = null; if (matchText) { passPhraseMatch =
-
{matchText}
+
{ matchText }
- {changeText} + { changeText }
; } const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
-

{_t( +

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

+ ) }

@@ -323,7 +323,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { autoFocus={true} />
- {passPhraseMatch} + { passPhraseMatch }
-

{_t( +

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

-

{_t( + ) }

+

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

+ ) }

- {_t("Your Security Key")} + { _t("Your Security Key") }
- {this._keyBackupInfo.recovery_key} + { this._keyBackupInfo.recovery_key }
@@ -370,26 +370,26 @@ 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} + { 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 } }) }
- +
; } @@ -404,9 +404,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent { _renderPhaseDone() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
-

{_t( +

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

+ ) }

- {_t( + { _t( "Without setting up Secure Message Recovery, you won't be able to restore your " + "encrypted message history if you log out or use another session.", - )} + ) } -

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

+

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

- {content} + { content }
); diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index e1254929db..aa78d68830 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -475,9 +475,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { >
- {_t("Generate a Security Key")} + { _t("Generate a Security Key") }
-
{_t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}
+
{ _t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }
); } @@ -494,9 +494,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { >
- {_t("Enter a Security Phrase")} + { _t("Enter a Security Phrase") }
-
{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}
+
{ _t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.") }
); } @@ -507,13 +507,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const optionPassphrase = setupMethods.includes("passphrase") ? this._renderOptionPassphrase() : null; return -

{_t( +

{ _t( "Safeguard against losing access to encrypted messages & data by " + "backing up encryption keys on your server.", - )}

+ ) }

- {optionKey} - {optionPassphrase} + { optionKey } + { optionPassphrase }
-
{_t("Enter your account password to confirm the upgrade:")}
+
{ _t("Enter your account password to confirm the upgrade:") }
; } else if (!this.state.backupSigStatus.usable) { authPrompt =
-
{_t("Restore your key backup to upgrade your encryption")}
+
{ _t("Restore your key backup to upgrade your encryption") }
; nextCaption = _t("Restore"); } else { authPrompt =

- {_t("You'll need to authenticate with the server to confirm the upgrade.")} + { _t("You'll need to authenticate with the server to confirm the upgrade.") }

; } return -

{_t( +

{ _t( "Upgrade this session to allow it to verify other sessions, " + "granting them access to encrypted messages and marking them " + "as trusted for other users.", - )}

-
{authPrompt}
+ ) }

+
{ authPrompt }
; @@ -579,10 +579,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _renderPhasePassPhrase() { return
-

{_t( +

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

+ ) }

{_t("Cancel")} + >{ _t("Cancel") } ; } @@ -637,18 +637,18 @@ export default class CreateSecretStorageDialog extends React.PureComponent { let passPhraseMatch = null; if (matchText) { passPhraseMatch =
-
{matchText}
+
{ matchText }
- {changeText} + { changeText }
; } return
-

{_t( +

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

+ ) }

- {passPhraseMatch} + { passPhraseMatch }
{_t("Skip")} + >{ _t("Skip") } ; } @@ -691,35 +691,35 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
; } return
-

{_t( +

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

+ ) }

- {this._recoveryKey.encodedPrivateKey} + { this._recoveryKey.encodedPrivateKey }
- {_t("Download")} + { _t("Download") } - {_t("or")} + { _t("or") } - {this.state.copied ? _t("Copied!") : _t("Copy")} + { this.state.copied ? _t("Copied!") : _t("Copy") }
- {continueButton} + { continueButton }
; } @@ -732,7 +732,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _renderPhaseLoadError() { return
-

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

+

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

-

{_t( +

{ _t( "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.", - )}

-

{_t( + ) }

+

{ _t( "You can also set up Secure Backup & manage your keys in Settings.", - )}

+ ) }

- +
; } @@ -787,7 +787,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { let content; if (this.state.error) { content =
-

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

+

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

- {content} + { content }
); diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js index 4a0aa37da0..263d25c98c 100644 --- a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js @@ -54,28 +54,28 @@ export default class NewRecoveryMethodDialog extends React.PureComponent { const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); const title = - {_t("New Recovery Method")} + { _t("New Recovery Method") } ; - const newMethodDetected =

{_t( + const newMethodDetected =

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

; + ) }

; - const hackWarning =

{_t( + const hackWarning =

{ _t( "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.", - )}

; + ) }

; let content; if (MatrixClientPeg.get().getKeyBackupEnabled()) { content =
- {newMethodDetected} -

{_t( + { newMethodDetected } +

{ _t( "This session is encrypting history using the new recovery method.", - )}

- {hackWarning} + ) }

+ { hackWarning } ; } else { content =
- {newMethodDetected} - {hackWarning} + { newMethodDetected } + { hackWarning } - {content} + { content } ); } diff --git a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js index f0f8a5273b..f586c9430a 100644 --- a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js +++ b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js @@ -46,7 +46,7 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent { const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); const title = - {_t("Recovery Method Removed")} + { _t("Recovery Method Removed") } ; return ( @@ -55,21 +55,21 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent { title={title} >
-

{_t( +

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

-

{_t( + ) }

+

{ _t( "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.", - )}

-

{_t( + ) }

+

{ _t( "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.", - )}

+ ) }

{ - const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar'); - // Disable autocompletions when composing commands because of various issues // (see https://github.com/vector-im/element-web/issues/4762) if (/^(\/join|\/leave)/.test(query)) { diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 2fc77e9a17..d3175edbdb 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -25,7 +25,6 @@ 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'; import EMOTICON_REGEX from 'emojibase-regex/emoticon'; @@ -36,20 +35,18 @@ const LIMIT = 20; // anchored to only match from the start of parts otherwise it'll show emoji suggestions whilst typing matrix IDs const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|(?:^|\\s):[+-\\w]*:?)$', 'g'); -interface IEmojiShort { +interface ISortedEmoji { emoji: IEmoji; - shortname: string; _orderBy: number; } -const EMOJI_SHORTNAMES: IEmojiShort[] = EMOJI.sort((a, b) => { +const SORTED_EMOJI: ISortedEmoji[] = EMOJI.sort((a, b) => { if (a.group === b.group) { return a.order - b.order; } return a.group - b.group; }).map((emoji, index) => ({ emoji, - shortname: `:${emoji.shortcodes[0]}:`, // Include the index so that we can preserve the original order _orderBy: index, })); @@ -64,20 +61,18 @@ function score(query, space) { } export default class EmojiProvider extends AutocompleteProvider { - matcher: QueryMatcher; - nameMatcher: QueryMatcher; + matcher: QueryMatcher; + nameMatcher: QueryMatcher; constructor() { super(EMOJI_REGEX); - this.matcher = new QueryMatcher(EMOJI_SHORTNAMES, { - keys: ['emoji.emoticon', 'shortname'], - funcs: [ - (o) => o.emoji.shortcodes.length > 1 ? o.emoji.shortcodes.slice(1).map(s => `:${s}:`).join(" ") : "", // aliases - ], + this.matcher = new QueryMatcher(SORTED_EMOJI, { + keys: ['emoji.emoticon'], + funcs: [o => o.emoji.shortcodes.map(s => `:${s}:`)], // For matching against ascii equivalents shouldMatchWordsOnly: false, }); - this.nameMatcher = new QueryMatcher(EMOJI_SHORTNAMES, { + this.nameMatcher = new QueryMatcher(SORTED_EMOJI, { keys: ['emoji.annotation'], // For removing punctuation shouldMatchWordsOnly: true, @@ -105,34 +100,33 @@ export default class EmojiProvider extends AutocompleteProvider { const sorters = []; // make sure that emoticons come first - sorters.push((c) => score(matchedString, c.emoji.emoticon || "")); + sorters.push(c => score(matchedString, c.emoji.emoticon || "")); - // then sort by score (Infinity if matchedString not in shortname) - sorters.push((c) => score(matchedString, c.shortname)); + // then sort by score (Infinity if matchedString not in shortcode) + sorters.push(c => score(matchedString, c.emoji.shortcodes[0])); // then sort by max score of all shortcodes, trim off the `:` - sorters.push((c) => Math.min(...c.emoji.shortcodes.map(s => score(matchedString.substring(1), s)))); - // If the matchedString is not empty, sort by length of shortname. Example: + sorters.push(c => Math.min( + ...c.emoji.shortcodes.map(s => score(matchedString.substring(1), s)), + )); + // If the matchedString is not empty, sort by length of shortcode. Example: // matchedString = ":bookmark" // completions = [":bookmark:", ":bookmark_tabs:", ...] if (matchedString.length > 1) { - sorters.push((c) => c.shortname.length); + sorters.push(c => c.emoji.shortcodes[0].length); } // Finally, sort by original ordering - sorters.push((c) => c._orderBy); + sorters.push(c => c._orderBy); completions = sortBy(uniq(completions), sorters); - completions = completions.map(({ shortname }) => { - const unicode = shortcodeToUnicode(shortname); - return { - completion: unicode, - component: ( - - { unicode } - - ), - range, - }; - }).slice(0, LIMIT); + completions = completions.map(c => ({ + completion: c.emoji.unicode, + component: ( + + { c.emoji.unicode } + + ), + range, + })).slice(0, LIMIT); } return completions; } diff --git a/src/autocomplete/NotifProvider.tsx b/src/autocomplete/NotifProvider.tsx index 1d42915ec9..31b834ccfe 100644 --- a/src/autocomplete/NotifProvider.tsx +++ b/src/autocomplete/NotifProvider.tsx @@ -21,8 +21,8 @@ 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 RoomAvatar from '../components/views/avatars/RoomAvatar'; const AT_ROOM_REGEX = /@\S*/g; @@ -40,8 +40,6 @@ export default class NotifProvider extends AutocompleteProvider { force = false, limit = -1, ): Promise { - const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); - const client = MatrixClientPeg.get(); if (!this.room.currentState.mayTriggerNotifOfType('room', client.credentials.userId)) return []; diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index 7865a76daa..37ddf2c387 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -28,7 +28,7 @@ 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"; +import SpaceStore from "../stores/SpaceStore"; const ROOM_REGEX = /\B#\S*/g; @@ -59,7 +59,8 @@ export default class RoomProvider extends AutocompleteProvider { const cli = MatrixClientPeg.get(); let rooms = cli.getVisibleRooms(); - if (SettingsStore.getValue("feature_spaces")) { + // if spaces are enabled then filter them out here as they get their own autocomplete provider + if (SpaceStore.spacesEnabled) { rooms = rooms.filter(r => !r.isSpaceRoom()); } diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 470e018e22..182743abb3 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -21,7 +21,6 @@ import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import { PillCompletion } from './Components'; -import * as sdk from '../index'; import QueryMatcher from './QueryMatcher'; import { sortBy } from 'lodash'; import { MatrixClientPeg } from '../MatrixClientPeg'; @@ -33,6 +32,7 @@ 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; @@ -108,10 +108,8 @@ export default class UserProvider extends AutocompleteProvider { force = false, limit = -1, ): Promise { - const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); - // lazy-load user list into matcher - if (!this.users) this._makeUsers(); + if (!this.users) this.makeUsers(); let completions = []; const { command, range } = this.getCurrentCommand(rawQuery, selection, force); @@ -149,7 +147,7 @@ export default class UserProvider extends AutocompleteProvider { return _t('Users'); } - _makeUsers() { + private makeUsers() { const events = this.room.getLiveTimeline().getEvents(); const lastSpoken = {}; diff --git a/src/components/structures/AutoHideScrollbar.tsx b/src/components/structures/AutoHideScrollbar.tsx index e8a9872b48..184d883dda 100644 --- a/src/components/structures/AutoHideScrollbar.tsx +++ b/src/components/structures/AutoHideScrollbar.tsx @@ -21,8 +21,8 @@ interface IProps extends Omit, "onScroll"> { className?: string; onScroll?: (event: Event) => void; onWheel?: (event: WheelEvent) => void; - style?: React.CSSProperties - tabIndex?: number, + style?: React.CSSProperties; + tabIndex?: number; wrappedRef?: (ref: HTMLDivElement) => void; } diff --git a/src/components/structures/CallEventGrouper.ts b/src/components/structures/CallEventGrouper.ts new file mode 100644 index 0000000000..384f20cd4e --- /dev/null +++ b/src/components/structures/CallEventGrouper.ts @@ -0,0 +1,145 @@ +/* +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 { EventType } from "matrix-js-sdk/src/@types/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import CallHandler, { CallHandlerEvent } from '../../CallHandler'; +import { EventEmitter } from 'events'; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import defaultDispatcher from "../../dispatcher/dispatcher"; + +export enum CallEventGrouperEvent { + StateChanged = "state_changed", + SilencedChanged = "silenced_changed", +} + +const SUPPORTED_STATES = [ + CallState.Connected, + CallState.Connecting, + CallState.Ringing, +]; + +export enum CustomCallState { + Missed = "missed", +} + +export default class CallEventGrouper extends EventEmitter { + private events: Set = new Set(); + private call: MatrixCall; + public state: CallState | CustomCallState; + + constructor() { + super(); + + CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.setCall); + CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged); + } + + private get invite(): MatrixEvent { + return [...this.events].find((event) => event.getType() === EventType.CallInvite); + } + + private get hangup(): MatrixEvent { + return [...this.events].find((event) => event.getType() === EventType.CallHangup); + } + + private get reject(): MatrixEvent { + return [...this.events].find((event) => event.getType() === EventType.CallReject); + } + + public get isVoice(): boolean { + const invite = this.invite; + if (!invite) return; + + // FIXME: Find a better way to determine this from the event? + if (invite.getContent()?.offer?.sdp?.indexOf('m=video') !== -1) return false; + return true; + } + + public get hangupReason(): string | null { + return this.hangup?.getContent()?.reason; + } + + /** + * Returns true if there are only events from the other side - we missed the call + */ + private get callWasMissed(): boolean { + return ![...this.events].some((event) => event.sender?.userId === MatrixClientPeg.get().getUserId()); + } + + private get callId(): string { + return [...this.events][0].getContent().call_id; + } + + private onSilencedCallsChanged = () => { + const newState = CallHandler.sharedInstance().isCallSilenced(this.callId); + this.emit(CallEventGrouperEvent.SilencedChanged, newState); + }; + + public answerCall = () => { + this.call?.answer(); + }; + + public rejectCall = () => { + this.call?.reject(); + }; + + public callBack = () => { + defaultDispatcher.dispatch({ + action: 'place_call', + type: this.isVoice ? CallType.Voice : CallType.Video, + room_id: [...this.events][0]?.getRoomId(), + }); + }; + + public toggleSilenced = () => { + const silenced = CallHandler.sharedInstance().isCallSilenced(this.callId); + silenced ? + CallHandler.sharedInstance().unSilenceCall(this.callId) : + CallHandler.sharedInstance().silenceCall(this.callId); + }; + + private setCallListeners() { + if (!this.call) return; + this.call.addListener(CallEvent.State, this.setState); + } + + private setState = () => { + if (SUPPORTED_STATES.includes(this.call?.state)) { + this.state = this.call.state; + } else { + if (this.callWasMissed) this.state = CustomCallState.Missed; + else if (this.reject) this.state = CallState.Ended; + else if (this.hangup) this.state = CallState.Ended; + else if (this.invite && this.call) this.state = CallState.Connecting; + } + this.emit(CallEventGrouperEvent.StateChanged, this.state); + }; + + private setCall = () => { + if (this.call) return; + + this.call = CallHandler.sharedInstance().getCallById(this.callId); + this.setCallListeners(); + this.setState(); + }; + + public add(event: MatrixEvent) { + this.events.add(event); + this.setCall(); + } +} diff --git a/src/components/structures/CustomRoomTagPanel.js b/src/components/structures/CustomRoomTagPanel.js index 037d7c251c..5e31048207 100644 --- a/src/components/structures/CustomRoomTagPanel.js +++ b/src/components/structures/CustomRoomTagPanel.js @@ -56,7 +56,7 @@ class CustomRoomTagPanel extends React.Component { return (
- {tags} + { tags }
); } @@ -84,7 +84,7 @@ class CustomRoomTagTile extends React.Component { "mx_TagTile_badge": true, "mx_TagTile_badgeHighlight": badgeNotifState.hasMentions, }); - badgeElement = (
{FormattingUtils.formatCount(badgeNotifState.count)}
); + badgeElement = (
{ FormattingUtils.formatCount(badgeNotifState.count) }
); } return ( diff --git a/src/components/structures/EmbeddedPage.js b/src/components/structures/EmbeddedPage.js index 628c16f322..472a43e142 100644 --- a/src/components/structures/EmbeddedPage.js +++ b/src/components/structures/EmbeddedPage.js @@ -125,11 +125,11 @@ export default class EmbeddedPage extends React.PureComponent { if (this.props.scrollbar) { return - {content} + { content } ; } else { return
- {content} + { content }
; } } diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 21ef0c4f31..c6d72d04bb 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -19,11 +19,11 @@ import React from 'react'; import { Filter } from 'matrix-js-sdk/src/filter'; import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; +import { Direction } from "matrix-js-sdk/src/models/event-timeline"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from 'matrix-js-sdk/src/models/room'; import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; -import * as sdk from '../../index'; import { MatrixClientPeg } from '../../MatrixClientPeg'; import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from '../../languageHandler'; @@ -33,11 +33,14 @@ import DesktopBuildsNotice, { WarningKind } from "../views/elements/DesktopBuild 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 + resizeNotifier: ResizeNotifier; } interface IState { @@ -129,7 +132,7 @@ class FilePanel extends React.Component { } } - public async fetchFileEventsServer(room: Room): Promise { + public async fetchFileEventsServer(room: Room): Promise { const client = MatrixClientPeg.get(); const filter = new Filter(client.credentials.userId); @@ -153,7 +156,11 @@ class FilePanel extends React.Component { return timelineSet; } - private onPaginationRequest = (timelineWindow: TimelineWindow, direction: string, limit: number): void => { + private onPaginationRequest = ( + timelineWindow: TimelineWindow, + direction: Direction, + limit: number, + ): Promise => { const client = MatrixClientPeg.get(); const eventIndex = EventIndexPeg.get(); const roomId = this.props.roomId; @@ -232,12 +239,10 @@ 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')}

-

{_t('Attach files from chat or just drag and drop them anywhere in a room.')}

+

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

+

{ _t('Attach files from chat or just drag and drop them anywhere in a room.') }

); const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId); @@ -257,9 +262,9 @@ class FilePanel extends React.Component { manageReadReceipts={false} manageReadMarkers={false} timelineSet={this.state.timelineSet} - showUrlPreview = {false} + showUrlPreview={false} onPaginationRequest={this.onPaginationRequest} - tileShape="file_grid" + tileShape={TileShape.FileGrid} resizeNotifier={this.props.resizeNotifier} empty={emptyState} /> @@ -272,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 c9ed4ae622..017d365273 100644 --- a/src/components/structures/GenericErrorPage.js +++ b/src/components/structures/GenericErrorPage.js @@ -28,8 +28,8 @@ export default class GenericErrorPage extends React.PureComponent { render() { return
-

{this.props.title}

-

{this.props.message}

+

{ this.props.title }

+

{ this.props.message }

; } diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 93c44c4e50..9d69fce801 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -36,7 +36,7 @@ import FlairStore from '../../stores/FlairStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import { makeGroupPermalink, makeUserPermalink } from "../../utils/permalinks/Permalinks"; import { Group } from "matrix-js-sdk/src/models/group"; -import { sleep } from "../../utils/promise"; +import { sleep } from "matrix-js-sdk/src/utils"; import RightPanelStore from "../../stores/RightPanelStore"; import AutoHideScrollbar from "./AutoHideScrollbar"; import { mediaFromMxc } from "../../customisations/Media"; @@ -819,12 +819,12 @@ export default class GroupView extends React.Component { let hostingSignup = null; if (hostingSignupLink && this.state.isUserPrivileged) { hostingSignup =
- {_t( + { _t( "Want more than a community? Get your own server", {}, { - a: sub => {sub}, + a: sub => { sub }, }, - )} + ) } diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index 046e07f455..4ed160d493 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -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 ; } diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 9ff830f66a..61ae1882df 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -54,7 +54,7 @@ export default class InteractiveAuthComponent extends React.Component { // * emailSid {string} If email auth was performed, the sid of // the auth session. // * clientSecret {string} The client secret used in auth - // sessions with the ID server. + // sessions with the identity server. onAuthFinished: PropTypes.func.isRequired, // Inputs provided by the user to the auth process diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 3d5e386b00..3bd2c68c6c 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -429,7 +429,7 @@ export default class LeftPanel extends React.Component { onSelectRoom={this.selectRoom} /> - {dialPadButton} + { dialPadButton } { leftLeftPanel = (
- {SettingsStore.getValue("feature_custom_tags") ? : null} + { SettingsStore.getValue("feature_custom_tags") ? : null }
); } @@ -476,11 +476,11 @@ export default class LeftPanel extends React.Component { return (
- {leftLeftPanel} + { leftLeftPanel }
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 26bb0fe24a..d496c4ad21 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -17,14 +17,13 @@ limitations under the License. */ import * as React from 'react'; -import * as PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; import MediaDeviceHandler from '../../MediaDeviceHandler'; import { fixupColorFonts } from '../../utils/FontManager'; -import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; import { IMatrixClientCreds } from '../../MatrixClientPeg'; import SettingsStore from "../../settings/SettingsStore"; @@ -48,7 +47,7 @@ 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'; @@ -59,6 +58,12 @@ import { replaceableComponent } from "../../utils/replaceableComponent"; import CallHandler, { CallHandlerEvent } from '../../CallHandler'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall'; +import RoomView from './RoomView'; +import ToastContainer from './ToastContainer'; +import MyGroups from "./MyGroups"; +import UserView from "./UserView"; +import GroupView from "./GroupView"; +import SpaceStore from "../../stores/SpaceStore"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -74,21 +79,23 @@ function canElementReceiveInput(el) { interface IProps { matrixClient: MatrixClient; + // Called with the credentials of a registered user (if they were a ROU that + // transitioned to PWLU) onRegistered: (credentials: IMatrixClientCreds) => Promise; 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; @@ -135,18 +142,6 @@ interface IState { class LoggedInView extends React.Component { static displayName = 'LoggedInView'; - static propTypes = { - matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, - page_type: PropTypes.string.isRequired, - onRoomCreated: PropTypes.func, - - // Called with the credentials of a registered user (if they were a ROU that - // transitioned to PWLU) - onRegistered: PropTypes.func, - - // and lots and lots of other stuff. - }; - protected readonly _matrixClient: MatrixClient; protected readonly _roomView: React.RefObject; protected readonly _resizeContainer: React.RefObject; @@ -176,10 +171,10 @@ class LoggedInView extends React.Component { } componentDidMount() { - document.addEventListener('keydown', this._onNativeKeyDown, false); + document.addEventListener('keydown', this.onNativeKeyDown, false); CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged); - this._updateServerNoticeEvents(); + this.updateServerNoticeEvents(); this._matrixClient.on("accountData", this.onAccountData); this._matrixClient.on("sync", this.onSync); @@ -195,13 +190,13 @@ class LoggedInView extends React.Component { "useCompactLayout", null, this.onCompactLayoutChanged, ); - this.resizer = this._createResizer(); + this.resizer = this.createResizer(); this.resizer.attach(); - this._loadResizerPreferences(); + this.loadResizerPreferences(); } componentWillUnmount() { - document.removeEventListener('keydown', this._onNativeKeyDown, false); + 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); @@ -216,37 +211,37 @@ class LoggedInView extends React.Component { }); }; - canResetTimelineInRoom = (roomId) => { + public canResetTimelineInRoom = (roomId: string) => { if (!this._roomView.current) { return true; } return this._roomView.current.canResetTimeline(); }; - _createResizer() { - let size; - let collapsed; + private createResizer() { + let panelSize; + let panelCollapsed; const collapseConfig: ICollapseConfig = { // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel toggleSize: 206 - 50, - onCollapsed: (_collapsed) => { - collapsed = _collapsed; - if (_collapsed) { + onCollapsed: (collapsed) => { + panelCollapsed = collapsed; + if (collapsed) { dis.dispatch({ action: "hide_left_panel" }); window.localStorage.setItem("mx_lhs_size", '0'); } else { dis.dispatch({ action: "show_left_panel" }); } }, - onResized: (_size) => { - size = _size; + onResized: (size) => { + panelSize = size; this.props.resizeNotifier.notifyLeftHandleResized(); }, onResizeStart: () => { this.props.resizeNotifier.startResizing(); }, onResizeStop: () => { - if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size); + if (!panelCollapsed) window.localStorage.setItem("mx_lhs_size", '' + panelSize); this.props.resizeNotifier.stopResizing(); }, isItemCollapsed: domNode => { @@ -262,7 +257,7 @@ class LoggedInView extends React.Component { return resizer; } - _loadResizerPreferences() { + private loadResizerPreferences() { let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size"), 10); if (isNaN(lhsSize)) { lhsSize = 350; @@ -270,7 +265,7 @@ class LoggedInView extends React.Component { this.resizer.forHandleAt(0).resize(lhsSize); } - onAccountData = (event) => { + private onAccountData = (event: MatrixEvent) => { if (event.getType() === "m.ignored_user_list") { dis.dispatch({ action: "ignore_state_changed" }); } @@ -302,16 +297,16 @@ class LoggedInView extends React.Component { } if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') { - this._updateServerNoticeEvents(); + this.updateServerNoticeEvents(); } else { - this._calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent); + this.calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent); } }; onRoomStateEvents = (ev, state) => { const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice]; if (serverNoticeList && serverNoticeList.some(r => r.roomId === ev.getRoomId())) { - this._updateServerNoticeEvents(); + this.updateServerNoticeEvents(); } }; @@ -321,7 +316,7 @@ class LoggedInView extends React.Component { }); }; - _calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) { + private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) { const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; if (error) { usageLimitEventContent = syncError.error.data; @@ -341,7 +336,7 @@ class LoggedInView extends React.Component { } } - _updateServerNoticeEvents = async () => { + private updateServerNoticeEvents = async () => { const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice]; if (!serverNoticeList) return []; @@ -373,7 +368,7 @@ class LoggedInView extends React.Component { ); }); const usageLimitEventContent = usageLimitEvent && usageLimitEvent.getContent(); - this._calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent); + this.calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent); this.setState({ usageLimitEventContent, usageLimitEventTs: pinnedEventTs, @@ -382,7 +377,7 @@ class LoggedInView extends React.Component { }); }; - _onPaste = (ev) => { + private onPaste = (ev) => { let canReceiveInput = false; let element = ev.target; // test for all parents because the target can be a child of a contenteditable element @@ -394,7 +389,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); } }; @@ -420,22 +415,22 @@ class LoggedInView extends React.Component { We also listen with a native listener on the document to get keydown events when no element is focused. Bubbling is irrelevant here as the target is the body element. */ - _onReactKeyDown = (ev) => { + private onReactKeyDown = (ev) => { // events caught while bubbling up on the root element // of this component, so something must be focused. - this._onKeyDown(ev); + this.onKeyDown(ev); }; - _onNativeKeyDown = (ev) => { + private onNativeKeyDown = (ev) => { // only pass this if there is no focused element. - // if there is, _onKeyDown will be called by the + // if there is, onKeyDown will be called by the // react keydown handler that respects the react bubbling order. if (ev.target === document.body) { - this._onKeyDown(ev); + this.onKeyDown(ev); } }; - _onKeyDown = (ev) => { + private onKeyDown = (ev) => { let handled = false; const roomAction = getKeyBindingsManager().getRoomAction(ev); @@ -445,7 +440,7 @@ class LoggedInView extends React.Component { case RoomAction.JumpToFirstMessage: case RoomAction.JumpToLatestMessage: // pass the event down to the scroll panel - this._onScrollKeyPressed(ev); + this.onScrollKeyPressed(ev); handled = true; break; case RoomAction.FocusSearch: @@ -548,7 +543,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 @@ -560,19 +555,13 @@ class LoggedInView extends React.Component { * dispatch a page-up/page-down/etc to the appropriate component * @param {Object} ev The key event */ - _onScrollKeyPressed = (ev) => { + private onScrollKeyPressed = (ev) => { if (this._roomView.current) { this._roomView.current.handleScrollKey(ev); } }; 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) { @@ -626,14 +615,14 @@ class LoggedInView extends React.Component { return (
- { SettingsStore.getValue("feature_spaces") ? : null } + { SpaceStore.spacesEnabled ? : null } { - {audioFeedArraysForCalls} + { audioFeedArraysForCalls } ); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index c1e0b8d7cb..8cfe35c4cf 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, QueryDict } 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,7 +36,6 @@ import dis from "../../dispatcher/dispatcher"; import Notifier from '../../Notifier'; import Modal from "../../Modal"; -import * as sdk from '../../index'; import { showRoomInviteDialog, showStartChatInviteDialog } from '../../RoomInvite'; import * as Rooms from '../../Rooms'; import linkifyMatrix from "../../linkify-matrix"; @@ -55,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"; @@ -84,9 +84,29 @@ import RoomListStore from "../../stores/room-list/RoomListStore"; import { RoomUpdateCause } from "../../stores/room-list/models"; import defaultDispatcher from "../../dispatcher/dispatcher"; import SecurityCustomisations from "../../customisations/Security"; +import Spinner from "../views/elements/Spinner"; +import QuestionDialog from "../views/dialogs/QuestionDialog"; +import UserSettingsDialog from '../views/dialogs/UserSettingsDialog'; +import CreateGroupDialog from '../views/dialogs/CreateGroupDialog'; +import CreateRoomDialog from '../views/dialogs/CreateRoomDialog'; +import RoomDirectory from './RoomDirectory'; +import KeySignatureUploadFailedDialog from "../views/dialogs/KeySignatureUploadFailedDialog"; +import IncomingSasDialog from "../views/dialogs/IncomingSasDialog"; +import CompleteSecurity from "./auth/CompleteSecurity"; +import LoggedInView from './LoggedInView'; +import Welcome from "../views/auth/Welcome"; +import ForgotPassword from "./auth/ForgotPassword"; +import E2eSetup from "./auth/E2eSetup"; +import Registration from './auth/Registration'; +import Login from "./auth/Login"; +import ErrorBoundary from '../views/elements/ErrorBoundary'; +import VerificationRequestToast from '../views/toasts/VerificationRequestToast'; import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; import UIStore, { UI_EVENTS } from "../../stores/UIStore"; +import SoftLogout from './auth/SoftLogout'; +import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; +import { copyPlaintext } from "../../utils/strings"; /** constants for MatrixChat.state.view */ export enum Views { @@ -135,7 +155,7 @@ const ONBOARDING_FLOW_STARTERS = [ interface IScreen { screen: string; - params?: object; + params?: QueryDict; } /* eslint-disable camelcase */ @@ -155,14 +175,19 @@ 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; // the queryParams extracted from the [real] query-string of the URI - realQueryParams?: Record; + realQueryParams?: QueryDict; // the initial queryParams extracted from the hash-fragment of the URI - startingFragmentQueryParams?: Record; + startingFragmentQueryParams?: QueryDict; // called when we have completed a token login onTokenLoginCompleted?: () => void; // Represents the screen to display as a result of parsing the initial window.location @@ -170,7 +195,7 @@ interface IProps { // TODO type things better // displayname, if any, to set on the device when logging in/registering. defaultDeviceDisplayName?: string; // A function that makes a registration URL - makeRegistrationUrl: (object) => string; + makeRegistrationUrl: (params: QueryDict) => string; } interface IState { @@ -203,7 +228,7 @@ interface IState { resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; ready: boolean; - threepidInvite?: IThreepidInvite, + threepidInvite?: IThreepidInvite; roomOobData?: object; pendingInitialSync?: boolean; justRegistered?: boolean; @@ -228,7 +253,7 @@ export default class MatrixChat extends React.PureComponent { private pageChanging: boolean; private tokenLogin?: boolean; private accountPassword?: string; - private accountPasswordTimer?: NodeJS.Timeout; + private accountPasswordTimer?: number; private focusComposer: boolean; private subTitleStatus: string; private prevWindowWidth: number; @@ -273,7 +298,7 @@ export default class MatrixChat extends React.PureComponent { if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) { // probably a threepid invite - try to store it const roomId = this.screenAfterLogin.screen.substring("room/".length); - ThreepidInviteStore.instance.storeInvite(roomId, params as IThreepidInviteWireFormat); + ThreepidInviteStore.instance.storeInvite(roomId, params as unknown as IThreepidInviteWireFormat); } } @@ -406,7 +431,7 @@ export default class MatrixChat extends React.PureComponent { } // TODO: [REACT-WARNING] Replace with appropriate lifecycle stage - // eslint-disable-next-line camelcase + // eslint-disable-next-line UNSAFE_componentWillUpdate(props, state) { if (this.shouldTrackPageChange(this.state, state)) { this.startPageChangeTimer(); @@ -420,7 +445,7 @@ export default class MatrixChat extends React.PureComponent { CountlyAnalytics.instance.trackPageChange(durationMs); } if (this.focusComposer) { - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); this.focusComposer = false; } } @@ -518,7 +543,6 @@ export default class MatrixChat extends React.PureComponent { onAction = (payload) => { // console.log(`MatrixClientPeg.onAction: ${payload.action}`); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); // Start the onboarding process for certain actions if (MatrixClientPeg.get() && MatrixClientPeg.get().isGuest() && @@ -539,7 +563,7 @@ export default class MatrixChat extends React.PureComponent { switch (payload.action) { case 'MatrixActions.accountData': // XXX: This is a collection of several hacks to solve a minor problem. We want to - // update our local state when the ID server changes, but don't want to put that in + // update our local state when the identity server changes, but don't want to put that in // the js-sdk as we'd be then dictating how all consumers need to behave. However, // this component is already bloated and we probably don't want this tiny logic in // here, but there's no better place in the react-sdk for it. Additionally, we're @@ -605,6 +629,9 @@ export default class MatrixChat extends React.PureComponent { case 'forget_room': this.forgetRoom(payload.room_id); break; + case 'copy_room': + this.copyRoom(payload.room_id); + break; case 'reject_invite': Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, { title: _t('Reject invitation'), @@ -612,8 +639,7 @@ 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(); @@ -649,7 +675,6 @@ 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 }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); @@ -662,11 +687,12 @@ export default class MatrixChat extends React.PureComponent { 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: { @@ -676,7 +702,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); @@ -1018,7 +1043,6 @@ export default class MatrixChat extends React.PureComponent { } } - const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic, defaultName, @@ -1080,7 +1104,7 @@ export default class MatrixChat extends React.PureComponent { private leaveRoomWarnings(roomId: string) { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); - const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); + const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom(); // Show a warning if there are additional complications. const warnings = []; @@ -1088,7 +1112,7 @@ export default class MatrixChat extends React.PureComponent { if (memberCount === 1) { warnings.push(( - {' '/* Whitespace, otherwise the sentences get smashed together */ } + { ' '/* Whitespace, otherwise the sentences get smashed together */ } { _t("You are the only person here. " + "If you leave, no one will be able to join in the future, including you.") } @@ -1103,7 +1127,7 @@ export default class MatrixChat extends React.PureComponent { if (rule !== "public") { warnings.push(( - {' '/* Whitespace, otherwise the sentences get smashed together */ } + { ' '/* Whitespace, otherwise the sentences get smashed together */ } { isSpace ? _t("This space is not public. You will not be able to rejoin without an invite.") : _t("This room is not public. You will not be able to rejoin without an invite.") } @@ -1115,11 +1139,10 @@ 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 = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); + const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom(); Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { title: isSpace ? _t("Leave space") : _t("Leave room"), description: ( @@ -1132,7 +1155,7 @@ export default class MatrixChat extends React.PureComponent { : _t( "Are you sure you want to leave the room '%(roomName)s'?", { roomName: roomToLeave.name }, - )} + ) } { warnings } ), @@ -1142,8 +1165,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({ @@ -1176,6 +1198,17 @@ export default class MatrixChat extends React.PureComponent { }); } + private async copyRoom(roomId: string) { + const roomLink = makeRoomPermalink(roomId); + const success = await copyPlaintext(roomLink); + if (!success) { + Modal.createTrackedDialog("Unable to copy room link", "", ErrorDialog, { + title: _t("Unable to copy room link"), + description: _t("Unable to copy a link to the room to the clipboard."), + }); + } + } + /** * Starts a chat with the welcome user, if the user doesn't already have one * @returns {string} The room ID of the new room, or null if no room was created @@ -1410,7 +1443,7 @@ export default class MatrixChat extends React.PureComponent { showNotificationsToast(false); } - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); this.setState({ ready: true, }); @@ -1438,7 +1471,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:
@@ -1547,8 +1579,6 @@ export default class MatrixChat extends React.PureComponent { }); cli.on("crypto.keySignatureUploadFailure", (failures, source, continuation) => { - const KeySignatureUploadFailedDialog = - sdk.getComponent('views.dialogs.KeySignatureUploadFailedDialog'); Modal.createTrackedDialog( 'Failed to upload key signatures', 'Failed to upload key signatures', @@ -1558,7 +1588,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); @@ -1568,7 +1597,7 @@ export default class MatrixChat extends React.PureComponent { title: _t("Verification requested"), icon: "verification", props: { request }, - component: sdk.getComponent("toasts.VerificationRequestToast"), + component: VerificationRequestToast, priority: 90, }); } @@ -1674,7 +1703,7 @@ 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")) { + if (SpaceStore.spacesEnabled) { dis.dispatch({ action: "view_home_page" }); return; } @@ -1761,7 +1790,7 @@ export default class MatrixChat extends React.PureComponent { subAction: params.action, }); } else if (screen.indexOf('group/') === 0) { - if (SettingsStore.getValue("feature_spaces")) { + if (SpaceStore.spacesEnabled) { dis.dispatch({ action: "view_home_page" }); return; } @@ -1835,13 +1864,6 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: 'timeline_resize' }); } - onRoomCreated(roomId: string) { - dis.dispatch({ - action: "view_room", - room_id: roomId, - }); - } - onRegisterClick = () => { this.showScreen("register"); }; @@ -1923,7 +1945,7 @@ export default class MatrixChat extends React.PureComponent { this.setState({ serverConfig }); }; - private makeRegistrationUrl = (params: {[key: string]: string}) => { + private makeRegistrationUrl = (params: QueryDict) => { if (this.props.startingFragmentQueryParams.referrer) { params.referrer = this.props.startingFragmentQueryParams.referrer; } @@ -1976,21 +1998,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 = ( ); } 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 =
- {messageForSyncError(this.state.syncError)} + { messageForSyncError(this.state.syncError) }
; } view = (
- {errorBox} + { errorBox } - {_t('Logout')} + { _t('Logout') }
); } } 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} + defaultUsername={this.props.startingFragmentQueryParams.defaultUsername as string} {...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} + { view } ; } } diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index a0a1ac9b10..39ede68a75 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -36,6 +36,7 @@ import DMRoomMap from "../../utils/DMRoomMap"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; import { replaceableComponent } from "../../utils/replaceableComponent"; import defaultDispatcher from '../../dispatcher/dispatcher'; +import CallEventGrouper from "./CallEventGrouper"; import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile'; import ScrollPanel, { IScrollState } from "./ScrollPanel"; import EventListSummary from '../views/elements/EventListSummary'; @@ -54,7 +55,11 @@ const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, E // 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: MatrixEvent, mxEvent: MatrixEvent): boolean { +function shouldFormContinuation( + prevEvent: MatrixEvent, + mxEvent: MatrixEvent, + showHiddenEvents: boolean, +): boolean { // sanity check inputs if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false; // check if within the max continuation period @@ -74,7 +79,7 @@ function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): b mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false; // if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile - if (!haveTileForEvent(prevEvent)) return false; + if (!haveTileForEvent(prevEvent, showHiddenEvents)) return false; return true; } @@ -228,6 +233,11 @@ export default class MessagePanel extends React.Component { private readonly showTypingNotificationsWatcherRef: string; private eventNodes: Record; + // A map of + private callEventGroupers = new Map(); + + private membersCount = 0; + constructor(props, context) { super(props, context); @@ -239,7 +249,8 @@ export default class MessagePanel extends React.Component { }; // Cache hidden events setting on mount since Settings is expensive to - // query, and we check this in a hot code path. + // query, and we check this in a hot code path. This is also cached in + // our RoomContext, however we still need a fallback for roomless MessagePanels. this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline"); this.showTypingNotificationsWatcherRef = @@ -247,11 +258,14 @@ export default class MessagePanel extends React.Component { } componentDidMount() { + this.calculateRoomMembersCount(); + this.props.room?.on("RoomState.members", this.calculateRoomMembersCount); this.isMounted = true; } componentWillUnmount() { this.isMounted = false; + this.props.room?.off("RoomState.members", this.calculateRoomMembersCount); SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef); } @@ -265,6 +279,10 @@ export default class MessagePanel extends React.Component { } } + private calculateRoomMembersCount = (): void => { + this.membersCount = this.props.room?.getMembers().length || 0; + }; + private onShowTypingNotificationsChange = (): void => { this.setState({ showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), @@ -399,17 +417,21 @@ export default class MessagePanel extends React.Component { return !this.isMounted; }; + private get showHiddenEvents(): boolean { + return this.context?.showHiddenEventsInTimeline ?? this.showHiddenEventsInTimeline; + } + // TODO: Implement granular (per-room) hide options public shouldShowEvent(mxEv: MatrixEvent): boolean { - if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) { + if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender())) { return false; // ignored = no show (only happens if the ignore happens after an event was received) } - if (this.showHiddenEventsInTimeline) { + if (this.showHiddenEvents) { return true; } - if (!haveTileForEvent(mxEv)) { + if (!haveTileForEvent(mxEv, this.showHiddenEvents)) { return false; // no tile = no show } @@ -567,9 +589,23 @@ export default class MessagePanel extends React.Component { const last = (mxEv === lastShownEvent); const { nextEvent, nextTile } = this.getNextEventInfo(this.props.events, i); + if ( + mxEv.getType().indexOf("m.call.") === 0 || + mxEv.getType().indexOf("org.matrix.call.") === 0 + ) { + const callId = mxEv.getContent().call_id; + if (this.callEventGroupers.has(callId)) { + this.callEventGroupers.get(callId).add(mxEv); + } else { + const callEventGrouper = new CallEventGrouper(); + callEventGrouper.add(mxEv); + this.callEventGroupers.set(callId, callEventGrouper); + } + } + if (grouper) { if (grouper.shouldGroup(mxEv)) { - grouper.add(mxEv); + grouper.add(mxEv, this.showHiddenEvents); continue; } else { // not part of group, so get the group tiles, close the @@ -644,12 +680,15 @@ export default class MessagePanel extends React.Component { } let willWantDateSeparator = false; + let lastInSection = true; if (nextEvent) { willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date()); + lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEvent.getSender(); } // is this a continuation of the previous message? - const continuation = !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv); + const continuation = !wantsDateSeparator && + shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents); const eventId = mxEv.getId(); const highlight = (eventId === this.props.highlightedEventId); @@ -680,6 +719,7 @@ export default class MessagePanel extends React.Component { // it's successful: we received it. isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId(); + const callEventGrouper = this.callEventGroupers.get(mxEv.getContent().call_id); // use txnId as key if available so that we don't remount during sending ret.push( @@ -702,7 +742,7 @@ export default class MessagePanel extends React.Component { isTwelveHour={this.props.isTwelveHour} permalinkCreator={this.props.permalinkCreator} last={last} - lastInSection={willWantDateSeparator} + lastInSection={lastInSection} lastSuccessful={isLastSuccessful} isSelectedEvent={highlight} getRelationsForEvent={this.props.getRelationsForEvent} @@ -710,6 +750,8 @@ export default class MessagePanel extends React.Component { layout={this.props.layout} enableFlair={this.props.enableFlair} showReadReceipts={this.props.showReadReceipts} + callEventGrouper={callEventGrouper} + hideSender={this.membersCount <= 2 && this.props.layout === Layout.Bubble} /> , ); @@ -946,7 +988,7 @@ abstract class BaseGrouper { } public abstract shouldGroup(ev: MatrixEvent): boolean; - public abstract add(ev: MatrixEvent): void; + public abstract add(ev: MatrixEvent, showHiddenEvents?: boolean): void; public abstract getTiles(): ReactNode[]; public abstract getNewPrevEvent(): MatrixEvent; } @@ -1200,10 +1242,10 @@ class MemberGrouper extends BaseGrouper { return membershipTypes.includes(ev.getType() as EventType); } - public add(ev: MatrixEvent): void { + public add(ev: MatrixEvent, showHiddenEvents?: boolean): void { if (ev.getType() === EventType.RoomMember) { // We can ignore any events that don't actually have a message to display - if (!hasText(ev)) return; + if (!hasText(ev, showHiddenEvents)) return; } this.readMarker = this.readMarker || this.panel.readMarkerForEvent( ev.getId(), diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 87447b6aba..fca5613ede 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -121,7 +121,7 @@ export default class MyGroups extends React.Component { ) }
- {/*
+ { /*
@@ -137,7 +137,7 @@ export default class MyGroups extends React.Component { { 'i': (sub) => { sub } }) }
-
*/} +
*/ }
diff --git a/src/components/structures/NonUrgentToastContainer.tsx b/src/components/structures/NonUrgentToastContainer.tsx index b1424a2974..6e914c40fb 100644 --- a/src/components/structures/NonUrgentToastContainer.tsx +++ b/src/components/structures/NonUrgentToastContainer.tsx @@ -24,7 +24,7 @@ interface IProps { } interface IState { - toasts: ComponentClass[], + toasts: ComponentClass[]; } @replaceableComponent("structures.NonUrgentToastContainer") @@ -51,14 +51,14 @@ export default class NonUrgentToastContainer extends React.PureComponent { return (
- {React.createElement(t, {})} + { React.createElement(t, {}) }
); }); return (
- {toasts} + { toasts }
); } diff --git a/src/components/structures/NotificationPanel.tsx b/src/components/structures/NotificationPanel.tsx index 8c8fab7ece..8abc161bab 100644 --- a/src/components/structures/NotificationPanel.tsx +++ b/src/components/structures/NotificationPanel.tsx @@ -35,8 +35,8 @@ interface IProps { export default class NotificationPanel extends React.PureComponent { render() { const emptyState = (
-

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

-

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

+

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

+

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

); let content; diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index c608f0eee9..95d70e913a 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -17,13 +17,13 @@ limitations under the License. import React from 'react'; import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomState } from "matrix-js-sdk/src/models/room-state"; 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 dis from '../../dispatcher/dispatcher'; -import RateLimitedFunc from '../../ratelimitedfunc'; import GroupStore from '../../stores/GroupStore'; import { RIGHT_PANEL_PHASES_NO_ARGS, @@ -48,6 +48,8 @@ import FilePanel from "./FilePanel"; import NotificationPanel from "./NotificationPanel"; import ResizeNotifier from "../../utils/ResizeNotifier"; import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard"; +import { throttle } from 'lodash'; +import SpaceStore from "../../stores/SpaceStore"; interface IProps { room?: Room; // if showing panels for a given room, this is set @@ -73,7 +75,6 @@ interface IState { export default class RightPanel extends React.Component { static contextType = MatrixClientContext; - private readonly delayedUpdate: RateLimitedFunc; private dispatcherRef: string; constructor(props, context) { @@ -84,12 +85,12 @@ export default class RightPanel extends React.Component { isUserPrivilegedInGroup: null, member: this.getUserForPanel(), }; - - this.delayedUpdate = new RateLimitedFunc(() => { - this.forceUpdate(); - }, 500); } + private readonly delayedUpdate = throttle((): void => { + this.forceUpdate(); + }, 500, { leading: true, trailing: true }); + // Helper function to split out the logic for getPhaseFromProps() and the constructor // as both are called at the same time in the constructor. private getUserForPanel() { @@ -108,7 +109,7 @@ export default class RightPanel extends React.Component { return RightPanelPhases.GroupMemberList; } return rps.groupPanelPhase; - } else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom() + } else if (SpaceStore.spacesEnabled && this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase) ) { return RightPanelPhases.SpaceMemberList; @@ -152,7 +153,7 @@ export default class RightPanel extends React.Component { } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase + UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line if (newProps.groupId !== this.props.groupId) { this.unregisterGroupStore(); this.initGroupStore(newProps.groupId); @@ -174,7 +175,7 @@ export default class RightPanel extends React.Component { }); }; - private onRoomStateMember = (ev: MatrixEvent, _, member: RoomMember) => { + private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember) => { if (!this.props.room || member.roomId !== this.props.room.roomId) { return; } diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx index 2ac990436f..84e8de8221 100644 --- a/src/components/structures/RoomDirectory.tsx +++ b/src/components/structures/RoomDirectory.tsx @@ -16,6 +16,9 @@ limitations under the License. */ import React from "react"; +import { IFieldType, IInstance, IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client"; +import { Visibility } from "matrix-js-sdk/src/@types/partials"; +import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import dis from "../../dispatcher/dispatcher"; @@ -25,7 +28,7 @@ import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; -import { ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols } from "../views/directory/NetworkDropdown"; +import NetworkDropdown, { ALL_ROOMS, Protocols } from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupStore from "../../stores/GroupStore"; @@ -40,14 +43,17 @@ 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; +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); } @@ -57,46 +63,23 @@ interface IProps extends IDialogProps { } interface IState { - publicRooms: IRoom[]; + publicRooms: IPublicRoomsChunkRoom[]; loading: boolean; protocolsLoading: boolean; error?: string; - instanceId: string | symbol; + 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 { private readonly startTime: number; private unmounted = false; private nextBatch: string = null; - private filterTimeout: NodeJS.Timeout; + private filterTimeout: number; private protocols: Protocols; constructor(props) { @@ -116,6 +99,36 @@ export default class RoomDirectory extends React.Component { } else if (!selectedCommunityId) { MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { this.protocols = response; + 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}`); @@ -150,8 +163,8 @@ export default class RoomDirectory extends React.Component { publicRooms: [], loading: true, error: null, - instanceId: undefined, - roomServer: MatrixClientPeg.getHomeserverName(), + instanceId: localStorage.getItem(LAST_INSTANCE_KEY), + roomServer: localStorage.getItem(LAST_SERVER_KEY), filterString: this.props.initialText || "", selectedCommunityId, communityName: null, @@ -219,7 +232,7 @@ export default class RoomDirectory extends React.Component { // remember the next batch token when we sent the request // too. If it's changed, appending to the list will corrupt it. const nextBatch = this.nextBatch; - const opts: IPublicRoomsRequest = { limit: 20 }; + const opts: IRoomDirectoryOptions = { limit: 20 }; if (roomServer != MatrixClientPeg.getHomeserverName()) { opts.server = roomServer; } @@ -292,7 +305,7 @@ export default class RoomDirectory extends React.Component { * HS admins to do this through the RoomSettings interface, but * this needs SPEC-417. */ - private removeFromDirectory(room: IRoom) { + private removeFromDirectory(room: IPublicRoomsChunkRoom) { const alias = getDisplayAliasForRoom(room); const name = room.name || alias || _t('Unnamed room'); @@ -312,7 +325,7 @@ export default class RoomDirectory extends React.Component { const modal = Modal.createDialog(Spinner); let step = _t('remove %(name)s from the directory.', { name: name }); - MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => { + MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, Visibility.Private).then(() => { if (!alias) return; step = _t('delete the address.'); return MatrixClientPeg.get().deleteAlias(alias); @@ -334,7 +347,7 @@ export default class RoomDirectory extends React.Component { }); } - private onRoomClicked = (room: IRoom, ev: ButtonEvent) => { + private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => { // If room was shift-clicked, remove it from the room directory if (ev.shiftKey && !this.state.selectedCommunityId) { ev.preventDefault(); @@ -342,7 +355,7 @@ export default class RoomDirectory extends React.Component { } }; - private onOptionChange = (server: string, instanceId?: string | symbol) => { + private onOptionChange = (server: string, instanceId?: string) => { // clear next batch so we don't try to load more rooms this.nextBatch = null; this.setState({ @@ -360,6 +373,14 @@ 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); + } }; private onFillRequest = (backwards: boolean) => { @@ -370,7 +391,7 @@ export default class RoomDirectory extends React.Component { private onFilterChange = (alias: string) => { this.setState({ - filterString: alias || null, + filterString: alias || "", }); // don't send the request for a little bit, @@ -389,7 +410,7 @@ export default class RoomDirectory extends React.Component { private onFilterClear = () => { // update immediately this.setState({ - filterString: null, + filterString: "", }, this.refreshRoomList); if (this.filterTimeout) { @@ -439,17 +460,17 @@ export default class RoomDirectory extends React.Component { } }; - private onPreviewClick = (ev: ButtonEvent, room: IRoom) => { + private onPreviewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => { this.showRoom(room, null, false, true); ev.stopPropagation(); }; - private onViewClick = (ev: ButtonEvent, room: IRoom) => { + private onViewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => { this.showRoom(room); ev.stopPropagation(); }; - private onJoinClick = (ev: ButtonEvent, room: IRoom) => { + private onJoinClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => { this.showRoom(room, null, true); ev.stopPropagation(); }; @@ -467,7 +488,7 @@ export default class RoomDirectory extends React.Component { this.showRoom(null, alias, autoJoin); } - private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) { + private showRoom(room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) { this.onFinished(); const payload: ActionPayload = { action: 'view_room', @@ -516,7 +537,7 @@ export default class RoomDirectory extends React.Component { dis.dispatch(payload); } - private createRoomCells(room: IRoom) { + private createRoomCells(room: IPublicRoomsChunkRoom) { const client = MatrixClientPeg.get(); const clientRoom = client.getRoom(room.room_id); const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join"; @@ -568,7 +589,7 @@ export default class RoomDirectory extends React.Component { // We use onMouseDown instead of onClick, so that we can avoid text getting selected return [
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_roomAvatar" > @@ -582,7 +603,7 @@ export default class RoomDirectory extends React.Component { />
,
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_roomDescription" > @@ -605,14 +626,14 @@ export default class RoomDirectory extends React.Component {
,
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_roomMemberCount" > { room.num_joined_members }
,
this.onRoomClicked(room, ev)} // cancel onMouseDown otherwise shift-clicking highlights text className="mx_RoomDirectory_preview" @@ -620,7 +641,7 @@ export default class RoomDirectory extends React.Component { { previewButton }
,
this.onRoomClicked(room, ev)} className="mx_RoomDirectory_join" > @@ -775,7 +796,7 @@ export default class RoomDirectory extends React.Component { showJoinButton={showJoinButton} initialText={this.props.initialText} /> - {dropdown} + { dropdown }
; } const explanation = @@ -793,16 +814,16 @@ export default class RoomDirectory extends React.Component { }) : _t("Explore rooms"); return (
- {explanation} + { explanation }
- {listHeader} - {content} + { listHeader } + { content }
@@ -812,6 +833,6 @@ export default class RoomDirectory extends React.Component { // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // but works with the objects we get from the public room list -function getDisplayAliasForRoom(room: IRoom) { - return room.canonical_alias || room.aliases?.[0] || ""; +function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) { + return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases); } diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 9cdd1efe7e..9acfb7bb8e 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -131,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: @@ -209,9 +209,9 @@ export default class RoomSearch extends React.PureComponent { return (
- {icon} - {input} - {clearButton} + { icon } + { input } + { clearButton }
); } diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index f6e42a4f9c..ac4d197346 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -118,12 +118,12 @@ export default class RoomStatusBar extends React.PureComponent { this.setState({ isResending: false }); }); this.setState({ isResending: true }); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); }; _onCancelAllClick = () => { Resend.cancelUnsentEvents(this.props.room); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); }; _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { @@ -222,17 +222,17 @@ export default class RoomStatusBar extends React.PureComponent { let buttonRow = <> - {_t("Delete all")} + { _t("Delete all") } - {_t("Retry all")} + { _t("Retry all") } ; if (this.state.isResending) { buttonRow = <> - {/* span for css */} - {_t("Sending")} + { /* span for css */ } + { _t("Sending") } ; } @@ -253,7 +253,7 @@ export default class RoomStatusBar extends React.PureComponent {
- {buttonRow} + { buttonRow }
@@ -270,10 +270,10 @@ export default class RoomStatusBar extends React.PureComponent { height="24" title="/!\ " alt="/!\ " />
- {_t('Connectivity to the server has been lost.')} + { _t('Connectivity to the server has been lost.') }
- {_t('Sent messages will be stored until your connection has returned.')} + { _t('Sent messages will be stored until your connection has returned.') }
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 61685e7ba8..1eb958fa6c 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -25,8 +25,8 @@ import React, { createRef } from 'react'; import classNames from 'classnames'; 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 { ISearchResults } from 'matrix-js-sdk/src/@types/search'; import shouldHideEvent from '../../shouldHideEvent'; import { _t } from '../../languageHandler'; @@ -34,16 +34,14 @@ 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 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"; @@ -64,7 +62,7 @@ import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import AuxPanel from "../views/rooms/AuxPanel"; import RoomHeader from "../views/rooms/RoomHeader"; import { XOR } from "../../@types/common"; -import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from '../../effects/utils'; import { CHAT_EFFECTS } from '../../effects'; @@ -82,6 +80,16 @@ import { IOpts } from "../../createRoom"; import { replaceableComponent } from "../../utils/replaceableComponent"; import UIStore from "../../stores/UIStore"; import EditorStateTransfer from "../../utils/EditorStateTransfer"; +import { throttle } from "lodash"; +import ErrorDialog from '../views/dialogs/ErrorDialog'; +import SearchResultTile from '../views/rooms/SearchResultTile'; +import Spinner from "../views/elements/Spinner"; +import UploadBar from './UploadBar'; +import RoomStatusBar from "./RoomStatusBar"; +import MessageComposer from '../views/rooms/MessageComposer'; +import JumpToBottomButton from "../views/rooms/JumpToBottomButton"; +import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; +import SpaceStore from "../../stores/SpaceStore"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -94,22 +102,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; @@ -140,12 +134,7 @@ export interface IState { searching: boolean; searchTerm?: string; searchScope?: SearchScope; - searchResults?: XOR<{}, { - count: number; - highlights: string[]; - results: SearchResult[]; - next_batch: string; // eslint-disable-line camelcase - }>; + searchResults?: XOR<{}, ISearchResults>; searchHighlights?: string[]; searchInProgress?: boolean; callState?: CallState; @@ -177,6 +166,11 @@ export interface IState { canReply: boolean; layout: Layout; lowBandwidth: boolean; + alwaysShowTimestamps: boolean; + showTwelveHourTimestamps: boolean; + readMarkerInViewThresholdMs: number; + readMarkerOutOfViewThresholdMs: number; + showHiddenEventsInTimeline: boolean; showReadReceipts: boolean; showRedactions: boolean; showJoinLeaves: boolean; @@ -241,6 +235,11 @@ export default class RoomView extends React.Component { canReply: false, layout: SettingsStore.getValue("layout"), lowBandwidth: SettingsStore.getValue("lowBandwidth"), + alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"), + showTwelveHourTimestamps: SettingsStore.getValue("showTwelveHourTimestamps"), + readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"), + readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"), + showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"), showReadReceipts: true, showRedactions: true, showJoinLeaves: true, @@ -264,7 +263,6 @@ export default class RoomView extends React.Component { this.context.on("userTrustStatusChanged", this.onUserVerificationChanged); this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged); this.context.on("Event.decrypted", this.onEventDecrypted); - this.context.on("event", this.onEvent); // Start listening for RoomViewStore updates this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); @@ -273,11 +271,26 @@ export default class RoomView extends React.Component { WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); this.settingWatchers = [ - SettingsStore.watchSetting("layout", null, () => - this.setState({ layout: SettingsStore.getValue("layout") }), + SettingsStore.watchSetting("layout", null, (...[,,, value]) => + this.setState({ layout: value as Layout }), ), - SettingsStore.watchSetting("lowBandwidth", null, () => - this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }), + SettingsStore.watchSetting("lowBandwidth", null, (...[,,, value]) => + this.setState({ lowBandwidth: value as boolean }), + ), + SettingsStore.watchSetting("alwaysShowTimestamps", null, (...[,,, value]) => + this.setState({ alwaysShowTimestamps: value as boolean }), + ), + SettingsStore.watchSetting("showTwelveHourTimestamps", null, (...[,,, value]) => + this.setState({ showTwelveHourTimestamps: value as boolean }), + ), + SettingsStore.watchSetting("readMarkerInViewThresholdMs", null, (...[,,, value]) => + this.setState({ readMarkerInViewThresholdMs: value as number }), + ), + SettingsStore.watchSetting("readMarkerOutOfViewThresholdMs", null, (...[,,, value]) => + this.setState({ readMarkerOutOfViewThresholdMs: value as number }), + ), + SettingsStore.watchSetting("showHiddenEventsInTimeline", null, (...[,,, value]) => + this.setState({ showHiddenEventsInTimeline: value as boolean }), ), ]; } @@ -344,30 +357,20 @@ export default class RoomView extends React.Component { // 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("showReadReceipts", roomId, (...[,,, value]) => + this.setState({ showReadReceipts: value as boolean }), ), - SettingsStore.watchSetting("showRedactions", null, () => - this.setState({ - showRedactions: SettingsStore.getValue("showRedactions", roomId), - }), + SettingsStore.watchSetting("showRedactions", roomId, (...[,,, value]) => + this.setState({ showRedactions: value as boolean }), ), - SettingsStore.watchSetting("showJoinLeaves", null, () => - this.setState({ - showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), - }), + SettingsStore.watchSetting("showJoinLeaves", roomId, (...[,,, value]) => + this.setState({ showJoinLeaves: value as boolean }), ), - SettingsStore.watchSetting("showAvatarChanges", null, () => - this.setState({ - showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), - }), + SettingsStore.watchSetting("showAvatarChanges", roomId, (...[,,, value]) => + this.setState({ showAvatarChanges: value as boolean }), ), - SettingsStore.watchSetting("showDisplaynameChanges", null, () => - this.setState({ - showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), - }), + SettingsStore.watchSetting("showDisplaynameChanges", roomId, (...[,,, value]) => + this.setState({ showDisplaynameChanges: value as boolean }), ), ]); @@ -648,7 +651,6 @@ export default class RoomView extends React.Component { this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged); this.context.removeListener("Event.decrypted", this.onEventDecrypted); - this.context.removeListener("event", this.onEvent); } window.removeEventListener('beforeunload', this.onPageUnload); @@ -675,8 +677,8 @@ export default class RoomView extends React.Component { ); } - // cancel any pending calls to the rate_limited_funcs - this.updateRoomMembers.cancelPendingCall(); + // cancel any pending calls to the throttled updated + this.updateRoomMembers.cancel(); for (const watcher of this.settingWatchers) { SettingsStore.unwatchSetting(watcher); @@ -825,17 +827,16 @@ export default class RoomView extends React.Component { case Action.ComposerInsert: { // re-dispatch to the correct composer - if (this.state.editState) { - dis.dispatch({ - ...payload, - action: "edit_composer_insert", - }); - } else { - dis.dispatch({ - ...payload, - action: "send_composer_insert", - }); - } + 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; } @@ -849,8 +850,7 @@ export default class RoomView extends React.Component { if (this.unmounted) return; // ignore events for other rooms - if (!room) return; - if (!this.state.room || room.roomId != this.state.room.roomId) return; + if (!room || room.roomId !== this.state.room?.roomId) return; // ignore events from filtered timelines if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; @@ -871,6 +871,10 @@ export default class RoomView extends React.Component { // we'll only be showing a spinner. if (this.state.joining) return; + if (!ev.isBeingDecrypted() && !ev.isDecryptionFailure()) { + this.handleEffects(ev); + } + if (ev.getSender() !== this.context.credentials.userId) { // update unread count when scrolled up if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { @@ -883,20 +887,14 @@ export default class RoomView extends React.Component { } }; - private onEventDecrypted = (ev) => { + private onEventDecrypted = (ev: MatrixEvent) => { + if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all + if (ev.getRoomId() !== this.state.room.roomId) return; // not for us if (ev.isDecryptionFailure()) return; this.handleEffects(ev); }; - private onEvent = (ev) => { - if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; - this.handleEffects(ev); - }; - - private handleEffects = (ev) => { - if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all - if (ev.getRoomId() !== this.state.room.roomId) return; // not for us - + private handleEffects = (ev: MatrixEvent) => { const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room); if (!notifState.isUnread) return; @@ -929,6 +927,7 @@ export default class RoomView extends React.Component { // called when state.room is first initialised (either at initial load, // after a successful peek, or after we join the room). private onRoomLoaded = (room: Room) => { + if (this.unmounted) return; // Attach a widget store listener only when we get a room WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); this.onWidgetLayoutChange(); // provoke an update @@ -943,9 +942,9 @@ export default class RoomView extends React.Component { }; private async calculateRecommendedVersion(room: Room) { - this.setState({ - upgradeRecommendation: await room.getRecommendedVersion(), - }); + const upgradeRecommendation = await room.getRecommendedVersion(); + if (this.unmounted) return; + this.setState({ upgradeRecommendation }); } private async loadMembersIfJoined(room: Room) { @@ -1035,28 +1034,19 @@ export default class RoomView extends React.Component { }; private async updateE2EStatus(room: Room) { - if (!this.context.isRoomEncrypted(room.roomId)) { - return; - } - if (!this.context.isCryptoEnabled()) { - // If crypto is not currently enabled, we aren't tracking devices at all, - // so we don't know what the answer is. Let's error on the safe side and show - // a warning for this case. - this.setState({ - e2eStatus: E2EStatus.Warning, - }); - return; + if (!this.context.isRoomEncrypted(room.roomId)) return; + + // If crypto is not currently enabled, we aren't tracking devices at all, + // so we don't know what the answer is. Let's error on the safe side and show + // a warning for this case. + let e2eStatus = E2EStatus.Warning; + if (this.context.isCryptoEnabled()) { + /* At this point, the user has encryption on and cross-signing on */ + e2eStatus = await shieldStatusForRoom(this.context, room); } - /* At this point, the user has encryption on and cross-signing on */ - this.setState({ - e2eStatus: await shieldStatusForRoom(this.context, room), - }); - } - - private updateTint() { - const room = this.state.room; - if (!room) return; + if (this.unmounted) return; + this.setState({ e2eStatus }); } private onAccountData = (event: MatrixEvent) => { @@ -1097,7 +1087,7 @@ export default class RoomView extends React.Component { return; } - this.updateRoomMembers(member); + this.updateRoomMembers(); }; private onMyMembership = (room: Room, membership: string, oldMembership: string) => { @@ -1119,10 +1109,10 @@ export default class RoomView extends React.Component { } // 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(); @@ -1150,7 +1140,7 @@ export default class RoomView extends React.Component { if (this.state.searchResults.next_batch) { debuglog("requesting more search results"); - const searchPromise = searchPagination(this.state.searchResults); + const searchPromise = searchPagination(this.state.searchResults as ISearchResults); return this.handleSearchResult(searchPromise); } else { debuglog("no more search results"); @@ -1258,7 +1248,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, @@ -1266,7 +1256,7 @@ 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' }); return; @@ -1347,7 +1337,6 @@ 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"), @@ -1363,9 +1352,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? @@ -1417,7 +1403,7 @@ export default class RoomView extends React.Component { continue; } - if (!haveTileForEvent(mxEv)) { + if (!haveTileForEvent(mxEv, this.state.showHiddenEventsInTimeline)) { // XXX: can this ever happen? It will make the result count // not match the displayed count. continue; @@ -1465,13 +1451,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', @@ -1492,7 +1471,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, @@ -1526,7 +1504,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, @@ -1573,7 +1550,7 @@ export default class RoomView extends React.Component { } else { // Otherwise we have to jump manually this.messagePanel.jumpToLiveTimeline(); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); } }; @@ -1603,7 +1580,7 @@ export default class RoomView extends React.Component { // 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; @@ -1705,10 +1682,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() { @@ -1783,10 +1756,8 @@ export default class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); - if (myMembership === "invite" - // SpaceRoomView handles invites itself - && (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom()) - ) { + // SpaceRoomView handles invites itself + if (myMembership === "invite" && (!SpaceStore.spacesEnabled || !this.state.room.isSpaceRoom())) { if (this.state.joining || this.state.rejecting) { return ( @@ -1864,10 +1835,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 = { room={this.state.room} /> ); - if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) { + if (!this.state.canPeek && (!SpaceStore.spacesEnabled || !this.state.room?.isSpaceRoom())) { return (
{ previewBar } @@ -1933,10 +1902,10 @@ export default class RoomView extends React.Component { className="mx_RoomView_auxPanel_hiddenHighlights" onClick={this.onHiddenHighlightsClick} > - {_t( + { _t( "You have %(count)s unread notifications in a prior version of this room.", { count: hiddenHighlightCount }, - )} + ) } ); } @@ -1973,12 +1942,9 @@ export default class RoomView extends React.Component { myMembership === 'join' && !this.state.searchResults ); if (canSpeak) { - const MessageComposer = sdk.getComponent('rooms.MessageComposer'); messageComposer = { onScroll={this.onMessageListScroll} onUserScroll={this.onUserScroll} onReadMarkerUpdated={this.updateTopUnreadMessagesBar} - showUrlPreview = {this.state.showUrlPreview} + showUrlPreview={this.state.showUrlPreview} className={messagePanelClassNames} membersLoaded={this.state.membersLoaded} permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} @@ -2064,7 +2030,6 @@ export default class RoomView extends React.Component { 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 = ( ); @@ -2072,7 +2037,6 @@ 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} numUnreadMessages={this.state.numUnreadMessages} @@ -2103,7 +2067,7 @@ export default class RoomView extends React.Component { return (
- {showChatEffects && this.roomView.current && + { showChatEffects && this.roomView.current && } @@ -2115,7 +2079,6 @@ export default class RoomView extends React.Component { onSearchClick={this.onSearchClick} onSettingsClick={this.onSettingsClick} onForgetClick={(myMembership === "leave") ? this.onForgetClick : null} - onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} e2eStatus={this.state.e2eStatus} onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null} appsShown={this.state.showApps} @@ -2123,22 +2086,22 @@ export default class RoomView extends React.Component { />
- {auxPanel} + { auxPanel }
- {fileDropTarget} - {topUnreadMessagesBar} - {jumpToBottom} - {messagePanel} - {searchResultsPanel} + { fileDropTarget } + { topUnreadMessagesBar } + { jumpToBottom } + { messagePanel } + { searchResultsPanel }
- {statusBar} + { statusBar }
- {previewBar} - {messageComposer} + { previewBar } + { messageComposer }
diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index df885575df..1d16755106 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -187,7 +187,7 @@ export default class ScrollPanel extends React.Component { private fillRequestWhileRunning: boolean; private scrollState: IScrollState; private preventShrinkingState: IPreventShrinkingState; - private unfillDebouncer: NodeJS.Timeout; + private unfillDebouncer: number; private bottomGrowth: number; private pages: number; private heightUpdateInProgress: boolean; diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index 5c966d2d3a..3cf4b9b593 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -136,7 +136,7 @@ export default class SearchBox extends React.Component { key="button" tabIndex={-1} className="mx_SearchBox_closeButton" - onClick={ () => {this._clearSearch("button"); } }> + onClick={() => {this._clearSearch("button"); }}> ) : undefined; // show a shorter placeholder when blurred, if requested @@ -153,12 +153,12 @@ export default class SearchBox extends React.Component { type="text" ref={this._search} className={"mx_textinput_icon mx_textinput_search " + className} - value={ this.state.searchTerm } - onFocus={ this._onFocus } - onChange={ this.onChange } - onKeyDown={ this._onKeyDown } + value={this.state.searchTerm} + onFocus={this._onFocus} + onChange={this.onChange} + onKeyDown={this._onKeyDown} onBlur={this._onBlur} - placeholder={ placeholder } + placeholder={placeholder} autoComplete="off" autoFocus={this.props.autoFocus} /> diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index e5c4372ab6..038c1df514 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -18,6 +18,7 @@ import React, { ReactNode, useMemo, useState } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; +import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces"; import classNames from "classnames"; import { sortBy } from "lodash"; @@ -42,6 +43,7 @@ import { useStateToggle } from "../../hooks/useStateToggle"; import { getChildOrder } from "../../stores/SpaceStore"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { linkifyElement } from "../../HtmlUtils"; +import { getDisplayAliasForAliasSet } from "../../Rooms"; interface IHierarchyProps { space: Room; @@ -51,36 +53,6 @@ interface IHierarchyProps { showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void; } -/* eslint-disable camelcase */ -export interface ISpaceSummaryRoom { - canonical_alias?: string; - aliases: string[]; - avatar_url?: string; - guest_can_join: boolean; - name?: string; - num_joined_members: number - room_id: string; - topic?: string; - world_readable: boolean; - num_refs: number; - room_type: string; -} - -export interface ISpaceSummaryEvent { - room_id: string; - event_id: string; - origin_server_ts: number; - type: string; - state_key: string; - content: { - order?: string; - suggested?: boolean; - auto_join?: boolean; - via?: string[]; - }; -} -/* eslint-enable camelcase */ - interface ITileProps { room: ISpaceSummaryRoom; suggested?: boolean; @@ -432,7 +404,7 @@ export const SpaceHierarchy: React.FC = ({ const [saving, setSaving] = useState(false); if (summaryError) { - return

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

; + return

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

; } let content; @@ -597,7 +569,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 => { - return {sub}; + return { sub }; } }, ) } @@ -666,5 +638,5 @@ export default SpaceRoomDirectory; // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // but works with the objects we get from the public room list function getDisplayAliasForRoom(room: ISpaceSummaryRoom) { - return room.canonical_alias || (room.aliases ? room.aliases[0] : ""); + return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases); } diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 24b460284f..06b2f4a629 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -62,7 +62,6 @@ import IconizedContextMenu, { import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { BetaPill } from "../views/beta/BetaCard"; import { UserTab } from "../views/dialogs/UserSettingsDialog"; -import SettingsStore from "../../settings/SettingsStore"; import Modal from "../../Modal"; import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog"; import SdkConfig from "../../SdkConfig"; @@ -147,7 +146,7 @@ const SpaceInfo = ({ space }) => { return
{ visibilitySection } { joinRule === "public" && - {(count) => count > 0 ? ( + { (count) => count > 0 ? ( { @@ -160,7 +159,7 @@ const SpaceInfo = ({ space }) => { > { _t("%(count)s members", { count }) } - ) : null} + ) : null } }
; }; @@ -178,7 +177,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => const [busy, setBusy] = useState(false); - const spacesEnabled = SettingsStore.getValue("feature_spaces"); + const spacesEnabled = SpaceStore.spacesEnabled; const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave && space.getJoinRule() !== JoinRule.Public; @@ -293,7 +292,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => - {(topic, ref) => + { (topic, ref) =>
{ topic }
@@ -420,12 +419,12 @@ const SpaceLanding = ({ space }) => {
- {(name) => { + { (name) => { const tags = { name: () =>

{ name }

}; return _t("Welcome to ", {}, tags) as JSX.Element; - }} + } }
@@ -435,11 +434,11 @@ const SpaceLanding = ({ space }) => { { settingsButton }
- {(topic, ref) => ( + { (topic, ref) => (
{ topic }
- )} + ) }

@@ -459,7 +458,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { const numFields = 3; const placeholders = [_t("General"), _t("Random"), _t("Support")]; const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]); - const fields = new Array(numFields).fill(0).map((_, i) => { + const fields = new Array(numFields).fill(0).map((x, i) => { const name = "roomName" + i; return { const numFields = 3; const fieldRefs: RefObject[] = [useRef(), useRef(), useRef()]; const [emailAddresses, setEmailAddress] = useStateArray(numFields, ""); - const fields = new Array(numFields).fill(0).map((_, i) => { + const fields = new Array(numFields).fill(0).map((x, i) => { const name = "emailAddress" + i; return { private renderBody() { switch (this.state.phase) { case Phase.Landing: - if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) { + if (this.state.myMembership === "join" && SpaceStore.spacesEnabled) { return ; } else { return void; } interface IState { @@ -62,7 +70,11 @@ export default class TabbedView extends React.Component { }; } - private _getActiveTabIndex() { + static defaultProps = { + tabLocation: TabLocation.LEFT, + }; + + private getActiveTabIndex() { if (!this.state || !this.state.activeTabIndex) return 0; return this.state.activeTabIndex; } @@ -72,34 +84,33 @@ export default class TabbedView extends React.Component { * @param {Tab} tab the tab to show * @private */ - private _setActiveTab(tab: Tab) { + private setActiveTab(tab: Tab) { const idx = this.props.tabs.indexOf(tab); if (idx !== -1) { + if (this.props.onChange) this.props.onChange(tab.id); this.setState({ activeTabIndex: idx }); } else { console.error("Could not find tab " + tab.label + " in tabs"); } } - private _renderTabLabel(tab: Tab) { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - + private renderTabLabel(tab: Tab) { let classes = "mx_TabbedView_tabLabel "; const idx = this.props.tabs.indexOf(tab); - if (idx === this._getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active"; + if (idx === this.getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active"; let tabIcon = null; if (tab.icon) { tabIcon = ; } - const onClickHandler = () => this._setActiveTab(tab); + const onClickHandler = () => this.setActiveTab(tab); const label = _t(tab.label); return ( - {tabIcon} + { tabIcon } { label } @@ -107,26 +118,32 @@ export default class TabbedView extends React.Component { ); } - private _renderTabPanel(tab: Tab): React.ReactNode { + private renderTabPanel(tab: Tab): React.ReactNode { return (
- {tab.body} + { tab.body }
); } public render(): React.ReactNode { - const labels = this.props.tabs.map(tab => this._renderTabLabel(tab)); - const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]); + const labels = this.props.tabs.map(tab => this.renderTabLabel(tab)); + const panel = this.renderTabPanel(this.props.tabs[this.getActiveTabIndex()]); + + const tabbedViewClasses = classNames({ + 'mx_TabbedView': true, + 'mx_TabbedView_tabsOnLeft': this.props.tabLocation == TabLocation.LEFT, + 'mx_TabbedView_tabsOnTop': this.props.tabLocation == TabLocation.TOP, + }); return ( -
+
- {labels} + { labels }
- {panel} + { panel }
); } diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index e4c7d15596..0899b1c72a 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -16,11 +16,13 @@ limitations under the License. import React, { createRef, ReactNode, SyntheticEvent } from 'react'; import ReactDOM from "react-dom"; -import { Room } from "matrix-js-sdk/src/models/room"; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { TimelineSet } 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 { 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"; @@ -30,7 +32,6 @@ 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'; @@ -39,14 +40,13 @@ import { UIFeature } from "../../settings/UIFeature"; import { replaceableComponent } from "../../utils/replaceableComponent"; import { arrayFastClone } from "../../utils/arrays"; import MessagePanel from "./MessagePanel"; -import { SyncState } from 'matrix-js-sdk/src/sync.api'; import { IScrollState } from "./ScrollPanel"; import { ActionPayload } from "../../dispatcher/payloads"; -import { EventType } from 'matrix-js-sdk/src/@types/event'; import ResizeNotifier from "../../utils/ResizeNotifier"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import Spinner from "../views/elements/Spinner"; import EditorStateTransfer from '../../utils/EditorStateTransfer'; +import ErrorDialog from '../views/dialogs/ErrorDialog'; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -65,7 +65,7 @@ interface IProps { // 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: TimelineSet; + timelineSet: EventTimelineSet; showReadReceipts?: boolean; // Enable managing RRs and RMs. These require the timelineSet to have a room. manageReadReceipts?: boolean; @@ -125,7 +125,7 @@ interface IProps { onReadMarkerUpdated?(): void; // callback which is called when we wish to paginate the timeline window. - onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise, + onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise; } interface IState { @@ -277,7 +277,7 @@ class TimelinePanel extends React.Component { } // TODO: [REACT-WARNING] Move into constructor - // eslint-disable-next-line camelcase + // eslint-disable-next-line UNSAFE_componentWillMount() { if (this.props.manageReadReceipts) { this.updateReadReceiptOnUserActivity(); @@ -290,7 +290,7 @@ class TimelinePanel extends React.Component { } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase + // eslint-disable-next-line UNSAFE_componentWillReceiveProps(newProps) { if (newProps.timelineSet !== this.props.timelineSet) { // throw new Error("changing timelineSet on a TimelinePanel is not supported"); @@ -388,7 +388,7 @@ class TimelinePanel extends React.Component { private onPaginationRequest = ( timelineWindow: TimelineWindow, - direction: string, + direction: Direction, size: number, ): Promise => { if (this.props.onPaginationRequest) { @@ -555,9 +555,8 @@ class TimelinePanel extends React.Component { // more than the timeout on userActiveRecently. // const myUserId = MatrixClientPeg.get().credentials.userId; - const sender = ev.sender ? ev.sender.userId : null; callRMUpdated = false; - if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) { + if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) { updatedState.readMarkerVisible = true; } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { // we know we're stuckAtBottom, so we can advance the RM @@ -579,7 +578,7 @@ class TimelinePanel extends React.Component { }); }; - private onRoomTimelineReset = (room: Room, timelineSet: TimelineSet): void => { + private onRoomTimelineReset = (room: Room, timelineSet: EventTimelineSet): void => { if (timelineSet !== this.props.timelineSet) return; if (this.messagePanel.current && this.messagePanel.current.isAtBottom()) { @@ -666,8 +665,8 @@ class TimelinePanel extends React.Component { private readMarkerTimeout(readMarkerPosition: number): number { return readMarkerPosition === 0 ? - this.state.readMarkerInViewThresholdMs : - this.state.readMarkerOutOfViewThresholdMs; + this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs : + this.context?.readMarkerOutOfViewThresholdMs ?? this.state.readMarkerOutOfViewThresholdMs; } private async updateReadMarkerOnUserActivity(): Promise { @@ -792,8 +791,8 @@ class TimelinePanel extends React.Component { // that sending an RR for the latest message will set our notif counter // to zero: it may not do this if we send an RR for somewhere before the end. if (this.isAtEndOfLiveTimeline()) { - this.props.timelineSet.room.setUnreadNotificationCount('total', 0); - this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0); + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); dis.dispatch({ action: 'on_room_read', roomId: this.props.timelineSet.room.roomId, @@ -863,7 +862,7 @@ class TimelinePanel extends React.Component { const myUserId = MatrixClientPeg.get().credentials.userId; for (i++; i < events.length; i++) { const ev = events[i]; - if (!ev.sender || ev.sender.userId != myUserId) { + if (ev.getSender() !== myUserId) { break; } } @@ -1051,6 +1050,8 @@ class TimelinePanel extends React.Component { { windowLimit: this.props.timelineCap }); const onLoaded = () => { + if (this.unmounted) return; + // clear the timeline min-height when // (re)loading the timeline if (this.messagePanel.current) { @@ -1092,11 +1093,12 @@ class TimelinePanel extends React.Component { }; const onError = (error) => { + if (this.unmounted) return; + this.setState({ timelineLoading: false }); console.error( `Error loading timeline panel at ${eventId}: ${error}`, ); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); let onFinished; @@ -1334,8 +1336,9 @@ 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, this.context); + (ignoreOwn && ev.getSender() === myUserId); // own message + const isWithoutTile = !haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline) || + shouldHideEvent(ev, this.context); if (isWithoutTile || !node) { // don't start counting if the event should be ignored, @@ -1416,7 +1419,11 @@ class TimelinePanel extends React.Component { }); } - private getRelationsForEvent = (...args) => this.props.timelineSet.getRelationsForEvent(...args); + private getRelationsForEvent = ( + eventId: string, + relationType: RelationType, + eventType: EventType | string, + ) => this.props.timelineSet.getRelationsForEvent(eventId, relationType, eventType); render() { // just show a spinner while the timeline loads. @@ -1441,7 +1448,7 @@ class TimelinePanel extends React.Component { if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) { return (
-
{this.props.empty}
+
{ this.props.empty }
); } @@ -1486,8 +1493,12 @@ class TimelinePanel extends React.Component { onUserScroll={this.props.onUserScroll} onFillRequest={this.onMessageListFillRequest} onUnfillRequest={this.onMessageListUnfillRequest} - isTwelveHour={this.state.isTwelveHour} - alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.alwaysShowTimestamps} + isTwelveHour={this.context?.showTwelveHourTimestamps ?? this.state.isTwelveHour} + alwaysShowTimestamps={ + this.props.alwaysShowTimestamps ?? + this.context?.alwaysShowTimestamps ?? + this.state.alwaysShowTimestamps + } className={this.props.className} tileShape={this.props.tileShape} resizeNotifier={this.props.resizeNotifier} diff --git a/src/components/structures/ToastContainer.tsx b/src/components/structures/ToastContainer.tsx index 79a73735f4..b7b0b7c652 100644 --- a/src/components/structures/ToastContainer.tsx +++ b/src/components/structures/ToastContainer.tsx @@ -37,14 +37,14 @@ export default class ToastContainer extends React.Component<{}, IState> { // toasts may dismiss themselves in their didMount if they find // they're already irrelevant by the time they're mounted, and // our own componentDidMount is too late. - ToastStore.sharedInstance().on('update', this._onToastStoreUpdate); + ToastStore.sharedInstance().on('update', this.onToastStoreUpdate); } componentWillUnmount() { - ToastStore.sharedInstance().removeListener('update', this._onToastStoreUpdate); + ToastStore.sharedInstance().removeListener('update', this.onToastStoreUpdate); } - _onToastStoreUpdate = () => { + private onToastStoreUpdate = () => { this.setState({ toasts: ToastStore.sharedInstance().getToasts(), countSeen: ToastStore.sharedInstance().getCountSeen(), @@ -75,10 +75,10 @@ export default class ToastContainer extends React.Component<{}, IState> { }); toast = (
-

{title}

- {countIndicator} +

{ title }

+ { countIndicator }
-
{React.createElement(component, toastProps)}
+
{ React.createElement(component, toastProps) }
); containerClasses = classNames("mx_ToastContainer", { @@ -88,7 +88,7 @@ export default class ToastContainer extends React.Component<{}, IState> { return toast ? (
- {toast} + { toast }
) : null; diff --git a/src/components/structures/UploadBar.tsx b/src/components/structures/UploadBar.tsx index b8dce48235..6ee53da5d1 100644 --- a/src/components/structures/UploadBar.tsx +++ b/src/components/structures/UploadBar.tsx @@ -26,6 +26,7 @@ import ProgressBar from "../views/elements/ProgressBar"; import AccessibleButton from "../views/elements/AccessibleButton"; import { IUpload } from "../../models/IUpload"; import { replaceableComponent } from "../../utils/replaceableComponent"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; interface IProps { room: Room; @@ -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; @@ -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() { @@ -101,7 +104,7 @@ export default class UploadBar extends React.Component { const uploadSize = filesize(this.state.currentUpload.total); return (
-
{uploadText} ({uploadSize})
+
{ uploadText } ({ uploadSize })
diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index d85817486b..0a30367e4b 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -90,7 +90,7 @@ export default class UserMenu extends React.Component { }; OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); - if (SettingsStore.getValue("feature_spaces")) { + if (SpaceStore.spacesEnabled) { SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); } @@ -115,7 +115,7 @@ export default class UserMenu extends React.Component { if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); this.tagStoreRef.remove(); - if (SettingsStore.getValue("feature_spaces")) { + if (SpaceStore.spacesEnabled) { SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); } MatrixClientPeg.get().removeListener("Room", this.onRoom); @@ -342,20 +342,20 @@ export default class UserMenu extends React.Component { if (MatrixClientPeg.get().isGuest()) { topSection = (
- {_t("Got an account? Sign in", {}, { + { _t("Got an account? Sign in", {}, { a: sub => ( - {sub} + { sub } ), - })} - {_t("New here? Create an account", {}, { + }) } + { _t("New here? Create an account", {}, { a: sub => ( - {sub} + { sub } ), - })} + }) }
); } else if (hostSignupConfig) { @@ -394,17 +394,17 @@ export default class UserMenu extends React.Component { let primaryHeader = (
- {OwnProfileStore.instance.displayName} + { OwnProfileStore.instance.displayName } - {MatrixClientPeg.get().getUserId()} + { MatrixClientPeg.get().getUserId() }
); let primaryOptionList = ( - {homeButton} + { homeButton } { label={_t("All settings")} onClick={(e) => this.onSettingsOpen(e, null)} /> - {/* */} + /> */ } { feedbackButton } @@ -443,7 +443,7 @@ export default class UserMenu extends React.Component { primaryHeader = (
- {prototypeCommunityName} + { prototypeCommunityName }
); @@ -470,13 +470,13 @@ export default class UserMenu extends React.Component { } primaryOptionList = ( - {settingsOption} + { settingsOption } - {inviteOption} + { inviteOption } ); secondarySection = ( @@ -485,10 +485,10 @@ export default class UserMenu extends React.Component {
- {OwnProfileStore.instance.displayName} + { OwnProfileStore.instance.displayName } - {MatrixClientPeg.get().getUserId()} + { MatrixClientPeg.get().getUserId() }
@@ -540,7 +540,7 @@ export default class UserMenu extends React.Component { className={classes} >
- {primaryHeader} + { primaryHeader } { />
- {topSection} - {primaryOptionList} - {secondarySection} + { topSection } + { primaryOptionList } + { secondarySection } ; }; @@ -570,27 +570,27 @@ export default class UserMenu extends React.Component { let isPrototype = false; let menuName = _t("User menu"); - let name = {displayName}; + let name = { displayName }; let buttons = ( - {/* masked image in CSS */} + { /* masked image in CSS */ } ); let dnd; if (this.state.selectedSpace) { name = (
- {displayName} + { displayName } - {(roomName) => {roomName}} + { (roomName) => { roomName } }
); } else if (prototypeCommunityName) { name = (
- {prototypeCommunityName} - {displayName} + { prototypeCommunityName } + { displayName }
); menuName = _t("Community and user menu"); @@ -598,8 +598,8 @@ export default class UserMenu extends React.Component { } else if (SettingsStore.getValue("feature_communities_v2_prototypes")) { name = (
- {_t("Home")} - {displayName} + { _t("Home") } + { displayName }
); isPrototype = true; @@ -647,20 +647,20 @@ export default class UserMenu extends React.Component { className="mx_UserMenu_userAvatar" /> - {name} - {this.state.pendingRoomJoin.size > 0 && ( + { name } + { this.state.pendingRoomJoin.size > 0 && ( - )} - {dnd} - {buttons} + ) } + { dnd } + { buttons }
- {this.renderContextMenu()} + { this.renderContextMenu() } ); } diff --git a/src/components/structures/ViewSource.js b/src/components/structures/ViewSource.js index b69a92dd61..2bfa20e892 100644 --- a/src/components/structures/ViewSource.js +++ b/src/components/structures/ViewSource.js @@ -63,23 +63,23 @@ export default class ViewSource extends React.Component { <>
- {_t("Decrypted event source")} + { _t("Decrypted event source") } - {JSON.stringify(decryptedEventSource, null, 2)} + { JSON.stringify(decryptedEventSource, null, 2) }
- {_t("Original event source")} + { _t("Original event source") } - {JSON.stringify(originalEventSource, null, 2)} + { JSON.stringify(originalEventSource, null, 2) }
); } else { return ( <> -
{_t("Original event source")}
- {JSON.stringify(originalEventSource, null, 2)} +
{ _t("Original event source") }
+ { JSON.stringify(originalEventSource, null, 2) } ); } @@ -110,7 +110,7 @@ export default class ViewSource extends React.Component { if (isStateEvent) { return ( - {(cli) => ( + { (cli) => ( - )} + ) } ); } else { @@ -142,7 +142,7 @@ export default class ViewSource extends React.Component { }; return ( - {(cli) => ( + { (cli) => ( - )} + ) } ); } @@ -176,16 +176,16 @@ export default class ViewSource extends React.Component { return (
-
Room ID: {roomId}
-
Event ID: {eventId}
+
Room ID: { roomId }
+
Event ID: { eventId }
- {isEditing ? this.editSourceContent() : this.viewSourceContent()} + { isEditing ? this.editSourceContent() : this.viewSourceContent() }
- {!isEditing && canEdit && ( + { !isEditing && canEdit && (
- +
- )} + ) } ); } diff --git a/src/components/structures/auth/CompleteSecurity.js b/src/components/structures/auth/CompleteSecurity.tsx similarity index 85% rename from src/components/structures/auth/CompleteSecurity.js rename to src/components/structures/auth/CompleteSecurity.tsx index d691f6034b..8c3d5e80a0 100644 --- a/src/components/structures/auth/CompleteSecurity.js +++ b/src/components/structures/auth/CompleteSecurity.tsx @@ -15,39 +15,42 @@ 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 } from '../../../stores/SetupEncryptionStore'; import SetupEncryptionBody from "./SetupEncryptionBody"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -@replaceableComponent("structures.auth.CompleteSecurity") -export default class CompleteSecurity extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; +interface IProps { + onFinished: () => void; +} - constructor() { - super(); +interface IState { + phase: Phase; +} + +@replaceableComponent("structures.auth.CompleteSecurity") +export default class CompleteSecurity extends React.Component { + constructor(props: IProps) { + super(props); const store = SetupEncryptionStore.sharedInstance(); - store.on("update", this._onStoreUpdate); + store.on("update", this.onStoreUpdate); store.start(); this.state = { phase: store.phase }; } - _onStoreUpdate = () => { + private onStoreUpdate = (): void => { const store = SetupEncryptionStore.sharedInstance(); 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; @@ -76,8 +79,8 @@ export default class CompleteSecurity extends React.Component {

- {icon} - {title} + { icon } + { title }

diff --git a/src/components/structures/auth/E2eSetup.js b/src/components/structures/auth/E2eSetup.tsx similarity index 84% rename from src/components/structures/auth/E2eSetup.js rename to src/components/structures/auth/E2eSetup.tsx index 9b627449bc..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"; -@replaceableComponent("structures.auth.E2eSetup") -export default class E2eSetup extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - accountPassword: PropTypes.string, - tokenLogin: PropTypes.bool, - }; +interface IProps { + onFinished: () => void; + accountPassword?: string; + tokenLogin?: boolean; +} +@replaceableComponent("structures.auth.E2eSetup") +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 77% rename from src/components/structures/auth/ForgotPassword.js rename to src/components/structures/auth/ForgotPassword.tsx index 9f2ac9deed..3755505f3d 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -17,7 +17,6 @@ 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"; @@ -31,27 +30,50 @@ import PassphraseField from '../../views/auth/PassphraseField'; 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) { + // eslint-disable-next-line + 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, }); @@ -216,14 +239,14 @@ export default class ForgotPassword extends React.Component { }); serverDeadSection = (
- {this.state.serverDeadError} + { this.state.serverDeadError }
); } return
- {errorText} - {serverDeadSection} + { errorText } + { serverDeadSection }
- {_t( + { _t( 'A verification email will be sent to your inbox to confirm ' + 'setting your new password.', - )} + ) } - {_t('Sign in instead')} + { _t('Sign in instead') }
; } @@ -289,8 +312,8 @@ export default class ForgotPassword extends React.Component { renderEmailSent() { return
- {_t("An email has been sent to %(emailAddress)s. Once you've followed the " + - "link it contains, click below.", { emailAddress: this.state.email })} + { _t("An email has been sent to %(emailAddress)s. Once you've followed the " + + "link it contains, click below.", { emailAddress: this.state.email }) }
@@ -299,12 +322,12 @@ export default class ForgotPassword extends React.Component { renderDone() { return
-

{_t("Your password has been reset.")}

-

{_t( +

{ _t("Your password has been reset.") }

+

{ _t( "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.", - )}

+ ) }

; @@ -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; } @@ -335,7 +358,7 @@ export default class ForgotPassword extends React.Component {

{ _t('Set a new password') }

- {resetPasswordJsx} + { resetPasswordJsx }
); diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 61d3759dee..6a3d339681 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -18,7 +18,6 @@ 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 SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; @@ -36,6 +35,8 @@ import Spinner from "../../views/elements/Spinner"; import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from "../../views/elements/ServerPicker"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AuthBody from "../../views/auth/AuthBody"; +import AuthHeader from "../../views/auth/AuthHeader"; // These are used in several places, and come from the js-sdk's autodiscovery // stuff. We define them here so that they'll be picked up by i18n. @@ -143,7 +144,7 @@ export default class LoginComponent extends React.PureComponent } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase + // eslint-disable-next-line UNSAFE_componentWillMount() { this.initLoginLogic(this.props.serverConfig); } @@ -153,7 +154,7 @@ export default class LoginComponent extends React.PureComponent } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase + // eslint-disable-next-line UNSAFE_componentWillReceiveProps(newProps) { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; @@ -238,8 +239,8 @@ export default class LoginComponent extends React.PureComponent ); errorText = (
-
{errorTop}
-
{errorDetail}
+
{ errorTop }
+
{ errorDetail }
); } else if (error.httpStatus === 401 || error.httpStatus === 403) { @@ -250,10 +251,10 @@ export default class LoginComponent extends React.PureComponent
{ _t('Incorrect username and/or password.') }
- {_t( + { _t( 'Please note you are logging into the %(hs)s server, not matrix.org.', { hs: this.props.serverConfig.hsName }, - )} + ) }
); @@ -541,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; @@ -566,7 +565,7 @@ export default class LoginComponent extends React.PureComponent }); serverDeadSection = (
- {this.state.serverDeadError} + { this.state.serverDeadError }
); } @@ -579,15 +578,15 @@ export default class LoginComponent extends React.PureComponent { this.props.isSyncing ? _t("Syncing...") : _t("Signing In...") }
{ this.props.isSyncing &&
- {_t("If you've joined lots of rooms, this might take a while")} + { _t("If you've joined lots of rooms, this might take a while") }
}
; } else if (SettingsStore.getValue(UIFeature.Registration)) { footer = ( - {_t("New? Create account", {}, { + { _t("New? Create account", {}, { a: sub => { sub }, - })} + }) } ); } @@ -597,8 +596,8 @@ export default class LoginComponent extends React.PureComponent

- {_t('Sign in')} - {loader} + { _t('Sign in') } + { loader }

{ errorTextSection } { serverDeadSection } diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index f27bed2cc3..549e47260f 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -18,19 +18,24 @@ 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 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 dis from "../../../dispatcher/dispatcher"; import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from '../../views/elements/ServerPicker'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import RegistrationForm from '../../views/auth/RegistrationForm'; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import AuthBody from "../../views/auth/AuthBody"; +import AuthHeader from "../../views/auth/AuthHeader"; +import InteractiveAuth from "../InteractiveAuth"; +import Spinner from "../../views/elements/Spinner"; 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; @@ -142,7 +141,7 @@ export default class Registration extends React.Component { } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase + // eslint-disable-next-line UNSAFE_componentWillReceiveProps(newProps) { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; @@ -246,7 +245,7 @@ export default class Registration extends React.Component { } } - private onFormSubmit = formVals => { + private onFormSubmit = async (formVals): Promise => { this.setState({ errorText: "", busy: true, @@ -291,8 +290,8 @@ export default class Registration extends React.Component { }, ); msg =
-

{errorTop}

-

{errorDetail}

+

{ errorTop }

+

{ errorDetail }

; } else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { let msisdnAvailable = false; @@ -442,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 { fragmentAfterLogin={this.props.fragmentAfterLogin} />

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

; } @@ -516,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) { @@ -535,15 +526,15 @@ export default class Registration extends React.Component { }); serverDeadSection = (
- {this.state.serverDeadError} + { this.state.serverDeadError }
); } const signIn = - {_t("Already have an account? Sign in here", {}, { + { _t("Already have an account? Sign in here", {}, { a: sub => { sub }, - })} + }) } ; // Only show the 'go back' button if you're not looking at the form @@ -559,43 +550,43 @@ export default class Registration extends React.Component { let regDoneText; if (this.state.differentLoggedInUserId) { regDoneText =
-

{_t( +

{ _t( "Your new account (%(newAccountId)s) is registered, but you're already " + "logged into a different account (%(loggedInUserId)s).", { newAccountId: this.state.registeredUsername, loggedInUserId: this.state.differentLoggedInUserId, }, - )}

+ ) }

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

; } else if (this.state.formVals.password) { // We're the client that started the registration - regDoneText =

{_t( + regDoneText =

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

; + ) }; } else { // We're not the original client: the user probably got to us by clicking the // email validation link. We can't offer a 'go straight to your account' link // as we don't have the original creds. - regDoneText =

{_t( + regDoneText =

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

; + ) }; } body =
-

{_t("Registration Successful")}

+

{ _t("Registration Successful") }

{ regDoneText }
; } else { diff --git a/src/components/structures/auth/SetupEncryptionBody.js b/src/components/structures/auth/SetupEncryptionBody.tsx similarity index 75% rename from src/components/structures/auth/SetupEncryptionBody.js rename to src/components/structures/auth/SetupEncryptionBody.tsx index f0798b6d1a..6731156807 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,33 +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 } 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, @@ -53,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(); + this.props.onFinished(true); return; } this.setState({ @@ -66,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); @@ -91,42 +101,44 @@ 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) { const store = SetupEncryptionStore.sharedInstance(); @@ -139,29 +151,29 @@ export default class SetupEncryptionBody extends React.Component { let useRecoveryKeyButton; if (recoveryKeyPrompt) { - useRecoveryKeyButton = - {recoveryKeyPrompt} + useRecoveryKeyButton = + { recoveryKeyPrompt } ; } let verifyButton; if (store.hasDevicesToVerifyAgainst) { - verifyButton = + verifyButton = { _t("Use another login") } ; } return (
-

{_t( +

{ _t( "Verify your identity to access encrypted messages and prove your identity to others.", - )}

+ ) }

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

{_t( + message =

{ _t( "Your new session is now verified. It has access to your " + "encrypted messages, and other users will see it as trusted.", - )}

; + ) }

; } else { - message =

{_t( + message =

{ _t( "Your new session is now verified. Other users will see it as trusted.", - )}

; + ) }

; } return (
- {message} + { message }
- {_t("Done")} + { _t("Done") }
@@ -195,29 +207,28 @@ export default class SetupEncryptionBody extends React.Component { } else if (phase === Phase.ConfirmSkip) { return (
-

{_t( +

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

+ ) }

- {_t("Skip")} + { _t("Skip") } - {_t("Go Back")} + { _t("Go Back") }
); } else if (phase === Phase.Busy || phase === Phase.Loading) { - const Spinner = sdk.getComponent('views.elements.Spinner'); 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 7fb60a7b5d..fffec949fe 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -16,7 +16,6 @@ limitations under the License. import React from 'react'; import { _t } from '../../../languageHandler'; -import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import * as Lifecycle from '../../../Lifecycle'; import Modal from '../../../Modal'; @@ -26,6 +25,12 @@ import AuthPage from "../../views/auth/AuthPage"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform"; import SSOButtons from "../../views/elements/SSOButtons"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ConfirmWipeDeviceDialog from '../../views/dialogs/ConfirmWipeDeviceDialog'; +import Field from '../../views/elements/Field'; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import Spinner from "../../views/elements/Spinner"; +import AuthHeader from "../../views/auth/AuthHeader"; +import AuthBody from "../../views/auth/AuthBody"; const LOGIN_VIEW = { LOADING: 1, @@ -49,7 +54,7 @@ interface IProps { fragmentAfterLogin?: string; // Called when the SSO login completes - onTokenLoginCompleted: () => void, + onTokenLoginCompleted: () => void; } interface IState { @@ -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; @@ -202,7 +206,6 @@ export default class SoftLogout extends React.Component { private renderSignInSection() { if (this.state.loginView === LOGIN_VIEW.LOADING) { - const Spinner = sdk.getComponent("elements.Spinner"); return ; } @@ -214,12 +217,9 @@ 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}; + error = { this.state.errorText }; } if (!introText) { @@ -228,8 +228,8 @@ export default class SoftLogout extends React.Component { return (
-

{introText}

- {error} +

{ introText }

+ { error } { type="submit" disabled={this.state.busy} > - {_t("Sign In")} + { _t("Sign In") } - {_t("Forgotten your password?")} + { _t("Forgotten your password?") } ); @@ -262,7 +262,7 @@ export default class SoftLogout extends React.Component { return (
-

{introText}

+

{ introText }

{ // Default: assume unsupported/error return (

- {_t( + { _t( "You cannot sign in to your account. Please contact your " + "homeserver admin for more information.", - )} + ) }

); } render() { - const AuthHeader = sdk.getComponent("auth.AuthHeader"); - const AuthBody = sdk.getComponent("auth.AuthBody"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - return (

- {_t("You're signed out")} + { _t("You're signed out") }

-

{_t("Sign in")}

+

{ _t("Sign in") }

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

{_t("Clear personal data")}

+

{ _t("Clear personal data") }

- {_t( + { _t( "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.", - )} + ) }

- {_t("Clear all data")} + { _t("Clear all data") }
diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx new file mode 100644 index 0000000000..748b1c9ffc --- /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 92% rename from src/components/views/voice_messages/Clock.tsx rename to src/components/views/audio_messages/Clock.tsx index 1e78cc7bbd..cb1a179f2e 100644 --- a/src/components/views/voice_messages/Clock.tsx +++ b/src/components/views/audio_messages/Clock.tsx @@ -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); @@ -43,6 +43,6 @@ export default class Clock extends React.Component { public render() { const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0'); const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis - return {minutes}:{seconds}; + return { minutes }:{ seconds }; } } diff --git a/src/components/views/audio_messages/DurationClock.tsx b/src/components/views/audio_messages/DurationClock.tsx new file mode 100644 index 0000000000..81852b5944 --- /dev/null +++ b/src/components/views/audio_messages/DurationClock.tsx @@ -0,0 +1,55 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Clock from "./Clock"; +import { Playback } from "../../../voice/Playback"; + +interface IProps { + playback: Playback; +} + +interface IState { + durationSeconds: number; +} + +/** + * A clock which shows a clip's maximum duration. + */ +@replaceableComponent("views.audio_messages.DurationClock") +export default class DurationClock extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + // we track the duration on state because we won't really know what the clip duration + // is until the first time update, and as a PureComponent we are trying to dedupe state + // updates as much as possible. This is just the easiest way to avoid a forceUpdate() or + // member property to track "did we get a duration". + durationSeconds: this.props.playback.clockInfo.durationSeconds, + }; + this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); + } + + private onTimeUpdate = (time: number[]) => { + this.setState({ durationSeconds: time[1] }); + }; + + public render() { + return ; + } +} diff --git a/src/components/views/voice_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx similarity index 90% rename from src/components/views/voice_messages/LiveRecordingClock.tsx rename to src/components/views/audio_messages/LiveRecordingClock.tsx index 2a20e9bfec..a9dbd3c52f 100644 --- a/src/components/views/voice_messages/LiveRecordingClock.tsx +++ b/src/components/views/audio_messages/LiveRecordingClock.tsx @@ -1,9 +1,12 @@ /* 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. @@ -12,16 +15,13 @@ limitations under the License. */ import React from "react"; -import Clock from "./Clock"; +import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Clock from "./Clock"; import { MarkedExecution } from "../../../utils/MarkedExecution"; -import { - IRecordingUpdate, - VoiceRecording, -} from "../../../voice/VoiceRecording"; interface IProps { - recorder?: VoiceRecording; + recorder: VoiceRecording; } interface IState { @@ -31,7 +31,7 @@ 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 { private seconds = 0; private scheduledUpdate = new MarkedExecution( diff --git a/src/components/views/voice_messages/LiveRecordingWaveform.tsx b/src/components/views/audio_messages/LiveRecordingWaveform.tsx similarity index 66% rename from src/components/views/voice_messages/LiveRecordingWaveform.tsx rename to src/components/views/audio_messages/LiveRecordingWaveform.tsx index 1e3440fbad..b9c5f80f05 100644 --- a/src/components/views/voice_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/audio_messages/LiveRecordingWaveform.tsx @@ -1,9 +1,12 @@ /* 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. @@ -12,16 +15,15 @@ limitations under the License. */ import React from "react"; -import Waveform from "./Waveform"; +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"; -import { - IRecordingUpdate, - VoiceRecording, -} from "../../../voice/VoiceRecording"; interface IProps { - recorder?: VoiceRecording; + recorder: VoiceRecording; } interface IState { @@ -31,7 +33,7 @@ interface IState { /** * A waveform which shows the waveform of a live recording */ -@replaceableComponent("views.voice_messages.LiveRecordingWaveform") +@replaceableComponent("views.audio_messages.LiveRecordingWaveform") export default class LiveRecordingWaveform extends React.PureComponent { public static defaultProps = { progress: 1, @@ -52,15 +54,18 @@ export default class LiveRecordingWaveform extends React.PureComponent { - this.waveform = update.waveform; + 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, - }); + this.setState({ waveform: this.waveform }); } public render() { diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/audio_messages/PlayPauseButton.tsx similarity index 76% rename from src/components/views/voice_messages/PlayPauseButton.tsx rename to src/components/views/audio_messages/PlayPauseButton.tsx index 1ca05a2cd0..a4f1e770f2 100644 --- a/src/components/views/voice_messages/PlayPauseButton.tsx +++ b/src/components/views/audio_messages/PlayPauseButton.tsx @@ -21,7 +21,8 @@ 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 83% rename from src/components/views/voice_messages/PlaybackClock.tsx rename to src/components/views/audio_messages/PlaybackClock.tsx index 9c9298f764..374d47c31d 100644 --- a/src/components/views/voice_messages/PlaybackClock.tsx +++ b/src/components/views/audio_messages/PlaybackClock.tsx @@ -22,6 +22,11 @@ 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); @@ -64,7 +69,11 @@ export default class PlaybackClock extends React.PureComponent { 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 97% rename from src/components/views/voice_messages/PlaybackWaveform.tsx rename to src/components/views/audio_messages/PlaybackWaveform.tsx index 096ba8199c..ea1b846c01 100644 --- a/src/components/views/voice_messages/PlaybackWaveform.tsx +++ b/src/components/views/audio_messages/PlaybackWaveform.tsx @@ -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); diff --git a/src/components/views/voice_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx similarity index 74% rename from src/components/views/voice_messages/RecordingPlayback.tsx rename to src/components/views/audio_messages/RecordingPlayback.tsx index 63e823200e..7d9312f369 100644 --- a/src/components/views/voice_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -17,20 +17,25 @@ limitations under the License. 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"; +import { TileShape } from "../rooms/EventTile"; +import PlaybackWaveform from "./PlaybackWaveform"; interface IProps { // Playback instance to render. Cannot change during component lifecycle: create // an all-new component instead. playback: Playback; + + tileShape?: TileShape; } interface IState { playbackPhase: PlaybackState; } +@replaceableComponent("views.audio_messages.RecordingPlayback") export default class RecordingPlayback extends React.PureComponent { constructor(props: IProps) { super(props); @@ -48,15 +53,22 @@ export default class RecordingPlayback extends React.PureComponent { this.setState({ playbackPhase: ev }); }; public render(): ReactNode { - return
+ const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : ''; + return
- + { this.isWaveformable && }
; } } diff --git a/src/components/views/audio_messages/SeekBar.tsx b/src/components/views/audio_messages/SeekBar.tsx new file mode 100644 index 0000000000..5231a2fb79 --- /dev/null +++ b/src/components/views/audio_messages/SeekBar.tsx @@ -0,0 +1,112 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Playback, PlaybackState } from "../../../voice/Playback"; +import React, { ChangeEvent, CSSProperties, ReactNode } from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { MarkedExecution } from "../../../utils/MarkedExecution"; +import { percentageOf } from "../../../utils/numbers"; + +interface IProps { + // Playback instance to render. Cannot change during component lifecycle: create + // an all-new component instead. + playback: Playback; + + // Tab index for the underlying component. Useful if the seek bar is in a managed state. + // Defaults to zero. + tabIndex?: number; + + playbackPhase: PlaybackState; +} + +interface IState { + percentage: number; +} + +interface ISeekCSS extends CSSProperties { + '--fillTo': number; +} + +const ARROW_SKIP_SECONDS = 5; // arbitrary + +@replaceableComponent("views.audio_messages.SeekBar") +export default class SeekBar extends React.PureComponent { + // We use an animation frame request to avoid overly spamming prop updates, even if we aren't + // really using anything demanding on the CSS front. + + private animationFrameFn = new MarkedExecution( + () => this.doUpdate(), + () => requestAnimationFrame(() => this.animationFrameFn.trigger())); + + public static defaultProps = { + tabIndex: 0, + }; + + constructor(props: IProps) { + super(props); + + this.state = { + percentage: 0, + }; + + // We don't need to de-register: the class handles this for us internally + this.props.playback.clockInfo.liveData.onUpdate(() => this.animationFrameFn.mark()); + } + + private doUpdate() { + this.setState({ + percentage: percentageOf( + this.props.playback.clockInfo.timeSeconds, + 0, + this.props.playback.clockInfo.durationSeconds), + }); + } + + public left() { + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds - ARROW_SKIP_SECONDS); + } + + public right() { + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds + ARROW_SKIP_SECONDS); + } + + private onChange = (ev: ChangeEvent) => { + // Thankfully, onChange is only called when the user changes the value, not when we + // change the value on the component. We can use this as a reliable "skip to X" function. + // + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(Number(ev.target.value) * this.props.playback.clockInfo.durationSeconds); + }; + + public render(): ReactNode { + // We use a range input to avoid having to re-invent accessibility handling on + // a custom set of divs. + return ; + } +} diff --git a/src/components/views/voice_messages/Waveform.tsx b/src/components/views/audio_messages/Waveform.tsx similarity index 90% rename from src/components/views/voice_messages/Waveform.tsx rename to src/components/views/audio_messages/Waveform.tsx index 5a4447065a..8a4427fd01 100644 --- a/src/components/views/voice_messages/Waveform.tsx +++ b/src/components/views/audio_messages/Waveform.tsx @@ -17,8 +17,13 @@ limitations under the License. import React from "react"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import classNames from "classnames"; +import { CSSProperties } from "react"; -export interface IProps { +interface WaveformCSSProperties extends CSSProperties { + '--barHeight': number; +} + +interface IProps { relHeights: number[]; // relative heights (0-1) progress: number; // percent complete, 0-1, default 100% } @@ -34,14 +39,7 @@ 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. */ - -import { CSSProperties } from "react"; - -export interface WaveformCSSProperties extends CSSProperties { - '--barHeight': number; -} - -@replaceableComponent("views.voice_messages.Waveform") +@replaceableComponent("views.audio_messages.Waveform") export default class Waveform extends React.PureComponent { public static defaultProps = { progress: 1, @@ -49,7 +47,7 @@ export default class Waveform extends React.PureComponent { public render() { return
- {this.props.relHeights.map((h, i) => { + { this.props.relHeights.map((h, i) => { const progress = this.props.progress; const isCompleteBar = (i / this.props.relHeights.length) <= progress && progress > 0; const classes = classNames({ @@ -59,7 +57,7 @@ export default class Waveform extends React.PureComponent { return ; - })} + }) }
; } } diff --git a/src/components/views/auth/AuthBody.js b/src/components/views/auth/AuthBody.tsx similarity index 95% rename from src/components/views/auth/AuthBody.js rename to src/components/views/auth/AuthBody.tsx index abe7fd2fd3..3543a573d7 100644 --- a/src/components/views/auth/AuthBody.js +++ b/src/components/views/auth/AuthBody.tsx @@ -19,7 +19,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthBody") export default class AuthBody extends React.PureComponent { - render() { + public render(): React.ReactNode { return
{ this.props.children }
; diff --git a/src/components/views/auth/AuthFooter.js b/src/components/views/auth/AuthFooter.tsx similarity index 96% rename from src/components/views/auth/AuthFooter.js rename to src/components/views/auth/AuthFooter.tsx index e81d2cd969..00bced8c39 100644 --- a/src/components/views/auth/AuthFooter.js +++ b/src/components/views/auth/AuthFooter.tsx @@ -22,7 +22,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthFooter") export default class AuthFooter extends React.Component { - render() { + public render(): React.ReactNode { return (
{ _t("powered by Matrix") } diff --git a/src/components/views/auth/AuthHeader.js b/src/components/views/auth/AuthHeader.tsx similarity index 71% rename from src/components/views/auth/AuthHeader.js rename to src/components/views/auth/AuthHeader.tsx index d9bd81adcb..cab7da1468 100644 --- a/src/components/views/auth/AuthHeader.js +++ b/src/components/views/auth/AuthHeader.tsx @@ -16,20 +16,17 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AuthHeaderLogo from "./AuthHeaderLogo"; +import LanguageSelector from "./LanguageSelector"; + +interface IProps { + disableLanguageSelector?: boolean; +} @replaceableComponent("views.auth.AuthHeader") -export default class AuthHeader extends React.Component { - static propTypes = { - disableLanguageSelector: PropTypes.bool, - }; - - render() { - const AuthHeaderLogo = sdk.getComponent('auth.AuthHeaderLogo'); - const LanguageSelector = sdk.getComponent('views.auth.LanguageSelector'); - +export default class AuthHeader extends React.Component { + public render(): React.ReactNode { return (
diff --git a/src/components/views/auth/AuthHeaderLogo.js b/src/components/views/auth/AuthHeaderLogo.tsx similarity index 95% rename from src/components/views/auth/AuthHeaderLogo.js rename to src/components/views/auth/AuthHeaderLogo.tsx index 0adf18dc1c..b6724793a5 100644 --- a/src/components/views/auth/AuthHeaderLogo.js +++ b/src/components/views/auth/AuthHeaderLogo.tsx @@ -19,7 +19,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.AuthHeaderLogo") export default class AuthHeaderLogo extends React.PureComponent { - render() { + public render(): React.ReactNode { return
Matrix
; diff --git a/src/components/views/auth/AuthPage.js b/src/components/views/auth/AuthPage.tsx similarity index 86% rename from src/components/views/auth/AuthPage.js rename to src/components/views/auth/AuthPage.tsx index 6ba47e5288..c402d5b699 100644 --- a/src/components/views/auth/AuthPage.js +++ b/src/components/views/auth/AuthPage.tsx @@ -17,18 +17,16 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AuthFooter from "./AuthFooter"; @replaceableComponent("views.auth.AuthPage") export default class AuthPage extends React.PureComponent { - render() { - const AuthFooter = sdk.getComponent('auth.AuthFooter'); - + public render(): React.ReactNode { return (
- {this.props.children} + { this.props.children }
diff --git a/src/components/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.tsx similarity index 66% rename from src/components/views/auth/CaptchaForm.js rename to src/components/views/auth/CaptchaForm.tsx index bea4f89f53..b1c09f2b22 100644 --- a/src/components/views/auth/CaptchaForm.js +++ b/src/components/views/auth/CaptchaForm.tsx @@ -15,66 +15,74 @@ limitations under the License. */ import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import CountlyAnalytics from "../../../CountlyAnalytics"; import { replaceableComponent } from "../../../utils/replaceableComponent"; const DIV_ID = 'mx_recaptcha'; +interface ICaptchaFormProps { + sitePublicKey: string; + onCaptchaResponse: (response: string) => void; +} + +interface ICaptchaFormState { + errorText?: string; + +} + /** * A pure UI component which displays a captcha form. */ @replaceableComponent("views.auth.CaptchaForm") -export default class CaptchaForm extends React.Component { - static propTypes = { - sitePublicKey: PropTypes.string, - - // called with the captcha response - onCaptchaResponse: PropTypes.func, - }; - +export default class CaptchaForm extends React.Component { static defaultProps = { onCaptchaResponse: () => {}, }; - constructor(props) { + private captchaWidgetId?: string; + private recaptchaContainer = createRef(); + + constructor(props: ICaptchaFormProps) { super(props); this.state = { - errorText: null, + errorText: undefined, }; - this._captchaWidgetId = null; - - this._recaptchaContainer = createRef(); - CountlyAnalytics.instance.track("onboarding_grecaptcha_begin"); } componentDidMount() { // Just putting a script tag into the returned jsx doesn't work, annoyingly, // so we do this instead. - if (global.grecaptcha) { + if (this.isRecaptchaReady()) { // already loaded - this._onCaptchaLoaded(); + this.onCaptchaLoaded(); } else { console.log("Loading recaptcha script..."); - window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();}; + window.mxOnRecaptchaLoaded = () => { this.onCaptchaLoaded(); }; const scriptTag = document.createElement('script'); scriptTag.setAttribute( - 'src', `https://www.recaptcha.net/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit`, + 'src', `https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit`, ); - this._recaptchaContainer.current.appendChild(scriptTag); + this.recaptchaContainer.current.appendChild(scriptTag); } } componentWillUnmount() { - this._resetRecaptcha(); + this.resetRecaptcha(); } - _renderRecaptcha(divId) { - if (!global.grecaptcha) { + // Borrowed directly from: https://github.com/codeep/react-recaptcha-google/commit/e118fa5670fa268426969323b2e7fe77698376ba + private isRecaptchaReady(): boolean { + return typeof window !== "undefined" && + typeof global.grecaptcha !== "undefined" && + typeof global.grecaptcha.render === 'function'; + } + + private renderRecaptcha(divId: string) { + if (!this.isRecaptchaReady()) { console.error("grecaptcha not loaded!"); throw new Error("Recaptcha did not load successfully"); } @@ -84,26 +92,26 @@ export default class CaptchaForm extends React.Component { console.error("No public key for recaptcha!"); throw new Error( "This server has not supplied enough information for Recaptcha " - + "authentication"); + + "authentication"); } console.info("Rendering to %s", divId); - this._captchaWidgetId = global.grecaptcha.render(divId, { + this.captchaWidgetId = global.grecaptcha.render(divId, { sitekey: publicKey, callback: this.props.onCaptchaResponse, }); } - _resetRecaptcha() { - if (this._captchaWidgetId !== null) { - global.grecaptcha.reset(this._captchaWidgetId); + private resetRecaptcha() { + if (this.captchaWidgetId !== null) { + global.grecaptcha.reset(this.captchaWidgetId); } } - _onCaptchaLoaded() { + private onCaptchaLoaded() { console.log("Loaded recaptcha script."); try { - this._renderRecaptcha(DIV_ID); + this.renderRecaptcha(DIV_ID); // clear error if re-rendered this.setState({ errorText: null, @@ -128,10 +136,10 @@ export default class CaptchaForm extends React.Component { } return ( -
-

{_t( +

+

{ _t( "This homeserver would like to make sure you are not a robot.", - )}

+ ) }

{ error }
diff --git a/src/components/views/auth/CompleteSecurityBody.js b/src/components/views/auth/CompleteSecurityBody.tsx similarity index 95% rename from src/components/views/auth/CompleteSecurityBody.js rename to src/components/views/auth/CompleteSecurityBody.tsx index 745d7abbf2..8f6affb64e 100644 --- a/src/components/views/auth/CompleteSecurityBody.js +++ b/src/components/views/auth/CompleteSecurityBody.tsx @@ -19,7 +19,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; @replaceableComponent("views.auth.CompleteSecurityBody") export default class CompleteSecurityBody extends React.PureComponent { - render() { + public render(): React.ReactNode { return
{ this.props.children }
; diff --git a/src/components/views/auth/CountryDropdown.js b/src/components/views/auth/CountryDropdown.tsx similarity index 75% rename from src/components/views/auth/CountryDropdown.js rename to src/components/views/auth/CountryDropdown.tsx index cbc19e0f8d..eb5b27be9d 100644 --- a/src/components/views/auth/CountryDropdown.js +++ b/src/components/views/auth/CountryDropdown.tsx @@ -15,21 +15,19 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; - -import { COUNTRIES, getEmojiFlag } from '../../../phonenumber'; +import { COUNTRIES, getEmojiFlag, PhoneNumberCountryDefinition } from '../../../phonenumber'; import SdkConfig from "../../../SdkConfig"; import { _t } from "../../../languageHandler"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Dropdown from "../elements/Dropdown"; const COUNTRIES_BY_ISO2 = {}; for (const c of COUNTRIES) { COUNTRIES_BY_ISO2[c.iso2] = c; } -function countryMatchesSearchQuery(query, country) { +function countryMatchesSearchQuery(query: string, country: PhoneNumberCountryDefinition): boolean { // Remove '+' if present (when searching for a prefix) if (query[0] === '+') { query = query.slice(1); @@ -41,15 +39,26 @@ function countryMatchesSearchQuery(query, country) { return false; } -@replaceableComponent("views.auth.CountryDropdown") -export default class CountryDropdown extends React.Component { - constructor(props) { - super(props); - this._onSearchChange = this._onSearchChange.bind(this); - this._onOptionChange = this._onOptionChange.bind(this); - this._getShortOption = this._getShortOption.bind(this); +interface IProps { + value?: string; + onOptionChange: (country: PhoneNumberCountryDefinition) => void; + isSmall: boolean; // if isSmall, show +44 in the selected value + showPrefix: boolean; + className?: string; + disabled?: boolean; +} - let defaultCountry = COUNTRIES[0]; +interface IState { + searchQuery: string; + defaultCountry: PhoneNumberCountryDefinition; +} + +@replaceableComponent("views.auth.CountryDropdown") +export default class CountryDropdown extends React.Component { + constructor(props: IProps) { + super(props); + + let defaultCountry: PhoneNumberCountryDefinition = COUNTRIES[0]; const defaultCountryCode = SdkConfig.get()["defaultCountryCode"]; if (defaultCountryCode) { const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase()); @@ -62,7 +71,7 @@ export default class CountryDropdown extends React.Component { }; } - componentDidMount() { + public componentDidMount(): void { if (!this.props.value) { // If no value is given, we start with the default // country selected, but our parent component @@ -71,21 +80,21 @@ export default class CountryDropdown extends React.Component { } } - _onSearchChange(search) { + private onSearchChange = (search: string): void => { this.setState({ searchQuery: search, }); - } + }; - _onOptionChange(iso2) { + private onOptionChange = (iso2: string): void => { this.props.onOptionChange(COUNTRIES_BY_ISO2[iso2]); - } + }; - _flagImgForIso2(iso2) { + private flagImgForIso2(iso2: string): React.ReactNode { return
{ getEmojiFlag(iso2) }
; } - _getShortOption(iso2) { + private getShortOption = (iso2: string): React.ReactNode => { if (!this.props.isSmall) { return undefined; } @@ -94,14 +103,12 @@ export default class CountryDropdown extends React.Component { countryPrefix = '+' + COUNTRIES_BY_ISO2[iso2].prefix; } return - { this._flagImgForIso2(iso2) } + { this.flagImgForIso2(iso2) } { countryPrefix } ; - } - - render() { - const Dropdown = sdk.getComponent('elements.Dropdown'); + }; + public render(): React.ReactNode { let displayedCountries; if (this.state.searchQuery) { displayedCountries = COUNTRIES.filter( @@ -124,7 +131,7 @@ export default class CountryDropdown extends React.Component { const options = displayedCountries.map((country) => { return
- { this._flagImgForIso2(country.iso2) } + { this.flagImgForIso2(country.iso2) } { _t(country.name) } (+{ country.prefix })
; }); @@ -136,10 +143,10 @@ export default class CountryDropdown extends React.Component { return ; } } - -CountryDropdown.propTypes = { - className: PropTypes.string, - isSmall: PropTypes.bool, - // if isSmall, show +44 in the selected value - showPrefix: PropTypes.bool, - onOptionChange: PropTypes.func.isRequired, - value: PropTypes.string, - disabled: PropTypes.bool, -}; diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index e002eb5717..763ce10cd9 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -18,7 +18,6 @@ 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"; @@ -26,6 +25,8 @@ import Spinner from "../elements/Spinner"; import CountlyAnalytics from "../../../CountlyAnalytics"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { LocalisedPolicy, Policies } from '../../../Terms'; +import Field from '../elements/Field'; +import CaptchaForm from "./CaptchaForm"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -40,7 +41,7 @@ import { LocalisedPolicy, Policies } from '../../../Terms'; * one HS whilst beign a guest on another). * loginType: the login type of the auth stage being attempted * authSessionId: session id from the server - * clientSecret: The client secret in use for ID server auth sessions + * clientSecret: The client secret in use for identity server auth sessions * stageParams: params from the server for the stage being attempted * errorText: error message from a previous attempt to authenticate * submitAuthDict: a function which will be called with the new auth dict @@ -53,8 +54,8 @@ import { LocalisedPolicy, Policies } from '../../../Terms'; * Defined keys for stages are: * m.login.email.identity: * * emailSid: string representing the sid of the active - * verification session from the ID server, or - * null if no session is active. + * verification session from the identity server, + * or null if no session is active. * fail: a function which should be called with an error object if an * error occurred during the auth stage. This will cause the auth * session to be failed and the process to go back to the start. @@ -164,8 +165,7 @@ export class PasswordAuthEntry extends React.Component; + submitButtonOrSpinner = ; } else { submitButtonOrSpinner = (

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

@@ -236,13 +234,11 @@ export class RecaptchaAuthEntry extends React.Component; + 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( @@ -390,8 +386,7 @@ export class TermsAuthEntry extends React.Component; + return ; } const checkboxes = []; @@ -422,12 +417,12 @@ export class TermsAuthEntry extends React.Component{_t("Accept")}; + onClick={this.trySubmit} disabled={!allChecked}>{ _t("Accept") }; } return (
-

{_t("Please review and accept the policies of this homeserver:")}

+

{ _t("Please review and accept the policies of this homeserver:") }

{ checkboxes } { errorSection } { submitButton } @@ -590,8 +585,7 @@ export class MsisdnAuthEntry extends React.Component; + return ; } else { const enableSubmit = Boolean(this.state.token); const submitClasses = classNames({ @@ -619,7 +613,7 @@ export class MsisdnAuthEntry extends React.Component
- {errorSection} + { errorSection }
); @@ -723,21 +717,21 @@ export class SSOAuthEntry extends React.Component{_t("Cancel")} + >{ _t("Cancel") } ); if (this.state.phase === SSOAuthEntry.PHASE_PREAUTH) { continueButton = ( {this.props.continueText || _t("Single Sign On")} + >{ this.props.continueText || _t("Single Sign On") } ); } else { continueButton = ( {this.props.continueText || _t("Confirm")} + >{ this.props.continueText || _t("Confirm") } ); } @@ -759,8 +753,8 @@ export class SSOAuthEntry extends React.Component { errorSection }
- {cancelButton} - {continueButton} + { cancelButton } + { continueButton }
; } @@ -831,7 +825,7 @@ export class FallbackAuthEntry extends React.Component { { _t("Start authentication") } - {errorSection} + { errorSection }
); } diff --git a/src/components/views/auth/LanguageSelector.js b/src/components/views/auth/LanguageSelector.tsx similarity index 85% rename from src/components/views/auth/LanguageSelector.js rename to src/components/views/auth/LanguageSelector.tsx index 88293310e7..c26b4797f3 100644 --- a/src/components/views/auth/LanguageSelector.js +++ b/src/components/views/auth/LanguageSelector.tsx @@ -18,21 +18,23 @@ import SdkConfig from "../../../SdkConfig"; 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 LanguageDropdown from "../elements/LanguageDropdown"; -function onChange(newLang) { +function onChange(newLang: string): void { if (getCurrentLanguage() !== newLang) { SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang); PlatformPeg.get().reload(); } } -export default function LanguageSelector({ disabled }) { - if (SdkConfig.get()['disable_login_language_selector']) return
; +interface IProps { + disabled?: boolean; +} - const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown'); +export default function LanguageSelector({ disabled }: IProps): JSX.Element { + if (SdkConfig.get()['disable_login_language_selector']) return
; return >; - loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone, - password: "", + loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone; + password: ""; } enum LoginField { @@ -416,7 +416,7 @@ export default class PasswordLogin extends React.PureComponent { kind="link" onClick={this.onForgotPasswordClick} > - {_t("Forgot password?")} + { _t("Forgot password?") } ; } @@ -441,16 +441,16 @@ export default class PasswordLogin extends React.PureComponent { disabled={this.props.disableSubmit} >
@@ -460,8 +460,8 @@ export default class PasswordLogin extends React.PureComponent { return (
- {loginType} - {loginField} + { loginType } + { loginField } { onValidate={this.onPasswordValidate} ref={field => this[LoginField.Password] = field} /> - {forgotPasswordJsx} + { forgotPasswordJsx } { !this.props.busy &&
- {this.renderUsername()} + { this.renderUsername() }
- {this.renderPassword()} - {this.renderPasswordConfirm()} + { this.renderPassword() } + { this.renderPasswordConfirm() }
- {this.renderEmail()} - {this.renderPhoneNumber()} + { this.renderEmail() } + { this.renderPhoneNumber() }
{ emailHelperText } { registerButton } diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.tsx similarity index 83% rename from src/components/views/auth/Welcome.js rename to src/components/views/auth/Welcome.tsx index e3f7a601f2..0e12025fbd 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import classNames from "classnames"; -import * as sdk from '../../../index'; +import * as sdk from "../../../index"; import SdkConfig from '../../../SdkConfig'; import AuthPage from "./AuthPage"; import { _td } from "../../../languageHandler"; @@ -25,21 +25,26 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import LanguageSelector from "./LanguageSelector"; // translatable strings for Welcome pages _td("Sign in with SSO"); +interface IProps { + +} + @replaceableComponent("views.auth.Welcome") -export default class Welcome extends React.PureComponent { - constructor(props) { +export default class Welcome extends React.PureComponent { + constructor(props: IProps) { super(props); CountlyAnalytics.instance.track("onboarding_welcome"); } - render() { - const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); - const LanguageSelector = sdk.getComponent('auth.LanguageSelector'); + public render(): React.ReactNode { + // FIXME: Using an import will result in wrench-element-tests failures + const EmbeddedPage = sdk.getComponent("structures.EmbeddedPage"); const pagesConfig = SdkConfig.get().embeddedPages; let pageUrl = null; diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index 950caefa02..99f2b70efc 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -30,13 +30,14 @@ import { _t } from "../../../languageHandler"; import TextWithTooltip from "../elements/TextWithTooltip"; import DMRoomMap from "../../../utils/DMRoomMap"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { IOOBData } from "../../../stores/ThreepidInviteStore"; interface IProps { room: Room; avatarSize: number; displayBadge?: boolean; forceCount?: boolean; - oobData?: object; + oobData?: IOOBData; viewAvatarOnClick?: boolean; } @@ -204,8 +205,8 @@ export default class DecoratedRoomAvatar extends React.PureComponent - {icon} - {badge} + { icon } + { badge }
; } } diff --git a/src/components/views/avatars/MemberStatusMessageAvatar.js b/src/components/views/avatars/MemberStatusMessageAvatar.js index b8b23dc33e..82b7b8e400 100644 --- a/src/components/views/avatars/MemberStatusMessageAvatar.js +++ b/src/components/views/avatars/MemberStatusMessageAvatar.js @@ -145,7 +145,7 @@ export default class MemberStatusMessageAvatar extends React.Component { isExpanded={this.state.menuDisplayed} label={_t("User Status")} > - {avatar} + { avatar } { contextMenu } diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index c3f49d4a12..a07990c3bb 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -22,16 +22,17 @@ import ImageView from '../elements/ImageView'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import * as Avatar from '../../../Avatar'; +import DMRoomMap from "../../../utils/DMRoomMap"; 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; @@ -131,11 +132,14 @@ export default class RoomAvatar extends React.Component { const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props; const roomName = room ? room.name : oobData.name; + // If the room is a DM, we use the other user's ID for the color hash + // in order to match the room avatar with their avatar + const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : null; return ( diff --git a/src/components/views/beta/BetaCard.tsx b/src/components/views/beta/BetaCard.tsx index 3127e1a915..ec662d831b 100644 --- a/src/components/views/beta/BetaCard.tsx +++ b/src/components/views/beta/BetaCard.tsx @@ -105,7 +105,7 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
- { extraSettings &&
+ { extraSettings && value &&
{ extraSettings.map(key => ( )) } diff --git a/src/components/views/context_menus/CallContextMenu.tsx b/src/components/views/context_menus/CallContextMenu.tsx index 428e18ed30..a61cdeedd3 100644 --- a/src/components/views/context_menus/CallContextMenu.tsx +++ b/src/components/views/context_menus/CallContextMenu.tsx @@ -53,7 +53,7 @@ export default class CallContextMenu extends React.Component { onTransferClick = () => { Modal.createTrackedDialog( 'Transfer Call', '', InviteDialog, { kind: KIND_CALL_TRANSFER, call: this.props.call }, - /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + /*className=*/"mx_InviteDialog_transferWrapper", /*isPriority=*/false, /*isStatic=*/true, ); this.props.onFinished(); }; @@ -65,15 +65,15 @@ export default class CallContextMenu extends React.Component { let transferItem; if (this.props.call.opponentCanBeTransferred()) { transferItem = - {_t("Transfer")} + { _t("Transfer") } ; } return - {holdUnholdCaption} + { holdUnholdCaption } - {transferItem} + { transferItem } ; } } diff --git a/src/components/views/context_menus/DialpadContextMenu.tsx b/src/components/views/context_menus/DialpadContextMenu.tsx index 28a73ba8d4..39dfd50795 100644 --- a/src/components/views/context_menus/DialpadContextMenu.tsx +++ b/src/components/views/context_menus/DialpadContextMenu.tsx @@ -15,11 +15,11 @@ limitations under the License. */ import React from 'react'; -import { _t } from '../../../languageHandler'; +import AccessibleButton from "../elements/AccessibleButton"; import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import Field from "../elements/Field"; -import Dialpad from '../voip/DialPad'; +import DialPad from '../voip/DialPad'; import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends IContextMenuProps { @@ -45,24 +45,29 @@ export default class DialpadContextMenu extends React.Component this.setState({ value: this.state.value + digit }); }; + onCancelClick = () => { + this.props.onFinished(); + }; + onChange = (ev) => { this.setState({ value: ev.target.value }); }; render() { return -
+
- {_t("Dial pad")} + +
+
+ +
+
+
- -
-
-
-
; } diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index a9c75bf3ba..1d822fd246 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -64,8 +64,8 @@ export const IconizedContextMenuRadio: React.FC = ({ label={label} > - {label} - {active && } + { label } + { active && } ; }; @@ -85,15 +85,15 @@ export const IconizedContextMenuCheckbox: React.FC = ({ label={label} > - {label} - {active && } + { label } + { active && } ; }; export const IconizedContextMenuOption: React.FC = ({ label, iconClassName, ...props }) => { return { iconClassName && } - {label} + { label } ; }; @@ -104,7 +104,7 @@ export const IconizedContextMenuOptionList: React.FC = ({ firs }); return
- {children} + { children }
; }; diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.tsx similarity index 71% rename from src/components/views/context_menus/MessageContextMenu.js rename to src/components/views/context_menus/MessageContextMenu.tsx index a2086451cd..8f5d3baa17 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -1,6 +1,6 @@ /* Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2015, 2016, 2018, 2019, 2021 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,12 +16,11 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import { EventStatus } from 'matrix-js-sdk/src/models/event'; +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 * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; import Resend from '../../../Resend'; @@ -29,53 +28,69 @@ import SettingsStore from '../../../settings/SettingsStore'; import { isUrlPermitted } from '../../../HtmlUtils'; import { isContentActionable } from '../../../utils/EventUtils'; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu'; -import { EventType } from "matrix-js-sdk/src/@types/event"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard"; import ForwardDialog from "../dialogs/ForwardDialog"; import { Action } from "../../../dispatcher/actions"; +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) { +export function canCancel(eventStatus: EventStatus): boolean { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; } +export interface IEventTileOps { + isWidgetHidden(): boolean; + unhideWidget(): void; +} + +export interface IOperableEventTile { + getEventTileOps(): IEventTileOps; +} + +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 { - 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, - }; - +export default class MessageContextMenu extends React.Component { state = { canRedact: false, canPin: false, }; componentDidMount() { - MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions); - this._checkPermissions(); + MatrixClientPeg.get().on('RoomMember.powerLevel', this.checkPermissions); + this.checkPermissions(); } componentWillUnmount() { const cli = MatrixClientPeg.get(); if (cli) { - cli.removeListener('RoomMember.powerLevel', this._checkPermissions); + cli.removeListener('RoomMember.powerLevel', this.checkPermissions); } } - _checkPermissions = () => { + private checkPermissions = (): void => { const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.mxEvent.getRoomId()); @@ -93,7 +108,7 @@ export default class MessageContextMenu extends React.Component { this.setState({ canRedact, canPin }); }; - _isPinned() { + private isPinned(): boolean { const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, ''); if (!pinnedEvent) return false; @@ -101,38 +116,35 @@ export default class MessageContextMenu extends React.Component { return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); } - onResendReactionsClick = () => { - for (const reaction of this._getUnsentReactions()) { + private onResendReactionsClick = (): void => { + for (const reaction of this.getUnsentReactions()) { Resend.resend(reaction); } this.closeMenu(); }; - onReportEventClick = () => { - const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog"); + private onReportEventClick = (): void => { Modal.createTrackedDialog('Report Event', '', ReportEventDialog, { mxEvent: this.props.mxEvent, }, 'mx_Dialog_reportEvent'); this.closeMenu(); }; - onViewSourceClick = () => { - const ViewSource = sdk.getComponent('structures.ViewSource'); + private onViewSourceClick = (): void => { Modal.createTrackedDialog('View Event Source', '', ViewSource, { mxEvent: this.props.mxEvent, }, 'mx_Dialog_viewsource'); this.closeMenu(); }; - onRedactClick = () => { - const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); + private onRedactClick = (): void => { Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { - onFinished: async (proceed, reason) => { + onFinished: async (proceed: boolean, reason?: string) => { if (!proceed) return; const cli = MatrixClientPeg.get(); try { - if (this.props.onCloseDialog) this.props.onCloseDialog(); + this.props.onCloseDialog?.(); await cli.redactEvent( this.props.mxEvent.getRoomId(), this.props.mxEvent.getId(), @@ -145,7 +157,6 @@ export default class MessageContextMenu extends React.Component { // (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'), @@ -158,7 +169,7 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - onForwardClick = () => { + private onForwardClick = (): void => { Modal.createTrackedDialog('Forward Message', '', ForwardDialog, { matrixClient: MatrixClientPeg.get(), event: this.props.mxEvent, @@ -167,12 +178,12 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - onPinClick = () => { + 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, "")?.pinned || []; + const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || []; if (pinnedIds.includes(eventId)) { pinnedIds.splice(pinnedIds.indexOf(eventId), 1); } else { @@ -188,18 +199,16 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - closeMenu = () => { - if (this.props.onFinished) this.props.onFinished(); + private closeMenu = (): void => { + this.props.onFinished(); }; - onUnhidePreviewClick = () => { - if (this.props.eventTileOps) { - this.props.eventTileOps.unhideWidget(); - } + private onUnhidePreviewClick = (): void => { + this.props.eventTileOps?.unhideWidget(); this.closeMenu(); }; - onQuoteClick = () => { + private onQuoteClick = (): void => { dis.dispatch({ action: Action.ComposerInsert, event: this.props.mxEvent, @@ -207,9 +216,8 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - onPermalinkClick = (e) => { + private onPermalinkClick = (e: React.MouseEvent): void => { e.preventDefault(); - const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { target: this.props.mxEvent, permalinkCreator: this.props.permalinkCreator, @@ -217,30 +225,27 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - onCollapseReplyThreadClick = () => { + private onCollapseReplyThreadClick = (): void => { this.props.collapseReplyThread(); this.closeMenu(); }; - _getReactions(filter) { + 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 && - relation.rel_type === "m.annotation" && - relation.event_id === eventId && - filter(e); + return relation?.rel_type === RelationType.Annotation && relation.event_id === eventId && filter(e); }); } - _getPendingReactions() { - return this._getReactions(e => canCancel(e.status)); + private getPendingReactions(): MatrixEvent[] { + return this.getReactions(e => canCancel(e.status)); } - _getUnsentReactions() { - return this._getReactions(e => e.status === EventStatus.NOT_SENT); + private getUnsentReactions(): MatrixEvent[] { + return this.getReactions(e => e.status === EventStatus.NOT_SENT); } render() { @@ -248,16 +253,17 @@ export default class MessageContextMenu extends React.Component { 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; - let redactItemList; + 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; @@ -266,7 +272,7 @@ export default class MessageContextMenu extends React.Component { resendReactionsButton = ( ); @@ -296,7 +302,7 @@ export default class MessageContextMenu extends React.Component { pinButton = ( ); @@ -327,16 +333,20 @@ export default class MessageContextMenu extends React.Component { 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 = ( ); @@ -351,18 +361,23 @@ export default class MessageContextMenu extends React.Component { } // 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) + if (typeof (mxEvent.getContent().external_url) === "string" && + isUrlPermitted(mxEvent.getContent().external_url) ) { externalURLButton = ( ); } @@ -377,7 +392,7 @@ export default class MessageContextMenu extends React.Component { ); } - let reportEventButton; + let reportEventButton: JSX.Element; if (mxEvent.getSender() !== me) { reportEventButton = ( - {_t("Clear status")} + { _t("Clear status") } ; } else { actionButton = - {_t("Update status")} + { _t("Update status") } ; } } else { actionButton = - {_t("Set status")} + { _t("Set status") } ; } @@ -130,8 +130,8 @@ export default class StatusMessageContextMenu extends React.Component { onChange={this._onStatusChange} />
- {actionButton} - {spinner} + { actionButton } + { spinner }
; diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index c09097c4b4..0f78b971eb 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -18,6 +18,7 @@ import React, { ReactNode, useContext, useMemo, useState } from "react"; import classNames from "classnames"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { sleep } from "matrix-js-sdk/src/utils"; import { _t } from '../../../languageHandler'; import { IDialogProps } from "./IDialogProps"; @@ -29,7 +30,6 @@ import RoomAvatar from "../avatars/RoomAvatar"; import { getDisplayAliasForRoom } from "../../../Rooms"; import AccessibleButton from "../elements/AccessibleButton"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import { sleep } from "../../../utils/promise"; import DMRoomMap from "../../../utils/DMRoomMap"; import { calculateRoomVia } from "../../../utils/permalinks/Permalinks"; import StyledCheckbox from "../elements/StyledCheckbox"; @@ -221,7 +221,7 @@ export const AddExistingToSpace: React.FC = ({ return
- +
; } @@ -690,7 +690,7 @@ export default class AddressPickerDialog extends React.Component { && this.props.validAddressTypes.includes('email')) { const defaultIdentityServerUrl = getDefaultIdentityServerUrl(); if (defaultIdentityServerUrl) { - identityServer =
{_t( + identityServer =
{ _t( "Use an identity server to invite by email. " + "Use the default (%(defaultIdentityServerName)s) " + "or manage in Settings.", @@ -698,25 +698,25 @@ export default class AddressPickerDialog extends React.Component { defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl), }, { - default: sub => {sub}, - settings: sub => {sub}, + default: sub => { sub }, + settings: sub => { sub }, }, - )}
; + ) }
; } else { - identityServer =
{_t( + identityServer =
{ _t( "Use an identity server to invite by email. " + "Manage in Settings.", {}, { - settings: sub => {sub}, + settings: sub => { sub }, }, - )}
; + ) }
; } } return ( - {inputLabel} + { inputLabel }
{ query }
{ error } diff --git a/src/components/views/dialogs/AskInviteAnywayDialog.tsx b/src/components/views/dialogs/AskInviteAnywayDialog.tsx index 970883aca2..3ae82f1026 100644 --- a/src/components/views/dialogs/AskInviteAnywayDialog.tsx +++ b/src/components/views/dialogs/AskInviteAnywayDialog.tsx @@ -15,11 +15,11 @@ limitations under the License. */ import React from 'react'; -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 BaseDialog from "./BaseDialog"; interface IProps { unknownProfileUsers: Array<{ @@ -50,10 +50,8 @@ export default class AskInviteAnywayDialog extends React.Component { }; public render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const errorList = this.props.unknownProfileUsers - .map(address =>
  • {address.userId}: {address.errorText}
  • ); + .map(address =>
  • { address.userId }: { address.errorText }
  • ); return ( { contentId='mx_Dialog_content' >
    - {/* eslint-disable-next-line */} -

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

    +

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

      { errorList }
    diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index e92bd6315e..8ccc485d7c 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -149,7 +149,7 @@ export default class BaseDialog extends React.Component { 'mx_Dialog_headerWithCancel': !!cancelButton, })}>
    - {headerImage} + { headerImage } { this.props.title }
    { this.props.headerButton } diff --git a/src/components/views/dialogs/BetaFeedbackDialog.tsx b/src/components/views/dialogs/BetaFeedbackDialog.tsx index 5a2f16f169..917004dbc7 100644 --- a/src/components/views/dialogs/BetaFeedbackDialog.tsx +++ b/src/components/views/dialogs/BetaFeedbackDialog.tsx @@ -69,7 +69,7 @@ const BetaFeedbackDialog: React.FC = ({ featureId, onFinished }) => {
    { _t(info.feedbackSubheading) }   - { _t("Your platform and username will be noted to help us use your feedback as much as we can.")} + { _t("Your platform and username will be noted to help us use your feedback as much as we can.") } { onFinished(false); diff --git a/src/components/views/dialogs/BugReportDialog.tsx b/src/components/views/dialogs/BugReportDialog.tsx index eeb3769bf9..64e984fe20 100644 --- a/src/components/views/dialogs/BugReportDialog.tsx +++ b/src/components/views/dialogs/BugReportDialog.tsx @@ -18,13 +18,17 @@ limitations under the License. */ import React from 'react'; -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 AccessibleButton from "../elements/AccessibleButton"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import QuestionDialog from "./QuestionDialog"; +import BaseDialog from "./BaseDialog"; +import Field from '../elements/Field'; +import Spinner from "../elements/Spinner"; +import DialogButtons from "../elements/DialogButtons"; interface IProps { onFinished: (success: boolean) => void; @@ -93,7 +97,6 @@ export default class BugReportDialog extends React.Component { }).then(() => { 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'), @@ -160,15 +163,10 @@ export default class BugReportDialog extends React.Component { }; public 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'); - let error = null; if (this.state.err) { error =
    - {this.state.err} + { this.state.err }
    ; } @@ -176,8 +174,8 @@ export default class BugReportDialog extends React.Component { if (this.state.busy) { progress = (
    - - {this.state.progress} ... + + { this.state.progress } ...
    ); } @@ -223,7 +221,7 @@ export default class BugReportDialog extends React.Component { { _t("Download logs") } - {this.state.downloadProgress && {this.state.downloadProgress} ...} + { this.state.downloadProgress && { this.state.downloadProgress } ... }
    { "please include those things here.", )} /> - {progress} - {error} + { progress } + { error }
    */ import React from 'react'; -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; @@ -58,16 +59,13 @@ export default class ChangelogDialog extends React.Component { return (
  • - {commit.commit.message.split('\n')[0]} + { commit.commit.message.split('\n')[0] }
  • ); } public render() { - const Spinner = sdk.getComponent('views.elements.Spinner'); - const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); - const logs = REPOS.map(repo => { let content; if (this.state[repo] == null) { @@ -81,15 +79,15 @@ export default class ChangelogDialog extends React.Component { } return (
    -

    {repo}

    -
      {content}
    +

    { repo }

    +
      { content }
    ); }); const content = (
    - {this.props.version == null || this.props.newVersion == null ?

    {_t("Unavailable")}

    : logs} + { this.props.version == null || this.props.newVersion == null ?

    { _t("Unavailable") }

    : logs }
    ); diff --git a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx index 7627489deb..73fd4def25 100644 --- a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx +++ b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx @@ -156,8 +156,8 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< height={avatarSize} />
    - {person.user.name} - {person.userId} + { person.user.name } + { person.userId }
    this.setPersonToggle(person, e.target.checked)} />
    @@ -187,7 +187,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< emailAddresses.push(( this.onAddressChange(e, emailAddresses.length)} label={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")} placeholder={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")} @@ -207,16 +207,16 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< onClick={this.onShowMorePeople} kind="link" key="more" className="mx_CommunityPrototypeInviteDialog_morePeople" - >{_t("Show more")} + >{ _t("Show more") } )); } } 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")} + { this.state.showPeople ? _t("Hide") : _t("Show") }
    ); @@ -236,14 +236,14 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< >
    - {emailAddresses} - {peopleIntro} - {people} + { emailAddresses } + { peopleIntro } + { people } {buttonText} + >{ buttonText }
    diff --git a/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx index 90b749b959..d21fde329c 100644 --- a/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx +++ b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx @@ -15,9 +15,12 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ConfirmRedactDialog from './ConfirmRedactDialog'; +import ErrorDialog from './ErrorDialog'; +import BaseDialog from "./BaseDialog"; +import Spinner from "../elements/Spinner"; interface IProps { redact: () => Promise; @@ -73,7 +76,6 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent ); } else { - const BaseDialog = sdk.getComponent("dialogs.BaseDialog"); - const Spinner = sdk.getComponent('elements.Spinner'); return ( ; } } diff --git a/src/components/views/dialogs/ConfirmRedactDialog.tsx b/src/components/views/dialogs/ConfirmRedactDialog.tsx index 94f29a71fc..a2f2b10144 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.tsx +++ b/src/components/views/dialogs/ConfirmRedactDialog.tsx @@ -15,9 +15,9 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import TextInputDialog from "./TextInputDialog"; interface IProps { onFinished: (success: boolean) => void; @@ -29,7 +29,6 @@ interface IProps { @replaceableComponent("views.dialogs.ConfirmRedactDialog") export default class ConfirmRedactDialog extends React.Component { render() { - const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog'); return ( { }; public 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"); - const confirmButtonClass = this.props.danger ? 'danger' : ''; let reasonBox; diff --git a/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx b/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx index 2978179817..2577d5456d 100644 --- a/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx +++ b/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx @@ -16,8 +16,9 @@ limitations under the License. import React from 'react'; import { _t } from "../../../languageHandler"; -import * as sdk from "../../../index"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; interface IProps { onFinished: (success: boolean) => void; @@ -34,9 +35,6 @@ export default class ConfirmWipeDeviceDialog extends React.Component { }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return ( { >

    - {_t( + { _t( "Clearing all data from this session is permanent. Encrypted messages will be lost " + "unless their keys have been backed up.", - )} + ) }

    - {_t("Community ID: +:%(domain)s", { + { _t("Community ID: +:%(domain)s", { domain: MatrixClientPeg.getHomeserverName(), }, { - localpart: () => {this.state.localpart}, - })} + localpart: () => { this.state.localpart }, + }) } - {_t("You can change this later if needed.")} + { _t("You can change this later if needed.") }
    ); if (this.state.error) { const classes = "mx_CreateCommunityPrototypeDialog_subtext mx_CreateCommunityPrototypeDialog_subtext_error"; helpText = ( - {this.state.error} + { this.state.error } ); } @@ -193,13 +193,13 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< placeholder={_t("Enter name")} label={_t("Enter name")} /> - {helpText} + { helpText } - {/*nbsp is to reserve the height of this element when there's nothing*/} -  {communityId} + { /*nbsp is to reserve the height of this element when there's nothing*/ } +  { communityId } - {_t("Create")} + { _t("Create") }
    @@ -212,12 +212,12 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent< onClick={this.onChangeAvatar} className="mx_CreateCommunityPrototypeDialog_avatarContainer" > - {preview} + { preview }
    - {_t("Add image (optional)")} + { _t("Add image (optional)") } - {_t("An image will help people identify your community.")} + { _t("An image will help people identify your community.") }
    diff --git a/src/components/views/dialogs/CreateGroupDialog.tsx b/src/components/views/dialogs/CreateGroupDialog.tsx index f03a40ddc5..88ae801441 100644 --- a/src/components/views/dialogs/CreateGroupDialog.tsx +++ b/src/components/views/dialogs/CreateGroupDialog.tsx @@ -15,11 +15,12 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; +import Spinner from "../elements/Spinner"; interface IProps { onFinished: (success: boolean) => void; @@ -101,14 +102,11 @@ export default class CreateGroupDialog extends React.Component { }); }; - _onCancel = () => { + private onCancel = () => { this.props.onFinished(false); }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const Spinner = sdk.getComponent('elements.Spinner'); - if (this.state.creating) { return ; } @@ -169,7 +167,7 @@ export default class CreateGroupDialog extends React.Component {
    -
    diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index b5c0096771..6d75b94c70 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -224,15 +224,15 @@ export default class CreateRoomDialog extends React.Component { ); } - let publicPrivateLabel =

    {_t( + let publicPrivateLabel =

    { _t( "Private rooms can be found and joined by invitation only. Public rooms can be " + "found and joined by anyone.", - )}

    ; + ) }

    ; if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { - publicPrivateLabel =

    {_t( + publicPrivateLabel =

    { _t( "Private rooms can be found and joined by invitation only. Public rooms can be " + "found and joined by anyone in this community.", - )}

    ; + ) }

    ; } let e2eeSection; @@ -250,7 +250,7 @@ export default class CreateRoomDialog extends React.Component { } e2eeSection = { onChange={this.onNoFederateChange} value={this.state.noFederate} /> -

    {federateLabel}

    +

    { federateLabel }

    diff --git a/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx b/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx index 2cdaf9cf4f..134c4ab79e 100644 --- a/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx +++ b/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx @@ -16,21 +16,22 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; +import QuestionDialog from "./QuestionDialog"; interface IProps { onFinished: (success: boolean) => void; } -export default (props: IProps) => { +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( @@ -58,8 +59,6 @@ export default (props: IProps) => { { 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.tsx b/src/components/views/dialogs/DeactivateAccountDialog.tsx index 6df6056670..7221df222f 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.tsx +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from 'react'; -import * as sdk from '../../../index'; import Analytics from '../../../Analytics'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import * as Lifecycle from '../../../Lifecycle'; @@ -26,6 +25,7 @@ import InteractiveAuth, { ERROR_USER_CANCELLED } from "../../structures/Interact import { DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import StyledCheckbox from "../elements/StyledCheckbox"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; interface IProps { onFinished: (success: boolean) => void; @@ -165,8 +165,6 @@ export default class DeactivateAccountDialog extends React.Component @@ -174,11 +172,11 @@ export default class DeactivateAccountDialog extends React.Component; } - let auth =
    {_t("Loading...")}
    ; + let auth =
    { _t("Loading...") }
    ; if (this.state.authData && this.state.authEnabled) { auth = (
    - {this.state.bodyText} + { this.state.bodyText } - {_t( + { _t( "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)", {}, { b: (sub) => { sub } }, - )} + ) }

    - {error} - {auth} + { error } + { auth }
    diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index de958f8e9a..61cda796ee 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -16,7 +16,6 @@ limitations under the License. */ import React, { useState, useEffect, ChangeEvent, MouseEvent } from 'react'; -import * as sdk from '../../../index'; import SyntaxHighlight from '../elements/SyntaxHighlight'; import { _t } from '../../../languageHandler'; import Field from "../elements/Field"; @@ -42,6 +41,8 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { SettingLevel } from '../../../settings/SettingLevel'; +import BaseDialog from "./BaseDialog"; +import TruncatedList from "../elements/TruncatedList"; interface IGenericEditorProps { onBack: () => void; @@ -336,7 +337,7 @@ class FilteredList extends React.PureComponent - {eventType} + { eventType } ; }) } @@ -726,17 +726,17 @@ const VerificationRequestExplorer: React.FC<{ return (
    Transaction
    -
    {txnId}
    +
    { txnId }
    Phase
    -
    {PHASE_MAP[request.phase] || request.phase}
    +
    { PHASE_MAP[request.phase] || request.phase }
    Timeout
    -
    {Math.floor(timeout / 1000)}
    +
    { Math.floor(timeout / 1000) }
    Methods
    -
    {request.methods && request.methods.join(", ")}
    +
    { request.methods && request.methods.join(", ") }
    requestingUserId
    -
    {request.requestingUserId}
    +
    { request.requestingUserId }
    observeOnly
    -
    {JSON.stringify(request.observeOnly)}
    +
    { JSON.stringify(request.observeOnly) }
    ); }; @@ -771,12 +771,12 @@ class VerificationExplorer extends React.PureComponent { return (
    - {Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) => + { Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) => , - )} + ) }
    - +
    ); } @@ -844,9 +844,9 @@ class WidgetExplorer extends React.Component ev.getId() === editWidget.eventId); if (!stateEv) { // "should never happen" return
    - {_t("There was an error finding this widget.")} + { _t("There was an error finding this widget.") }
    - +
    ; } @@ -865,17 +865,17 @@ class WidgetExplorer extends React.Component
    - {widgets.map(w => { + { widgets.map(w => { return ; - })} + >{ w.url }; + }) }
    - +
    ); } @@ -1007,7 +1007,7 @@ class SettingsExplorer extends React.PureComponent{canEdit.toString()}; + return
    ; } render() { @@ -1028,17 +1028,17 @@ class SettingsExplorer extends React.PureComponent - - - + + + - {allSettings.map(i => ( + { allSettings.map(i => ( - ))} + )) }
    {_t( + { _t( customVariables[row[0]].expl, customVariables[row[0]].getTextVariables ? customVariables[row[0]].getTextVariables() : null, - )}{ row[1] }
    { canEdit.toString() }
    {_t("Setting ID")}{_t("Value")}{_t("Value in this room")}{ _t("Setting ID") }{ _t("Value") }{ _t("Value in this room") }
    this.onViewClick(e, i)}> - {i} + { i } this.onEditClick(e, i)} className='mx_DevTools_SettingsExplorer_edit' @@ -1047,20 +1047,20 @@ class SettingsExplorer extends React.PureComponent - {this.renderSettingValue(SettingsStore.getValue(i))} + { this.renderSettingValue(SettingsStore.getValue(i)) } - {this.renderSettingValue(SettingsStore.getValue(i, room.roomId))} + { this.renderSettingValue(SettingsStore.getValue(i, room.roomId)) }
    - +
    ); @@ -1068,36 +1068,36 @@ class SettingsExplorer extends React.PureComponent
    -

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

    +

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

    - {_t("Caution:")} {_t( + { _t("Caution:") } { _t( "This UI does NOT check the types of the values. Use at your own risk.", - )} + ) }
    - {_t("Setting definition:")} -
    {JSON.stringify(SETTINGS[this.state.editSetting], null, 4)}
    + { _t("Setting definition:") } +
    { JSON.stringify(SETTINGS[this.state.editSetting], null, 4) }
    - - - + + + - {LEVEL_ORDER.map(lvl => ( + { LEVEL_ORDER.map(lvl => ( - - {this.renderCanEditLevel(null, lvl)} - {this.renderCanEditLevel(room.roomId, lvl)} + + { this.renderCanEditLevel(null, lvl) } + { this.renderCanEditLevel(room.roomId, lvl) } - ))} + )) }
    {_t("Level")}{_t("Settable at global")}{_t("Settable at room")}{ _t("Level") }{ _t("Settable at global") }{ _t("Settable at room") }
    {lvl}{ lvl }
    @@ -1122,8 +1122,8 @@ class SettingsExplorer extends React.PureComponent
    - - + +
    ); @@ -1131,39 +1131,39 @@ class SettingsExplorer extends React.PureComponent
    -

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

    +

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

    - {_t("Setting definition:")} -
    {JSON.stringify(SETTINGS[this.state.viewSetting], null, 4)}
    + { _t("Setting definition:") } +
    { JSON.stringify(SETTINGS[this.state.viewSetting], null, 4) }
    - {_t("Value:")}  - {this.renderSettingValue( + { _t("Value:") }  + { this.renderSettingValue( SettingsStore.getValue(this.state.viewSetting), - )} + ) }
    - {_t("Value in this room:")}  - {this.renderSettingValue( + { _t("Value in this room:") }  + { this.renderSettingValue( SettingsStore.getValue(this.state.viewSetting, room.roomId), - )} + ) }
    - {_t("Values at explicit levels:")} -
    {this.renderExplicitSettingValues(
    +                            { _t("Values at explicit levels:") }
    +                            
    { this.renderExplicitSettingValues(
                                     this.state.viewSetting, null,
    -                            )}
    + ) }
    - {_t("Values at explicit levels in this room:")} -
    {this.renderExplicitSettingValues(
    +                            { _t("Values at explicit levels in this room:") }
    +                            
    { this.renderExplicitSettingValues(
                                     this.state.viewSetting, room.roomId,
    -                            )}
    + ) }
    @@ -1171,7 +1171,7 @@ class SettingsExplorer extends React.PureComponent this.onEditClick(e, this.state.viewSetting)}>{ _t("Edit Values") } - + ); @@ -1232,12 +1232,12 @@ export default class DevtoolsDialog extends React.PureComponent if (this.state.mode) { body = - {(cli) => + { (cli) =>
    { this.state.mode.getLabel() }
    Room ID: { this.props.roomId }
    - } + } ; } else { const classes = "mx_DevTools_RoomStateExplorer_button"; @@ -1261,7 +1261,6 @@ export default class DevtoolsDialog extends React.PureComponent ; } - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( { body } diff --git a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx index 217e4f2d37..1eabb68081 100644 --- a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx +++ b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx @@ -151,16 +151,16 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent{preview} + >{ preview }
    - {_t("Add image (optional)")} + { _t("Add image (optional)") } - {_t("An image will help people identify your community.")} + { _t("An image will help people identify your community.") }
    - {_t("Save")} + { _t("Save") } diff --git a/src/components/views/dialogs/ErrorDialog.tsx b/src/components/views/dialogs/ErrorDialog.tsx index 0f675f0df7..56cd76237f 100644 --- a/src/components/views/dialogs/ErrorDialog.tsx +++ b/src/components/views/dialogs/ErrorDialog.tsx @@ -26,9 +26,9 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; interface IProps { onFinished: (success: boolean) => void; @@ -57,7 +57,6 @@ export default class ErrorDialog extends React.Component { }; public render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( { countlyFeedbackSection =
    -

    {_t("Rate %(brand)s", { brand })}

    +

    { _t("Rate %(brand)s", { brand }) }

    -

    {_t("Tell us below how you feel about %(brand)s so far.", { brand })}

    -

    {_t("Please go into as much detail as you like, so we can track down the problem.")}

    +

    { _t("Tell us below how you feel about %(brand)s so far.", { brand }) }

    +

    { _t("Please go into as much detail as you like, so we can track down the problem.") }

    { let subheading; if (hasFeedback) { subheading = ( -

    {_t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand })}

    +

    { _t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand }) }

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

    @@ -121,7 +121,7 @@ export default (props) => { { subheading }
    -

    {_t("Report a bug")}

    +

    { _t("Report a bug") }

    { _t("Please view existing bugs on Github first. " + "No match? Start a new one.", {}, { @@ -133,7 +133,7 @@ export default (props) => { }, }) }

    - {bugReports} + { bugReports }
    { countlyFeedbackSection } } diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index ba06436ae2..839ca6da2f 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -43,6 +43,7 @@ import QueryMatcher from "../../../autocomplete/QueryMatcher"; import TruncatedList from "../elements/TruncatedList"; import EntityTile from "../rooms/EntityTile"; import BaseAvatar from "../avatars/BaseAvatar"; +import SpaceStore from "../../../stores/SpaceStore"; const AVATAR_SIZE = 30; @@ -180,7 +181,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr const [query, setQuery] = useState(""); const lcQuery = query.toLowerCase(); - const spacesEnabled = useFeatureEnabled("feature_spaces"); + const spacesEnabled = SpaceStore.spacesEnabled; const flairEnabled = useFeatureEnabled(UIFeature.Flair); const previewLayout = useSettingValue("layout"); diff --git a/src/components/views/dialogs/HostSignupDialog.tsx b/src/components/views/dialogs/HostSignupDialog.tsx index 64c080bf01..4b8b7f32f0 100644 --- a/src/components/views/dialogs/HostSignupDialog.tsx +++ b/src/components/views/dialogs/HostSignupDialog.tsx @@ -177,32 +177,32 @@ export default class HostSignupDialog extends React.PureComponent

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

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

    ); @@ -241,12 +241,12 @@ export default class HostSignupDialog extends React.PureComponent - {this.state.minimized && + { this.state.minimized &&
    - {_t("%(hostSignupBrand)s Setup", { + { _t("%(hostSignupBrand)s Setup", { hostSignupBrand: this.config.brand, - })} + }) }
    } - {!this.state.minimized && + { !this.state.minimized &&
    } - {this.state.error && + { this.state.error &&
    - {this.state.error} + { this.state.error }
    } - {!this.state.error && + { !this.state.error &&