diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 3fb847ad18..0000000000 --- a/.babelrc +++ /dev/null @@ -1,21 +0,0 @@ -{ - "presets": [ - "react", - "es2015", - "es2016" - ], - "plugins": [ - [ - "transform-builtin-extend", - { - "globals": ["Error"] - } - ], - "transform-class-properties", - "transform-object-rest-spread", - "transform-async-to-bluebird", - "transform-runtime", - "add-module-exports", - "syntax-dynamic-import" - ] -} diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml deleted file mode 100644 index af55fe8cb4..0000000000 --- a/.buildkite/pipeline.yaml +++ /dev/null @@ -1,106 +0,0 @@ -steps: - - label: ":eslint: Lint" - command: - # TODO: Remove hacky chmod for BuildKite - - "echo '--- Setup'" - - "chmod +x ./scripts/ci/*.sh" - - "chmod +x ./scripts/*" - - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh" - - "yarn lintwithexclusions" - - "yarn stylelint" - plugins: - - docker#v3.0.1: - image: "node:10" - - - label: ":chains: End-to-End Tests" - agents: - # We use a medium sized instance instead of the normal small ones because - # e2e tests otherwise take +-8min - queue: "medium" - command: - # TODO: Remove hacky chmod for BuildKite - - "echo '--- Setup'" - - "chmod +x ./scripts/ci/*.sh" - - "chmod +x ./scripts/*" - - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh" - - "./scripts/ci/end-to-end-tests.sh" - plugins: - - docker#v3.0.1: - image: "matrixdotorg/riotweb-ci-e2etests-env:latest" - propagate-environment: true - - - label: ":karma: Tests" - agents: - # We use a medium sized instance instead of the normal small ones because - # webpack loves to gorge itself on resources. - queue: "medium" - command: - # Install chrome - - "echo '--- Installing Chrome'" - - "wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -" - - "sh -c 'echo \"deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main\" >> /etc/apt/sources.list.d/google.list'" - - "apt-get update" - - "apt-get install -y google-chrome-stable" - # Run tests - # TODO: Remove hacky chmod for BuildKite - - "chmod +x ./scripts/ci/*.sh" - - "chmod +x ./scripts/*" - - "echo '--- Installing Dependencies'" - - "./scripts/ci/install-deps.sh" - - "echo '+++ Running Tests'" - - "./scripts/ci/unit-tests.sh" - env: - CHROME_BIN: "/usr/bin/google-chrome-stable" - plugins: - - docker#v3.0.1: - image: "node:10" - propagate-environment: true - - - label: "🔧 Riot Tests" - agents: - # We use a medium sized instance instead of the normal small ones because - # webpack loves to gorge itself on resources. - queue: "medium" - command: - # Install chrome - - "echo '--- Installing Chrome'" - - "wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -" - - "sh -c 'echo \"deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main\" >> /etc/apt/sources.list.d/google.list'" - - "apt-get update" - - "apt-get install -y google-chrome-stable" - # Run tests - # TODO: Remove hacky chmod for BuildKite - - "chmod +x ./scripts/ci/*.sh" - - "chmod +x ./scripts/*" - - "echo '--- Installing Dependencies'" - - "./scripts/ci/install-deps.sh" - - "echo '+++ Running Tests'" - - "./scripts/ci/riot-unit-tests.sh" - env: - CHROME_BIN: "/usr/bin/google-chrome-stable" - plugins: - - docker#v3.0.1: - image: "node:10" - propagate-environment: true - - - label: "🌐 i18n" - command: - - "echo '--- Fetching Dependencies'" - - "yarn install" - - "echo '+++ Testing i18n output'" - - "yarn diff-i18n" - plugins: - - docker#v3.0.1: - image: "node:10" - - - wait - - - label: "🐴 Trigger riot-web" - trigger: "riot-web" - branches: "develop" - build: - branch: "develop" - message: "[react-sdk] ${BUILDKITE_MESSAGE}" - async: true diff --git a/.eslintignore b/.eslintignore index c4c7fe5067..c4f7298047 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,4 @@ src/component-index.js +test/end-to-end-tests/node_modules/ +test/end-to-end-tests/riot/ +test/end-to-end-tests/synapse/ diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 7d998f8c4b..ffd398cb14 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -33,7 +33,6 @@ src/components/views/rooms/RoomList.js src/components/views/rooms/RoomPreviewBar.js src/components/views/rooms/SearchBar.js src/components/views/rooms/SearchResultTile.js -src/components/views/rooms/SlateMessageComposer.js src/components/views/settings/ChangeAvatar.js src/components/views/settings/ChangePassword.js src/components/views/settings/DevicesPanel.js @@ -58,8 +57,11 @@ src/utils/Receipt.js src/Velociraptor.js test/components/structures/MessagePanel-test.js test/components/views/dialogs/InteractiveAuthDialog-test.js -test/components/views/rooms/MessageComposerInput-test.js test/mock-clock.js test/notifications/ContentRules-test.js test/notifications/PushRuleVectorState-test.js test/stores/RoomViewStore-test.js +src/component-index.js +test/end-to-end-tests/node_modules/ +test/end-to-end-tests/riot/ +test/end-to-end-tests/synapse/ diff --git a/.eslintrc.js b/.eslintrc.js index fdf0bb351e..6a0576c58a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,13 +5,17 @@ const path = require('path'); // but only if they come from a module that starts with eslint-config- // So we load the filename directly (and it could be in node_modules/ // or or ../node_modules/ etc) -const matrixJsSdkPath = path.dirname(require.resolve('matrix-js-sdk')); +// +// We add a `..` to the end because the js-sdk lives out of lib/, but the eslint +// config is at the project root. +const matrixJsSdkPath = path.join(path.dirname(require.resolve('matrix-js-sdk')), '..'); module.exports = { parser: "babel-eslint", extends: [matrixJsSdkPath + "/.eslintrc.js"], plugins: [ "react", + "react-hooks", "flowtype", "babel" ], @@ -24,6 +28,7 @@ module.exports = { parserOptions: { ecmaFeatures: { jsx: true, + legacyDecorators: true, } }, rules: { @@ -104,6 +109,9 @@ module.exports = { // crashes currently: https://github.com/eslint/eslint/issues/6274 "generator-star-spacing": "off", + + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", }, settings: { flowtype: { diff --git a/.stylelintrc.js b/.stylelintrc.js index f028c76cc0..1690f2186f 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -15,6 +15,7 @@ module.exports = { "number-leading-zero": null, "selector-list-comma-newline-after": null, "at-rule-no-unknown": null, + "no-descending-specificity": null, "scss/at-rule-no-unknown": [true, { // https://github.com/vector-im/riot-web/issues/10544 "ignoreAtRules": ["define-mixin"], diff --git a/CHANGELOG.md b/CHANGELOG.md index 89faf70d42..8d436ca690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,1298 @@ +Changes in [2.2.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.2.1) (2020-03-04) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.2.0...v2.2.1) + + * Adjust scroll offset with relative scrolling + [\#4171](https://github.com/matrix-org/matrix-react-sdk/pull/4171) + * Disable registration flows on SSO servers + [\#4169](https://github.com/matrix-org/matrix-react-sdk/pull/4169) + +Changes in [2.2.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.2.0) (2020-03-02) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.2.0-rc.1...v2.2.0) + + * Upgrade JS SDK to 5.1.0 + * Ignore cursor jumping shortcuts with shift + [\#4142](https://github.com/matrix-org/matrix-react-sdk/pull/4142) + +Changes in [2.2.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.2.0-rc.1) (2020-02-26) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.1.1...v2.2.0-rc.1) + + * Upgrade JS SDK to 5.1.0-rc.1 + * Fix message context menu breaking on invalid m.room.pinned_events event + [\#4133](https://github.com/matrix-org/matrix-react-sdk/pull/4133) + * Update from Weblate + [\#4134](https://github.com/matrix-org/matrix-react-sdk/pull/4134) + * Notify platform of language changes + [\#4121](https://github.com/matrix-org/matrix-react-sdk/pull/4121) + * Handle errors when previewing rooms more safely + [\#4132](https://github.com/matrix-org/matrix-react-sdk/pull/4132) + * Don't try to collapse zero events with a group + [\#4131](https://github.com/matrix-org/matrix-react-sdk/pull/4131) + * Don't print errors when the tab is used with no autocomplete present + [\#4130](https://github.com/matrix-org/matrix-react-sdk/pull/4130) + * Improve UI feedback while waiting for network + [\#4126](https://github.com/matrix-org/matrix-react-sdk/pull/4126) + * Ensure DMs tagged outside of account data work in the invite dialog + [\#4123](https://github.com/matrix-org/matrix-react-sdk/pull/4123) + * Show a warning dialog when user indicates a new session wasn't them + [\#4125](https://github.com/matrix-org/matrix-react-sdk/pull/4125) + * Show cancel events as hidden events if we wouldn't usually render them + [\#4120](https://github.com/matrix-org/matrix-react-sdk/pull/4120) + * Collapsed room list has unaligned room tiles #4030 version 2 + [\#4033](https://github.com/matrix-org/matrix-react-sdk/pull/4033) + * Check for cross-signing homeserver support + [\#4118](https://github.com/matrix-org/matrix-react-sdk/pull/4118) + * Don't leak if show_sas never comes (or already came) + [\#4119](https://github.com/matrix-org/matrix-react-sdk/pull/4119) + * Add verification request viewer in devtools + [\#4106](https://github.com/matrix-org/matrix-react-sdk/pull/4106) + * update phase when request prop changes + [\#4117](https://github.com/matrix-org/matrix-react-sdk/pull/4117) + * Handle file downloading locally in electron rather than sending to browser + [\#4113](https://github.com/matrix-org/matrix-react-sdk/pull/4113) + * Remove unused CIDER setting watcher + [\#4116](https://github.com/matrix-org/matrix-react-sdk/pull/4116) + * Use alt_aliases for pills and autocomplete + [\#4102](https://github.com/matrix-org/matrix-react-sdk/pull/4102) + * Add shortcuts for beginning / end of composer + [\#4108](https://github.com/matrix-org/matrix-react-sdk/pull/4108) + * Update from Weblate + [\#4115](https://github.com/matrix-org/matrix-react-sdk/pull/4115) + * Revert "Fix escaped markdown passing backslashes through" + [\#4114](https://github.com/matrix-org/matrix-react-sdk/pull/4114) + * Fix a couple of React warnings/errors + [\#4112](https://github.com/matrix-org/matrix-react-sdk/pull/4112) + * Fix two big DOM leaks which were locking Chrome solid. + [\#4111](https://github.com/matrix-org/matrix-react-sdk/pull/4111) + * Filter out empty strings when pasting IDs into the invite dialog + [\#4109](https://github.com/matrix-org/matrix-react-sdk/pull/4109) + * Remove buildkite pipeline + [\#4107](https://github.com/matrix-org/matrix-react-sdk/pull/4107) + * Use binary packing for verification QR codes + [\#4091](https://github.com/matrix-org/matrix-react-sdk/pull/4091) + * Fix several small bugs with the invite/DM dialog + [\#4099](https://github.com/matrix-org/matrix-react-sdk/pull/4099) + * ignore e2e tests node_modules during linting + [\#4103](https://github.com/matrix-org/matrix-react-sdk/pull/4103) + * Apply null-guard to room pills for when we can't fetch the room + [\#4104](https://github.com/matrix-org/matrix-react-sdk/pull/4104) + * Fix theme being overridden to light even after login is completed + [\#4105](https://github.com/matrix-org/matrix-react-sdk/pull/4105) + * Fix bug where SSSS could be overwritten if user never cross-signs + [\#4100](https://github.com/matrix-org/matrix-react-sdk/pull/4100) + * Accept canonical alias for pills + [\#4096](https://github.com/matrix-org/matrix-react-sdk/pull/4096) + * Fix: don't advertise ability to scan a QR code for verification + [\#4094](https://github.com/matrix-org/matrix-react-sdk/pull/4094) + * Fixes for printing event indexing stats. + [\#4082](https://github.com/matrix-org/matrix-react-sdk/pull/4082) + * Remove exec so release script continues + [\#4095](https://github.com/matrix-org/matrix-react-sdk/pull/4095) + * Use Persistent Storage where possible + [\#4092](https://github.com/matrix-org/matrix-react-sdk/pull/4092) + * Fix user page (missing null check) + [\#4088](https://github.com/matrix-org/matrix-react-sdk/pull/4088) + * Cancel verification request on dialog close + [\#4081](https://github.com/matrix-org/matrix-react-sdk/pull/4081) + * Fix various memory leaks due to method re-binding + [\#4093](https://github.com/matrix-org/matrix-react-sdk/pull/4093) + * Fix share message context menu option keyboard a11y + [\#4073](https://github.com/matrix-org/matrix-react-sdk/pull/4073) + +Changes in [2.1.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.1.1) (2020-02-19) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.1.0...v2.1.1) + + * show spinner while loading local aliases + [\#4090](https://github.com/matrix-org/matrix-react-sdk/pull/4090) + * Don't index key verification events. + [\#4083](https://github.com/matrix-org/matrix-react-sdk/pull/4083) + * Get rid of dependence on usercontent.riot.im + [\#4046](https://github.com/matrix-org/matrix-react-sdk/pull/4046) + * also detect aliases using new /aliases endpoint for room access settings + [\#4089](https://github.com/matrix-org/matrix-react-sdk/pull/4089) + * get local aliases from /aliases in room settings + [\#4086](https://github.com/matrix-org/matrix-react-sdk/pull/4086) + * Start verification sessions in an E2E DM where possible + [\#4080](https://github.com/matrix-org/matrix-react-sdk/pull/4080) + * Only show supported verification methods + [\#4077](https://github.com/matrix-org/matrix-react-sdk/pull/4077) + * Use local echo in VerificationRequest for accepting/declining a verification + request + [\#4072](https://github.com/matrix-org/matrix-react-sdk/pull/4072) + * Report installed PWA, touch input status in rageshakes, analytics + [\#4078](https://github.com/matrix-org/matrix-react-sdk/pull/4078) + * refactor event grouping into separate helper classes + [\#4059](https://github.com/matrix-org/matrix-react-sdk/pull/4059) + * Find existing requests when starting a new verification request + [\#4070](https://github.com/matrix-org/matrix-react-sdk/pull/4070) + * Always speak the full text of the typing indicator when it updates. + [\#4074](https://github.com/matrix-org/matrix-react-sdk/pull/4074) + * Fix escaped markdown passing backslashes through + [\#4008](https://github.com/matrix-org/matrix-react-sdk/pull/4008) + * Move the sidebar to below the sidebar tab buttons for screen readers. + [\#4071](https://github.com/matrix-org/matrix-react-sdk/pull/4071) + +Changes in [2.1.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.1.0) (2020-02-17) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.1.0-rc.2...v2.1.0) + + * Automate SDK dep upgrades for release + [\#4076](https://github.com/matrix-org/matrix-react-sdk/pull/4076) + +Changes in [2.1.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.1.0-rc.2) (2020-02-13) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.1.0-rc.1...v2.1.0-rc.2) + + * Fix error in previous attempt to upgrade JS SDK + +Changes in [2.1.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.1.0-rc.1) (2020-02-13) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.0.0...v2.1.0-rc.1) + + * Upgrade JS SDK to 5.0.0-rc.1 + * don't show tooltips on big icons + [\#4067](https://github.com/matrix-org/matrix-react-sdk/pull/4067) + * Update from Weblate + [\#4069](https://github.com/matrix-org/matrix-react-sdk/pull/4069) + * Fix sending of visit variables to Matomo + [\#4068](https://github.com/matrix-org/matrix-react-sdk/pull/4068) + * Use embedded piwik script rather than piwik.js to respect CSP + [\#4066](https://github.com/matrix-org/matrix-react-sdk/pull/4066) + * remove methods arg to requestVerification(DM) + [\#4058](https://github.com/matrix-org/matrix-react-sdk/pull/4058) + * Check for null config settings a bit safer + [\#4061](https://github.com/matrix-org/matrix-react-sdk/pull/4061) + * Score user ID searches higher when they match nearly exactly + [\#4060](https://github.com/matrix-org/matrix-react-sdk/pull/4060) + * Fix uncentered letter inside avatar for currently typing users + [\#4051](https://github.com/matrix-org/matrix-react-sdk/pull/4051) + * Disable 'start' button after clicking in VerificationPanel + [\#4065](https://github.com/matrix-org/matrix-react-sdk/pull/4065) + * Fixed bug where key reset didn't always return the right key + [\#4057](https://github.com/matrix-org/matrix-react-sdk/pull/4057) + * Don't render avatars in pills for screen readers. + [\#4062](https://github.com/matrix-org/matrix-react-sdk/pull/4062) + * Make QR self-verification compatible with RiotX + [\#4044](https://github.com/matrix-org/matrix-react-sdk/pull/4044) + * Verify single device from other user in right panel & Not Trusted dialog + [\#4043](https://github.com/matrix-org/matrix-react-sdk/pull/4043) + * Disable verification buttons after clicking to avoid double submission + [\#4049](https://github.com/matrix-org/matrix-react-sdk/pull/4049) + * Verification toast fixes + [\#4048](https://github.com/matrix-org/matrix-react-sdk/pull/4048) + * Use EncryptionPanel everywhere, part I + [\#4042](https://github.com/matrix-org/matrix-react-sdk/pull/4042) + * quick fix for cross-signing reset bug + [\#4056](https://github.com/matrix-org/matrix-react-sdk/pull/4056) + * Fix error message rendering for key entry + [\#4055](https://github.com/matrix-org/matrix-react-sdk/pull/4055) + * Fix recaptcha blocked by CSP for non-SSL origins + [\#4052](https://github.com/matrix-org/matrix-react-sdk/pull/4052) + * Fix watcher for showTypingNotifications setting + [\#4054](https://github.com/matrix-org/matrix-react-sdk/pull/4054) + * Allow custom hs url submission on enter + [\#4053](https://github.com/matrix-org/matrix-react-sdk/pull/4053) + * Support keepSecretStoragePassphraseForSession at the config level too + [\#4045](https://github.com/matrix-org/matrix-react-sdk/pull/4045) + * Add setting to allow hiding of typing indicator + [\#4047](https://github.com/matrix-org/matrix-react-sdk/pull/4047) + * Button to reset cross-signing and SSSS keys + [\#4041](https://github.com/matrix-org/matrix-react-sdk/pull/4041) + * Use forms to wrap password fields so Chrome doesn't go wild + [\#3974](https://github.com/matrix-org/matrix-react-sdk/pull/3974) + * Update QR code rendering to support VerificationRequests + [\#4001](https://github.com/matrix-org/matrix-react-sdk/pull/4001) + * Differentiate AccessSecretStorageDialog dismiss dialog based on which key we + want to read + [\#4038](https://github.com/matrix-org/matrix-react-sdk/pull/4038) + * Only emit in RoomViewStore when state actually changes + [\#4039](https://github.com/matrix-org/matrix-react-sdk/pull/4039) + * Mark AccessSecretStorageDialog to not be closed by clicking background + [\#4029](https://github.com/matrix-org/matrix-react-sdk/pull/4029) + * Let pointer events fall through to scroll button + [\#4037](https://github.com/matrix-org/matrix-react-sdk/pull/4037) + * Improve event indexing status strings for translation + [\#4035](https://github.com/matrix-org/matrix-react-sdk/pull/4035) + * Button size reviewed for word consuming languages & Settings showing devices + are a bit too tight + [\#4024](https://github.com/matrix-org/matrix-react-sdk/pull/4024) + * Only enumerate settings handlers which are supported + [\#4034](https://github.com/matrix-org/matrix-react-sdk/pull/4034) + * Fix listener removal in verification tile + [\#4036](https://github.com/matrix-org/matrix-react-sdk/pull/4036) + * Do not show alarming red shields on large encrypted rooms for your own + device + [\#4028](https://github.com/matrix-org/matrix-react-sdk/pull/4028) + * Add a class for styling room directory permissions + [\#4007](https://github.com/matrix-org/matrix-react-sdk/pull/4007) + * double-check user verification + [\#4010](https://github.com/matrix-org/matrix-react-sdk/pull/4010) + * Use minimist instead of optimist as it is deprecated + [\#4031](https://github.com/matrix-org/matrix-react-sdk/pull/4031) + * SettingsStore, use a counter instead of wall clock for watcher ids + [\#4032](https://github.com/matrix-org/matrix-react-sdk/pull/4032) + * Don't crash immediately if the room directory chunk is null/empty + [\#4027](https://github.com/matrix-org/matrix-react-sdk/pull/4027) + * Fix verification toast to close at 0s + [\#3998](https://github.com/matrix-org/matrix-react-sdk/pull/3998) + * Fix listener leak in TagPanel + [\#4026](https://github.com/matrix-org/matrix-react-sdk/pull/4026) + * Update from Weblate + [\#4025](https://github.com/matrix-org/matrix-react-sdk/pull/4025) + * Honour the isLogin flag in theme.js + [\#4023](https://github.com/matrix-org/matrix-react-sdk/pull/4023) + * ManageEventIndexDialog: Show how many rooms are being currently crawled. + [\#4022](https://github.com/matrix-org/matrix-react-sdk/pull/4022) + * Advertise that we can scan QR codes even though we can't + [\#4021](https://github.com/matrix-org/matrix-react-sdk/pull/4021) + * Checkpoint addition fixes and return of the crawler sleep time setting. + [\#4020](https://github.com/matrix-org/matrix-react-sdk/pull/4020) + * Truncate SAS emoji labels to fit + [\#4018](https://github.com/matrix-org/matrix-react-sdk/pull/4018) + * Apply copy edits to security setup flow + [\#4017](https://github.com/matrix-org/matrix-react-sdk/pull/4017) + * Fix user trust text to match what was checked + [\#4016](https://github.com/matrix-org/matrix-react-sdk/pull/4016) + * Fix size of invite only icon + [\#4015](https://github.com/matrix-org/matrix-react-sdk/pull/4015) + * Add temporary feature flag to control padlocks + [\#4013](https://github.com/matrix-org/matrix-react-sdk/pull/4013) + * Add an override for the theme + [\#4014](https://github.com/matrix-org/matrix-react-sdk/pull/4014) + * Add title to complete security loading + [\#4011](https://github.com/matrix-org/matrix-react-sdk/pull/4011) + * Only display the first zxcvbn warning/suggestion + [\#4012](https://github.com/matrix-org/matrix-react-sdk/pull/4012) + * Log exceptions from accessSecretStorage + [\#4009](https://github.com/matrix-org/matrix-react-sdk/pull/4009) + * Add advanced option to keep secret storage in memory for session + [\#3995](https://github.com/matrix-org/matrix-react-sdk/pull/3995) + * Add shields to member list, move power label to text + [\#4006](https://github.com/matrix-org/matrix-react-sdk/pull/4006) + * Make encryption events into bubble-style tiles + [\#4005](https://github.com/matrix-org/matrix-react-sdk/pull/4005) + * Update copy when the user verifies their own devices + [\#4000](https://github.com/matrix-org/matrix-react-sdk/pull/4000) + * Use Sets instead of array scans and simplify hiding of invalid users when + inviting + [\#4004](https://github.com/matrix-org/matrix-react-sdk/pull/4004) + * Fix room completion for invited rooms and upgraded rooms + [\#4003](https://github.com/matrix-org/matrix-react-sdk/pull/4003) + * Make shields in UserInfo black if user isn't verified + [\#3999](https://github.com/matrix-org/matrix-react-sdk/pull/3999) + * Change verify user text + [\#3994](https://github.com/matrix-org/matrix-react-sdk/pull/3994) + * Disable all inputs in login form while busy, not just the submit button + [\#3996](https://github.com/matrix-org/matrix-react-sdk/pull/3996) + * fix SAS dialog width + [\#3993](https://github.com/matrix-org/matrix-react-sdk/pull/3993) + * Update placeholder in the composer when it gets changed + [\#3990](https://github.com/matrix-org/matrix-react-sdk/pull/3990) + * Send initial device display name on register + [\#3992](https://github.com/matrix-org/matrix-react-sdk/pull/3992) + * Update QR code handling for new spec + [\#3959](https://github.com/matrix-org/matrix-react-sdk/pull/3959) + * Apply the Olympic effect to SAS Emoji Verification + [\#3989](https://github.com/matrix-org/matrix-react-sdk/pull/3989) + * Pass an ID to the as needed and fix div inside p nesting + [\#3988](https://github.com/matrix-org/matrix-react-sdk/pull/3988) + * Update user info for device and trust changes + [\#3987](https://github.com/matrix-org/matrix-react-sdk/pull/3987) + * Relax secret storage account data check + [\#3985](https://github.com/matrix-org/matrix-react-sdk/pull/3985) + * Fix various races that prevented the right panel being in the right state + for verifications + [\#3984](https://github.com/matrix-org/matrix-react-sdk/pull/3984) + * Fix verifying individual devices + [\#3986](https://github.com/matrix-org/matrix-react-sdk/pull/3986) + * Update from Weblate + [\#3982](https://github.com/matrix-org/matrix-react-sdk/pull/3982) + * Replace device with session in UI text + [\#3980](https://github.com/matrix-org/matrix-react-sdk/pull/3980) + * Add missing await causing promises to be leaked as room IDs + [\#3981](https://github.com/matrix-org/matrix-react-sdk/pull/3981) + * Change new session toast to unverified + [\#3978](https://github.com/matrix-org/matrix-react-sdk/pull/3978) + * Replace Verify button in UserInfo verification with "Learn more" + [\#3975](https://github.com/matrix-org/matrix-react-sdk/pull/3975) + * Don't peek until the matrix client is ready + [\#3979](https://github.com/matrix-org/matrix-react-sdk/pull/3979) + * Verification: don't block UI update on verification finishing + [\#3976](https://github.com/matrix-org/matrix-react-sdk/pull/3976) + * Adjust icons with in person with design + [\#3977](https://github.com/matrix-org/matrix-react-sdk/pull/3977) + * Update copy for right panel verification + [\#3973](https://github.com/matrix-org/matrix-react-sdk/pull/3973) + * Check for timeline in pre-join UISI path + [\#3972](https://github.com/matrix-org/matrix-react-sdk/pull/3972) + * Let users paste text if they've already started filtering invite targets + [\#3970](https://github.com/matrix-org/matrix-react-sdk/pull/3970) + * Filter event types when deciding on activity metrics for DM suggestions + [\#3969](https://github.com/matrix-org/matrix-react-sdk/pull/3969) + * Revert a change causing a login loop + [\#3971](https://github.com/matrix-org/matrix-react-sdk/pull/3971) + * Improve the docs for the event index and fix some type hints. + [\#3960](https://github.com/matrix-org/matrix-react-sdk/pull/3960) + * Automatically focus on the invite dialog input + [\#3968](https://github.com/matrix-org/matrix-react-sdk/pull/3968) + * Restore key backup in Complete Security dialog + [\#3966](https://github.com/matrix-org/matrix-react-sdk/pull/3966) + * Right Panel Verification improvements + [\#3967](https://github.com/matrix-org/matrix-react-sdk/pull/3967) + * Cross Signing Right Panel Verification Decoration + [\#3950](https://github.com/matrix-org/matrix-react-sdk/pull/3950) + * Passing refireParams actually prevented this from working + [\#3965](https://github.com/matrix-org/matrix-react-sdk/pull/3965) + * Start new key backup in security setup flow + [\#3964](https://github.com/matrix-org/matrix-react-sdk/pull/3964) + * Tweak styling of the unread indicator circle. + [\#3958](https://github.com/matrix-org/matrix-react-sdk/pull/3958) + * Add device IDs in user info tooltips + [\#3963](https://github.com/matrix-org/matrix-react-sdk/pull/3963) + * Improve encryption upgrade on login flow + [\#3962](https://github.com/matrix-org/matrix-react-sdk/pull/3962) + * Switch back to legacy decorators + [\#3961](https://github.com/matrix-org/matrix-react-sdk/pull/3961) + * Style bridge settings tab according to design + [\#3894](https://github.com/matrix-org/matrix-react-sdk/pull/3894) + * Fix skinning and babel targets + [\#3957](https://github.com/matrix-org/matrix-react-sdk/pull/3957) + * Enable cross-signing lab when key in storage + [\#3956](https://github.com/matrix-org/matrix-react-sdk/pull/3956) + * Add new session verification details dialog + [\#3953](https://github.com/matrix-org/matrix-react-sdk/pull/3953) + * Fix issue where we don't notice if our own devices shouldn't be trusted + [\#3949](https://github.com/matrix-org/matrix-react-sdk/pull/3949) + * Add separate component for post-auth security flows + [\#3951](https://github.com/matrix-org/matrix-react-sdk/pull/3951) + * Add more logging to settings watchers + [\#3952](https://github.com/matrix-org/matrix-react-sdk/pull/3952) + * Use https for recaptcha for all non-http protocols + [\#3944](https://github.com/matrix-org/matrix-react-sdk/pull/3944) + * Add status and management UI for the event indexer + [\#3672](https://github.com/matrix-org/matrix-react-sdk/pull/3672) + * Remove DM icons if `feature_cross_signing` is enabled; hide padlocks in DM + room headers + [\#3948](https://github.com/matrix-org/matrix-react-sdk/pull/3948) + * Stop rogue verification toast if you verify during login + [\#3943](https://github.com/matrix-org/matrix-react-sdk/pull/3943) + * Show incoming verification requests in the 'complete security' phase + [\#3942](https://github.com/matrix-org/matrix-react-sdk/pull/3942) + * Dismiss logged out device toasts + [\#3941](https://github.com/matrix-org/matrix-react-sdk/pull/3941) + * Verification nag toasts + [\#3940](https://github.com/matrix-org/matrix-react-sdk/pull/3940) + * Update from Weblate + [\#3947](https://github.com/matrix-org/matrix-react-sdk/pull/3947) + * Remember password for e2e bootstrapping + [\#3939](https://github.com/matrix-org/matrix-react-sdk/pull/3939) + * fix compound emoji + [\#3946](https://github.com/matrix-org/matrix-react-sdk/pull/3946) + * Setup flow for cross-signing on login / registration + [\#3937](https://github.com/matrix-org/matrix-react-sdk/pull/3937) + * Update profile avatar letter size + [\#3935](https://github.com/matrix-org/matrix-react-sdk/pull/3935) + * Hide default encryption algorithm + [\#3936](https://github.com/matrix-org/matrix-react-sdk/pull/3936) + * Resolve default export warnings from Webpack + [\#3938](https://github.com/matrix-org/matrix-react-sdk/pull/3938) + * Add null check for cross-signing info in verification panel + [\#3934](https://github.com/matrix-org/matrix-react-sdk/pull/3934) + * Add trace logging to figure out which component is causing weird events + [\#3926](https://github.com/matrix-org/matrix-react-sdk/pull/3926) + * Remove user lists feature flag, making it the default + [\#3906](https://github.com/matrix-org/matrix-react-sdk/pull/3906) + * Last bit of polish for user lists + [\#3925](https://github.com/matrix-org/matrix-react-sdk/pull/3925) + * QR code verification + [\#3871](https://github.com/matrix-org/matrix-react-sdk/pull/3871) + * Do less unnecessary work on CI + [\#3933](https://github.com/matrix-org/matrix-react-sdk/pull/3933) + * Re-enable stylelint on CI + [\#3932](https://github.com/matrix-org/matrix-react-sdk/pull/3932) + * Design pass for room icons + [\#3931](https://github.com/matrix-org/matrix-react-sdk/pull/3931) + * Populate the file panel using the event index if available. + [\#3858](https://github.com/matrix-org/matrix-react-sdk/pull/3858) + * Split AsyncWrapper out from Modal + [\#3928](https://github.com/matrix-org/matrix-react-sdk/pull/3928) + * Fix error in verification code on develop + [\#3930](https://github.com/matrix-org/matrix-react-sdk/pull/3930) + * Seperates out the padlock icon, and adds a tooltip + [\#3929](https://github.com/matrix-org/matrix-react-sdk/pull/3929) + * Cross Signing redesign for composer + [\#3910](https://github.com/matrix-org/matrix-react-sdk/pull/3910) + * Fix verifying your own devices with to_device messages + [\#3927](https://github.com/matrix-org/matrix-react-sdk/pull/3927) + * Room list reflects encryption state + [\#3908](https://github.com/matrix-org/matrix-react-sdk/pull/3908) + * Make the entire User Info scrollable, sticky close button + [\#3914](https://github.com/matrix-org/matrix-react-sdk/pull/3914) + * Remove riot logo from the security setup screens + [\#3916](https://github.com/matrix-org/matrix-react-sdk/pull/3916) + * Only say the session is verified if it is now verified + [\#3917](https://github.com/matrix-org/matrix-react-sdk/pull/3917) + * Hide password section if you can't change your password + [\#3924](https://github.com/matrix-org/matrix-react-sdk/pull/3924) + * Ensure a plaintext version of the composer ends up on the clipboard + [\#3922](https://github.com/matrix-org/matrix-react-sdk/pull/3922) + * Move & upgrade babel runtime into dependencies (like it wants) + [\#3920](https://github.com/matrix-org/matrix-react-sdk/pull/3920) + * Don't list every single alias when there's many + [\#3918](https://github.com/matrix-org/matrix-react-sdk/pull/3918) + * Try to populate user IDs even when the server's directory fails us + [\#3907](https://github.com/matrix-org/matrix-react-sdk/pull/3907) + * Remove .event property on verification request + [\#3912](https://github.com/matrix-org/matrix-react-sdk/pull/3912) + * Attempt to fix Safari + VoiceOver misunderstanding the timeline list + [\#3911](https://github.com/matrix-org/matrix-react-sdk/pull/3911) + * Enable encryption in DMs with device keys + [\#3913](https://github.com/matrix-org/matrix-react-sdk/pull/3913) + * Fix scrollable area and padding in user lists dialog + [\#3905](https://github.com/matrix-org/matrix-react-sdk/pull/3905) + * Add Reject & Ignore user button to invites view + [\#3909](https://github.com/matrix-org/matrix-react-sdk/pull/3909) + * Fix paragraph-awareness of the composer formatting features + [\#3891](https://github.com/matrix-org/matrix-react-sdk/pull/3891) + * Updated visuals for cross-signing bootstrap + [\#3903](https://github.com/matrix-org/matrix-react-sdk/pull/3903) + * Implement some parts of new cross signing bootstrap UI + [\#3897](https://github.com/matrix-org/matrix-react-sdk/pull/3897) + * Treat links as external in report content admin message + [\#3904](https://github.com/matrix-org/matrix-react-sdk/pull/3904) + * Be consistent about our settings svg, free the other one + [\#3902](https://github.com/matrix-org/matrix-react-sdk/pull/3902) + * Change prepublish script to prepare + [\#3899](https://github.com/matrix-org/matrix-react-sdk/pull/3899) + * Remove the react-sdk version + [\#3901](https://github.com/matrix-org/matrix-react-sdk/pull/3901) + * BuildKite: Retry end-to-end tests automatically once if they fail + [\#3900](https://github.com/matrix-org/matrix-react-sdk/pull/3900) + * Slash Command improvements around sending messages with leading slash + [\#3893](https://github.com/matrix-org/matrix-react-sdk/pull/3893) + * Support admin configurable message when reporting content + [\#3898](https://github.com/matrix-org/matrix-react-sdk/pull/3898) + * Don't warn on unverified users; ensured behavior stays the same with flags + off + [\#3896](https://github.com/matrix-org/matrix-react-sdk/pull/3896) + * Fix roving room list for resizer and ff tabstop a11y + [\#3895](https://github.com/matrix-org/matrix-react-sdk/pull/3895) + * Verify individual messages via cross-signing + [\#3875](https://github.com/matrix-org/matrix-react-sdk/pull/3875) + * Fix layering of dependencies in riot-web and e2e tests + [\#3882](https://github.com/matrix-org/matrix-react-sdk/pull/3882) + * Implement Roving Tab Index and Room List as TreeView + [\#3844](https://github.com/matrix-org/matrix-react-sdk/pull/3844) + * Move room header shields over the avatar for the room + [\#3888](https://github.com/matrix-org/matrix-react-sdk/pull/3888) + * Fix toast icon to prevent clipping + [\#3890](https://github.com/matrix-org/matrix-react-sdk/pull/3890) + * Only show devices and verify actions in E2EE rooms + [\#3889](https://github.com/matrix-org/matrix-react-sdk/pull/3889) + * Change user info verification checks to use cross-signing + [\#3887](https://github.com/matrix-org/matrix-react-sdk/pull/3887) + * Fix click-to-ping not inserting colon if composer non-empty + [\#3886](https://github.com/matrix-org/matrix-react-sdk/pull/3886) + * Fix emoticon space completion for upper case emoticons like :D xD + [\#3884](https://github.com/matrix-org/matrix-react-sdk/pull/3884) + * Repair cross-signing panel with async status + [\#3880](https://github.com/matrix-org/matrix-react-sdk/pull/3880) + * Remove temporary key backup button + [\#3878](https://github.com/matrix-org/matrix-react-sdk/pull/3878) + * Score users who have recently spoken higher in invite suggestions + [\#3866](https://github.com/matrix-org/matrix-react-sdk/pull/3866) + * Initial support for verification in right panel + [\#3796](https://github.com/matrix-org/matrix-react-sdk/pull/3796) + * Prevent the invite dialog from jumping around when elements change + [\#3868](https://github.com/matrix-org/matrix-react-sdk/pull/3868) + * Add prepublish script + [\#3876](https://github.com/matrix-org/matrix-react-sdk/pull/3876) + +Changes in [2.0.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.0.0) (2020-01-27) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.0.0-rc.2...v2.0.0) + + * Ensure a plaintext version of the composer ends up on the clipboard + [\#3923](https://github.com/matrix-org/matrix-react-sdk/pull/3923) + * Move & upgrade babel runtime into dependencies (like it wants) + [\#3921](https://github.com/matrix-org/matrix-react-sdk/pull/3921) + * Don't list every single alias when there's many + [\#3919](https://github.com/matrix-org/matrix-react-sdk/pull/3919) + +Changes in [2.0.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.0.0-rc.2) (2020-01-20) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.0.0-rc.1...v2.0.0-rc.2) + + * Add prepublish script + [\#3877](https://github.com/matrix-org/matrix-react-sdk/pull/3877) + +Changes in [2.0.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.0.0-rc.1) (2020-01-20) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.6...v2.0.0-rc.1) + +BREAKING CHANGES +================ + * The react-sdk node module now exports ES6 rather than ES5. If you + wish to supports target that aren't compatible with ES6, you + will need to transpile the react-sdk to a suitable dialect. + +All Changes +=========== + * Fix arrows keys moving through edit history + [\#3874](https://github.com/matrix-org/matrix-react-sdk/pull/3874) + * Fix error about MessagePanel not being available for read markers + [\#3867](https://github.com/matrix-org/matrix-react-sdk/pull/3867) + * Adjust secret storage to work before sync + [\#3864](https://github.com/matrix-org/matrix-react-sdk/pull/3864) + * Update from Weblate + [\#3872](https://github.com/matrix-org/matrix-react-sdk/pull/3872) + * Remove unused deps and dev-deps + [\#3870](https://github.com/matrix-org/matrix-react-sdk/pull/3870) + * Tidy Jest test stuff and dependencies + [\#3869](https://github.com/matrix-org/matrix-react-sdk/pull/3869) + * Move feature flag check for new session toast + [\#3865](https://github.com/matrix-org/matrix-react-sdk/pull/3865) + * Catch exception in checkTerms if no ID server + [\#3863](https://github.com/matrix-org/matrix-react-sdk/pull/3863) + * Catch exception if passphrase dialog cancelled + [\#3862](https://github.com/matrix-org/matrix-react-sdk/pull/3862) + * Disable key request dialogs with cross-signing + [\#3860](https://github.com/matrix-org/matrix-react-sdk/pull/3860) + * Toasts for new, unverified sessions + [\#3859](https://github.com/matrix-org/matrix-react-sdk/pull/3859) + * Check for a matrixclient before trying to use it + [\#3861](https://github.com/matrix-org/matrix-react-sdk/pull/3861) + * Room header & message box shields now reflect cross-signing state + [\#3850](https://github.com/matrix-org/matrix-react-sdk/pull/3850) + * Fix Array.concat undefined + [\#3857](https://github.com/matrix-org/matrix-react-sdk/pull/3857) + * Update chokidar to fix reskindex not working + [\#3856](https://github.com/matrix-org/matrix-react-sdk/pull/3856) + * Make the new DM invite dialog work for regular invites too + [\#3854](https://github.com/matrix-org/matrix-react-sdk/pull/3854) + * Fix event handler leak in MemberStatusMessageAvatar + [\#3855](https://github.com/matrix-org/matrix-react-sdk/pull/3855) + * Move DM creation logic into DMInviteDialog + [\#3843](https://github.com/matrix-org/matrix-react-sdk/pull/3843) + * Remove all text when cutting in the composer + [\#3848](https://github.com/matrix-org/matrix-react-sdk/pull/3848) + * Add a ToastStore + [\#3853](https://github.com/matrix-org/matrix-react-sdk/pull/3853) + * 'Members' button always toggle the right panel + [\#3804](https://github.com/matrix-org/matrix-react-sdk/pull/3804) + * Fix timing of when Composer considers itself to be modified + [\#3842](https://github.com/matrix-org/matrix-react-sdk/pull/3842) + * Compute download file icon immediately + [\#3851](https://github.com/matrix-org/matrix-react-sdk/pull/3851) + * Fix not being able to open profiles from the timeline + [\#3852](https://github.com/matrix-org/matrix-react-sdk/pull/3852) + * Add post-login complete security flow + [\#3847](https://github.com/matrix-org/matrix-react-sdk/pull/3847) + * Added cut/copy and pasting user pills from editor. + [\#3828](https://github.com/matrix-org/matrix-react-sdk/pull/3828) + * Fix imports for help & support tab + [\#3846](https://github.com/matrix-org/matrix-react-sdk/pull/3846) + * Humanize the recent DM rooms ourselves for translations + [\#3841](https://github.com/matrix-org/matrix-react-sdk/pull/3841) + * Improve the quality of invite suggestions by filtering out DMs + [\#3840](https://github.com/matrix-org/matrix-react-sdk/pull/3840) + * Fix linter and tests on develop + [\#3845](https://github.com/matrix-org/matrix-react-sdk/pull/3845) + * Fix sourcemaps by refactoring the build system + [\#3839](https://github.com/matrix-org/matrix-react-sdk/pull/3839) + * Don't error on unverified/unknown devices. + [\#3837](https://github.com/matrix-org/matrix-react-sdk/pull/3837) + * Padlock icons in room header + [\#3835](https://github.com/matrix-org/matrix-react-sdk/pull/3835) + * Don't allow upgrade from untrusted key backup. + [\#3822](https://github.com/matrix-org/matrix-react-sdk/pull/3822) + * Emoji verification: Change name of 🔒 to lock + [\#3825](https://github.com/matrix-org/matrix-react-sdk/pull/3825) + * Room padlock decorations only if cross-signing is enabled + [\#3838](https://github.com/matrix-org/matrix-react-sdk/pull/3838) + * Enable end-to-end tests for sourcemaps (+Windows instructions) + [\#3827](https://github.com/matrix-org/matrix-react-sdk/pull/3827) + * Repair community member info panel + [\#3832](https://github.com/matrix-org/matrix-react-sdk/pull/3832) + * Add feature flag around the presence indicator in room list + [\#3831](https://github.com/matrix-org/matrix-react-sdk/pull/3831) + * Display a padlock icon beside invite-only rooms in the room list + [\#3821](https://github.com/matrix-org/matrix-react-sdk/pull/3821) + * Update from Weblate + [\#3830](https://github.com/matrix-org/matrix-react-sdk/pull/3830) + * Fix listener leak on RoomView + [\#3826](https://github.com/matrix-org/matrix-react-sdk/pull/3826) + * Regenerate i18n for sourcemaps branch + [\#3824](https://github.com/matrix-org/matrix-react-sdk/pull/3824) + * Fix tests for sourcemaps branch + [\#3823](https://github.com/matrix-org/matrix-react-sdk/pull/3823) + * Jest + [\#3724](https://github.com/matrix-org/matrix-react-sdk/pull/3724) + * Sourcemaps: develop -> feature branch + [\#3817](https://github.com/matrix-org/matrix-react-sdk/pull/3817) + * Support pasting a bunch of identifiers into the invite dialog + [\#3820](https://github.com/matrix-org/matrix-react-sdk/pull/3820) + * Support 3PIDs (email addresses) in the invite dialog + [\#3819](https://github.com/matrix-org/matrix-react-sdk/pull/3819) + * Placeholder PR for cleaner diffs: ES6 + [\#3765](https://github.com/matrix-org/matrix-react-sdk/pull/3765) + * Misc fixes for ES6 imports/exports + [\#3766](https://github.com/matrix-org/matrix-react-sdk/pull/3766) + * Wire up the invite targets dialog to a real composer and show selections + [\#3815](https://github.com/matrix-org/matrix-react-sdk/pull/3815) + * Change ref handling in TextualBody to prevent it parsing generated nodes + [\#3711](https://github.com/matrix-org/matrix-react-sdk/pull/3711) + * Render encoded html entities in og:description + [\#3789](https://github.com/matrix-org/matrix-react-sdk/pull/3789) + * Update package.json for new build process + cosmetics + [\#3767](https://github.com/matrix-org/matrix-react-sdk/pull/3767) + * Convert CommonJS exports to ES6 exports + [\#3761](https://github.com/matrix-org/matrix-react-sdk/pull/3761) + * Round 2 of CommonJS to ES6 imports + [\#3764](https://github.com/matrix-org/matrix-react-sdk/pull/3764) + * Strip all variation selectors on emoji + [\#3814](https://github.com/matrix-org/matrix-react-sdk/pull/3814) + * Use the new js-sdk imports and import from src + [\#3763](https://github.com/matrix-org/matrix-react-sdk/pull/3763) + * Convert many imports to handle ES6 exports + [\#3762](https://github.com/matrix-org/matrix-react-sdk/pull/3762) + * Fix userinfo for users not in the room + [\#3812](https://github.com/matrix-org/matrix-react-sdk/pull/3812) + * Attempt to fix e2e tests + [\#3811](https://github.com/matrix-org/matrix-react-sdk/pull/3811) + * Add bunch of null-guards and similar to fix React Errors/complaints + [\#3752](https://github.com/matrix-org/matrix-react-sdk/pull/3752) + * Delegate all room alias validation to the RoomAliasField validator + [\#3807](https://github.com/matrix-org/matrix-react-sdk/pull/3807) + * Support filtering and searching for users to invite in DMs + [\#3802](https://github.com/matrix-org/matrix-react-sdk/pull/3802) + * Add suggestions for which users to invite to chat + [\#3801](https://github.com/matrix-org/matrix-react-sdk/pull/3801) + * Use `flex-start` instead of `start` for postcss + [\#3760](https://github.com/matrix-org/matrix-react-sdk/pull/3760) + * Define getLanguageFromBrowser() for LanguageDropdown + [\#3769](https://github.com/matrix-org/matrix-react-sdk/pull/3769) + * Introduce babel's export-default-from plugin to fix build errors + [\#3768](https://github.com/matrix-org/matrix-react-sdk/pull/3768) + * Add a bit of debugging to incorrect components in the Skinner + [\#3770](https://github.com/matrix-org/matrix-react-sdk/pull/3770) + * [BREAKING] Refactor the entire build process for babel@7 and TypeScript + (chunk 1 of many) + [\#3722](https://github.com/matrix-org/matrix-react-sdk/pull/3722) + * Implementation of new potential skinning mechanism + [\#3723](https://github.com/matrix-org/matrix-react-sdk/pull/3723) + +Changes in [1.7.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.6) (2020-01-13) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.6-rc.2...v1.7.6) + + * Repair community member info panel + [\#3834](https://github.com/matrix-org/matrix-react-sdk/pull/3834) + * Add feature flag around the presence indicator in room list + [\#3833](https://github.com/matrix-org/matrix-react-sdk/pull/3833) + +Changes in [1.7.6-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.6-rc.2) (2020-01-08) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.6-rc.1...v1.7.6-rc.2) + + * Strip all variation selectors on emoji + [\#3818](https://github.com/matrix-org/matrix-react-sdk/pull/3818) + +Changes in [1.7.6-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.6-rc.1) (2020-01-06) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.5...v1.7.6-rc.1) + + * Deduplicate recent emoji + [\#3806](https://github.com/matrix-org/matrix-react-sdk/pull/3806) + * Fix ability to remove avatars + [\#3803](https://github.com/matrix-org/matrix-react-sdk/pull/3803) + * Update from Weblate + [\#3810](https://github.com/matrix-org/matrix-react-sdk/pull/3810) + * User Info fetch latest RoomMember instead of showing historical data + [\#3788](https://github.com/matrix-org/matrix-react-sdk/pull/3788) + * Remove all usages of slate in favour of CIDER + [\#3808](https://github.com/matrix-org/matrix-react-sdk/pull/3808) + * Use display name when pinned messages are changed + [\#3809](https://github.com/matrix-org/matrix-react-sdk/pull/3809) + * Fix inverted diff line highlighting in dark theme + [\#3790](https://github.com/matrix-org/matrix-react-sdk/pull/3790) + * Bridge info settings tab + [\#3693](https://github.com/matrix-org/matrix-react-sdk/pull/3693) + * Send the labs flags the client is running with in rageshake + [\#3805](https://github.com/matrix-org/matrix-react-sdk/pull/3805) + * Initial implementation of FTUE user lists design + [\#3792](https://github.com/matrix-org/matrix-react-sdk/pull/3792) + * Update key backup creation and recovery paths for SSSS + [\#3800](https://github.com/matrix-org/matrix-react-sdk/pull/3800) + * Don't fail if logs exists and is an empty dir + [\#3798](https://github.com/matrix-org/matrix-react-sdk/pull/3798) + * Comment remaining non-cross-signing-compliant components + [\#3799](https://github.com/matrix-org/matrix-react-sdk/pull/3799) + * Remove 'unverify' from UserInfoPanel + [\#3797](https://github.com/matrix-org/matrix-react-sdk/pull/3797) + * Use deviceTrust when displaying key backup trust status + [\#3795](https://github.com/matrix-org/matrix-react-sdk/pull/3795) + * Don't crash if a keyshare request is removed + [\#3793](https://github.com/matrix-org/matrix-react-sdk/pull/3793) + * Convert /verify to checkDeviceTrust + [\#3794](https://github.com/matrix-org/matrix-react-sdk/pull/3794) + * Remove E2eIcon onClick + [\#3791](https://github.com/matrix-org/matrix-react-sdk/pull/3791) + * support channel names with slash in name/alias + [\#3778](https://github.com/matrix-org/matrix-react-sdk/pull/3778) + * Fix NPE when filtering the room list + [\#3787](https://github.com/matrix-org/matrix-react-sdk/pull/3787) + * Turn RoomAliasField into properly controlled and use in RoomSettings + [\#3782](https://github.com/matrix-org/matrix-react-sdk/pull/3782) + * fuzzy-sort MemberList + [\#3783](https://github.com/matrix-org/matrix-react-sdk/pull/3783) + * Serialize file uploads into room to match confirmation dialog order + [\#3786](https://github.com/matrix-org/matrix-react-sdk/pull/3786) + * Do not show Top Unread Messages Bar and Jump to bottom button if searching + [\#3785](https://github.com/matrix-org/matrix-react-sdk/pull/3785) + * Fix sticker picker chevron offset calculation + [\#3784](https://github.com/matrix-org/matrix-react-sdk/pull/3784) + * Fix not being able to promote others to the same power level as your own + [\#3781](https://github.com/matrix-org/matrix-react-sdk/pull/3781) + * Room Tile DMs online/active green dot + [\#3751](https://github.com/matrix-org/matrix-react-sdk/pull/3751) + * Fix spelling and grammar in README + [\#3780](https://github.com/matrix-org/matrix-react-sdk/pull/3780) + * Reintroduce working resizer code for right panel + [\#3776](https://github.com/matrix-org/matrix-react-sdk/pull/3776) + * Fix wrong scope binding on openHelp for TopLeftMenu + [\#3775](https://github.com/matrix-org/matrix-react-sdk/pull/3775) + * UserInfo hide kick/mute buttons if they make no sense + [\#3774](https://github.com/matrix-org/matrix-react-sdk/pull/3774) + * Fix duplicate Incoming Call prompt on Community Invite sublist + [\#3773](https://github.com/matrix-org/matrix-react-sdk/pull/3773) + * Apply new design to highlighted tags and add toggle mechanic + [\#3755](https://github.com/matrix-org/matrix-react-sdk/pull/3755) + * stop using ReactDOM.findDOMNode in componentWillUnmount, use refs + [\#3771](https://github.com/matrix-org/matrix-react-sdk/pull/3771) + * Add alt="" to presentational images + [\#3772](https://github.com/matrix-org/matrix-react-sdk/pull/3772) + * Fix room list filtering weird case sensitivity + [\#3759](https://github.com/matrix-org/matrix-react-sdk/pull/3759) + * Don't show the 'verify' button if the user is verified + [\#3758](https://github.com/matrix-org/matrix-react-sdk/pull/3758) + * Switch to using checkDeviceTrust + [\#3757](https://github.com/matrix-org/matrix-react-sdk/pull/3757) + * Migrate away from React Legacy contexts API + [\#3743](https://github.com/matrix-org/matrix-react-sdk/pull/3743) + * Migrate key backups to SSSS + [\#3749](https://github.com/matrix-org/matrix-react-sdk/pull/3749) + * Get rid of stripped-emoji.json in favour of an in-memory single source of + truth + [\#3745](https://github.com/matrix-org/matrix-react-sdk/pull/3745) + * Combine cross signing and verification over DM feature flags + [\#3753](https://github.com/matrix-org/matrix-react-sdk/pull/3753) + * apply unhomoglyph when filtering room list to fuzzify it + [\#3754](https://github.com/matrix-org/matrix-react-sdk/pull/3754) + * Make EmojiPicker an unmanaged Context Menu as it is too complex to be + managed + [\#3746](https://github.com/matrix-org/matrix-react-sdk/pull/3746) + * Internationalise M_TOO_LARGE error from Synapse + [\#3750](https://github.com/matrix-org/matrix-react-sdk/pull/3750) + * Replace UserInfo avatar with for fallback logic + [\#3748](https://github.com/matrix-org/matrix-react-sdk/pull/3748) + * Dropdown stop keyboard propagation if key handled + [\#3741](https://github.com/matrix-org/matrix-react-sdk/pull/3741) + * Fix right panel for multiple member info viewings + [\#3742](https://github.com/matrix-org/matrix-react-sdk/pull/3742) + * Fix Field validation tooltip sticking if blurred before async validation + resolved + [\#3740](https://github.com/matrix-org/matrix-react-sdk/pull/3740) + * Fix UserInfo exploding without a room being passed to it + [\#3738](https://github.com/matrix-org/matrix-react-sdk/pull/3738) + * Fix room directory maintaining and error state + [\#3737](https://github.com/matrix-org/matrix-react-sdk/pull/3737) + * Stop trapping tab in AddressPickerDialog + [\#3735](https://github.com/matrix-org/matrix-react-sdk/pull/3735) + * Stop using KeyboardEvent.keyCode as it is deprecated + [\#3736](https://github.com/matrix-org/matrix-react-sdk/pull/3736) + * Implement new design for uploading/removing avatars + [\#3733](https://github.com/matrix-org/matrix-react-sdk/pull/3733) + * Fix aspect ratio on room/profile avatar preview + [\#3731](https://github.com/matrix-org/matrix-react-sdk/pull/3731) + * Switch to react-focus-lock for it to comprehend Portals + [\#3732](https://github.com/matrix-org/matrix-react-sdk/pull/3732) + * Make combobox dropdown keyboard and screen reader accessible + [\#3729](https://github.com/matrix-org/matrix-react-sdk/pull/3729) + * Verify users when cross-signing enabled + [\#3728](https://github.com/matrix-org/matrix-react-sdk/pull/3728) + * Update from Weblate + [\#3730](https://github.com/matrix-org/matrix-react-sdk/pull/3730) + * Improve a11y of the unignore button in Settings + [\#3727](https://github.com/matrix-org/matrix-react-sdk/pull/3727) + * Fix ToggleSwitch A11Y (trapping tab and switch v. checkbox) + [\#3726](https://github.com/matrix-org/matrix-react-sdk/pull/3726) + * Make URL previews dismissable via keyboard and accessible to screen readers + [\#3725](https://github.com/matrix-org/matrix-react-sdk/pull/3725) + * Create new key backups using secret storage + [\#3720](https://github.com/matrix-org/matrix-react-sdk/pull/3720) + * Replace sign-ins with sessions + [\#3721](https://github.com/matrix-org/matrix-react-sdk/pull/3721) + * Refactor RightPanel to match expected behaviour + [\#3703](https://github.com/matrix-org/matrix-react-sdk/pull/3703) + * Render policy room event updates in the timeline + [\#3716](https://github.com/matrix-org/matrix-react-sdk/pull/3716) + * Wrap the await call for unknown device lookups + [\#3718](https://github.com/matrix-org/matrix-react-sdk/pull/3718) + * Add testing flow to bootstrap secret storage + [\#3640](https://github.com/matrix-org/matrix-react-sdk/pull/3640) + * Fix remaining context menu regressions + [\#3715](https://github.com/matrix-org/matrix-react-sdk/pull/3715) + * Migrate away from React Legacy string refs + [\#3712](https://github.com/matrix-org/matrix-react-sdk/pull/3712) + * Update copy for DM invites + [\#3706](https://github.com/matrix-org/matrix-react-sdk/pull/3706) + * Fix message action bar reaction picker regression + [\#3714](https://github.com/matrix-org/matrix-react-sdk/pull/3714) + * Add what-input to allow different scoping to focus-visible for MAB a11y + [\#3709](https://github.com/matrix-org/matrix-react-sdk/pull/3709) + * Mark the This/All Rooms scope buttons as radios for a11y + [\#3708](https://github.com/matrix-org/matrix-react-sdk/pull/3708) + * Switch ReactionsRowButton to an AccessibleButton for space/enter handling + [\#3707](https://github.com/matrix-org/matrix-react-sdk/pull/3707) + * Change the (edited) link to an AccessibleButton for a11y + [\#3710](https://github.com/matrix-org/matrix-react-sdk/pull/3710) + * Update from Weblate + [\#3713](https://github.com/matrix-org/matrix-react-sdk/pull/3713) + * Fix ?via= args in SpecPermalinkConstructor.js + [\#3694](https://github.com/matrix-org/matrix-react-sdk/pull/3694) + * Don't mark a room as unread when server ACLs are set + [\#3705](https://github.com/matrix-org/matrix-react-sdk/pull/3705) + * Make reaction buttons more accessible + [\#3704](https://github.com/matrix-org/matrix-react-sdk/pull/3704) + * yarn upgrade + [\#3701](https://github.com/matrix-org/matrix-react-sdk/pull/3701) + * Make CI scripts executable + [\#3698](https://github.com/matrix-org/matrix-react-sdk/pull/3698) + * ARIA compliant context menus + [\#3611](https://github.com/matrix-org/matrix-react-sdk/pull/3611) + +Changes in [1.7.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.5) (2019-12-09) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.5-rc.1...v1.7.5) + + * No changes since rc.1 + +Changes in [1.7.5-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.5-rc.1) (2019-12-04) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.4...v1.7.5-rc.1) + + * Remove logs before running end-to-end tests + [\#3700](https://github.com/matrix-org/matrix-react-sdk/pull/3700) + * Update from Weblate + [\#3699](https://github.com/matrix-org/matrix-react-sdk/pull/3699) + * Match e2e icons on events to expectations + [\#3697](https://github.com/matrix-org/matrix-react-sdk/pull/3697) + * Match room upgrade warning to the new design + [\#3695](https://github.com/matrix-org/matrix-react-sdk/pull/3695) + * Remove unused translations + [\#3683](https://github.com/matrix-org/matrix-react-sdk/pull/3683) + * Remove broken velocity-ui animation + [\#3678](https://github.com/matrix-org/matrix-react-sdk/pull/3678) + * Update from Weblate + [\#3696](https://github.com/matrix-org/matrix-react-sdk/pull/3696) + * Hide Remove button in message editing history if you don't have permission + to redact + [\#3685](https://github.com/matrix-org/matrix-react-sdk/pull/3685) + * Add an option to invite users to upgraded private rooms + [\#3684](https://github.com/matrix-org/matrix-react-sdk/pull/3684) + * Do not trap Key ContextMenu into composer for keyboard a11y + [\#3689](https://github.com/matrix-org/matrix-react-sdk/pull/3689) + * Make EmojiPicker filtering case-insensitive + [\#3690](https://github.com/matrix-org/matrix-react-sdk/pull/3690) + * Ensure the settings page accurately represents theme choices + [\#3686](https://github.com/matrix-org/matrix-react-sdk/pull/3686) + * Ensure read receipts end up with a valid reference to checkUnmounting + [\#3688](https://github.com/matrix-org/matrix-react-sdk/pull/3688) + * Convert Velociraptor component to a class + [\#3687](https://github.com/matrix-org/matrix-react-sdk/pull/3687) + * Add a link to the labs feature documentation + [\#3675](https://github.com/matrix-org/matrix-react-sdk/pull/3675) + * Improve translatable strings for calls + [\#3682](https://github.com/matrix-org/matrix-react-sdk/pull/3682) + * Don't assume that diffs will have an appropriate child node + [\#3680](https://github.com/matrix-org/matrix-react-sdk/pull/3680) + * Fix persisted widgets getting stuck at loading screens + [\#3681](https://github.com/matrix-org/matrix-react-sdk/pull/3681) + * Add button to clear all notification counts, sometimes stuck in historical + [\#2959](https://github.com/matrix-org/matrix-react-sdk/pull/2959) + * Fix multi-invite error dialog messaging + [\#3679](https://github.com/matrix-org/matrix-react-sdk/pull/3679) + * Make the communities button behave more like a toggle + [\#3670](https://github.com/matrix-org/matrix-react-sdk/pull/3670) + * Change read markers to use CSS transitions + [\#3674](https://github.com/matrix-org/matrix-react-sdk/pull/3674) + * fix font smoothing to match figma + [\#3677](https://github.com/matrix-org/matrix-react-sdk/pull/3677) + * Update breadcrumbs when we do eventually see upgraded rooms + [\#3669](https://github.com/matrix-org/matrix-react-sdk/pull/3669) + * Fix override behaviour of system vs defined themes + [\#3673](https://github.com/matrix-org/matrix-react-sdk/pull/3673) + * console.log doesn't take %s substitutions + [\#3671](https://github.com/matrix-org/matrix-react-sdk/pull/3671) + * EventIndex: Move the checkpoint loading logic into the init method. + [\#3648](https://github.com/matrix-org/matrix-react-sdk/pull/3648) + * Clarify that cross-signing is in development + [\#3668](https://github.com/matrix-org/matrix-react-sdk/pull/3668) + * Hide tooltips with CSS when they aren't visible + [\#3665](https://github.com/matrix-org/matrix-react-sdk/pull/3665) + * a11y: adjustments for toasts + [\#3667](https://github.com/matrix-org/matrix-react-sdk/pull/3667) + * Update from Weblate + [\#3666](https://github.com/matrix-org/matrix-react-sdk/pull/3666) + * Null check on thumbnail_file + [\#3664](https://github.com/matrix-org/matrix-react-sdk/pull/3664) + * Fix double date separator for room upgrade tiles + [\#3662](https://github.com/matrix-org/matrix-react-sdk/pull/3662) + * Show incoming verification requests in in-app notifications + [\#3661](https://github.com/matrix-org/matrix-react-sdk/pull/3661) + * Show m.room.create event before the ELS on room upgrade + [\#3655](https://github.com/matrix-org/matrix-react-sdk/pull/3655) + * Convert MessagePanel to React class + [\#3656](https://github.com/matrix-org/matrix-react-sdk/pull/3656) + * Make addEventListener conditional + [\#3657](https://github.com/matrix-org/matrix-react-sdk/pull/3657) + * Fix e2e icons + [\#3653](https://github.com/matrix-org/matrix-react-sdk/pull/3653) + * Workaround for soft-crash with calls on startup + [\#3654](https://github.com/matrix-org/matrix-react-sdk/pull/3654) + * Catch exceptions when we can't play audio + [\#3652](https://github.com/matrix-org/matrix-react-sdk/pull/3652) + * Rename section heading for integrations in settings + [\#3650](https://github.com/matrix-org/matrix-react-sdk/pull/3650) + * Update copy for widgets not using message encryption + [\#3651](https://github.com/matrix-org/matrix-react-sdk/pull/3651) + * Ignore media actions + [\#3649](https://github.com/matrix-org/matrix-react-sdk/pull/3649) + * Add an option to disable the use of integration managers for provisioning + [\#3646](https://github.com/matrix-org/matrix-react-sdk/pull/3646) + * Move many widget options to a context menu + [\#3645](https://github.com/matrix-org/matrix-react-sdk/pull/3645) + * Re-add encryption warning to widget permission prompt + [\#3644](https://github.com/matrix-org/matrix-react-sdk/pull/3644) + * Update CIDER docs now that it is used for main composer as well + [\#3647](https://github.com/matrix-org/matrix-react-sdk/pull/3647) + * get rid of bluebird + [\#3593](https://github.com/matrix-org/matrix-react-sdk/pull/3593) + * Remove getBaseTheme + [\#3638](https://github.com/matrix-org/matrix-react-sdk/pull/3638) + * ReactionsRowButtonTooltip: fix null dereference if emoji owner left room + [\#3643](https://github.com/matrix-org/matrix-react-sdk/pull/3643) + * Add eslint-plugin-jest because we inherit js-sdk's eslintrc and it wants + [\#3642](https://github.com/matrix-org/matrix-react-sdk/pull/3642) + +Changes in [1.7.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.4) (2019-11-27) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.3...v1.7.4) + +* Upgrade to JS SDK 2.5.4 to relax identity server discovery and E2EE debugging +* Fix override behaviour of system vs defined theme +* Clarify that cross-signing is in development + +Changes in [1.7.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.3) (2019-11-25) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.3-rc.2...v1.7.3) + + * No changes since rc.2 + +Changes in [1.7.3-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.3-rc.2) (2019-11-22) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.3-rc.1...v1.7.3-rc.2) + + * Fix double date separator for room upgrade tiles + [\#3663](https://github.com/matrix-org/matrix-react-sdk/pull/3663) + * Show m.room.create event before the ELS on room upgrade + [\#3660](https://github.com/matrix-org/matrix-react-sdk/pull/3660) + * Make addEventListener conditional + [\#3659](https://github.com/matrix-org/matrix-react-sdk/pull/3659) + * Fix e2e icons + [\#3658](https://github.com/matrix-org/matrix-react-sdk/pull/3658) + +Changes in [1.7.3-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.3-rc.1) (2019-11-20) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.2...v1.7.3-rc.1) + + * Fix positioning, size, and colour of the composer e2e icon + [\#3641](https://github.com/matrix-org/matrix-react-sdk/pull/3641) + * upgrade nunito from 3.500 to 3.504 + [\#3639](https://github.com/matrix-org/matrix-react-sdk/pull/3639) + * Wire up the widget permission prompt to the cross-platform setting + [\#3630](https://github.com/matrix-org/matrix-react-sdk/pull/3630) + * Get theme automatically from system setting + [\#3637](https://github.com/matrix-org/matrix-react-sdk/pull/3637) + * Update code style for our 90 char life + [\#3636](https://github.com/matrix-org/matrix-react-sdk/pull/3636) + * use general warning icon instead of e2e one for room status + [\#3633](https://github.com/matrix-org/matrix-react-sdk/pull/3633) + * Add support for platform specific event indexing and search + [\#3550](https://github.com/matrix-org/matrix-react-sdk/pull/3550) + * Update from Weblate + [\#3635](https://github.com/matrix-org/matrix-react-sdk/pull/3635) + * Use a settings watcher to set the theme + [\#3634](https://github.com/matrix-org/matrix-react-sdk/pull/3634) + * Merge the `feature_user_info_panel` flag into `feature_dm_verification` + [\#3632](https://github.com/matrix-org/matrix-react-sdk/pull/3632) + * Fix some styling regressions in member panel + [\#3631](https://github.com/matrix-org/matrix-react-sdk/pull/3631) + * Add a bit more safety around breadcrumbs + [\#3629](https://github.com/matrix-org/matrix-react-sdk/pull/3629) + * Ensure widgets always have a sender associated with them + [\#3628](https://github.com/matrix-org/matrix-react-sdk/pull/3628) + * re-add missing case of codepath + [\#3627](https://github.com/matrix-org/matrix-react-sdk/pull/3627) + * Implement the bulk of the new widget permission prompt design + [\#3622](https://github.com/matrix-org/matrix-react-sdk/pull/3622) + * Relax identity server discovery error handling + [\#3588](https://github.com/matrix-org/matrix-react-sdk/pull/3588) + * Add cross-signing feature flag + [\#3626](https://github.com/matrix-org/matrix-react-sdk/pull/3626) + * Attempt number two at ripping out Bluebird from rageshake.js + [\#3624](https://github.com/matrix-org/matrix-react-sdk/pull/3624) + * Update from Weblate + [\#3625](https://github.com/matrix-org/matrix-react-sdk/pull/3625) + * Remove Bluebird: phase 2.1 + [\#3618](https://github.com/matrix-org/matrix-react-sdk/pull/3618) + * Add better error handling to Synapse user deactivation + [\#3619](https://github.com/matrix-org/matrix-react-sdk/pull/3619) + * New design for member panel + [\#3620](https://github.com/matrix-org/matrix-react-sdk/pull/3620) + * Show server details on login for unreachable homeserver + [\#3617](https://github.com/matrix-org/matrix-react-sdk/pull/3617) + * Add a function to get the "base" theme for a theme + [\#3615](https://github.com/matrix-org/matrix-react-sdk/pull/3615) + * Remove Bluebird: phase 2 + [\#3616](https://github.com/matrix-org/matrix-react-sdk/pull/3616) + * Remove Bluebird: phase 1 + [\#3612](https://github.com/matrix-org/matrix-react-sdk/pull/3612) + * Move notification count to in front of the room name in the page title + [\#3613](https://github.com/matrix-org/matrix-react-sdk/pull/3613) + * Add some logging/recovery for lost rooms + [\#3614](https://github.com/matrix-org/matrix-react-sdk/pull/3614) + * Add Mjolnir ban list support + [\#3585](https://github.com/matrix-org/matrix-react-sdk/pull/3585) + * Improve room switching performance with alias cache + [\#3610](https://github.com/matrix-org/matrix-react-sdk/pull/3610) + * Fix draw order when hovering composer format buttons + [\#3609](https://github.com/matrix-org/matrix-react-sdk/pull/3609) + * Use a ternary operator instead of relying on AND semantics in + EditHistoryDialog + [\#3606](https://github.com/matrix-org/matrix-react-sdk/pull/3606) + * Update from Weblate + [\#3608](https://github.com/matrix-org/matrix-react-sdk/pull/3608) + * Fix HTML fallback in replies + [\#3607](https://github.com/matrix-org/matrix-react-sdk/pull/3607) + * Fix rounded corners for the formatting toolbar + [\#3605](https://github.com/matrix-org/matrix-react-sdk/pull/3605) + * Check for a message type before assuming it is a room message + [\#3604](https://github.com/matrix-org/matrix-react-sdk/pull/3604) + * Remove lint comments about no-descending-specificity + [\#3603](https://github.com/matrix-org/matrix-react-sdk/pull/3603) + * Show verification requests in the timeline + [\#3601](https://github.com/matrix-org/matrix-react-sdk/pull/3601) + * Match identity server registration to the IS r0.3.0 spec + [\#3602](https://github.com/matrix-org/matrix-react-sdk/pull/3602) + * Restore thumbs after variation selector removal + [\#3600](https://github.com/matrix-org/matrix-react-sdk/pull/3600) + * Fix breadcrumbs so the bar is a toolbar and the buttons are buttons. + [\#3599](https://github.com/matrix-org/matrix-react-sdk/pull/3599) + * Now that part of spacing is padding, make it smaller when collapsed + [\#3597](https://github.com/matrix-org/matrix-react-sdk/pull/3597) + * Remove variation selectors from quick reactions + [\#3598](https://github.com/matrix-org/matrix-react-sdk/pull/3598) + * Fix linkify imports + [\#3595](https://github.com/matrix-org/matrix-react-sdk/pull/3595) + +Changes in [1.7.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.2) (2019-11-06) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.1...v1.7.2) + + * Fix softcrash if editing silly events + [\#3596](https://github.com/matrix-org/matrix-react-sdk/pull/3596) + * Fix: file and notifications panel back-paginating forever. + [\#3594](https://github.com/matrix-org/matrix-react-sdk/pull/3594) + * Fix focus-within on EventTile and more showing onClick + [\#3591](https://github.com/matrix-org/matrix-react-sdk/pull/3591) + * Support RTL language in message composer + [\#3592](https://github.com/matrix-org/matrix-react-sdk/pull/3592) + * Update from Weblate + [\#3590](https://github.com/matrix-org/matrix-react-sdk/pull/3590) + * Improve A11Y of timeline. Show timestamp & Actions on focus-within + [\#3587](https://github.com/matrix-org/matrix-react-sdk/pull/3587) + * Fix SVG mask-image usage in a bunch of places for correct outlining + [\#3589](https://github.com/matrix-org/matrix-react-sdk/pull/3589) + * Handle breadcrumbs, integration manager provisioning, and allowed widgets + Riot settings + [\#3577](https://github.com/matrix-org/matrix-react-sdk/pull/3577) + * Add a prompt when interacting with an identity server without terms + [\#3582](https://github.com/matrix-org/matrix-react-sdk/pull/3582) + * Fix bug where rooms would not appear when filtering + [\#3584](https://github.com/matrix-org/matrix-react-sdk/pull/3584) + * Guard against misconfigured homeservers when adding / binding phone numbers + [\#3583](https://github.com/matrix-org/matrix-react-sdk/pull/3583) + * Fix error message which is shown when unknown slash command attempted + [\#3580](https://github.com/matrix-org/matrix-react-sdk/pull/3580) + * Attempt to fix soft crash on some pinned events by null guarding member + [\#3581](https://github.com/matrix-org/matrix-react-sdk/pull/3581) + +Changes in [1.7.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.1) (2019-11-04) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.1-rc.2...v1.7.1) + + * No changes since rc.2 + +Changes in [1.7.1-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.1-rc.2) (2019-11-01) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.1-rc.1...v1.7.1-rc.2) + + * Fix bug where rooms would not appear when filtering + [\#3586](https://github.com/matrix-org/matrix-react-sdk/pull/3586) + +Changes in [1.7.1-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.1-rc.1) (2019-10-30) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.0...v1.7.1-rc.1) + + * Add ability to hide tray icon on non-Mac + [\#3573](https://github.com/matrix-org/matrix-react-sdk/pull/3573) + * Update from Weblate + [\#3579](https://github.com/matrix-org/matrix-react-sdk/pull/3579) + * Fix call state logging + [\#3578](https://github.com/matrix-org/matrix-react-sdk/pull/3578) + * Match widgets up with their integration manager + [\#3576](https://github.com/matrix-org/matrix-react-sdk/pull/3576) + * Add diagnostic log to catch events without an ID + [\#3575](https://github.com/matrix-org/matrix-react-sdk/pull/3575) + * Fix missing i18n for RoomTile ARIA labels and add a case for notif-off bold + [\#3574](https://github.com/matrix-org/matrix-react-sdk/pull/3574) + * LifeCycle onLoggedOut unmount before stopping client + [\#3566](https://github.com/matrix-org/matrix-react-sdk/pull/3566) + * Remove unneeded help about identity servers + [\#3572](https://github.com/matrix-org/matrix-react-sdk/pull/3572) + * Remove messages implying you need an identity server for email recovery + [\#3571](https://github.com/matrix-org/matrix-react-sdk/pull/3571) + * Fix quick reactions to be aligned with other emoji + [\#3570](https://github.com/matrix-org/matrix-react-sdk/pull/3570) + * If ToS gets rejected/any Scalar error then don't make Jitsi widget + [\#3569](https://github.com/matrix-org/matrix-react-sdk/pull/3569) + * Update from Weblate + [\#3568](https://github.com/matrix-org/matrix-react-sdk/pull/3568) + * Fix Room Create ELS using MXID instead of newly set Displayname/Avatar + [\#3567](https://github.com/matrix-org/matrix-react-sdk/pull/3567) + * Improve opening emoji picker performance + [\#3565](https://github.com/matrix-org/matrix-react-sdk/pull/3565) + * Update ServerTypeSelector for new matrix.org CS API URL + [\#3564](https://github.com/matrix-org/matrix-react-sdk/pull/3564) + * Accessibility Improvements + [\#3563](https://github.com/matrix-org/matrix-react-sdk/pull/3563) + * A11Y fixes in the Left Panel + [\#3562](https://github.com/matrix-org/matrix-react-sdk/pull/3562) + * Fix lint and i18n test failures + [\#3560](https://github.com/matrix-org/matrix-react-sdk/pull/3560) + * Fix: editor tests + [\#3561](https://github.com/matrix-org/matrix-react-sdk/pull/3561) + * Use Navigation Treeview pattern for RoomList Accessibility + [\#3556](https://github.com/matrix-org/matrix-react-sdk/pull/3556) + * Abort scroll updates when already unmounted + [\#3557](https://github.com/matrix-org/matrix-react-sdk/pull/3557) + * UserInfo consolidation of GroupMemberInfo and MemberInfo panels + [\#3465](https://github.com/matrix-org/matrix-react-sdk/pull/3465) + * Fix some things in the edit HTML parser + [\#3552](https://github.com/matrix-org/matrix-react-sdk/pull/3552) + * Update from Weblate + [\#3559](https://github.com/matrix-org/matrix-react-sdk/pull/3559) + * Merge end-to-end tests + [\#3537](https://github.com/matrix-org/matrix-react-sdk/pull/3537) + * Add full emoji picker for reactions + [\#3554](https://github.com/matrix-org/matrix-react-sdk/pull/3554) + * Accessibility fixes to autocomplete and tabpanels + [\#3555](https://github.com/matrix-org/matrix-react-sdk/pull/3555) + * Show warning dialog when changing unreachable IS + [\#3549](https://github.com/matrix-org/matrix-react-sdk/pull/3549) + * Fix reply fallback being included in edit m.new_content + [\#3551](https://github.com/matrix-org/matrix-react-sdk/pull/3551) + * Document composer features + [\#3548](https://github.com/matrix-org/matrix-react-sdk/pull/3548) + * Correctly update the banned users list when a user is unbanned + [\#3547](https://github.com/matrix-org/matrix-react-sdk/pull/3547) + * Summarise state events after room creation + [\#3433](https://github.com/matrix-org/matrix-react-sdk/pull/3433) + * Don't intercept TAB on the app outside of the composer, fix tabIndex > 0 + [\#3543](https://github.com/matrix-org/matrix-react-sdk/pull/3543) + * Add some type checking on event body + [\#3546](https://github.com/matrix-org/matrix-react-sdk/pull/3546) + * Fix: crash while canceling editing an event when no selection + [\#3544](https://github.com/matrix-org/matrix-react-sdk/pull/3544) + * SettingsFlag always run ToggleSwitch fully-controlled + [\#3541](https://github.com/matrix-org/matrix-react-sdk/pull/3541) + * Use Keyboard Key consts instead of hardcoded strings + [\#3540](https://github.com/matrix-org/matrix-react-sdk/pull/3540) + +Changes in [1.7.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.0) (2019-10-18) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.0-rc.1...v1.7.0) + + * Upgrade to JS SDK v2.4.2 + * Fix: edit unmount when no selection + [\#3545](https://github.com/matrix-org/matrix-react-sdk/pull/3545) + * "SettingsFlag always run ToggleSwitch fully controlled" to release + [\#3542](https://github.com/matrix-org/matrix-react-sdk/pull/3542) + +Changes in [1.7.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.7.0-rc.1) (2019-10-09) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.6.2...v1.7.0-rc.1) + + * Update from Weblate + [\#3539](https://github.com/matrix-org/matrix-react-sdk/pull/3539) + * React error/warning cleanup + [\#3529](https://github.com/matrix-org/matrix-react-sdk/pull/3529) + * Add label to rageshakes for React soft crashes + [\#3535](https://github.com/matrix-org/matrix-react-sdk/pull/3535) + * Support UI Auth on adding email addresses & phone numbers + [\#3534](https://github.com/matrix-org/matrix-react-sdk/pull/3534) + * Unmount React components before stopping the client + [\#3533](https://github.com/matrix-org/matrix-react-sdk/pull/3533) + * Fix soft crash on room join + [\#3532](https://github.com/matrix-org/matrix-react-sdk/pull/3532) + * Fix: Unable to verify email address error + [\#3528](https://github.com/matrix-org/matrix-react-sdk/pull/3528) + * Fix: submit create room dialog when pressing enter + [\#3509](https://github.com/matrix-org/matrix-react-sdk/pull/3509) + * Allow cyclic objects in console logs + [\#3531](https://github.com/matrix-org/matrix-react-sdk/pull/3531) + * Fix: watch emoticon autoreplace setting + [\#3530](https://github.com/matrix-org/matrix-react-sdk/pull/3530) + * Make "remove recent messages" more robust + [\#3508](https://github.com/matrix-org/matrix-react-sdk/pull/3508) + * Label submit button in UI auth password prompt + [\#3527](https://github.com/matrix-org/matrix-react-sdk/pull/3527) + * Null-guard the recaptcha setup + [\#3526](https://github.com/matrix-org/matrix-react-sdk/pull/3526) + * Use a mask instead of an img for "Show image" eye + [\#3513](https://github.com/matrix-org/matrix-react-sdk/pull/3513) + * Only limit the rageshake log size in one place + [\#3523](https://github.com/matrix-org/matrix-react-sdk/pull/3523) + * Rename UPPER_CAMEL_CASE to UPPER_SNAKE_CASE in Coding Style + [\#3525](https://github.com/matrix-org/matrix-react-sdk/pull/3525) + * Revert "Run yarn upgrade" + [\#3524](https://github.com/matrix-org/matrix-react-sdk/pull/3524) + * Run yarn upgrade + [\#3521](https://github.com/matrix-org/matrix-react-sdk/pull/3521) + * Limit Backspace-consuming workaround to just Slate, tidy Keyboard :) + [\#3522](https://github.com/matrix-org/matrix-react-sdk/pull/3522) + * Enable CIDER composer by default + [\#3519](https://github.com/matrix-org/matrix-react-sdk/pull/3519) + * Update from Weblate + [\#3520](https://github.com/matrix-org/matrix-react-sdk/pull/3520) + * Cull some easily fixable errors which make the console a mess + [\#3516](https://github.com/matrix-org/matrix-react-sdk/pull/3516) + Changes in [1.6.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.6.2) (2019-10-04) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.6.2-rc.1...v1.6.2) diff --git a/README.md b/README.md index e944b04ff2..0fbed22030 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ matrix-react-sdk This is a react-based SDK for inserting a Matrix chat/voip client into a web page. This package provides the React components needed to build a Matrix web client -using React. It is not useable in isolation, and instead must must be used from +using React. It is not useable in isolation, and instead must be used from a 'skin'. A skin provides: * Customised implementations of presentation components. * Custom CSS @@ -67,6 +67,7 @@ practices that anyone working with the SDK needs to be be aware of and uphold: * After creating a new component you must run `yarn reskindex` to regenerate the `component-index.js` for the SDK (used in future for skinning) + * The view's CSS file MUST have the same name (e.g. view/rooms/MessageTile.css). CSS for matrix-react-sdk currently resides in @@ -82,7 +83,7 @@ practices that anyone working with the SDK needs to be be aware of and uphold: 'Stealing' styling information from other components (including parents) is not cool, as it breaks the independence of the components. - * CSS classes are named with an app-specific namespacing prefix to try to avoid + * CSS classes are named with an app-specific name-spacing prefix to try to avoid CSS collisions. The base skin shipped by Matrix.org with the matrix-react-sdk uses the naming prefix "mx_". A company called Yoyodyne Inc might use a prefix like "yy_" for its app-specific classes. @@ -107,7 +108,7 @@ practices that anyone working with the SDK needs to be be aware of and uphold: .mx_RoomTile {} in RoomList.css - only RoomTile.css is allowed to define its own CSS. Instead, say .mx_RoomList .mx_RoomTile {} to scope the override only to the context of RoomList views. N.B. overrides should be relatively - rare as in general CSS inheritence should be enough. + rare as in general CSS inheritance should be enough. * Components should render only within the bounding box of their outermost DOM element. Page-absolute positioning and negative CSS margins and similar are @@ -168,3 +169,8 @@ Ensure you've followed the above development instructions and then: ```bash yarn test ``` + +## End-to-End tests + +Make sure you've got your Riot development server running (by doing `yarn start` in riot-web), and then in this project, run `yarn run e2etests`. +See `test/end-to-end-tests/README.md` for more information. diff --git a/__mocks__/browser-request.js b/__mocks__/browser-request.js new file mode 100644 index 0000000000..7d231fb9db --- /dev/null +++ b/__mocks__/browser-request.js @@ -0,0 +1,17 @@ +const en = require("../src/i18n/strings/en_EN"); + +module.exports = jest.fn((opts, cb) => { + const url = opts.url || opts.uri; + if (url && url.endsWith("languages.json")) { + cb(undefined, {status: 200}, JSON.stringify({ + "en": { + "fileName": "en_EN.json", + "label": "English", + }, + })); + } else if (url && url.endsWith("en_EN.json")) { + cb(undefined, {status: 200}, JSON.stringify(en)); + } else { + cb(true, {status: 404}, ""); + } +}); diff --git a/__mocks__/imageMock.js b/__mocks__/imageMock.js new file mode 100644 index 0000000000..474ac702b4 --- /dev/null +++ b/__mocks__/imageMock.js @@ -0,0 +1 @@ +module.exports = "image-file-stub"; diff --git a/__mocks__/languages.json b/__mocks__/languages.json new file mode 100644 index 0000000000..f62fe9b9b4 --- /dev/null +++ b/__mocks__/languages.json @@ -0,0 +1,10 @@ +{ + "en": { + "fileName": "en_EN.json", + "label": "English" + }, + "en-us": { + "fileName": "en_US.json", + "label": "English (US)" + } +} diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000000..d5a97d56ce --- /dev/null +++ b/babel.config.js @@ -0,0 +1,23 @@ +module.exports = { + "sourceMaps": "inline", + "presets": [ + ["@babel/preset-env", { + "targets": [ + "last 2 Chrome versions", "last 2 Firefox versions", "last 2 Safari versions" + ], + }], + "@babel/preset-typescript", + "@babel/preset-flow", + "@babel/preset-react" + ], + "plugins": [ + ["@babel/plugin-proposal-decorators", {legacy: true}], + "@babel/plugin-proposal-export-default-from", + "@babel/plugin-proposal-numeric-separator", + "@babel/plugin-proposal-class-properties", + "@babel/plugin-proposal-object-rest-spread", + "@babel/plugin-transform-flow-comments", + "@babel/plugin-syntax-dynamic-import", + "@babel/plugin-transform-runtime" + ] +}; diff --git a/code_style.md b/code_style.md index e7844b939c..3ad0d38873 100644 --- a/code_style.md +++ b/code_style.md @@ -22,7 +22,7 @@ number throgh from the original code to the final application. General Style ------------- - 4 spaces to indent, for consistency with Matrix Python. -- 120 columns per line, but try to keep JavaScript code around the 80 column mark. +- 120 columns per line, but try to keep JavaScript code around the 90 column mark. Inline JSX in particular can be nicer with more columns per line. - No trailing whitespace at end of lines. - Don't indent empty lines. @@ -174,12 +174,6 @@ React // Best, if onFooClick would do anything other than directly calling doStuff ``` - Not doing so is acceptable in a single case: in function-refs: - - ```jsx - this.component = self}> - ``` - - Prefer classes that extend `React.Component` (or `React.PureComponent`) instead of `React.createClass` - You can avoid the need to bind handler functions by using [property initializers](https://reactjs.org/docs/react-component.html#constructor): @@ -208,3 +202,5 @@ React ``` - Think about whether your component really needs state: are you duplicating information in component state that could be derived from the model? + +- Avoid things marked as Legacy or Deprecated in React 16 (e.g string refs and legacy contexts) diff --git a/docs/ciderEditor.md b/docs/ciderEditor.md index e67c74a95c..00033b5b8c 100644 --- a/docs/ciderEditor.md +++ b/docs/ciderEditor.md @@ -2,8 +2,7 @@ The CIDER editor is a custom editor written for Riot. Most of the code can be found in the `/editor/` directory of the `matrix-react-sdk` project. -It is used to power the composer to edit messages, -and will soon be used as the main composer to send messages as well. +It is used to power the composer main composer (both to send and edit messages), and might be used for other usecases where autocomplete is desired (invite box, ...). ## High-level overview. diff --git a/docs/scrolling.md b/docs/scrolling.md new file mode 100644 index 0000000000..71329e5c32 --- /dev/null +++ b/docs/scrolling.md @@ -0,0 +1,28 @@ +# ScrollPanel + +## Updates + +During an onscroll event, we check whether we're getting close to the top or bottom edge of the loaded content. If close enough, we fire a request to load more through the callback passed in the `onFillRequest` prop. This returns a promise is passed down from `TimelinePanel`, where it will call paginate on the `TimelineWindow` and once the events are received back, update its state with the new events. This update trickles down to the `MessagePanel`, which rerenders all tiles and passed that to `ScrollPanel`. ScrollPanels `componentDidUpdate` method gets called, and we do the scroll housekeeping there (read below). Once the rerender has completed, the `setState` callback is called and we resolve the promise returned by `onFillRequest`. Now we check the DOM to see if we need more fill requests. + +## Prevent Shrinking + +ScrollPanel supports a mode to prevent it shrinking. This is used to prevent a jump when at the bottom of the timeline and people start and stop typing. It gets cleared automatically when 200px above the bottom of the timeline. + + +## BACAT (Bottom-Aligned, Clipped-At-Top) scrolling + +BACAT scrolling implements a different way of restoring the scroll position in the timeline while tiles out of view are changing height or tiles are being added or removed. It was added in https://github.com/matrix-org/matrix-react-sdk/pull/2842. + +The motivation for the changes is having noticed that setting scrollTop while scrolling tends to not work well, with it interrupting ongoing scrolling and also querying scrollTop reporting outdated values and consecutive scroll adjustments cancelling each out previous ones. This seems to be worse on macOS than other platforms, presumably because of a higher resolution in scroll events there. Also see https://github.com/vector-im/riot-web/issues/528. The BACAT approach allows to only have to change the scroll offset when adding or removing tiles. + +The approach taken instead is to vertically align the timeline tiles to the bottom of the scroll container (using flexbox) and give the timeline inside the scroll container an explicit height, initially set to a multiple of the PAGE_SIZE (400px at time of writing) as needed by the content. When scrolled up, we can compensate for anything that grew below the viewport by changing the height of the timeline to maintain what's currently visible in the viewport without adjusting the scrollTop and hence without jumping. + +For anything above the viewport growing or shrinking, we don't need to do anything as the timeline is bottom-aligned. We do need to update the height manually to keep all content visible as more is loaded. To maintain scroll position after the portion above the viewport changes height, we need to set the scrollTop, as we cannot balance it out with more height changes. We do this 100ms after the user has stopped scrolling, so setting scrollTop has not nasty side-effects. + +As of https://github.com/matrix-org/matrix-react-sdk/pull/4166, we are scrolling to compensate for height changes by calling `scrollBy(0, x)` rather than reading and than setting `scrollTop`, as reading `scrollTop` can (again, especially on macOS) easily return values that are out of sync with what is on the screen, probably because scrolling can be done [off the main thread](https://wiki.mozilla.org/Platform/GFX/APZ) in some circumstances. This seems to further prevent jumps. + +### How does it work? + +`componentDidUpdate` is called when a tile in the timeline is updated (as we rerender the whole timeline) or tiles are added or removed (see Updates section before). From here, `checkScroll` is called, which calls `_restoreSavedScrollState`. Now, we increase the timeline height if something below the viewport grew by adjusting `this._bottomGrowth`. `bottomGrowth` is the height added to the timeline (on top of the height from the number of pages calculated at the last `_updateHeight` run) to compensate for growth below the viewport. This is cleared during the next run of `_updateHeight`. Remember that the tiles in the timeline are aligned to the bottom. + +From `_restoreSavedScrollState` we also call `_updateHeight` which waits until the user stops scrolling for 100ms and then recalculates the amount of pages of 400px the timeline should be sized to, to be able to show all of its (newly added) content. We have to adjust the scroll offset (which is why we wait until scrolling has stopped) now because the space above the viewport has likely changed. diff --git a/docs/skinning.md b/docs/skinning.md new file mode 100644 index 0000000000..229bc78372 --- /dev/null +++ b/docs/skinning.md @@ -0,0 +1,71 @@ +# Skinning + +The react-sdk can be skinned to replace presentation components, CSS, or +other relevant parts of the SDK. Typically consumers will replace entire +components and get the ability for custom CSS as a result. + +This doc isn't exhaustive on how skinning works, though it should cover +some of the more complicated parts such as component replacement. + +## Loading a skin + +1. Generate a `component-index.js` (preferably using the tools that the react-sdk +exposes). This can typically be done with a npm script like `"reskindex -h src/header"`. +2. In your app's entry point, add something like this code: + ```javascript + import {loadSkin} from "matrix-react-sdk"; + loadSkin(import("component-index").components); + // The rest of your imports go under this. + ``` +3. Import the remainder of the SDK and bootstrap your app. + +It is extremely important that you **do not** import anything else from the +SDK prior to loading your skin as otherwise the skin might not work. Loading +the skin should be one of the first things your app does, if not the very +first thing. + +Additionally, **do not** provide `loadSkin` with the react-sdk components +themselves otherwise the app might explode. The SDK is already aware of its +components and doesn't need to be told. + +## Replacing components + +Components that replace the react-sdk ones MUST have a `replaces` static +key on the component's class to describe which component it overrides. For +example, if your `VectorAuthPage` component is meant to replace the react-sdk +`AuthPage` component then you'd add `static replaces = 'views.auth.AuthPage';` +to the `VectorAuthPage` class. + +Other than that, the skin just needs to be loaded normally as mentioned above. +Consumers of the SDK likely will not be interested in the rest of this section. + +### SDK developer notes + +Components in the react-sdk MUST be decorated with the `@replaceableComponent` +function. For components that can't use the decorator, they must use a +variation that provides similar functionality. The decorator gives consumers +an opportunity to load skinned components by abusing import ordering and +behaviour. + +Decorators are executed at import time which is why we can abuse the import +ordering behaviour: importing `loadSkin` doesn't trigger any components to +be imported, allowing the consumer to specify a skin. When the consumer does +import a component (for example, `MatrixChat`), it starts to pull in all the +components via `import` statements. When the components get pulled in the +decorator checks with the skinned components to see if it should be replacing +the component being imported. The decorator then effectively replaces the +components when needed by specifying the skinned component as an override for +the SDK's component, which should in theory override critical functions like +`render()` and lifecycle event handlers. + +The decorator also means that older usage of `getComponent()` is no longer +required because components should be replaced by the decorator. Eventually +the react-sdk should only have one usage of `getComponent()`: the decorator. + +The decorator assumes that if `getComponent()` returns null that there is +no skinned version of the component and continues on using the SDK's component. +In previous versions of the SDK, the function would throw an error instead +because it also expected the skin to list the SDK's components as well, however +that is no longer possible due to the above. + +In short, components should always be `import`ed. diff --git a/docs/usercontent.md b/docs/usercontent.md new file mode 100644 index 0000000000..e54851dd0d --- /dev/null +++ b/docs/usercontent.md @@ -0,0 +1,27 @@ +# Usercontent + +While decryption itself is safe to be done without a sandbox, +letting the browser and user interact with the resulting data may be dangerous, +previously `usercontent.riot.im` was used to act as a sandbox on a different origin to close the attack surface, +it is now possible to do by using a combination of a sandboxed iframe and some code written into the app which consumes this SDK. + +Usercontent is an iframe sandbox target for allowing a user to safely download a decrypted attachment from a sandboxed origin where it cannot be used to XSS your riot session out from under you. + +Its function is to create an Object URL for the user/browser to use but bound to an origin different to that of the riot instance to protect against XSS. + +It exposes a function over a postMessage API, when sent an object with the matching fields to render a download link with the Object URL: + +```json5 +{ + "imgSrc": "", // the src of the image to display in the download link + "imgStyle": "", // the style to apply to the image + "style": "", // the style to apply to the download link + "download": "", // download attribute to pass to the tag + "textContent": "", // the text to put inside the download link + "blob": "", // the data blob to wrap in an object url and allow the user to download +} +``` + +If only imgSrc, imgStyle and style are passed then just update the existing link without overwriting other things about it. + +It is expected that this target be available at `usercontent/` relative to the root of the app, this can be seen in riot-web's webpack config. diff --git a/jenkins.sh b/jenkins.sh deleted file mode 100755 index 70bc12e42d..0000000000 --- a/jenkins.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash - -set -e - -export NVM_DIR="$HOME/.nvm" -[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" -nvm use 10 - -set -x - -scripts/fetchdep.sh matrix-org matrix-js-sdk - -pushd matrix-js-sdk -yarn link -yarn install -popd - -yarn link matrix-js-sdk - -# install the other dependencies -yarn install - -# run the mocha tests -yarn test --no-colors - -# run eslint -yarn lintall -f checkstyle -o eslint.xml || true - -# re-run the linter, excluding any files known to have errors or warnings. -yarn lintwithexclusions - -# lint styles -yarn stylelint - -# delete the old tarball, if it exists -rm -f matrix-react-sdk-*.tgz - -# build our tarball -yarn pack diff --git a/karma.conf.js b/karma.conf.js deleted file mode 100644 index d55be049bb..0000000000 --- a/karma.conf.js +++ /dev/null @@ -1,228 +0,0 @@ -// karma.conf.js - the config file for karma, which runs our tests. - -var path = require('path'); -var fs = require('fs'); - -/* - * We use webpack to build our tests. It's a pain to have to wait for webpack - * to build everything; however it's the easiest way to load our dependencies - * from node_modules. - * - * If you run karma in multi-run mode (with `yarn test-multi`), it will watch - * the tests for changes, and webpack will rebuild using a cache. This is much quicker - * than a clean rebuild. - */ - -// the name of the test file. By default, a special file which runs all tests. -// -// TODO: this could be a pattern, and karma would run each file, with a -// separate webpack bundle for each file. But then we get a separate instance -// of the sdk, and each of the dependencies, for each test file, and everything -// gets very confused. Can we persuade webpack to put all of the dependencies -// in a 'common' bundle? -// -var testFile = process.env.KARMA_TEST_FILE || 'test/all-tests.js'; - - -process.env.PHANTOMJS_BIN = 'node_modules/.bin/phantomjs'; - -function fileExists(name) { - try { - fs.statSync(name); - return true; - } catch (e) { - return false; - } -} - -// try find the gemini-scrollbar css in an version-agnostic way -var gsCss = 'node_modules/gemini-scrollbar/gemini-scrollbar.css'; -if (!fileExists(gsCss)) { - gsCss = 'node_modules/react-gemini-scrollbar/'+gsCss; -} - - -module.exports = function (config) { - config.set({ - // frameworks to use - // available frameworks: https://npmjs.org/browse/keyword/karma-adapter - frameworks: ['mocha'], - - // list of files / patterns to load in the browser - files: [ - testFile, - gsCss, - - // some images to reduce noise from the tests - {pattern: 'test/img/*', watched: false, included: false, - served: true, nocache: false}, - // translation files - {pattern: 'src/i18n/strings/*', watcheed: false, included: false, served: true}, - {pattern: 'test/i18n/*', watched: false, included: false, served: true}, - ], - - proxies: { - // redirect img links to the karma server - "/img/": "/base/test/img/", - // special languages.json file for the tests - "/i18n/languages.json": "/base/test/i18n/languages.json", - // and redirect i18n requests - "/i18n/": "/base/src/i18n/strings/", - }, - - // list of files to exclude - // - // This doesn't work. It turns out that it's webpack which does the - // watching of the /test directory (karma only watches `testFile` - // itself). Webpack watches the directory so that it can spot - // new tests, which is fair enough; unfortunately it triggers a rebuild - // every time a lockfile is created in that directory, and there - // doesn't seem to be any way to tell webpack to ignore particular - // files in a watched directory. - // - // exclude: [ - // '**/.#*' - // ], - - // preprocess matching files before serving them to the browser - // available preprocessors: - // https://npmjs.org/browse/keyword/karma-preprocessor - preprocessors: { - 'test/**/*.js': ['webpack', 'sourcemap'] - }, - - // test results reporter to use - // possible values: 'dots', 'progress' - // available reporters: https://npmjs.org/browse/keyword/karma-reporter - reporters: ['logcapture', 'spec', 'summary'], - - specReporter: { - suppressErrorSummary: false, // do print error summary - suppressFailed: false, // do print information about failed tests - suppressPassed: false, // do print information about passed tests - showSpecTiming: true, // print the time elapsed for each spec - }, - - client: { - captureLogs: true, - }, - - // web server port - port: 9876, - - // enable / disable colors in the output (reporters and logs) - colors: true, - - // level of logging - // possible values: config.LOG_DISABLE || config.LOG_ERROR || - // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - // - // This is strictly for logs that would be generated by the browser itself and we - // don't want to log about missing images, which are emitted on LOG_WARN. - logLevel: config.LOG_ERROR, - - // enable / disable watching file and executing tests whenever any file - // changes - autoWatch: true, - - // start these browsers - // available browser launchers: - // https://npmjs.org/browse/keyword/karma-launcher - browsers: [ - 'Chrome', - //'PhantomJS', - //'ChromeHeadless', - ], - - customLaunchers: { - 'VectorChromeHeadless': { - base: 'Chrome', - flags: [ - '--no-sandbox', - // See https://chromium.googlesource.com/chromium/src/+/lkgr/headless/README.md - '--headless', - '--disable-gpu', - // Without a remote debugging port, Google Chrome exits immediately. - '--remote-debugging-port=9222', - ], - } - }, - - // Continuous Integration mode - // if true, Karma captures browsers, runs the tests and exits - // singleRun: false, - - // Concurrency level - // how many browser should be started simultaneous - concurrency: Infinity, - - webpack: { - module: { - rules: [ - { - test: /\.js$/, loader: "babel-loader", - include: [path.resolve('./src'), - path.resolve('./test'), - ] - }, - { - test: /\.(gif|png|svg|ttf|woff2)$/, - loader: 'file-loader', - }, - ], - noParse: [ - // for cross platform compatibility use [\\\/] as the path separator - // this ensures that the regex trips on both Windows and *nix - - // don't parse the languages within highlight.js. They - // cause stack overflows - // (https://github.com/webpack/webpack/issues/1721), and - // there is no need for webpack to parse them - they can - // just be included as-is. - /highlight\.js[\\\/]lib[\\\/]languages/, - - // olm takes ages for webpack to process, and it's already heavily - // optimised, so there is little to gain by us uglifying it. - /olm[\\\/](javascript[\\\/])?olm\.js$/, - - // also disable parsing for sinon, because it - // tries to do voodoo with 'require' which upsets - // webpack (https://github.com/webpack/webpack/issues/304) - /sinon[\\\/]pkg[\\\/]sinon\.js$/, - ], - }, - resolve: { - alias: { - // alias any requires to the react module to the one in our - // path, otherwise we tend to get the react source included - // twice when using `npm link` / `yarn link`. - react: path.resolve('./node_modules/react'), - - 'matrix-react-sdk': path.resolve('test/skinned-sdk.js'), - 'sinon': 'sinon/pkg/sinon.js', - }, - modules: [ - path.resolve('./test'), - "node_modules" - ], - }, - devtool: 'inline-source-map', - externals: { - // Don't try to bundle electron: leave it as a commonjs dependency - // (the 'commonjs' here means it will output a 'require') - "electron": "commonjs electron", - }, - // make sure we're flagged as development to avoid wasting time optimising - mode: 'development', - }, - - webpackMiddleware: { - stats: { - // don't fill the console up with a mahoosive list of modules - chunks: false, - }, - }, - - browserNoActivityTimeout: 15000, - }); -}; diff --git a/package.json b/package.json index d2955f89be..6380eabd9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "1.6.2", + "version": "2.2.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -8,58 +8,53 @@ "url": "https://github.com/matrix-org/matrix-react-sdk" }, "license": "Apache-2.0", - "main": "lib/index.js", "files": [ - ".babelrc", - ".eslintrc.js", + "lib", + "res", + "src", + "scripts", + "git-revision.txt", + "docs", + "header", "CHANGELOG.md", "CONTRIBUTING.rst", "LICENSE", "README.md", - "code_style.md", - "git-revision.txt", - "header", - "jenkins.sh", - "karma.conf.js", - "lib", - "package.json", - "release.sh", - "scripts", - "src", - "test", - "res" + "package.json" ], "bin": { "reskindex": "scripts/reskindex.js", "matrix-gen-i18n": "scripts/gen-i18n.js", "matrix-prune-i18n": "scripts/prune-i18n.js" }, + "main": "./lib/index.js", + "typings": "./lib/index.d.ts", + "matrix_src_main": "./src/index.js", "scripts": { - "reskindex": "node scripts/reskindex.js -h header", - "reskindex:watch": "node scripts/reskindex.js -h header -w", - "rethemendex": "res/css/rethemendex.sh", + "prepare": "yarn build", "i18n": "matrix-gen-i18n", "prunei18n": "matrix-prune-i18n", "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", - "build": "yarn reskindex && yarn start:init", - "build:watch": "babel src -w --skip-initial-build -d lib --source-maps --copy-files", - "emoji-data-strip": "node scripts/emoji-data-strip.js", - "start": "yarn start:init && yarn start:all", - "start:all": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n build,reskindex \"yarn build:watch\" \"yarn reskindex:watch\"", - "start:init": "babel src -d lib --source-maps --copy-files", - "lint": "eslint src/", - "lintall": "eslint src/ test/", - "lintwithexclusions": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", - "stylelint": "stylelint 'res/css/**/*.scss'", + "reskindex": "node scripts/reskindex.js -h header", + "reskindex:watch": "node scripts/reskindex.js -h header -w", + "rethemendex": "res/css/rethemendex.sh", "clean": "rimraf lib", - "prepare": "yarn clean && yarn build && git rev-parse HEAD > git-revision.txt", - "test": "karma start --single-run=true --browsers VectorChromeHeadless", - "test-multi": "karma start" + "build": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types", + "build:compile": "yarn reskindex && babel -d lib --verbose --extensions \".ts,.js\" src", + "build:types": "tsc --emitDeclarationOnly", + "start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && yarn start:all", + "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:ts && yarn lint:js && yarn lint:style", + "lint:js": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", + "lint:ts": "tslint --project ./tsconfig.json -t stylish", + "lint:types": "tsc --noEmit", + "lint:style": "stylelint 'res/css/**/*.scss'", + "test": "jest", + "test:e2e": "./test/end-to-end-tests/run.sh --riot-url http://localhost:8080" }, "dependencies": { - "babel-plugin-syntax-dynamic-import": "^6.18.0", - "babel-runtime": "^6.26.0", - "bluebird": "^3.5.0", + "@babel/runtime": "^7.8.3", "blueimp-canvas-to-blob": "^3.5.0", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", @@ -71,96 +66,104 @@ "diff-match-patch": "^1.0.4", "emojibase-data": "^4.0.2", "emojibase-regex": "^3.0.0", + "escape-html": "^1.0.3", "file-saver": "^1.3.3", "filesize": "3.5.6", "flux": "2.1.1", - "focus-trap-react": "^3.0.5", "focus-visible": "^5.0.2", "fuse.js": "^2.2.0", "gemini-scrollbar": "github:matrix-org/gemini-scrollbar#91e1e566", "gfm.css": "^1.1.1", - "glob": "^5.0.14", + "glob-to-regexp": "^0.4.1", "highlight.js": "^9.15.8", + "html-entities": "^1.2.1", "is-ip": "^2.0.0", - "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.6", "lodash": "^4.17.14", - "lolex": "4.2", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "optimist": "^0.6.1", + "minimist": "^1.2.0", "pako": "^1.0.5", "png-chunks-extract": "^1.0.0", "prop-types": "^15.5.8", + "qrcode": "^1.4.4", "qrcode-react": "^0.1.16", "qs": "^6.6.0", - "querystring": "^0.2.0", "react": "^16.9.0", "react-addons-css-transition-group": "15.6.2", "react-beautiful-dnd": "^4.0.1", "react-dom": "^16.9.0", + "react-focus-lock": "^2.2.1", "react-gemini-scrollbar": "github:matrix-org/react-gemini-scrollbar#9cf17f63b7c0b0ec5f31df27da0f82f7238dc594", "resize-observer-polyfill": "^1.5.0", "sanitize-html": "^1.18.4", - "slate": "^0.41.2", - "slate-html-serializer": "^0.6.1", - "slate-md-serializer": "github:matrix-org/slate-md-serializer#f7c4ad3", - "slate-react": "^0.18.10", "text-encoding-utf-8": "^1.0.1", "url": "^0.11.0", "velocity-animate": "^1.5.2", - "whatwg-fetch": "^1.1.1", + "what-input": "^5.2.6", "zxcvbn": "^4.4.2" }, "devDependencies": { - "babel-cli": "^6.26.0", - "babel-core": "^6.26.3", - "babel-eslint": "^10.0.1", - "babel-loader": "^7.1.5", - "babel-plugin-add-module-exports": "^0.2.1", - "babel-plugin-transform-async-to-bluebird": "^1.1.1", - "babel-plugin-transform-builtin-extend": "^1.1.2", - "babel-plugin-transform-class-properties": "^6.24.1", - "babel-plugin-transform-object-rest-spread": "^6.26.0", - "babel-plugin-transform-runtime": "^6.23.0", - "babel-polyfill": "^6.26.0", - "babel-preset-es2015": "^6.24.1", - "babel-preset-es2016": "^6.24.1", - "babel-preset-es2017": "^6.24.1", - "babel-preset-react": "^6.24.1", - "chokidar": "^2.1.2", + "@babel/cli": "^7.7.5", + "@babel/core": "^7.7.5", + "@babel/plugin-proposal-class-properties": "^7.7.4", + "@babel/plugin-proposal-decorators": "^7.7.4", + "@babel/plugin-proposal-export-default-from": "^7.7.4", + "@babel/plugin-proposal-numeric-separator": "^7.7.4", + "@babel/plugin-proposal-object-rest-spread": "^7.7.4", + "@babel/plugin-transform-flow-comments": "^7.7.4", + "@babel/plugin-transform-runtime": "^7.8.3", + "@babel/preset-env": "^7.7.6", + "@babel/preset-flow": "^7.7.4", + "@babel/preset-react": "^7.7.4", + "@babel/preset-typescript": "^7.7.4", + "@babel/register": "^7.7.4", + "@peculiar/webcrypto": "^1.0.22", + "babel-eslint": "^10.0.3", + "babel-jest": "^24.9.0", + "chokidar": "^3.3.1", "concurrently": "^4.0.1", + "enzyme": "^3.10.0", + "enzyme-adapter-react-16": "^1.15.1", "eslint": "^5.12.0", "eslint-config-google": "^0.7.1", "eslint-plugin-babel": "^5.2.1", "eslint-plugin-flowtype": "^2.30.0", + "eslint-plugin-jest": "^23.0.4", "eslint-plugin-react": "^7.7.0", + "eslint-plugin-react-hooks": "^2.0.1", "estree-walker": "^0.5.0", - "expect": "^24.1.0", "file-loader": "^3.0.1", "flow-parser": "^0.57.3", - "jest-mock": "^23.2.0", - "karma": "^4.0.1", - "karma-chrome-launcher": "^2.2.0", - "karma-cli": "^1.0.1", - "karma-logcapture-reporter": "0.0.1", - "karma-mocha": "^1.3.0", - "karma-sourcemap-loader": "^0.3.7", - "karma-spec-reporter": "^0.0.31", - "karma-summary-reporter": "^1.5.1", - "karma-webpack": "^4.0.0-beta.0", + "glob": "^5.0.14", + "jest": "^24.9.0", + "lolex": "^5.1.2", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.2", - "mocha": "^5.0.5", "react-test-renderer": "^16.9.0", - "require-json": "0.0.1", "rimraf": "^2.4.3", - "sinon": "^5.0.7", "source-map-loader": "^0.2.3", "stylelint": "^9.10.1", "stylelint-config-standard": "^18.2.0", "stylelint-scss": "^3.9.0", + "tslint": "^5.20.1", + "typescript": "^3.7.3", "walk": "^2.3.9", "webpack": "^4.20.2", "webpack-cli": "^3.1.1" + }, + "jest": { + "testMatch": [ + "/test/**/*-test.js" + ], + "setupFilesAfterEnv": [ + "/test/setupTests.js" + ], + "moduleNameMapper": { + "\\.(gif|png|svg|ttf|woff2)$": "/__mocks__/imageMock.js", + "\\$webapp/i18n/languages.json": "/__mocks__/languages.json" + }, + "transformIgnorePatterns": [ + "/node_modules/(?!matrix-js-sdk).+$" + ] } } diff --git a/release.sh b/release.sh index 1f287bc839..23b8822041 100755 --- a/release.sh +++ b/release.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # # Script to perform a release of matrix-react-sdk. # @@ -9,4 +9,52 @@ set -e cd `dirname $0` -exec ./node_modules/matrix-js-sdk/release.sh -z "$@" +for i in matrix-js-sdk +do + echo "Checking version of $i..." + depver=`cat package.json | jq -r .dependencies[\"$i\"]` + latestver=`yarn info -s $i dist-tags.next` + if [ "$depver" != "$latestver" ] + then + echo "The latest version of $i is $latestver but package.json depends on $depver." + echo -n "Type 'u' to auto-upgrade, 'c' to continue anyway, or 'a' to abort:" + read resp + if [ "$resp" != "u" ] && [ "$resp" != "c" ] + then + echo "Aborting." + exit 1 + fi + if [ "$resp" == "u" ] + then + echo "Upgrading $i to $latestver..." + yarn add -E $i@$latestver + git add -u + # The `-e` flag opens the editor and gives you a chance to check + # the upgrade for correctness. + git commit -m "Upgrade $i to $latestver" -e + fi + fi +done + +./node_modules/matrix-js-sdk/release.sh -z "$@" + +release="${1#v}" +prerelease=0 +# We check if this build is a prerelease by looking to +# see if the version has a hyphen in it. Crude, +# but semver doesn't support postreleases so anything +# with a hyphen is a prerelease. +echo $release | grep -q '-' && prerelease=1 + +if [ $prerelease -eq 0 ] +then + # For a release, reset SDK deps back to the `develop` branch. + for i in matrix-js-sdk + do + echo "Resetting $i to develop branch..." + yarn add github:matrix-org/$i#develop + git add -u + git commit -m "Reset $i back to develop branch" + done + git push origin develop +fi diff --git a/res/css/_common.scss b/res/css/_common.scss index 70ab2457f1..e062e0bd73 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -30,6 +30,11 @@ body { color: $primary-fg-color; border: 0px; margin: 0px; + + // needed to match the designs correctly on macOS + // see https://github.com/vector-im/riot-web/issues/11425 + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } pre, code { @@ -333,6 +338,14 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { margin-bottom: 10px; } +.mx_Dialog_titleImage { + vertical-align: middle; + width: 25px; + height: 25px; + margin-left: -2px; + margin-right: 4px; +} + .mx_Dialog_title { font-size: 22px; line-height: 36px; @@ -373,7 +386,13 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { text-align: right; } -.mx_Dialog button, .mx_Dialog input[type="submit"] { +/* XXX: Our button style are a mess: buttons that happen to appear in dialogs get special styles applied + * to them that no button anywhere else in the app gets by default. In practice, buttons in other places + * in the app look the same by being AccessibleButtons, or possibly by having explict button classes. + * We should go through and have one consistent set of styles for buttons throughout the app. + * For now, I am duplicating the selectors here for mx_Dialog and mx_DialogButtons. + */ +.mx_Dialog button, .mx_Dialog input[type="submit"], .mx_Dialog_buttons button, .mx_Dialog_buttons input[type="submit"] { @mixin mx_DialogButton; margin-left: 0px; margin-right: 8px; @@ -389,27 +408,32 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { margin-right: 0px; } -.mx_Dialog button:hover, .mx_Dialog input[type="submit"]:hover { +.mx_Dialog button:hover, .mx_Dialog input[type="submit"]:hover, .mx_Dialog_buttons button:hover, .mx_Dialog_buttons input[type="submit"]:hover { @mixin mx_DialogButton_hover; } -.mx_Dialog button:focus, .mx_Dialog input[type="submit"]:focus { +.mx_Dialog button:focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:focus, .mx_Dialog_buttons input[type="submit"]:focus { filter: brightness($focus-brightness); } -.mx_Dialog button.mx_Dialog_primary, .mx_Dialog input[type="submit"].mx_Dialog_primary { +.mx_Dialog button.mx_Dialog_primary, .mx_Dialog input[type="submit"].mx_Dialog_primary, .mx_Dialog_buttons button.mx_Dialog_primary, .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: $accent-fg-color; background-color: $accent-color; min-width: 156px; } -.mx_Dialog button.danger, .mx_Dialog input[type="submit"].danger { +.mx_Dialog button.danger, .mx_Dialog input[type="submit"].danger, .mx_Dialog_buttons button.danger, .mx_Dialog_buttons input[type="submit"].danger { background-color: $warning-color; border: solid 1px $warning-color; color: $accent-fg-color; } -.mx_Dialog button:disabled, .mx_Dialog input[type="submit"]:disabled { +.mx_Dialog button.warning, .mx_Dialog input[type="submit"].warning { + border: solid 1px $warning-color; + color: $warning-color; +} + +.mx_Dialog button:disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:disabled, .mx_Dialog_buttons input[type="submit"]:disabled { background-color: $light-fg-color; border: solid 1px $light-fg-color; opacity: 0.7; @@ -550,6 +574,22 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { color: $username-variant8-color; } +@define-mixin mx_Tooltip_dark { + box-shadow: none; + background-color: $tooltip-timeline-bg-color; + color: $tooltip-timeline-fg-color; + border: none; + border-radius: 3px; + padding: 6px 8px; +} + +// This is a workaround for our mixins not supporting child selectors +.mx_Tooltip_dark { + .mx_Tooltip_chevron::after { + border-right-color: $tooltip-timeline-bg-color; + } +} + @define-mixin mx_Settings_fullWidthField { margin-right: 100px; } diff --git a/res/css/_components.scss b/res/css/_components.scss index 2c54c5f37f..b3ab7564b1 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -24,10 +24,11 @@ @import "./structures/_SearchBox.scss"; @import "./structures/_TabbedView.scss"; @import "./structures/_TagPanel.scss"; -@import "./structures/_TagPanelButtons.scss"; +@import "./structures/_ToastContainer.scss"; @import "./structures/_TopLeftMenuButton.scss"; @import "./structures/_UploadBar.scss"; @import "./structures/_ViewSource.scss"; +@import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; @import "./views/auth/_AuthBody.scss"; @import "./views/auth/_AuthButtons.scss"; @@ -35,6 +36,7 @@ @import "./views/auth/_AuthHeader.scss"; @import "./views/auth/_AuthHeaderLogo.scss"; @import "./views/auth/_AuthPage.scss"; +@import "./views/auth/_CompleteSecurityBody.scss"; @import "./views/auth/_CountryDropdown.scss"; @import "./views/auth/_InteractiveAuthEntryComponents.scss"; @import "./views/auth/_LanguageSelector.scss"; @@ -48,6 +50,7 @@ @import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; @import "./views/context_menus/_TopLeftMenu.scss"; +@import "./views/context_menus/_WidgetContextMenu.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @@ -61,10 +64,13 @@ @import "./views/dialogs/_EncryptedEventDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; +@import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; -@import "./views/dialogs/_RestoreKeyBackupDialog.scss"; +@import "./views/dialogs/_NewSessionReviewDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; +@import "./views/dialogs/_RoomSettingsDialogBridges.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; +@import "./views/dialogs/_RoomUpgradeWarningDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss"; @import "./views/dialogs/_SetMxIdDialog.scss"; @import "./views/dialogs/_SetPasswordDialog.scss"; @@ -80,6 +86,8 @@ @import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss"; @import "./views/dialogs/keybackup/_KeyBackupFailedDialog.scss"; @import "./views/dialogs/keybackup/_RestoreKeyBackupDialog.scss"; +@import "./views/dialogs/secretstorage/_AccessSecretStorageDialog.scss"; +@import "./views/dialogs/secretstorage/_CreateSecretStorageDialog.scss"; @import "./views/directory/_NetworkDropdown.scss"; @import "./views/elements/_AccessibleButton.scss"; @import "./views/elements/_AddressSelector.scss"; @@ -90,6 +98,8 @@ @import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_EventListSummary.scss"; @import "./views/elements/_Field.scss"; +@import "./views/elements/_FormButton.scss"; +@import "./views/elements/_IconButton.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_InteractiveTooltip.scss"; @@ -108,6 +118,7 @@ @import "./views/elements/_Tooltip.scss"; @import "./views/elements/_TooltipButton.scss"; @import "./views/elements/_Validation.scss"; +@import "./views/emojipicker/_EmojiPicker.scss"; @import "./views/globals/_MatrixToolbar.scss"; @import "./views/groups/_GroupPublicityToggle.scss"; @import "./views/groups/_GroupRoomList.scss"; @@ -122,8 +133,7 @@ @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; -@import "./views/messages/_ReactionQuickTooltip.scss"; -@import "./views/messages/_ReactionTooltipButton.scss"; +@import "./views/messages/_MjolnirBody.scss"; @import "./views/messages/_ReactionsRow.scss"; @import "./views/messages/_ReactionsRowButton.scss"; @import "./views/messages/_ReactionsRowButtonTooltip.scss"; @@ -132,6 +142,10 @@ @import "./views/messages/_TextualEvent.scss"; @import "./views/messages/_UnknownBody.scss"; @import "./views/messages/_ViewSourceEvent.scss"; +@import "./views/messages/_common_CryptoEvent.scss"; +@import "./views/right_panel/_EncryptionInfo.scss"; +@import "./views/right_panel/_UserInfo.scss"; +@import "./views/right_panel/_VerificationPanel.scss"; @import "./views/room_settings/_AliasSettings.scss"; @import "./views/room_settings/_ColorSettings.scss"; @import "./views/rooms/_AppsDrawer.scss"; @@ -142,6 +156,7 @@ @import "./views/rooms/_EditMessageComposer.scss"; @import "./views/rooms/_EntityTile.scss"; @import "./views/rooms/_EventTile.scss"; +@import "./views/rooms/_InviteOnlyIcon.scss"; @import "./views/rooms/_JumpToBottomButton.scss"; @import "./views/rooms/_LinkPreviewWidget.scss"; @import "./views/rooms/_MemberDeviceInfo.scss"; @@ -167,10 +182,13 @@ @import "./views/rooms/_SendMessageComposer.scss"; @import "./views/rooms/_Stickers.scss"; @import "./views/rooms/_TopUnreadMessagesBar.scss"; +@import "./views/rooms/_UserOnlineDot.scss"; @import "./views/rooms/_WhoIsTypingTile.scss"; +@import "./views/settings/_AvatarSetting.scss"; +@import "./views/settings/_CrossSigningPanel.scss"; @import "./views/settings/_DevicesPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; -@import "./views/settings/_IntegrationsManager.scss"; +@import "./views/settings/_IntegrationManager.scss"; @import "./views/settings/_KeyBackupPanel.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @@ -183,6 +201,7 @@ @import "./views/settings/tabs/room/_SecurityRoomSettingsTab.scss"; @import "./views/settings/tabs/user/_GeneralUserSettingsTab.scss"; @import "./views/settings/tabs/user/_HelpUserSettingsTab.scss"; +@import "./views/settings/tabs/user/_MjolnirUserSettingsTab.scss"; @import "./views/settings/tabs/user/_NotificationUserSettingsTab.scss"; @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; diff --git a/res/css/structures/_AutoHideScrollbar.scss b/res/css/structures/_AutoHideScrollbar.scss index db86a6fbd6..6e4484157c 100644 --- a/res/css/structures/_AutoHideScrollbar.scss +++ b/res/css/structures/_AutoHideScrollbar.scss @@ -14,12 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* This file has CSS for both native and non-native scrollbars in an - * order that's fairly logic to read but violates stylelints descending - * specificity rule, so turn it off for this file. It also duplicates - * a selector to separate the hiding/showing from the sizing. +/* This file has CSS for both native and non-native scrollbars in an order + * that's fairly logical to read but duplicates a selector to separate the + * hiding/showing from the sizing. */ -/* stylelint-disable no-descending-specificity, no-duplicate-selectors */ +/* stylelint-disable no-duplicate-selectors */ /* 1. for browsers that support native overlay auto-hiding scrollbars diff --git a/res/css/structures/_CustomRoomTagPanel.scss b/res/css/structures/_CustomRoomTagPanel.scss index 45961d7be1..1fb18ec41e 100644 --- a/res/css/structures/_CustomRoomTagPanel.scss +++ b/res/css/structures/_CustomRoomTagPanel.scss @@ -26,11 +26,16 @@ limitations under the License. .mx_CustomRoomTagPanel_scroller { max-height: inherit; + display: flex; + flex-direction: column; + align-items: center; } .mx_CustomRoomTagPanel .mx_AccessibleButton { - margin: 9px auto; + margin: 0 auto; width: 40px; + padding: 10px 0 9px 0; + position: relative; } .mx_CustomRoomTagPanel .mx_BaseAvatar_image { @@ -39,7 +44,13 @@ limitations under the License. height: 40px; } -.mx_CustomRoomTagPanel .mx_AccessibleButton.CustomRoomTagPanel_tileSelected .mx_BaseAvatar_image { - border: 3px solid $warning-color; - border-radius: 40px; +.mx_CustomRoomTagPanel .mx_AccessibleButton.CustomRoomTagPanel_tileSelected::before { + content: ''; + height: 56px; + background-color: $accent-color-alt; + width: 5px; + position: absolute; + left: -15px; + border-radius: 0 3px 3px 0; + top: 2px; // 10 [padding-top] - (56 - 40)/2 } diff --git a/res/css/structures/_FilePanel.scss b/res/css/structures/_FilePanel.scss index 703e90f402..87e885e668 100644 --- a/res/css/structures/_FilePanel.scss +++ b/res/css/structures/_FilePanel.scss @@ -18,6 +18,7 @@ limitations under the License. order: 2; flex: 1 1 0; overflow-y: auto; + display: flex; } .mx_FilePanel .mx_RoomView_messageListWrapper { diff --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss index ae86f68fd0..517b8b1922 100644 --- a/res/css/structures/_GroupView.scss +++ b/res/css/structures/_GroupView.scss @@ -44,21 +44,29 @@ limitations under the License. } .mx_GroupHeader_button { + position: relative; margin-left: 5px; margin-right: 5px; cursor: pointer; height: 20px; width: 20px; - background-color: $groupheader-button-color; - mask-repeat: no-repeat; - mask-size: contain; + + &::before { + content: ''; + position: absolute; + height: 20px; + width: 20px; + background-color: $groupheader-button-color; + mask-repeat: no-repeat; + mask-size: contain; + } } -.mx_GroupHeader_editButton { - mask-image: url('$(res)/img/icons-settings-room.svg'); +.mx_GroupHeader_editButton::before { + mask-image: url('$(res)/img/feather-customised/settings.svg'); } -.mx_GroupHeader_shareButton { +.mx_GroupHeader_shareButton::before { mask-image: url('$(res)/img/icons-share.svg'); } diff --git a/res/css/structures/_NotificationPanel.scss b/res/css/structures/_NotificationPanel.scss index 78b3522d4e..c9e0261ec9 100644 --- a/res/css/structures/_NotificationPanel.scss +++ b/res/css/structures/_NotificationPanel.scss @@ -18,6 +18,7 @@ limitations under the License. order: 2; flex: 1 1 0; overflow-y: auto; + display: flex; } .mx_NotificationPanel .mx_RoomView_messageListWrapper { diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index c63db5d274..3c373e8883 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,7 +19,7 @@ limitations under the License. overflow-x: hidden; flex: 0 0 auto; position: relative; - min-width: 250px; + min-width: 264px; display: flex; flex-direction: column; } @@ -50,18 +51,18 @@ limitations under the License. height: 20px; width: 20px; position: relative; -} -.mx_RightPanel_headerButton::before { - content: ''; - position: absolute; - top: 0; - left: 0; - height: 20px; - width: 20px; - background-color: $rightpanel-button-color; - mask-repeat: no-repeat; - mask-size: contain; + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 20px; + width: 20px; + background-color: $rightpanel-button-color; + mask-repeat: no-repeat; + mask-size: contain; + } } .mx_RightPanel_membersButton::before { diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index 4b49332af7..5ae8df7176 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -119,6 +119,16 @@ limitations under the License. display: inline-block; } +.mx_RoomDirectory_perm { + border-radius: 10px; + display: inline-block; + height: 20px; + line-height: 20px; + padding: 0 5px; + color: $accent-fg-color; + background-color: $rte-room-pill-color; +} + .mx_RoomDirectory_topic { cursor: initial; color: $light-fg-color; diff --git a/res/css/structures/_RoomSubList.scss b/res/css/structures/_RoomSubList.scss index fc61395bf9..be44563cfb 100644 --- a/res/css/structures/_RoomSubList.scss +++ b/res/css/structures/_RoomSubList.scss @@ -20,7 +20,7 @@ limitations under the License. so they ideally wouldn't affect each other. lowest category: .mx_RoomSubList flex-shrink: 10000000 - distribute size of items within the same categery by their size + distribute size of items within the same category by their size middle category: .mx_RoomSubList.resized-sized flex-shrink: 1000 applied when using the resizer, will have a max-height set to it, @@ -46,10 +46,15 @@ limitations under the License. flex-direction: row; align-items: center; flex: 0 0 auto; - margin: 0 16px; + margin: 0 8px; + padding: 0 8px; height: 36px; } +.mx_RoomSubList_labelContainer.focus-visible:focus-within { + background-color: $roomtile-focused-bg-color; +} + .mx_RoomSubList_label { flex: 1; cursor: pointer; @@ -67,7 +72,7 @@ limitations under the License. margin-left: 8px; } -.mx_RoomSubList_badge { +.mx_RoomSubList_badge > div { flex: 0 0 auto; border-radius: 8px; font-weight: 600; @@ -103,7 +108,7 @@ limitations under the License. } } -.mx_RoomSubList_badgeHighlight { +.mx_RoomSubList_badgeHighlight > div { color: $accent-fg-color; background-color: $warning-color; } @@ -146,6 +151,7 @@ limitations under the License. .mx_RoomSubList_labelContainer { margin-right: 8px; margin-left: 2px; + padding: 0; } .mx_RoomSubList_addRoom { diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 50d412ad58..5e826306c6 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -221,6 +221,9 @@ hr.mx_RoomView_myReadMarker { position: relative; top: -1px; z-index: 1; + transition: width 400ms easeInSine 1s, opacity 400ms easeInSine 1s; + width: 99%; + opacity: 1; } .mx_RoomView_callStatusBar .mx_UploadBar_uploadProgressInner { diff --git a/res/css/structures/_TagPanel.scss b/res/css/structures/_TagPanel.scss index b03d36a592..dddd2e324c 100644 --- a/res/css/structures/_TagPanel.scss +++ b/res/css/structures/_TagPanel.scss @@ -68,7 +68,7 @@ limitations under the License. } .mx_TagPanel .mx_TagPanel_tagTileContainer > div { height: 40px; - padding: 5px 0 4px 0; + padding: 10px 0 9px 0; } .mx_TagPanel .mx_TagTile { @@ -82,21 +82,39 @@ limitations under the License. // opacity: 1; } -.mx_TagPanel .mx_TagTile.mx_TagTile_selected .mx_TagTile_avatar .mx_BaseAvatar { - background-color: $accent-color; - border-radius: 40px; - - /* In case this is a "initial" avatar */ - display: block; +.mx_TagPanel .mx_TagTile_plus { + margin-bottom: 12px; height: 40px; width: 40px; + border-radius: 20px; + background-color: $roomheader-addroom-bg-color; + position: relative; + /* overwrite mx_RoleButton inline-block */ + display: block !important; + + &::before { + background-color: $roomheader-addroom-fg-color; + mask-image: url('$(res)/img/feather-customised/plus.svg'); + mask-position: center; + mask-repeat: no-repeat; + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } } -.mx_TagPanel .mx_TagTile_selected .mx_BaseAvatar_image { - border: 3px solid $accent-color; - height: 40px; - width: 40px; - box-sizing: border-box; +.mx_TagPanel .mx_TagTile.mx_TagTile_selected::before { + content: ''; + height: 56px; + background-color: $accent-color; + width: 5px; + position: absolute; + left: -15px; + border-radius: 0 3px 3px 0; + top: -8px; // (56 - 40)/2 } .mx_TagPanel .mx_TagTile.mx_AccessibleButton:focus { diff --git a/res/css/structures/_TagPanelButtons.scss b/res/css/structures/_TagPanelButtons.scss deleted file mode 100644 index 70fea92959..0000000000 --- a/res/css/structures/_TagPanelButtons.scss +++ /dev/null @@ -1,56 +0,0 @@ -/* -Copyright 2019 New Vector Ltd. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_TagPanelButtons { - background-color: $tagpanel-bg-color; - display: flex; - flex-direction: column; - align-items: center; - justify-content: space-between; - padding: 17px 0 3px 0; -} - -.mx_TagPanelButtons > .mx_GroupsButton::before { - mask: url('$(res)/img/feather-customised/users.svg'); - mask-position: center 11px; -} - -.mx_TagPanelButtons > .mx_TagPanelButtons_report::before { - mask: url('$(res)/img/feather-customised/life-buoy.svg'); - mask-position: center 9px; -} - -.mx_TagPanelButtons > .mx_AccessibleButton { - margin-bottom: 12px; - height: 40px; - width: 40px; - border-radius: 20px; - background-color: $tagpanel-button-color; - position: relative; - /* overwrite mx_RoleButton inline-block */ - display: block !important; - - &::before { - background-color: $tagpanel-bg-color; - mask-repeat: no-repeat; - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - } -} diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss new file mode 100644 index 0000000000..d1687743d6 --- /dev/null +++ b/res/css/structures/_ToastContainer.scss @@ -0,0 +1,106 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ToastContainer { + position: absolute; + top: 0; + left: 70px; + z-index: 101; + padding: 4px; + display: grid; + grid-template-rows: 1fr 14px 6px; + + &.mx_ToastContainer_stacked::before { + content: ""; + margin: 0 4px; + grid-row: 2 / 4; + grid-column: 1; + background-color: white; + box-shadow: 0px 4px 12px $menu-box-shadow-color; + border-radius: 8px; + } + + .mx_Toast_toast { + grid-row: 1 / 3; + grid-column: 1; + color: $primary-fg-color; + background-color: $primary-bg-color; + box-shadow: 0px 4px 12px $menu-box-shadow-color; + border-radius: 8px; + overflow: hidden; + display: grid; + grid-template-columns: 20px 1fr; + column-gap: 10px; + row-gap: 4px; + padding: 8px; + padding-right: 16px; + + &.mx_Toast_hasIcon { + &::after { + content: ""; + width: 22px; + height: 22px; + grid-column: 1; + grid-row: 1; + mask-size: 100%; + mask-repeat: no-repeat; + } + + &.mx_Toast_icon_verification::after { + mask-image: url("$(res)/img/e2e/normal.svg"); + background-color: $primary-fg-color; + } + + &.mx_Toast_icon_verification_warning::after { + background-image: url("$(res)/img/e2e/warning.svg"); + } + + h2, .mx_Toast_body { + grid-column: 2; + } + } + + h2 { + grid-column: 1 / 3; + grid-row: 1; + margin: 0; + font-size: 15px; + font-weight: 600; + } + + .mx_Toast_body { + grid-column: 1 / 3; + grid-row: 2; + } + + .mx_Toast_buttons { + display: flex; + } + + .mx_Toast_description { + max-width: 400px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 4px 0 11px 0; + font-size: 12px; + } + + .mx_Toast_deviceID { + font-size: 10px; + } + } +} diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss new file mode 100644 index 0000000000..2bf51d9574 --- /dev/null +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -0,0 +1,51 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CompleteSecurity_header { + display: flex; + align-items: center; +} + +.mx_CompleteSecurity_headerIcon { + width: 24px; + height: 24px; + margin-right: 4px; + position: relative; +} + +.mx_CompleteSecurity_heroIcon { + width: 128px; + height: 128px; + position: relative; + margin: 0 auto; +} + +.mx_CompleteSecurity_body { + font-size: 15px; +} + +.mx_CompleteSecurity_actionRow { + display: flex; + justify-content: flex-end; + + .mx_AccessibleButton { + margin-inline-start: 18px; + + &.warning { + color: $warning-color; + } + } +} diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index b05629003e..7c5b008535 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -1,5 +1,6 @@ /* Copyright 2019 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,12 +17,12 @@ limitations under the License. .mx_AuthBody { width: 500px; + font-size: 12px; + color: $authpage-secondary-color; background-color: $authpage-body-bg-color; border-radius: 0 4px 4px 0; padding: 25px 60px; box-sizing: border-box; - font-size: 12px; - color: $authpage-secondary-color; h2 { font-size: 24px; diff --git a/res/css/views/auth/_CompleteSecurityBody.scss b/res/css/views/auth/_CompleteSecurityBody.scss new file mode 100644 index 0000000000..c7860fbe74 --- /dev/null +++ b/res/css/views/auth/_CompleteSecurityBody.scss @@ -0,0 +1,42 @@ +/* +Copyright 2019 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CompleteSecurityBody { + width: 600px; + color: $authpage-primary-color; + background-color: $authpage-body-bg-color; + border-radius: 4px; + padding: 20px; + box-sizing: border-box; + + h2 { + font-size: 24px; + font-weight: 600; + margin-top: 0; + } + + h3 { + font-size: 14px; + font-weight: 600; + } + + a:link, + a:hover, + a:visited { + @mixin mx_Dialog_link; + } +} diff --git a/res/css/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index a085034758..e59598278f 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -40,6 +40,7 @@ limitations under the License. } .mx_BaseAvatar_image { + object-fit: cover; border-radius: 40px; vertical-align: top; background-color: $avatar-bg-color; diff --git a/res/css/views/context_menus/_StatusMessageContextMenu.scss b/res/css/views/context_menus/_StatusMessageContextMenu.scss index 972f608caf..2c8d608950 100644 --- a/res/css/views/context_menus/_StatusMessageContextMenu.scss +++ b/res/css/views/context_menus/_StatusMessageContextMenu.scss @@ -61,5 +61,5 @@ input.mx_StatusMessageContextMenu_message { } .mx_StatusMessageContextMenu_actionContainer .mx_Spinner { - justify-content: start; + justify-content: flex-start; } diff --git a/res/css/views/context_menus/_TopLeftMenu.scss b/res/css/views/context_menus/_TopLeftMenu.scss index 9d258bcf55..ed0d0106bc 100644 --- a/res/css/views/context_menus/_TopLeftMenu.scss +++ b/res/css/views/context_menus/_TopLeftMenu.scss @@ -49,23 +49,27 @@ limitations under the License. padding: 0; list-style: none; - li.mx_TopLeftMenu_icon_home::after { + .mx_TopLeftMenu_icon_home::after { mask-image: url('$(res)/img/feather-customised/home.svg'); } - li.mx_TopLeftMenu_icon_settings::after { + .mx_TopLeftMenu_icon_help::after { + mask-image: url('$(res)/img/feather-customised/life-buoy.svg'); + } + + .mx_TopLeftMenu_icon_settings::after { mask-image: url('$(res)/img/feather-customised/settings.svg'); } - li.mx_TopLeftMenu_icon_signin::after { + .mx_TopLeftMenu_icon_signin::after { mask-image: url('$(res)/img/feather-customised/sign-in.svg'); } - li.mx_TopLeftMenu_icon_signout::after { + .mx_TopLeftMenu_icon_signout::after { mask-image: url('$(res)/img/feather-customised/sign-out.svg'); } - li::after { + .mx_AccessibleButton::after { mask-repeat: no-repeat; mask-position: 0 center; mask-size: 16px; @@ -78,14 +82,14 @@ limitations under the License. background-color: $primary-fg-color; } - li { + .mx_AccessibleButton { position: relative; cursor: pointer; white-space: nowrap; padding: 5px 20px 5px 43px; } - li:hover { + .mx_AccessibleButton:hover { background-color: $menu-selected-color; } } diff --git a/res/css/views/context_menus/_WidgetContextMenu.scss b/res/css/views/context_menus/_WidgetContextMenu.scss new file mode 100644 index 0000000000..60b7b93f99 --- /dev/null +++ b/res/css/views/context_menus/_WidgetContextMenu.scss @@ -0,0 +1,36 @@ +/* +Copyright 2019 The Matrix.org Foundaction 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_WidgetContextMenu { + padding: 6px; + + .mx_WidgetContextMenu_option { + padding: 3px 6px 3px 6px; + cursor: pointer; + white-space: nowrap; + } + + .mx_WidgetContextMenu_separator { + margin-top: 0; + margin-bottom: 0; + border-bottom-style: none; + border-left-style: none; + border-right-style: none; + border-top-style: solid; + border-top-width: 1px; + border-color: $menu-border-color; + } +} diff --git a/res/css/views/dialogs/_CreateRoomDialog.scss b/res/css/views/dialogs/_CreateRoomDialog.scss index d3a8f6ff42..7416ec2ef4 100644 --- a/res/css/views/dialogs/_CreateRoomDialog.scss +++ b/res/css/views/dialogs/_CreateRoomDialog.scss @@ -30,7 +30,7 @@ limitations under the License. > div { display: flex; - align-items: start; + align-items: flex-start; margin: 5px 0; input[type=checkbox] { diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss index 417d0d6744..500c46b5fd 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.scss +++ b/res/css/views/dialogs/_DevtoolsDialog.scss @@ -135,9 +135,6 @@ limitations under the License. } } -/* Ordering this block by specificity would require breaking it up into several - chunks, which seems like it would be more confusing to read. */ -/* stylelint-disable no-descending-specificity */ .mx_DevTools_tgl-flip { + .mx_DevTools_tgl-btn { padding: 2px; @@ -192,4 +189,37 @@ limitations under the License. } } } -/* stylelint-enable no-descending-specificity */ + +.mx_DevTools_VerificationRequest { + border: 1px solid #cccccc; + border-radius: 3px; + padding: 1px 5px; + margin-bottom: 6px; + font-family: $monospace-font-family; + + dl { + display: grid; + grid-template-columns: max-content auto; + margin: 0; + } + + dd { + grid-column-start: 2; + } + + dd:empty { + color: #666666; + &::after { + content: "(empty)"; + } + } + + dt { + font-weight: bold; + grid-column-start: 1; + } + + dt::after { + content: ":"; + } +} diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss new file mode 100644 index 0000000000..5e0893b8fd --- /dev/null +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -0,0 +1,228 @@ +/* +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_InviteDialog_addressBar { + display: flex; + flex-direction: row; + + .mx_InviteDialog_editor { + flex: 1; + width: 100%; // Needed to make the Field inside grow + background-color: $user-tile-hover-bg-color; + border-radius: 4px; + min-height: 25px; + padding-left: 8px; + overflow-x: hidden; + overflow-y: auto; + + .mx_InviteDialog_userTile { + display: inline-block; + float: left; + position: relative; + top: 7px; + } + + // Using a textarea for this element, to circumvent autofill + // Mostly copied from AddressPickerDialog + textarea, + textarea:focus { + height: 34px; + line-height: 34px; + font-size: 14px; + padding-left: 12px; + margin: 0 !important; + border: 0 !important; + outline: 0 !important; + resize: none; + overflow: hidden; + box-sizing: border-box; + word-wrap: nowrap; + + // Roughly fill about 2/5ths of the available space. This is to try and 'fill' the + // remaining space after a bunch of pills, but is a bit hacky. Ideally we'd have + // support for "fill remaining width", but traditional tricks don't work with what + // we're pushing into this "field". Flexbox just makes things worse. The theory is + // that users won't need more than about 2/5ths of the input to find the person + // they're looking for. + width: 40%; + } + } + + .mx_InviteDialog_goButton { + min-width: 48px; + margin-left: 10px; + height: 25px; + line-height: 25px; + } + + .mx_InviteDialog_buttonAndSpinner { + .mx_Spinner { + // Width and height are required to trick the layout engine. + width: 20px; + height: 20px; + margin-left: 5px; + display: inline-block; + vertical-align: middle; + } + } +} + +.mx_InviteDialog_section { + padding-bottom: 10px; + + h3 { + font-size: 12px; + color: $muted-fg-color; + font-weight: bold; + text-transform: uppercase; + } +} + +.mx_InviteDialog_roomTile { + cursor: pointer; + padding: 5px 10px; + + &:hover { + background-color: $user-tile-hover-bg-color; + border-radius: 4px; + } + + * { + vertical-align: middle; + } + + .mx_InviteDialog_roomTile_avatarStack { + display: inline-block; + position: relative; + width: 36px; + height: 36px; + + & > * { + position: absolute; + top: 0; + left: 0; + } + } + + .mx_InviteDialog_roomTile_selected { + width: 36px; + height: 36px; + border-radius: 36px; + background-color: $username-variant1-color; + display: inline-block; + position: relative; + + &::before { + content: ""; + width: 24px; + height: 24px; + grid-column: 1; + grid-row: 1; + mask-image: url("$(res)/img/feather-customised/check.svg"); + mask-size: 100%; + mask-repeat: no-repeat; + position: absolute; + top: 6px; // 50% + left: 6px; // 50% + background-color: #ffffff; // this is fine without a var because it's for both themes + } + } + + .mx_InviteDialog_roomTile_name { + font-weight: 600; + font-size: 14px; + color: $primary-fg-color; + margin-left: 7px; + } + + .mx_InviteDialog_roomTile_userId { + font-size: 12px; + color: $muted-fg-color; + margin-left: 7px; + } + + .mx_InviteDialog_roomTile_time { + text-align: right; + font-size: 12px; + color: $muted-fg-color; + float: right; + line-height: 36px; // Height of the avatar to keep the time vertically aligned + } + + .mx_InviteDialog_roomTile_highlight { + font-weight: 900; + } +} + +// Many of these styles are stolen from mx_UserPill, but adjusted for the invite dialog. +.mx_InviteDialog_userTile { + margin-right: 8px; + + .mx_InviteDialog_userTile_pill { + background-color: $username-variant1-color; + border-radius: 12px; + display: inline-block; + height: 24px; + line-height: 24px; + padding-left: 8px; + padding-right: 8px; + color: #ffffff; // this is fine without a var because it's for both themes + + .mx_InviteDialog_userTile_avatar { + border-radius: 20px; + position: relative; + left: -5px; + top: 2px; + } + + img.mx_InviteDialog_userTile_avatar { + vertical-align: top; + } + + .mx_InviteDialog_userTile_name { + vertical-align: top; + } + + .mx_InviteDialog_userTile_threepidAvatar { + background-color: #ffffff; // this is fine without a var because it's for both themes + } + } + + .mx_InviteDialog_userTile_remove { + display: inline-block; + margin-left: 4px; + } +} + +.mx_InviteDialog { + // Prevent the dialog from jumping around randomly when elements change. + height: 590px; + padding-left: 20px; // the design wants some padding on the left +} + +.mx_InviteDialog_userSections { + margin-top: 10px; + overflow-y: auto; + padding-right: 45px; + height: 455px; // mx_InviteDialog's height minus some for the upper elements +} + +// Right margin for the design. We could apply this to the whole dialog, but then the scrollbar +// for the user section gets weird. +.mx_InviteDialog_helpText, +.mx_InviteDialog_addressBar { + margin-right: 45px; +} diff --git a/res/css/views/dialogs/_NewSessionReviewDialog.scss b/res/css/views/dialogs/_NewSessionReviewDialog.scss new file mode 100644 index 0000000000..7e35fe941e --- /dev/null +++ b/res/css/views/dialogs/_NewSessionReviewDialog.scss @@ -0,0 +1,37 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_NewSessionReviewDialog_header { + display: flex; + align-items: center; + margin-top: 0; +} + +.mx_NewSessionReviewDialog_headerIcon { + width: 24px; + height: 24px; + margin-right: 4px; + position: relative; +} + +.mx_NewSessionReviewDialog_deviceName { + font-weight: 600; +} + +.mx_NewSessionReviewDialog_deviceID { + font-size: 12px; + color: $notice-secondary-color; +} diff --git a/res/css/views/dialogs/_RoomSettingsDialog.scss b/res/css/views/dialogs/_RoomSettingsDialog.scss index 723eb237ad..2a4e62f9aa 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.scss +++ b/res/css/views/dialogs/_RoomSettingsDialog.scss @@ -29,6 +29,11 @@ limitations under the License. mask-image: url('$(res)/img/feather-customised/users-sm.svg'); } +.mx_RoomSettingsDialog_bridgesIcon::before { + // This icon is pants, please improve :) + mask-image: url('$(res)/img/feather-customised/bridge.svg'); +} + .mx_RoomSettingsDialog_warningIcon::before { mask-image: url('$(res)/img/feather-customised/warning-triangle.svg'); } @@ -42,3 +47,12 @@ limitations under the License. padding-left: 40px; padding-right: 80px; } + +// show a different AvatarSetting placeholder for RoomProfileSettings which is basically a clone of ProfileSettings +.mx_RoomSettingsDialog .mx_AvatarSetting_avatar .mx_AvatarSetting_avatarPlaceholder::before { + mask: url("$(res)/img/feather-customised/image.svg"); + mask-repeat: no-repeat; + mask-size: 36px; + mask-position: center; +} + diff --git a/res/css/views/dialogs/_RoomSettingsDialogBridges.scss b/res/css/views/dialogs/_RoomSettingsDialogBridges.scss new file mode 100644 index 0000000000..a1793cc75e --- /dev/null +++ b/res/css/views/dialogs/_RoomSettingsDialogBridges.scss @@ -0,0 +1,112 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_RoomSettingsDialog_BridgeList { + padding: 0; + + .mx_AccessibleButton { + display: inline; + margin: 0; + padding: 0; + } +} + +.mx_RoomSettingsDialog_BridgeList li { + list-style-type: none; + padding: 5px; + margin-bottom: 8px; + border-width: 1px 1px; + border-color: $primary-hairline-color; + border-style: solid; + border-radius: 5px; + + .column-icon { + float: left; + padding-right: 10px; + + * { + border-radius: 5px; + border: 1px solid $input-darker-bg-color; + } + + .noProtocolIcon { + width: 48px; + height: 48px; + background: $input-darker-bg-color; + border-radius: 5px; + } + + .protocol-icon { + float: left; + margin-right: 5px; + img { + border-radius: 5px; + border-width: 1px 1px; + border-color: $primary-hairline-color; + } + span { + /* Correct letter placement */ + left: auto; + } + } + } + + .column-data { + display: inline-block; + width: 85%; + + > h3 { + margin-top: 0px; + margin-bottom: 0px; + font-size: 16pt; + color: $primary-fg-color; + } + + > * { + margin-top: 4px; + margin-bottom: 0; + } + + .workspace-channel-details { + color: $primary-fg-color; + font-weight: 600; + + .channel { + margin-left: 5px; + } + } + + .mx_showMore { + display: block; + text-align: left; + margin-top: 10px; + } + + .metadata { + color: $muted-fg-color; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 0; + } + + .metadata.visible { + overflow-y: visible; + text-overflow: ellipsis; + white-space: normal; + } + } +} diff --git a/res/css/views/dialogs/_RoomUpgradeWarningDialog.scss b/res/css/views/dialogs/_RoomUpgradeWarningDialog.scss new file mode 100644 index 0000000000..5b9978eba0 --- /dev/null +++ b/res/css/views/dialogs/_RoomUpgradeWarningDialog.scss @@ -0,0 +1,37 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_RoomUpgradeWarningDialog { + max-width: 38vw; + width: 38vw; +} + +.mx_RoomUpgradeWarningDialog .mx_SettingsFlag { + font-weight: 700; + + .mx_ToggleSwitch { + display: inline-block; + vertical-align: middle; + margin-left: 8px; + float: right; + } + + .mx_SettingsFlag_label { + display: inline-block; + vertical-align: middle; + } +} + diff --git a/res/css/views/dialogs/_TermsDialog.scss b/res/css/views/dialogs/_TermsDialog.scss index aad679a5b3..beb507e778 100644 --- a/res/css/views/dialogs/_TermsDialog.scss +++ b/res/css/views/dialogs/_TermsDialog.scss @@ -16,10 +16,10 @@ limitations under the License. /* * To avoid visual glitching of two modals stacking briefly, we customise the - * terms dialog sizing when it will appear for the integrations manager so that + * terms dialog sizing when it will appear for the integration manager so that * it gets the same basic size as the IM's own modal. */ -.mx_TermsDialog_forIntegrationsManager .mx_Dialog { +.mx_TermsDialog_forIntegrationManager .mx_Dialog { width: 60%; height: 70%; box-sizing: border-box; diff --git a/res/css/views/dialogs/_UserSettingsDialog.scss b/res/css/views/dialogs/_UserSettingsDialog.scss index 2a046ff501..4d831d7858 100644 --- a/res/css/views/dialogs/_UserSettingsDialog.scss +++ b/res/css/views/dialogs/_UserSettingsDialog.scss @@ -45,6 +45,10 @@ limitations under the License. mask-image: url('$(res)/img/feather-customised/flag.svg'); } +.mx_UserSettingsDialog_mjolnirIcon::before { + mask-image: url('$(res)/img/feather-customised/face.svg'); +} + .mx_UserSettingsDialog_flairIcon::before { mask-image: url('$(res)/img/feather-customised/flair.svg'); } diff --git a/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss b/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss index 7ba5f01a76..b9babd05f5 100644 --- a/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss +++ b/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss @@ -32,7 +32,7 @@ limitations under the License. .mx_CreateKeyBackupDialog_passPhraseContainer { display: flex; - align-items: start; + align-items: flex-start; } .mx_CreateKeyBackupDialog_passPhraseHelp { @@ -85,3 +85,9 @@ limitations under the License. flex: 1; white-space: nowrap; } + +.mx_CreateKeyBackupDialog { + details .mx_AccessibleButton { + margin: 1em 0; // emulate paragraph spacing because we can't put this button in a paragraph due to HTML rules + } +} diff --git a/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss b/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss index 415a2021cc..9cba8e0da9 100644 --- a/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss +++ b/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss @@ -1,5 +1,6 @@ /* Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,6 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_RestoreKeyBackupDialog_keyStatus { + height: 30px; +} + .mx_RestoreKeyBackupDialog_primaryContainer { /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ padding: 20px; diff --git a/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss new file mode 100644 index 0000000000..db11e91bdb --- /dev/null +++ b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss @@ -0,0 +1,34 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_AccessSecretStorageDialog_keyStatus { + height: 30px; +} + +.mx_AccessSecretStorageDialog_primaryContainer { + /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ + padding: 20px; +} + +.mx_AccessSecretStorageDialog_passPhraseInput, +.mx_AccessSecretStorageDialog_recoveryKeyInput { + width: 300px; + border: 1px solid $accent-color; + border-radius: 5px; + padding: 10px; +} + diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss new file mode 100644 index 0000000000..a9ebd54b31 --- /dev/null +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -0,0 +1,116 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CreateSecretStorageDialog { + // Why you ask? Because CompleteSecurityBody is 600px so this is the width + // we end up when in there, but when in our own dialog we set our own width + // so need to fix it to something sensible as otherwise we'd end up either + // really wide or really narrow depending on the phase. I bet you wish you + // never asked. + width: 560px; + + .mx_SettingsFlag { + display: flex; + } + + .mx_SettingsFlag_label { + flex: 1 1 0; + min-width: 0; + font-weight: 600; + } + + .mx_ToggleSwitch { + flex: 0 0 auto; + margin-left: 30px; + } + + details .mx_AccessibleButton { + margin: 1em 0; // emulate paragraph spacing because we can't put this button in a paragraph due to HTML rules + } +} + +.mx_CreateSecretStorageDialog .mx_Dialog_title { + /* TODO: Consider setting this for all dialog titles. */ + margin-bottom: 1em; +} + +.mx_CreateSecretStorageDialog_primaryContainer { + /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ + padding-top: 20px; +} + +.mx_CreateSecretStorageDialog_primaryContainer::after { + content: ""; + clear: both; + display: block; +} + +.mx_CreateSecretStorageDialog_passPhraseContainer { + display: flex; + align-items: flex-start; +} + +.mx_Field.mx_CreateSecretStorageDialog_passPhraseField { + margin-top: 0px; +} + +.mx_CreateSecretStorageDialog_passPhraseHelp { + flex: 1; + height: 64px; + margin-left: 20px; + font-size: 80%; +} + +.mx_CreateSecretStorageDialog_passPhraseHelp progress { + width: 100%; +} + +.mx_CreateSecretStorageDialog_passPhraseMatch { + width: 200px; + margin-left: 20px; +} + +.mx_CreateSecretStorageDialog_recoveryKeyHeader { + margin-bottom: 1em; +} + +.mx_CreateSecretStorageDialog_recoveryKeyContainer { + display: flex; +} + +.mx_CreateSecretStorageDialog_recoveryKey { + width: 262px; + padding: 20px; + color: $info-plinth-fg-color; + background-color: $info-plinth-bg-color; + margin-right: 12px; +} + +.mx_CreateSecretStorageDialog_recoveryKeyButtons { + flex: 1; + display: flex; + align-items: center; +} + +.mx_CreateSecretStorageDialog_recoveryKeyButtons .mx_AccessibleButton { + margin-right: 10px; +} + +.mx_CreateSecretStorageDialog_recoveryKeyButtons button { + flex: 1; + white-space: nowrap; +} diff --git a/res/css/views/elements/_Field.scss b/res/css/views/elements/_Field.scss index da896f947d..b260d4b097 100644 --- a/res/css/views/elements/_Field.scss +++ b/res/css/views/elements/_Field.scss @@ -49,6 +49,7 @@ limitations under the License. color: $primary-fg-color; background-color: $primary-bg-color; flex: 1; + min-width: 0; } .mx_Field select { @@ -148,9 +149,6 @@ limitations under the License. color: $greyed-fg-color; } -/* Ordering this block by specificity would require breaking it up into several - chunks, which seems like it would be more confusing to read. */ -/* stylelint-disable no-descending-specificity */ .mx_Field_valid { &.mx_Field, &.mx_Field:focus-within { @@ -174,7 +172,6 @@ limitations under the License. color: $input-invalid-border-color; } } -/* stylelint-enable no-descending-specificity */ .mx_Field_tooltip { margin-top: -12px; diff --git a/res/css/views/elements/_FormButton.scss b/res/css/views/elements/_FormButton.scss new file mode 100644 index 0000000000..1483fe2091 --- /dev/null +++ b/res/css/views/elements/_FormButton.scss @@ -0,0 +1,36 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_FormButton { + line-height: 16px; + padding: 5px 15px; + font-size: 12px; + height: min-content; + + &:not(:last-child) { + margin-right: 8px; + } + + &.mx_AccessibleButton_kind_primary { + color: $accent-color; + background-color: $accent-bg-color; + } + + &.mx_AccessibleButton_kind_danger { + color: $notice-primary-color; + background-color: $notice-primary-bg-color; + } +} diff --git a/res/css/views/elements/_IconButton.scss b/res/css/views/elements/_IconButton.scss new file mode 100644 index 0000000000..d8ebbeb65e --- /dev/null +++ b/res/css/views/elements/_IconButton.scss @@ -0,0 +1,55 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_IconButton { + width: 32px; + height: 32px; + border-radius: 100%; + background-color: $accent-bg-color; + // don't shrink or grow if in a flex container + flex: 0 0 auto; + + &.mx_AccessibleButton_disabled { + background-color: none; + + &::before { + background-color: lightgrey; + } + } + + &:hover { + opacity: 90%; + } + + &::before { + content: ""; + display: block; + width: 100%; + height: 100%; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 55%; + background-color: $accent-color; + } + + &.mx_IconButton_icon_check::before { + mask-image: url('$(res)/img/feather-customised/check.svg'); + } + + &.mx_IconButton_icon_edit::before { + mask-image: url('$(res)/img/feather-customised/edit.svg'); + } +} diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss index 73f0be291f..5066ee10f3 100644 --- a/res/css/views/elements/_RichText.scss +++ b/res/css/views/elements/_RichText.scss @@ -13,6 +13,11 @@ padding-left: 5px; } +a.mx_Pill { + word-break: break-all; + display: inline; +} + /* More specific to override `.markdown-body a` text-decoration */ .mx_EventTile_content .markdown-body a.mx_Pill { text-decoration: none; diff --git a/res/css/views/emojipicker/_EmojiPicker.scss b/res/css/views/emojipicker/_EmojiPicker.scss new file mode 100644 index 0000000000..5d9b3f2687 --- /dev/null +++ b/res/css/views/emojipicker/_EmojiPicker.scss @@ -0,0 +1,229 @@ +/* +Copyright 2019 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_EmojiPicker { + width: 340px; + height: 450px; + + border-radius: 4px; + + display: flex; + flex-direction: column; +} + +.mx_EmojiPicker_body { + flex: 1; + overflow-y: scroll; + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; +} + +.mx_EmojiPicker_header { + padding: 4px 8px 0; + border-bottom: 1px solid $message-action-bar-border-color; +} + +.mx_EmojiPicker_anchor { + border: none; + padding: 8px 8px 6px; + border-bottom: 2px solid transparent; + background-color: transparent; + border-radius: 4px 4px 0 0; + + width: 36px; + height: 38px; + + &:not(:disabled) { + cursor: pointer; + } + + &:not(:disabled):hover { + background-color: $focus-bg-color; + border-bottom: 2px solid $button-bg-color; + } +} + +.mx_EmojiPicker_anchor::before { + background-color: $primary-fg-color; + content: ''; + display: inline-block; + mask-size: 100%; + mask-repeat: no-repeat; + width: 100%; + height: 100%; +} + +.mx_EmojiPicker_anchor:disabled::before { + background-color: $focus-bg-color; +} + +.mx_EmojiPicker_anchor_activity::before { mask-image: url('$(res)/img/emojipicker/activity.svg'); } +.mx_EmojiPicker_anchor_custom::before { mask-image: url('$(res)/img/emojipicker/custom.svg'); } +.mx_EmojiPicker_anchor_flags::before { mask-image: url('$(res)/img/emojipicker/flags.svg'); } +.mx_EmojiPicker_anchor_foods::before { mask-image: url('$(res)/img/emojipicker/foods.svg'); } +.mx_EmojiPicker_anchor_nature::before { mask-image: url('$(res)/img/emojipicker/nature.svg'); } +.mx_EmojiPicker_anchor_objects::before { mask-image: url('$(res)/img/emojipicker/objects.svg'); } +.mx_EmojiPicker_anchor_people::before { mask-image: url('$(res)/img/emojipicker/people.svg'); } +.mx_EmojiPicker_anchor_places::before { mask-image: url('$(res)/img/emojipicker/places.svg'); } +.mx_EmojiPicker_anchor_recent::before { mask-image: url('$(res)/img/emojipicker/recent.svg'); } +.mx_EmojiPicker_anchor_symbols::before { mask-image: url('$(res)/img/emojipicker/symbols.svg'); } + +.mx_EmojiPicker_anchor_visible { + border-bottom: 2px solid $button-bg-color; +} + +.mx_EmojiPicker_search { + margin: 8px; + border-radius: 4px; + border: 1px solid $input-border-color; + background-color: $primary-bg-color; + display: flex; + + input { + flex: 1; + border: none; + padding: 8px 12px; + border-radius: 4px 0; + } + + button { + border: none; + background-color: inherit; + margin: 0; + padding: 8px; + align-self: center; + width: 32px; + height: 32px; + } +} + +.mx_EmojiPicker_search_clear { + cursor: pointer; +} + +.mx_EmojiPicker_search_icon { + width: 16px; + margin: 8px; +} + +.mx_EmojiPicker_search_icon:not(.mx_EmojiPicker_search_clear) { + pointer-events: none; +} + +.mx_EmojiPicker_search_icon::after { + mask: url('$(res)/img/emojipicker/search.svg') no-repeat; + mask-size: 100%; + background-color: $primary-fg-color; + content: ''; + display: inline-block; + width: 100%; + height: 100%; +} + +.mx_EmojiPicker_search_clear::after { + mask-image: url('$(res)/img/emojipicker/delete.svg'); +} + +.mx_EmojiPicker_category { + padding: 0 12px; + display: flex; + flex-direction: column; + align-items: center; +} + +.mx_EmojiPicker_category_label { + width: 304px; +} + +.mx_EmojiPicker_list { + width: 304px; + padding: 0; + margin: 0; +} + +.mx_EmojiPicker_item_wrapper { + display: inline-block; + list-style: none; + width: 38px; + cursor: pointer; +} + +.mx_EmojiPicker_item { + display: inline-block; + font-size: 20px; + padding: 5px; + width: 100%; + height: 100%; + box-sizing: border-box; + text-align: center; + border-radius: 4px; + + &:hover { + background-color: $focus-bg-color; + } +} + +.mx_EmojiPicker_item_selected { + color: rgba(0, 0, 0, .5); + border: 1px solid $input-valid-border-color; + padding: 4px; +} + +.mx_EmojiPicker_category_label, .mx_EmojiPicker_preview_name { + font-size: 16px; + font-weight: 600; + margin: 0; +} + +.mx_EmojiPicker_footer { + border-top: 1px solid $message-action-bar-border-color; + height: 72px; + + display: flex; + align-items: center; +} + +.mx_EmojiPicker_preview_emoji { + font-size: 32px; + padding: 8px 16px; +} + +.mx_EmojiPicker_preview_text { + display: flex; + flex-direction: column; +} + +.mx_EmojiPicker_name { + text-transform: capitalize; +} + +.mx_EmojiPicker_shortcode { + color: $light-fg-color; + font-size: 14px; + + &::before, &::after { + content: ":"; + } +} + +.mx_EmojiPicker_quick { + flex-direction: column; + justify-content: space-around; +} + +.mx_EmojiPicker_quick_header .mx_EmojiPicker_name { + margin-right: 4px; +} diff --git a/res/css/views/dialogs/_RestoreKeyBackupDialog.scss b/res/css/views/messages/_MjolnirBody.scss similarity index 85% rename from res/css/views/dialogs/_RestoreKeyBackupDialog.scss rename to res/css/views/messages/_MjolnirBody.scss index 69e00c416a..2760adfd7e 100644 --- a/res/css/views/dialogs/_RestoreKeyBackupDialog.scss +++ b/res/css/views/messages/_MjolnirBody.scss @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,6 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RestoreKeyBackupDialog_keyStatus { - height: 30px; +.mx_MjolnirBody { + opacity: 0.4; } diff --git a/res/css/views/messages/_common_CryptoEvent.scss b/res/css/views/messages/_common_CryptoEvent.scss new file mode 100644 index 0000000000..98e1e97e39 --- /dev/null +++ b/res/css/views/messages/_common_CryptoEvent.scss @@ -0,0 +1,79 @@ +/* +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_cryptoEvent { + + display: grid; + grid-template-columns: 24px minmax(0, 1fr) min-content; + + &.mx_cryptoEvent_icon::after { + grid-column: 1; + grid-row: 1 / 3; + width: 16px; + height: 16px; + content: ""; + background-image: url("$(res)/img/e2e/normal.svg"); + background-repeat: no-repeat; + background-size: 100%; + margin-top: 4px; + } + + &.mx_cryptoEvent_icon_verified::after { + background-image: url("$(res)/img/e2e/verified.svg"); + } + + &.mx_cryptoEvent_icon_warning::after { + background-image: url("$(res)/img/e2e/warning.svg"); + } + + .mx_cryptoEvent_title, .mx_cryptoEvent_subtitle, .mx_cryptoEvent_state { + overflow-wrap: break-word; + } + + .mx_cryptoEvent_title { + font-weight: 600; + font-size: 15px; + grid-column: 2; + grid-row: 1; + } + + .mx_cryptoEvent_subtitle { + grid-column: 2; + grid-row: 2; + } + + .mx_cryptoEvent_state, .mx_cryptoEvent_subtitle { + font-size: 12px; + } + + .mx_cryptoEvent_state, .mx_cryptoEvent_buttons { + grid-column: 3; + grid-row: 1 / 3; + } + + .mx_cryptoEvent_buttons { + align-items: center; + display: flex; + } + + .mx_cryptoEvent_state { + width: 130px; + padding: 10px 20px; + margin: auto 0; + text-align: center; + color: $notice-secondary-color; + } +} diff --git a/res/css/views/right_panel/_EncryptionInfo.scss b/res/css/views/right_panel/_EncryptionInfo.scss new file mode 100644 index 0000000000..e13b1b6802 --- /dev/null +++ b/res/css/views/right_panel/_EncryptionInfo.scss @@ -0,0 +1,26 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_UserInfo { + .mx_EncryptionInfo_spinner { + .mx_Spinner { + margin-top: 25px; + margin-bottom: 15px; + } + + text-align: center; + } +} diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss new file mode 100644 index 0000000000..0e4b1bda9e --- /dev/null +++ b/res/css/views/right_panel/_UserInfo.scss @@ -0,0 +1,287 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_UserInfo { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: auto; + font-size: 12px; + + .mx_UserInfo_cancel { + cursor: pointer; + position: absolute; + top: 0; + border-radius: 4px; + background-color: $dark-panel-bg-color; + margin: 9px; + z-index: 1; // render on top of the right panel + + div { + height: 16px; + width: 16px; + padding: 4px; + mask-image: url('$(res)/img/minimise.svg'); + mask-repeat: no-repeat; + mask-position: 7px center; + background-color: $rightpanel-button-color; + } + } + + h2 { + font-size: 18px; + font-weight: 600; + margin: 18px 0 0 0; + } + + .mx_UserInfo_container { + padding: 8px 16px; + } + + .mx_UserInfo_separator { + border-bottom: 1px solid lightgray; + } + + .mx_UserInfo_memberDetailsContainer { + padding-top: 0; + padding-bottom: 0; + margin-bottom: 8px; + } + + .mx_RoomTile_nameContainer { + width: 154px; + } + + .mx_RoomTile_badge { + display: none; + } + + .mx_RoomTile_name { + width: 160px; + } + + .mx_UserInfo_avatar { + margin: 24px 32px 0 32px; + } + + .mx_UserInfo_avatar > div { + max-width: 30vh; + margin: 0 auto; + transition: 0.5s; + } + + .mx_UserInfo_avatar > div > div { + /* use padding-top instead of height to make this element square, + as the % in padding is a % of the width (including margin, + that's why we had to put the margin to center on a parent div), + and not a % of the parent height. */ + padding-top: 100%; + position: relative; + } + + .mx_UserInfo_avatar > div > div * { + border-radius: 100%; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .mx_UserInfo_avatar .mx_BaseAvatar_initial { + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + + // override the calculated sizes so that the letter isn't HUGE + font-size: 56px !important; + width: 100% !important; + transition: font-size 0.5s; + } + + .mx_UserInfo_avatar .mx_BaseAvatar.mx_BaseAvatar_image { + cursor: zoom-in; + } + + h3 { + text-transform: uppercase; + color: $notice-secondary-color; + font-weight: bold; + font-size: 12px; + margin: 4px 0; + } + + p { + margin: 5px 0; + } + + .mx_UserInfo_profile { + text-align: center; + + h2 { + font-size: 18px; + line-height: 25px; + flex: 1; + justify-content: center; + align-items: center; + + // limit to 2 lines, show an ellipsis if it overflows + // this looks webkit specific but is supported by Firefox 68+ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + + overflow: hidden; + word-break: break-all; + text-overflow: ellipsis; + + .mx_E2EIcon { + margin: 5px; + } + } + + .mx_UserInfo_profileStatus { + margin-top: 12px; + } + } + + .mx_UserInfo_memberDetails .mx_UserInfo_profileField { + display: flex; + justify-content: center; + align-items: center; + + margin: 6px 0; + + .mx_IconButton, .mx_Spinner { + margin-left: 20px; + width: 16px; + height: 16px; + + &::before { + mask-size: 80%; + } + } + + .mx_UserInfo_roleDescription { + display: flex; + justify-content: center; + align-items: center; + // try to make it the same height as the dropdown + margin: 11px 0 12px 0; + + .mx_IconButton { + margin-left: 6px; + } + } + + .mx_Field { + margin: 0; + } + } + + .mx_UserInfo_field { + cursor: pointer; + color: $accent-color; + line-height: 16px; + margin: 8px 0; + + &.mx_UserInfo_destructive { + color: $warning-color; + } + } + + .mx_UserInfo_statusMessage { + font-size: 11px; + opacity: 0.5; + overflow: hidden; + white-space: nowrap; + text-overflow: clip; + } + + .mx_UserInfo_scrollContainer { + flex: 1 1 0; + padding-bottom: 16px; + } + + .mx_UserInfo_container:not(.mx_UserInfo_separator) { + padding-top: 16px; + padding-bottom: 0; + + > :not(h3) { + margin-left: 8px; + } + } + + .mx_UserInfo_devices { + .mx_UserInfo_device { + display: flex; + margin: 8px 0; + + + &.mx_UserInfo_device_verified { + .mx_UserInfo_device_trusted { + color: $accent-color; + } + } + &.mx_UserInfo_device_unverified { + .mx_UserInfo_device_trusted { + color: $warning-color; + } + } + + .mx_UserInfo_device_name { + flex: 1; + margin-right: 5px; + word-break: break-word; + } + } + + // both for icon in expand button and device item + .mx_E2EIcon { + // don't squeeze + flex: 0 0 auto; + margin: 2px 5px 0 0; + width: 12px; + height: 12px; + } + + .mx_UserInfo_expand { + display: flex; + margin-top: 11px; + } + } + + .mx_UserInfo_wideButton { + display: block; + margin: 16px 0; + } + button.mx_UserInfo_wideButton { + width: 100%; // FIXME get rid of this once we get rid of DialogButtons here + } +} + +.mx_UserInfo.mx_UserInfo_smallAvatar { + .mx_UserInfo_avatar > div { + max-width: 72px; + margin: 0 auto; + } + + .mx_UserInfo_avatar .mx_BaseAvatar_initial { + font-size: 40px !important; // override the other override because here the avatar is smaller + } +} diff --git a/res/css/views/right_panel/_VerificationPanel.scss b/res/css/views/right_panel/_VerificationPanel.scss new file mode 100644 index 0000000000..2a733d11a7 --- /dev/null +++ b/res/css/views/right_panel/_VerificationPanel.scss @@ -0,0 +1,106 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_UserInfo { + .mx_VerificationPanel_verified_section .mx_E2EIcon { + // Override general user info margin + margin: 0 auto !important; + } + + .mx_VerificationPanel_qrCode { + padding: 4px 4px 0 4px; + background: white; + border-radius: 4px; + width: max-content; + max-width: 100%; + // Override general user info margin + margin: 0 auto !important; + + canvas { + // override height and width which are set on the element directly + height: auto !important; + width: 100% !important; + max-width: 240px; + } + } +} + +// Special case styling for EncryptionPanel in a Modal dialog +.mx_Dialog, .mx_CompleteSecurity_body { + .mx_VerificationPanel_QRPhase_startOptions { + display: flex; + margin-top: 10px; + margin-bottom: 10px; + align-items: stretch; + + > .mx_VerificationPanel_QRPhase_betweenText { + width: 50px; + vertical-align: middle; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + } + + .mx_VerificationPanel_QRPhase_startOption { + background-color: $user-tile-hover-bg-color; + border-radius: 10px; + flex: 1; + display: flex; + padding: 10px; + align-items: center; + flex-direction: column; + position: relative; + + canvas, .mx_VerificationPanel_QRPhase_noQR { + width: 220px !important; + height: 220px !important; + background-color: #fff; + border-radius: 4px; + vertical-align: middle; + text-align: center; + padding: 10px; + } + + > p { + font-weight: 700; + } + + .mx_VerificationPanel_QRPhase_helpText { + font-size: 14px; + margin-top: 71px; + text-align: center; + } + + .mx_AccessibleButton { + position: absolute; + bottom: 30px; + } + } + } + + // EncryptionPanel when verification is done + .mx_VerificationPanel_verified_section { + // center the big shield icon + .mx_E2EIcon { + margin: 0 auto; + } + // right align the "Got it" button + .mx_AccessibleButton { + float: right; + } + } +} diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 9ca6954af7..a3fe573ad0 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -153,40 +153,12 @@ $AppsDrawerBodyHeight: 273px; background-color: $accent-color; } -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_reload { - mask-image: url('$(res)/img/feather-customised/widget/refresh.svg'); - mask-size: 100%; -} - .mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_popout { mask-image: url('$(res)/img/feather-customised/widget/external-link.svg'); } -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_snapshot { - mask-image: url('$(res)/img/feather-customised/widget/camera.svg'); -} - -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_edit { - mask-image: url('$(res)/img/feather-customised/widget/edit.svg'); -} - -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_delete { - mask-image: url('$(res)/img/feather-customised/widget/bin.svg'); - background-color: $warning-color; -} - -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_cancel { - mask-image: url('$(res)/img/feather-customised/widget/x-circle.svg'); -} - -/* delete ? */ -.mx_AppTileMenuBarWidget { - cursor: pointer; - width: 10px; - height: 10px; - padding: 1px; - transition-duration: 500ms; - border: 1px solid transparent; +.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_menu { + mask-image: url('$(res)/img/icon_context.svg'); } .mx_AppTileMenuBarWidgetDelete { @@ -294,49 +266,61 @@ form.mx_Custom_Widget_Form div { .mx_AppPermissionWarning { text-align: center; - background-color: $primary-bg-color; + background-color: $widget-menu-bar-bg-color; display: flex; height: 100%; flex-direction: column; justify-content: center; align-items: center; + font-size: 16px; } -.mx_AppPermissionWarningImage { - margin: 10px 0; +.mx_AppPermissionWarning_row { + margin-bottom: 12px; } -.mx_AppPermissionWarningImage img { - width: 100px; +.mx_AppPermissionWarning_smallText { + font-size: 12px; } -.mx_AppPermissionWarningText { - max-width: 90%; - margin: 10px auto 10px auto; - color: $primary-fg-color; +.mx_AppPermissionWarning_bolder { + font-weight: 600; } -.mx_AppPermissionWarningTextLabel { - font-weight: bold; - display: block; +.mx_AppPermissionWarning h4 { + margin: 0; + padding: 0; } -.mx_AppPermissionWarningTextURL { +.mx_AppPermissionWarning_helpIcon { + margin-top: 1px; + margin-right: 2px; + width: 10px; + height: 10px; display: inline-block; - max-width: 100%; - color: $accent-color; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; } -.mx_AppPermissionButton { - border: none; - padding: 5px 20px; - border-radius: 5px; - background-color: $button-bg-color; - color: $button-fg-color; - cursor: pointer; +.mx_AppPermissionWarning_helpIcon::before { + display: inline-block; + background-color: $accent-color; + mask-repeat: no-repeat; + mask-size: 12px; + width: 12px; + height: 12px; + mask-position: center; + content: ''; + vertical-align: middle; + mask-image: url('$(res)/img/feather-customised/help-circle.svg'); +} + +.mx_AppPermissionWarning_tooltip { + @mixin mx_Tooltip_dark; + + ul { + list-style-position: inside; + padding-left: 2px; + margin-left: 0; + } } .mx_AppLoading { diff --git a/res/css/views/rooms/_E2EIcon.scss b/res/css/views/rooms/_E2EIcon.scss index 84a16611de..584ea17433 100644 --- a/res/css/views/rooms/_E2EIcon.scss +++ b/res/css/views/rooms/_E2EIcon.scss @@ -15,19 +15,35 @@ limitations under the License. */ .mx_E2EIcon { - width: 25px; - height: 25px; - mask-repeat: no-repeat; - mask-position: center 0; + width: 16px; + height: 16px; margin: 0 9px; + position: relative; + display: block; } -.mx_E2EIcon_verified { - mask-image: url('$(res)/img/e2e/lock-verified.svg'); - background-color: $accent-color; +.mx_E2EIcon_warning::after, +.mx_E2EIcon_normal::after, +.mx_E2EIcon_verified::after { + content: ""; + display: block; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-repeat: no-repeat; + background-size: contain; } -.mx_E2EIcon_warning { - mask-image: url('$(res)/img/e2e/lock-warning.svg'); - background-color: $warning-color; +.mx_E2EIcon_warning::after { + background-image: url('$(res)/img/e2e/warning.svg'); +} + +.mx_E2EIcon_normal::after { + background-image: url('$(res)/img/e2e/normal.svg'); +} + +.mx_E2EIcon_verified::after { + background-image: url('$(res)/img/e2e/verified.svg'); } diff --git a/res/css/views/rooms/_EntityTile.scss b/res/css/views/rooms/_EntityTile.scss index 2b6b31acb4..a2867de3a7 100644 --- a/res/css/views/rooms/_EntityTile.scss +++ b/res/css/views/rooms/_EntityTile.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,6 +20,15 @@ limitations under the License. align-items: center; color: $primary-fg-color; cursor: pointer; + + .mx_E2EIcon { + margin: 0; + position: absolute; + bottom: 2px; + right: 7px; + height: 15px; + width: 15px; + } } .mx_EntityTile:hover { @@ -30,7 +40,7 @@ limitations under the License. content: ""; position: absolute; top: calc(50% - 8px); // center - right: 10px; + right: -8px; mask: url('$(res)/img/member_chevron.png'); mask-repeat: no-repeat; width: 16px; @@ -64,14 +74,6 @@ limitations under the License. position: relative; } -.mx_EntityTile_power { - position: absolute; - width: 16px; - height: 17px; - top: 0px; - right: 6px; -} - .mx_EntityTile_name, .mx_GroupRoomTile_name { flex: 1 1 0; @@ -83,6 +85,7 @@ limitations under the License. .mx_EntityTile_details { overflow: hidden; + flex: 1; } .mx_EntityTile_ellipsis .mx_EntityTile_name { @@ -112,10 +115,6 @@ limitations under the License. opacity: 0.25; } -.mx_EntityTile:not(.mx_EntityTile_noHover):hover .mx_EntityTile_name { - font-size: 13px; -} - .mx_EntityTile_subtext { font-size: 11px; opacity: 0.5; @@ -123,3 +122,17 @@ limitations under the License. white-space: nowrap; text-overflow: clip; } + +.mx_EntityTile_power { + padding-inline-start: 6px; + font-size: 10px; + color: $notice-secondary-color; + max-width: 6em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mx_EntityTile:hover .mx_EntityTile_power { + display: none; +} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index fafd34f8ca..d292c729dd 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,6 +23,15 @@ limitations under the License. position: relative; } +.mx_EventTile_bubble { + background-color: $dark-panel-bg-color; + padding: 10px; + border-radius: 5px; + margin: 10px auto; + max-width: 75%; + box-sizing: border-box; +} + .mx_EventTile.mx_EventTile_info { padding-top: 0px; } @@ -112,6 +122,21 @@ limitations under the License. line-height: 22px; } +.mx_EventTile_bubbleContainer { + display: grid; + grid-template-columns: 1fr 100px; + + .mx_EventTile_line { + margin-right: 0px; + grid-column: 1 / 3; + padding: 0; + } + + .mx_EventTile_msgOption { + grid-column: 2; + } +} + .mx_EventTile_reply { margin-right: 10px; } @@ -138,12 +163,15 @@ limitations under the License. // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) .mx_EventTile_last > div > a > .mx_MessageTimestamp, .mx_EventTile:hover > div > a > .mx_MessageTimestamp, -.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp { +.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp, +.mx_EventTile.focus-visible:focus-within > div > a > .mx_MessageTimestamp { visibility: visible; } .mx_EventTile:hover .mx_MessageActionBar, -.mx_EventTile.mx_EventTile_actionBarFocused .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; } @@ -165,8 +193,13 @@ limitations under the License. } } +.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { + padding-left: 78px; +} + .mx_EventTile:hover .mx_EventTile_line, -.mx_EventTile.mx_EventTile_actionBarFocused .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; } @@ -316,27 +349,32 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { } .mx_EventTile_e2eIcon { - display: block; position: absolute; - top: 8px; + top: 6px; left: 46px; width: 15px; height: 15px; - cursor: pointer; - mask-size: 14px; - mask-repeat: no-repeat; - mask-position: 0; + display: block; + bottom: 0; + right: 0; opacity: 0.2; + background-repeat: no-repeat; + background-size: contain; } .mx_EventTile_e2eIcon_undecryptable, .mx_EventTile_e2eIcon_unverified { - mask-image: url('$(res)/img/e2e/warning.svg'); - background-color: $warning-color; + background-image: url('$(res)/img/e2e/warning.svg'); + opacity: 1; +} + +.mx_EventTile_e2eIcon_unknown { + background-image: url('$(res)/img/e2e/warning.svg'); + opacity: 1; } .mx_EventTile_e2eIcon_unencrypted { - mask-image: url('$(res)/img/e2e/warning.svg'); - background-color: $composer-e2e-icon-color; + background-image: url('$(res)/img/e2e/warning.svg'); + opacity: 1; } .mx_EventTile_e2eIcon_hidden { @@ -381,12 +419,9 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { padding-left: 5px; } -.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { - padding-left: 78px; -} - .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line, -.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line { +.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line, +.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line { padding-left: 60px; } @@ -398,8 +433,13 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { border-left: $e2e-unverified-color 5px solid; } +.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line { + border-left: $e2e-unknown-color 5px 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_unverified.mx_EventTile_info .mx_EventTile_line, +.mx_EventTile:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line { padding-left: 78px; } @@ -410,14 +450,16 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { // 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_unverified .mx_EventTile_line > a > .mx_MessageTimestamp, +.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp { left: 3px; width: auto; } // 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_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon, +.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > .mx_EventTile_e2eIcon { display: block; left: 41px; } @@ -465,7 +507,8 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { } } -.mx_EventTile:hover .mx_EventTile_body pre { +.mx_EventTile:hover .mx_EventTile_body pre, +.mx_EventTile.focus-visible:focus-within .mx_EventTile_body pre { border: 1px solid #e5e5e5; // deliberate constant as we're behind an invert filter } @@ -487,6 +530,7 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { background-image: url($copy-button-url); } +.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_copyButton, .mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton { visibility: visible; } @@ -532,9 +576,6 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { /* end of overrides */ -/* Ordering this block by specificity would require breaking it up into several - chunks, which seems like it would be more confusing to read. */ -/* stylelint-disable no-descending-specificity */ .mx_MatrixChat_useCompactLayout { .mx_EventTile { padding-top: 4px; @@ -612,4 +653,3 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { } } } -/* stylelint-enable no-descending-specificity */ diff --git a/res/css/views/rooms/_InviteOnlyIcon.scss b/res/css/views/rooms/_InviteOnlyIcon.scss new file mode 100644 index 0000000000..b71fd6348d --- /dev/null +++ b/res/css/views/rooms/_InviteOnlyIcon.scss @@ -0,0 +1,58 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +@define-mixin mx_InviteOnlyIcon { + width: 12px; + height: 12px; + position: relative; + display: block !important; +} + +@define-mixin mx_InviteOnlyIcon_padlock { + background-color: $roomtile-name-color; + mask-image: url("$(res)/img/feather-customised/lock-solid.svg"); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; +} + +.mx_InviteOnlyIcon_large { + @mixin mx_InviteOnlyIcon; + margin: 0 4px; + + &::before { + @mixin mx_InviteOnlyIcon_padlock; + width: 12px; + height: 12px; + } +} + +.mx_InviteOnlyIcon_small { + @mixin mx_InviteOnlyIcon; + left: -2px; + + &::before { + @mixin mx_InviteOnlyIcon_padlock; + width: 10px; + height: 10px; + } +} diff --git a/res/css/views/rooms/_LinkPreviewWidget.scss b/res/css/views/rooms/_LinkPreviewWidget.scss index 4495b142e6..022cf3ed28 100644 --- a/res/css/views/rooms/_LinkPreviewWidget.scss +++ b/res/css/views/rooms/_LinkPreviewWidget.scss @@ -52,12 +52,18 @@ limitations under the License. } .mx_LinkPreviewWidget_cancel { - visibility: hidden; cursor: pointer; - flex: 0 0 40px; + width: 18px; + height: 18px; + + img { + flex: 0 0 40px; + visibility: hidden; + } } -.mx_LinkPreviewWidget:hover .mx_LinkPreviewWidget_cancel { +.mx_LinkPreviewWidget:hover .mx_LinkPreviewWidget_cancel img, +.mx_LinkPreviewWidget_cancel.focus-visible:focus img { visibility: visible; } diff --git a/res/css/views/rooms/_MemberDeviceInfo.scss b/res/css/views/rooms/_MemberDeviceInfo.scss index 951d1945b1..15b4832dc5 100644 --- a/res/css/views/rooms/_MemberDeviceInfo.scss +++ b/res/css/views/rooms/_MemberDeviceInfo.scss @@ -17,7 +17,7 @@ limitations under the License. .mx_MemberDeviceInfo { display: flex; padding-bottom: 10px; - align-items: start; + align-items: flex-start; } .mx_MemberDeviceInfo_icon { @@ -25,6 +25,7 @@ limitations under the License. width: 12px; height: 12px; mask-repeat: no-repeat; + mask-size: 100%; } .mx_MemberDeviceInfo_icon_blacklisted { mask-image: url('$(res)/img/e2e/blacklisted.svg'); diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 5b4a9b764b..a05b4c0c0e 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -23,10 +23,6 @@ limitations under the License. padding-left: 84px; } -.mx_MessageComposer_wrapper.mx_MessageComposer_hasE2EIcon { - padding-left: 109px; -} - .mx_MessageComposer_replaced_wrapper { margin-left: auto; margin-right: auto; @@ -78,7 +74,10 @@ limitations under the License. .mx_MessageComposer_e2eIcon.mx_E2EIcon { position: absolute; left: 60px; - background-color: $composer-e2e-icon-color; + margin-right: 0; // Counteract the E2EIcon class + margin-left: 3px; // Counteract the E2EIcon class + width: 15px; + height: 15px; } .mx_MessageComposer_noperm_error { @@ -104,7 +103,7 @@ limitations under the License. display: flex; flex-direction: column; min-height: 60px; - justify-content: start; + justify-content: flex-start; align-items: flex-start; font-size: 14px; margin-right: 6px; @@ -180,34 +179,42 @@ limitations under the License. } .mx_MessageComposer_button { + position: relative; margin-right: 12px; cursor: pointer; - padding-top: 4px; height: 20px; width: 20px; - background-color: $composer-button-color; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; + + &::before { + content: ''; + position: absolute; + + height: 20px; + width: 20px; + background-color: $composer-button-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } } -.mx_MessageComposer_upload { +.mx_MessageComposer_upload::before { mask-image: url('$(res)/img/feather-customised/paperclip.svg'); } -.mx_MessageComposer_hangup { +.mx_MessageComposer_hangup::before { mask-image: url('$(res)/img/hangup.svg'); } -.mx_MessageComposer_voicecall { +.mx_MessageComposer_voicecall::before { mask-image: url('$(res)/img/feather-customised/phone.svg'); } -.mx_MessageComposer_videocall { +.mx_MessageComposer_videocall::before { mask-image: url('$(res)/img/feather-customised/video.svg'); } -.mx_MessageComposer_stickers { +.mx_MessageComposer_stickers::before { mask-image: url('$(res)/img/feather-customised/face.svg'); } diff --git a/res/css/views/rooms/_MessageComposerFormatBar.scss b/res/css/views/rooms/_MessageComposerFormatBar.scss index 43a2fb257e..1b5a21bed0 100644 --- a/res/css/views/rooms/_MessageComposerFormatBar.scss +++ b/res/css/views/rooms/_MessageComposerFormatBar.scss @@ -40,6 +40,19 @@ limitations under the License. &:hover { border-color: $message-action-bar-hover-border-color; + z-index: 1; + } + + &:first-child { + border-radius: 3px 0 0 3px; + } + + &:last-child { + border-radius: 0 3px 3px 0; + } + + &:only-child { + border-radius: 3px; } } diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 2ee991cac7..47b8131ef0 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -17,6 +17,15 @@ limitations under the License. .mx_RoomHeader { flex: 0 0 52px; border-bottom: 1px solid $primary-hairline-color; + + .mx_E2EIcon { + margin: 0; + position: absolute; + bottom: -2px; + right: -6px; + height: 15px; + width: 15px; + } } .mx_RoomHeader_wrapper { @@ -167,6 +176,7 @@ limitations under the License. width: 28px; height: 28px; margin: 0 7px; + position: relative; } .mx_RoomHeader_avatar .mx_BaseAvatar_image { @@ -192,33 +202,41 @@ limitations under the License. } .mx_RoomHeader_button { + position: relative; margin-left: 10px; cursor: pointer; height: 20px; width: 20px; - background-color: $roomheader-button-color; - mask-repeat: no-repeat; - mask-size: contain; + + &::before { + content: ''; + position: absolute; + height: 20px; + width: 20px; + background-color: $roomheader-button-color; + mask-repeat: no-repeat; + mask-size: contain; + } } -.mx_RoomHeader_settingsButton { +.mx_RoomHeader_settingsButton::before { mask-image: url('$(res)/img/feather-customised/settings.svg'); } -.mx_RoomHeader_forgetButton { +.mx_RoomHeader_forgetButton::before { mask-image: url('$(res)/img/leave.svg'); width: 26px; } -.mx_RoomHeader_searchButton { +.mx_RoomHeader_searchButton::before { mask-image: url('$(res)/img/feather-customised/search.svg'); } -.mx_RoomHeader_shareButton { +.mx_RoomHeader_shareButton::before { mask-image: url('$(res)/img/feather-customised/share.svg'); } -.mx_RoomHeader_manageIntegsButton { +.mx_RoomHeader_manageIntegsButton::before { mask-image: url('$(res)/img/feather-customised/grid.svg'); } @@ -234,8 +252,7 @@ limitations under the License. margin-top: 18px; } -.mx_RoomHeader_pinnedButton { - position: relative; +.mx_RoomHeader_pinnedButton::before { mask-image: url('$(res)/img/icons-pin.svg'); } diff --git a/res/css/views/rooms/_RoomPreviewBar.scss b/res/css/views/rooms/_RoomPreviewBar.scss index c7d03e3523..85b6916226 100644 --- a/res/css/views/rooms/_RoomPreviewBar.scss +++ b/res/css/views/rooms/_RoomPreviewBar.scss @@ -117,12 +117,17 @@ limitations under the License. .mx_RoomPreviewBar_actions { flex-direction: column-reverse; .mx_AccessibleButton { - padding: 7px 50px;//extra wide + padding: 7px 50px; //extra wide } & > * { margin-top: 12px; } + .mx_AccessibleButton.mx_AccessibleButton_kind_primary { + // to account for the padding of the primary button which causes inconsistent look between + // subsequent secondary (text) buttons + margin-bottom: 7px; + } } } diff --git a/res/css/views/rooms/_RoomRecoveryReminder.scss b/res/css/views/rooms/_RoomRecoveryReminder.scss index 68e2bf861e..85d42ca4b4 100644 --- a/res/css/views/rooms/_RoomRecoveryReminder.scss +++ b/res/css/views/rooms/_RoomRecoveryReminder.scss @@ -40,4 +40,5 @@ limitations under the License. .mx_RoomRecoveryReminder_secondary { font-size: 90%; + margin-top: 1em; } diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index 2acddc233c..31d887cbbb 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -97,9 +98,22 @@ limitations under the License. z-index: 2; } +// Note we match .mx_E2EIcon to make sure this matches more tightly than just +// .mx_E2EIcon on its own +.mx_RoomTile_e2eIcon.mx_E2EIcon { + height: 14px; + width: 14px; + display: block; + position: absolute; + bottom: -2px; + right: -5px; + z-index: 1; + margin: 0; +} + .mx_RoomTile_name { font-size: 14px; - padding: 0 6px; + padding: 0 4px; color: $roomtile-name-color; white-space: nowrap; overflow-x: hidden; @@ -141,8 +155,11 @@ limitations under the License. } } -// toggle menuButton and badge on hover/menu displayed +// toggle menuButton and badge on menu displayed .mx_RoomTile_menuDisplayed, +// or on keyboard focus of room tile +.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:focus-within, +// or on pointer hover .mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover { .mx_RoomTile_menuButton { display: block; diff --git a/res/css/views/rooms/_SearchBar.scss b/res/css/views/rooms/_SearchBar.scss index 894473a5fe..b6748e5ad2 100644 --- a/res/css/views/rooms/_SearchBar.scss +++ b/res/css/views/rooms/_SearchBar.scss @@ -37,6 +37,10 @@ limitations under the License. mask-position: center; } + .mx_SearchBar_buttons { + display: inherit; + } + .mx_SearchBar_button { border: 0; margin: 0 0 0 22px; diff --git a/res/css/views/rooms/_TopUnreadMessagesBar.scss b/res/css/views/rooms/_TopUnreadMessagesBar.scss index 77f19dac1c..a3916f321a 100644 --- a/res/css/views/rooms/_TopUnreadMessagesBar.scss +++ b/res/css/views/rooms/_TopUnreadMessagesBar.scss @@ -25,19 +25,16 @@ limitations under the License. } .mx_TopUnreadMessagesBar::after { - content: "·"; + content: ""; position: absolute; top: -8px; left: 11px; - width: 16px; - height: 16px; + width: 4px; + height: 4px; border-radius: 16px; - font-weight: 600; - font-size: 30px; - line-height: 14px; - text-align: center; - color: $secondary-accent-color; - background-color: $accent-color; + background-color: $secondary-accent-color; + border: 6px solid $accent-color; + pointer-events: none; } .mx_TopUnreadMessagesBar_scrollUp { diff --git a/res/css/views/rooms/_UserOnlineDot.scss b/res/css/views/rooms/_UserOnlineDot.scss new file mode 100644 index 0000000000..339e5cc48a --- /dev/null +++ b/res/css/views/rooms/_UserOnlineDot.scss @@ -0,0 +1,23 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_UserOnlineDot { + border-radius: 50%; + background-color: $accent-color; + height: 5px; + width: 5px; + display: inline-block; +} diff --git a/res/css/views/rooms/_WhoIsTypingTile.scss b/res/css/views/rooms/_WhoIsTypingTile.scss index ef20c24c84..579ea7e73e 100644 --- a/res/css/views/rooms/_WhoIsTypingTile.scss +++ b/res/css/views/rooms/_WhoIsTypingTile.scss @@ -31,14 +31,15 @@ limitations under the License. margin-left: -12px; } -.mx_WhoIsTypingTile_avatars .mx_BaseAvatar_image { - border: 1px solid $primary-bg-color; -} - .mx_WhoIsTypingTile_avatars .mx_BaseAvatar_initial { padding-top: 1px; } +.mx_WhoIsTypingTile_avatars .mx_BaseAvatar { + border: 1px solid $primary-bg-color; + border-radius: 40px; +} + .mx_WhoIsTypingTile_remainingAvatarPlaceholder { position: relative; display: inline-block; diff --git a/res/css/views/settings/_AvatarSetting.scss b/res/css/views/settings/_AvatarSetting.scss new file mode 100644 index 0000000000..35dba90f85 --- /dev/null +++ b/res/css/views/settings/_AvatarSetting.scss @@ -0,0 +1,87 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_AvatarSetting_avatar { + width: 88px; + height: 88px; + margin-left: 13px; + position: relative; + + & > * { + width: 88px; + box-sizing: border-box; + } + + .mx_AccessibleButton.mx_AccessibleButton_kind_primary { + margin-top: 8px; + + div { + position: relative; + height: 12px; + width: 12px; + display: inline; + padding-right: 6px; // 0.5 * 12px + left: -6px; // 0.5 * 12px + top: 3px; + } + + div::before { + content: ''; + position: absolute; + height: 12px; + width: 12px; + + background-color: $button-primary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/feather-customised/upload.svg'); + } + } + + .mx_AccessibleButton.mx_AccessibleButton_kind_link_sm { + color: $button-danger-bg-color; + } + + & > img { + cursor: pointer; + object-fit: cover; + } + + & > img, + .mx_AvatarSetting_avatarPlaceholder { + display: block; + height: 88px; + border-radius: 4px; + } + + .mx_AvatarSetting_avatarPlaceholder::before { + background-color: $settings-profile-overlay-placeholder-fg-color; + mask: url("$(res)/img/feather-customised/user.svg"); + mask-repeat: no-repeat; + mask-size: 36px; + mask-position: center; + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } +} + +.mx_AvatarSetting_avatar .mx_AvatarSetting_avatarPlaceholder { + background-color: $settings-profile-placeholder-bg-color; +} diff --git a/res/css/views/messages/_ReactionQuickTooltip.scss b/res/css/views/settings/_CrossSigningPanel.scss similarity index 71% rename from res/css/views/messages/_ReactionQuickTooltip.scss rename to res/css/views/settings/_CrossSigningPanel.scss index 7b1611483b..fa9f76a963 100644 --- a/res/css/views/messages/_ReactionQuickTooltip.scss +++ b/res/css/views/settings/_CrossSigningPanel.scss @@ -14,16 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ReactionsQuickTooltip_buttons { - display: grid; - grid-template-columns: repeat(4, auto); +.mx_CrossSigningPanel_statusList { + border-spacing: 0; + + td { + padding: 0; + + &:first-of-type { + padding-inline-end: 1em; + } + } } -.mx_ReactionsQuickTooltip_label { - text-align: center; -} - -.mx_ReactionsQuickTooltip_shortcode { - padding-left: 6px; - opacity: 0.7; +.mx_CrossSigningPanel_buttonRow { + margin: 1em 0; } diff --git a/res/css/views/settings/_DevicesPanel.scss b/res/css/views/settings/_DevicesPanel.scss index 581ff47fc1..49debe842f 100644 --- a/res/css/views/settings/_DevicesPanel.scss +++ b/res/css/views/settings/_DevicesPanel.scss @@ -18,7 +18,7 @@ limitations under the License. display: table; table-layout: fixed; width: 880px; - border-spacing: 2px; + border-spacing: 10px; } .mx_DevicesPanel_header { @@ -32,7 +32,11 @@ limitations under the License. .mx_DevicesPanel_header > div { display: table-cell; - vertical-align: bottom; + vertical-align: middle; +} + +.mx_DevicesPanel_header .mx_DevicesPanel_deviceName { + width: 50%; } .mx_DevicesPanel_header .mx_DevicesPanel_deviceLastSeen { diff --git a/res/css/views/settings/_IntegrationsManager.scss b/res/css/views/settings/_IntegrationManager.scss similarity index 83% rename from res/css/views/settings/_IntegrationsManager.scss rename to res/css/views/settings/_IntegrationManager.scss index 8b51eb272e..81b01ab8de 100644 --- a/res/css/views/settings/_IntegrationsManager.scss +++ b/res/css/views/settings/_IntegrationManager.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_IntegrationsManager .mx_Dialog { +.mx_IntegrationManager .mx_Dialog { width: 60%; height: 70%; overflow: hidden; @@ -23,22 +23,22 @@ limitations under the License. max-height: initial; } -.mx_IntegrationsManager iframe { +.mx_IntegrationManager iframe { background-color: #fff; border: 0px; width: 100%; height: 100%; } -.mx_IntegrationsManager_loading h3 { +.mx_IntegrationManager_loading h3 { text-align: center; } -.mx_IntegrationsManager_error { +.mx_IntegrationManager_error { text-align: center; padding-top: 20px; } -.mx_IntegrationsManager_error h3 { +.mx_IntegrationManager_error h3 { color: $warning-color; } diff --git a/res/css/views/settings/_KeyBackupPanel.scss b/res/css/views/settings/_KeyBackupPanel.scss index 1bcc0ab10d..872162caad 100644 --- a/res/css/views/settings/_KeyBackupPanel.scss +++ b/res/css/views/settings/_KeyBackupPanel.scss @@ -1,5 +1,6 @@ /* Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -30,3 +31,7 @@ limitations under the License. .mx_KeyBackupPanel_deviceName { font-style: italic; } + +.mx_KeyBackupPanel_buttonRow { + margin: 1em 0; +} diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index 432b713c1b..58624d1597 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -38,91 +38,6 @@ limitations under the License. } } -.mx_ProfileSettings_avatar { - width: 88px; - height: 88px; - margin-left: 13px; - position: relative; -} - -.mx_ProfileSettings_avatar > * { - display: block; - width: 88px; - height: 88px; - border-radius: 4px; -} - -.mx_ProfileSettings_avatar .mx_ProfileSettings_avatarOverlay_disabled { - cursor: default; -} - -.mx_ProfileSettings_avatar .mx_ProfileSettings_avatarPlaceholder { - background-color: $settings-profile-placeholder-bg-color; -} - -.mx_ProfileSettings_avatarOverlay { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: none; - text-align: center; - vertical-align: middle; - font-size: 10px; - cursor: pointer; -} - -.mx_ProfileSettings_avatar:hover .mx_ProfileSettings_avatarOverlay:not(.mx_ProfileSettings_avatarOverlay_disabled) { - display: inline-block; - opacity: 0.5 !important; - color: $settings-profile-overlay-fg-color !important; - background-color: $settings-profile-overlay-bg-color !important; -} - -.mx_ProfileSettings_avatarOverlay_show { - display: inline-block; - opacity: 1; - color: $settings-profile-overlay-placeholder-fg-color; - background-color: $settings-profile-overlay-placeholder-bg-color; -} - -.mx_ProfileSettings_avatarOverlayText { - display: block; - margin-top: 17px; - margin-bottom: 8px; -} - -.mx_ProfileSettings_noAvatarText { - display: block; - margin: 34px auto auto; -} - -.mx_ProfileSettings_avatarOverlayImgContainer { - position: relative; - width: 14px; - height: 14px; - margin: auto; -} - -.mx_ProfileSettings_avatarOverlayImg::before { - background-color: $settings-profile-overlay-placeholder-fg-color; - mask: url("$(res)/img/feather-customised/upload.svg"); - mask-repeat: no-repeat; - mask-size: 14px; - mask-position: center; - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; -} - -.mx_ProfileSettings_avatar:hover .mx_ProfileSettings_avatarOverlayImg::before { - background-color: $settings-profile-overlay-fg-color !important; -} - .mx_ProfileSettings_avatarUpload { display: none; } diff --git a/res/css/views/settings/_SetIntegrationManager.scss b/res/css/views/settings/_SetIntegrationManager.scss index 99537f9eb4..3e59ac73ac 100644 --- a/res/css/views/settings/_SetIntegrationManager.scss +++ b/res/css/views/settings/_SetIntegrationManager.scss @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SetIntegrationManager .mx_Field_input { - @mixin mx_Settings_fullWidthField; -} - .mx_SetIntegrationManager { margin-top: 10px; margin-bottom: 10px; @@ -32,6 +28,10 @@ limitations under the License. padding-left: 5px; } -.mx_SetIntegrationManager_tooltip { - @mixin mx_Settings_tooltip; +.mx_SetIntegrationManager .mx_ToggleSwitch { + display: inline-block; + float: right; + top: 9px; + + @mixin mx_Settings_fullWidthField; } diff --git a/res/css/views/messages/_ReactionTooltipButton.scss b/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.scss similarity index 69% rename from res/css/views/messages/_ReactionTooltipButton.scss rename to res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.scss index 59244ab63b..2a3fd12f31 100644 --- a/res/css/views/messages/_ReactionTooltipButton.scss +++ b/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.scss @@ -14,18 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ReactionTooltipButton { - font-size: 16px; - padding: 6px; - user-select: none; - cursor: pointer; - transition: transform 0.25s; - - &:hover { - transform: scale(1.2); - } +.mx_MjolnirUserSettingsTab .mx_Field { + @mixin mx_Settings_fullWidthField; } -.mx_ReactionTooltipButton_selected { - opacity: 0.4; +.mx_MjolnirUserSettingsTab_listItem { + margin-bottom: 2px; } diff --git a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss index d003e175d9..be0af9123b 100644 --- a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss @@ -14,6 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_PreferencesUserSettingsTab .mx_Field { - @mixin mx_Settings_fullWidthField; +.mx_PreferencesUserSettingsTab { + .mx_Field { + @mixin mx_Settings_fullWidthField; + } + + .mx_SettingsTab_section { + margin-bottom: 30px; + } } diff --git a/res/css/views/verification/_VerificationShowSas.scss b/res/css/views/verification/_VerificationShowSas.scss index a0da7e2539..5038d40b73 100644 --- a/res/css/views/verification/_VerificationShowSas.scss +++ b/res/css/views/verification/_VerificationShowSas.scss @@ -1,5 +1,6 @@ /* Copyright 2019 New Vector Ltd. +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -28,21 +29,35 @@ limitations under the License. .mx_VerificationShowSas_emojiSas { text-align: center; + display: flex; + flex-wrap: wrap; + justify-content: center; + margin: 25px 0; } .mx_VerificationShowSas_emojiSas_block { display: inline-block; - margin-left: 15px; - margin-right: 15px; - text-align: center; - margin-bottom: 20px; + margin-bottom: 16px; + position: relative; + width: 52px; +} + +.mx_Dialog .mx_VerificationShowSas_emojiSas_block, +.mx_AuthPage_modal .mx_VerificationShowSas_emojiSas_block { + width: 60px; } .mx_VerificationShowSas_emojiSas_emoji { - font-size: 48px; + font-size: 32px; } .mx_VerificationShowSas_emojiSas_label { - text-align: center; - font-weight: bold; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; +} + +.mx_VerificationShowSas_emojiSas_break { + flex-basis: 100%; } diff --git a/res/fonts/Nunito/Nunito-Bold.ttf b/res/fonts/Nunito/Nunito-Bold.ttf index c70de76bbd..c8fabf7d92 100644 Binary files a/res/fonts/Nunito/Nunito-Bold.ttf and b/res/fonts/Nunito/Nunito-Bold.ttf differ diff --git a/res/fonts/Nunito/Nunito-Regular.ttf b/res/fonts/Nunito/Nunito-Regular.ttf index 064e805431..86ce522f60 100644 Binary files a/res/fonts/Nunito/Nunito-Regular.ttf and b/res/fonts/Nunito/Nunito-Regular.ttf differ diff --git a/res/fonts/Nunito/Nunito-SemiBold.ttf b/res/fonts/Nunito/Nunito-SemiBold.ttf index a84b3b35a6..8bf953b59a 100644 Binary files a/res/fonts/Nunito/Nunito-SemiBold.ttf and b/res/fonts/Nunito/Nunito-SemiBold.ttf differ diff --git a/res/fonts/Nunito/XRXQ3I6Li01BKofIMN44Y9vKUTo.ttf b/res/fonts/Nunito/XRXQ3I6Li01BKofIMN44Y9vKUTo.ttf deleted file mode 100644 index 4387fb67c4..0000000000 Binary files a/res/fonts/Nunito/XRXQ3I6Li01BKofIMN44Y9vKUTo.ttf and /dev/null differ diff --git a/res/fonts/Nunito/XRXQ3I6Li01BKofIMN5cYtvKUTo.ttf b/res/fonts/Nunito/XRXQ3I6Li01BKofIMN5cYtvKUTo.ttf deleted file mode 100644 index 68fb3ff5cb..0000000000 Binary files a/res/fonts/Nunito/XRXQ3I6Li01BKofIMN5cYtvKUTo.ttf and /dev/null differ diff --git a/res/fonts/Nunito/XRXV3I6Li01BKofINeaE.ttf b/res/fonts/Nunito/XRXV3I6Li01BKofINeaE.ttf deleted file mode 100644 index c40e599260..0000000000 Binary files a/res/fonts/Nunito/XRXV3I6Li01BKofINeaE.ttf and /dev/null differ diff --git a/res/fonts/Nunito/XRXW3I6Li01BKofA6sKUYevN.ttf b/res/fonts/Nunito/XRXW3I6Li01BKofA6sKUYevN.ttf deleted file mode 100644 index 0c4fd17dfa..0000000000 Binary files a/res/fonts/Nunito/XRXW3I6Li01BKofA6sKUYevN.ttf and /dev/null differ diff --git a/res/fonts/Nunito/XRXW3I6Li01BKofAjsOUYevN.ttf b/res/fonts/Nunito/XRXW3I6Li01BKofAjsOUYevN.ttf deleted file mode 100644 index 339d59ac00..0000000000 Binary files a/res/fonts/Nunito/XRXW3I6Li01BKofAjsOUYevN.ttf and /dev/null differ diff --git a/res/fonts/Nunito/XRXX3I6Li01BKofIMNaDRss.ttf b/res/fonts/Nunito/XRXX3I6Li01BKofIMNaDRss.ttf deleted file mode 100644 index b5fcd891af..0000000000 Binary files a/res/fonts/Nunito/XRXX3I6Li01BKofIMNaDRss.ttf and /dev/null differ diff --git a/res/img/admin.svg b/res/img/admin.svg deleted file mode 100644 index 7ea7459304..0000000000 --- a/res/img/admin.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - icons_owner - Created with sketchtool. - - - - - - - - - - - - diff --git a/res/img/e2e/normal.svg b/res/img/e2e/normal.svg new file mode 100644 index 0000000000..5b848bc27f --- /dev/null +++ b/res/img/e2e/normal.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/e2e/verified.svg b/res/img/e2e/verified.svg index 459a552a40..464b443dcf 100644 --- a/res/img/e2e/verified.svg +++ b/res/img/e2e/verified.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/res/img/e2e/warning.svg b/res/img/e2e/warning.svg index 3d5fba550c..209ae0f71f 100644 --- a/res/img/e2e/warning.svg +++ b/res/img/e2e/warning.svg @@ -1,6 +1,5 @@ - - - - - + + + + diff --git a/res/img/emojipicker/activity.svg b/res/img/emojipicker/activity.svg new file mode 100644 index 0000000000..d921667e7a --- /dev/null +++ b/res/img/emojipicker/activity.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/res/img/emojipicker/custom.svg b/res/img/emojipicker/custom.svg new file mode 100644 index 0000000000..814cd8ec13 --- /dev/null +++ b/res/img/emojipicker/custom.svg @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/res/img/emojipicker/delete.svg b/res/img/emojipicker/delete.svg new file mode 100644 index 0000000000..5f5d4e52eb --- /dev/null +++ b/res/img/emojipicker/delete.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/res/img/emojipicker/flags.svg b/res/img/emojipicker/flags.svg new file mode 100644 index 0000000000..bd0a935265 --- /dev/null +++ b/res/img/emojipicker/flags.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/res/img/emojipicker/foods.svg b/res/img/emojipicker/foods.svg new file mode 100644 index 0000000000..57a15976d8 --- /dev/null +++ b/res/img/emojipicker/foods.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/res/img/emojipicker/nature.svg b/res/img/emojipicker/nature.svg new file mode 100644 index 0000000000..a4778be927 --- /dev/null +++ b/res/img/emojipicker/nature.svg @@ -0,0 +1,15 @@ + + + + + + diff --git a/res/img/emojipicker/objects.svg b/res/img/emojipicker/objects.svg new file mode 100644 index 0000000000..e0d39f985a --- /dev/null +++ b/res/img/emojipicker/objects.svg @@ -0,0 +1,15 @@ + + + + + + diff --git a/res/img/emojipicker/people.svg b/res/img/emojipicker/people.svg new file mode 100644 index 0000000000..c2fdb579f6 --- /dev/null +++ b/res/img/emojipicker/people.svg @@ -0,0 +1,15 @@ + + + + + + diff --git a/res/img/emojipicker/places.svg b/res/img/emojipicker/places.svg new file mode 100644 index 0000000000..0947baaf04 --- /dev/null +++ b/res/img/emojipicker/places.svg @@ -0,0 +1,15 @@ + + + + + + diff --git a/res/img/emojipicker/recent.svg b/res/img/emojipicker/recent.svg new file mode 100644 index 0000000000..2fdcc65cd2 --- /dev/null +++ b/res/img/emojipicker/recent.svg @@ -0,0 +1,15 @@ + + + + + + diff --git a/res/img/emojipicker/search.svg b/res/img/emojipicker/search.svg new file mode 100644 index 0000000000..b5f660b3ac --- /dev/null +++ b/res/img/emojipicker/search.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/res/img/emojipicker/symbols.svg b/res/img/emojipicker/symbols.svg new file mode 100644 index 0000000000..a2b86d9ec8 --- /dev/null +++ b/res/img/emojipicker/symbols.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/res/img/feather-customised/bridge.svg b/res/img/feather-customised/bridge.svg new file mode 100644 index 0000000000..f8f3468155 --- /dev/null +++ b/res/img/feather-customised/bridge.svg @@ -0,0 +1,50 @@ + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/res/img/feather-customised/edit.svg b/res/img/feather-customised/edit.svg new file mode 100644 index 0000000000..f511aa1477 --- /dev/null +++ b/res/img/feather-customised/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/feather-customised/image.svg b/res/img/feather-customised/image.svg new file mode 100644 index 0000000000..9690aecf36 --- /dev/null +++ b/res/img/feather-customised/image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/feather-customised/lock-solid.svg b/res/img/feather-customised/lock-solid.svg new file mode 100644 index 0000000000..9eb8b6a4c5 --- /dev/null +++ b/res/img/feather-customised/lock-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/feather-customised/plus.svg b/res/img/feather-customised/plus.svg new file mode 100644 index 0000000000..c747253139 --- /dev/null +++ b/res/img/feather-customised/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/feather-customised/widget/bin.svg b/res/img/feather-customised/widget/bin.svg deleted file mode 100644 index 7616d8931b..0000000000 --- a/res/img/feather-customised/widget/bin.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - diff --git a/res/img/feather-customised/widget/camera.svg b/res/img/feather-customised/widget/camera.svg deleted file mode 100644 index 5502493068..0000000000 --- a/res/img/feather-customised/widget/camera.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/feather-customised/widget/edit.svg b/res/img/feather-customised/widget/edit.svg deleted file mode 100644 index 749e83f982..0000000000 --- a/res/img/feather-customised/widget/edit.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/feather-customised/widget/refresh.svg b/res/img/feather-customised/widget/refresh.svg deleted file mode 100644 index 0994bbdd52..0000000000 --- a/res/img/feather-customised/widget/refresh.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/feather-customised/widget/x-circle.svg b/res/img/feather-customised/widget/x-circle.svg deleted file mode 100644 index 951407b39c..0000000000 --- a/res/img/feather-customised/widget/x-circle.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/icon-email-pill-avatar.svg b/res/img/icon-email-pill-avatar.svg new file mode 100644 index 0000000000..6b0ac200a5 --- /dev/null +++ b/res/img/icon-email-pill-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/icon-pill-remove.svg b/res/img/icon-pill-remove.svg new file mode 100644 index 0000000000..adf6fd4771 --- /dev/null +++ b/res/img/icon-pill-remove.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/icons-settings-room.svg b/res/img/icons-settings-room.svg deleted file mode 100644 index 421eefdefa..0000000000 --- a/res/img/icons-settings-room.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/mod.svg b/res/img/mod.svg deleted file mode 100644 index 847baf98f9..0000000000 --- a/res/img/mod.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - icons_admin - Created with sketchtool. - - - - - - - - - - - diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index eadde4c672..a3515a9d99 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -16,6 +16,7 @@ $room-highlight-color: #343a46; // typical text (dark-on-white in light skin) $primary-fg-color: $text-primary-color; $primary-bg-color: $bg-color; +$muted-fg-color: $header-panel-text-primary-color; // used for dialog box text $light-fg-color: $header-panel-text-secondary-color; @@ -172,6 +173,8 @@ $interactive-tooltip-fg-color: #ffffff; $breadcrumb-placeholder-bg-color: #272c35; +$user-tile-hover-bg-color: $header-panel-bg-color; + // ***** Mixins! ***** @define-mixin mx_DialogButton { @@ -243,3 +246,13 @@ $breadcrumb-placeholder-bg-color: #272c35; } } } + +// diff highlight colors +// intentionally swapped to avoid inversion +.hljs-addition { + background: #fdd; +} + +.hljs-deletion { + background: #dfd; +} diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index b412261d10..626ccb2e13 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -5,20 +5,26 @@ Arial empirically gets it right, hence prioritising Arial here. */ /* We fall through to Twemoji for emoji rather than falling through to native Emoji fonts (if any) to ensure cross-browser consistency */ -$font-family: Nunito, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', Arial, Helvetica, Sans-Serif; +/* Noto Color Emoji contains digits, in fixed-width, therefore causing + digits in flowed text to stand out. + TODO: Consider putting all emoji fonts to the end rather than the front. */ +$font-family: Nunito, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji'; -$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', Courier, monospace; +$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Courier, monospace, 'Noto Color Emoji'; // unified palette // try to use these colors when possible $accent-color: #03b381; +$accent-bg-color: rgba(3, 179, 129, 0.16); $notice-primary-color: #ff4b55; +$notice-primary-bg-color: rgba(255, 75, 85, 0.16); $notice-secondary-color: #61708b; $header-panel-bg-color: #f3f8fd; // typical text (dark-on-white in light skin) $primary-fg-color: #2e2f32; $primary-bg-color: #ffffff; +$muted-fg-color: #61708b; // Commonly used in headings and relevant alt text // used for dialog box text $light-fg-color: #747474; @@ -221,6 +227,7 @@ $copy-button-url: "$(res)/img/icon_copy_message.svg"; // e2e $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color +$e2e-unknown-color: #e8bf37; $e2e-unverified-color: #e8bf37; $e2e-warning-color: #ba6363; @@ -291,6 +298,8 @@ $interactive-tooltip-fg-color: #ffffff; $breadcrumb-placeholder-bg-color: #e8eef5; +$user-tile-hover-bg-color: $header-panel-bg-color; + // ***** Mixins! ***** @define-mixin mx_DialogButton { @@ -336,3 +345,12 @@ $breadcrumb-placeholder-bg-color: #e8eef5; color: $accent-color; text-decoration: none; } + +// diff highlight colors +.hljs-addition { + background: #dfd; +} + +.hljs-deletion { + background: #fdd; +} diff --git a/scripts/ci/build.sh b/scripts/ci/build.sh deleted file mode 100644 index 0b1fa23093..0000000000 --- a/scripts/ci/build.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -# -# script which is run by the CI build (after `yarn test`). -# -# clones riot-web develop and runs the tests against our version of react-sdk. - -set -ev - -RIOT_WEB_DIR=riot-web -REACT_SDK_DIR=`pwd` - -yarn link - -scripts/fetchdep.sh vector-im riot-web - -pushd "$RIOT_WEB_DIR" - -yarn link matrix-js-sdk -yarn link matrix-react-sdk - -yarn install - -yarn build - -popd diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/end-to-end-tests.sh old mode 100644 new mode 100755 index 0ec26df450..9bdb512940 --- a/scripts/ci/end-to-end-tests.sh +++ b/scripts/ci/end-to-end-tests.sh @@ -21,21 +21,23 @@ handle_error() { trap 'handle_error' ERR -RIOT_WEB_DIR=riot-web -REACT_SDK_DIR=`pwd` - echo "--- Building Riot" -scripts/ci/build.sh +scripts/ci/layered-riot-web.sh +cd ../riot-web +riot_web_dir=`pwd` +CI_PACKAGE=true yarn build +cd ../matrix-react-sdk # run end to end tests -echo "--- Fetching end-to-end tests from master" -scripts/fetchdep.sh matrix-org matrix-react-end-to-end-tests master -pushd matrix-react-end-to-end-tests -ln -s $REACT_SDK_DIR/$RIOT_WEB_DIR riot/riot-web +pushd test/end-to-end-tests +ln -s $riot_web_dir riot/riot-web # PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh # CHROME_PATH=$(which google-chrome-stable) ./run.sh echo "--- Install synapse & other dependencies" ./install.sh +# install static webserver to server symlinked local copy of riot +./riot/install-webserver.sh +rm -r logs || true mkdir logs echo "+++ Running end-to-end tests" TESTS_STARTED=1 diff --git a/scripts/ci/install-deps.sh b/scripts/ci/install-deps.sh old mode 100644 new mode 100755 index 6484ebab29..14b5fc5393 --- a/scripts/ci/install-deps.sh +++ b/scripts/ci/install-deps.sh @@ -6,8 +6,9 @@ scripts/fetchdep.sh matrix-org matrix-js-sdk pushd matrix-js-sdk yarn link -yarn install +yarn install $@ +yarn build popd yarn link matrix-js-sdk -yarn install +yarn install $@ diff --git a/scripts/ci/layered-riot-web.sh b/scripts/ci/layered-riot-web.sh new file mode 100755 index 0000000000..f58794b451 --- /dev/null +++ b/scripts/ci/layered-riot-web.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Creates an environment similar to one that riot-web would expect for +# development. This means going one directory up (and assuming we're in +# a directory like /workdir/matrix-react-sdk) and putting riot-web and +# the js-sdk there. + +cd ../ # Assume we're at something like /workdir/matrix-react-sdk + +# Set up the js-sdk first +matrix-react-sdk/scripts/fetchdep.sh matrix-org matrix-js-sdk +pushd matrix-js-sdk +yarn link +yarn install +popd + +# Now set up the react-sdk +pushd matrix-react-sdk +yarn link matrix-js-sdk +yarn link +yarn install +popd + +# Finally, set up riot-web +matrix-react-sdk/scripts/fetchdep.sh vector-im riot-web +pushd riot-web +yarn link matrix-js-sdk +yarn link matrix-react-sdk +yarn install +yarn build:res +popd diff --git a/scripts/ci/riot-unit-tests.sh b/scripts/ci/riot-unit-tests.sh old mode 100644 new mode 100755 index 215af13030..337c0fe6c3 --- a/scripts/ci/riot-unit-tests.sh +++ b/scripts/ci/riot-unit-tests.sh @@ -6,9 +6,7 @@ set -ev -RIOT_WEB_DIR=riot-web - -scripts/ci/build.sh -pushd "$RIOT_WEB_DIR" +scripts/ci/layered-riot-web.sh +cd ../riot-web +yarn build:genfiles # so the tests can run. Faster version of `build` yarn test -popd diff --git a/scripts/ci/unit-tests.sh b/scripts/ci/unit-tests.sh deleted file mode 100644 index 5b86190963..0000000000 --- a/scripts/ci/unit-tests.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -# -# script which is run by the CI build (after `yarn test`). -# -# clones riot-web develop and runs the tests against our version of react-sdk. - -set -ev - -scripts/ci/build.sh -yarn test diff --git a/scripts/emoji-data-strip.js b/scripts/emoji-data-strip.js deleted file mode 100644 index 1c3738cab1..0000000000 --- a/scripts/emoji-data-strip.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node - -// This generates src/stripped-emoji.json as used by the EmojiProvider autocomplete -// provider. - -const EMOJIBASE = require('emojibase-data/en/compact.json'); - -const fs = require('fs'); - -const output = EMOJIBASE.map( - (datum) => { - const newDatum = { - name: datum.annotation, - shortname: `:${datum.shortcodes[0]}:`, - category: datum.group, - emoji_order: datum.order, - }; - if (datum.shortcodes.length > 1) { - newDatum.aliases = datum.shortcodes.slice(1).map(s => `:${s}:`); - } - if (datum.emoticon) { - newDatum.aliases_ascii = [ datum.emoticon ]; - } - return newDatum; - } -); - -// Write to a file in src. Changes should be checked into git. This file is copied by -// babel using --copy-files -fs.writeFileSync('./src/stripped-emoji.json', JSON.stringify(output)); diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index f82752bfc5..0142305797 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -17,7 +17,7 @@ clone() { if [ -n "$branch" ] then echo "Trying to use $org/$repo#$branch" - git clone git://github.com/$org/$repo.git $repo --branch "$branch" && exit 0 + git clone git://github.com/$org/$repo.git $repo --branch "$branch" --depth 1 && exit 0 fi } diff --git a/scripts/generate-eslint-error-ignore-file b/scripts/generate-eslint-error-ignore-file index 3a635f5a7d..54aacfc9fa 100755 --- a/scripts/generate-eslint-error-ignore-file +++ b/scripts/generate-eslint-error-ignore-file @@ -14,8 +14,10 @@ echo "generating $out" # autogenerated file: run scripts/generate-eslint-error-ignore-file to update. EOF - - ./node_modules/.bin/eslint --no-ignore -f json src test | + + ./node_modules/.bin/eslint -f json src test | jq -r '.[] | select((.errorCount + .warningCount) > 0) | .filePath' | sed -e 's/.*matrix-react-sdk\///'; } > "$out" +# also append rules from eslintignore file +cat .eslintignore >> $out diff --git a/scripts/reskindex.js b/scripts/reskindex.js index 833151a298..81ab111f46 100755 --- a/scripts/reskindex.js +++ b/scripts/reskindex.js @@ -2,7 +2,7 @@ var fs = require('fs'); var path = require('path'); var glob = require('glob'); -var args = require('optimist').argv; +var args = require('minimist')(process.argv); var chokidar = require('chokidar'); var componentIndex = path.join('src', 'component-index.js'); @@ -19,7 +19,6 @@ function reskindex() { prevFiles = files; var header = args.h || args.header; - var packageJson = JSON.parse(fs.readFileSync('./package.json')); var strm = fs.createWriteStream(componentIndexTmp); @@ -34,19 +33,7 @@ function reskindex() { strm.write(" * so you'd just be trying to swim upstream like a salmon.\n"); strm.write(" * You are not a salmon.\n"); strm.write(" */\n\n"); - - if (packageJson['matrix-react-parent']) { - const parentIndex = packageJson['matrix-react-parent'] + - '/lib/component-index'; - strm.write( -`let components = require('${parentIndex}').components; -if (!components) { - throw new Error("'${parentIndex}' didn't export components"); -} -`); - } else { - strm.write("let components = {};\n"); - } + strm.write("let components = {};\n"); for (var i = 0; i < files.length; ++i) { var file = files[i].replace('.js', ''); diff --git a/src/AddThreepid.js b/src/AddThreepid.js index 548ed4ce48..7a3250d0ca 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.js @@ -16,8 +16,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixClientPeg from './MatrixClientPeg'; -import sdk from './index'; +import {MatrixClientPeg} from './MatrixClientPeg'; +import * as sdk from './index'; import Modal from './Modal'; import { _t } from './languageHandler'; import IdentityAuthClient from './IdentityAuthClient'; @@ -236,6 +236,8 @@ export default class AddThreepid { */ async haveMsisdnToken(msisdnToken) { const authClient = new IdentityAuthClient(); + const supportsSeparateAddAndBind = + await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind(); let result; if (this.submitUrl) { @@ -245,19 +247,21 @@ export default class AddThreepid { this.clientSecret, msisdnToken, ); - } else { + } else if (this.bind || !supportsSeparateAddAndBind) { result = await MatrixClientPeg.get().submitMsisdnToken( this.sessionId, this.clientSecret, msisdnToken, await authClient.getAccessToken(), ); + } else { + throw new Error("The add / bind with MSISDN flow is misconfigured"); } if (result.errcode) { throw result; } - if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) { + if (supportsSeparateAddAndBind) { if (this.bind) { await MatrixClientPeg.get().bindThreePid({ sid: this.sessionId, diff --git a/src/Analytics.js b/src/Analytics.js index 3e208ad6bd..c96cfdefee 100644 --- a/src/Analytics.js +++ b/src/Analytics.js @@ -1,24 +1,27 @@ /* - Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2020 The Matrix.org Foundation C.I.C. - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at +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 + 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. - */ +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 { getCurrentLanguage, _t, _td } from './languageHandler'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; import Modal from './Modal'; -import sdk from './index'; +import * as sdk from './index'; const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password|home|directory)/; const hashVarRegex = /#\/(group|room|user)\/.*$/; @@ -54,6 +57,8 @@ function getRedactedUrl() { } const customVariables = { + // The Matomo installation at https://matomo.riot.im is currently configured + // with a limit of 10 custom variables. 'App Platform': { id: 1, expl: _td('The platform you\'re on'), @@ -61,7 +66,7 @@ const customVariables = { }, 'App Version': { id: 2, - expl: _td('The version of Riot.im'), + expl: _td('The version of Riot'), example: '15.0.0', }, 'User Type': { @@ -84,20 +89,25 @@ const customVariables = { expl: _td('Whether or not you\'re using the Richtext mode of the Rich Text Editor'), example: 'off', }, - 'Breadcrumbs': { - id: 9, - expl: _td("Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)"), - example: 'disabled', - }, 'Homeserver URL': { id: 7, expl: _td('Your homeserver\'s URL'), example: 'https://matrix.org', }, - 'Identity Server URL': { + 'Touch Input': { id: 8, - expl: _td('Your identity server\'s URL'), - example: 'https://vector.im', + expl: _td("Whether you're using Riot on a device where touch is the primary input mechanism"), + example: 'false', + }, + 'Breadcrumbs': { + id: 9, + expl: _td("Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)"), + example: 'disabled', + }, + 'Installed PWA': { + id: 10, + expl: _td("Whether you're using Riot as an installed Progressive Web App"), + example: 'false', }, }; @@ -106,61 +116,80 @@ function whitelistRedact(whitelist, str) { return ''; } +const UID_KEY = "mx_Riot_Analytics_uid"; +const CREATION_TS_KEY = "mx_Riot_Analytics_cts"; +const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc"; +const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts"; + +function getUid() { + try { + let data = localStorage.getItem(UID_KEY); + if (!data) { + localStorage.setItem(UID_KEY, data = [...Array(16)].map(() => Math.random().toString(16)[2]).join('')); + } + return data; + } catch (e) { + console.error("Analytics error: ", e); + return ""; + } +} + +const HEARTBEAT_INTERVAL = 30 * 1000; // seconds + class Analytics { constructor() { - this._paq = null; - this.disabled = true; + this.baseUrl = null; + this.siteId = null; + this.visitVariables = {}; + this.firstPage = true; + this._heartbeatIntervalID = null; + + this.creationTs = localStorage.getItem(CREATION_TS_KEY); + if (!this.creationTs) { + localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime()); + } + + this.lastVisitTs = localStorage.getItem(LAST_VISIT_TS_KEY); + this.visitCount = localStorage.getItem(VISIT_COUNT_KEY) || 0; + localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1); + } + + get disabled() { + return !this.baseUrl; } /** * Enable Analytics if initialized but disabled * otherwise try and initalize, no-op if piwik config missing */ - enable() { - if (this._paq || this._init()) { - this.disabled = false; - } - } + async enable() { + if (!this.disabled) return; - /** - * Disable Analytics calls, will not fully unload Piwik until a refresh, - * but this is second best, Piwik should not pull anything implicitly. - */ - disable() { - this.trackEvent('Analytics', 'opt-out'); - // disableHeartBeatTimer is undocumented but exists in the piwik code - // the _paq.push method will result in an error being printed in the console - // if an unknown method signature is passed - this._paq.push(['disableHeartBeatTimer']); - this.disabled = true; - } - - _init() { const config = SdkConfig.get(); if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return; - const url = config.piwik.url; - const siteId = config.piwik.siteId; - const self = this; - - window._paq = this._paq = window._paq || []; - - this._paq.push(['setTrackerUrl', url+'piwik.php']); - this._paq.push(['setSiteId', siteId]); - - this._paq.push(['trackAllContentImpressions']); - this._paq.push(['discardHashTag', false]); - this._paq.push(['enableHeartBeatTimer']); - // this._paq.push(['enableLinkTracking', true]); + this.baseUrl = new URL("piwik.php", config.piwik.url); + // set constants + this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking + this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking + this.baseUrl.searchParams.set("apiv", 1); // API version to use + this.baseUrl.searchParams.set("send_image", 0); // we want a 204, not a tiny GIF + // set user parameters + this.baseUrl.searchParams.set("_id", getUid()); // uuid + this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts + this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count + if (this.lastVisitTs) { + this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts + } const platform = PlatformPeg.get(); this._setVisitVariable('App Platform', platform.getHumanReadableName()); - platform.getAppVersion().then((version) => { - this._setVisitVariable('App Version', version); - }).catch(() => { + try { + this._setVisitVariable('App Version', await platform.getAppVersion()); + } catch (e) { this._setVisitVariable('App Version', 'unknown'); - }); + } this._setVisitVariable('Chosen Language', getCurrentLanguage()); @@ -168,20 +197,77 @@ class Analytics { this._setVisitVariable('Instance', window.location.pathname); } - (function() { - const g = document.createElement('script'); - const s = document.getElementsByTagName('script')[0]; - g.type='text/javascript'; g.async=true; g.defer=true; g.src=url+'piwik.js'; + let installedPWA = "unknown"; + try { + // Known to work at least for desktop Chrome + installedPWA = window.matchMedia('(display-mode: standalone)').matches; + } catch (e) { } + this._setVisitVariable('Installed PWA', installedPWA); - g.onload = function() { - console.log('Initialised anonymous analytics'); - self._paq = window._paq; - }; + let touchInput = "unknown"; + try { + // MDN claims broad support across browsers + touchInput = window.matchMedia('(pointer: coarse)').matches; + } catch (e) { } + this._setVisitVariable('Touch Input', touchInput); - s.parentNode.insertBefore(g, s); - })(); + // start heartbeat + this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL); + } - return true; + /** + * Disable Analytics, stop the heartbeat and clear identifiers from localStorage + */ + disable() { + if (this.disabled) return; + this.trackEvent('Analytics', 'opt-out'); + window.clearInterval(this._heartbeatIntervalID); + this.baseUrl = null; + this.visitVariables = {}; + localStorage.removeItem(UID_KEY); + localStorage.removeItem(CREATION_TS_KEY); + localStorage.removeItem(VISIT_COUNT_KEY); + localStorage.removeItem(LAST_VISIT_TS_KEY); + } + + async _track(data) { + if (this.disabled) return; + + const now = new Date(); + const params = { + ...data, + url: getRedactedUrl(), + + _cvar: JSON.stringify(this.visitVariables), // user custom vars + res: `${window.screen.width}x${window.screen.height}`, // resolution as WWWWxHHHH + rand: String(Math.random()).slice(2, 8), // random nonce to cache-bust + h: now.getHours(), + m: now.getMinutes(), + s: now.getSeconds(), + }; + + const url = new URL(this.baseUrl); + for (const key in params) { + url.searchParams.set(key, params[key]); + } + + try { + await window.fetch(url, { + method: "GET", + mode: "no-cors", + cache: "no-cache", + redirect: "follow", + }); + } catch (e) { + console.error("Analytics error: ", e); + } + } + + ping() { + this._track({ + ping: 1, + }); + localStorage.setItem(LAST_VISIT_TS_KEY, new Date().getTime()); // update last visit ts } trackPageChange(generationTimeMs) { @@ -193,31 +279,29 @@ class Analytics { return; } - if (typeof generationTimeMs === 'number') { - this._paq.push(['setGenerationTimeMs', generationTimeMs]); - } else { + if (typeof generationTimeMs !== 'number') { console.warn('Analytics.trackPageChange: expected generationTimeMs to be a number'); // But continue anyway because we still want to track the change } - this._paq.push(['setCustomUrl', getRedactedUrl()]); - this._paq.push(['trackPageView']); + this._track({ + gt_ms: generationTimeMs, + }); } trackEvent(category, action, name, value) { if (this.disabled) return; - this._paq.push(['setCustomUrl', getRedactedUrl()]); - this._paq.push(['trackEvent', category, action, name, value]); - } - - logout() { - if (this.disabled) return; - this._paq.push(['deleteCookies']); + this._track({ + e_c: category, + e_a: action, + e_n: name, + e_v: value, + }); } _setVisitVariable(key, value) { if (this.disabled) return; - this._paq.push(['setCustomVariable', customVariables[key].id, key, value, 'visit']); + this.visitVariables[customVariables[key].id] = [key, value]; } setLoggedIn(isGuest, homeserverUrl, identityServerUrl) { @@ -227,16 +311,9 @@ class Analytics { if (!config.piwik) return; const whitelistedHSUrls = config.piwik.whitelistedHSUrls || []; - const whitelistedISUrls = config.piwik.whitelistedISUrls || []; this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In'); this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); - this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl)); - } - - setRichtextMode(state) { - if (this.disabled) return; - this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off'); } setBreadcrumbs(state) { @@ -244,13 +321,11 @@ class Analytics { this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled'); } - showDetailsModal() { + showDetailsModal = () => { let rows = []; - if (window.Piwik) { - const Tracker = window.Piwik.getAsyncTracker(); - rows = Object.values(customVariables).map((v) => Tracker.getCustomVariable(v.id)).filter(Boolean); + if (!this.disabled) { + rows = Object.values(this.visitVariables); } else { - // Piwik may not have been enabled, so show example values rows = Object.keys(customVariables).map( (k) => [ k, @@ -271,7 +346,7 @@ class Analytics { }, ), }, - { expl: _td('Your User Agent'), value: navigator.userAgent }, + { expl: _td('Your user agent'), value: navigator.userAgent }, { expl: _td('Your device resolution'), value: resolution }, ]; @@ -280,7 +355,7 @@ class Analytics { title: _t('Analytics'), description:
- { _t('The information being sent to us to help make Riot.im better includes:') } + { _t('The information being sent to us to help make Riot better includes:') }
{ rows.map((row) => @@ -300,10 +375,10 @@ class Analytics { , }); - } + }; } if (!global.mxAnalytics) { global.mxAnalytics = new Analytics(); } -module.exports = global.mxAnalytics; +export default global.mxAnalytics; diff --git a/src/AsyncWrapper.js b/src/AsyncWrapper.js new file mode 100644 index 0000000000..b7b81688e1 --- /dev/null +++ b/src/AsyncWrapper.js @@ -0,0 +1,92 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import createReactClass from 'create-react-class'; +import * as sdk from './index'; +import PropTypes from 'prop-types'; +import { _t } from './languageHandler'; + +/** + * Wrap an asynchronous loader function with a react component which shows a + * spinner until the real component loads. + */ +export default createReactClass({ + propTypes: { + /** A promise which resolves with the real component + */ + prom: PropTypes.object.isRequired, + }, + + getInitialState: function() { + return { + component: null, + error: null, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Starting load of AsyncWrapper for modal'); + this.props.prom.then((result) => { + if (this._unmounted) { + return; + } + // Take the 'default' member if it's there, then we support + // passing in just an import()ed module, since ES6 async import + // always returns a module *namespace*. + const component = result.default ? result.default : result; + this.setState({component}); + }).catch((e) => { + console.warn('AsyncWrapper promise failed', e); + this.setState({error: e}); + }); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + _onWrapperCancelClick: function() { + this.props.onFinished(false); + }, + + render: function() { + if (this.state.component) { + const Component = this.state.component; + return ; + } else if (this.state.error) { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return + {_t("Unable to load! Check your network connectivity and try again.")} + + ; + } else { + // show a spinner until the component is loaded. + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + }, +}); + diff --git a/src/Avatar.js b/src/Avatar.js index 17860698cb..217b196348 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -15,13 +15,14 @@ limitations under the License. */ 'use strict'; -import {ContentRepo} from 'matrix-js-sdk'; -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; import DMRoomMap from './utils/DMRoomMap'; +import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; -module.exports = { - avatarUrlForMember: function(member, width, height, resizeMethod) { - let url = member.getAvatarUrl( +export function avatarUrlForMember(member, width, height, resizeMethod) { + let url; + if (member && member.getAvatarUrl) { + url = member.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), Math.floor(width * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio), @@ -29,106 +30,108 @@ module.exports = { false, false, ); - if (!url) { - // member can be null here currently since on invites, the JS SDK - // does not have enough info to build a RoomMember object for - // the inviter. - url = this.defaultAvatarUrlForString(member ? member.userId : ''); + } + if (!url) { + // member can be null here currently since on invites, the JS SDK + // does not have enough info to build a RoomMember object for + // the inviter. + url = defaultAvatarUrlForString(member ? member.userId : ''); + } + return url; +} + +export function avatarUrlForUser(user, width, height, resizeMethod) { + const url = getHttpUriForMxc( + MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, + Math.floor(width * window.devicePixelRatio), + Math.floor(height * window.devicePixelRatio), + resizeMethod, + ); + if (!url || url.length === 0) { + return null; + } + return url; +} + +export function defaultAvatarUrlForString(s) { + const images = ['03b381', '368bd6', 'ac3ba8']; + let total = 0; + for (let i = 0; i < s.length; ++i) { + total += s.charCodeAt(i); + } + return require('../res/img/' + images[total % images.length] + '.png'); +} + +/** + * returns the first (non-sigil) character of 'name', + * converted to uppercase + * @param {string} name + * @return {string} the first letter + */ +export function getInitialLetter(name) { + if (!name) { + // XXX: We should find out what causes the name to sometimes be falsy. + console.trace("`name` argument to `getInitialLetter` not supplied"); + return undefined; + } + if (name.length < 1) { + return undefined; + } + + let idx = 0; + const initial = name[0]; + if ((initial === '@' || initial === '#' || initial === '+') && name[1]) { + idx++; + } + + // 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++; } - return url; - }, + } - avatarUrlForUser: function(user, width, height, resizeMethod) { - const url = ContentRepo.getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, - Math.floor(width * window.devicePixelRatio), - Math.floor(height * window.devicePixelRatio), - resizeMethod, - ); - if (!url || url.length === 0) { - return null; - } - return url; - }, + const firstChar = name.substring(idx, idx+chars); + return firstChar.toUpperCase(); +} - defaultAvatarUrlForString: function(s) { - const images = ['03b381', '368bd6', 'ac3ba8']; - let total = 0; - for (let i = 0; i < s.length; ++i) { - total += s.charCodeAt(i); - } - return require('../res/img/' + images[total % images.length] + '.png'); - }, +export function avatarUrlForRoom(room, width, height, resizeMethod) { + if (!room) return null; // null-guard - /** - * returns the first (non-sigil) character of 'name', - * converted to uppercase - * @param {string} name - * @return {string} the first letter - */ - getInitialLetter(name) { - if (!name) { - // XXX: We should find out what causes the name to sometimes be falsy. - console.trace("`name` argument to `getInitialLetter` not supplied"); - return undefined; - } - if (name.length < 1) { - return undefined; - } + const explicitRoomAvatar = room.getAvatarUrl( + MatrixClientPeg.get().getHomeserverUrl(), + width, + height, + resizeMethod, + false, + ); + if (explicitRoomAvatar) { + return explicitRoomAvatar; + } - let idx = 0; - const initial = name[0]; - if ((initial === '@' || initial === '#' || initial === '+') && name[1]) { - idx++; - } - - // 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(); - }, - - avatarUrlForRoom(room, width, height, resizeMethod) { - const explicitRoomAvatar = room.getAvatarUrl( + let otherMember = null; + const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); + if (otherUserId) { + otherMember = room.getMember(otherUserId); + } else { + // if the room is not marked as a 1:1, but only has max 2 members + // then still try to show any avatar (pref. other member) + otherMember = room.getAvatarFallbackMember(); + } + if (otherMember) { + return otherMember.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), width, height, resizeMethod, false, ); - if (explicitRoomAvatar) { - return explicitRoomAvatar; - } - - let otherMember = null; - const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); - if (otherUserId) { - otherMember = room.getMember(otherUserId); - } else { - // if the room is not marked as a 1:1, but only has max 2 members - // then still try to show any avatar (pref. other member) - otherMember = room.getAvatarFallbackMember(); - } - if (otherMember) { - return otherMember.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), - width, - height, - resizeMethod, - false, - ); - } - return null; - }, -}; + } + return null; +} diff --git a/src/BasePlatform.js b/src/BasePlatform.js index a97c14bf90..5d809eb28f 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -4,6 +4,7 @@ Copyright 2016 Aviral Dasgupta Copyright 2016 OpenMarket Ltd Copyright 2018 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,7 +19,9 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {MatrixClient} from "matrix-js-sdk"; import dis from './dispatcher'; +import BaseEventIndexManager from './indexing/BaseEventIndexManager'; /** * Base class for classes that provide platform-specific functionality @@ -151,4 +154,38 @@ export default class BasePlatform { async setMinimizeToTrayEnabled(enabled: boolean): void { throw new Error("Unimplemented"); } + + /** + * Get our platform specific EventIndexManager. + * + * @return {BaseEventIndexManager} The EventIndex manager for our platform, + * can be null if the platform doesn't support event indexing. + */ + getEventIndexingManager(): BaseEventIndexManager | null { + return null; + } + + setLanguage(preferredLangs: string[]) {} + + getSSOCallbackUrl(hsUrl: string, isUrl: string): URL { + const url = new URL(window.location.href); + // XXX: at this point, the fragment will always be #/login, which is no + // use to anyone. Ideally, we would get the intended fragment from + // MatrixChat.screenAfterLogin so that you could follow #/room links etc + // through an SSO login. + url.hash = ""; + url.searchParams.set("homeserver", hsUrl); + url.searchParams.set("identityServer", isUrl); + return url; + } + + /** + * Begin Single Sign On flows. + * @param {MatrixClient} mxClient the matrix client using which we should start the flow + * @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO. + */ + startSingleSignOn(mxClient: MatrixClient, loginType: "sso"|"cas") { + const callbackUrl = this.getSSOCallbackUrl(mxClient.getHomeserverUrl(), mxClient.getIdentityServerUrl()); + window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO + } } diff --git a/src/CallHandler.js b/src/CallHandler.js index f6b3e18538..1551b57313 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -53,10 +53,10 @@ limitations under the License. * } */ -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; import PlatformPeg from './PlatformPeg'; import Modal from './Modal'; -import sdk from './index'; +import * as sdk from './index'; import { _t } from './languageHandler'; import Matrix from 'matrix-js-sdk'; import dis from './dispatcher'; @@ -80,13 +80,26 @@ function play(audioId) { // which listens? const audio = document.getElementById(audioId); if (audio) { + const playAudio = async () => { + try { + // This still causes the chrome debugger to break on promise rejection if + // the promise is rejected, even though we're catching the exception. + await audio.play(); + } catch (e) { + // This is usually because the user hasn't interacted with the document, + // or chrome doesn't think so and is denying the request. Not sure what + // we can really do here... + // https://github.com/vector-im/riot-web/issues/7657 + console.log("Unable to play audio clip", e); + } + }; if (audioPromises[audioId]) { audioPromises[audioId] = audioPromises[audioId].then(()=>{ audio.load(); - return audio.play(); + return playAudio(); }); } else { - audioPromises[audioId] = audio.play(); + audioPromises[audioId] = playAudio(); } } } @@ -126,7 +139,7 @@ function _setCallListeners(call) { Modal.createTrackedDialog('Call Failed', '', QuestionDialog, { title: _t('Call Failed'), description: _t( - "There are unknown devices in this room: "+ + "There are unknown sessions in this room: "+ "if you proceed without verifying them, it will be "+ "possible for someone to eavesdrop on your call.", ), @@ -205,7 +218,7 @@ function _setCallListeners(call) { function _setCallState(call, roomId, status) { console.log( - "Call state in %s changed to %s (%s)", roomId, status, (call ? call.call_state : "-"), + `Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`, ); calls[roomId] = call; @@ -289,7 +302,7 @@ function _onAction(payload) { switch (payload.action) { case 'place_call': { - if (module.exports.getAnyActiveCall()) { + if (callHandler.getAnyActiveCall()) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { title: _t('Existing Call'), @@ -322,7 +335,7 @@ function _onAction(payload) { }); return; } else if (members.length === 2) { - console.log("Place %s call in %s", payload.type, payload.room_id); + console.info("Place %s call in %s", payload.type, payload.room_id); const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id); placeCall(call); } else { // > 2 @@ -337,12 +350,12 @@ function _onAction(payload) { } break; case 'place_conference_call': - console.log("Place conference call in %s", payload.room_id); + console.info("Place conference call in %s", payload.room_id); _startCallApp(payload.room_id, payload.type); break; case 'incoming_call': { - if (module.exports.getAnyActiveCall()) { + if (callHandler.getAnyActiveCall()) { // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup. // we avoid rejecting with "busy" in case the user wants to answer it on a different device. // in future we could signal a "local busy" as a warning to the caller. @@ -382,12 +395,12 @@ function _onAction(payload) { } async function _startCallApp(roomId, type) { - // check for a working integrations manager. Technically we could put + // check for a working integration manager. Technically we could put // the state event in anyway, but the resulting widget would then not // work for us. Better that the user knows before everyone else in the // room sees it. const managers = IntegrationManagers.sharedInstance(); - let haveScalar = true; + let haveScalar = false; if (managers.hasManager()) { try { const scalarClient = managers.getPrimaryManager().getScalarClient(); @@ -396,8 +409,6 @@ async function _startCallApp(roomId, type) { } catch (e) { // ignore } - } else { - haveScalar = false; } if (!haveScalar) { @@ -497,11 +508,22 @@ async function _startCallApp(roomId, type) { // with the dispatcher once if (!global.mxCallHandler) { dis.register(_onAction); + // add empty handlers for media actions, otherwise the media keys + // end up causing the audio elements with our ring/ringback etc + // audio clips in to play. + if (navigator.mediaSession) { + navigator.mediaSession.setActionHandler('play', function() {}); + navigator.mediaSession.setActionHandler('pause', function() {}); + navigator.mediaSession.setActionHandler('seekbackward', function() {}); + navigator.mediaSession.setActionHandler('seekforward', function() {}); + navigator.mediaSession.setActionHandler('previoustrack', function() {}); + navigator.mediaSession.setActionHandler('nexttrack', function() {}); + } } const callHandler = { getCallForRoom: function(roomId) { - let call = module.exports.getCall(roomId); + let call = callHandler.getCall(roomId); if (call) return call; if (ConferenceHandler) { @@ -561,4 +583,4 @@ if (global.mxCallHandler === undefined) { global.mxCallHandler = callHandler; } -module.exports = global.mxCallHandler; +export default global.mxCallHandler; diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 2d58622db8..34379c029b 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -17,11 +17,10 @@ limitations under the License. 'use strict'; -import Promise from 'bluebird'; import extend from './extend'; import dis from './dispatcher'; -import MatrixClientPeg from './MatrixClientPeg'; -import sdk from './index'; +import {MatrixClientPeg} from './MatrixClientPeg'; +import * as sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; import RoomViewStore from './stores/RoomViewStore'; @@ -59,40 +58,38 @@ export class UploadCanceledError extends Error {} * and a thumbnail key. */ function createThumbnail(element, inputWidth, inputHeight, mimeType) { - const deferred = Promise.defer(); + 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"); - canvas.width = targetWidth; - canvas.height = targetHeight; - canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); - canvas.toBlob(function(thumbnail) { - deferred.resolve({ - info: { - thumbnail_info: { - w: targetWidth, - h: targetHeight, - mimetype: thumbnail.type, - size: thumbnail.size, + const 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, }, - w: inputWidth, - h: inputHeight, - }, - thumbnail: thumbnail, - }); - }, mimeType); - - return deferred.promise; + thumbnail: thumbnail, + }); + }, mimeType); + }); } /** @@ -179,30 +176,29 @@ function infoForImageFile(matrixClient, roomId, imageFile) { * @return {Promise} A promise that resolves with the video image element. */ function loadVideoElement(videoFile) { - const deferred = Promise.defer(); + return new Promise((resolve, reject) => { + // Load the file into an html element + const video = document.createElement("video"); - // Load the file into an html element - const video = document.createElement("video"); + const reader = new FileReader(); - const reader = new FileReader(); - reader.onload = function(e) { - video.src = e.target.result; + reader.onload = function(e) { + video.src = e.target.result; - // Once ready, returns its size - // Wait until we have enough data to thumbnail the first frame. - video.onloadeddata = function() { - deferred.resolve(video); + // Once ready, returns its size + // Wait until we have enough data to thumbnail the first frame. + video.onloadeddata = function() { + resolve(video); + }; + video.onerror = function(e) { + reject(e); + }; }; - video.onerror = function(e) { - deferred.reject(e); + reader.onerror = function(e) { + reject(e); }; - }; - reader.onerror = function(e) { - deferred.reject(e); - }; - reader.readAsDataURL(videoFile); - - return deferred.promise; + reader.readAsDataURL(videoFile); + }); } /** @@ -236,16 +232,16 @@ function infoForVideoFile(matrixClient, roomId, videoFile) { * is read. */ function readFileAsArrayBuffer(file) { - const deferred = Promise.defer(); - const reader = new FileReader(); - reader.onload = function(e) { - deferred.resolve(e.target.result); - }; - reader.onerror = function(e) { - deferred.reject(e); - }; - reader.readAsArrayBuffer(file); - return deferred.promise; + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = function(e) { + resolve(e.target.result); + }; + reader.onerror = function(e) { + reject(e); + }; + reader.readAsArrayBuffer(file); + }); } /** @@ -426,6 +422,9 @@ export default class ContentMessages { const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); let uploadAll = false; + // Promise to complete before sending next file into room, used for synchronisation of file-sending + // to match the order the files were specified in + let promBefore = Promise.resolve(); for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; if (!uploadAll) { @@ -444,11 +443,11 @@ export default class ContentMessages { }); if (!shouldContinue) break; } - this._sendContentToRoom(file, roomId, matrixClient); + promBefore = this._sendContentToRoom(file, roomId, matrixClient, promBefore); } } - _sendContentToRoom(file, roomId, matrixClient) { + _sendContentToRoom(file, roomId, matrixClient, promBefore) { const content = { body: file.name || 'Attachment', info: { @@ -461,33 +460,34 @@ export default class ContentMessages { content.info.mimetype = file.type; } - const def = Promise.defer(); - if (file.type.indexOf('image/') == 0) { - content.msgtype = 'm.image'; - infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{ - extend(content.info, imageInfo); - def.resolve(); - }, (error)=>{ - console.error(error); + const prom = new Promise((resolve) => { + if (file.type.indexOf('image/') == 0) { + content.msgtype = 'm.image'; + infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{ + extend(content.info, imageInfo); + resolve(); + }, (error)=>{ + console.error(error); + content.msgtype = 'm.file'; + resolve(); + }); + } else if (file.type.indexOf('audio/') == 0) { + content.msgtype = 'm.audio'; + resolve(); + } else if (file.type.indexOf('video/') == 0) { + content.msgtype = 'm.video'; + infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{ + extend(content.info, videoInfo); + resolve(); + }, (error)=>{ + content.msgtype = 'm.file'; + resolve(); + }); + } else { content.msgtype = 'm.file'; - def.resolve(); - }); - } else if (file.type.indexOf('audio/') == 0) { - content.msgtype = 'm.audio'; - def.resolve(); - } else if (file.type.indexOf('video/') == 0) { - content.msgtype = 'm.video'; - infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{ - extend(content.info, videoInfo); - def.resolve(); - }, (error)=>{ - content.msgtype = 'm.file'; - def.resolve(); - }); - } else { - content.msgtype = 'm.file'; - def.resolve(); - } + resolve(); + } + }); const upload = { fileName: file.name || 'Attachment', @@ -509,7 +509,7 @@ export default class ContentMessages { dis.dispatch({action: 'upload_progress', upload: upload}); } - return def.promise.then(function() { + return prom.then(function() { // XXX: upload.promise must be the promise that // is returned by uploadFile as it has an abort() // method hacked onto it. @@ -520,7 +520,10 @@ export default class ContentMessages { content.file = result.file; content.url = result.url; }); - }).then(function(url) { + }).then((url) => { + // Await previous message being sent into the room + return promBefore; + }).then(function() { return matrixClient.sendMessage(roomId, content); }, function(err) { error = err; diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js new file mode 100644 index 0000000000..f19be03574 --- /dev/null +++ b/src/CrossSigningManager.js @@ -0,0 +1,201 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import Modal from './Modal'; +import * as sdk from './index'; +import {MatrixClientPeg} from './MatrixClientPeg'; +import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; +import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; +import { _t } from './languageHandler'; +import SettingsStore from './settings/SettingsStore'; + +// This stores the secret storage private keys in memory for the JS SDK. This is +// only meant to act as a cache to avoid prompting the user multiple times +// during the same single operation. Use `accessSecretStorage` below to scope a +// single secret storage operation, as it will clear the cached keys once the +// operation ends. +let secretStorageKeys = {}; +let secretStorageBeingAccessed = false; + +function isCachingAllowed() { + return ( + secretStorageBeingAccessed || + SettingsStore.getValue("keepSecretStoragePassphraseForSession") + ); +} + +export class AccessCancelledError extends Error { + constructor() { + super("Secret storage access canceled"); + } +} + +async function confirmToDismiss(name) { + let description; + if (name === "m.cross_signing.user_signing") { + description = _t("If you cancel now, you won't complete verifying the other user."); + } else if (name === "m.cross_signing.self_signing") { + description = _t("If you cancel now, you won't complete verifying your other session."); + } else { + description = _t("If you cancel now, you won't complete your secret storage operation."); + } + + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const [sure] = await Modal.createDialog(QuestionDialog, { + title: _t("Cancel entering passphrase?"), + description, + danger: true, + cancelButton: _t("Enter passphrase"), + button: _t("Cancel"), + }).finished; + return sure; +} + +async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { + const keyInfoEntries = Object.entries(keyInfos); + if (keyInfoEntries.length > 1) { + throw new Error("Multiple storage key requests not implemented"); + } + const [name, info] = keyInfoEntries[0]; + + // Check the in-memory cache + if (isCachingAllowed() && secretStorageKeys[name]) { + return [name, secretStorageKeys[name]]; + } + + const inputToKey = async ({ passphrase, recoveryKey }) => { + if (passphrase) { + return deriveKey( + passphrase, + info.passphrase.salt, + info.passphrase.iterations, + ); + } else { + return decodeRecoveryKey(recoveryKey); + } + }; + const AccessSecretStorageDialog = + sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); + const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", + AccessSecretStorageDialog, + /* props= */ + { + keyInfo: info, + checkPrivateKey: async (input) => { + const key = await inputToKey(input); + return MatrixClientPeg.get().checkSecretStoragePrivateKey(key, info.pubkey); + }, + }, + /* className= */ null, + /* isPriorityModal= */ false, + /* isStaticModal= */ false, + /* options= */ { + onBeforeClose: async (reason) => { + if (reason === "backgroundClick") { + return confirmToDismiss(ssssItemName); + } + return true; + }, + }, + ); + const [input] = await finished; + if (!input) { + throw new AccessCancelledError(); + } + const key = await inputToKey(input); + + // Save to cache to avoid future prompts in the current session + if (isCachingAllowed()) { + secretStorageKeys[name] = key; + } + + return [name, key]; +} + +export const crossSigningCallbacks = { + getSecretStorageKey, +}; + +/** + * This helper should be used whenever you need to access secret storage. It + * ensures that secret storage (and also cross-signing since they each depend on + * each other in a cycle of sorts) have been bootstrapped before running the + * provided function. + * + * Bootstrapping secret storage may take one of these paths: + * 1. Create secret storage from a passphrase and store cross-signing keys + * in secret storage. + * 2. Access existing secret storage by requesting passphrase and accessing + * cross-signing keys as needed. + * 3. All keys are loaded and there's nothing to do. + * + * Additionally, the secret storage keys are cached during the scope of this function + * to ensure the user is prompted only once for their secret storage + * passphrase. The cache is then cleared once the provided function completes. + * + * @param {Function} [func] An operation to perform once secret storage has been + * bootstrapped. Optional. + * @param {bool} [force] Reset secret storage even if it's already set up + */ +export async function accessSecretStorage(func = async () => { }, force = false) { + const cli = MatrixClientPeg.get(); + secretStorageBeingAccessed = true; + try { + if (!await cli.hasSecretStorageKey() || force) { + // This dialog calls bootstrap itself after guiding the user through + // passphrase creation. + const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '', + import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"), + { + force, + }, + null, /* priority = */ false, /* static = */ true, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Secret storage creation canceled"); + } + } else { + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + await cli.bootstrapSecretStorage({ + authUploadDeviceSigningKeys: async (makeRequest) => { + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Setting up keys"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + }, + }); + } + + // `return await` needed here to ensure `finally` block runs after the + // inner operation completes. + return await func(); + } finally { + // Clear secret storage key cache now that work is complete + secretStorageBeingAccessed = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + } + } +} diff --git a/src/DeviceListener.js b/src/DeviceListener.js new file mode 100644 index 0000000000..4e7bc8470d --- /dev/null +++ b/src/DeviceListener.js @@ -0,0 +1,178 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClientPeg } from './MatrixClientPeg'; +import SettingsStore from './settings/SettingsStore'; +import * as sdk from './index'; +import { _t } from './languageHandler'; +import ToastStore from './stores/ToastStore'; + +function toastKey(deviceId) { + return 'unverified_session_' + deviceId; +} + +const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; +const THIS_DEVICE_TOAST_KEY = 'setupencryption'; + +export default class DeviceListener { + static sharedInstance() { + if (!global.mx_DeviceListener) global.mx_DeviceListener = new DeviceListener(); + return global.mx_DeviceListener; + } + + constructor() { + // set of device IDs we're currently showing toasts for + this._activeNagToasts = new Set(); + // device IDs for which the user has dismissed the verify toast ('Later') + this._dismissed = new Set(); + // has the user dismissed any of the various nag toasts to setup encryption on this device? + this._dismissedThisDeviceToast = false; + + // cache of the key backup info + this._keyBackupInfo = null; + this._keyBackupFetchedAt = null; + } + + start() { + MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated); + MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged); + MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged); + this._recheck(); + } + + stop() { + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated); + MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged); + MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged); + } + this._dismissed.clear(); + } + + dismissVerification(deviceId) { + this._dismissed.add(deviceId); + this._recheck(); + } + + dismissEncryptionSetup() { + this._dismissedThisDeviceToast = true; + this._recheck(); + } + + _onDevicesUpdated = (users) => { + if (!users.includes(MatrixClientPeg.get().getUserId())) return; + this._recheck(); + } + + _onDeviceVerificationChanged = (userId) => { + if (userId !== MatrixClientPeg.get().getUserId()) return; + this._recheck(); + } + + _onUserTrustStatusChanged = (userId, trustLevel) => { + if (userId !== MatrixClientPeg.get().getUserId()) return; + this._recheck(); + } + + // The server doesn't tell us when key backup is set up, so we poll + // & cache the result + async _getKeyBackupInfo() { + const now = (new Date()).getTime(); + if (!this._keyBackupInfo || this._keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) { + this._keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); + this._keyBackupFetchedAt = now; + } + return this._keyBackupInfo; + } + + async _recheck() { + if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return; + const cli = MatrixClientPeg.get(); + + if (!cli.isCryptoEnabled()) return; + if (!cli.getCrossSigningId()) { + if (this._dismissedThisDeviceToast) { + ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY); + return; + } + + // cross signing isn't enabled - nag to enable it + // There are 3 different toasts for: + if (cli.getStoredCrossSigningForUser(cli.getUserId())) { + // Cross-signing on account but this device doesn't trust the master key (verify this session) + ToastStore.sharedInstance().addOrReplaceToast({ + key: THIS_DEVICE_TOAST_KEY, + title: _t("Verify this session"), + icon: "verification_warning", + props: {kind: 'verify_this_session'}, + component: sdk.getComponent("toasts.SetupEncryptionToast"), + }); + } else { + const backupInfo = await this._getKeyBackupInfo(); + if (backupInfo) { + // No cross-signing on account but key backup available (upgrade encryption) + ToastStore.sharedInstance().addOrReplaceToast({ + key: THIS_DEVICE_TOAST_KEY, + title: _t("Encryption upgrade available"), + icon: "verification_warning", + props: {kind: 'upgrade_encryption'}, + component: sdk.getComponent("toasts.SetupEncryptionToast"), + }); + } else { + // No cross-signing or key backup on account (set up encryption) + ToastStore.sharedInstance().addOrReplaceToast({ + key: THIS_DEVICE_TOAST_KEY, + title: _t("Set up encryption"), + icon: "verification_warning", + props: {kind: 'set_up_encryption'}, + component: sdk.getComponent("toasts.SetupEncryptionToast"), + }); + } + } + return; + } else { + ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY); + } + + const newActiveToasts = new Set(); + + const devices = await cli.getStoredDevicesForUser(cli.getUserId()); + for (const device of devices) { + if (device.deviceId == cli.deviceId) continue; + + const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId); + if (deviceTrust.isCrossSigningVerified() || this._dismissed.has(device.deviceId)) { + ToastStore.sharedInstance().dismissToast(toastKey(device.deviceId)); + } else { + this._activeNagToasts.add(device.deviceId); + ToastStore.sharedInstance().addOrReplaceToast({ + key: toastKey(device.deviceId), + title: _t("Unverified session"), + icon: "verification_warning", + props: { device }, + component: sdk.getComponent("toasts.UnverifiedSessionToast"), + }); + newActiveToasts.add(device.deviceId); + } + } + + // clear any other outstanding toasts (eg. logged out devices) + for (const deviceId of this._activeNagToasts) { + if (!newActiveToasts.has(deviceId)) ToastStore.sharedInstance().dismissToast(toastKey(deviceId)); + } + this._activeNagToasts = newActiveToasts; + } +} diff --git a/src/Entities.js b/src/Entities.js deleted file mode 100644 index 8be1da0db8..0000000000 --- a/src/Entities.js +++ /dev/null @@ -1,140 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import sdk from './index'; - -function isMatch(query, name, uid) { - query = query.toLowerCase(); - name = name.toLowerCase(); - uid = uid.toLowerCase(); - - // direct prefix matches - if (name.indexOf(query) === 0 || uid.indexOf(query) === 0) { - return true; - } - - // strip @ on uid and try matching again - if (uid.length > 1 && uid[0] === "@" && uid.substring(1).indexOf(query) === 0) { - return true; - } - - // split spaces in name and try matching constituent parts - const parts = name.split(" "); - for (let i = 0; i < parts.length; i++) { - if (parts[i].indexOf(query) === 0) { - return true; - } - } - return false; -} - -/* - * Converts various data models to Entity objects. - * - * Entity objects provide an interface for UI components to use to display - * members in a data-agnostic way. This means they don't need to care if the - * underlying data model is a RoomMember, User or 3PID data structure, it just - * cares about rendering. - */ - -class Entity { - constructor(model) { - this.model = model; - } - - getJsx() { - return null; - } - - matches(queryString) { - return false; - } -} - -class MemberEntity extends Entity { - getJsx() { - const MemberTile = sdk.getComponent("rooms.MemberTile"); - return ( - - ); - } - - matches(queryString) { - return isMatch(queryString, this.model.name, this.model.userId); - } -} - -class UserEntity extends Entity { - constructor(model, showInviteButton, inviteFn) { - super(model); - this.showInviteButton = Boolean(showInviteButton); - this.inviteFn = inviteFn; - this.onClick = this.onClick.bind(this); - } - - onClick() { - if (this.inviteFn) { - this.inviteFn(this.model.userId); - } - } - - getJsx() { - const UserTile = sdk.getComponent("rooms.UserTile"); - return ( - - ); - } - - matches(queryString) { - const name = this.model.displayName || this.model.userId; - return isMatch(queryString, name, this.model.userId); - } -} - - -module.exports = { - newEntity: function(jsx, matchFn) { - const entity = new Entity(); - entity.getJsx = function() { - return jsx; - }; - entity.matches = matchFn; - return entity; - }, - - /** - * @param {RoomMember[]} members - * @return {Entity[]} - */ - fromRoomMembers: function(members) { - return members.map(function(m) { - return new MemberEntity(m); - }); - }, - - /** - * @param {User[]} users - * @param {boolean} showInviteButton - * @param {Function} inviteFn Called with the user ID. - * @return {Entity[]} - */ - fromUsers: function(users, showInviteButton, inviteFn) { - return users.map(function(u) { - return new UserEntity(u, showInviteButton, inviteFn); - }); - }, -}; diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index 8915c1412f..64caba0fdf 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -20,7 +20,7 @@ import URL from 'url'; import dis from './dispatcher'; import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; -import MatrixClientPeg from "./MatrixClientPeg"; +import {MatrixClientPeg} from "./MatrixClientPeg"; import RoomViewStore from "./stores/RoomViewStore"; import {IntegrationManagers} from "./integrations/IntegrationManagers"; import SettingsStore from "./settings/SettingsStore"; diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index 7da37b6df1..9131a89e5d 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -16,11 +16,12 @@ limitations under the License. import React from 'react'; import Modal from './Modal'; -import sdk from './'; +import * as sdk from './'; import MultiInviter from './utils/MultiInviter'; import { _t } from './languageHandler'; -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; import GroupStore from './stores/GroupStore'; +import {allSettled} from "./utils/promise"; export function showGroupInviteDialog(groupId) { return new Promise((resolve, reject) => { @@ -118,7 +119,7 @@ function _onGroupInviteFinished(groupId, addrs) { function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { const matrixClient = MatrixClientPeg.get(); const errorList = []; - return Promise.all(addrs.map((addr) => { + return allSettled(addrs.map((addr) => { return GroupStore .addRoomToGroup(groupId, addr.address, addRoomsPublicly) .catch(() => { errorList.push(addr.address); }) @@ -138,7 +139,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { groups.push(groupId); return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, ''); } - }).reflect(); + }); })).then(() => { if (errorList.length === 0) { return; diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 2266522bfe..a58ea25c8a 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -23,18 +23,17 @@ import ReplyThread from "./components/views/elements/ReplyThread"; import React from 'react'; import sanitizeHtml from 'sanitize-html'; -import highlight from 'highlight.js'; import * as linkify from 'linkifyjs'; import linkifyMatrix from './linkify-matrix'; import _linkifyElement from 'linkifyjs/element'; import _linkifyString from 'linkifyjs/string'; import classNames from 'classnames'; -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; import url from 'url'; -import EMOJIBASE from 'emojibase-data/en/compact.json'; import EMOJIBASE_REGEX from 'emojibase-regex'; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; +import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; linkifyMatrix(linkify); @@ -53,14 +52,11 @@ const ZWJ_REGEX = new RegExp("\u200D|\u2003", "g"); const WHITESPACE_REGEX = new RegExp("\\s", "g"); const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); -const SINGLE_EMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})$`, 'i'); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; -const VARIATION_SELECTOR = String.fromCharCode(0xFE0F); - /* * Return true if the given string contains emoji * Uses a much, much simpler regex than emojibase's so will give false @@ -79,10 +75,7 @@ function mightContainEmoji(str) { * @return {String} The shortcode (such as :thumbup:) */ export function unicodeToShortcode(char) { - // Check against both the char and the char with an empty variation selector appended because that's how - // emoji-base stores its base emojis which have variations. https://github.com/vector-im/riot-web/issues/9785 - const emptyVariation = char + VARIATION_SELECTOR; - const data = EMOJIBASE.find(e => e.unicode === char || e.unicode === emptyVariation); + const data = getEmojiFromUnicode(char); return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); } @@ -94,7 +87,7 @@ export function unicodeToShortcode(char) { */ export function shortcodeToUnicode(shortcode) { shortcode = shortcode.slice(1, shortcode.length - 1); - const data = EMOJIBASE.find(e => e.shortcodes && e.shortcodes.includes(shortcode)); + const data = SHORTCODE_TO_EMOJI.get(shortcode); return data ? data.unicode : null; } @@ -166,7 +159,7 @@ const transformTags = { // custom to matrix delete attribs.target; } } - attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ + attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/ return { tagName, attribs }; }, 'img': function(tagName, attribs) { @@ -383,6 +376,7 @@ class TextHighlighter extends BaseHighlighter { * opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing * opts.returnString: return an HTML string rather than JSX elements * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer + * opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString) */ export function bodyToHtml(content, highlights, opts={}) { const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; @@ -465,18 +459,19 @@ export function bodyToHtml(content, highlights, opts={}) { }); return isDisplayedWithHtml ? - : - { strippedBody }; + : + { strippedBody }; } /** * Linkifies the given string. This is a wrapper around 'linkifyjs/string'. * - * @param {string} str - * @returns {string} + * @param {string} str string to linkify + * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options + * @returns {string} Linkified string */ -export function linkifyString(str) { - return _linkifyString(str); +export function linkifyString(str, options = linkifyMatrix.options) { + return _linkifyString(str, options); } /** @@ -494,10 +489,11 @@ export function linkifyElement(element, options = linkifyMatrix.options) { * Linkify the given string and sanitize the HTML afterwards. * * @param {string} dirtyHtml The HTML string to sanitize and linkify + * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @returns {string} */ -export function linkifyAndSanitizeHtml(dirtyHtml) { - return sanitizeHtml(linkifyString(dirtyHtml), sanitizeHtmlParams); +export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) { + return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } /** diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index 7cbad074bf..72432b9a44 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -16,8 +16,19 @@ limitations under the License. import { createClient, SERVICE_TYPES } from 'matrix-js-sdk'; -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; +import Modal from './Modal'; +import * as sdk from './index'; +import { _t } from './languageHandler'; import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; +import { + doesAccountDataHaveIdentityServer, + doesIdentityServerHaveTerms, + useDefaultIdentityServer, +} from './utils/IdentityServerUtils'; +import { abbreviateUrl } from './utils/UrlUtils'; + +export class AbortedIdentityActionError extends Error {} export default class IdentityAuthClient { /** @@ -89,7 +100,10 @@ export default class IdentityAuthClient { try { await this._checkToken(token); } catch (e) { - if (e instanceof TermsNotSignedError) { + if ( + e instanceof TermsNotSignedError || + e instanceof AbortedIdentityActionError + ) { // Retrying won't help this throw e; } @@ -106,6 +120,8 @@ export default class IdentityAuthClient { } async _checkToken(token) { + const identityServerUrl = this._matrixClient.getIdentityServerUrl(); + try { await this._matrixClient.getIdentityAccount(token); } catch (e) { @@ -113,7 +129,7 @@ export default class IdentityAuthClient { console.log("Identity Server requires new terms to be agreed to"); await startTermsFlow([new Service( SERVICE_TYPES.IS, - this._matrixClient.getIdentityServerUrl(), + identityServerUrl, token, )]); return; @@ -121,6 +137,42 @@ export default class IdentityAuthClient { throw e; } + if ( + !this.tempClient && + !doesAccountDataHaveIdentityServer() && + !await doesIdentityServerHaveTerms(identityServerUrl) + ) { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '', + QuestionDialog, { + title: _t("Identity server has no terms of service"), + description: ( +
+

{_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)}, + }, + )}

+

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

+
+ ), + button: _t("Trust"), + }); + const [confirmed] = await finished; + if (confirmed) { + useDefaultIdentityServer(); + } else { + throw new AbortedIdentityActionError( + "User aborted identity server action without terms", + ); + } + } + // We should ensure the token in `localStorage` is cleared // appropriately. We already clear storage on sign out, but we'll need // additional clearing when changing ISes in settings as part of future @@ -131,8 +183,10 @@ export default class IdentityAuthClient { async registerForToken(check=true) { try { const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken(); - const { access_token: identityAccessToken } = + // XXX: The spec is `token`, but we used `access_token` for a Sydent release. + const { access_token: accessToken, token } = await this._matrixClient.registerWithIdentityServer(hsOpenIdToken); + const identityAccessToken = token ? token : accessToken; if (check) await this._checkToken(identityAccessToken); return identityAccessToken; } catch (e) { diff --git a/src/ImageUtils.js b/src/ImageUtils.js index a83d94a633..c0f7b94b81 100644 --- a/src/ImageUtils.js +++ b/src/ImageUtils.js @@ -16,41 +16,38 @@ limitations under the License. 'use strict'; -module.exports = { - - /** - * Returns the actual height that an image of dimensions (fullWidth, fullHeight) - * will occupy if resized to fit inside a thumbnail bounding box of size - * (thumbWidth, thumbHeight). - * - * If the aspect ratio of the source image is taller than the aspect ratio of - * the thumbnail bounding box, then we return the thumbHeight parameter unchanged. - * Otherwise we return the thumbHeight parameter scaled down appropriately to - * reflect the actual height the scaled thumbnail occupies. - * - * This is very useful for calculating how much height a thumbnail will actually - * consume in the timeline, when performing scroll offset calcuations - * (e.g. scroll locking) - */ - thumbHeight: function(fullWidth, fullHeight, thumbWidth, thumbHeight) { - if (!fullWidth || !fullHeight) { - // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even - // log this because it's spammy - return undefined; - } - if (fullWidth < thumbWidth && fullHeight < thumbHeight) { - // no scaling needs to be applied - return fullHeight; - } - const widthMulti = thumbWidth / fullWidth; - const heightMulti = thumbHeight / fullHeight; - if (widthMulti < heightMulti) { - // width is the dominant dimension so scaling will be fixed on that - return Math.floor(widthMulti * fullHeight); - } else { - // height is the dominant dimension so scaling will be fixed on that - return Math.floor(heightMulti * fullHeight); - } - }, -}; +/** + * Returns the actual height that an image of dimensions (fullWidth, fullHeight) + * will occupy if resized to fit inside a thumbnail bounding box of size + * (thumbWidth, thumbHeight). + * + * If the aspect ratio of the source image is taller than the aspect ratio of + * the thumbnail bounding box, then we return the thumbHeight parameter unchanged. + * Otherwise we return the thumbHeight parameter scaled down appropriately to + * reflect the actual height the scaled thumbnail occupies. + * + * This is very useful for calculating how much height a thumbnail will actually + * consume in the timeline, when performing scroll offset calcuations + * (e.g. scroll locking) + */ +export function thumbHeight(fullWidth, fullHeight, thumbWidth, thumbHeight) { + if (!fullWidth || !fullHeight) { + // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even + // log this because it's spammy + return undefined; + } + if (fullWidth < thumbWidth && fullHeight < thumbHeight) { + // no scaling needs to be applied + return fullHeight; + } + const widthMulti = thumbWidth / fullWidth; + const heightMulti = thumbHeight / fullHeight; + if (widthMulti < heightMulti) { + // width is the dominant dimension so scaling will be fixed on that + return Math.floor(widthMulti * fullHeight); + } else { + // height is the dominant dimension so scaling will be fixed on that + return Math.floor(heightMulti * fullHeight); + } +} diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js index c3de7988b2..30f3b7d50e 100644 --- a/src/KeyRequestHandler.js +++ b/src/KeyRequestHandler.js @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,9 +15,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import sdk from './index'; +import * as sdk from './index'; import Modal from './Modal'; +import SettingsStore from './settings/SettingsStore'; +// TODO: We can remove this once cross-signing is the only way. +// https://github.com/vector-im/riot-web/issues/11908 export default class KeyRequestHandler { constructor(matrixClient) { this._matrixClient = matrixClient; @@ -30,6 +34,11 @@ export default class KeyRequestHandler { } handleKeyRequest(keyRequest) { + // Ignore own device key requests if cross-signing lab enabled + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + return; + } + const userId = keyRequest.userId; const deviceId = keyRequest.deviceId; const requestId = keyRequest.requestId; @@ -60,6 +69,11 @@ export default class KeyRequestHandler { } handleKeyRequestCancellation(cancellation) { + // Ignore own device key requests if cross-signing lab enabled + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + return; + } + // see if we can find the request in the queue const userId = cancellation.userId; const deviceId = cancellation.deviceId; @@ -111,6 +125,12 @@ export default class KeyRequestHandler { this._currentUser = null; this._currentDevice = null; + if (!this._pendingKeyRequests[userId] || !this._pendingKeyRequests[userId][deviceId]) { + // request was removed in the time the dialog was displayed + this._processNextRequest(); + return; + } + if (r) { for (const req of this._pendingKeyRequests[userId][deviceId]) { req.share(); diff --git a/src/Keyboard.js b/src/Keyboard.js index 738da478e4..478d75acc1 100644 --- a/src/Keyboard.js +++ b/src/Keyboard.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,52 +16,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* a selection of key codes, as used in KeyboardEvent.keyCode */ -export const KeyCode = { - BACKSPACE: 8, - TAB: 9, - ENTER: 13, - SHIFT: 16, - ESCAPE: 27, - SPACE: 32, - PAGE_UP: 33, - PAGE_DOWN: 34, - END: 35, - HOME: 36, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - DELETE: 46, - KEY_A: 65, - KEY_B: 66, - KEY_C: 67, - KEY_D: 68, - KEY_E: 69, - KEY_F: 70, - KEY_G: 71, - KEY_H: 72, - KEY_I: 73, - KEY_J: 74, - KEY_K: 75, - KEY_L: 76, - KEY_M: 77, - KEY_N: 78, - KEY_O: 79, - KEY_P: 80, - KEY_Q: 81, - KEY_R: 82, - KEY_S: 83, - KEY_T: 84, - KEY_U: 85, - KEY_V: 86, - KEY_W: 87, - KEY_X: 88, - KEY_Y: 89, - KEY_Z: 90, - KEY_BACKTICK: 223, // DO NOT USE THIS: browsers disagree on backtick 192 vs 223 -}; - export const Key = { HOME: "Home", END: "End", @@ -69,6 +24,8 @@ export const Key = { BACKSPACE: "Backspace", ARROW_UP: "ArrowUp", ARROW_DOWN: "ArrowDown", + ARROW_LEFT: "ArrowLeft", + ARROW_RIGHT: "ArrowRight", TAB: "Tab", ESCAPE: "Escape", ENTER: "Enter", @@ -76,14 +33,37 @@ export const Key = { CONTROL: "Control", META: "Meta", SHIFT: "Shift", + CONTEXT_MENU: "ContextMenu", + COMMA: ",", LESS_THAN: "<", GREATER_THAN: ">", BACKTICK: "`", SPACE: " ", + A: "a", B: "b", + C: "c", + D: "d", + E: "e", + F: "f", + G: "g", + H: "h", I: "i", + J: "j", K: "k", + L: "l", + M: "m", + N: "n", + O: "o", + P: "p", + Q: "q", + R: "r", + S: "s", + T: "t", + U: "u", + V: "v", + W: "w", + X: "x", Y: "y", Z: "z", }; diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 7490c5d464..72cd84bfd9 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,10 +17,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Promise from 'bluebird'; import Matrix from 'matrix-js-sdk'; -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; +import EventIndexPeg from './indexing/EventIndexPeg'; import createMatrixClient from './utils/createMatrixClient'; import Analytics from './Analytics'; import Notifier from './Notifier'; @@ -28,14 +29,17 @@ import Presence from './Presence'; import dis from './dispatcher'; import DMRoomMap from './utils/DMRoomMap'; import Modal from './Modal'; -import sdk from './index'; +import * as sdk from './index'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; import PlatformPeg from "./PlatformPeg"; import { sendLoginRequest } from "./Login"; import * as StorageManager from './utils/StorageManager'; import SettingsStore from "./settings/SettingsStore"; import TypingStore from "./stores/TypingStore"; +import ToastStore from "./stores/ToastStore"; import {IntegrationManagers} from "./integrations/IntegrationManagers"; +import {Mjolnir} from "./mjolnir/Mjolnir"; +import DeviceListener from "./DeviceListener"; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -312,18 +316,14 @@ async function _restoreFromLocalStorage(opts) { function _handleLoadSessionFailure(e) { console.error("Unable to load session", e); - const def = Promise.defer(); const SessionRestoreErrorDialog = sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); - Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, { + const modal = Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, { error: e.message, - onFinished: (success) => { - def.resolve(success); - }, }); - return def.promise.then((success) => { + return modal.finished.then(([success]) => { if (success) { // user clicked continue. _clearStorage(); @@ -378,7 +378,7 @@ export function hydrateSession(credentials) { const overwrite = credentials.userId !== oldUserId || credentials.deviceId !== oldDeviceId; if (overwrite) { - console.warn("Clearing all data: Old session belongs to a different user/device"); + console.warn("Clearing all data: Old session belongs to a different user/session"); } return _doSetLoggedIn(credentials, overwrite); @@ -435,7 +435,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { } } - Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl, credentials.identityServerUrl); + Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); if (localStorage) { try { @@ -528,7 +528,7 @@ export function logout() { console.log("Failed to call logout API: token will not be invalidated"); onLoggedOut(); }, - ).done(); + ); } export function softLogout() { @@ -578,6 +578,7 @@ async function startMatrixClient(startSyncing=true) { Notifier.start(); UserActivity.sharedInstance().start(); TypingStore.sharedInstance().reset(); // just in case + ToastStore.sharedInstance().reset(); if (!SettingsStore.getValue("lowBandwidth")) { Presence.start(); } @@ -585,13 +586,25 @@ async function startMatrixClient(startSyncing=true) { IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.start(); + // Start Mjolnir even though we haven't checked the feature flag yet. Starting + // the thing just wastes CPU cycles, but should result in no actual functionality + // being exposed to the user. + Mjolnir.sharedInstance().start(); + if (startSyncing) { + // The client might want to populate some views with events from the + // index (e.g. the FilePanel), therefore initialize the event index + // before the client. + await EventIndexPeg.init(); await MatrixClientPeg.start(); } else { console.warn("Caller requested only auxiliary services be started"); await MatrixClientPeg.assign(); } + // This needs to be started after crypto is set up + DeviceListener.sharedInstance().start(); + // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. dis.dispatch({action: 'client_started'}); @@ -605,21 +618,21 @@ async function startMatrixClient(startSyncing=true) { * Stops a running client and all related services, and clears persistent * storage. Used after a session has been logged out. */ -export function onLoggedOut() { +export async function onLoggedOut() { _isLoggingOut = false; // Ensure that we dispatch a view change **before** stopping the client so // so that React components unmount first. This avoids React soft crashes // that can occur when components try to use a null client. - dis.dispatch({action: 'on_logged_out'}); + dis.dispatch({action: 'on_logged_out'}, true); stopMatrixClient(); - _clearStorage().done(); + await _clearStorage(); } /** * @returns {Promise} promise which resolves once the stores have been cleared */ -function _clearStorage() { - Analytics.logout(); +async function _clearStorage() { + Analytics.disable(); if (window.localStorage) { window.localStorage.clear(); @@ -630,7 +643,9 @@ function _clearStorage() { // we'll never make any requests, so can pass a bogus HS URL baseUrl: "", }); - return cli.clearStores(); + + await EventIndexPeg.deleteEventIndex(); + await cli.clearStores(); } /** @@ -645,7 +660,10 @@ export function stopMatrixClient(unsetClient=true) { Presence.stop(); ActiveWidgetStore.stop(); IntegrationManagers.sharedInstance().stopWatching(); + Mjolnir.sharedInstance().stop(); + DeviceListener.sharedInstance().stop(); if (DMRoomMap.shared()) DMRoomMap.shared().stop(); + EventIndexPeg.stop(); const cli = MatrixClientPeg.get(); if (cli) { cli.stopClient(); @@ -653,6 +671,7 @@ export function stopMatrixClient(unsetClient=true) { if (unsetClient) { MatrixClientPeg.unset(); + EventIndexPeg.unset(); } } } diff --git a/src/Login.js b/src/Login.js index d9ce8adaaa..1590e5ac28 100644 --- a/src/Login.js +++ b/src/Login.js @@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,8 +20,6 @@ limitations under the License. import Matrix from "matrix-js-sdk"; -import url from 'url'; - export default class Login { constructor(hsUrl, isUrl, fallbackHsUrl, opts) { this._hsUrl = hsUrl; @@ -29,6 +28,7 @@ export default class Login { this._currentFlowIndex = 0; this._flows = []; this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; + this._tempClient = null; // memoize } getHomeserverUrl() { @@ -40,10 +40,12 @@ export default class Login { } setHomeserverUrl(hsUrl) { + this._tempClient = null; // clear memoization this._hsUrl = hsUrl; } setIdentityServerUrl(isUrl) { + this._tempClient = null; // clear memoization this._isUrl = isUrl; } @@ -52,8 +54,9 @@ export default class Login { * requests. * @returns {MatrixClient} */ - _createTemporaryClient() { - return Matrix.createClient({ + createTemporaryClient() { + if (this._tempClient) return this._tempClient; // use memoization + return this._tempClient = Matrix.createClient({ baseUrl: this._hsUrl, idBaseUrl: this._isUrl, }); @@ -61,7 +64,7 @@ export default class Login { getFlows() { const self = this; - const client = this._createTemporaryClient(); + const client = this.createTemporaryClient(); return client.loginFlows().then(function(result) { self._flows = result.flows; self._currentFlowIndex = 0; @@ -139,21 +142,6 @@ export default class Login { throw error; }); } - - getSsoLoginUrl(loginType) { - const client = this._createTemporaryClient(); - const parsedUrl = url.parse(window.location.href, true); - - // XXX: at this point, the fragment will always be #/login, which is no - // use to anyone. Ideally, we would get the intended fragment from - // MatrixChat.screenAfterLogin so that you could follow #/room links etc - // through an SSO login. - parsedUrl.hash = ""; - - parsedUrl.query["homeserver"] = client.getHomeserverUrl(); - parsedUrl.query["identityServer"] = client.getIdentityServerUrl(); - return client.getSsoLoginUrl(url.format(parsedUrl), loginType); - } } diff --git a/src/Markdown.js b/src/Markdown.js index acfea52100..fb1f8bf0ea 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -91,7 +91,7 @@ export default class Markdown { return true; } - toHTML() { + toHTML({ externalLinks = false } = {}) { const renderer = new commonmark.HtmlRenderer({ safe: false, @@ -125,6 +125,24 @@ export default class Markdown { } }; + renderer.link = function(node, entering) { + const attrs = this.attrs(node); + if (entering) { + attrs.push(['href', this.esc(node.destination)]); + if (node.title) { + attrs.push(['title', this.esc(node.title)]); + } + // Modified link behaviour to treat them all as external and + // thus opening in a new tab. + if (externalLinks) { + attrs.push(['target', '_blank']); + attrs.push(['rel', 'noreferrer noopener']); + } + this.tag('a', attrs); + } else { + this.tag('/a'); + } + }; renderer.html_inline = html_if_tag_allowed; diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index bebb254afc..98fcc85d60 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -1,7 +1,8 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd. -Copyright 2017 New Vector Ltd +Copyright 2017, 2018, 2019 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,18 +19,20 @@ limitations under the License. import {MatrixClient, MemoryStore} from 'matrix-js-sdk'; -import utils from 'matrix-js-sdk/lib/utils'; -import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline'; -import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set'; -import sdk from './index'; +import * as utils from 'matrix-js-sdk/src/utils'; +import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline'; +import {EventTimelineSet} from 'matrix-js-sdk/src/models/event-timeline-set'; +import * as sdk from './index'; import createMatrixClient from './utils/createMatrixClient'; import SettingsStore from './settings/SettingsStore'; import MatrixActionCreators from './actions/MatrixActionCreators'; import Modal from './Modal'; -import {verificationMethods} from 'matrix-js-sdk/lib/crypto'; +import {verificationMethods} from 'matrix-js-sdk/src/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; +import { crossSigningCallbacks } from './CrossSigningManager'; +import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; interface MatrixClientCreds { homeserverUrl: string, @@ -46,7 +49,7 @@ interface MatrixClientCreds { * This module provides a singleton instance of this class so the 'current' * Matrix Client object is available easily. */ -class MatrixClientPeg { +class _MatrixClientPeg { constructor() { this.matrixClient = null; this._justRegisteredUserId = null; @@ -215,11 +218,21 @@ class MatrixClientPeg { timelineSupport: true, forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false), fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'), - verificationMethods: [verificationMethods.SAS], + verificationMethods: [ + verificationMethods.SAS, + SHOW_QR_CODE_METHOD, + verificationMethods.RECIPROCATE_QR_CODE, + ], unstableClientRelationAggregation: true, identityServer: new IdentityAuthClient(), }; + opts.cryptoCallbacks = {}; + // These are always installed regardless of the labs flag so that + // cross-signing features can toggle on without reloading and also be + // accessed immediately after login. + Object.assign(opts.cryptoCallbacks, crossSigningCallbacks); + this.matrixClient = createMatrixClient(opts); // we're going to add eventlisteners for each matrix event tile, so the @@ -238,6 +251,7 @@ class MatrixClientPeg { } if (!global.mxMatrixClientPeg) { - global.mxMatrixClientPeg = new MatrixClientPeg(); + global.mxMatrixClientPeg = new _MatrixClientPeg(); } -export default global.mxMatrixClientPeg; + +export const MatrixClientPeg = global.mxMatrixClientPeg; diff --git a/src/Modal.js b/src/Modal.js index 26c9da8bbb..de441740f1 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -17,87 +17,14 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import Analytics from './Analytics'; -import sdk from './index'; import dis from './dispatcher'; -import { _t } from './languageHandler'; -import Promise from "bluebird"; +import {defer} from './utils/promise'; +import AsyncWrapper from './AsyncWrapper'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; -/** - * Wrap an asynchronous loader function with a react component which shows a - * spinner until the real component loads. - */ -const AsyncWrapper = createReactClass({ - propTypes: { - /** A promise which resolves with the real component - */ - prom: PropTypes.object.isRequired, - }, - - getInitialState: function() { - return { - component: null, - error: null, - }; - }, - - componentWillMount: function() { - this._unmounted = false; - // XXX: temporary logging to try to diagnose - // https://github.com/vector-im/riot-web/issues/3148 - console.log('Starting load of AsyncWrapper for modal'); - this.props.prom.then((result) => { - if (this._unmounted) { - return; - } - // Take the 'default' member if it's there, then we support - // passing in just an import()ed module, since ES6 async import - // always returns a module *namespace*. - const component = result.default ? result.default : result; - this.setState({component}); - }).catch((e) => { - console.warn('AsyncWrapper promise failed', e); - this.setState({error: e}); - }); - }, - - componentWillUnmount: function() { - this._unmounted = true; - }, - - _onWrapperCancelClick: function() { - this.props.onFinished(false); - }, - - render: function() { - if (this.state.component) { - const Component = this.state.component; - return ; - } else if (this.state.error) { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return - {_t("Unable to load! Check your network connectivity and try again.")} - - ; - } else { - // show a spinner until the component is loaded. - const Spinner = sdk.getComponent("elements.Spinner"); - return ; - } - }, -}); - class ModalManager { constructor() { this._counter = 0; @@ -120,7 +47,7 @@ class ModalManager { } */ ]; - this.closeAll = this.closeAll.bind(this); + this.onBackgroundClick = this.onBackgroundClick.bind(this); } hasDialogs() { @@ -179,7 +106,7 @@ class ModalManager { return this.appendDialogAsync(...rest); } - _buildModal(prom, props, className) { + _buildModal(prom, props, className, options) { const modal = {}; // never call this from onFinished() otherwise it will loop @@ -197,13 +124,27 @@ class ModalManager { ); modal.onFinished = props ? props.onFinished : null; modal.className = className; + modal.onBeforeClose = options.onBeforeClose; + modal.beforeClosePromise = null; + modal.close = closeDialog; + modal.closeReason = null; return {modal, closeDialog, onFinishedProm}; } _getCloseFn(modal, props) { - const deferred = Promise.defer(); - return [(...args) => { + const deferred = defer(); + return [async (...args) => { + if (modal.beforeClosePromise) { + await modal.beforeClosePromise; + } else if (modal.onBeforeClose) { + modal.beforeClosePromise = modal.onBeforeClose(modal.closeReason); + const shouldClose = await modal.beforeClosePromise; + modal.beforeClosePromise = null; + if (!shouldClose) { + return; + } + } deferred.resolve(args); if (props && props.onFinished) props.onFinished.apply(null, args); const i = this._modals.indexOf(modal); @@ -229,6 +170,12 @@ class ModalManager { }, deferred.promise]; } + /** + * @callback onBeforeClose + * @param {string?} reason either "backgroundClick" or null + * @return {Promise} whether the dialog should close + */ + /** * Open a modal view. * @@ -256,11 +203,12 @@ class ModalManager { * also be removed from the stack. This is not compatible * with being a priority modal. Only one modal can be * static at a time. + * @param {Object} options? extra options for the dialog + * @param {onBeforeClose} options.onBeforeClose a callback to decide whether to close the dialog * @returns {object} Object with 'close' parameter being a function that will close the dialog */ - createDialogAsync(prom, props, className, isPriorityModal, isStaticModal) { - const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className); - + createDialogAsync(prom, props, className, isPriorityModal, isStaticModal, options = {}) { + const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className, options); if (isPriorityModal) { // XXX: This is destructive this._priorityModal = modal; @@ -279,7 +227,7 @@ class ModalManager { } appendDialogAsync(prom, props, className) { - const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className); + const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className, {}); this._modals.push(modal); this._reRender(); @@ -289,24 +237,22 @@ class ModalManager { }; } - closeAll() { - const modalsToClose = [...this._modals, this._priorityModal]; - this._modals = []; - this._priorityModal = null; - - if (this._staticModal && modalsToClose.length === 0) { - modalsToClose.push(this._staticModal); - this._staticModal = null; + onBackgroundClick() { + const modal = this._getCurrentModal(); + if (!modal) { + return; } + // we want to pass a reason to the onBeforeClose + // callback, but close is currently defined to + // pass all number of arguments to the onFinished callback + // so, pass the reason to close through a member variable + modal.closeReason = "backgroundClick"; + modal.close(); + modal.closeReason = null; + } - for (let i = 0; i < modalsToClose.length; i++) { - const m = modalsToClose[i]; - if (m && m.onFinished) { - m.onFinished(false); - } - } - - this._reRender(); + _getCurrentModal() { + return this._priorityModal ? this._priorityModal : (this._modals[0] || this._staticModal); } _reRender() { @@ -337,7 +283,7 @@ class ModalManager {
{ this._staticModal.elem }
-
+
); @@ -347,8 +293,8 @@ class ModalManager { ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer()); } - const modal = this._priorityModal ? this._priorityModal : this._modals[0]; - if (modal) { + const modal = this._getCurrentModal(); + if (modal !== this._staticModal) { const classes = "mx_Dialog_wrapper " + (this._staticModal ? "mx_Dialog_wrapperWithStaticUnder " : '') + (modal.className ? modal.className : ''); @@ -358,7 +304,7 @@ class ModalManager {
{modal.elem}
-
+
); diff --git a/src/Notifier.js b/src/Notifier.js index cca0ea2b89..36a6f13bb6 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -16,13 +16,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; import PlatformPeg from './PlatformPeg'; -import TextForEvent from './TextForEvent'; +import * as TextForEvent from './TextForEvent'; import Analytics from './Analytics'; -import Avatar from './Avatar'; +import * as Avatar from './Avatar'; import dis from './dispatcher'; -import sdk from './index'; +import * as sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; @@ -146,17 +146,19 @@ const Notifier = { } document.body.appendChild(audioElement); } - audioElement.play(); + await audioElement.play(); } catch (ex) { console.warn("Caught error when trying to fetch room notification sound:", ex); } }, start: function() { - this.boundOnEvent = this.onEvent.bind(this); - this.boundOnSyncStateChange = this.onSyncStateChange.bind(this); - this.boundOnRoomReceipt = this.onRoomReceipt.bind(this); - this.boundOnEventDecrypted = this.onEventDecrypted.bind(this); + // do not re-bind in the case of repeated call + this.boundOnEvent = this.boundOnEvent || this.onEvent.bind(this); + this.boundOnSyncStateChange = this.boundOnSyncStateChange || this.onSyncStateChange.bind(this); + this.boundOnRoomReceipt = this.boundOnRoomReceipt || this.onRoomReceipt.bind(this); + this.boundOnEventDecrypted = this.boundOnEventDecrypted || this.onEventDecrypted.bind(this); + MatrixClientPeg.get().on('event', this.boundOnEvent); MatrixClientPeg.get().on('Room.receipt', this.boundOnRoomReceipt); MatrixClientPeg.get().on('Event.decrypted', this.boundOnEventDecrypted); @@ -166,7 +168,7 @@ const Notifier = { }, stop: function() { - if (MatrixClientPeg.get() && this.boundOnRoomTimeline) { + if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener('Event', this.boundOnEvent); MatrixClientPeg.get().removeListener('Room.receipt', this.boundOnRoomReceipt); MatrixClientPeg.get().removeListener('Event.decrypted', this.boundOnEventDecrypted); @@ -198,7 +200,7 @@ const Notifier = { if (enable) { // Attempt to get permission from user - plaf.requestNotificationPermission().done((result) => { + plaf.requestNotificationPermission().then((result) => { if (result !== 'granted') { // The permission request was dismissed or denied // TODO: Support alternative branding in messaging @@ -364,4 +366,4 @@ if (!global.mxNotifier) { global.mxNotifier = Notifier; } -module.exports = global.mxNotifier; +export default global.mxNotifier; diff --git a/src/ObjectUtils.js b/src/ObjectUtils.js index 07d8b465af..24dfe61d68 100644 --- a/src/ObjectUtils.js +++ b/src/ObjectUtils.js @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,7 +23,7 @@ limitations under the License. * @return {Object[]} An array of objects with the form: * { key: $KEY, val: $VALUE, place: "add|del" } */ -module.exports.getKeyValueArrayDiffs = function(before, after) { +export function getKeyValueArrayDiffs(before, after) { const results = []; const delta = {}; Object.keys(before).forEach(function(beforeKey) { @@ -76,7 +77,7 @@ module.exports.getKeyValueArrayDiffs = function(before, after) { }); return results; -}; +} /** * Shallow-compare two objects for equality: each key and value must be identical @@ -84,7 +85,7 @@ module.exports.getKeyValueArrayDiffs = function(before, after) { * @param {Object} objB Second object to compare against the first * @return {boolean} whether the two objects have same key=values */ -module.exports.shallowEqual = function(objA, objB) { +export function shallowEqual(objA, objB) { if (objA === objB) { return true; } @@ -109,4 +110,4 @@ module.exports.shallowEqual = function(objA, objB) { } return true; -}; +} diff --git a/src/PasswordReset.js b/src/PasswordReset.js index 31339eb4e5..320599f6d9 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -25,7 +25,7 @@ import { _t } from './languageHandler'; * the client owns the given email address, which is then passed to the password * API on the homeserver in question with the new password. */ -class PasswordReset { +export default class PasswordReset { /** * Configure the endpoints for password resetting. * @param {string} homeserverUrl The URL to the HS which has the account to reset. @@ -101,4 +101,3 @@ class PasswordReset { } } -module.exports = PasswordReset; diff --git a/src/PlatformPeg.js b/src/PlatformPeg.js index 5c1112e23b..34131fde7d 100644 --- a/src/PlatformPeg.js +++ b/src/PlatformPeg.js @@ -47,4 +47,4 @@ class PlatformPeg { if (!global.mxPlatformPeg) { global.mxPlatformPeg = new PlatformPeg(); } -module.exports = global.mxPlatformPeg; +export default global.mxPlatformPeg; diff --git a/src/Presence.js b/src/Presence.js index ca3db9b762..2fc13a090b 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixClientPeg from "./MatrixClientPeg"; +import {MatrixClientPeg} from "./MatrixClientPeg"; import dis from "./dispatcher"; import Timer from './utils/Timer'; @@ -96,7 +97,7 @@ class Presence { try { await MatrixClientPeg.get().setPresence(this.state); - console.log("Presence: %s", newState); + console.info("Presence: %s", newState); } catch (err) { console.error("Failed to set presence: %s", err); this.state = oldState; @@ -104,4 +105,4 @@ class Presence { } } -module.exports = new Presence(); +export default new Presence(); diff --git a/src/Registration.js b/src/Registration.js index 42e172ca0b..ca162bac03 100644 --- a/src/Registration.js +++ b/src/Registration.js @@ -21,10 +21,10 @@ limitations under the License. */ import dis from './dispatcher'; -import sdk from './index'; +import * as sdk from './index'; import Modal from './Modal'; import { _t } from './languageHandler'; -// import MatrixClientPeg from './MatrixClientPeg'; +// import {MatrixClientPeg} from './MatrixClientPeg'; // Regex for what a "safe" or "Matrix-looking" localpart would be. // TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514 @@ -39,6 +39,8 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/; * If true, goes to the home page if the user cancels the action * @param {bool} options.go_welcome_on_cancel * If true, goes to the welcome page if the user cancels the action + * @param {bool} options.screen_after + * If present the screen to redirect to after a successful login or register. */ export async function startAnyRegistrationFlow(options) { if (options === undefined) options = {}; @@ -66,13 +68,21 @@ export async function startAnyRegistrationFlow(options) { // }); //} else { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Registration required', '', QuestionDialog, { - title: _t("Registration Required"), - description: _t("You need to register to do this. Would you like to register now?"), - button: _t("Register"), + const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { + hasCancelButton: true, + quitOnly: true, + title: _t("Sign In or Create Account"), + description: _t("Use your account or create a new one to continue."), + button: _t("Create Account"), + extraButtons: [ + , + ], onFinished: (proceed) => { if (proceed) { - dis.dispatch({action: 'start_registration'}); + dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after}); } else if (options.go_home_on_cancel) { dis.dispatch({action: 'view_home_page'}); } else if (options.go_welcome_on_cancel) { @@ -101,4 +111,3 @@ export async function startAnyRegistrationFlow(options) { // } // throw new Error("Register request succeeded when it should have returned 401!"); // } - diff --git a/src/Resend.js b/src/Resend.js index 4eaee16d1b..6d6c18cf27 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,28 +15,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; import dis from './dispatcher'; import { EventStatus } from 'matrix-js-sdk'; -module.exports = { - resendUnsentEvents: function(room) { +export default class Resend { + static resendUnsentEvents(room) { room.getPendingEvents().filter(function(ev) { return ev.status === EventStatus.NOT_SENT; }).forEach(function(event) { - module.exports.resend(event); + Resend.resend(event); }); - }, - cancelUnsentEvents: function(room) { + } + + static cancelUnsentEvents(room) { room.getPendingEvents().filter(function(ev) { return ev.status === EventStatus.NOT_SENT; }).forEach(function(event) { - module.exports.removeFromQueue(event); + Resend.removeFromQueue(event); }); - }, - resend: function(event) { + } + + static resend(event) { const room = MatrixClientPeg.get().getRoom(event.getRoomId()); - MatrixClientPeg.get().resendEvent(event, room).done(function(res) { + MatrixClientPeg.get().resendEvent(event, room).then(function(res) { dis.dispatch({ action: 'message_sent', event: event, @@ -43,15 +46,16 @@ module.exports = { }, function(err) { // XXX: temporary logging to try to diagnose // https://github.com/vector-im/riot-web/issues/3148 - console.log('Resend got send failure: ' + err.name + '('+err+')'); + console.log('Resend got send failure: ' + err.name + '(' + err + ')'); dis.dispatch({ action: 'message_send_failed', event: event, }); }); - }, - removeFromQueue: function(event) { + } + + static removeFromQueue(event) { MatrixClientPeg.get().cancelPendingEvent(event); - }, -}; + } +} diff --git a/src/Roles.js b/src/Roles.js index 10c4ceaf1e..7cc3c880d7 100644 --- a/src/Roles.js +++ b/src/Roles.js @@ -28,8 +28,8 @@ export function levelRoleMap(usersDefault) { export function textualPowerLevel(level, usersDefault) { const LEVEL_ROLE_MAP = levelRoleMap(usersDefault); if (LEVEL_ROLE_MAP[level]) { - return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`); + return LEVEL_ROLE_MAP[level]; } else { - return level; + return _t("Custom (%(level)s)", {level}); } } diff --git a/src/RoomAliasCache.js b/src/RoomAliasCache.js new file mode 100644 index 0000000000..bb511ba4d7 --- /dev/null +++ b/src/RoomAliasCache.js @@ -0,0 +1,35 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * This is meant to be a cache of room alias to room ID so that moving between + * rooms happens smoothly (for example using browser back / forward buttons). + * + * For the moment, it's in memory only and so only applies for the current + * session for simplicity, but could be extended further in the future. + * + * A similar thing could also be achieved via `pushState` with a state object, + * but keeping it separate like this seems easier in case we do want to extend. + */ +const aliasToIDMap = new Map(); + +export function storeRoomAliasInCache(alias, id) { + aliasToIDMap.set(alias, id); +} + +export function getCachedRoomIDForAlias(alias) { + return aliasToIDMap.get(alias); +} diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 64aab36128..839d677069 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,15 +17,12 @@ limitations under the License. */ import React from 'react'; -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; import MultiInviter from './utils/MultiInviter'; import Modal from './Modal'; -import { getAddressType } from './UserAddress'; -import createRoom from './createRoom'; -import sdk from './'; -import dis from './dispatcher'; -import DMRoomMap from './utils/DMRoomMap'; +import * as sdk from './'; import { _t } from './languageHandler'; +import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; /** * Invites multiple addresses to a room @@ -35,50 +33,27 @@ import { _t } from './languageHandler'; * @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids. * @returns {Promise} Promise */ -function inviteMultipleToRoom(roomId, addrs) { +export function inviteMultipleToRoom(roomId, addrs) { const inviter = new MultiInviter(roomId); return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); } export function showStartChatInviteDialog() { - const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); - - Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, { - title: _t('Start a chat'), - description: _t("Who would you like to communicate with?"), - placeholder: (validAddressTypes) => { - // The set of valid address type can be mutated inside the dialog - // when you first have no IS but agree to use one in the dialog. - if (validAddressTypes.includes('email')) { - return _t("Email, name or Matrix ID"); - } - return _t("Name or Matrix ID"); - }, - validAddressTypes: ['mx-user-id', 'email'], - button: _t("Start Chat"), - onFinished: _onStartDmFinished, - }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); + // This dialog handles the room creation internally - we don't need to worry about it. + const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); + Modal.createTrackedDialog( + 'Start DM', '', InviteDialog, {kind: KIND_DM}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); } export function showRoomInviteDialog(roomId) { - const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); - - Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, { - title: _t('Invite new room members'), - button: _t('Send Invites'), - placeholder: (validAddressTypes) => { - // The set of valid address type can be mutated inside the dialog - // when you first have no IS but agree to use one in the dialog. - if (validAddressTypes.includes('email')) { - return _t("Email, name or Matrix ID"); - } - return _t("Name or Matrix ID"); - }, - validAddressTypes: ['mx-user-id', 'email'], - onFinished: (shouldInvite, addrs) => { - _onRoomInviteFinished(roomId, shouldInvite, addrs); - }, - }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); + // This dialog handles the room creation internally - we don't need to worry about it. + const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); + Modal.createTrackedDialog( + 'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); } /** @@ -99,67 +74,8 @@ export function isValid3pidInvite(event) { return true; } -// TODO: Immutable DMs replaces this -function _onStartDmFinished(shouldInvite, addrs) { - if (!shouldInvite) return; - - const addrTexts = addrs.map((addr) => addr.address); - - if (_isDmChat(addrTexts)) { - const rooms = _getDirectMessageRooms(addrTexts[0]); - if (rooms.length > 0) { - // A Direct Message room already exists for this user, so reuse it - dis.dispatch({ - action: 'view_room', - room_id: rooms[0], - should_peek: false, - joining: false, - }); - } else { - // Start a new DM chat - createRoom({dmUserId: addrTexts[0]}).catch((err) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to start chat', '', ErrorDialog, { - title: _t("Failed to start chat"), - description: ((err && err.message) ? err.message : _t("Operation failed")), - }); - }); - } - } else if (addrTexts.length === 1) { - // Start a new DM chat - createRoom({dmUserId: addrTexts[0]}).catch((err) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to start chat', '', ErrorDialog, { - title: _t("Failed to start chat"), - description: ((err && err.message) ? err.message : _t("Operation failed")), - }); - }); - } else { - // Start multi user chat - let room; - createRoom().then((roomId) => { - room = MatrixClientPeg.get().getRoom(roomId); - return inviteMultipleToRoom(roomId, addrTexts); - }).then((result) => { - return _showAnyInviteErrors(result.states, room, result.inviter); - }).catch((err) => { - console.error(err.stack); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { - title: _t("Failed to invite"), - description: ((err && err.message) ? err.message : _t("Operation failed")), - }); - }); - } -} - -function _onRoomInviteFinished(roomId, shouldInvite, addrs) { - if (!shouldInvite) return; - - const addrTexts = addrs.map((addr) => addr.address); - - // Invite new users to a room - inviteMultipleToRoom(roomId, addrTexts).then((result) => { +export function inviteUsersToRoom(roomId, userIds) { + return inviteMultipleToRoom(roomId, userIds).then((result) => { const room = MatrixClientPeg.get().getRoom(roomId); return _showAnyInviteErrors(result.states, room, result.inviter); }).catch((err) => { @@ -172,15 +88,6 @@ function _onRoomInviteFinished(roomId, shouldInvite, addrs) { }); } -// TODO: Immutable DMs replaces this -function _isDmChat(addrTexts) { - if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx-user-id') { - return true; - } else { - return false; - } -} - function _showAnyInviteErrors(addrs, room, inviter) { // Show user any errors const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error'); @@ -203,26 +110,16 @@ function _showAnyInviteErrors(addrs, room, inviter) { } if (errorList.length > 0) { + // React 16 doesn't let us use `errorList.join(
)` anymore, so this is our solution + const description =
{errorList.map(e =>
{e}
)}
; + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, { title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), - description: errorList.join(
), + description, }); } } return addrs; } - -function _getDirectMessageRooms(addr) { - const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); - const dmRooms = dmRoomMap.getDMRoomsForUserId(addr); - const rooms = dmRooms.filter((dmRoom) => { - const room = MatrixClientPeg.get().getRoom(dmRoom); - if (room) { - return room.getMyMembership() === 'join'; - } - }); - return rooms; -} - diff --git a/src/RoomListSorter.js b/src/RoomListSorter.js index c06cc60c97..0ff37a6af2 100644 --- a/src/RoomListSorter.js +++ b/src/RoomListSorter.js @@ -24,12 +24,8 @@ function tsOfNewestEvent(room) { } } -function mostRecentActivityFirst(roomList) { +export function mostRecentActivityFirst(roomList) { return roomList.sort(function(a, b) { return tsOfNewestEvent(b) - tsOfNewestEvent(a); }); } - -module.exports = { - mostRecentActivityFirst, -}; diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index 2d5e4b3136..c67acaf314 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -15,9 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixClientPeg from './MatrixClientPeg'; -import PushProcessor from 'matrix-js-sdk/lib/pushprocessor'; -import Promise from 'bluebird'; +import {MatrixClientPeg} from './MatrixClientPeg'; +import {PushProcessor} from 'matrix-js-sdk/src/pushprocessor'; export const ALL_MESSAGES_LOUD = 'all_messages_loud'; export const ALL_MESSAGES = 'all_messages'; diff --git a/src/Rooms.js b/src/Rooms.js index c8f90ec39a..218e970f35 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixClientPeg from './MatrixClientPeg'; -import Promise from 'bluebird'; +import {MatrixClientPeg} from './MatrixClientPeg'; /** * Given a room object, return the alias we should use for it, @@ -24,7 +23,7 @@ import Promise from 'bluebird'; * of aliases. Otherwise return null; */ export function getDisplayAliasForRoom(room) { - return room.getCanonicalAlias() || room.getAliases()[0]; + return room.getCanonicalAlias() || room.getAltAliases()[0]; } /** diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index 3623d47f8e..819fe3c998 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -16,15 +16,13 @@ limitations under the License. */ import url from 'url'; -import Promise from 'bluebird'; import SettingsStore from "./settings/SettingsStore"; import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; -const request = require('browser-request'); - -const SdkConfig = require('./SdkConfig'); -const MatrixClientPeg = require('./MatrixClientPeg'); +import {MatrixClientPeg} from "./MatrixClientPeg"; +import request from "browser-request"; import * as Matrix from 'matrix-js-sdk'; +import SdkConfig from "./SdkConfig"; // The version of the integration manager API we're intending to work with const imApiVersion = "1.1"; diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 910a6c4f13..2211e513c3 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -232,7 +232,7 @@ Example: } */ -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; import { MatrixEvent } from 'matrix-js-sdk'; import dis from './dispatcher'; import WidgetUtils from './utils/WidgetUtils'; @@ -279,7 +279,7 @@ function inviteUser(event, roomId, userId) { } } - client.invite(roomId, userId).done(function() { + client.invite(roomId, userId).then(function() { sendResponse(event, { success: true, }); @@ -398,7 +398,7 @@ function setPlumbingState(event, roomId, status) { sendError(event, _t('You need to be logged in.')); return; } - client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).done(() => { + client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).then(() => { sendResponse(event, { success: true, }); @@ -414,7 +414,7 @@ function setBotOptions(event, roomId, userId) { sendError(event, _t('You need to be logged in.')); return; } - client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).done(() => { + client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).then(() => { sendResponse(event, { success: true, }); @@ -444,7 +444,7 @@ function setBotPower(event, roomId, userId, level) { }, ); - client.setPowerLevel(roomId, userId, level, powerEvent).done(() => { + client.setPowerLevel(roomId, userId, level, powerEvent).then(() => { sendResponse(event, { success: true, }); @@ -658,30 +658,29 @@ const onMessage = function(event) { let listenerCount = 0; let openManagerUrl = null; -module.exports = { - startListening: function() { - if (listenerCount === 0) { - window.addEventListener("message", onMessage, false); - } - listenerCount += 1; - }, - stopListening: function() { - listenerCount -= 1; - if (listenerCount === 0) { - window.removeEventListener("message", onMessage); - } - if (listenerCount < 0) { - // Make an error so we get a stack trace - const e = new Error( - "ScalarMessaging: mismatched startListening / stopListening detected." + - " Negative count", - ); - console.error(e); - } - }, +export function startListening() { + if (listenerCount === 0) { + window.addEventListener("message", onMessage, false); + } + listenerCount += 1; +} - setOpenManagerUrl: function(url) { - openManagerUrl = url; - }, -}; +export function stopListening() { + listenerCount -= 1; + if (listenerCount === 0) { + window.removeEventListener("message", onMessage); + } + if (listenerCount < 0) { + // Make an error so we get a stack trace + const e = new Error( + "ScalarMessaging: mismatched startListening / stopListening detected." + + " Negative count", + ); + console.error(e); + } +} + +export function setOpenManagerUrl(url) { + openManagerUrl = url; +} diff --git a/src/SdkConfig.js b/src/SdkConfig.ts similarity index 70% rename from src/SdkConfig.js rename to src/SdkConfig.ts index eb18dad453..8177a6c5b8 100644 --- a/src/SdkConfig.js +++ b/src/SdkConfig.ts @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,7 +15,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -export const DEFAULTS = { +export interface ConfigOptions { + [key: string]: any; +} + +export const DEFAULTS: ConfigOptions = { // URL to a page we show in an iframe to configure integrations integrations_ui_url: "https://scalar.vector.im/", // Base URL to the REST interface of the integrations server @@ -23,30 +28,37 @@ export const DEFAULTS = { bug_report_endpoint_url: null, }; -class SdkConfig { - static get() { - return global.mxReactSdkConfig || {}; +export default class SdkConfig { + private static instance: ConfigOptions; + + private static setInstance(i: ConfigOptions) { + SdkConfig.instance = i; + + // For debugging purposes + (window).mxReactSdkConfig = i; } - static put(cfg) { + static get() { + return SdkConfig.instance || {}; + } + + static put(cfg: ConfigOptions) { const defaultKeys = Object.keys(DEFAULTS); for (let i = 0; i < defaultKeys.length; ++i) { if (cfg[defaultKeys[i]] === undefined) { cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]]; } } - global.mxReactSdkConfig = cfg; + SdkConfig.setInstance(cfg); } static unset() { - global.mxReactSdkConfig = undefined; + SdkConfig.setInstance({}); } - static add(cfg) { + static add(cfg: ConfigOptions) { const liveConfig = SdkConfig.get(); const newConfig = Object.assign({}, liveConfig, cfg); SdkConfig.put(newConfig); } } - -module.exports = SdkConfig; diff --git a/src/Searching.js b/src/Searching.js new file mode 100644 index 0000000000..a5d945f64b --- /dev/null +++ b/src/Searching.js @@ -0,0 +1,138 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import EventIndexPeg from "./indexing/EventIndexPeg"; +import {MatrixClientPeg} from "./MatrixClientPeg"; + +function serverSideSearch(term, roomId = undefined) { + let filter; + if (roomId !== undefined) { + // XXX: it's unintuitive that the filter for searching doesn't have + // the same shape as the v2 filter API :( + filter = { + rooms: [roomId], + }; + } + + const searchPromise = MatrixClientPeg.get().searchRoomEvents({ + filter, + term, + }); + + return searchPromise; +} + +async function combinedSearch(searchTerm) { + // Create two promises, one for the local search, one for the + // server-side search. + const serverSidePromise = serverSideSearch(searchTerm); + const localPromise = localSearch(searchTerm); + + // Wait for both promises to resolve. + await Promise.all([serverSidePromise, localPromise]); + + // Get both search results. + const localResult = await localPromise; + const serverSideResult = await serverSidePromise; + + // Combine the search results into one result. + const result = {}; + + // Our localResult and serverSideResult are both ordered by + // recency separately, when we combine them the order might not + // be the right one so we need to sort them. + const compare = (a, b) => { + const aEvent = a.context.getEvent().event; + const bEvent = b.context.getEvent().event; + + if (aEvent.origin_server_ts > + bEvent.origin_server_ts) return -1; + if (aEvent.origin_server_ts < + bEvent.origin_server_ts) return 1; + return 0; + }; + + result.count = localResult.count + serverSideResult.count; + result.results = localResult.results.concat( + serverSideResult.results).sort(compare); + result.highlights = localResult.highlights.concat( + serverSideResult.highlights); + + return result; +} + +async function localSearch(searchTerm, roomId = undefined) { + const searchArgs = { + search_term: searchTerm, + before_limit: 1, + after_limit: 1, + order_by_recency: true, + room_id: undefined, + }; + + if (roomId !== undefined) { + searchArgs.room_id = roomId; + } + + const eventIndex = EventIndexPeg.get(); + + const localResult = await eventIndex.search(searchArgs); + + const response = { + search_categories: { + room_events: localResult, + }, + }; + + const emptyResult = { + results: [], + highlights: [], + }; + + const result = MatrixClientPeg.get()._processRoomEventsSearch( + emptyResult, response); + + return result; +} + +function eventIndexSearch(term, roomId = undefined) { + let searchPromise; + + if (roomId !== undefined) { + if (MatrixClientPeg.get().isRoomEncrypted(roomId)) { + // The search is for a single encrypted room, use our local + // search method. + searchPromise = localSearch(term, roomId); + } else { + // The search is for a single non-encrypted room, use the + // server-side search. + searchPromise = serverSideSearch(term, roomId); + } + } else { + // Search across all rooms, combine a server side search and a + // local search. + searchPromise = combinedSearch(term); + } + + return searchPromise; +} + +export default function eventSearch(term, roomId = undefined) { + const eventIndex = EventIndexPeg.get(); + + if (eventIndex === null) return serverSideSearch(term, roomId); + else return eventIndexSearch(term, roomId); +} diff --git a/src/Skinner.js b/src/Skinner.js index 1fe12f85ab..87c5a7be7f 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -20,6 +20,7 @@ class Skinner { } getComponent(name) { + if (!name) throw new Error(`Invalid component name: ${name}`); if (this.components === null) { throw new Error( "Attempted to get a component before a skin has been loaded."+ @@ -28,21 +29,31 @@ class Skinner { " b) A component has called getComponent at the root level", ); } - let comp = this.components[name]; - // XXX: Temporarily also try 'views.' as we're currently - // leaving the 'views.' off views. - if (!comp) { - comp = this.components['views.'+name]; - } + const doLookup = (components) => { + if (!components) return null; + let comp = components[name]; + // XXX: Temporarily also try 'views.' as we're currently + // leaving the 'views.' off views. + if (!comp) { + comp = components['views.' + name]; + } + return comp; + }; + + // Check the skin first + const comp = doLookup(this.components); + + // Just return nothing instead of erroring - the consumer should be smart enough to + // handle this at this point. if (!comp) { - throw new Error("No such component: "+name); + return null; } // components have to be functions. const validType = typeof comp === 'function'; if (!validType) { - throw new Error(`Not a valid component: ${name}.`); + throw new Error(`Not a valid component: ${name} (type = ${typeof(comp)}).`); } return comp; } @@ -59,6 +70,13 @@ class Skinner { const comp = skinObject.components[compKeys[i]]; this.addComponent(compKeys[i], comp); } + + // Now that we have a skin, load our components too + const idx = require("./component-index"); + if (!idx || !idx.components) throw new Error("Invalid react-sdk component index"); + for (const c in idx.components) { + if (!this.components[c]) this.components[c] = idx.components[c]; + } } addComponent(name, comp) { @@ -90,5 +108,5 @@ class Skinner { if (global.mxSkinner === undefined) { global.mxSkinner = new Skinner(); } -module.exports = global.mxSkinner; +export default global.mxSkinner; diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 1a491da54f..d306978f78 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -18,9 +18,9 @@ limitations under the License. import React from 'react'; -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; import dis from './dispatcher'; -import sdk from './index'; +import * as sdk from './index'; import {_t, _td} from './languageHandler'; import Modal from './Modal'; import MultiInviter from './utils/MultiInviter'; @@ -28,11 +28,11 @@ import { linkifyAndSanitizeHtml } from './HtmlUtils'; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import WidgetUtils from "./utils/WidgetUtils"; import {textToHtmlRainbow} from "./utils/colour"; -import Promise from "bluebird"; import { getAddressType } from './UserAddress'; import { abbreviateUrl } from './utils/UrlUtils'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils'; import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks"; +import {inviteUsersToRoom} from "./RoomInvite"; const singleMxcUpload = async () => { return new Promise((resolve) => { @@ -81,6 +81,8 @@ class Command { } run(roomId, args) { + // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` + if (!this.runFn) return; return this.runFn.bind(this)(roomId, args); } @@ -155,70 +157,58 @@ export const CommandMap = { 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', - QuestionDialog, { - title: _t('Room upgrade confirmation'), - description: ( -
-

{_t("Upgrading a room can be destructive and isn't always necessary.")}

-

- {_t( - "Room upgrades are usually recommended when a room version is considered " + - "unstable. Unstable room versions might have bugs, missing features, or " + - "security vulnerabilities.", - {}, { - "i": (sub) => {sub}, - }, - )} -

-

- {_t( - "Room upgrades usually only affect server-side processing of the " + - "room. If you're having problems with your Riot client, please file an issue " + - "with .", - {}, { - "i": (sub) => {sub}, - "issueLink": () => { - return - https://github.com/vector-im/riot-web/issues/new/choose - ; - }, - }, - )} -

-

- {_t( - "Warning: Upgrading a room will not automatically migrate room " + - "members to the new version of the room. We'll post a link to the new room " + - "in the old version of the room - room members will have to click this link to " + - "join the new room.", - {}, { - "b": (sub) => {sub}, - "i": (sub) => {sub}, - }, - )} -

-

- {_t( - "Please confirm that you'd like to go forward with upgrading this room " + - "from to .", - {}, - { - oldVersion: () => {room ? room.getVersion() : "1"}, - newVersion: () => {args}, - }, - )} -

-
- ), - button: _t("Upgrade"), - }); + RoomUpgradeWarningDialog, {roomId: roomId, targetVersion: args}, /*className=*/null, + /*isPriority=*/false, /*isStatic=*/true); - return success(finished.then(([confirm]) => { - if (!confirm) return; + return success(finished.then(async ([resp]) => { + if (!resp.continue) return; - return cli.upgradeRoom(roomId, args); + let checkForUpgradeFn; + try { + const upgradePromise = cli.upgradeRoom(roomId, args); + + // We have to wait for the js-sdk to give us the room back so + // we can more effectively abuse the MultiInviter behaviour + // which heavily relies on the Room object being available. + if (resp.invite) { + checkForUpgradeFn = async (newRoom) => { + // The upgradePromise should be done by the time we await it here. + const {replacement_room: newRoomId} = await upgradePromise; + if (newRoom.roomId !== newRoomId) return; + + const toInvite = [ + ...room.getMembersWithMembership("join"), + ...room.getMembersWithMembership("invite"), + ].map(m => m.userId).filter(m => m !== cli.getUserId()); + + if (toInvite.length > 0) { + // Errors are handled internally to this function + await inviteUsersToRoom(newRoomId, toInvite); + } + + cli.removeListener('Room', checkForUpgradeFn); + }; + cli.on('Room', checkForUpgradeFn); + } + + // We have to await after so that the checkForUpgradesFn has a proper reference + // to the new room's ID. + await upgradePromise; + } catch (e) { + console.error(e); + + 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( + 'Double check that your server supports the room version chosen and try again.'), + }); + } })); } return reject(this.getUsage()); @@ -781,7 +771,7 @@ export const CommandMap = { verify: new Command({ name: 'verify', args: ' ', - description: _td('Verifies a user, device, and pubkey tuple'), + description: _td('Verifies a user, session, and pubkey tuple'), runFn: function(roomId, args) { if (args) { const matches = args.match(/^(\S+) +(\S+) +(\S+)$/); @@ -792,54 +782,52 @@ export const CommandMap = { const deviceId = matches[2]; const fingerprint = matches[3]; - return success( - // Promise.resolve to handle transition from static result to promise; can be removed - // in future - Promise.resolve(cli.getStoredDevice(userId, deviceId)).then((device) => { - if (!device) { - throw new Error(_t('Unknown (user, device) pair:') + ` (${userId}, ${deviceId})`); - } + return success((async () => { + const device = await cli.getStoredDevice(userId, deviceId); + if (!device) { + throw new Error(_t('Unknown (user, session) pair:') + ` (${userId}, ${deviceId})`); + } + const deviceTrust = await cli.checkDeviceTrust(userId, deviceId); - if (device.isVerified()) { - if (device.getFingerprint() === fingerprint) { - throw new Error(_t('Device already verified!')); - } else { - throw new Error(_t('WARNING: Device already verified, but keys do NOT MATCH!')); - } + if (deviceTrust.isVerified()) { + if (device.getFingerprint() === fingerprint) { + throw new Error(_t('Session already verified!')); + } else { + throw new Error(_t('WARNING: Session already verified, but keys do NOT MATCH!')); } + } - if (device.getFingerprint() !== fingerprint) { - const fprint = device.getFingerprint(); - throw new Error( - _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' + - ' %(deviceId)s is "%(fprint)s" which does not match the provided key ' + - '"%(fingerprint)s". This could mean your communications are being intercepted!', - { - fprint, - userId, - deviceId, - fingerprint, - })); - } + if (device.getFingerprint() !== fingerprint) { + const fprint = device.getFingerprint(); + throw new Error( + _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session' + + ' %(deviceId)s is "%(fprint)s" which does not match the provided key ' + + '"%(fingerprint)s". This could mean your communications are being intercepted!', + { + fprint, + userId, + deviceId, + fingerprint, + })); + } - return cli.setDeviceVerified(userId, deviceId, true); - }).then(() => { - // Tell the user we verified everything - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); - Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, { - title: _t('Verified key'), - description:
-

- { - _t('The signing key you provided matches the signing key you received ' + - 'from %(userId)s\'s device %(deviceId)s. Device marked as verified.', - {userId, deviceId}) - } -

-
, - }); - }), - ); + 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:
+

+ { + _t('The signing key you provided matches the signing key you received ' + + 'from %(userId)s\'s session %(deviceId)s. Session marked as verified.', + {userId, deviceId}) + } +

+
, + }); + })()); } } return reject(this.getUsage()); @@ -905,6 +893,26 @@ export const CommandMap = { }, category: CommandCategories.advanced, }), + + whois: new Command({ + name: "whois", + description: _td("Displays information about a user"), + args: '', + runFn: function(roomId, userId) { + if (!userId || !userId.startsWith("@") || !userId.includes(":")) { + return reject(this.getUsage()); + } + + const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId); + + dis.dispatch({ + action: 'view_user', + member: member || {userId}, + }); + return success(); + }, + category: CommandCategories.advanced, + }), }; /* eslint-enable babel/no-invalid-this */ @@ -919,25 +927,25 @@ const aliases = { /** - * Process the given text for /commands and perform them. + * 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. * @param {string} input The raw text input by the user. - * @return {Object|null} An object with the property 'error' if there was an error + * @return {null|function(): Object} Function returning an object with the property 'error' if there was an error * processing the command, or 'promise' if a request was sent out. * Returns null if the input didn't match a command. */ -export function processCommandInput(roomId, input) { +export function getCommand(roomId, input) { // trim any trailing whitespace, as it can confuse the parser for // IRC-style commands input = input.replace(/\s+$/, ''); if (input[0] !== '/') return null; // not a command - const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); + const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/); let cmd; let args; if (bits) { cmd = bits[1].substring(1).toLowerCase(); - args = bits[3]; + args = bits[2]; } else { cmd = input; } @@ -946,11 +954,6 @@ export function processCommandInput(roomId, input) { cmd = aliases[cmd]; } if (CommandMap[cmd]) { - // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` - if (!CommandMap[cmd].runFn) return null; - - return CommandMap[cmd].run(roomId, args); - } else { - return reject(_t('Unrecognised command:') + ' ' + input); + return () => CommandMap[cmd].run(roomId, args); } } diff --git a/src/SlateComposerHistoryManager.js b/src/SlateComposerHistoryManager.js deleted file mode 100644 index 948dcf64ff..0000000000 --- a/src/SlateComposerHistoryManager.js +++ /dev/null @@ -1,86 +0,0 @@ -//@flow -/* -Copyright 2017 Aviral Dasgupta - -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 {Value} from 'slate'; - -import _clamp from 'lodash/clamp'; - -type MessageFormat = 'rich' | 'markdown'; - -class HistoryItem { - // We store history items in their native format to ensure history is accurate - // and then convert them if our RTE has subsequently changed format. - value: Value; - format: MessageFormat = 'rich'; - - constructor(value: ?Value, format: ?MessageFormat) { - this.value = value; - this.format = format; - } - - static fromJSON(obj: Object): HistoryItem { - return new HistoryItem( - Value.fromJSON(obj.value), - obj.format, - ); - } - - toJSON(): Object { - return { - value: this.value.toJSON(), - format: this.format, - }; - } -} - -export default class SlateComposerHistoryManager { - history: Array = []; - prefix: string; - lastIndex: number = 0; // used for indexing the storage - currentIndex: number = 0; // used for indexing the loaded validated history Array - - constructor(roomId: string, prefix: string = 'mx_composer_history_') { - this.prefix = prefix + roomId; - - // TODO: Performance issues? - let item; - for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) { - try { - this.history.push( - HistoryItem.fromJSON(JSON.parse(item)), - ); - } catch (e) { - console.warn("Throwing away unserialisable history", e); - } - } - this.lastIndex = this.currentIndex; - // reset currentIndex to account for any unserialisable history - this.currentIndex = this.history.length; - } - - save(value: Value, format: MessageFormat) { - const item = new HistoryItem(value, format); - this.history.push(item); - this.currentIndex = this.history.length; - sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON())); - } - - getItem(offset: number): ?HistoryItem { - this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); - return this.history[this.currentIndex]; - } -} diff --git a/src/Terms.js b/src/Terms.js index 685a39709c..6ae89f9a2c 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -14,11 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Promise from 'bluebird'; import classNames from 'classnames'; -import MatrixClientPeg from './MatrixClientPeg'; -import sdk from './'; +import {MatrixClientPeg} from './MatrixClientPeg'; +import * as sdk from './'; import Modal from './Modal'; export class TermsNotSignedError extends Error {} diff --git a/src/TextForEvent.js b/src/TextForEvent.js index e3c249df3f..d4003058c8 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -13,12 +13,13 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import MatrixClientPeg from './MatrixClientPeg'; +import {MatrixClientPeg} from './MatrixClientPeg'; import CallHandler from './CallHandler'; import { _t } from './languageHandler'; import * as Roles from './Roles'; import {isValid3pidInvite} from "./RoomInvite"; import SettingsStore from "./settings/SettingsStore"; +import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; function textForMemberEvent(ev) { // XXX: SYJS-16 "sender is sometimes null for join messages" @@ -274,6 +275,8 @@ function textForRoomAliasesEvent(ev) { // This feels a bit overkill though, and it's not clear the i18n really needs it // so instead it's landing as a simple textual event. + const maxShown = 3; + const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const oldAliases = ev.getPrevContent().aliases || []; const newAliases = ev.getContent().aliases || []; @@ -286,18 +289,40 @@ function textForRoomAliasesEvent(ev) { } if (addedAliases.length && !removedAliases.length) { + if (addedAliases.length > maxShown) { + return _t("%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room", { + senderName: senderName, + count: addedAliases.length - maxShown, + addedAddresses: addedAliases.slice(0, maxShown).join(', '), + }); + } return _t('%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.', { senderName: senderName, count: addedAliases.length, addedAddresses: addedAliases.join(', '), }); } else if (!addedAliases.length && removedAliases.length) { + if (removedAliases.length > maxShown) { + return _t("%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room", { + senderName: senderName, + count: removedAliases.length - maxShown, + removedAddresses: removedAliases.slice(0, maxShown).join(', '), + }); + } return _t('%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.', { senderName: senderName, count: removedAliases.length, removedAddresses: removedAliases.join(', '), }); } else { + const combined = addedAliases.length + removedAliases.length; + if (combined > maxShown) { + return _t("%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room", { + senderName: senderName, + countAdded: addedAliases.length, + countRemoved: removedAliases.length, + }); + } return _t( '%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.', { senderName: senderName, @@ -358,13 +383,25 @@ function textForCallHangupEvent(event) { function textForCallInviteEvent(event) { const senderName = event.sender ? event.sender.name : _t('Someone'); // FIXME: Find a better way to determine this from the event? - let callType = "voice"; + let isVoice = true; if (event.getContent().offer && event.getContent().offer.sdp && event.getContent().offer.sdp.indexOf('m=video') !== -1) { - callType = "video"; + isVoice = false; + } + const isSupported = MatrixClientPeg.get().supportsVoip(); + + // This ladder could be reduced down to a couple string variables, however other languages + // can have a hard time translating those strings. In an effort to make translations easier + // and more accurate, we break out the string-based variables to a couple booleans. + if (isVoice && isSupported) { + return _t("%(senderName)s placed a voice call.", {senderName}); + } else if (isVoice && !isSupported) { + return _t("%(senderName)s placed a voice call. (not supported by this browser)", {senderName}); + } else if (!isVoice && isSupported) { + return _t("%(senderName)s placed a video call.", {senderName}); + } else if (!isVoice && !isSupported) { + return _t("%(senderName)s placed a video call. (not supported by this browser)", {senderName}); } - const supported = MatrixClientPeg.get().supportsVoip() ? "" : _t('(not supported by this browser)'); - return _t('%(senderName)s placed a %(callType)s call.', {senderName, callType}) + ' ' + supported; } function textForThreePidInviteEvent(event) { @@ -405,14 +442,6 @@ function textForHistoryVisibilityEvent(event) { } } -function textForEncryptionEvent(event) { - const senderName = event.sender ? event.sender.name : event.getSender(); - return _t('%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).', { - senderName, - algorithm: event.getContent().algorithm, - }); -} - // Currently will only display a change if a user's power level is changed function textForPowerEvent(event) { const senderName = event.sender ? event.sender.name : event.getSender(); @@ -460,7 +489,7 @@ function textForPowerEvent(event) { } function textForPinnedEvent(event) { - const senderName = event.getSender(); + const senderName = event.sender ? event.sender.name : event.getSender(); return _t("%(senderName)s changed the pinned messages for the room.", {senderName}); } @@ -494,6 +523,87 @@ function textForWidgetEvent(event) { } } +function textForMjolnirEvent(event) { + const senderName = event.getSender(); + const {entity: prevEntity} = event.getPrevContent(); + const {entity, recommendation, reason} = event.getContent(); + + // Rule removed + if (!entity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s removed the rule banning users matching %(glob)s", + {senderName, glob: prevEntity}); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s removed the rule banning rooms matching %(glob)s", + {senderName, glob: prevEntity}); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s removed the rule banning servers matching %(glob)s", + {senderName, glob: prevEntity}); + } + + // Unknown type. We'll say something, but we shouldn't end up here. + return _t("%(senderName)s removed a ban rule matching %(glob)s", {senderName, glob: prevEntity}); + } + + // Invalid rule + if (!recommendation || !reason) return _t(`%(senderName)s updated an invalid ban rule`, {senderName}); + + // Rule updated + if (entity === prevEntity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } + + // New rule + if (!prevEntity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } + + // else the entity !== prevEntity - count as a removal & add + if (USER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + {senderName, oldGlob: prevEntity, newGlob: entity, reason}); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + {senderName, oldGlob: prevEntity, newGlob: entity, reason}); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + {senderName, oldGlob: prevEntity, newGlob: entity, reason}); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " + + "for %(reason)s", {senderName, oldGlob: prevEntity, newGlob: entity, reason}); +} + const handlers = { 'm.room.message': textForMessageEvent, 'm.call.invite': textForCallInviteEvent, @@ -509,7 +619,6 @@ const stateHandlers = { 'm.room.member': textForMemberEvent, 'm.room.third_party_invite': textForThreePidInviteEvent, 'm.room.history_visibility': textForHistoryVisibilityEvent, - 'm.room.encryption': textForEncryptionEvent, 'm.room.power_levels': textForPowerEvent, 'm.room.pinned_events': textForPinnedEvent, 'm.room.server_acl': textForServerACLEvent, @@ -521,10 +630,13 @@ const stateHandlers = { 'im.vector.modular.widgets': textForWidgetEvent, }; -module.exports = { - textForEvent: function(ev) { - const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; - if (handler) return handler(ev); - return ''; - }, -}; +// Add all the Mjolnir stuff to the renderer +for (const evType of ALL_RULE_TYPES) { + stateHandlers[evType] = textForMjolnirEvent; +} + +export function textForEvent(ev) { + const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; + if (handler) return handler(ev); + return ''; +} diff --git a/src/Tinter.js b/src/Tinter.js index de9ae94097..24a4d25a00 100644 --- a/src/Tinter.js +++ b/src/Tinter.js @@ -143,10 +143,14 @@ class Tinter { * over time then the best bet is to register a single callback for the * entire set. * + * To ensure the tintable work happens at least once, it is also called as + * part of registration. + * * @param {Function} tintable Function to call when the tint changes. */ registerTintable(tintable) { this.tintables.push(tintable); + tintable(); } getKeyRgb() { diff --git a/src/ToWidgetPostMessageApi.js b/src/ToWidgetPostMessageApi.js index def4af56ae..00309d252c 100644 --- a/src/ToWidgetPostMessageApi.js +++ b/src/ToWidgetPostMessageApi.js @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Promise from "bluebird"; - // const OUTBOUND_API_NAME = 'toWidget'; // Initiate requests using the "toWidget" postMessage API and handle responses diff --git a/src/Unread.js b/src/Unread.js index 01617dc1ac..ca713b05e4 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -14,78 +14,78 @@ See the License for the specific language governing permissions and limitations under the License. */ -const MatrixClientPeg = require('./MatrixClientPeg'); +import {MatrixClientPeg} from "./MatrixClientPeg"; import shouldHideEvent from './shouldHideEvent'; -const sdk = require('./index'); +import * as sdk from "./index"; +import {haveTileForEvent} from "./components/views/rooms/EventTile"; -module.exports = { - /** - * Returns true iff this event arriving in a room should affect the room's - * count of unread messages - */ - eventTriggersUnreadCount: function(ev) { - if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) { - return false; - } else if (ev.getType() == 'm.room.member') { - return false; - } else if (ev.getType() == 'm.room.third_party_invite') { - return false; - } else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') { - return false; - } else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') { - return false; - } else if (ev.getType() == 'm.room.aliases' || ev.getType() == 'm.room.canonical_alias') { +/** + * Returns true iff this event arriving in a room should affect the room's + * count of unread messages + */ +export function eventTriggersUnreadCount(ev) { + if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) { + return false; + } else if (ev.getType() == 'm.room.member') { + return false; + } else if (ev.getType() == 'm.room.third_party_invite') { + return false; + } else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') { + return false; + } else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') { + return false; + } else if (ev.getType() == 'm.room.aliases' || ev.getType() == 'm.room.canonical_alias') { + return false; + } else if (ev.getType() == 'm.room.server_acl') { + return false; + } + return haveTileForEvent(ev); +} + +export function doesRoomHaveUnreadMessages(room) { + const myUserId = MatrixClientPeg.get().credentials.userId; + + // get the most recent read receipt sent by our account. + // N.B. this is NOT a read marker (RM, aka "read up to marker"), + // despite the name of the method :(( + const readUpToId = room.getEventReadUpTo(myUserId); + + // as we don't send RRs for our own messages, make sure we special case that + // if *we* sent the last message into the room, we consider it not unread! + // Should fix: https://github.com/vector-im/riot-web/issues/3263 + // https://github.com/vector-im/riot-web/issues/2427 + // ...and possibly some of the others at + // https://github.com/vector-im/riot-web/issues/3363 + if (room.timeline.length && + room.timeline[room.timeline.length - 1].sender && + room.timeline[room.timeline.length - 1].sender.userId === myUserId) { + return false; + } + + // this just looks at whatever history we have, which if we've only just started + // up probably won't be very much, so if the last couple of events are ones that + // don't count, we don't know if there are any events that do count between where + // we have and the read receipt. We could fetch more history to try & find out, + // but currently we just guess. + + // Loop through messages, starting with the most recent... + for (let i = room.timeline.length - 1; i >= 0; --i) { + const ev = room.timeline[i]; + + if (ev.getId() == readUpToId) { + // If we've read up to this event, there's nothing more recent + // that counts and we can stop looking because the user's read + // this and everything before. return false; + } else if (!shouldHideEvent(ev) && eventTriggersUnreadCount(ev)) { + // We've found a message that counts before we hit + // the user's read receipt, so this room is definitely unread. + return true; } - const EventTile = sdk.getComponent('rooms.EventTile'); - return EventTile.haveTileForEvent(ev); - }, - - doesRoomHaveUnreadMessages: function(room) { - const myUserId = MatrixClientPeg.get().credentials.userId; - - // get the most recent read receipt sent by our account. - // N.B. this is NOT a read marker (RM, aka "read up to marker"), - // despite the name of the method :(( - const readUpToId = room.getEventReadUpTo(myUserId); - - // as we don't send RRs for our own messages, make sure we special case that - // if *we* sent the last message into the room, we consider it not unread! - // Should fix: https://github.com/vector-im/riot-web/issues/3263 - // https://github.com/vector-im/riot-web/issues/2427 - // ...and possibly some of the others at - // https://github.com/vector-im/riot-web/issues/3363 - if (room.timeline.length && - room.timeline[room.timeline.length - 1].sender && - room.timeline[room.timeline.length - 1].sender.userId === myUserId) { - return false; - } - - // this just looks at whatever history we have, which if we've only just started - // up probably won't be very much, so if the last couple of events are ones that - // don't count, we don't know if there are any events that do count between where - // we have and the read receipt. We could fetch more history to try & find out, - // but currently we just guess. - - // Loop through messages, starting with the most recent... - for (let i = room.timeline.length - 1; i >= 0; --i) { - const ev = room.timeline[i]; - - if (ev.getId() == readUpToId) { - // If we've read up to this event, there's nothing more recent - // that counts and we can stop looking because the user's read - // this and everything before. - return false; - } else if (!shouldHideEvent(ev) && this.eventTriggersUnreadCount(ev)) { - // We've found a message that counts before we hit - // the user's read receipt, so this room is definitely unread. - return true; - } - } - // If we got here, we didn't find a message that counted but didn't find - // the user's read receipt either, so we guess and say that the room is - // unread on the theory that false positives are better than false - // negatives here. - return true; - }, -}; + } + // If we got here, we didn't find a message that counted but didn't find + // the user's read receipt either, so we guess and say that the room is + // unread on the theory that false positives are better than false + // negatives here. + return true; +} diff --git a/src/VectorConferenceHandler.js b/src/VectorConferenceHandler.js index 37b3a7ddad..180dad876b 100644 --- a/src/VectorConferenceHandler.js +++ b/src/VectorConferenceHandler.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,10 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Promise from 'bluebird'; -import {createNewMatrixCall, Room} from "matrix-js-sdk"; +import {createNewMatrixCall as jsCreateNewMatrixCall, Room} from "matrix-js-sdk"; import CallHandler from './CallHandler'; -import MatrixClientPeg from "./MatrixClientPeg"; +import {MatrixClientPeg} from "./MatrixClientPeg"; // FIXME: this is Riot (Vector) specific code, but will be removed shortly when // we switch over to jitsi entirely for video conferencing. @@ -29,10 +29,10 @@ import MatrixClientPeg from "./MatrixClientPeg"; const USER_PREFIX = "fs_"; const DOMAIN = "matrix.org"; -function ConferenceCall(matrixClient, groupChatRoomId) { +export function ConferenceCall(matrixClient, groupChatRoomId) { this.client = matrixClient; this.groupRoomId = groupChatRoomId; - this.confUserId = module.exports.getConferenceUserIdForRoom(this.groupRoomId); + this.confUserId = getConferenceUserIdForRoom(this.groupRoomId); } ConferenceCall.prototype.setup = function() { @@ -43,7 +43,7 @@ ConferenceCall.prototype.setup = function() { // return a call for *this* room to be placed. We also tack on // confUserId to speed up lookups (else we'd need to loop every room // looking for a 1:1 room with this conf user ID!) - const call = createNewMatrixCall(self.client, room.roomId); + const call = jsCreateNewMatrixCall(self.client, room.roomId); call.confUserId = self.confUserId; call.groupRoomId = self.groupRoomId; return call; @@ -91,7 +91,7 @@ ConferenceCall.prototype._getConferenceUserRoom = function() { * @param {string} userId The user ID to check. * @return {boolean} True if it is a conference bot. */ -module.exports.isConferenceUser = function(userId) { +export function isConferenceUser(userId) { if (userId.indexOf("@" + USER_PREFIX) !== 0) { return false; } @@ -102,26 +102,26 @@ module.exports.isConferenceUser = function(userId) { return /^!.+:.+/.test(decoded); } return false; -}; +} -module.exports.getConferenceUserIdForRoom = function(roomId) { +export function getConferenceUserIdForRoom(roomId) { // abuse browserify's core node Buffer support (strip padding ='s) const base64RoomId = new Buffer(roomId).toString("base64").replace(/=/g, ""); return "@" + USER_PREFIX + base64RoomId + ":" + DOMAIN; -}; +} -module.exports.createNewMatrixCall = function(client, roomId) { +export function createNewMatrixCall(client, roomId) { const confCall = new ConferenceCall( client, roomId, ); return confCall.setup(); -}; +} -module.exports.getConferenceCallForRoom = function(roomId) { +export function getConferenceCallForRoom(roomId) { // search for a conference 1:1 call for this group chat room ID const activeCall = CallHandler.getAnyActiveCall(); if (activeCall && activeCall.confUserId) { - const thisRoomConfUserId = module.exports.getConferenceUserIdForRoom( + const thisRoomConfUserId = getConferenceUserIdForRoom( roomId, ); if (thisRoomConfUserId === activeCall.confUserId) { @@ -129,8 +129,7 @@ module.exports.getConferenceCallForRoom = function(roomId) { } } return null; -}; +} -module.exports.ConferenceCall = ConferenceCall; - -module.exports.slot = 'conference'; +// TODO: Document this. +export const slot = 'conference'; diff --git a/src/Velociraptor.js b/src/Velociraptor.js index b7a2d7fb40..ce52f60dbd 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -1,8 +1,7 @@ -const React = require('react'); -const ReactDom = require('react-dom'); +import React from "react"; +import ReactDom from "react-dom"; +import Velocity from "velocity-animate"; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; -const Velocity = require('velocity-animate'); /** * The Velociraptor contains components and animates transitions with velocity. @@ -11,10 +10,8 @@ const Velocity = require('velocity-animate'); * from DOM order. This makes it a lot simpler and lighter: if you need fully * automatic positional animation, look at react-shuffle or similar libraries. */ -module.exports = createReactClass({ - displayName: 'Velociraptor', - - propTypes: { +export default class Velociraptor extends React.Component { + static propTypes = { // either a list of child nodes, or a single child. children: PropTypes.any, @@ -26,82 +23,71 @@ module.exports = createReactClass({ // a list of transition options from the corresponding startStyle enterTransitionOpts: PropTypes.array, - }, + }; - getDefaultProps: function() { - return { - startStyles: [], - enterTransitionOpts: [], - }; - }, + static defaultProps = { + startStyles: [], + enterTransitionOpts: [], + }; + + constructor(props) { + super(props); - componentWillMount: function() { this.nodes = {}; this._updateChildren(this.props.children); - }, + } - componentWillReceiveProps: function(nextProps) { - this._updateChildren(nextProps.children); - }, + componentDidUpdate() { + this._updateChildren(this.props.children); + } - /** - * update `this.children` according to the new list of children given - */ - _updateChildren: function(newChildren) { - const self = this; + _updateChildren(newChildren) { const oldChildren = this.children || {}; this.children = {}; - React.Children.toArray(newChildren).forEach(function(c) { + React.Children.toArray(newChildren).forEach((c) => { if (oldChildren[c.key]) { const old = oldChildren[c.key]; - const oldNode = ReactDom.findDOMNode(self.nodes[old.key]); + const oldNode = ReactDom.findDOMNode(this.nodes[old.key]); - if (oldNode && oldNode.style.left != c.props.style.left) { - Velocity(oldNode, { left: c.props.style.left }, self.props.transition).then(function() { + if (oldNode && oldNode.style.left !== c.props.style.left) { + Velocity(oldNode, { left: c.props.style.left }, this.props.transition).then(() => { // special case visibility because it's nonsensical to animate an invisible element // so we always hidden->visible pre-transition and visible->hidden after - if (oldNode.style.visibility == 'visible' && c.props.style.visibility == 'hidden') { + if (oldNode.style.visibility === 'visible' && c.props.style.visibility === 'hidden') { oldNode.style.visibility = c.props.style.visibility; } }); //console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); } - if (oldNode && oldNode.style.visibility == 'hidden' && c.props.style.visibility == 'visible') { + if (oldNode && oldNode.style.visibility === 'hidden' && c.props.style.visibility === 'visible') { oldNode.style.visibility = c.props.style.visibility; } // clone the old element with the props (and children) of the new element // so prop updates are still received by the children. - self.children[c.key] = React.cloneElement(old, c.props, c.props.children); + this.children[c.key] = React.cloneElement(old, c.props, c.props.children); } else { // new element. If we have a startStyle, use that as the style and go through // the enter animations const newProps = {}; const restingStyle = c.props.style; - const startStyles = self.props.startStyles; + const startStyles = this.props.startStyles; if (startStyles.length > 0) { const startStyle = startStyles[0]; newProps.style = startStyle; // console.log("mounted@startstyle0: "+JSON.stringify(startStyle)); } - newProps.ref = ((n) => self._collectNode( + newProps.ref = ((n) => this._collectNode( c.key, n, restingStyle, )); - self.children[c.key] = React.cloneElement(c, newProps); + this.children[c.key] = React.cloneElement(c, newProps); } }); - }, + } - /** - * called when a child element is mounted/unmounted - * - * @param {string} k key of the child - * @param {null|Object} node On mount: React node. On unmount: null - * @param {Object} restingStyle final style - */ - _collectNode: function(k, node, restingStyle) { + _collectNode(k, node, restingStyle) { if ( node && this.nodes[k] === undefined && @@ -125,12 +111,12 @@ module.exports = createReactClass({ // and then we animate to the resting state Velocity(domNode, restingStyle, - transitionOpts[i-1]) - .then(() => { - // once we've reached the resting state, hide the element if - // appropriate - domNode.style.visibility = restingStyle.visibility; - }); + transitionOpts[i-1]) + .then(() => { + // once we've reached the resting state, hide the element if + // appropriate + domNode.style.visibility = restingStyle.visibility; + }); /* console.log("enter:", @@ -153,13 +139,13 @@ module.exports = createReactClass({ if (domNode) Velocity.Utilities.removeData(domNode); } this.nodes[k] = node; - }, + } - render: function() { + render() { return ( { Object.values(this.children) } ); - }, -}); + } +} diff --git a/src/VelocityBounce.js b/src/VelocityBounce.js index db216f81fb..ffbf7de829 100644 --- a/src/VelocityBounce.js +++ b/src/VelocityBounce.js @@ -1,4 +1,4 @@ -const Velocity = require('velocity-animate'); +import Velocity from "velocity-animate"; // courtesy of https://github.com/julianshapiro/velocity/issues/283 // We only use easeOutBounce (easeInBounce is just sort of nonsensical) diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js index eb09685cbe..d11cddf487 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.js @@ -14,71 +14,69 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixClientPeg from "./MatrixClientPeg"; +import {MatrixClientPeg} from "./MatrixClientPeg"; import { _t } from './languageHandler'; -module.exports = { - usersTypingApartFromMeAndIgnored: function(room) { - return this.usersTyping( - room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()), - ); - }, +export function usersTypingApartFromMeAndIgnored(room) { + return usersTyping( + room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()), + ); +} - usersTypingApartFromMe: function(room) { - return this.usersTyping( - room, [MatrixClientPeg.get().credentials.userId], - ); - }, +export function usersTypingApartFromMe(room) { + return usersTyping( + room, [MatrixClientPeg.get().credentials.userId], + ); +} - /** - * Given a Room object and, optionally, a list of userID strings - * to exclude, return a list of user objects who are typing. - * @param {Room} room: room object to get users from. - * @param {string[]} exclude: list of user mxids to exclude. - * @returns {string[]} list of user objects who are typing. - */ - usersTyping: function(room, exclude) { - const whoIsTyping = []; +/** + * Given a Room object and, optionally, a list of userID strings + * to exclude, return a list of user objects who are typing. + * @param {Room} room: room object to get users from. + * @param {string[]} exclude: list of user mxids to exclude. + * @returns {string[]} list of user objects who are typing. + */ +export function usersTyping(room, exclude) { + const whoIsTyping = []; - if (exclude === undefined) { - exclude = []; - } + if (exclude === undefined) { + exclude = []; + } - const memberKeys = Object.keys(room.currentState.members); - for (let i = 0; i < memberKeys.length; ++i) { - const userId = memberKeys[i]; + const memberKeys = Object.keys(room.currentState.members); + for (let i = 0; i < memberKeys.length; ++i) { + const userId = memberKeys[i]; - if (room.currentState.members[userId].typing) { - if (exclude.indexOf(userId) === -1) { - whoIsTyping.push(room.currentState.members[userId]); - } + if (room.currentState.members[userId].typing) { + if (exclude.indexOf(userId) === -1) { + whoIsTyping.push(room.currentState.members[userId]); } } + } - return whoIsTyping; - }, + return whoIsTyping; +} - whoIsTypingString: function(whoIsTyping, limit) { - let othersCount = 0; - if (whoIsTyping.length > limit) { - othersCount = whoIsTyping.length - limit + 1; - } - if (whoIsTyping.length === 0) { - return ''; - } else if (whoIsTyping.length === 1) { - return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name}); - } - const names = whoIsTyping.map(function(m) { - return m.name; +export function whoIsTypingString(whoIsTyping, limit) { + let othersCount = 0; + if (whoIsTyping.length > limit) { + othersCount = whoIsTyping.length - limit + 1; + } + if (whoIsTyping.length === 0) { + return ''; + } else if (whoIsTyping.length === 1) { + return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name}); + } + const names = whoIsTyping.map(function(m) { + return m.name; + }); + if (othersCount>=1) { + return _t('%(names)s and %(count)s others are typing …', { + names: names.slice(0, limit - 1).join(', '), + count: othersCount, }); - if (othersCount>=1) { - return _t('%(names)s and %(count)s others are typing …', { - names: names.slice(0, limit - 1).join(', '), - count: othersCount, - }); - } else { - const lastPerson = names.pop(); - return _t('%(names)s and %(lastPerson)s are typing …', {names: names.join(', '), lastPerson: lastPerson}); - } - }, -}; + } else { + const lastPerson = names.pop(); + return _t('%(names)s and %(lastPerson)s are typing …', {names: names.join(', '), lastPerson: lastPerson}); + } +} diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index 1d8e1b9cd3..d40a8ab637 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -23,7 +23,7 @@ limitations under the License. import FromWidgetPostMessageApi from './FromWidgetPostMessageApi'; import ToWidgetPostMessageApi from './ToWidgetPostMessageApi'; import Modal from "./Modal"; -import MatrixClientPeg from "./MatrixClientPeg"; +import {MatrixClientPeg} from "./MatrixClientPeg"; import SettingsStore from "./settings/SettingsStore"; import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog"; import WidgetUtils from "./utils/WidgetUtils"; diff --git a/src/accessibility/RovingTabIndex.js b/src/accessibility/RovingTabIndex.js new file mode 100644 index 0000000000..b481f08fe2 --- /dev/null +++ b/src/accessibility/RovingTabIndex.js @@ -0,0 +1,224 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { + createContext, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + useReducer, +} from "react"; +import PropTypes from "prop-types"; +import {Key} from "../Keyboard"; + +/** + * Module to simplify implementing the Roving TabIndex accessibility technique + * + * Wrap the Widget in an RovingTabIndexContextProvider + * and then for all buttons make use of useRovingTabIndex or RovingTabIndexWrapper. + * The code will keep track of which tabIndex was most recently focused and expose that information as `isActive` which + * can then be used to only set the tabIndex to 0 as expected by the roving tabindex technique. + * When the active button gets unmounted the closest button will be chosen as expected. + * Initially the first button to mount will be given active state. + * + * https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex + */ + +const DOCUMENT_POSITION_PRECEDING = 2; + +const RovingTabIndexContext = createContext({ + state: { + activeRef: null, + refs: [], // list of refs in DOM order + }, + dispatch: () => {}, +}); +RovingTabIndexContext.displayName = "RovingTabIndexContext"; + +// TODO use a TypeScript type here +const types = { + REGISTER: "REGISTER", + UNREGISTER: "UNREGISTER", + SET_FOCUS: "SET_FOCUS", +}; + +const reducer = (state, action) => { + switch (action.type) { + case types.REGISTER: { + if (state.refs.length === 0) { + // Our list of refs was empty, set activeRef to this first item + return { + ...state, + activeRef: action.payload.ref, + refs: [action.payload.ref], + }; + } + + if (state.refs.includes(action.payload.ref)) { + return state; // already in refs, this should not happen + } + + // find the index of the first ref which is not preceding this one in DOM order + let newIndex = state.refs.findIndex(ref => { + return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING; + }); + + if (newIndex < 0) { + newIndex = state.refs.length; // append to the end + } + + // update the refs list + return { + ...state, + refs: [ + ...state.refs.slice(0, newIndex), + action.payload.ref, + ...state.refs.slice(newIndex), + ], + }; + } + case types.UNREGISTER: { + // filter out the ref which we are removing + const refs = state.refs.filter(r => r !== action.payload.ref); + + if (refs.length === state.refs.length) { + return state; // already removed, this should not happen + } + + if (state.activeRef === action.payload.ref) { + // we just removed the active ref, need to replace it + // pick the ref which is now in the index the old ref was in + const oldIndex = state.refs.findIndex(r => r === action.payload.ref); + return { + ...state, + activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex], + refs, + }; + } + + // update the refs list + return { + ...state, + refs, + }; + } + case types.SET_FOCUS: { + // update active ref + return { + ...state, + activeRef: action.payload.ref, + }; + } + default: + return state; + } +}; + +export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => { + const [state, dispatch] = useReducer(reducer, { + activeRef: null, + refs: [], + }); + + const context = useMemo(() => ({state, dispatch}), [state]); + + const onKeyDownHandler = useCallback((ev) => { + let handled = false; + if (handleHomeEnd) { + // check if we actually have any items + switch (ev.key) { + case Key.HOME: + handled = true; + // move focus to first item + if (context.state.refs.length > 0) { + context.state.refs[0].current.focus(); + } + break; + case Key.END: + handled = true; + // move focus to last item + if (context.state.refs.length > 0) { + context.state.refs[context.state.refs.length - 1].current.focus(); + } + break; + } + } + + if (handled) { + ev.preventDefault(); + ev.stopPropagation(); + } else if (onKeyDown) { + return onKeyDown(ev); + } + }, [context.state, onKeyDown, handleHomeEnd]); + + return + { children({onKeyDownHandler}) } + ; +}; +RovingTabIndexProvider.propTypes = { + handleHomeEnd: PropTypes.bool, + onKeyDown: PropTypes.func, +}; + +// Hook to register a roving tab index +// inputRef parameter specifies the ref to use +// onFocus should be called when the index gained focus in any manner +// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}` +// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition +export const useRovingTabIndex = (inputRef) => { + const context = useContext(RovingTabIndexContext); + let ref = useRef(null); + + if (inputRef) { + // if we are given a ref, use it instead of ours + ref = inputRef; + } + + // setup (after refs) + useLayoutEffect(() => { + context.dispatch({ + type: types.REGISTER, + payload: {ref}, + }); + // teardown + return () => { + context.dispatch({ + type: types.UNREGISTER, + payload: {ref}, + }); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const onFocus = useCallback(() => { + context.dispatch({ + type: types.SET_FOCUS, + payload: {ref}, + }); + }, [ref, context]); + + const isActive = context.state.activeRef === ref; + return [onFocus, isActive, ref]; +}; + +// Wrapper to allow use of useRovingTabIndex outside of React Functional Components. +export const RovingTabIndexWrapper = ({children, inputRef}) => { + const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); + return children({onFocus, isActive, ref}); +}; + diff --git a/src/actions/RoomListActions.js b/src/actions/RoomListActions.js index e5911c4e32..10a3848dda 100644 --- a/src/actions/RoomListActions.js +++ b/src/actions/RoomListActions.js @@ -15,12 +15,11 @@ limitations under the License. */ import { asyncAction } from './actionCreators'; -import RoomListStore from '../stores/RoomListStore'; - +import RoomListStore, {TAG_DM} from '../stores/RoomListStore'; import Modal from '../Modal'; import * as Rooms from '../Rooms'; import { _t } from '../languageHandler'; -import sdk from '../index'; +import * as sdk from '../index'; const RoomListActions = {}; @@ -74,11 +73,11 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, const roomId = room.roomId; // Evil hack to get DMs behaving - if ((oldTag === undefined && newTag === 'im.vector.fake.direct') || - (oldTag === 'im.vector.fake.direct' && newTag === undefined) + if ((oldTag === undefined && newTag === TAG_DM) || + (oldTag === TAG_DM && newTag === undefined) ) { return Rooms.guessAndSetDMRoom( - room, newTag === 'im.vector.fake.direct', + room, newTag === TAG_DM, ).catch((err) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to set direct chat tag " + err); @@ -92,10 +91,10 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, const hasChangedSubLists = oldTag !== newTag; // More evilness: We will still be dealing with moving to favourites/low prio, - // but we avoid ever doing a request with 'im.vector.fake.direct`. + // but we avoid ever doing a request with TAG_DM. // // if we moved lists, remove the old tag - if (oldTag && oldTag !== 'im.vector.fake.direct' && + if (oldTag && oldTag !== TAG_DM && hasChangedSubLists ) { const promiseToDelete = matrixClient.deleteRoomTag( @@ -113,7 +112,7 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, } // if we moved lists or the ordering changed, add the new tag - if (newTag && newTag !== 'im.vector.fake.direct' && + if (newTag && newTag !== TAG_DM && (hasChangedSubLists || metaData) ) { // metaData is the body of the PUT to set the tag, so it must diff --git a/src/async-components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js index 145203136a..b602cf60fe 100644 --- a/src/async-components/views/dialogs/EncryptedEventDialog.js +++ b/src/async-components/views/dialogs/EncryptedEventDialog.js @@ -14,14 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -const React = require("react"); +import React from "react"; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; -const sdk = require('../../../index'); -const MatrixClientPeg = require("../../../MatrixClientPeg"); +import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import {Key} from "../../../Keyboard"; +import * as sdk from "../../../index"; -module.exports = createReactClass({ +// XXX: This component is not cross-signing aware. +// https://github.com/vector-im/riot-web/issues/11752 tracks either updating this +// component or taking it out to pasture. +export default createReactClass({ displayName: 'EncryptedEventDialog', propTypes: { @@ -83,7 +87,7 @@ module.exports = createReactClass({ }, onKeyDown: function(e) { - if (e.keyCode === 27) { // escape + if (e.key === Key.ESCAPE) { e.stopPropagation(); e.preventDefault(); this.props.onFinished(false); @@ -187,7 +191,7 @@ module.exports = createReactClass({

{ _t('Event information') }

{ this._renderEventInfo() } -

{ _t('Sender device information') }

+

{ _t('Sender session information') }

{ this._renderDeviceInfo() }
diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js index 0fd412935a..481075d0fa 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -15,14 +15,14 @@ limitations under the License. */ import FileSaver from 'file-saver'; -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import { _t } from '../../../languageHandler'; import { MatrixClient } from 'matrix-js-sdk'; import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; -import sdk from '../../../index'; +import * as sdk from '../../../index'; const PHASE_EDIT = 1; const PHASE_EXPORTING = 2; @@ -44,6 +44,9 @@ export default createReactClass({ componentWillMount: function() { this._unmounted = false; + + this._passphrase1 = createRef(); + this._passphrase2 = createRef(); }, componentWillUnmount: function() { @@ -53,8 +56,8 @@ export default createReactClass({ _onPassphraseFormSubmit: function(ev) { ev.preventDefault(); - const passphrase = this.refs.passphrase1.value; - if (passphrase !== this.refs.passphrase2.value) { + const passphrase = this._passphrase1.current.value; + if (passphrase !== this._passphrase2.current.value) { this.setState({errStr: _t('Passphrases must match')}); return false; } @@ -148,7 +151,7 @@ export default createReactClass({
- @@ -161,7 +164,7 @@ export default createReactClass({
- diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index 17f3bba117..591c84f5d3 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import { MatrixClient } from 'matrix-js-sdk'; import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; -import sdk from '../../../index'; +import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; function readFileAsArrayBuffer(file) { @@ -56,6 +56,9 @@ export default createReactClass({ componentWillMount: function() { this._unmounted = false; + + this._file = createRef(); + this._passphrase = createRef(); }, componentWillUnmount: function() { @@ -63,15 +66,15 @@ export default createReactClass({ }, _onFormChange: function(ev) { - const files = this.refs.file.files || []; + const files = this._file.current.files || []; this.setState({ - enableSubmit: (this.refs.passphrase.value !== "" && files.length > 0), + enableSubmit: (this._passphrase.current.value !== "" && files.length > 0), }); }, _onFormSubmit: function(ev) { ev.preventDefault(); - this._startImport(this.refs.file.files[0], this.refs.passphrase.value); + this._startImport(this._file.current.files[0], this._passphrase.current.value); return false; }, @@ -146,7 +149,10 @@ export default createReactClass({
- @@ -159,8 +165,11 @@ export default createReactClass({
-
diff --git a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js new file mode 100644 index 0000000000..120b086ef6 --- /dev/null +++ b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js @@ -0,0 +1,73 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import * as sdk from '../../../../index'; +import PropTypes from 'prop-types'; +import dis from "../../../../dispatcher"; +import { _t } from '../../../../languageHandler'; + +import SettingsStore, {SettingLevel} from "../../../../settings/SettingsStore"; +import EventIndexPeg from "../../../../indexing/EventIndexPeg"; + +/* + * Allows the user to disable the Event Index. + */ +export default class DisableEventIndexDialog extends React.Component { + static propTypes = { + onFinished: PropTypes.func.isRequired, + } + + constructor(props) { + super(props); + + this.state = { + disabling: false, + }; + } + + _onDisable = async () => { + this.setState({ + disabling: true, + }); + + await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false); + await EventIndexPeg.deleteEventIndex(); + this.props.onFinished(); + dis.dispatch({ action: 'view_user_settings' }); + } + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const Spinner = sdk.getComponent('elements.Spinner'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + return ( + + {_t("If disabled, messages from encrypted rooms won't appear in search results.")} + {this.state.disabling ? :
} + + + ); + } +} diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js new file mode 100644 index 0000000000..f3ea3beb1c --- /dev/null +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js @@ -0,0 +1,198 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import * as sdk from '../../../../index'; +import PropTypes from 'prop-types'; +import { _t } from '../../../../languageHandler'; +import SettingsStore, {SettingLevel} from "../../../../settings/SettingsStore"; + +import Modal from '../../../../Modal'; +import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils"; +import EventIndexPeg from "../../../../indexing/EventIndexPeg"; + +/* + * Allows the user to introspect the event index state and disable it. + */ +export default class ManageEventIndexDialog extends React.Component { + static propTypes = { + onFinished: PropTypes.func.isRequired, + } + + constructor(props) { + super(props); + + this.state = { + eventIndexSize: 0, + eventCount: 0, + crawlingRoomsCount: 0, + roomCount: 0, + currentRoom: null, + crawlerSleepTime: + SettingsStore.getValueAt(SettingLevel.DEVICE, 'crawlerSleepTime'), + }; + } + + updateCurrentRoom = async (room) => { + const eventIndex = EventIndexPeg.get(); + let stats; + + try { + stats = await eventIndex.getStats(); + } catch { + // This call may fail if sporadically, not a huge issue as we will + // try later again and probably succeed. + return; + } + + let currentRoom = null; + + if (room) currentRoom = room.name; + const roomStats = eventIndex.crawlingRooms(); + const crawlingRoomsCount = roomStats.crawlingRooms.size; + const roomCount = roomStats.totalRooms.size; + + this.setState({ + eventIndexSize: stats.size, + eventCount: stats.eventCount, + crawlingRoomsCount: crawlingRoomsCount, + roomCount: roomCount, + currentRoom: currentRoom, + }); + }; + + componentWillUnmount(): void { + const eventIndex = EventIndexPeg.get(); + + if (eventIndex !== null) { + eventIndex.removeListener("changedCheckpoint", this.updateCurrentRoom); + } + } + + async componentWillMount(): void { + let eventIndexSize = 0; + let crawlingRoomsCount = 0; + let roomCount = 0; + let eventCount = 0; + let currentRoom = null; + + const eventIndex = EventIndexPeg.get(); + + if (eventIndex !== null) { + eventIndex.on("changedCheckpoint", this.updateCurrentRoom); + + try { + const stats = await eventIndex.getStats(); + eventIndexSize = stats.size; + eventCount = stats.eventCount; + } catch { + // This call may fail if sporadically, not a huge issue as we + // will try later again in the updateCurrentRoom call and + // probably succeed. + } + + const roomStats = eventIndex.crawlingRooms(); + crawlingRoomsCount = roomStats.crawlingRooms.size; + roomCount = roomStats.totalRooms.size; + + const room = eventIndex.currentRoom(); + if (room) currentRoom = room.name; + } + + this.setState({ + eventIndexSize, + eventCount, + crawlingRoomsCount, + roomCount, + currentRoom, + }); + } + + _onDisable = async () => { + Modal.createTrackedDialogAsync("Disable message search", "Disable message search", + import("./DisableEventIndexDialog"), + null, null, /* priority = */ false, /* static = */ true, + ); + } + + _onDone = () => { + this.props.onFinished(true); + } + + _onCrawlerSleepTimeChange = (e) => { + this.setState({crawlerSleepTime: e.target.value}); + SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value); + } + + render() { + let crawlerState; + + if (this.state.currentRoom === null) { + crawlerState = _t("Not currently downloading messages for any room."); + } else { + crawlerState = ( + _t("Downloading mesages for %(currentRoom)s.", { currentRoom: this.state.currentRoom }) + ); + } + + const Field = sdk.getComponent('views.elements.Field'); + + const eventIndexingSettings = ( +
+ { + _t( "Riot is securely caching encrypted messages locally for them " + + "to appear in search results:", + ) + } +
+ {_t("Space used:")} {formatBytes(this.state.eventIndexSize, 0)}
+ {_t("Indexed messages:")} {formatCountLong(this.state.eventCount)}
+ {_t("Indexed rooms:")} {_t("%(crawlingRooms)s out of %(totalRooms)s", { + crawlingRooms: formatCountLong(this.state.crawlingRoomsCount), + totalRooms: formatCountLong(this.state.roomCount), + })}
+ {crawlerState}
+ +
+
+ ); + + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + return ( + + {eventIndexingSettings} + + + ); + } +} diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index 4953cdff68..3a480a2579 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -1,5 +1,6 @@ /* Copyright 2018, 2019 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,14 +16,15 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; -import sdk from '../../../../index'; -import MatrixClientPeg from '../../../../MatrixClientPeg'; -import { scorePassword } from '../../../../utils/PasswordScorer'; - import FileSaver from 'file-saver'; - +import * as sdk from '../../../../index'; +import {MatrixClientPeg} from '../../../../MatrixClientPeg'; +import PropTypes from 'prop-types'; +import { scorePassword } from '../../../../utils/PasswordScorer'; import { _t } from '../../../../languageHandler'; +import { accessSecretStorage } from '../../../../CrossSigningManager'; +import SettingsStore from '../../../../settings/SettingsStore'; +import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; const PHASE_PASSPHRASE = 0; const PHASE_PASSPHRASE_CONFIRM = 1; @@ -45,40 +47,60 @@ function selectText(target) { selection.addRange(range); } -/** +/* * Walks the user through the process of creating an e2e key backup * on the server. */ -export default createReactClass({ - getInitialState: function() { - return { +export default class CreateKeyBackupDialog extends React.PureComponent { + static propTypes = { + onFinished: PropTypes.func.isRequired, + } + + constructor(props) { + super(props); + + this._recoveryKeyNode = null; + this._keyBackupInfo = null; + this._setZxcvbnResultTimeout = null; + + this.state = { + secureSecretStorage: null, phase: PHASE_PASSPHRASE, passPhrase: '', passPhraseConfirm: '', copied: false, downloaded: false, zxcvbnResult: null, - setPassPhrase: false, }; - }, + } - componentWillMount: function() { - this._recoveryKeyNode = null; - this._keyBackupInfo = null; - this._setZxcvbnResultTimeout = null; - }, + async componentDidMount() { + const cli = MatrixClientPeg.get(); + const secureSecretStorage = ( + SettingsStore.isFeatureEnabled("feature_cross_signing") && + await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing") + ); + this.setState({ secureSecretStorage }); - componentWillUnmount: function() { + // If we're using secret storage, skip ahead to the backing up step, as + // `accessSecretStorage` will handle passphrases as needed. + if (secureSecretStorage) { + this.setState({ phase: PHASE_BACKINGUP }); + this._createBackup(); + } + } + + componentWillUnmount() { if (this._setZxcvbnResultTimeout !== null) { clearTimeout(this._setZxcvbnResultTimeout); } - }, + } - _collectRecoveryKeyNode: function(n) { + _collectRecoveryKeyNode = (n) => { this._recoveryKeyNode = n; - }, + } - _onCopyClick: function() { + _onCopyClick = () => { selectText(this._recoveryKeyNode); const successful = document.execCommand('copy'); if (successful) { @@ -87,9 +109,9 @@ export default createReactClass({ phase: PHASE_KEEPITSAFE, }); } - }, + } - _onDownloadClick: function() { + _onDownloadClick = () => { const blob = new Blob([this._keyBackupInfo.recovery_key], { type: 'text/plain;charset=us-ascii', }); @@ -99,24 +121,35 @@ export default createReactClass({ downloaded: true, phase: PHASE_KEEPITSAFE, }); - }, + } - _createBackup: async function() { + _createBackup = async () => { + const { secureSecretStorage } = this.state; this.setState({ phase: PHASE_BACKINGUP, error: null, }); let info; try { - info = await MatrixClientPeg.get().createKeyBackupVersion( - this._keyBackupInfo, - ); + if (secureSecretStorage) { + await accessSecretStorage(async () => { + info = await MatrixClientPeg.get().prepareKeyBackupVersion( + null /* random key */, + { secureSecretStorage: true }, + ); + info = await MatrixClientPeg.get().createKeyBackupVersion(info); + }); + } else { + info = await MatrixClientPeg.get().createKeyBackupVersion( + this._keyBackupInfo, + ); + } await MatrixClientPeg.get().scheduleAllGroupSessionsForBackup(); this.setState({ phase: PHASE_DONE, }); } catch (e) { - console.log("Error creating key backup", e); + console.error("Error creating key backup", e); // TODO: If creating a version succeeds, but backup fails, should we // delete the version, disable backup, or do nothing? If we just // disable without deleting, we'll enable on next app reload since @@ -128,89 +161,82 @@ export default createReactClass({ error: e, }); } - }, + } - _onCancel: function() { + _onCancel = () => { this.props.onFinished(false); - }, + } - _onDone: function() { + _onDone = () => { this.props.onFinished(true); - }, + } - _onOptOutClick: function() { + _onOptOutClick = () => { this.setState({phase: PHASE_OPTOUT_CONFIRM}); - }, + } - _onSetUpClick: function() { + _onSetUpClick = () => { this.setState({phase: PHASE_PASSPHRASE}); - }, + } - _onSkipPassPhraseClick: async function() { + _onSkipPassPhraseClick = async () => { this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(); this.setState({ copied: false, downloaded: false, phase: PHASE_SHOWKEY, }); - }, + } - _onPassPhraseNextClick: function() { - this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); - }, + _onPassPhraseNextClick = async (e) => { + e.preventDefault(); - _onPassPhraseKeyPress: async function(e) { - if (e.key === 'Enter') { - // If we're waiting for the timeout before updating the result at this point, - // skip ahead and do it now, otherwise we'll deny the attempt to proceed - // even if the user entered a valid passphrase - if (this._setZxcvbnResultTimeout !== null) { - clearTimeout(this._setZxcvbnResultTimeout); - this._setZxcvbnResultTimeout = null; - await new Promise((resolve) => { - this.setState({ - zxcvbnResult: scorePassword(this.state.passPhrase), - }, resolve); - }); - } - if (this._passPhraseIsValid()) { - this._onPassPhraseNextClick(); - } + // If we're waiting for the timeout before updating the result at this point, + // skip ahead and do it now, otherwise we'll deny the attempt to proceed + // even if the user entered a valid passphrase + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + this._setZxcvbnResultTimeout = null; + await new Promise((resolve) => { + this.setState({ + zxcvbnResult: scorePassword(this.state.passPhrase), + }, resolve); + }); } - }, + if (this._passPhraseIsValid()) { + this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + } + }; + + _onPassPhraseConfirmNextClick = async (e) => { + e.preventDefault(); + + if (this.state.passPhrase !== this.state.passPhraseConfirm) return; - _onPassPhraseConfirmNextClick: async function() { this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(this.state.passPhrase); this.setState({ - setPassPhrase: true, copied: false, downloaded: false, phase: PHASE_SHOWKEY, }); - }, + }; - _onPassPhraseConfirmKeyPress: function(e) { - if (e.key === 'Enter' && this.state.passPhrase === this.state.passPhraseConfirm) { - this._onPassPhraseConfirmNextClick(); - } - }, - - _onSetAgainClick: function() { + _onSetAgainClick = () => { this.setState({ passPhrase: '', passPhraseConfirm: '', phase: PHASE_PASSPHRASE, zxcvbnResult: null, }); - }, + } - _onKeepItSafeBackClick: function() { + _onKeepItSafeBackClick = () => { this.setState({ phase: PHASE_SHOWKEY, }); - }, + } - _onPassPhraseChange: function(e) { + _onPassPhraseChange = (e) => { this.setState({ passPhrase: e.target.value, }); @@ -227,19 +253,19 @@ export default createReactClass({ zxcvbnResult: scorePassword(this.state.passPhrase), }); }, PASSPHRASE_FEEDBACK_DELAY); - }, + } - _onPassPhraseConfirmChange: function(e) { + _onPassPhraseConfirmChange = (e) => { this.setState({ passPhraseConfirm: e.target.value, }); - }, + } - _passPhraseIsValid: function() { + _passPhraseIsValid() { return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE; - }, + } - _renderPhasePassPhrase: function() { + _renderPhasePassPhrase() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); let strengthMeter; @@ -264,9 +290,9 @@ export default createReactClass({
; } - return
+ return

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

{_t( @@ -279,7 +305,6 @@ export default createReactClass({

- {_t("Advanced")} -

+ + {_t("Set up with a recovery key")} + -
; - }, + ; + } - _renderPhasePassPhraseConfirm: function() { + _renderPhasePassPhraseConfirm() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); let matchText; @@ -336,7 +362,7 @@ export default createReactClass({ ; } const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return
+ return

{_t( "Please enter your passphrase a second time to confirm.", )}

@@ -345,7 +371,6 @@ export default createReactClass({
- -
; - }, - - _renderPhaseShowKey: function() { - let bodyText; - if (this.state.setPassPhrase) { - bodyText = _t( - "As a safety net, you can use it to restore your encrypted message " + - "history if you forget your Recovery Passphrase.", - ); - } else { - bodyText = _t("As a safety net, you can use it to restore your encrypted message history."); - } + ; + } + _renderPhaseShowKey() { return

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

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

-

{bodyText}

- {_t("Your Recovery Key")} + {_t("Your recovery key")}
@@ -393,7 +408,7 @@ export default createReactClass({
; - }, + } - _renderPhaseKeepItSafe: function() { + _renderPhaseKeepItSafe() { let introText; if (this.state.copied) { introText = _t( - "Your Recovery Key has been copied to your clipboard, paste it to:", + "Your recovery key has been copied to your clipboard, paste it to:", {}, {b: s => {s}}, ); } else if (this.state.downloaded) { introText = _t( - "Your Recovery Key is in your Downloads folder.", + "Your recovery key is in your Downloads folder.", {}, {b: s => {s}}, ); } @@ -425,22 +440,22 @@ export default createReactClass({
  • {_t("Save it on a USB key or backup drive", {}, {b: s => {s}})}
  • {_t("Copy it to your personal cloud storage", {}, {b: s => {s}})}
  • -
    ; - }, + } - _renderBusyPhase: function(text) { + _renderBusyPhase(text) { const Spinner = sdk.getComponent('views.elements.Spinner'); return
    ; - }, + } - _renderPhaseDone: function() { + _renderPhaseDone() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

    {_t( @@ -451,14 +466,14 @@ export default createReactClass({ hasCancel={false} />

    ; - }, + } - _renderPhaseOptOutConfirm: function() { + _renderPhaseOptOutConfirm() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
    {_t( "Without setting up Secure Message Recovery, you won't be able to restore your " + - "encrypted message history if you log out or use another device.", + "encrypted message history if you log out or use another session.", )} I understand, continue without
    ; - }, + } - _titleForPhase: function(phase) { + _titleForPhase(phase) { switch (phase) { case PHASE_PASSPHRASE: return _t('Secure your backup with a passphrase'); @@ -478,19 +493,18 @@ export default createReactClass({ case PHASE_OPTOUT_CONFIRM: return _t('Warning!'); case PHASE_SHOWKEY: - return _t('Recovery key'); case PHASE_KEEPITSAFE: - return _t('Keep it safe'); + return _t('Make a copy of your recovery key'); case PHASE_BACKINGUP: return _t('Starting backup...'); case PHASE_DONE: return _t('Success!'); default: - return _t("Create Key Backup"); + return _t("Create key backup"); } - }, + } - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); let content; @@ -543,5 +557,5 @@ export default createReactClass({
    ); - }, -}); + } +} diff --git a/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js b/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js index a9df3cca6e..b79911c66e 100644 --- a/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js +++ b/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js @@ -16,7 +16,7 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; -import sdk from "../../../../index"; +import * as sdk from "../../../../index"; import { _t } from "../../../../languageHandler"; export default class IgnoreRecoveryReminderDialog extends React.PureComponent { diff --git a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js index 28281af771..6588ff5191 100644 --- a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js +++ b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js @@ -1,5 +1,6 @@ /* -Copyright 2018-2019 New Vector Ltd +Copyright 2018, 2019 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,8 +17,8 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; -import sdk from "../../../../index"; -import MatrixClientPeg from '../../../../MatrixClientPeg'; +import * as sdk from "../../../../index"; +import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import dis from "../../../../dispatcher"; import { _t } from "../../../../languageHandler"; import Modal from "../../../../Modal"; @@ -40,9 +41,11 @@ export default class NewRecoveryMethodDialog extends React.PureComponent { onSetupClick = async () => { const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); - Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, { - onFinished: this.props.onFinished, - }); + Modal.createTrackedDialog( + 'Restore Backup', '', RestoreKeyBackupDialog, { + onFinished: this.props.onFinished, + }, null, /* priority = */ false, /* static = */ true, + ); } render() { @@ -70,7 +73,7 @@ export default class NewRecoveryMethodDialog extends React.PureComponent { content =
    {newMethodDetected}

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

    {hackWarning}

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

    {_t( "If you did this accidentally, you can setup Secure Messages on " + - "this device which will re-encrypt this device's message " + + "this session which will re-encrypt this session's message " + "history with a new recovery method.", )}

    {_t( diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js new file mode 100644 index 0000000000..49b103ecf7 --- /dev/null +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -0,0 +1,787 @@ +/* +Copyright 2018, 2019 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import * as sdk from '../../../../index'; +import {MatrixClientPeg} from '../../../../MatrixClientPeg'; +import { scorePassword } from '../../../../utils/PasswordScorer'; +import FileSaver from 'file-saver'; +import { _t } from '../../../../languageHandler'; +import Modal from '../../../../Modal'; + +const PHASE_LOADING = 0; +const PHASE_MIGRATE = 1; +const PHASE_PASSPHRASE = 2; +const PHASE_PASSPHRASE_CONFIRM = 3; +const PHASE_SHOWKEY = 4; +const PHASE_KEEPITSAFE = 5; +const PHASE_STORING = 6; +const PHASE_DONE = 7; +const PHASE_CONFIRM_SKIP = 8; + +const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. +const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms. + +// XXX: copied from ShareDialog: factor out into utils +function selectText(target) { + const range = document.createRange(); + range.selectNodeContents(target); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +/* + * Walks the user through the process of creating a passphrase to guard Secure + * Secret Storage in account data. + */ +export default class CreateSecretStorageDialog extends React.PureComponent { + static propTypes = { + hasCancel: PropTypes.bool, + accountPassword: PropTypes.string, + force: PropTypes.bool, + }; + + static defaultProps = { + hasCancel: true, + force: false, + }; + + constructor(props) { + super(props); + + this._keyInfo = null; + this._encodedRecoveryKey = null; + this._recoveryKeyNode = null; + this._setZxcvbnResultTimeout = null; + + this.state = { + phase: PHASE_LOADING, + passPhrase: '', + passPhraseConfirm: '', + copied: false, + downloaded: false, + zxcvbnResult: null, + backupInfo: null, + backupSigStatus: null, + // does the server offer a UI auth flow with just m.login.password + // for /keys/device_signing/upload? + canUploadKeysWithPasswordOnly: null, + accountPassword: props.accountPassword || "", + accountPasswordCorrect: null, + // status of the key backup toggle switch + useKeyBackup: true, + }; + + this._fetchBackupInfo(); + this._queryKeyUploadAuth(); + + MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); + } + + componentWillUnmount() { + MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange); + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + } + } + + async _fetchBackupInfo() { + const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); + const backupSigStatus = ( + // we may not have started crypto yet, in which case we definitely don't trust the backup + MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) + ); + + const { force } = this.props; + const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_PASSPHRASE; + + this.setState({ + phase, + backupInfo, + backupSigStatus, + }); + + return { + backupInfo, + backupSigStatus, + }; + } + + async _queryKeyUploadAuth() { + try { + await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {}); + // We should never get here: the server should always require + // UI auth to upload device signing keys. If we do, we upload + // no keys which would be a no-op. + console.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); + } catch (error) { + if (!error.data.flows) { + console.log("uploadDeviceSigningKeys advertised no flows!"); + } + const canUploadKeysWithPasswordOnly = error.data.flows.some(f => { + return f.stages.length === 1 && f.stages[0] === 'm.login.password'; + }); + this.setState({ + canUploadKeysWithPasswordOnly, + }); + } + } + + _onKeyBackupStatusChange = () => { + if (this.state.phase === PHASE_MIGRATE) this._fetchBackupInfo(); + } + + _collectRecoveryKeyNode = (n) => { + this._recoveryKeyNode = n; + } + + _onUseKeyBackupChange = (enabled) => { + this.setState({ + useKeyBackup: enabled, + }); + } + + _onMigrateFormSubmit = (e) => { + e.preventDefault(); + if (this.state.backupSigStatus.usable) { + this._bootstrapSecretStorage(); + } else { + this._restoreBackup(); + } + } + + _onCopyClick = () => { + selectText(this._recoveryKeyNode); + const successful = document.execCommand('copy'); + if (successful) { + this.setState({ + copied: true, + phase: PHASE_KEEPITSAFE, + }); + } + } + + _onDownloadClick = () => { + const blob = new Blob([this._encodedRecoveryKey], { + type: 'text/plain;charset=us-ascii', + }); + FileSaver.saveAs(blob, 'recovery-key.txt'); + + this.setState({ + downloaded: true, + phase: PHASE_KEEPITSAFE, + }); + } + + _doBootstrapUIAuth = async (makeRequest) => { + if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { + await makeRequest({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: MatrixClientPeg.get().getUserId(), + }, + // https://github.com/matrix-org/synapse/issues/5665 + user: MatrixClientPeg.get().getUserId(), + password: this.state.accountPassword, + }); + } else { + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Setting up keys"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + } + } + + _bootstrapSecretStorage = async () => { + this.setState({ + phase: PHASE_STORING, + error: null, + }); + + const cli = MatrixClientPeg.get(); + + const { force } = this.props; + + try { + if (force) { + await cli.bootstrapSecretStorage({ + authUploadDeviceSigningKeys: this._doBootstrapUIAuth, + createSecretStorageKey: async () => this._keyInfo, + setupNewKeyBackup: true, + setupNewSecretStorage: true, + }); + } else { + await cli.bootstrapSecretStorage({ + authUploadDeviceSigningKeys: this._doBootstrapUIAuth, + createSecretStorageKey: async () => this._keyInfo, + keyBackupInfo: this.state.backupInfo, + setupNewKeyBackup: !this.state.backupInfo && this.state.useKeyBackup, + }); + } + this.setState({ + phase: PHASE_DONE, + }); + } catch (e) { + if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) { + this.setState({ + accountPassword: '', + accountPasswordCorrect: false, + phase: PHASE_MIGRATE, + }); + } else { + this.setState({ error: e }); + } + console.error("Error bootstrapping secret storage", e); + } + } + + _onCancel = () => { + this.props.onFinished(false); + } + + _onDone = () => { + this.props.onFinished(true); + } + + _restoreBackup = async () => { + const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); + const { finished } = Modal.createTrackedDialog( + 'Restore Backup', '', RestoreKeyBackupDialog, {showSummary: false}, null, + /* priority = */ false, /* static = */ false, + ); + + await finished; + const { backupSigStatus } = await this._fetchBackupInfo(); + if ( + backupSigStatus.usable && + this.state.canUploadKeysWithPasswordOnly && + this.state.accountPassword + ) { + this._bootstrapSecretStorage(); + } + } + + _onSkipSetupClick = () => { + this.setState({phase: PHASE_CONFIRM_SKIP}); + } + + _onSetUpClick = () => { + this.setState({phase: PHASE_PASSPHRASE}); + } + + _onSkipPassPhraseClick = async () => { + const [keyInfo, encodedRecoveryKey] = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); + this._keyInfo = keyInfo; + this._encodedRecoveryKey = encodedRecoveryKey; + this.setState({ + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseNextClick = async (e) => { + e.preventDefault(); + + // If we're waiting for the timeout before updating the result at this point, + // skip ahead and do it now, otherwise we'll deny the attempt to proceed + // even if the user entered a valid passphrase + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + this._setZxcvbnResultTimeout = null; + await new Promise((resolve) => { + this.setState({ + zxcvbnResult: scorePassword(this.state.passPhrase), + }, resolve); + }); + } + if (this._passPhraseIsValid()) { + this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + } + }; + + _onPassPhraseConfirmNextClick = async (e) => { + e.preventDefault(); + + if (this.state.passPhrase !== this.state.passPhraseConfirm) return; + + const [keyInfo, encodedRecoveryKey] = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase); + this._keyInfo = keyInfo; + this._encodedRecoveryKey = encodedRecoveryKey; + this.setState({ + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onSetAgainClick = () => { + this.setState({ + passPhrase: '', + passPhraseConfirm: '', + phase: PHASE_PASSPHRASE, + zxcvbnResult: null, + }); + } + + _onKeepItSafeBackClick = () => { + this.setState({ + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseChange = (e) => { + this.setState({ + passPhrase: e.target.value, + }); + + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + } + this._setZxcvbnResultTimeout = setTimeout(() => { + this._setZxcvbnResultTimeout = null; + this.setState({ + // precompute this and keep it in state: zxcvbn is fast but + // we use it in a couple of different places so no point recomputing + // it unnecessarily. + zxcvbnResult: scorePassword(this.state.passPhrase), + }); + }, PASSPHRASE_FEEDBACK_DELAY); + } + + _onPassPhraseConfirmChange = (e) => { + this.setState({ + passPhraseConfirm: e.target.value, + }); + } + + _passPhraseIsValid() { + return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE; + } + + _onAccountPasswordChange = (e) => { + this.setState({ + accountPassword: e.target.value, + }); + } + + _renderPhaseMigrate() { + // TODO: This is a temporary screen so people who have the labs flag turned on and + // click the button are aware they're making a change to their account. + // Once we're confident enough in this (and it's supported enough) we can do + // it automatically. + // https://github.com/vector-im/riot-web/issues/11696 + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const Field = sdk.getComponent('views.elements.Field'); + + let authPrompt; + let nextCaption = _t("Next"); + if (this.state.canUploadKeysWithPasswordOnly) { + authPrompt =

    +
    {_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")}
    +
    ; + nextCaption = _t("Restore"); + } else { + authPrompt =

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

    ; + } + + return
    +

    {_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}
    + + + + ; + } + + _renderPhasePassPhrase() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const Field = sdk.getComponent('views.elements.Field'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch'); + + let strengthMeter; + let helpText; + if (this.state.zxcvbnResult) { + if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) { + helpText = _t("Great! This passphrase looks strong enough."); + } else { + // We take the warning from zxcvbn or failing that, the first + // suggestion. In practice The first is generally the most relevant + // and it's probably better to present the user with one thing to + // improve about their password than a whole collection - it can + // spit out a warning and multiple suggestions which starts getting + // very information-dense. + const suggestion = ( + this.state.zxcvbnResult.feedback.warning || + this.state.zxcvbnResult.feedback.suggestions[0] + ); + const suggestionBlock =
    {suggestion || _t("Keep going...")}
    ; + + helpText =
    + {suggestionBlock} +
    ; + } + strengthMeter =
    + +
    ; + } + + return
    +

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

    +

    {_t( + "Secure your encryption keys with a passphrase. For maximum security " + + "this should be different to your account password:", + )}

    + +
    + +
    + {strengthMeter} + {helpText} +
    +
    + + + + + + + +
    + {_t("Advanced")} + + {_t("Set up with a recovery key")} + +
    + ; + } + + _renderPhasePassPhraseConfirm() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const Field = sdk.getComponent('views.elements.Field'); + + let matchText; + if (this.state.passPhraseConfirm === this.state.passPhrase) { + matchText = _t("That matches!"); + } else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) { + // only tell them they're wrong if they've actually gone wrong. + // Security concious readers will note that if you left riot-web unattended + // on this screen, this would make it easy for a malicious person to guess + // your passphrase one letter at a time, but they could get this faster by + // just opening the browser's developer tools and reading it. + // Note that not having typed anything at all will not hit this clause and + // fall through so empty box === no hint. + matchText = _t("That doesn't match."); + } + + let passPhraseMatch = null; + if (matchText) { + passPhraseMatch =
    +
    {matchText}
    +
    + + {_t("Go back to set it again.")} + +
    +
    ; + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
    +

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

    +
    + +
    + {passPhraseMatch} +
    +
    + + + + ; + } + + _renderPhaseShowKey() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return
    +

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

    +

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

    +
    +
    + {_t("Your recovery key")} +
    +
    +
    + {this._encodedRecoveryKey} +
    +
    + + {_t("Copy")} + + + {_t("Download")} + +
    +
    +
    +
    ; + } + + _renderPhaseKeepItSafe() { + let introText; + if (this.state.copied) { + introText = _t( + "Your recovery key has been copied to your clipboard, paste it to:", + {}, {b: s => {s}}, + ); + } else if (this.state.downloaded) { + introText = _t( + "Your recovery key is in your Downloads folder.", + {}, {b: s => {s}}, + ); + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
    + {introText} +
      +
    • {_t("Print it and store it somewhere safe", {}, {b: s => {s}})}
    • +
    • {_t("Save it on a USB key or backup drive", {}, {b: s => {s}})}
    • +
    • {_t("Copy it to your personal cloud storage", {}, {b: s => {s}})}
    • +
    + + + +
    ; + } + + _renderBusyPhase() { + const Spinner = sdk.getComponent('views.elements.Spinner'); + return
    + +
    ; + } + + _renderPhaseDone() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
    +

    {_t( + "You can now verify your other devices, " + + "and other users to keep your chats safe.", + )}

    + +
    ; + } + + _renderPhaseSkipConfirm() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
    + {_t( + "Without completing security on this session, it won’t have " + + "access to encrypted messages.", + )} + + + +
    ; + } + + _titleForPhase(phase) { + switch (phase) { + case PHASE_MIGRATE: + return _t('Upgrade your encryption'); + case PHASE_PASSPHRASE: + return _t('Set up encryption'); + case PHASE_PASSPHRASE_CONFIRM: + return _t('Confirm passphrase'); + case PHASE_CONFIRM_SKIP: + return _t('Are you sure?'); + case PHASE_SHOWKEY: + case PHASE_KEEPITSAFE: + return _t('Make a copy of your recovery key'); + case PHASE_STORING: + return _t('Setting up keys'); + case PHASE_DONE: + return _t("You're done!"); + default: + return ''; + } + } + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + + let content; + if (this.state.error) { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + content =
    +

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

    +
    + +
    +
    ; + } else { + switch (this.state.phase) { + case PHASE_LOADING: + content = this._renderBusyPhase(); + break; + case PHASE_MIGRATE: + content = this._renderPhaseMigrate(); + break; + case PHASE_PASSPHRASE: + content = this._renderPhasePassPhrase(); + break; + case PHASE_PASSPHRASE_CONFIRM: + content = this._renderPhasePassPhraseConfirm(); + break; + case PHASE_SHOWKEY: + content = this._renderPhaseShowKey(); + break; + case PHASE_KEEPITSAFE: + content = this._renderPhaseKeepItSafe(); + break; + case PHASE_STORING: + content = this._renderBusyPhase(); + break; + case PHASE_DONE: + content = this._renderPhaseDone(); + break; + case PHASE_CONFIRM_SKIP: + content = this._renderPhaseSkipConfirm(); + break; + } + } + + let headerImage; + if (this._titleForPhase(this.state.phase)) { + headerImage = require("../../../../../res/img/e2e/normal.svg"); + } + + return ( + +
    + {content} +
    +
    + ); + } +} diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index af2744950f..a26eb6033b 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -26,7 +26,7 @@ import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; import EmojiProvider from './EmojiProvider'; import NotifProvider from './NotifProvider'; -import Promise from 'bluebird'; +import {timeout} from "../utils/promise"; export type SelectionRange = { beginning: boolean, // whether the selection is in the first block of the editor or not @@ -77,23 +77,16 @@ export default class Autocompleter { while the user is interacting with the list, which makes it difficult to predict whether an action will actually do what is intended */ - const completionsList = await Promise.all( - // Array of inspections of promises that might timeout. Instead of allowing a - // single timeout to reject the Promise.all, reflect each one and once they've all - // settled, filter for the fulfilled ones - this.providers.map(provider => - provider - .getCompletions(query, selection, force) - .timeout(PROVIDER_COMPLETION_TIMEOUT) - .reflect(), - ), - ); + const completionsList = await Promise.all(this.providers.map(provider => { + return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT); + })); + + // map then filter to maintain the index for the map-operation, for this.providers to line up + return completionsList.map((completions, i) => { + if (!completions || !completions.length) return; - return completionsList.filter( - (inspection) => inspection.isFulfilled(), - ).map((completionsState, i) => { return { - completions: completionsState.value(), + completions, provider: this.providers[i], /* the currently matched "command" the completer tried to complete @@ -102,6 +95,6 @@ export default class Autocompleter { */ command: this.providers[i].getCurrentCommand(query, selection, force), }; - }); + }).filter(Boolean); } } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index b13680ece2..da8fa3ed3c 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -78,8 +78,10 @@ export default class CommandProvider extends AutocompleteProvider { } renderCompletions(completions: [React.Component]): ?React.Component { - return
    - { completions } -
    ; + return ( +
    + { completions } +
    + ); } } diff --git a/src/autocomplete/CommunityProvider.js b/src/autocomplete/CommunityProvider.js index 0acfd426fb..b863603aae 100644 --- a/src/autocomplete/CommunityProvider.js +++ b/src/autocomplete/CommunityProvider.js @@ -18,10 +18,10 @@ limitations under the License. import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import MatrixClientPeg from '../MatrixClientPeg'; +import {MatrixClientPeg} from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; -import sdk from '../index'; +import * as sdk from '../index'; import _sortBy from 'lodash/sortBy'; import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; import type {Completion, SelectionRange} from "./Autocompleter"; @@ -46,7 +46,7 @@ export default class CommunityProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array { + async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar'); // Disable autocompletions when composing commands because of various issues diff --git a/src/autocomplete/Components.js b/src/autocomplete/Components.js index ca105bb211..32bbeb46a0 100644 --- a/src/autocomplete/Components.js +++ b/src/autocomplete/Components.js @@ -34,7 +34,7 @@ export class TextualCompletion extends React.Component { ...restProps } = this.props; return ( -
    +
    { title } { subtitle } { description } diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index e25ef16428..8cff83554a 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -19,7 +19,6 @@ limitations under the License. import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import 'whatwg-fetch'; import {TextualCompletion} from './Components'; import type {SelectionRange} from "./Autocompleter"; @@ -37,7 +36,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; } - async getCompletions(query: string, selection: SelectionRange, force?: boolean = false) { + async getCompletions(query: string, selection: SelectionRange, force: boolean = false) { const {command, range} = this.getCurrentCommand(query, selection); if (!query || !command) { return []; @@ -97,8 +96,14 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { } renderCompletions(completions: [React.Component]): ?React.Component { - return
    - { completions } -
    ; + return ( +
    + { completions } +
    + ); } } diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index 1e39593022..9373ed662e 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -2,6 +2,7 @@ Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd Copyright 2017, 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -28,7 +29,7 @@ import SettingsStore from "../settings/SettingsStore"; import { shortcodeToUnicode } from '../HtmlUtils'; import EMOTICON_REGEX from 'emojibase-regex/emoticon'; -import EmojiData from '../stripped-emoji.json'; +import EMOJIBASE from 'emojibase-data/en/compact.json'; const LIMIT = 20; @@ -38,19 +39,15 @@ const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|:[+-\\w]*:?)$', ' // XXX: it's very unclear why we bother with this generated emojidata file. // all it means is that we end up bloating the bundle with precomputed stuff // which would be trivial to calculate and cache on demand. -const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sort( - (a, b) => { - if (a.category === b.category) { - return a.emoji_order - b.emoji_order; - } - return a.category - b.category; - }, -).map((a, index) => { +const EMOJI_SHORTNAMES = EMOJIBASE.sort((a, b) => { + if (a.group === b.group) { + return a.order - b.order; + } + return a.group - b.group; +}).map((emoji, index) => { return { - name: a.name, - shortname: a.shortname, - aliases: a.aliases ? a.aliases.join(' ') : '', - aliases_ascii: a.aliases_ascii ? a.aliases_ascii.join(' ') : '', + emoji, + shortname: `:${emoji.shortcodes[0]}:`, // Include the index so that we can preserve the original order _orderBy: index, }; @@ -69,12 +66,15 @@ export default class EmojiProvider extends AutocompleteProvider { constructor() { super(EMOJI_REGEX); this.matcher = new QueryMatcher(EMOJI_SHORTNAMES, { - keys: ['aliases_ascii', 'shortname', 'aliases'], + keys: ['emoji.emoticon', 'shortname'], + funcs: [ + (o) => o.emoji.shortcodes.length > 1 ? o.emoji.shortcodes.slice(1).map(s => `:${s}:`).join(" ") : "", // aliases + ], // For matching against ascii equivalents shouldMatchWordsOnly: false, }); this.nameMatcher = new QueryMatcher(EMOJI_SHORTNAMES, { - keys: ['name'], + keys: ['emoji.annotation'], // For removing punctuation shouldMatchWordsOnly: true, }); @@ -96,7 +96,7 @@ export default class EmojiProvider extends AutocompleteProvider { const sorters = []; // make sure that emoticons come first - sorters.push((c) => score(matchedString, c.aliases_ascii)); + 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)); @@ -110,8 +110,7 @@ export default class EmojiProvider extends AutocompleteProvider { sorters.push((c) => c._orderBy); completions = _sortBy(_uniq(completions), sorters); - completions = completions.map((result) => { - const { shortname } = result; + completions = completions.map(({shortname}) => { const unicode = shortcodeToUnicode(shortname); return { completion: unicode, diff --git a/src/autocomplete/NotifProvider.js b/src/autocomplete/NotifProvider.js index 95cfb34616..e7c8f6f70d 100644 --- a/src/autocomplete/NotifProvider.js +++ b/src/autocomplete/NotifProvider.js @@ -17,9 +17,9 @@ limitations under the License. import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import { _t } from '../languageHandler'; -import MatrixClientPeg from '../MatrixClientPeg'; +import {MatrixClientPeg} from '../MatrixClientPeg'; import {PillCompletion} from './Components'; -import sdk from '../index'; +import * as sdk from '../index'; import type {Completion, SelectionRange} from "./Autocompleter"; const AT_ROOM_REGEX = /@\S*/g; @@ -30,7 +30,7 @@ export default class NotifProvider extends AutocompleteProvider { this.room = room; } - async getCompletions(query: string, selection: SelectionRange, force?:boolean = false): Array { + async getCompletions(query: string, selection: SelectionRange, force:boolean = false): Array { const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const client = MatrixClientPeg.get(); diff --git a/src/autocomplete/PlainWithPillsSerializer.js b/src/autocomplete/PlainWithPillsSerializer.js deleted file mode 100644 index 09bb3772ac..0000000000 --- a/src/autocomplete/PlainWithPillsSerializer.js +++ /dev/null @@ -1,92 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Based originally on slate-plain-serializer - -import { Block } from 'slate'; - -/** - * Plain text serializer, which converts a Slate `value` to a plain text string, - * serializing pills into various different formats as required. - * - * @type {PlainWithPillsSerializer} - */ - -class PlainWithPillsSerializer { - /* - * @param {String} options.pillFormat - either 'md', 'plain', 'id' - */ - constructor(options = {}) { - const { - pillFormat = 'plain', - } = options; - this.pillFormat = pillFormat; - } - - /** - * Serialize a Slate `value` to a plain text string, - * serializing pills as either MD links, plain text representations or - * ID representations as required. - * - * @param {Value} value - * @return {String} - */ - serialize = value => { - return this._serializeNode(value.document); - } - - /** - * Serialize a `node` to plain text. - * - * @param {Node} node - * @return {String} - */ - _serializeNode = node => { - if ( - node.object == 'document' || - (node.object == 'block' && Block.isBlockList(node.nodes)) - ) { - return node.nodes.map(this._serializeNode).join('\n'); - } else if (node.type == 'emoji') { - return node.data.get('emojiUnicode'); - } else if (node.type == 'pill') { - const completion = node.data.get('completion'); - // over the wire the @room pill is just plaintext - if (completion === '@room') return completion; - - switch (this.pillFormat) { - case 'plain': - return completion; - case 'md': - return `[${ completion }](${ node.data.get('href') })`; - case 'id': - return node.data.get('completionId') || completion; - } - } else if (node.nodes) { - return node.nodes.map(this._serializeNode).join(''); - } else { - return node.text; - } - } -} - -/** - * Export. - * - * @type {PlainWithPillsSerializer} - */ - -export default PlainWithPillsSerializer; diff --git a/src/autocomplete/QueryMatcher.js b/src/autocomplete/QueryMatcher.js index a28d3003cf..ef1605e7a6 100644 --- a/src/autocomplete/QueryMatcher.js +++ b/src/autocomplete/QueryMatcher.js @@ -71,6 +71,7 @@ export default class QueryMatcher { } for (const keyValue of keyValues) { + if (!keyValue) continue; // skip falsy keyValues const key = stripDiacritics(keyValue).toLowerCase(); if (!this._items.has(key)) { this._items.set(key, []); diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index b67abc388e..a0f670e769 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -20,11 +20,10 @@ limitations under the License. import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import MatrixClientPeg from '../MatrixClientPeg'; +import {MatrixClientPeg} from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; -import {getDisplayAliasForRoom} from '../Rooms'; -import sdk from '../index'; +import * as sdk from '../index'; import _sortBy from 'lodash/sortBy'; import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; import type {Completion, SelectionRange} from "./Autocompleter"; @@ -40,15 +39,23 @@ function score(query, space) { } } +function matcherObject(room, displayedAlias, matchName = "") { + return { + room, + matchName, + displayedAlias, + }; +} + export default class RoomProvider extends AutocompleteProvider { constructor() { super(ROOM_REGEX); this.matcher = new QueryMatcher([], { - keys: ['displayedAlias', 'name'], + keys: ['displayedAlias', 'matchName'], }); } - async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array { + async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const client = MatrixClientPeg.get(); @@ -56,16 +63,16 @@ export default class RoomProvider extends AutocompleteProvider { const {command, range} = this.getCurrentCommand(query, selection, force); if (command) { // the only reason we need to do this is because Fuse only matches on properties - let matcherObjects = client.getRooms().filter( - (room) => !!room && !!getDisplayAliasForRoom(room), - ).map((room) => { - return { - room: room, - name: room.name, - displayedAlias: getDisplayAliasForRoom(room), - }; - }); - + let matcherObjects = client.getVisibleRooms().reduce((aliases, room) => { + if (room.getCanonicalAlias()) { + aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name)); + } + if (room.getAltAliases().length) { + const altAliases = room.getAltAliases().map(alias => matcherObject(room, alias)); + aliases = aliases.concat(altAliases); + } + return aliases; + }, []); // Filter out any matches where the user will have also autocompleted new rooms matcherObjects = matcherObjects.filter((r) => { const tombstone = r.room.currentState.getStateEvents("m.room.tombstone", ""); @@ -84,16 +91,16 @@ export default class RoomProvider extends AutocompleteProvider { completions = _sortBy(completions, [ (c) => score(matchedString, c.displayedAlias), (c) => c.displayedAlias.length, - ]).map((room) => { - const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; + ]); + completions = completions.map((room) => { return { - completion: displayAlias, - completionId: displayAlias, + completion: room.displayedAlias, + completionId: room.room.roomId, type: "room", suffix: ' ', - href: makeRoomPermalink(displayAlias), + href: makeRoomPermalink(room.displayedAlias), component: ( - } title={room.name} description={displayAlias} /> + } title={room.room.name} description={room.displayedAlias} /> ), range, }; diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index ac159c8213..7fd600b136 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -22,10 +22,10 @@ import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import {PillCompletion} from './Components'; -import sdk from '../index'; +import * as sdk from '../index'; import QueryMatcher from './QueryMatcher'; import _sortBy from 'lodash/sortBy'; -import MatrixClientPeg from '../MatrixClientPeg'; +import {MatrixClientPeg} from '../MatrixClientPeg'; import type {MatrixEvent, Room, RoomMember, RoomState} from 'matrix-js-sdk'; import {makeUserPermalink} from "../utils/permalinks/Permalinks"; @@ -91,7 +91,7 @@ export default class UserProvider extends AutocompleteProvider { this.users = null; } - async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array { + async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); // lazy-load user list into matcher diff --git a/src/components/structures/CompatibilityPage.js b/src/components/structures/CompatibilityPage.js index 28c86f8dd8..9a3fdb5f39 100644 --- a/src/components/structures/CompatibilityPage.js +++ b/src/components/structures/CompatibilityPage.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,7 +21,7 @@ import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { _t } from '../../languageHandler'; -module.exports = createReactClass({ +export default createReactClass({ displayName: 'CompatibilityPage', propTypes: { onAccept: PropTypes.func, diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.js new file mode 100644 index 0000000000..898991f4f2 --- /dev/null +++ b/src/components/structures/ContextMenu.js @@ -0,0 +1,488 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, {useRef, useState} from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import {Key} from "../../Keyboard"; +import * as sdk from "../../index"; +import AccessibleButton from "../views/elements/AccessibleButton"; + +// Shamelessly ripped off Modal.js. There's probably a better way +// of doing reusable widgets like dialog boxes & menus where we go and +// pass in a custom control as the actual body. + +const ContextualMenuContainerId = "mx_ContextualMenu_Container"; + +function getOrCreateContainer() { + let container = document.getElementById(ContextualMenuContainerId); + + if (!container) { + container = document.createElement("div"); + container.id = ContextualMenuContainerId; + document.body.appendChild(container); + } + + return container; +} + +const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); +// Generic ContextMenu Portal wrapper +// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1} +// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. +export class ContextMenu extends React.Component { + static propTypes = { + top: PropTypes.number, + bottom: PropTypes.number, + left: PropTypes.number, + right: PropTypes.number, + menuWidth: PropTypes.number, + menuHeight: PropTypes.number, + chevronOffset: PropTypes.number, + chevronFace: PropTypes.string, // top, bottom, left, right or none + // Function to be called on menu close + onFinished: PropTypes.func.isRequired, + menuPaddingTop: PropTypes.number, + menuPaddingRight: PropTypes.number, + menuPaddingBottom: PropTypes.number, + menuPaddingLeft: PropTypes.number, + zIndex: PropTypes.number, + + // If true, insert an invisible screen-sized element behind the + // menu that when clicked will close it. + hasBackground: PropTypes.bool, + + // on resize callback + windowResize: PropTypes.func, + + managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself + }; + + static defaultProps = { + hasBackground: true, + managed: true, + }; + + constructor() { + super(); + this.state = { + contextMenuElem: null, + }; + + // persist what had focus when we got initialized so we can return it after + this.initialFocus = document.activeElement; + } + + componentWillUnmount() { + // return focus to the thing which had it before us + this.initialFocus.focus(); + } + + collectContextMenuRect = (element) => { + // We don't need to clean up when unmounting, so ignore + if (!element) return; + + let first = element.querySelector('[role^="menuitem"]'); + if (!first) { + first = element.querySelector('[tab-index]'); + } + if (first) { + first.focus(); + } + + this.setState({ + contextMenuElem: element, + }); + }; + + onContextMenu = (e) => { + if (this.props.onFinished) { + this.props.onFinished(); + + e.preventDefault(); + const x = e.clientX; + const y = e.clientY; + + // XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst + // a context menu and its click-guard are up without completely rewriting how the context menus work. + setImmediate(() => { + const clickEvent = document.createEvent('MouseEvents'); + clickEvent.initMouseEvent( + 'contextmenu', true, true, window, 0, + 0, 0, x, y, false, false, + false, false, 0, null, + ); + document.elementFromPoint(x, y).dispatchEvent(clickEvent); + }); + } + }; + + _onMoveFocus = (element, up) => { + let descending = false; // are we currently descending or ascending through the DOM tree? + + do { + const child = up ? element.lastElementChild : element.firstElementChild; + const sibling = up ? element.previousElementSibling : element.nextElementSibling; + + if (descending) { + if (child) { + element = child; + } else if (sibling) { + element = sibling; + } else { + descending = false; + element = element.parentElement; + } + } else { + if (sibling) { + element = sibling; + descending = true; + } else { + element = element.parentElement; + } + } + + if (element) { + if (element.classList.contains("mx_ContextualMenu")) { // we hit the top + element = up ? element.lastElementChild : element.firstElementChild; + descending = true; + } + } + } while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role"))); + + if (element) { + element.focus(); + } + }; + + _onMoveFocusHomeEnd = (element, up) => { + let results = element.querySelectorAll('[role^="menuitem"]'); + if (!results) { + results = element.querySelectorAll('[tab-index]'); + } + if (results && results.length) { + if (up) { + results[0].focus(); + } else { + results[results.length - 1].focus(); + } + } + }; + + _onKeyDown = (ev) => { + if (!this.props.managed) { + if (ev.key === Key.ESCAPE) { + this.props.onFinished(); + ev.stopPropagation(); + ev.preventDefault(); + } + return; + } + + let handled = true; + + switch (ev.key) { + case Key.TAB: + case Key.ESCAPE: + this.props.onFinished(); + break; + case Key.ARROW_UP: + this._onMoveFocus(ev.target, true); + break; + case Key.ARROW_DOWN: + this._onMoveFocus(ev.target, false); + break; + case Key.HOME: + this._onMoveFocusHomeEnd(this.state.contextMenuElem, true); + break; + case Key.END: + this._onMoveFocusHomeEnd(this.state.contextMenuElem, false); + break; + default: + handled = false; + } + + if (handled) { + // consume all other keys in context menu + ev.stopPropagation(); + ev.preventDefault(); + } + }; + + renderMenu(hasBackground=this.props.hasBackground) { + const position = {}; + let chevronFace = null; + const props = this.props; + + if (props.top) { + position.top = props.top; + } else { + position.bottom = props.bottom; + } + + if (props.left) { + position.left = props.left; + chevronFace = 'left'; + } else { + position.right = props.right; + chevronFace = 'right'; + } + + const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null; + const padding = 10; + + const chevronOffset = {}; + if (props.chevronFace) { + chevronFace = props.chevronFace; + } + const hasChevron = chevronFace && chevronFace !== "none"; + + if (chevronFace === 'top' || chevronFace === 'bottom') { + chevronOffset.left = props.chevronOffset; + } else if (position.top !== undefined) { + const target = position.top; + + // By default, no adjustment is made + let adjusted = target; + + // If we know the dimensions of the context menu, adjust its position + // such that it does not leave the (padded) window. + if (contextMenuRect) { + adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding); + } + + position.top = adjusted; + chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted); + } + + let chevron; + if (hasChevron) { + chevron =
    ; + } + + const menuClasses = classNames({ + 'mx_ContextualMenu': true, + 'mx_ContextualMenu_left': !hasChevron && position.left, + 'mx_ContextualMenu_right': !hasChevron && position.right, + 'mx_ContextualMenu_top': !hasChevron && position.top, + 'mx_ContextualMenu_bottom': !hasChevron && position.bottom, + 'mx_ContextualMenu_withChevron_left': chevronFace === 'left', + 'mx_ContextualMenu_withChevron_right': chevronFace === 'right', + 'mx_ContextualMenu_withChevron_top': chevronFace === 'top', + 'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom', + }); + + const menuStyle = {}; + if (props.menuWidth) { + menuStyle.width = props.menuWidth; + } + + if (props.menuHeight) { + menuStyle.height = props.menuHeight; + } + + if (!isNaN(Number(props.menuPaddingTop))) { + menuStyle["paddingTop"] = props.menuPaddingTop; + } + if (!isNaN(Number(props.menuPaddingLeft))) { + menuStyle["paddingLeft"] = props.menuPaddingLeft; + } + if (!isNaN(Number(props.menuPaddingBottom))) { + menuStyle["paddingBottom"] = props.menuPaddingBottom; + } + if (!isNaN(Number(props.menuPaddingRight))) { + menuStyle["paddingRight"] = props.menuPaddingRight; + } + + const wrapperStyle = {}; + if (!isNaN(Number(props.zIndex))) { + menuStyle["zIndex"] = props.zIndex + 1; + wrapperStyle["zIndex"] = props.zIndex; + } + + let background; + if (hasBackground) { + background = ( +
    + ); + } + + return ( +
    +
    + { chevron } + { props.children } +
    + { background } +
    + ); + } + + render() { + return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer()); + } +} + +// Semantic component for representing the AccessibleButton which launches a +export const ContextMenuButton = ({ label, isExpanded, children, ...props }) => { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ( + + { children } + + ); +}; +ContextMenuButton.propTypes = { + ...AccessibleButton.propTypes, + label: PropTypes.string.isRequired, + isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open +}; + +// Semantic component for representing a role=menuitem +export const MenuItem = ({children, label, ...props}) => { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ( + + { children } + + ); +}; +MenuItem.propTypes = { + ...AccessibleButton.propTypes, + label: PropTypes.string, // optional + className: PropTypes.string, // optional + onClick: PropTypes.func.isRequired, +}; + +// Semantic component for representing a role=group for grouping menu radios/checkboxes +export const MenuGroup = ({children, label, ...props}) => { + return
    + { children } +
    ; +}; +MenuGroup.propTypes = { + ...AccessibleButton.propTypes, + label: PropTypes.string.isRequired, + className: PropTypes.string, // optional +}; + +// Semantic component for representing a role=menuitemcheckbox +export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ( + + { children } + + ); +}; +MenuItemCheckbox.propTypes = { + ...AccessibleButton.propTypes, + label: PropTypes.string, // optional + active: PropTypes.bool.isRequired, + disabled: PropTypes.bool, // optional + className: PropTypes.string, // optional + onClick: PropTypes.func.isRequired, +}; + +// Semantic component for representing a role=menuitemradio +export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ( + + { children } + + ); +}; +MenuItemRadio.propTypes = { + ...AccessibleButton.propTypes, + label: PropTypes.string, // optional + active: PropTypes.bool.isRequired, + disabled: PropTypes.bool, // optional + className: PropTypes.string, // optional + onClick: PropTypes.func.isRequired, +}; + +// Placement method for to position context menu to right of elementRect with chevronOffset +export const toRightOf = (elementRect, chevronOffset=12) => { + const left = elementRect.right + window.pageXOffset + 3; + let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; + top -= chevronOffset + 8; // where 8 is half the height of the chevron + return {left, top, chevronOffset}; +}; + +// Placement method for to position context menu right-aligned and flowing to the left of elementRect +export const aboveLeftOf = (elementRect, chevronFace="none") => { + const menuOptions = { chevronFace }; + + const buttonRight = elementRect.right + window.pageXOffset; + const buttonBottom = elementRect.bottom + window.pageYOffset; + const buttonTop = elementRect.top + window.pageYOffset; + // Align the right edge of the menu to the right edge of the button + menuOptions.right = window.innerWidth - buttonRight; + // Align the menu vertically on whichever side of the button has more space available. + if (buttonBottom < window.innerHeight / 2) { + menuOptions.top = buttonBottom; + } else { + menuOptions.bottom = window.innerHeight - buttonTop; + } + + return menuOptions; +}; + +export const useContextMenu = () => { + const button = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const open = () => { + setIsOpen(true); + }; + const close = () => { + setIsOpen(false); + }; + + return [isOpen, button, open, close, setIsOpen]; +}; + +export default class LegacyContextMenu extends ContextMenu { + render() { + return this.renderMenu(false); + } +} + +// XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs. +export function createMenu(ElementClass, props) { + const onFinished = function(...args) { + ReactDOM.unmountComponentAtNode(getOrCreateContainer()); + + if (props && props.onFinished) { + props.onFinished.apply(null, args); + } + }; + + const menu = + + ; + + ReactDOM.render(menu, getOrCreateContainer()); + + return {close: onFinished}; +} diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js deleted file mode 100644 index 3f8c87efef..0000000000 --- a/src/components/structures/ContextualMenu.js +++ /dev/null @@ -1,253 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import {focusCapturedRef} from "../../utils/Accessibility"; -import {KeyCode} from "../../Keyboard"; - -// Shamelessly ripped off Modal.js. There's probably a better way -// of doing reusable widgets like dialog boxes & menus where we go and -// pass in a custom control as the actual body. - -const ContextualMenuContainerId = "mx_ContextualMenu_Container"; - -function getOrCreateContainer() { - let container = document.getElementById(ContextualMenuContainerId); - - if (!container) { - container = document.createElement("div"); - container.id = ContextualMenuContainerId; - document.body.appendChild(container); - } - - return container; -} - -export default class ContextualMenu extends React.Component { - propTypes: { - top: PropTypes.number, - bottom: PropTypes.number, - left: PropTypes.number, - right: PropTypes.number, - menuWidth: PropTypes.number, - menuHeight: PropTypes.number, - chevronOffset: PropTypes.number, - chevronFace: PropTypes.string, // top, bottom, left, right or none - // Function to be called on menu close - onFinished: PropTypes.func, - menuPaddingTop: PropTypes.number, - menuPaddingRight: PropTypes.number, - menuPaddingBottom: PropTypes.number, - menuPaddingLeft: PropTypes.number, - zIndex: PropTypes.number, - - // If true, insert an invisible screen-sized element behind the - // menu that when clicked will close it. - hasBackground: PropTypes.bool, - - // The component to render as the context menu - elementClass: PropTypes.element.isRequired, - // on resize callback - windowResize: PropTypes.func, - // method to close menu - closeMenu: PropTypes.func.isRequired, - }; - - constructor() { - super(); - this.state = { - contextMenuRect: null, - }; - - this.onContextMenu = this.onContextMenu.bind(this); - this.collectContextMenuRect = this.collectContextMenuRect.bind(this); - } - - collectContextMenuRect(element) { - // We don't need to clean up when unmounting, so ignore - if (!element) return; - - // For screen readers to find the thing - focusCapturedRef(element); - - this.setState({ - contextMenuRect: element.getBoundingClientRect(), - }); - } - - onContextMenu(e) { - if (this.props.closeMenu) { - this.props.closeMenu(); - - e.preventDefault(); - const x = e.clientX; - const y = e.clientY; - - // XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst - // a context menu and its click-guard are up without completely rewriting how the context menus work. - setImmediate(() => { - const clickEvent = document.createEvent('MouseEvents'); - clickEvent.initMouseEvent( - 'contextmenu', true, true, window, 0, - 0, 0, x, y, false, false, - false, false, 0, null, - ); - document.elementFromPoint(x, y).dispatchEvent(clickEvent); - }); - } - } - - _onKeyDown = (ev) => { - if (ev.keyCode === KeyCode.ESCAPE) { - ev.stopPropagation(); - ev.preventDefault(); - this.props.closeMenu(); - } - }; - - render() { - const position = {}; - let chevronFace = null; - const props = this.props; - - if (props.top) { - position.top = props.top; - } else { - position.bottom = props.bottom; - } - - if (props.left) { - position.left = props.left; - chevronFace = 'left'; - } else { - position.right = props.right; - chevronFace = 'right'; - } - - const contextMenuRect = this.state.contextMenuRect || null; - const padding = 10; - - const chevronOffset = {}; - if (props.chevronFace) { - chevronFace = props.chevronFace; - } - const hasChevron = chevronFace && chevronFace !== "none"; - - if (chevronFace === 'top' || chevronFace === 'bottom') { - chevronOffset.left = props.chevronOffset; - } else { - const target = position.top; - - // By default, no adjustment is made - let adjusted = target; - - // If we know the dimensions of the context menu, adjust its position - // such that it does not leave the (padded) window. - if (contextMenuRect) { - adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding); - } - - position.top = adjusted; - chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted); - } - - const chevron = hasChevron ? -
    : - undefined; - const className = 'mx_ContextualMenu_wrapper'; - - const menuClasses = classNames({ - 'mx_ContextualMenu': true, - 'mx_ContextualMenu_left': !hasChevron && position.left, - 'mx_ContextualMenu_right': !hasChevron && position.right, - 'mx_ContextualMenu_top': !hasChevron && position.top, - 'mx_ContextualMenu_bottom': !hasChevron && position.bottom, - 'mx_ContextualMenu_withChevron_left': chevronFace === 'left', - 'mx_ContextualMenu_withChevron_right': chevronFace === 'right', - 'mx_ContextualMenu_withChevron_top': chevronFace === 'top', - 'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom', - }); - - const menuStyle = {}; - if (props.menuWidth) { - menuStyle.width = props.menuWidth; - } - - if (props.menuHeight) { - menuStyle.height = props.menuHeight; - } - - if (!isNaN(Number(props.menuPaddingTop))) { - menuStyle["paddingTop"] = props.menuPaddingTop; - } - if (!isNaN(Number(props.menuPaddingLeft))) { - menuStyle["paddingLeft"] = props.menuPaddingLeft; - } - if (!isNaN(Number(props.menuPaddingBottom))) { - menuStyle["paddingBottom"] = props.menuPaddingBottom; - } - if (!isNaN(Number(props.menuPaddingRight))) { - menuStyle["paddingRight"] = props.menuPaddingRight; - } - - const wrapperStyle = {}; - if (!isNaN(Number(props.zIndex))) { - menuStyle["zIndex"] = props.zIndex + 1; - wrapperStyle["zIndex"] = props.zIndex; - } - - const ElementClass = props.elementClass; - - // FIXME: If a menu uses getDefaultProps it clobbers the onFinished - // property set here so you can't close the menu from a button click! - return
    -
    - { chevron } - -
    - { props.hasBackground &&
    } -
    ; - } -} - -export function createMenu(ElementClass, props, hasBackground=true) { - const closeMenu = function(...args) { - ReactDOM.unmountComponentAtNode(getOrCreateContainer()); - - if (props && props.onFinished) { - props.onFinished.apply(null, args); - } - }; - - // We only reference closeMenu once per call to createMenu - const menu = ; - - ReactDOM.render(menu, getOrCreateContainer()); - - return {close: closeMenu}; -} diff --git a/src/components/structures/CustomRoomTagPanel.js b/src/components/structures/CustomRoomTagPanel.js index ee69d800ed..e8ff6e814e 100644 --- a/src/components/structures/CustomRoomTagPanel.js +++ b/src/components/structures/CustomRoomTagPanel.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import CustomRoomTagStore from '../../stores/CustomRoomTagStore'; import AutoHideScrollbar from './AutoHideScrollbar'; -import sdk from '../../index'; +import * as sdk from '../../index'; import dis from '../../dispatcher'; import classNames from 'classnames'; import * as FormattingUtils from '../../utils/FormattingUtils'; @@ -61,30 +61,13 @@ class CustomRoomTagPanel extends React.Component { } class CustomRoomTagTile extends React.Component { - constructor(props) { - super(props); - this.state = {hover: false}; - this.onClick = this.onClick.bind(this); - this.onMouseOut = this.onMouseOut.bind(this); - this.onMouseOver = this.onMouseOver.bind(this); - } - - onMouseOver() { - this.setState({hover: true}); - } - - onMouseOut() { - this.setState({hover: false}); - } - - onClick() { + onClick = () => { dis.dispatch({action: 'select_custom_room_tag', tag: this.props.tag.name}); - } + }; render() { const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const Tooltip = sdk.getComponent('elements.Tooltip'); + const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton'); const tag = this.props.tag; const avatarHeight = 40; @@ -102,12 +85,9 @@ class CustomRoomTagTile extends React.Component { badgeElement = (
    {FormattingUtils.formatCount(badge.count)}
    ); } - const tip = (this.state.hover ? - : -
    ); return ( - -
    + +
    { badgeElement } - { tip }
    - +
    ); } } diff --git a/src/components/structures/EmbeddedPage.js b/src/components/structures/EmbeddedPage.js index ecc01a443d..6d734c3838 100644 --- a/src/components/structures/EmbeddedPage.js +++ b/src/components/structures/EmbeddedPage.js @@ -23,11 +23,11 @@ import PropTypes from 'prop-types'; import request from 'browser-request'; import { _t } from '../../languageHandler'; import sanitizeHtml from 'sanitize-html'; -import sdk from '../../index'; +import * as sdk from '../../index'; import dis from '../../dispatcher'; -import MatrixClientPeg from '../../MatrixClientPeg'; -import { MatrixClient } from 'matrix-js-sdk'; +import {MatrixClientPeg} from '../../MatrixClientPeg'; import classnames from 'classnames'; +import MatrixClientContext from "../../contexts/MatrixClientContext"; export default class EmbeddedPage extends React.PureComponent { static propTypes = { @@ -39,9 +39,7 @@ export default class EmbeddedPage extends React.PureComponent { scrollbar: PropTypes.bool, }; - static contextTypes = { - matrixClient: PropTypes.instanceOf(MatrixClient), - }; + static contextType = MatrixClientContext; constructor(props) { super(props); @@ -104,7 +102,7 @@ export default class EmbeddedPage extends React.PureComponent { render() { // HACK: Workaround for the context's MatrixClient not updating. - const client = this.context.matrixClient || MatrixClientPeg.get(); + const client = this.context || MatrixClientPeg.get(); const isGuest = client ? client.isGuest() : true; const className = this.props.className; const classes = classnames({ diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index fb2bdcad42..f8c03be864 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,9 +19,10 @@ import React from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; -import Matrix from 'matrix-js-sdk'; -import sdk from '../../index'; -import MatrixClientPeg from '../../MatrixClientPeg'; +import {Filter} from 'matrix-js-sdk'; +import * as sdk from '../../index'; +import {MatrixClientPeg} from '../../MatrixClientPeg'; +import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from '../../languageHandler'; /* @@ -28,6 +30,9 @@ import { _t } from '../../languageHandler'; */ const FilePanel = createReactClass({ displayName: 'FilePanel', + // This is used to track if a decrypted event was a live event and should be + // added to the timeline. + decryptingEvents: new Set(), propTypes: { roomId: PropTypes.string.isRequired, @@ -39,55 +44,147 @@ const FilePanel = createReactClass({ }; }, - componentWillMount: function() { - this.updateTimelineSet(this.props.roomId); - }, + onRoomTimeline(ev, room, toStartOfTimeline, removed, data) { + if (room.roomId !== this.props.roomId) return; + if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return; - componentWillReceiveProps: function(nextProps) { - if (nextProps.roomId !== this.props.roomId) { - // otherwise we race between re-rendering the TimelinePanel and setting the new timelineSet. - // - // FIXME: this race only happens because of the promise returned by getOrCreateFilter(). - // We should only need to create the containsUrl filter once per login session, so in practice - // it shouldn't be being done here at all. Then we could just update the timelineSet directly - // without resetting it first, and speed up room-change. - this.setState({ timelineSet: null }); - this.updateTimelineSet(nextProps.roomId); + if (ev.isBeingDecrypted()) { + this.decryptingEvents.add(ev.getId()); + } else { + this.addEncryptedLiveEvent(ev); } }, - updateTimelineSet: function(roomId) { + onEventDecrypted(ev, err) { + if (ev.getRoomId() !== this.props.roomId) return; + const eventId = ev.getId(); + + if (!this.decryptingEvents.delete(eventId)) return; + if (err) return; + + this.addEncryptedLiveEvent(ev); + }, + + addEncryptedLiveEvent(ev, toStartOfTimeline) { + if (!this.state.timelineSet) return; + + const timeline = this.state.timelineSet.getLiveTimeline(); + if (ev.getType() !== "m.room.message") return; + if (["m.file", "m.image", "m.video", "m.audio"].indexOf(ev.getContent().msgtype) == -1) { + return; + } + + if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) { + this.state.timelineSet.addEventToTimeline(ev, timeline, false); + } + }, + + async componentDidMount() { + const client = MatrixClientPeg.get(); + + await this.updateTimelineSet(this.props.roomId); + + if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return; + + // The timelineSets filter makes sure that encrypted events that contain + // URLs never get added to the timeline, even if they are live events. + // These methods are here to manually listen for such events and add + // them despite the filter's best efforts. + // + // We do this only for encrypted rooms and if an event index exists, + // this could be made more general in the future or the filter logic + // could be fixed. + if (EventIndexPeg.get() !== null) { + client.on('Room.timeline', this.onRoomTimeline); + client.on('Event.decrypted', this.onEventDecrypted); + } + }, + + componentWillUnmount() { + const client = MatrixClientPeg.get(); + if (client === null) return; + + if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return; + + if (EventIndexPeg.get() !== null) { + client.removeListener('Room.timeline', this.onRoomTimeline); + client.removeListener('Event.decrypted', this.onEventDecrypted); + } + }, + + async fetchFileEventsServer(room) { + const client = MatrixClientPeg.get(); + + const filter = new Filter(client.credentials.userId); + filter.setDefinition( + { + "room": { + "timeline": { + "contains_url": true, + "types": [ + "m.room.message", + ], + }, + }, + }, + ); + + const filterId = await client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter); + filter.filterId = filterId; + const timelineSet = room.getOrCreateFilteredTimelineSet(filter); + + return timelineSet; + }, + + onPaginationRequest(timelineWindow, direction, limit) { + const client = MatrixClientPeg.get(); + const eventIndex = EventIndexPeg.get(); + const roomId = this.props.roomId; + + const room = client.getRoom(roomId); + + // We override the pagination request for encrypted rooms so that we ask + // the event index to fulfill the pagination request. Asking the server + // to paginate won't ever work since the server can't correctly filter + // out events containing URLs + if (client.isRoomEncrypted(roomId) && eventIndex !== null) { + return eventIndex.paginateTimelineWindow(room, timelineWindow, direction, limit); + } else { + return timelineWindow.paginate(direction, limit); + } + }, + + async updateTimelineSet(roomId: string) { const client = MatrixClientPeg.get(); const room = client.getRoom(roomId); + const eventIndex = EventIndexPeg.get(); this.noRoom = !room; if (room) { - const filter = new Matrix.Filter(client.credentials.userId); - filter.setDefinition( - { - "room": { - "timeline": { - "contains_url": true, - "types": [ - "m.room.message", - ], - }, - }, - }, - ); + let timelineSet; - // FIXME: we shouldn't be doing this every time we change room - see comment above. - client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then( - (filterId)=>{ - filter.filterId = filterId; - const timelineSet = room.getOrCreateFilteredTimelineSet(filter); - this.setState({ timelineSet: timelineSet }); - }, - (error)=>{ - console.error("Failed to get or create file panel filter", error); - }, - ); + try { + timelineSet = await this.fetchFileEventsServer(room); + + // If this room is encrypted the file panel won't be populated + // correctly since the defined filter doesn't support encrypted + // events and the server can't check if encrypted events contain + // URLs. + // + // This is where our event index comes into place, we ask the + // event index to populate the timelineSet for us. This call + // will add 10 events to the live timeline of the set. More can + // be requested using pagination. + if (client.isRoomEncrypted(roomId) && eventIndex !== null) { + const timeline = timelineSet.getLiveTimeline(); + await eventIndex.populateFileTimeline(timelineSet, timeline, room, 10); + } + + this.setState({ timelineSet: timelineSet }); + } catch (error) { + console.error("Failed to get or create file panel filter", error); + } } else { console.error("Failed to add filtered timelineSet for FilePanel as no room!"); } @@ -117,17 +214,18 @@ const FilePanel = createReactClass({ // console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " + // "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId); return ( - +
    + +
    ); } else { return ( @@ -139,4 +237,4 @@ const FilePanel = createReactClass({ }, }); -module.exports = FilePanel; +export default FilePanel; diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 4d8f47003c..af90fbbe83 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -19,9 +19,8 @@ limitations under the License. import React from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; -import Promise from 'bluebird'; -import MatrixClientPeg from '../../MatrixClientPeg'; -import sdk from '../../index'; +import {MatrixClientPeg} from '../../MatrixClientPeg'; +import * as sdk from '../../index'; import dis from '../../dispatcher'; import { getHostingLink } from '../../utils/HostingLink'; import { sanitizedHtmlNode } from '../../HtmlUtils'; @@ -38,6 +37,8 @@ import FlairStore from '../../stores/FlairStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks"; import {Group} from "matrix-js-sdk"; +import {allSettled, sleep} from "../../utils/promise"; +import RightPanelStore from "../../stores/RightPanelStore"; const LONG_DESC_PLACEHOLDER = _td( `

    HTML for your community's page

    @@ -98,11 +99,10 @@ const CategoryRoomList = createReactClass({ onFinished: (success, addrs) => { if (!success) return; const errorList = []; - Promise.all(addrs.map((addr) => { + allSettled(addrs.map((addr) => { return GroupStore .addRoomToGroupSummary(this.props.groupId, addr.address) - .catch(() => { errorList.push(addr.address); }) - .reflect(); + .catch(() => { errorList.push(addr.address); }); })).then(() => { if (errorList.length === 0) { return; @@ -275,11 +275,10 @@ const RoleUserList = createReactClass({ onFinished: (success, addrs) => { if (!success) return; const errorList = []; - Promise.all(addrs.map((addr) => { + allSettled(addrs.map((addr) => { return GroupStore .addUserToGroupSummary(addr.address) - .catch(() => { errorList.push(addr.address); }) - .reflect(); + .catch(() => { errorList.push(addr.address); }); })).then(() => { if (errorList.length === 0) { return; @@ -482,7 +481,7 @@ export default createReactClass({ group_id: groupId, }, }); - dis.dispatch({action: 'require_registration'}); + dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${groupId}`}}); willDoOnboarding = true; } if (stateKey === GroupStore.STATE_KEY.Summary) { @@ -544,10 +543,6 @@ export default createReactClass({ }); }, - _onShowRhsClick: function(ev) { - dis.dispatch({ action: 'show_right_panel' }); - }, - _onEditClick: function() { this.setState({ editing: true, @@ -585,6 +580,10 @@ export default createReactClass({ profileForm: null, }); break; + case 'after_right_panel_phase_change': + // We don't keep state on the right panel, so just re-render to update + this.forceUpdate(); + break; default: break; } @@ -638,7 +637,7 @@ export default createReactClass({ title: _t('Error'), description: _t('Failed to upload image'), }); - }).done(); + }); }, _onJoinableChange: function(ev) { @@ -677,7 +676,7 @@ export default createReactClass({ this.setState({ avatarChanged: false, }); - }).done(); + }); }, _saveGroup: async function() { @@ -692,7 +691,7 @@ export default createReactClass({ // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. - await Promise.delay(500); + await sleep(500); GroupStore.acceptGroupInvite(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync @@ -711,7 +710,7 @@ export default createReactClass({ // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. - await Promise.delay(500); + await sleep(500); GroupStore.leaveGroup(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync @@ -727,7 +726,7 @@ export default createReactClass({ _onJoinClick: async function() { if (this._matrixClient.isGuest()) { - dis.dispatch({action: 'require_registration'}); + dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}}); return; } @@ -735,7 +734,7 @@ export default createReactClass({ // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. - await Promise.delay(500); + await sleep(500); GroupStore.joinGroup(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync @@ -787,7 +786,7 @@ export default createReactClass({ // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // spinner disappearing after we have fetched new group data. - await Promise.delay(500); + await sleep(500); GroupStore.leaveGroup(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync @@ -822,10 +821,10 @@ export default createReactClass({ {_t( "Want more than a community? Get your own server", {}, { - a: sub => {sub}, + a: sub => {sub}, }, )} - +
    ; @@ -1216,25 +1215,25 @@ export default createReactClass({ const EditableText = sdk.getComponent("elements.EditableText"); - nameNode = ; + nameNode = ; - shortDescNode = ; + shortDescNode = ; } else { const onGroupHeaderItemClick = this.state.isUserMember ? this._onEditClick : null; const groupAvatarUrl = summary.profile ? summary.profile.avatar_url : null; @@ -1300,7 +1299,9 @@ export default createReactClass({ ); } - const rightPanel = !this.props.collapsedRhs ? : undefined; + const rightPanel = RightPanelStore.getSharedInstance().isOpenForGroup + ? + : undefined; const headerClasses = { "mx_GroupView_header": true, @@ -1328,9 +1329,9 @@ export default createReactClass({
    { rightButtons }
    - +
    - + { this._getMembershipSection() } { this._getGroupSection() } diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 5e06d124c4..f4adb5751f 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -15,16 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Matrix from 'matrix-js-sdk'; -const InteractiveAuth = Matrix.InteractiveAuth; - -import React from 'react'; +import {InteractiveAuth} from "matrix-js-sdk"; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; -import {getEntryComponentForLoginType} from '../views/auth/InteractiveAuthEntryComponents'; +import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents'; -import sdk from '../../index'; +import * as sdk from '../../index'; export default createReactClass({ displayName: 'InteractiveAuth', @@ -121,7 +119,7 @@ export default createReactClass({ this.setState({ errorText: msg, }); - }).done(); + }); this._intervalId = null; if (this.props.poll) { @@ -129,6 +127,8 @@ export default createReactClass({ this._authLogic.poll(); }, 2000); } + + this._stageComponent = createRef(); }, componentWillUnmount: function() { @@ -153,14 +153,15 @@ export default createReactClass({ }, tryContinue: function() { - if (this.refs.stageComponent && this.refs.stageComponent.tryContinue) { - this.refs.stageComponent.tryContinue(); + if (this._stageComponent.current && this._stageComponent.current.tryContinue) { + this._stageComponent.current.tryContinue(); } }, _authStateUpdated: function(stageType, stageState) { const oldStage = this.state.authStage; this.setState({ + busy: false, authStage: stageType, stageState: stageState, errorText: stageState.error, @@ -184,16 +185,18 @@ export default createReactClass({ errorText: null, stageErrorText: null, }); - } else { - this.setState({ - busy: false, - }); } + // The JS SDK eagerly reports itself as "not busy" right after any + // immediate work has completed, but that's not really what we want at + // the UI layer, so we ignore this signal and show a spinner until + // there's a new screen to show the user. This is implemented by setting + // `busy: false` in `_authStateUpdated`. + // See also https://github.com/vector-im/riot-web/issues/12546 }, _setFocus: function() { - if (this.refs.stageComponent && this.refs.stageComponent.focus) { - this.refs.stageComponent.focus(); + if (this._stageComponent.current && this._stageComponent.current.focus) { + this._stageComponent.current.focus(); } }, @@ -214,7 +217,8 @@ export default createReactClass({ const StageComponent = getEntryComponentForLoginType(stage); return ( - { isCustomTagsEnabled ? : undefined } -
    ); } @@ -268,9 +256,11 @@ const LeftPanel = createReactClass({ } const searchBox = ( { tagPanelContainer } -
    + scrollpanel_content =
    { rows } diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 21dd06767c..13b73ec02b 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,12 +21,12 @@ import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import Matrix from 'matrix-js-sdk'; import { _t, _td } from '../../languageHandler'; -import sdk from '../../index'; -import MatrixClientPeg from '../../MatrixClientPeg'; +import * as sdk from '../../index'; +import {MatrixClientPeg} from '../../MatrixClientPeg'; import Resend from '../../Resend'; import * as cryptodevices from '../../cryptodevices'; import dis from '../../dispatcher'; -import { messageForResourceLimitError } from '../../utils/ErrorUtils'; +import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -38,7 +39,7 @@ function getUnsentMessages(room) { }); } -module.exports = createReactClass({ +export default createReactClass({ displayName: 'RoomStatusBar', propTypes: { @@ -219,12 +220,12 @@ module.exports = createReactClass({ }); if (hasUDE) { - title = _t("Message not sent due to unknown devices being present"); + title = _t("Message not sent due to unknown sessions being present"); content = _t( - "Show devices, send anyway or cancel.", + "Show sessions, send anyway or cancel.", {}, { - 'showDevicesText': (sub) => { sub }, + 'showSessionsText': (sub) => { sub }, 'sendAnywayText': (sub) => { sub }, 'cancelText': (sub) => { sub }, }, @@ -272,7 +273,7 @@ module.exports = createReactClass({ unsentMessages[0].error.data && unsentMessages[0].error.data.error ) { - title = unsentMessages[0].error.data.error; + title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error; } else { title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length }); } @@ -289,7 +290,7 @@ module.exports = createReactClass({ } return
    - +
    { title } @@ -306,7 +307,7 @@ module.exports = createReactClass({ if (this._shouldShowConnectionError()) { return (
    - /!\ + /!\
    { _t('Connectivity to the server has been lost.') } diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index 3d09c05c43..fa2231328c 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018, 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,38 +17,35 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import createReactClass from 'create-react-class'; +import React, {createRef} from 'react'; import classNames from 'classnames'; -import sdk from '../../index'; +import * as sdk from '../../index'; import dis from '../../dispatcher'; -import Unread from '../../Unread'; +import * as Unread from '../../Unread'; import * as RoomNotifs from '../../RoomNotifs'; import * as FormattingUtils from '../../utils/FormattingUtils'; import IndicatorScrollbar from './IndicatorScrollbar'; -import { KeyCode } from '../../Keyboard'; +import {Key} from '../../Keyboard'; import { Group } from 'matrix-js-sdk'; import PropTypes from 'prop-types'; import RoomTile from "../views/rooms/RoomTile"; import LazyRenderList from "../views/elements/LazyRenderList"; import {_t} from "../../languageHandler"; +import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex"; // turn this on for drop & drag console debugging galore const debug = false; -const RoomSubList = createReactClass({ - displayName: 'RoomSubList', +export default class RoomSubList extends React.PureComponent { + static displayName = 'RoomSubList'; + static debug = debug; - debug: debug, - - propTypes: { + static propTypes = { list: PropTypes.arrayOf(PropTypes.object).isRequired, label: PropTypes.string.isRequired, tagName: PropTypes.string, addRoomLabel: PropTypes.string, - order: PropTypes.string.isRequired, - // passed through to RoomTile and used to highlight room with `!` regardless of notifications count isInvite: PropTypes.bool, @@ -56,49 +54,63 @@ const RoomSubList = createReactClass({ collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed? onHeaderClick: PropTypes.func, incomingCall: PropTypes.object, - isFiltered: PropTypes.bool, - headerItems: PropTypes.node, // content shown in the sublist header extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles - }, + forceExpand: PropTypes.bool, + }; - getInitialState: function() { + static defaultProps = { + onHeaderClick: function() { + }, // NOP + extraTiles: [], + isInvite: false, + }; + + static getDerivedStateFromProps(props, state) { return { + listLength: props.list.length, + scrollTop: props.list.length === state.listLength ? state.scrollTop : 0, + }; + } + + constructor(props) { + super(props); + + this.state = { hidden: this.props.startAsHidden || false, // some values to get LazyRenderList starting scrollerHeight: 800, scrollTop: 0, + // React 16's getDerivedStateFromProps(props, state) doesn't give the previous props so + // we have to store the length of the list here so we can see if it's changed or not... + listLength: null, }; - }, - getDefaultProps: function() { - return { - onHeaderClick: function() { - }, // NOP - extraTiles: [], - isInvite: false, - }; - }, + this._header = createRef(); + this._subList = createRef(); + this._scroller = createRef(); + this._headerButton = createRef(); + } - componentWillMount: function() { + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { dis.unregister(this.dispatcherRef); - }, + } - // The header is collapsable if it is hidden or not stuck + // The header is collapsible if it is hidden or not stuck // The dataset elements are added in the RoomList _initAndPositionStickyHeaders method - isCollapsableOnClick: function() { - const stuck = this.refs.header.dataset.stuck; + isCollapsibleOnClick() { + const stuck = this._header.current.dataset.stuck; if (!this.props.forceExpand && (this.state.hidden || stuck === undefined || stuck === "none")) { return true; } else { return false; } - }, + } - onAction: function(payload) { + onAction = (payload) => { // XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched, // but this is no longer true, so we must do it here (and can apply the small // optimisation of checking that we care about the room being read). @@ -111,37 +123,76 @@ const RoomSubList = createReactClass({ ) { this.forceUpdate(); } - }, + }; - onClick: function(ev) { - if (this.isCollapsableOnClick()) { - // The header isCollapsable, so the click is to be interpreted as collapse and truncation logic + onClick = (ev) => { + if (this.isCollapsibleOnClick()) { + // The header isCollapsible, so the click is to be interpreted as collapse and truncation logic const isHidden = !this.state.hidden; this.setState({hidden: isHidden}, () => { this.props.onHeaderClick(isHidden); }); } else { // The header is stuck, so the click is to be interpreted as a scroll to the header - this.props.onHeaderClick(this.state.hidden, this.refs.header.dataset.originalPosition); + this.props.onHeaderClick(this.state.hidden, this._header.current.dataset.originalPosition); } - }, + }; - onRoomTileClick(roomId, ev) { + onHeaderKeyDown = (ev) => { + switch (ev.key) { + case Key.ARROW_LEFT: + // On ARROW_LEFT collapse the room sublist + if (!this.state.hidden && !this.props.forceExpand) { + this.onClick(); + } + ev.stopPropagation(); + break; + case Key.ARROW_RIGHT: { + ev.stopPropagation(); + if (this.state.hidden && !this.props.forceExpand) { + // sublist is collapsed, expand it + this.onClick(); + } else if (!this.props.forceExpand) { + // sublist is expanded, go to first room + const element = this._subList.current && this._subList.current.querySelector(".mx_RoomTile"); + if (element) { + element.focus(); + } + } + break; + } + } + }; + + onKeyDown = (ev) => { + switch (ev.key) { + // On ARROW_LEFT go to the sublist header + case Key.ARROW_LEFT: + ev.stopPropagation(); + this._headerButton.current.focus(); + break; + // Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer + case Key.ARROW_RIGHT: + ev.stopPropagation(); + } + }; + + onRoomTileClick = (roomId, ev) => { dis.dispatch({ action: 'view_room', room_id: roomId, - clear_search: (ev && (ev.keyCode === KeyCode.ENTER || ev.keyCode === KeyCode.SPACE)), + clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)), }); - }, + }; - _updateSubListCount: function() { + _updateSubListCount = () => { // Force an update by setting the state to the current state // Doing it this way rather than using forceUpdate(), so that the shouldComponentUpdate() // method is honoured this.setState(this.state); - }, + }; - makeRoomTile: function(room) { + makeRoomTile = (room) => { return ; - }, + }; - _onNotifBadgeClick: function(e) { + _onNotifBadgeClick = (e) => { // prevent the roomsublist collapsing e.preventDefault(); e.stopPropagation(); @@ -169,9 +220,9 @@ const RoomSubList = createReactClass({ room_id: room.roomId, }); } - }, + }; - _onInviteBadgeClick: function(e) { + _onInviteBadgeClick = (e) => { // prevent the roomsublist collapsing e.preventDefault(); e.stopPropagation(); @@ -191,9 +242,14 @@ const RoomSubList = createReactClass({ }); } } - }, + }; - _getHeaderJsx: function(isCollapsed) { + onAddRoom = (e) => { + e.stopPropagation(); + if (this.props.onAddRoom) this.props.onAddRoom(); + }; + + _getHeaderJsx(isCollapsed) { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton'); const subListNotifications = !this.props.isInvite ? @@ -202,22 +258,6 @@ const RoomSubList = createReactClass({ const subListNotifCount = subListNotifications.count; const subListNotifHighlight = subListNotifications.highlight; - let badge; - if (!this.props.collapsed) { - const badgeClasses = classNames({ - 'mx_RoomSubList_badge': true, - 'mx_RoomSubList_badgeHighlight': subListNotifHighlight, - }); - if (subListNotifCount > 0) { - badge =
    - { FormattingUtils.formatCount(subListNotifCount) } -
    ; - } else if (this.props.isInvite && this.props.list.length) { - // no notifications but highlight anyway because this is an invite badge - badge =
    {this.props.list.length}
    ; - } - } - // When collapsed, allow a long hover on the header to show user // the full tag name and room count let title; @@ -233,17 +273,6 @@ const RoomSubList = createReactClass({ ; } - let addRoomButton; - if (this.props.onAddRoom) { - addRoomButton = ( - - ); - } - const len = this.props.list.length + this.props.extraTiles.length; let chevron; if (len) { @@ -255,65 +284,127 @@ const RoomSubList = createReactClass({ chevron = (
    ); } - const tabindex = this.props.isFiltered ? "0" : "-1"; - return ( -
    - - { chevron } - {this.props.label} - { incomingCall } - - { badge } - { addRoomButton } -
    - ); - }, + return + {({onFocus, isActive, ref}) => { + const tabIndex = isActive ? 0 : -1; - checkOverflow: function() { - if (this.refs.scroller) { - this.refs.scroller.checkOverflow(); + let badge; + if (!this.props.collapsed) { + const badgeClasses = classNames({ + 'mx_RoomSubList_badge': true, + 'mx_RoomSubList_badgeHighlight': subListNotifHighlight, + }); + // Wrap the contents in a div and apply styles to the child div so that the browser default outline works + if (subListNotifCount > 0) { + badge = ( + +
    + { FormattingUtils.formatCount(subListNotifCount) } +
    +
    + ); + } else if (this.props.isInvite && this.props.list.length) { + // no notifications but highlight anyway because this is an invite badge + badge = ( + +
    + { this.props.list.length } +
    +
    + ); + } + } + + let addRoomButton; + if (this.props.onAddRoom) { + addRoomButton = ( + + ); + } + + return ( +
    + + { chevron } + {this.props.label} + { incomingCall } + + { badge } + { addRoomButton } +
    + ); + } } +
    ; + } + + checkOverflow = () => { + if (this._scroller.current) { + this._scroller.current.checkOverflow(); } - }, + }; - setHeight: function(height) { - if (this.refs.subList) { - this.refs.subList.style.height = `${height}px`; + setHeight = (height) => { + if (this._subList.current) { + this._subList.current.style.height = `${height}px`; } this._updateLazyRenderHeight(height); - }, + }; - _updateLazyRenderHeight: function(height) { + _updateLazyRenderHeight(height) { this.setState({scrollerHeight: height}); - }, + } - _onScroll: function() { - this.setState({scrollTop: this.refs.scroller.getScrollTop()}); - }, + _onScroll = () => { + this.setState({scrollTop: this._scroller.current.getScrollTop()}); + }; _canUseLazyListRendering() { // for now disable lazy rendering as they are already rendered tiles // not rooms like props.list we pass to LazyRenderList return !this.props.extraTiles || !this.props.extraTiles.length; - }, + } - render: function() { + render() { const len = this.props.list.length + this.props.extraTiles.length; const isCollapsed = this.state.hidden && !this.props.forceExpand; - if (len) { - const subListClasses = classNames({ - "mx_RoomSubList": true, - "mx_RoomSubList_hidden": isCollapsed, - "mx_RoomSubList_nonEmpty": len && !isCollapsed, - }); + const subListClasses = classNames({ + "mx_RoomSubList": true, + "mx_RoomSubList_hidden": len && isCollapsed, + "mx_RoomSubList_nonEmpty": len && !isCollapsed, + }); + + let content; + if (len) { if (isCollapsed) { - return
    - {this._getHeaderJsx(isCollapsed)} -
    ; + // no body } else if (this._canUseLazyListRendering()) { - return
    - {this._getHeaderJsx(isCollapsed)} - + content = ( + -
    ; + ); } else { const roomTiles = this.props.list.map(r => this.makeRoomTile(r)); const tiles = roomTiles.concat(this.props.extraTiles); - return
    - {this._getHeaderJsx(isCollapsed)} - + content = ( + { tiles } -
    ; + ); } } else { - const Loader = sdk.getComponent("elements.Spinner"); - let content; if (this.props.showSpinner && !isCollapsed) { + const Loader = sdk.getComponent("elements.Spinner"); content = ; } - - return ( -
    - { this._getHeaderJsx(isCollapsed) } - { content } -
    - ); } - }, -}); -module.exports = RoomSubList; + return ( +
    + { this._getHeaderJsx(isCollapsed) } + { content } +
    + ); + } +} diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 4de573479d..36e30343e4 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -23,28 +23,26 @@ limitations under the License. import shouldHideEvent from '../../shouldHideEvent'; -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; -import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; -import Promise from 'bluebird'; import classNames from 'classnames'; -import {Room} from "matrix-js-sdk"; import { _t } from '../../languageHandler'; import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks'; -import MatrixClientPeg from '../../MatrixClientPeg'; +import {MatrixClientPeg} from '../../MatrixClientPeg'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; -import sdk from '../../index'; +import * as sdk from '../../index'; import CallHandler from '../../CallHandler'; import dis from '../../dispatcher'; import Tinter from '../../Tinter'; import rate_limited_func from '../../ratelimitedfunc'; -import ObjectUtils from '../../ObjectUtils'; +import * as ObjectUtils from '../../ObjectUtils'; import * as Rooms from '../../Rooms'; +import eventSearch from '../../Searching'; -import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; +import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; @@ -54,6 +52,9 @@ import WidgetEchoStore from '../../stores/WidgetEchoStore'; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import WidgetUtils from '../../utils/WidgetUtils'; import AccessibleButton from "../views/elements/AccessibleButton"; +import RightPanelStore from "../../stores/RightPanelStore"; +import {haveTileForEvent} from "../views/rooms/EventTile"; +import RoomContext from "../../contexts/RoomContext"; const DEBUG = false; let debuglog = function() {}; @@ -65,13 +66,7 @@ if (DEBUG) { debuglog = console.log.bind(console); } -const RoomContext = PropTypes.shape({ - canReact: PropTypes.bool.isRequired, - canReply: PropTypes.bool.isRequired, - room: PropTypes.instanceOf(Room), -}); - -module.exports = createReactClass({ +export default createReactClass({ displayName: 'RoomView', propTypes: { ConferenceHandler: PropTypes.any, @@ -98,9 +93,6 @@ module.exports = createReactClass({ // * invited us to the room oobData: PropTypes.object, - // is the RightPanel collapsed? - collapsedRhs: PropTypes.bool, - // Servers the RoomView can use to try and assist joins viaServers: PropTypes.arrayOf(PropTypes.string), }, @@ -166,23 +158,6 @@ module.exports = createReactClass({ canReact: false, canReply: false, - - useCider: false, - }; - }, - - childContextTypes: { - room: RoomContext, - }, - - getChildContext: function() { - const {canReact, canReply, room} = this.state; - return { - room: { - canReact, - canReply, - room, - }, }; }, @@ -198,19 +173,15 @@ module.exports = createReactClass({ MatrixClientPeg.get().on("accountData", this.onAccountData); MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus); MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged); + MatrixClientPeg.get().on("userTrustStatusChanged", this.onUserVerificationChanged); // Start listening for RoomViewStore updates this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._onRoomViewStoreUpdate(true); WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate); - this._onCiderUpdated(); - this._ciderWatcherRef = SettingsStore.watchSetting( - "useCiderComposer", null, this._onCiderUpdated); - }, - - _onCiderUpdated: function() { - this.setState({useCider: SettingsStore.getValue("useCiderComposer")}); + this._roomView = createRef(); + this._searchResultsPanel = createRef(); }, _onRoomViewStoreUpdate: function(initial) { @@ -357,7 +328,7 @@ module.exports = createReactClass({ if (this.props.autoJoin) { this.onJoinButtonClicked(); } else if (!room && shouldPeek) { - console.log("Attempting to peek into room %s", roomId); + console.info("Attempting to peek into room %s", roomId); this.setState({ peekLoading: true, isPeeking: true, // this will change to false if peeking fails @@ -371,7 +342,7 @@ module.exports = createReactClass({ peekLoading: false, }); this._onRoomLoaded(room); - }, (err) => { + }).catch((err) => { if (this.unmounted) { return; } @@ -384,7 +355,7 @@ module.exports = createReactClass({ // This won't necessarily be a MatrixError, but we duck-type // here and say if it's got an 'errcode' key with the right value, // it means we can't peek. - if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") { + if (err.errcode === "M_GUEST_ACCESS_FORBIDDEN" || err.errcode === 'M_FORBIDDEN') { // This is fine: the room just isn't peekable (we assume). this.setState({ peekLoading: false, @@ -394,8 +365,6 @@ module.exports = createReactClass({ } }); } else if (room) { - //viewing a previously joined room, try to lazy load members - // Stop peeking because we have joined this room previously MatrixClientPeg.get().stopPeeking(); this.setState({isPeeking: false}); @@ -459,8 +428,8 @@ module.exports = createReactClass({ }, componentDidUpdate: function() { - if (this.refs.roomView) { - const roomView = ReactDOM.findDOMNode(this.refs.roomView); + if (this._roomView.current) { + const roomView = this._roomView.current; if (!roomView.ondrop) { roomView.addEventListener('drop', this.onDrop); roomView.addEventListener('dragover', this.onDragOver); @@ -474,10 +443,10 @@ module.exports = createReactClass({ // in render() prevents the ref from being set on first mount, so we try and // catch the messagePanel when it does mount. Because we only want the ref once, // we use a boolean flag to avoid duplicate work. - if (this.refs.messagePanel && !this.state.atEndOfLiveTimelineInit) { + if (this._messagePanel && !this.state.atEndOfLiveTimelineInit) { this.setState({ atEndOfLiveTimelineInit: true, - atEndOfLiveTimeline: this.refs.messagePanel.isAtEndOfLiveTimeline(), + atEndOfLiveTimeline: this._messagePanel.isAtEndOfLiveTimeline(), }); } }, @@ -489,8 +458,6 @@ module.exports = createReactClass({ // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; - SettingsStore.unwatchSetting(this._ciderWatcherRef); - // update the scroll map before we get unmounted if (this.state.roomId) { RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState()); @@ -499,12 +466,12 @@ module.exports = createReactClass({ // stop tracking room changes to format permalinks this._stopAllPermalinkCreators(); - if (this.refs.roomView) { + if (this._roomView.current) { // disconnect the D&D event listeners from the room view. This // is really just for hygiene - we're going to be // deleted anyway, so it doesn't matter if the event listeners // don't get cleaned up. - const roomView = ReactDOM.findDOMNode(this.refs.roomView); + const roomView = this._roomView.current; roomView.removeEventListener('drop', this.onDrop); roomView.removeEventListener('dragover', this.onDragOver); roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd); @@ -516,11 +483,13 @@ module.exports = createReactClass({ MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData); + MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); MatrixClientPeg.get().removeListener("Room.myMembership", this.onMyMembership); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("accountData", this.onAccountData); MatrixClientPeg.get().removeListener("crypto.keyBackupStatus", this.onKeyBackupStatus); MatrixClientPeg.get().removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); + MatrixClientPeg.get().removeListener("userTrustStatusChanged", this.onUserVerificationChanged); } window.removeEventListener('beforeunload', this.onPageUnload); @@ -560,15 +529,15 @@ module.exports = createReactClass({ let handled = false; const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); - switch (ev.keyCode) { - case KeyCode.KEY_D: + switch (ev.key) { + case Key.D: if (ctrlCmdOnly) { this.onMuteAudioClick(); handled = true; } break; - case KeyCode.KEY_E: + case Key.E: if (ctrlCmdOnly) { this.onMuteVideoClick(); handled = true; @@ -584,6 +553,10 @@ module.exports = createReactClass({ onAction: function(payload) { switch (payload.action) { + case 'after_right_panel_phase_change': + // We don't keep state on the right panel, so just re-render to update + this.forceUpdate(); + break; case 'message_send_failed': case 'message_sent': this._checkIfAlone(this.state.room); @@ -641,6 +614,22 @@ module.exports = createReactClass({ this.onCancelSearchClick(); } break; + case 'quote': + if (this.state.searchResults) { + const roomId = payload.event.getRoomId(); + if (roomId === this.state.roomId) { + this.onCancelSearchClick(); + } + + setImmediate(() => { + dis.dispatch({ + action: 'view_room', + room_id: roomId, + deferred_action: payload, + }); + }); + } + break; } }, @@ -701,10 +690,10 @@ module.exports = createReactClass({ }, canResetTimeline: function() { - if (!this.refs.messagePanel) { + if (!this._messagePanel) { return true; } - return this.refs.messagePanel.canResetTimeline(); + return this._messagePanel.canResetTimeline(); }, // called when state.room is first initialised (either at initial load, @@ -787,11 +776,20 @@ module.exports = createReactClass({ this._updateE2EStatus(room); }, - _updateE2EStatus: function(room) { - if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) { + onUserVerificationChanged: function(userId, _trustStatus) { + const room = this.state.room; + if (!room || !room.currentState.getMember(userId)) { return; } - if (!MatrixClientPeg.get().isCryptoEnabled()) { + this._updateE2EStatus(room); + }, + + _updateE2EStatus: async function(room) { + const cli = MatrixClientPeg.get(); + if (!cli.isRoomEncrypted(room.roomId)) { + return; + } + if (!cli.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. @@ -800,10 +798,50 @@ module.exports = createReactClass({ }); return; } - room.hasUnverifiedDevices().then((hasUnverifiedDevices) => { - this.setState({ - e2eStatus: hasUnverifiedDevices ? "warning" : "verified", + if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) { + room.hasUnverifiedDevices().then((hasUnverifiedDevices) => { + this.setState({ + e2eStatus: hasUnverifiedDevices ? "warning" : "verified", + }); }); + debuglog("e2e check is warning/verified only as cross-signing is off"); + return; + } + + // Duplication between here and _updateE2eStatus in RoomTile + /* At this point, the user has encryption on and cross-signing on */ + const e2eMembers = await room.getEncryptionTargetMembers(); + const verified = []; + const unverified = []; + e2eMembers.map(({userId}) => userId) + .filter((userId) => userId !== cli.getUserId()) + .forEach((userId) => { + (cli.checkUserTrust(userId).isCrossSigningVerified() ? + verified : unverified).push(userId) + }); + + debuglog("e2e verified", verified, "unverified", unverified); + + /* Check all verified user devices. */ + /* Don't alarm if no other users are verified */ + const targets = (verified.length > 0) ? [...verified, cli.getUserId()] : verified; + for (const userId of targets) { + const devices = await cli.getStoredDevicesForUser(userId); + const anyDeviceNotVerified = devices.some(({deviceId}) => { + return !cli.checkDeviceTrust(userId, deviceId).isVerified(); + }); + if (anyDeviceNotVerified) { + this.setState({ + e2eStatus: "warning", + }); + debuglog("e2e status set to warning as not all users trust all of their sessions." + + " Aborted on user", userId); + return; + } + } + + this.setState({ + e2eStatus: unverified.length === 0 ? "verified" : "normal", }); }, @@ -882,7 +920,7 @@ module.exports = createReactClass({ // rate limited because a power level change will emit an event for every // member in the room. - _updateRoomMembers: new rate_limited_func(function(dueToMember) { + _updateRoomMembers: rate_limited_func(function(dueToMember) { // a member state changed in this room // refresh the conf call notification state this._updateConfCallNotification(); @@ -1046,7 +1084,7 @@ module.exports = createReactClass({ }, onMessageListScroll: function(ev) { - if (this.refs.messagePanel.isAtEndOfLiveTimeline()) { + if (this._messagePanel.isAtEndOfLiveTimeline()) { this.setState({ numUnreadMessages: 0, atEndOfLiveTimeline: true, @@ -1101,7 +1139,7 @@ module.exports = createReactClass({ } ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get()) - .done(undefined, (error) => { + .then(undefined, (error) => { if (error.name === "UnknownDeviceError") { // Let the staus bar handle this return; @@ -1119,8 +1157,8 @@ module.exports = createReactClass({ // if we already have a search panel, we need to tell it to forget // about its scroll state. - if (this.refs.searchResultsPanel) { - this.refs.searchResultsPanel.resetScrollState(); + if (this._searchResultsPanel.current) { + this._searchResultsPanel.current.resetScrollState(); } // make sure that we don't end up showing results from @@ -1129,23 +1167,12 @@ module.exports = createReactClass({ // todo: should cancel any previous search requests. this.searchId = new Date().getTime(); - let filter; - if (scope === "Room") { - filter = { - // XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :( - rooms: [ - this.state.room.roomId, - ], - }; - } + let roomId; + if (scope === "Room") roomId = this.state.room.roomId; debuglog("sending search request"); - - const searchPromise = MatrixClientPeg.get().searchRoomEvents({ - filter: filter, - term: term, - }); - this._handleSearchResult(searchPromise).done(); + const searchPromise = eventSearch(term, roomId); + this._handleSearchResult(searchPromise); }, _handleSearchResult: function(searchPromise) { @@ -1236,7 +1263,7 @@ module.exports = createReactClass({ // once dynamic content in the search results load, make the scrollPanel check // the scroll offsets. const onHeightChanged = () => { - const scrollPanel = this.refs.searchResultsPanel; + const scrollPanel = this._searchResultsPanel.current; if (scrollPanel) { scrollPanel.checkScroll(); } @@ -1251,7 +1278,7 @@ module.exports = createReactClass({ const roomId = mxEv.getRoomId(); const room = cli.getRoom(roomId); - if (!EventTile.haveTileForEvent(mxEv)) { + if (!haveTileForEvent(mxEv)) { // XXX: can this ever happen? It will make the result count // not match the displayed count. continue; @@ -1316,7 +1343,7 @@ module.exports = createReactClass({ }, onForgetClick: function() { - MatrixClientPeg.get().forget(this.state.room.roomId).done(function() { + MatrixClientPeg.get().forget(this.state.room.roomId).then(function() { dis.dispatch({ action: 'view_next_room' }); }, function(err) { const errCode = err.errcode || _t("unknown error code"); @@ -1333,7 +1360,7 @@ module.exports = createReactClass({ this.setState({ rejecting: true, }); - MatrixClientPeg.get().leave(this.state.roomId).done(function() { + MatrixClientPeg.get().leave(this.state.roomId).then(function() { dis.dispatch({ action: 'view_next_room' }); self.setState({ rejecting: false, @@ -1355,6 +1382,41 @@ module.exports = createReactClass({ }); }, + onRejectAndIgnoreClick: async function() { + this.setState({ + rejecting: true, + }); + + const cli = MatrixClientPeg.get(); + try { + const myMember = this.state.room.getMember(cli.getUserId()); + const inviteEvent = myMember.events.member; + const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); + ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk + await cli.setIgnoredUsers(ignoredUsers); + + await cli.leave(this.state.roomId); + dis.dispatch({ action: 'view_next_room' }); + this.setState({ + rejecting: false, + }); + } catch (error) { + 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, + }); + + self.setState({ + rejecting: false, + rejectError: error, + }); + } + }, + onRejectThreepidInviteButtonClicked: function(ev) { // We can reject 3pid invites in the same way that we accept them, // using /leave rather than /join. In the short term though, we @@ -1381,28 +1443,28 @@ module.exports = createReactClass({ // jump down to the bottom of this room, where new events are arriving jumpToLiveTimeline: function() { - this.refs.messagePanel.jumpToLiveTimeline(); + this._messagePanel.jumpToLiveTimeline(); dis.dispatch({action: 'focus_composer'}); }, // jump up to wherever our read marker is jumpToReadMarker: function() { - this.refs.messagePanel.jumpToReadMarker(); + this._messagePanel.jumpToReadMarker(); }, // update the read marker to match the read-receipt forgetReadMarker: function(ev) { ev.stopPropagation(); - this.refs.messagePanel.forgetReadMarker(); + this._messagePanel.forgetReadMarker(); }, // decide whether or not the top 'unread messages' bar should be shown _updateTopUnreadMessagesBar: function() { - if (!this.refs.messagePanel) { + if (!this._messagePanel) { return; } - const showBar = this.refs.messagePanel.canJumpToReadMarker(); + const showBar = this._messagePanel.canJumpToReadMarker(); if (this.state.showTopUnreadMessagesBar != showBar) { this.setState({showTopUnreadMessagesBar: showBar}); } @@ -1412,7 +1474,7 @@ module.exports = createReactClass({ // restored when we switch back to it. // _getScrollState: function() { - const messagePanel = this.refs.messagePanel; + const messagePanel = this._messagePanel; if (!messagePanel) return null; // if we're following the live timeline, we want to return null; that @@ -1517,10 +1579,10 @@ module.exports = createReactClass({ */ handleScrollKey: function(ev) { let panel; - if (this.refs.searchResultsPanel) { - panel = this.refs.searchResultsPanel; - } else if (this.refs.messagePanel) { - panel = this.refs.messagePanel; + if (this._searchResultsPanel.current) { + panel = this._searchResultsPanel.current; + } else if (this._messagePanel) { + panel = this._messagePanel; } if (panel) { @@ -1541,7 +1603,7 @@ module.exports = createReactClass({ // this has to be a proper method rather than an unnamed function, // otherwise react calls it with null on each update. _gatherTimelinePanelRef: function(r) { - this.refs.messagePanel = r; + this._messagePanel = r; if (r) { console.log("updateTint from RoomView._gatherTimelinePanelRef"); this.updateTint(); @@ -1659,9 +1721,11 @@ module.exports = createReactClass({ return (
    - ; } else if (this.state.searching) { hideCancel = true; // has own cancel - aux = ; + aux = ; } else if (showRoomUpgradeBar) { aux = ; hideCancel = true; @@ -1771,7 +1835,7 @@ module.exports = createReactClass({
    ); } else { - hideRightPanel = true; + forceHideRightPanel = true; } } else if (hiddenHighlightCount > 0) { aux = ( @@ -1786,7 +1850,7 @@ module.exports = createReactClass({ } const auxPanel = ( - ; - } else { - const SlateMessageComposer = sdk.getComponent('rooms.SlateMessageComposer'); - messageComposer = - ; - } + const MessageComposer = sdk.getComponent('rooms.MessageComposer'); + messageComposer = + ; } // TODO: Why aren't we storing the term/scope/count in this format @@ -1886,7 +1937,7 @@ module.exports = createReactClass({ searchResultsPanel = (
    ); } else { searchResultsPanel = ( - ); let topUnreadMessagesBar = null; - if (this.state.showTopUnreadMessagesBar) { + // 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 = (); } let jumpToBottom; - if (!this.state.atEndOfLiveTimeline) { + // 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 = (; - const collapsedRhs = hideRightPanel || this.props.collapsedRhs; + const showRightPanel = !forceHideRightPanel && this.state.room + && RightPanelStore.getSharedInstance().isOpenForRoom; + const rightPanel = showRightPanel + ? + : null; return ( -
    - - - -
    - {auxPanel} -
    - {topUnreadMessagesBar} - {jumpToBottom} - {messagePanel} - {searchResultsPanel} -
    -
    -
    -
    - {statusBar} + +
    + + + +
    + {auxPanel} +
    + {topUnreadMessagesBar} + {jumpToBottom} + {messagePanel} + {searchResultsPanel}
    +
    +
    +
    + {statusBar} +
    +
    + {previewBar} + {messageComposer}
    - {previewBar} - {messageComposer} -
    -
    -
    -
    + + +
    + ); }, }); - -module.exports.RoomContext = RoomContext; diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index cb29305dd3..b81b3ebede 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -14,11 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, {createRef} from "react"; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; -import Promise from 'bluebird'; -import { KeyCode } from '../../Keyboard'; +import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; import AutoHideScrollbar from "./AutoHideScrollbar"; @@ -85,7 +84,7 @@ if (DEBUG_SCROLL) { * offset as normal. */ -module.exports = createReactClass({ +export default createReactClass({ displayName: 'ScrollPanel', propTypes: { @@ -167,6 +166,8 @@ module.exports = createReactClass({ } this.resetScrollState(); + + this._itemlist = createRef(); }, componentDidMount: function() { @@ -329,7 +330,7 @@ module.exports = createReactClass({ this._isFilling = true; } - const itemlist = this.refs.itemlist; + const itemlist = this._itemlist.current; const firstTile = itemlist && itemlist.firstElementChild; const contentTop = firstTile && firstTile.offsetTop; const fillPromises = []; @@ -374,7 +375,7 @@ module.exports = createReactClass({ const origExcessHeight = excessHeight; - const tiles = this.refs.itemlist.children; + const tiles = this._itemlist.current.children; // The scroll token of the first/last tile to be unpaginated let markerScrollToken = null; @@ -522,7 +523,7 @@ module.exports = createReactClass({ scrollRelative: function(mult) { const scrollNode = this._getScrollNode(); const delta = mult * scrollNode.clientHeight * 0.5; - scrollNode.scrollTop = scrollNode.scrollTop + delta; + scrollNode.scrollBy(0, delta); this._saveScrollState(); }, @@ -531,26 +532,26 @@ module.exports = createReactClass({ * @param {object} ev the keyboard event */ handleScrollKey: function(ev) { - switch (ev.keyCode) { - case KeyCode.PAGE_UP: + switch (ev.key) { + case Key.PAGE_UP: if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { this.scrollRelative(-1); } break; - case KeyCode.PAGE_DOWN: + case Key.PAGE_DOWN: if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { this.scrollRelative(1); } break; - case KeyCode.HOME: + case Key.HOME: if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { this.scrollToTop(); } break; - case KeyCode.END: + case Key.END: if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { this.scrollToBottom(); } @@ -603,7 +604,7 @@ module.exports = createReactClass({ const scrollNode = this._getScrollNode(); const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight); - const itemlist = this.refs.itemlist; + const itemlist = this._itemlist.current; const messages = itemlist.children; let node = null; @@ -645,7 +646,7 @@ module.exports = createReactClass({ const sn = this._getScrollNode(); sn.scrollTop = sn.scrollHeight; } else if (scrollState.trackedScrollToken) { - const itemlist = this.refs.itemlist; + const itemlist = this._itemlist.current; const trackedNode = this._getTrackedNode(); if (trackedNode) { const newBottomOffset = this._topFromBottom(trackedNode); @@ -677,8 +678,13 @@ module.exports = createReactClass({ debuglog("updateHeight getting straight to business, no scrolling going on."); } + // We might have unmounted since the timer finished, so abort if so. + if (this.unmounted) { + return; + } + const sn = this._getScrollNode(); - const itemlist = this.refs.itemlist; + const itemlist = this._itemlist.current; const contentHeight = this._getMessagesHeight(); const minHeight = sn.clientHeight; const height = Math.max(minHeight, contentHeight); @@ -699,17 +705,15 @@ module.exports = createReactClass({ // the currently filled piece of the timeline if (trackedNode) { const oldTop = trackedNode.offsetTop; - // changing the height might change the scrollTop - // if the new height is smaller than the scrollTop. - // We calculate the diff that needs to be applied - // ourselves, so be sure to measure the - // scrollTop before changing the height. - const preexistingScrollTop = sn.scrollTop; itemlist.style.height = `${newHeight}px`; const newTop = trackedNode.offsetTop; const topDiff = newTop - oldTop; - sn.scrollTop = preexistingScrollTop + topDiff; - debuglog("updateHeight to", {newHeight, topDiff, preexistingScrollTop}); + // important to scroll by a relative amount as + // reading scrollTop and then setting it might + // yield out of date values and cause a jump + // when setting it + sn.scrollBy(0, topDiff); + debuglog("updateHeight to", {newHeight, topDiff}); } } }, @@ -720,7 +724,7 @@ module.exports = createReactClass({ if (!trackedNode || !trackedNode.parentElement) { let node; - const messages = this.refs.itemlist.children; + const messages = this._itemlist.current.children; const scrollToken = scrollState.trackedScrollToken; for (let i = messages.length-1; i >= 0; --i) { @@ -752,14 +756,17 @@ module.exports = createReactClass({ }, _getMessagesHeight() { - const itemlist = this.refs.itemlist; + const itemlist = this._itemlist.current; const lastNode = itemlist.lastElementChild; + const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0; + const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0; // 18 is itemlist padding - return (lastNode.offsetTop + lastNode.clientHeight) - itemlist.firstElementChild.offsetTop + (18 * 2); + return lastNodeBottom - firstNodeTop + (18 * 2); }, _topFromBottom(node) { - return this.refs.itemlist.clientHeight - node.offsetTop; + // current capped height - distance from top = distance from bottom of container to top of tracked element + return this._itemlist.current.clientHeight - node.offsetTop; }, /* get the DOM node which has the scrollTop property we care about for our @@ -791,7 +798,7 @@ module.exports = createReactClass({ the same minimum bottom offset, effectively preventing the timeline to shrink. */ preventShrinking: function() { - const messageList = this.refs.itemlist; + const messageList = this._itemlist.current; const tiles = messageList && messageList.children; if (!messageList) { return; @@ -818,7 +825,7 @@ module.exports = createReactClass({ /** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */ clearPreventShrinking: function() { - const messageList = this.refs.itemlist; + const messageList = this._itemlist.current; const balanceElement = messageList && messageList.parentElement; if (balanceElement) balanceElement.style.paddingBottom = null; this.preventShrinkingState = null; @@ -837,7 +844,7 @@ module.exports = createReactClass({ if (this.preventShrinkingState) { const sn = this._getScrollNode(); const scrollState = this.scrollState; - const messageList = this.refs.itemlist; + const messageList = this._itemlist.current; const {offsetNode, offsetFromBottom} = this.preventShrinkingState; // element used to set paddingBottom to balance the typing notifs disappearing const balanceElement = messageList.parentElement; @@ -869,11 +876,14 @@ module.exports = createReactClass({ // TODO: the classnames on the div and ol could do with being updated to // reflect the fact that we don't necessarily contain a list of messages. // it's not obvious why we have a separate div and ol anyway. + + // give the
      an explicit role=list because Safari+VoiceOver seems to think an ordered-list with + // list-style-type: none; is no longer a list return (
      -
        +
          { this.props.children }
      diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index de9a86c3a6..e169e09752 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -15,21 +15,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; -import { KeyCode } from '../../Keyboard'; +import { Key } from '../../Keyboard'; import dis from '../../dispatcher'; import { throttle } from 'lodash'; import AccessibleButton from '../../components/views/elements/AccessibleButton'; import classNames from 'classnames'; -module.exports = createReactClass({ +export default createReactClass({ displayName: 'SearchBox', propTypes: { onSearch: PropTypes.func, onCleared: PropTypes.func, + onKeyDown: PropTypes.func, className: PropTypes.string, placeholder: PropTypes.string.isRequired, @@ -52,6 +53,10 @@ module.exports = createReactClass({ }; }, + UNSAFE_componentWillMount: function() { + this._search = createRef(); + }, + componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); }, @@ -65,34 +70,35 @@ module.exports = createReactClass({ switch (payload.action) { case 'view_room': - if (this.refs.search && payload.clear_search) { + if (this._search.current && payload.clear_search) { this._clearSearch(); } break; case 'focus_room_filter': - if (this.refs.search) { - this.refs.search.focus(); + if (this._search.current) { + this._search.current.focus(); } break; } }, onChange: function() { - if (!this.refs.search) return; - this.setState({ searchTerm: this.refs.search.value }); + if (!this._search.current) return; + this.setState({ searchTerm: this._search.current.value }); this.onSearch(); }, onSearch: throttle(function() { - this.props.onSearch(this.refs.search.value); + this.props.onSearch(this._search.current.value); }, 200, {trailing: true, leading: true}), _onKeyDown: function(ev) { - switch (ev.keyCode) { - case KeyCode.ESCAPE: + switch (ev.key) { + case Key.ESCAPE: this._clearSearch("keyboard"); break; } + if (this.props.onKeyDown) this.props.onKeyDown(ev); }, _onFocus: function(ev) { @@ -111,7 +117,7 @@ module.exports = createReactClass({ }, _clearSearch: function(source) { - this.refs.search.value = ""; + this._search.current.value = ""; this.onChange(); if (this.props.onCleared) { this.props.onCleared(source); @@ -127,9 +133,11 @@ module.exports = createReactClass({ return null; } const clearButton = (!this.state.blurred || this.state.searchTerm) ? - ( {this._clearSearch("button"); } }> + ( {this._clearSearch("button"); } }> ) : undefined; // show a shorter placeholder when blurred, if requested @@ -144,7 +152,7 @@ module.exports = createReactClass({ { clearButton }
    diff --git a/src/components/structures/TabbedView.js b/src/components/structures/TabbedView.js index 01c68fad62..20af183af8 100644 --- a/src/components/structures/TabbedView.js +++ b/src/components/structures/TabbedView.js @@ -19,7 +19,7 @@ limitations under the License. import * as React from "react"; import {_t} from '../../languageHandler'; import PropTypes from "prop-types"; -import sdk from "../../index"; +import * as sdk from "../../index"; /** * Represents a tab for the TabbedView. @@ -38,7 +38,7 @@ export class Tab { } } -export class TabbedView extends React.Component { +export default class TabbedView extends React.Component { static propTypes = { // The tabs to show tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired, diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index a758092dc8..622e63d8ce 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -1,5 +1,6 @@ /* Copyright 2017, 2018 New Vector Ltd. +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,24 +17,23 @@ limitations under the License. import React from 'react'; import createReactClass from 'create-react-class'; -import PropTypes from 'prop-types'; -import { MatrixClient } from 'matrix-js-sdk'; import TagOrderStore from '../../stores/TagOrderStore'; import GroupActions from '../../actions/GroupActions'; -import sdk from '../../index'; +import * as sdk from '../../index'; import dis from '../../dispatcher'; import { _t } from '../../languageHandler'; import { Droppable } from 'react-beautiful-dnd'; import classNames from 'classnames'; +import MatrixClientContext from "../../contexts/MatrixClientContext"; const TagPanel = createReactClass({ displayName: 'TagPanel', - contextTypes: { - matrixClient: PropTypes.instanceOf(MatrixClient), + statics: { + contextType: MatrixClientContext, }, getInitialState() { @@ -45,8 +45,8 @@ const TagPanel = createReactClass({ componentWillMount: function() { this.unmounted = false; - this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership); - this.context.matrixClient.on("sync", this._onClientSync); + this.context.on("Group.myMembership", this._onGroupMyMembership); + this.context.on("sync", this._onClientSync); this._tagOrderStoreToken = TagOrderStore.addListener(() => { if (this.unmounted) { @@ -58,21 +58,21 @@ const TagPanel = createReactClass({ }); }); // This could be done by anything with a matrix client - dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); + dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); }, componentWillUnmount() { this.unmounted = true; - this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); - this.context.matrixClient.removeListener("sync", this._onClientSync); - if (this._filterStoreToken) { - this._filterStoreToken.remove(); + this.context.removeListener("Group.myMembership", this._onGroupMyMembership); + this.context.removeListener("sync", this._onClientSync); + if (this._tagOrderStoreToken) { + this._tagOrderStoreToken.remove(); } }, _onGroupMyMembership() { if (this.unmounted) return; - dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); + dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); }, _onClientSync(syncState, prevState) { @@ -81,7 +81,7 @@ const TagPanel = createReactClass({ const reconnected = syncState !== "ERROR" && prevState !== syncState; if (reconnected) { // Load joined groups - dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); + dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); } }, @@ -104,6 +104,7 @@ const TagPanel = createReactClass({ render() { const DNDTagTile = sdk.getComponent('elements.DNDTagTile'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const ActionButton = sdk.getComponent('elements.ActionButton'); const TintableSvg = sdk.getComponent('elements.TintableSvg'); const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); @@ -154,6 +155,13 @@ const TagPanel = createReactClass({ ref={provided.innerRef} > { tags } +
    + +
    { provided.placeholder }
    ) } diff --git a/src/components/structures/TagPanelButtons.js b/src/components/structures/TagPanelButtons.js index 7255e12307..93a596baa3 100644 --- a/src/components/structures/TagPanelButtons.js +++ b/src/components/structures/TagPanelButtons.js @@ -16,7 +16,7 @@ limitations under the License. import React from 'react'; import createReactClass from 'create-react-class'; -import sdk from '../../index'; +import * as sdk from '../../index'; import dis from '../../dispatcher'; import Modal from '../../Modal'; import { _t } from '../../languageHandler'; diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index faa6f2564a..25526c3139 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -2,7 +2,7 @@ Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,27 +18,24 @@ limitations under the License. */ import SettingsStore from "../../settings/SettingsStore"; - -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import ReactDOM from "react-dom"; import PropTypes from 'prop-types'; -import Promise from 'bluebird'; - -const Matrix = require("matrix-js-sdk"); -const EventTimeline = Matrix.EventTimeline; - -const sdk = require('../../index'); +import {EventTimeline} from "matrix-js-sdk"; +import * as Matrix from "matrix-js-sdk"; import { _t } from '../../languageHandler'; -const MatrixClientPeg = require("../../MatrixClientPeg"); -const dis = require("../../dispatcher"); -const ObjectUtils = require('../../ObjectUtils'); -const Modal = require("../../Modal"); -const UserActivity = require("../../UserActivity"); -import { KeyCode } from '../../Keyboard'; +import {MatrixClientPeg} from "../../MatrixClientPeg"; +import * as ObjectUtils from "../../ObjectUtils"; +import UserActivity from "../../UserActivity"; +import Modal from "../../Modal"; +import dis from "../../dispatcher"; +import * as sdk from "../../index"; +import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; import shouldHideEvent from '../../shouldHideEvent'; import EditorStateTransfer from '../../utils/EditorStateTransfer'; +import {haveTileForEvent} from "../views/rooms/EventTile"; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -97,6 +94,10 @@ const TimelinePanel = createReactClass({ // callback which is called when the read-up-to mark is updated. onReadMarkerUpdated: PropTypes.func, + // callback which is called when we wish to paginate the timeline + // window. + onPaginationRequest: PropTypes.func, + // maximum number of events to show in a timeline timelineCap: PropTypes.number, @@ -145,6 +146,9 @@ const TimelinePanel = createReactClass({ liveEvents: [], timelineLoading: true, // track whether our room timeline is loading + // the index of the first event that is to be shown + firstVisibleEventIndex: 0, + // canBackPaginate == false may mean: // // * we haven't (successfully) loaded the timeline yet, or: @@ -204,6 +208,8 @@ const TimelinePanel = createReactClass({ this.lastRRSentEventId = undefined; this.lastRMSentEventId = undefined; + this._messagePanel = createRef(); + if (this.props.manageReadReceipts) { this.updateReadReceiptOnUserActivity(); } @@ -330,15 +336,24 @@ const TimelinePanel = createReactClass({ // We can now paginate in the unpaginated direction const canPaginateKey = (backwards) ? 'canBackPaginate' : 'canForwardPaginate'; - const { events, liveEvents } = this._getEvents(); + const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); this.setState({ [canPaginateKey]: true, events, liveEvents, + firstVisibleEventIndex, }); } }, + onPaginationRequest(timelineWindow, direction, size) { + if (this.props.onPaginationRequest) { + return this.props.onPaginationRequest(timelineWindow, direction, size); + } else { + return timelineWindow.paginate(direction, size); + } + }, + // set off a pagination request. onMessageListFillRequest: function(backwards) { if (!this._shouldPaginate()) return Promise.resolve(false); @@ -358,20 +373,26 @@ const TimelinePanel = createReactClass({ return Promise.resolve(false); } + if (backwards && this.state.firstVisibleEventIndex !== 0) { + debuglog("TimelinePanel: won't", dir, "paginate past first visible event"); + return Promise.resolve(false); + } + debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards); this.setState({[paginatingKey]: true}); - return this._timelineWindow.paginate(dir, PAGINATE_SIZE).then((r) => { + return this.onPaginationRequest(this._timelineWindow, dir, PAGINATE_SIZE).then((r) => { if (this.unmounted) { return; } debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r); - const { events, liveEvents } = this._getEvents(); + const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); const newState = { [paginatingKey]: false, [canPaginateKey]: r, events, liveEvents, + firstVisibleEventIndex, }; // moving the window in this direction may mean that we can now @@ -391,7 +412,11 @@ const TimelinePanel = createReactClass({ // itself into the right place return new Promise((resolve) => { this.setState(newState, () => { - resolve(r); + // we can continue paginating in the given direction if: + // - _timelineWindow.paginate says we can + // - we're paginating forwards, or we won't be trying to + // paginate backwards past the first visible event + resolve(r && (!backwards || firstVisibleEventIndex === 0)); }); }); }); @@ -426,8 +451,8 @@ const TimelinePanel = createReactClass({ if (payload.action === "edit_event") { const editState = payload.event ? new EditorStateTransfer(payload.event) : null; this.setState({editState}, () => { - if (payload.event && this.refs.messagePanel) { - this.refs.messagePanel.scrollToEventIfNeeded( + if (payload.event && this._messagePanel.current) { + this._messagePanel.current.scrollToEventIfNeeded( payload.event.getId(), ); } @@ -443,9 +468,9 @@ const TimelinePanel = createReactClass({ // updates from pagination will happen when the paginate completes. if (toStartOfTimeline || !data || !data.liveEvent) return; - if (!this.refs.messagePanel) return; + if (!this._messagePanel.current) return; - if (!this.refs.messagePanel.getScrollState().stuckAtBottom) { + if (!this._messagePanel.current.getScrollState().stuckAtBottom) { // we won't load this event now, because we don't want to push any // events off the other end of the timeline. But we need to note // that we can now paginate. @@ -462,15 +487,16 @@ const TimelinePanel = createReactClass({ // timeline window. // // see https://github.com/vector-im/vector-web/issues/1035 - this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).done(() => { + this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => { if (this.unmounted) { return; } - const { events, liveEvents } = this._getEvents(); + const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); const lastLiveEvent = liveEvents[liveEvents.length - 1]; const updatedState = { events, liveEvents, + firstVisibleEventIndex, }; let callRMUpdated; @@ -500,7 +526,7 @@ const TimelinePanel = createReactClass({ } this.setState(updatedState, () => { - this.refs.messagePanel.updateTimelineMinHeight(); + this._messagePanel.current.updateTimelineMinHeight(); if (callRMUpdated) { this.props.onReadMarkerUpdated(); } @@ -511,13 +537,13 @@ const TimelinePanel = createReactClass({ onRoomTimelineReset: function(room, timelineSet) { if (timelineSet !== this.props.timelineSet) return; - if (this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) { + if (this._messagePanel.current && this._messagePanel.current.isAtBottom()) { this._loadTimeline(); } }, canResetTimeline: function() { - return this.refs.messagePanel && this.refs.messagePanel.isAtBottom(); + return this._messagePanel.current && this._messagePanel.current.isAtBottom(); }, onRoomRedaction: function(ev, room) { @@ -630,7 +656,7 @@ const TimelinePanel = createReactClass({ sendReadReceipt: function() { if (SettingsStore.getValue("lowBandwidth")) return; - if (!this.refs.messagePanel) return; + if (!this._messagePanel.current) return; if (!this.props.manageReadReceipts) return; // This happens on user_activity_end which is delayed, and it's // very possible have logged out within that timeframe, so check @@ -816,8 +842,8 @@ const TimelinePanel = createReactClass({ if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { this._loadTimeline(); } else { - if (this.refs.messagePanel) { - this.refs.messagePanel.scrollToBottom(); + if (this._messagePanel.current) { + this._messagePanel.current.scrollToBottom(); } } }, @@ -827,7 +853,7 @@ const TimelinePanel = createReactClass({ */ jumpToReadMarker: function() { if (!this.props.manageReadMarkers) return; - if (!this.refs.messagePanel) return; + if (!this._messagePanel.current) return; if (!this.state.readMarkerEventId) return; // we may not have loaded the event corresponding to the read-marker @@ -836,11 +862,11 @@ const TimelinePanel = createReactClass({ // // a quick way to figure out if we've loaded the relevant event is // simply to check if the messagepanel knows where the read-marker is. - const ret = this.refs.messagePanel.getReadMarkerPosition(); + const ret = this._messagePanel.current.getReadMarkerPosition(); if (ret !== null) { // The messagepanel knows where the RM is, so we must have loaded // the relevant event. - this.refs.messagePanel.scrollToEvent(this.state.readMarkerEventId, + this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId, 0, 1/3); return; } @@ -875,8 +901,8 @@ const TimelinePanel = createReactClass({ * at the end of the live timeline. */ isAtEndOfLiveTimeline: function() { - return this.refs.messagePanel - && this.refs.messagePanel.isAtBottom() + return this._messagePanel.current + && this._messagePanel.current.isAtBottom() && this._timelineWindow && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); }, @@ -888,8 +914,8 @@ const TimelinePanel = createReactClass({ * returns null if we are not mounted. */ getScrollState: function() { - if (!this.refs.messagePanel) { return null; } - return this.refs.messagePanel.getScrollState(); + if (!this._messagePanel.current) { return null; } + return this._messagePanel.current.getScrollState(); }, // returns one of: @@ -900,9 +926,9 @@ const TimelinePanel = createReactClass({ // +1: read marker is below the window getReadMarkerPosition: function() { if (!this.props.manageReadMarkers) return null; - if (!this.refs.messagePanel) return null; + if (!this._messagePanel.current) return null; - const ret = this.refs.messagePanel.getReadMarkerPosition(); + const ret = this._messagePanel.current.getReadMarkerPosition(); if (ret !== null) { return ret; } @@ -937,15 +963,14 @@ const TimelinePanel = createReactClass({ * We pass it down to the scroll panel. */ handleScrollKey: function(ev) { - if (!this.refs.messagePanel) { return; } + if (!this._messagePanel.current) { return; } // jump to the live timeline on ctrl-end, rather than the end of the // timeline window. - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && - ev.keyCode == KeyCode.END) { + if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === Key.END) { this.jumpToLiveTimeline(); } else { - this.refs.messagePanel.handleScrollKey(ev); + this._messagePanel.current.handleScrollKey(ev); } }, @@ -987,8 +1012,8 @@ const TimelinePanel = createReactClass({ const onLoaded = () => { // clear the timeline min-height when // (re)loading the timeline - if (this.refs.messagePanel) { - this.refs.messagePanel.onTimelineReset(); + if (this._messagePanel.current) { + this._messagePanel.current.onTimelineReset(); } this._reloadEvents(); @@ -1003,7 +1028,7 @@ const TimelinePanel = createReactClass({ timelineLoading: false, }, () => { // initialise the scroll state of the message panel - if (!this.refs.messagePanel) { + if (!this._messagePanel.current) { // this shouldn't happen - we know we're mounted because // we're in a setState callback, and we know // timelineLoading is now false, so render() should have @@ -1013,10 +1038,10 @@ const TimelinePanel = createReactClass({ return; } if (eventId) { - this.refs.messagePanel.scrollToEvent(eventId, pixelOffset, + this._messagePanel.current.scrollToEvent(eventId, pixelOffset, offsetBase); } else { - this.refs.messagePanel.scrollToBottom(); + this._messagePanel.current.scrollToBottom(); } this.sendReadReceipt(); @@ -1064,8 +1089,6 @@ const TimelinePanel = createReactClass({ }); }; - let prom = this._timelineWindow.load(eventId, INITIAL_SIZE); - // if we already have the event in question, TimelineWindow.load // returns a resolved promise. // @@ -1074,9 +1097,14 @@ const TimelinePanel = createReactClass({ // quite slow. So we detect that situation and shortcut straight to // calling _reloadEvents and updating the state. - if (prom.isFulfilled()) { + const timeline = this.props.timelineSet.getTimelineForEvent(eventId); + if (timeline) { + // This is a hot-path optimization by skipping a promise tick + // by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline + this._timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time onLoaded(); } else { + const prom = this._timelineWindow.load(eventId, INITIAL_SIZE); this.setState({ events: [], liveEvents: [], @@ -1084,11 +1112,8 @@ const TimelinePanel = createReactClass({ canForwardPaginate: false, timelineLoading: true, }); - - prom = prom.then(onLoaded, onError); + prom.then(onLoaded, onError); } - - prom.done(); }, // handle the completion of a timeline load or localEchoUpdate, by @@ -1105,6 +1130,7 @@ const TimelinePanel = createReactClass({ // get the list of events from the timeline window and the pending event list _getEvents: function() { const events = this._timelineWindow.getEvents(); + const firstVisibleEventIndex = this._checkForPreJoinUISI(events); // Hold onto the live events separately. The read receipt and read marker // should use this list, so that they don't advance into pending events. @@ -1118,9 +1144,84 @@ const TimelinePanel = createReactClass({ return { events, liveEvents, + firstVisibleEventIndex, }; }, + /** + * Check for undecryptable messages that were sent while the user was not in + * the room. + * + * @param {Array} events The timeline events to check + * + * @return {Number} The index within `events` of the event after the most recent + * undecryptable event that was sent while the user was not in the room. If no + * such events were found, then it returns 0. + */ + _checkForPreJoinUISI: function(events) { + const room = this.props.timelineSet.room; + + if (events.length === 0 || !room || + !MatrixClientPeg.get().isRoomEncrypted(room.roomId)) { + return 0; + } + + const userId = MatrixClientPeg.get().credentials.userId; + + // get the user's membership at the last event by getting the timeline + // that the event belongs to, and traversing the timeline looking for + // that event, while keeping track of the user's membership + let i; + let userMembership = "leave"; + for (i = events.length - 1; i >= 0; i--) { + const timeline = room.getTimelineForEvent(events[i].getId()); + if (!timeline) { + // Somehow, it seems to be possible for live events to not have + // a timeline, even though that should not happen. :( + // https://github.com/vector-im/riot-web/issues/12120 + console.warn( + `Event ${events[i].getId()} in room ${room.roomId} is live, ` + + `but it does not have a timeline`, + ); + continue; + } + const userMembershipEvent = + timeline.getState(EventTimeline.FORWARDS).getMember(userId); + userMembership = userMembershipEvent ? userMembershipEvent.membership : "leave"; + const timelineEvents = timeline.getEvents(); + for (let j = timelineEvents.length - 1; j >= 0; j--) { + const event = timelineEvents[j]; + if (event.getId() === events[i].getId()) { + break; + } else if (event.getStateKey() === userId + && event.getType() === "m.room.member") { + const prevContent = event.getPrevContent(); + userMembership = prevContent.membership || "leave"; + } + } + break; + } + + // now go through the rest of the events and find the first undecryptable + // one that was sent when the user wasn't in the room + for (; i >= 0; i--) { + const event = events[i]; + if (event.getStateKey() === userId + && event.getType() === "m.room.member") { + const prevContent = event.getPrevContent(); + userMembership = prevContent.membership || "leave"; + } else if (userMembership === "leave" && + (event.isDecryptionFailure() || event.isBeingDecrypted())) { + // reached an undecryptable message when the user wasn't in + // the room -- don't try to load any more + // Note: for now, we assume that events that are being decrypted are + // not decryptable + return i + 1; + } + } + return 0; + }, + _indexForEventId: function(evId) { for (let i = 0; i < this.state.events.length; ++i) { if (evId == this.state.events[i].getId()) { @@ -1135,12 +1236,12 @@ const TimelinePanel = createReactClass({ const ignoreOwn = opts.ignoreOwn || false; const allowPartial = opts.allowPartial || false; - const messagePanel = this.refs.messagePanel; - if (messagePanel === undefined) return null; + const messagePanel = this._messagePanel.current; + if (!messagePanel) return null; - const EventTile = sdk.getComponent('rooms.EventTile'); - - const wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect(); + const messagePanelNode = ReactDOM.findDOMNode(messagePanel); + if (!messagePanelNode) return null; // sometimes this happens for fresh rooms/post-sync + const wrapperRect = messagePanelNode.getBoundingClientRect(); const myUserId = MatrixClientPeg.get().credentials.userId; const isNodeInView = (node) => { @@ -1181,7 +1282,7 @@ const TimelinePanel = createReactClass({ const shouldIgnore = !!ev.status || // local echo (ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message - const isWithoutTile = !EventTile.haveTileForEvent(ev) || shouldHideEvent(ev); + const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev); if (isWithoutTile || !node) { // don't start counting if the event should be ignored, @@ -1313,14 +1414,18 @@ const TimelinePanel = createReactClass({ this.state.forwardPaginating || ['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState) ); + const events = this.state.firstVisibleEventIndex + ? this.state.events.slice(this.state.firstVisibleEventIndex) + : this.state.events; return ( -