diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml deleted file mode 100644 index a8ce1273fb..0000000000 --- a/.buildkite/pipeline.yaml +++ /dev/null @@ -1,126 +0,0 @@ -steps: - - label: ":eslint: JS Lint" - command: - - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh" - - "yarn lint:js" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: ":eslint: TS Lint" - command: - - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh" - - "yarn lint:ts" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: ":eslint: Types Lint" - command: - - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh" - - "yarn lint:types" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: ":jest: 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: - - "echo '--- Install js-sdk'" - # TODO: Remove hacky chmod for BuildKite - - "chmod +x ./scripts/ci/*.sh" - - "chmod +x ./scripts/*" - - "echo '--- Installing Dependencies'" - - "./scripts/ci/install-deps.sh" - - "echo '--- Running initial build steps'" - - "yarn build" - - "echo '+++ Running Tests'" - - "yarn test" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: "🛠 Build" - command: - - "echo '--- Install js-sdk'" - - "./scripts/ci/install-deps.sh" - - "echo '+++ Building Project'" - - "yarn build" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: ":chains: End-to-End Tests" - agents: - # We use a xlarge sized instance instead of the normal small ones because - # e2e tests otherwise take +-8min - queue: "xlarge" - 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" - - "echo '--- Running initial build steps'" - - "yarn build" - - "echo '+++ Running Tests'" - - "./scripts/ci/end-to-end-tests.sh" - plugins: - - docker#v3.0.1: - image: "matrixdotorg/riotweb-ci-e2etests-env:latest" - 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" - # TODO: Remove hacky chmod for BuildKite - - "chmod +x ./scripts/ci/*.sh" - - "chmod +x ./scripts/*" - - "echo '--- Installing Dependencies'" - - "./scripts/ci/install-deps.sh" - - "echo '--- Running initial build steps'" - - "yarn build" - - "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 36b03b121c..ffd398cb14 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -61,3 +61,7 @@ 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/CHANGELOG.md b/CHANGELOG.md index 3ae2711e25..8d436ca690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,690 @@ +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) diff --git a/README.md b/README.md index 0fbed22030..d6fd6db1b7 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ All code lands on the `develop` branch - `master` is only used for stable releas **Please file PRs against `develop`!!** Please follow the standard Matrix contributor's guide: -https://github.com/matrix-org/synapse/tree/master/CONTRIBUTING.rst +https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst Please follow the Matrix JS/React code style as per: https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md diff --git a/babel.config.js b/babel.config.js index c83be72518..d5a97d56ce 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,19 +2,16 @@ module.exports = { "sourceMaps": "inline", "presets": [ ["@babel/preset-env", { - "targets": { - "browsers": [ - "last 2 versions" - ] - }, - "modules": "commonjs" + "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-decorators", {legacy: true}], "@babel/plugin-proposal-export-default-from", "@babel/plugin-proposal-numeric-separator", "@babel/plugin-proposal-class-properties", 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/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/package.json b/package.json index aa2cf8bf8b..6380eabd9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "1.7.6", + "version": "2.2.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -31,7 +31,7 @@ "typings": "./lib/index.d.ts", "matrix_src_main": "./src/index.js", "scripts": { - "prepublish": "yarn build", + "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", @@ -54,6 +54,7 @@ "test:e2e": "./test/end-to-end-tests/run.sh --riot-url http://localhost:8080" }, "dependencies": { + "@babel/runtime": "^7.8.3", "blueimp-canvas-to-blob": "^3.5.0", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", @@ -79,10 +80,12 @@ "is-ip": "^2.0.0", "linkifyjs": "^2.1.6", "lodash": "^4.17.14", - "matrix-js-sdk": "3.0.0", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "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", "react": "^16.9.0", @@ -108,13 +111,12 @@ "@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.7.6", + "@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", - "@babel/runtime": "^7.7.6", "@peculiar/webcrypto": "^1.0.22", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", 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 51d985efb7..e062e0bd73 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -338,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; @@ -378,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; @@ -394,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; diff --git a/res/css/_components.scss b/res/css/_components.scss index 60f749de9c..bc636eb3c6 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -36,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"; @@ -65,7 +66,9 @@ @import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.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"; @@ -125,7 +128,6 @@ @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; -@import "./views/messages/_MKeyVerificationRequest.scss"; @import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; @@ -140,7 +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"; @@ -151,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"; diff --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss index 4ec53a3c9a..517b8b1922 100644 --- a/res/css/structures/_GroupView.scss +++ b/res/css/structures/_GroupView.scss @@ -63,7 +63,7 @@ limitations under the License. } .mx_GroupHeader_editButton::before { - mask-image: url('$(res)/img/icons-settings-room.svg'); + mask-image: url('$(res)/img/feather-customised/settings.svg'); } .mx_GroupHeader_shareButton::before { diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index 973f6fe9b3..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; } 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/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index 5634a97c53..d1687743d6 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -51,8 +51,8 @@ limitations under the License. &.mx_Toast_hasIcon { &::after { content: ""; - width: 21px; - height: 20px; + width: 22px; + height: 22px; grid-column: 1; grid-row: 1; mask-size: 100%; @@ -98,5 +98,9 @@ limitations under the License. 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 index c258ce4ec7..2bf51d9574 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -22,7 +22,7 @@ limitations under the License. .mx_CompleteSecurity_headerIcon { width: 24px; height: 24px; - margin: 0 4px; + margin-right: 4px; position: relative; } 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/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss index 9d58c999c3..500c46b5fd 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.scss +++ b/res/css/views/dialogs/_DevtoolsDialog.scss @@ -189,3 +189,37 @@ limitations under the License. } } } + +.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 index 221ad7d48c..5e0893b8fd 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -62,7 +62,7 @@ limitations under the License. } .mx_InviteDialog_goButton { - width: 48px; + min-width: 48px; margin-left: 10px; height: 25px; line-height: 25px; @@ -131,7 +131,7 @@ limitations under the License. height: 24px; grid-column: 1; grid-row: 1; - mask-image: url('$(res)/img/feather-customised/check.svg'); + mask-image: url("$(res)/img/feather-customised/check.svg"); mask-size: 100%; mask-repeat: no-repeat; position: absolute; @@ -210,4 +210,19 @@ limitations under the License. .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 aa66e97f9e..2a4e62f9aa 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.scss +++ b/res/css/views/dialogs/_RoomSettingsDialog.scss @@ -56,16 +56,3 @@ limitations under the License. mask-position: center; } -.mx_RoomSettingsDialog_BridgeList { - padding: 0; -} - -.mx_RoomSettingsDialog_BridgeList li { - list-style-type: none; - padding: 5px; - margin-bottom: 5px; - border-width: 1px 0px; - border-color: #dee1f3; - border-style: solid; -} - 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/keybackup/_CreateKeyBackupDialog.scss b/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss index 04ee575867..b9babd05f5 100644 --- a/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss +++ b/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss @@ -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/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss index 5899abdf73..a9ebd54b31 100644 --- a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -1,6 +1,6 @@ /* Copyright 2018 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. @@ -15,6 +15,34 @@ 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; @@ -22,7 +50,7 @@ limitations under the License. .mx_CreateSecretStorageDialog_primaryContainer { /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ - padding: 20px; + padding-top: 20px; } .mx_CreateSecretStorageDialog_primaryContainer::after { @@ -36,9 +64,13 @@ limitations under the License. align-items: flex-start; } +.mx_Field.mx_CreateSecretStorageDialog_passPhraseField { + margin-top: 0px; +} + .mx_CreateSecretStorageDialog_passPhraseHelp { flex: 1; - height: 85px; + height: 64px; margin-left: 20px; font-size: 80%; } @@ -47,16 +79,8 @@ limitations under the License. width: 100%; } -.mx_CreateSecretStorageDialog_passPhraseInput { - flex: none; - width: 250px; - border: 1px solid $accent-color; - border-radius: 5px; - padding: 10px; - margin-bottom: 1em; -} - .mx_CreateSecretStorageDialog_passPhraseMatch { + width: 200px; margin-left: 20px; } @@ -82,6 +106,10 @@ limitations under the License. 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/_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/messages/_MKeyVerificationRequest.scss b/res/css/views/messages/_common_CryptoEvent.scss similarity index 61% rename from res/css/views/messages/_MKeyVerificationRequest.scss rename to res/css/views/messages/_common_CryptoEvent.scss index ee20751083..98e1e97e39 100644 --- a/res/css/views/messages/_MKeyVerificationRequest.scss +++ b/res/css/views/messages/_common_CryptoEvent.scss @@ -1,5 +1,5 @@ /* -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. @@ -14,60 +14,62 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_KeyVerification { +.mx_cryptoEvent { display: grid; grid-template-columns: 24px minmax(0, 1fr) min-content; - &.mx_KeyVerification_icon::after { + &.mx_cryptoEvent_icon::after { grid-column: 1; grid-row: 1 / 3; - width: 12px; + width: 16px; height: 16px; content: ""; - mask-image: url("$(res)/img/e2e/normal.svg"); - mask-repeat: no-repeat; - mask-size: 100%; + background-image: url("$(res)/img/e2e/normal.svg"); + background-repeat: no-repeat; + background-size: 100%; margin-top: 4px; - background-color: $primary-fg-color; } - &.mx_KeyVerification_icon_verified::after { - mask-image: url("$(res)/img/e2e/verified.svg"); - background-color: $accent-color; + &.mx_cryptoEvent_icon_verified::after { + background-image: url("$(res)/img/e2e/verified.svg"); } - .mx_KeyVerification_title, .mx_KeyVerification_subtitle, .mx_KeyVerification_state { + &.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_KeyVerification_title { + .mx_cryptoEvent_title { font-weight: 600; font-size: 15px; grid-column: 2; grid-row: 1; } - .mx_KeyVerification_subtitle { + .mx_cryptoEvent_subtitle { grid-column: 2; grid-row: 2; } - .mx_KeyVerification_state, .mx_KeyVerification_subtitle { + .mx_cryptoEvent_state, .mx_cryptoEvent_subtitle { font-size: 12px; } - .mx_KeyVerification_state, .mx_KeyVerification_buttons { + .mx_cryptoEvent_state, .mx_cryptoEvent_buttons { grid-column: 3; grid-row: 1 / 3; } - .mx_KeyVerification_buttons { + .mx_cryptoEvent_buttons { align-items: center; display: flex; } - .mx_KeyVerification_state { + .mx_cryptoEvent_state { width: 130px; padding: 10px 20px; margin: auto 0; 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 index e87fe06a94..0e4b1bda9e 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -23,15 +23,23 @@ limitations under the License. font-size: 12px; .mx_UserInfo_cancel { - height: 16px; - width: 16px; - padding: 10px 0 10px 10px; cursor: pointer; - mask-image: url('$(res)/img/minimise.svg'); - mask-repeat: no-repeat; - mask-position: 16px center; - background-color: $rightpanel-button-color; 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 { @@ -41,12 +49,17 @@ limitations under the License. } .mx_UserInfo_container { - padding: 0 16px 16px 16px; + 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 { @@ -68,6 +81,7 @@ limitations under the License. .mx_UserInfo_avatar > div { max-width: 30vh; margin: 0 auto; + transition: 0.5s; } .mx_UserInfo_avatar > div > div { @@ -95,8 +109,9 @@ limitations under the License. justify-content: center; // override the calculated sizes so that the letter isn't HUGE - font-size: 26px !important; + font-size: 56px !important; width: 100% !important; + transition: font-size 0.5s; } .mx_UserInfo_avatar .mx_BaseAvatar.mx_BaseAvatar_image { @@ -122,12 +137,19 @@ limitations under the License. font-size: 18px; line-height: 25px; flex: 1; - overflow-x: auto; - max-height: 50px; - display: flex; 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; } @@ -196,10 +218,9 @@ limitations under the License. padding-bottom: 16px; } - .mx_UserInfo_scrollContainer .mx_UserInfo_container { + .mx_UserInfo_container:not(.mx_UserInfo_separator) { padding-top: 16px; padding-bottom: 0; - border-bottom: none; > :not(h3) { margin-left: 8px; @@ -242,17 +263,25 @@ limitations under the License. .mx_UserInfo_expand { display: flex; margin-top: 11px; - color: $accent-color; } } - .mx_UserInfo_verify { + .mx_UserInfo_wideButton { display: block; - background-color: $accent-color; - color: $accent-fg-color; - border-radius: 4px; - padding: 7px 1.5em; - text-align: center; 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/_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 0b5c4a48a6..09827fe3bb 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -361,6 +361,11 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { opacity: 1; } +.mx_EventTile_e2eIcon_unknown { + background-image: url('$(res)/img/e2e/warning.svg'); + opacity: 1; +} + .mx_EventTile_e2eIcon_unencrypted { background-image: url('$(res)/img/e2e/warning.svg'); opacity: 1; @@ -409,7 +414,8 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { } .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; } @@ -421,8 +427,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; } @@ -433,14 +444,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; } 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/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 5efca51844..a05b4c0c0e 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -76,6 +76,8 @@ limitations under the License. left: 60px; margin-right: 0; // Counteract the E2EIcon class margin-left: 3px; // Counteract the E2EIcon class + width: 15px; + height: 15px; } .mx_MessageComposer_noperm_error { diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 45b9733faa..47b8131ef0 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -19,7 +19,12 @@ limitations under the License. border-bottom: 1px solid $primary-hairline-color; .mx_E2EIcon { - margin: 0 5px; + margin: 0; + position: absolute; + bottom: -2px; + right: -6px; + height: 15px; + width: 15px; } } @@ -171,6 +176,7 @@ limitations under the License. width: 28px; height: 28px; margin: 0 7px; + position: relative; } .mx_RoomHeader_avatar .mx_BaseAvatar_image { @@ -263,24 +269,3 @@ limitations under the License. .mx_RoomHeader_pinsIndicatorUnread { background-color: $pinned-unread-color; } - -.mx_RoomHeader_PrivateIcon.mx_RoomHeader_isPrivate { - width: 12px; - height: 12px; - position: relative; - display: block !important; - - &::before { - 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; - } -} 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/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index cb1137bb2f..31d887cbbb 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -98,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; @@ -142,10 +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_RoomTile.focus-visible:focus-within, +.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; @@ -200,31 +214,3 @@ limitations under the License. .mx_GroupInviteTile .mx_RoomTile_name { flex: 1; } - -.mx_RoomTile.mx_RoomTile.mx_RoomTile_isPrivate .mx_RoomTile_name { - // Scoot the padding in a bit from 6px to make it look better - padding-left: 3px; -} - -.mx_RoomTile.mx_RoomTile_isPrivate .mx_RoomTile_PrivateIcon { - width: 12px; - height: 12px; - position: relative; - display: block !important; - // Align the padlock with unencrypted room names - margin-left: 6px; - - &::before { - 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; - } -} diff --git a/res/css/views/rooms/_TopUnreadMessagesBar.scss b/res/css/views/rooms/_TopUnreadMessagesBar.scss index 77f19dac1c..5f6f869877 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 { @@ -59,3 +56,17 @@ limitations under the License. mask-position: 9px 13px; background: $roomtile-name-color; } + +.mx_TopUnreadMessagesBar_markAsRead { + display: block; + width: 18px; + height: 18px; + background-image: url('$(res)/img/cancel.svg'); + background-position: center; + background-size: 10px; + background-repeat: no-repeat; + background-color: $primary-bg-color; + border: 1.3px solid $roomtile-name-color; + border-radius: 99px; + margin: 5px auto; +} 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/_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/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss index 794c8106be..9727946893 100644 --- a/res/css/views/settings/tabs/_SettingsTab.scss +++ b/res/css/views/settings/tabs/_SettingsTab.scss @@ -45,6 +45,10 @@ limitations under the License. margin: 10px 100px 10px 0; // Align with the rest of the view } +.mx_SettingsTab_section { + margin-bottom: 24px; +} + .mx_SettingsTab_section .mx_SettingsFlag { margin-right: 100px; margin-bottom: 10px; 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/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/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/light/css/_light.scss b/res/themes/light/css/_light.scss index 288fb3cadc..626ccb2e13 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -5,9 +5,12 @@ 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 @@ -224,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; diff --git a/scripts/ci/build.sh b/scripts/ci/build.sh deleted file mode 100755 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 index a592888292..9bdb512940 100755 --- a/scripts/ci/end-to-end-tests.sh +++ b/scripts/ci/end-to-end-tests.sh @@ -21,15 +21,16 @@ 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 pushd test/end-to-end-tests -ln -s $REACT_SDK_DIR/$RIOT_WEB_DIR riot/riot-web +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" diff --git a/scripts/ci/install-deps.sh b/scripts/ci/install-deps.sh index a2e2e59a45..14b5fc5393 100755 --- a/scripts/ci/install-deps.sh +++ b/scripts/ci/install-deps.sh @@ -6,9 +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 index 215af13030..337c0fe6c3 100755 --- 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/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 3919295078..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'); diff --git a/src/Analytics.js b/src/Analytics.js index d0c7a52814..c96cfdefee 100644 --- a/src/Analytics.js +++ b/src/Analytics.js @@ -1,18 +1,21 @@ /* - 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'; @@ -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,7 +375,7 @@ class Analytics { , }); - } + }; } if (!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 5a330c31e9..217b196348 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -102,6 +102,8 @@ export function getInitialLetter(name) { } export function avatarUrlForRoom(room, width, height, resizeMethod) { + if (!room) return null; // null-guard + const explicitRoomAvatar = room.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), width, diff --git a/src/BasePlatform.js b/src/BasePlatform.js index 14e34a1f40..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,6 +19,7 @@ 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'; @@ -162,4 +164,28 @@ export default class BasePlatform { 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 33e15d3cc9..1551b57313 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -139,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.", ), diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index 085764214f..f19be03574 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -20,6 +20,7 @@ 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 @@ -27,9 +28,43 @@ import { _t } from './languageHandler'; // single secret storage operation, as it will clear the cached keys once the // operation ends. let secretStorageKeys = {}; -let cachingAllowed = false; +let secretStorageBeingAccessed = false; -async function getSecretStorageKey({ keys: keyInfos }) { +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"); @@ -37,7 +72,7 @@ async function getSecretStorageKey({ keys: keyInfos }) { const [name, info] = keyInfoEntries[0]; // Check the in-memory cache - if (cachingAllowed && secretStorageKeys[name]) { + if (isCachingAllowed() && secretStorageKeys[name]) { return [name, secretStorageKeys[name]]; } @@ -56,6 +91,7 @@ async function getSecretStorageKey({ keys: keyInfos }) { sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", AccessSecretStorageDialog, + /* props= */ { keyInfo: info, checkPrivateKey: async (input) => { @@ -63,15 +99,26 @@ async function getSecretStorageKey({ keys: keyInfos }) { 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 Error("Secret storage access canceled"); + throw new AccessCancelledError(); } const key = await inputToKey(input); // Save to cache to avoid future prompts in the current session - if (cachingAllowed) { + if (isCachingAllowed()) { secretStorageKeys[name] = key; } @@ -101,18 +148,21 @@ export const crossSigningCallbacks = { * * @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 () => { }) { +export async function accessSecretStorage(func = async () => { }, force = false) { const cli = MatrixClientPeg.get(); - cachingAllowed = true; - + secretStorageBeingAccessed = true; try { - if (!await cli.hasSecretStorageKey()) { + 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"), - null, null, /* priority = */ false, /* static = */ true, + { + force, + }, + null, /* priority = */ false, /* static = */ true, ); const [confirmed] = await finished; if (!confirmed) { @@ -125,7 +175,7 @@ export async function accessSecretStorage(func = async () => { }) { const { finished } = Modal.createTrackedDialog( 'Cross-signing keys dialog', '', InteractiveAuthDialog, { - title: _t("Send cross-signing keys to homeserver"), + title: _t("Setting up keys"), matrixClient: MatrixClientPeg.get(), makeRequest, }, @@ -143,7 +193,9 @@ export async function accessSecretStorage(func = async () => { }) { return await func(); } finally { // Clear secret storage key cache now that work is complete - cachingAllowed = false; - secretStorageKeys = {}; + secretStorageBeingAccessed = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + } } } diff --git a/src/DeviceListener.js b/src/DeviceListener.js index a4c5785db4..4e7bc8470d 100644 --- a/src/DeviceListener.js +++ b/src/DeviceListener.js @@ -20,10 +20,13 @@ import * as sdk from './index'; import { _t } from './languageHandler'; import ToastStore from './stores/ToastStore'; -function toastKey(device) { - return 'newsession_' + device.deviceId; +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(); @@ -31,44 +34,120 @@ export default class 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); - this.recheck(); + 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(); + this._recheck(); + } + + dismissEncryptionSetup() { + this._dismissedThisDeviceToast = true; + this._recheck(); } _onDevicesUpdated = (users) => { if (!users.includes(MatrixClientPeg.get().getUserId())) return; - this.recheck(); + this._recheck(); } - _onDeviceVerificationChanged = (users) => { - if (!users.includes(MatrixClientPeg.get().getUserId())) return; - this.recheck(); + _onDeviceVerificationChanged = (userId) => { + if (userId !== MatrixClientPeg.get().getUserId()) return; + this._recheck(); } - async 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 false; + 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) { @@ -76,16 +155,24 @@ export default class DeviceListener { const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId); if (deviceTrust.isCrossSigningVerified() || this._dismissed.has(device.deviceId)) { - ToastStore.sharedInstance().dismissToast(toastKey(device)); + ToastStore.sharedInstance().dismissToast(toastKey(device.deviceId)); } else { + this._activeNagToasts.add(device.deviceId); ToastStore.sharedInstance().addOrReplaceToast({ - key: toastKey(device), - title: _t("New Session"), + key: toastKey(device.deviceId), + title: _t("Unverified session"), icon: "verification_warning", - props: {deviceId: device.deviceId}, - component: sdk.getComponent("toasts.NewSessionToast"), + 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 872a837f3a..0000000000 --- a/src/Entities.js +++ /dev/null @@ -1,137 +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 * as 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); - } -} - -export function newEntity(jsx, matchFn) { - const entity = new Entity(); - entity.getJsx = function() { - return jsx; - }; - entity.matches = matchFn; - return entity; -} - -/** - * @param {RoomMember[]} members - * @return {Entity[]} - */ -export function fromRoomMembers(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[]} - */ -export function fromUsers(users, showInviteButton, inviteFn) { - return users.map(function(u) { - return new UserEntity(u, showInviteButton, inviteFn); - }); -} diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 236aa0157e..a58ea25c8a 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -23,7 +23,6 @@ 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'; @@ -160,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) { @@ -467,11 +466,12 @@ export function bodyToHtml(content, highlights, opts={}) { /** * 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); } /** @@ -489,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/Lifecycle.js b/src/Lifecycle.js index 1603c73d25..72cd84bfd9 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -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 { @@ -592,8 +592,11 @@ async function startMatrixClient(startSyncing=true) { Mjolnir.sharedInstance().start(); if (startSyncing) { - await MatrixClientPeg.start(); + // 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(); @@ -629,7 +632,7 @@ export async function onLoggedOut() { * @returns {Promise} promise which resolves once the stores have been cleared */ async function _clearStorage() { - Analytics.logout(); + Analytics.disable(); if (window.localStorage) { window.localStorage.clear(); 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 dbc570c872..98fcc85d60 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -32,6 +32,7 @@ import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientB 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, @@ -217,7 +218,11 @@ 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(), }; diff --git a/src/Modal.js b/src/Modal.js index 29d3af2e74..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 * as sdk from './index'; import dis from './dispatcher'; -import { _t } from './languageHandler'; -import {defer} from "./utils/promise"; +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 = defer(); - return [(...args) => { + 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 b030f1b6f9..36a6f13bb6 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -153,10 +153,12 @@ const Notifier = { }, 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); diff --git a/src/Registration.js b/src/Registration.js index ac8baa3cca..ca162bac03 100644 --- a/src/Registration.js +++ b/src/Registration.js @@ -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/RoomInvite.js b/src/RoomInvite.js index 2eccf69b0f..839d677069 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -20,13 +20,8 @@ import React from 'react'; import {MatrixClientPeg} from './MatrixClientPeg'; import MultiInviter from './utils/MultiInviter'; import Modal from './Modal'; -import { getAddressType } from './UserAddress'; -import createRoom from './createRoom'; import * as sdk from './'; -import dis from './dispatcher'; -import DMRoomMap from './utils/DMRoomMap'; import { _t } from './languageHandler'; -import SettingsStore from "./settings/SettingsStore"; import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; /** @@ -44,64 +39,21 @@ export function inviteMultipleToRoom(roomId, addrs) { } export function showStartChatInviteDialog() { - if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) { - // This new 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, - ); - return; - } - - 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) { - if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) { - // This new 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, - ); - return; - } - - 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, + ); } /** @@ -122,60 +74,6 @@ export function isValid3pidInvite(event) { return true; } -// TODO: Canonical 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")), - }); - }); - } -} - export function inviteUsersToRoom(roomId, userIds) { return inviteMultipleToRoom(roomId, userIds).then((result) => { const room = MatrixClientPeg.get().getRoom(roomId); @@ -190,24 +88,6 @@ export function inviteUsersToRoom(roomId, userIds) { }); } -function _onRoomInviteFinished(roomId, shouldInvite, addrs) { - if (!shouldInvite) return; - - const addrTexts = addrs.map((addr) => addr.address); - - // Invite new users to a room - inviteUsersToRoom(roomId, addrTexts); -} - -// 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'); @@ -243,15 +123,3 @@ function _showAnyInviteErrors(addrs, room, inviter) { 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/Rooms.js b/src/Rooms.js index f65e0ff218..218e970f35 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -23,7 +23,7 @@ import {MatrixClientPeg} from './MatrixClientPeg'; * 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/Skinner.js b/src/Skinner.js index 3baecc9fb3..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."+ @@ -41,13 +42,7 @@ class Skinner { }; // Check the skin first - let comp = doLookup(this.components); - - // If that failed, check against our own components - if (!comp) { - // Lazily load our own components because they might end up calling .getComponent() - comp = doLookup(require("./component-index").components); - } + const comp = doLookup(this.components); // Just return nothing instead of erroring - the consumer should be smart enough to // handle this at this point. @@ -75,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) { diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 20b8ba76da..d306978f78 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -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); } @@ -769,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+)$/); @@ -783,22 +785,22 @@ export const CommandMap = { return success((async () => { const device = await cli.getStoredDevice(userId, deviceId); if (!device) { - throw new Error(_t('Unknown (user, device) pair:') + ` (${userId}, ${deviceId})`); + throw new Error(_t('Unknown (user, session) pair:') + ` (${userId}, ${deviceId})`); } const deviceTrust = await cli.checkDeviceTrust(userId, deviceId); if (deviceTrust.isVerified()) { if (device.getFingerprint() === fingerprint) { - throw new Error(_t('Device already verified!')); + throw new Error(_t('Session already verified!')); } else { - throw new Error(_t('WARNING: Device already verified, but keys do NOT MATCH!')); + 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' + + _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!', { @@ -819,7 +821,7 @@ export const CommandMap = {

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

@@ -891,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 */ @@ -905,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; } @@ -932,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/TextForEvent.js b/src/TextForEvent.js index a0d088affb..d4003058c8 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -275,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 || []; @@ -287,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, @@ -418,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(); @@ -603,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, 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 d534fe5d1d..10a3848dda 100644 --- a/src/actions/RoomListActions.js +++ b/src/actions/RoomListActions.js @@ -15,7 +15,7 @@ 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'; @@ -73,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); @@ -91,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( @@ -112,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 f6e17b1c84..b602cf60fe 100644 --- a/src/async-components/views/dialogs/EncryptedEventDialog.js +++ b/src/async-components/views/dialogs/EncryptedEventDialog.js @@ -191,7 +191,7 @@ export default createReactClass({

{ _t('Event information') }

{ this._renderEventInfo() } -

{ _t('Sender device information') }

+

{ _t('Sender session information') }

{ this._renderDeviceInfo() }
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 8940239cfd..3a480a2579 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -24,6 +24,7 @@ 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; @@ -52,7 +53,6 @@ function selectText(target) { */ export default class CreateKeyBackupDialog extends React.PureComponent { static propTypes = { - secureSecretStorage: PropTypes.bool, onFinished: PropTypes.func.isRequired, } @@ -64,32 +64,28 @@ export default class CreateKeyBackupDialog extends React.PureComponent { this._setZxcvbnResultTimeout = null; this.state = { - secureSecretStorage: props.secureSecretStorage, + secureSecretStorage: null, phase: PHASE_PASSPHRASE, passPhrase: '', passPhraseConfirm: '', copied: false, downloaded: false, zxcvbnResult: null, - setPassPhrase: false, }; - - if (this.state.secureSecretStorage === undefined) { - this.state.secureSecretStorage = - SettingsStore.isFeatureEnabled("feature_cross_signing"); - } - - // If we're using secret storage, skip ahead to the backing up step, as - // `accessSecretStorage` will handle passphrases as needed. - if (this.state.secureSecretStorage) { - this.state.phase = PHASE_BACKINGUP; - } } - componentDidMount() { + async componentDidMount() { + const cli = MatrixClientPeg.get(); + const secureSecretStorage = ( + SettingsStore.isFeatureEnabled("feature_cross_signing") && + await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing") + ); + this.setState({ secureSecretStorage }); + // If we're using secret storage, skip ahead to the backing up step, as // `accessSecretStorage` will handle passphrases as needed. - if (this.state.secureSecretStorage) { + if (secureSecretStorage) { + this.setState({ phase: PHASE_BACKINGUP }); this._createBackup(); } } @@ -192,45 +188,38 @@ export default class CreateKeyBackupDialog extends React.PureComponent { }); } - _onPassPhraseNextClick = () => { - this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); - } + _onPassPhraseNextClick = async (e) => { + e.preventDefault(); - _onPassPhraseKeyPress = async (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 () => { this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(this.state.passPhrase); this.setState({ - setPassPhrase: true, copied: false, downloaded: false, phase: PHASE_SHOWKEY, }); - } - - _onPassPhraseConfirmKeyPress = (e) => { - if (e.key === 'Enter' && this.state.passPhrase === this.state.passPhraseConfirm) { - this._onPassPhraseConfirmNextClick(); - } - } + }; _onSetAgainClick = () => { this.setState({ @@ -301,7 +290,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
; } - return
+ return

{_t( "Warning: You should only set up key backup from a trusted computer.", {}, { b: sub => {sub} }, @@ -316,7 +305,6 @@ export default class CreateKeyBackupDialog extends React.PureComponent {

- {_t("Advanced")} -

+ + {_t("Set up with a recovery key")} + -
; + ; } _renderPhasePassPhraseConfirm() { @@ -373,7 +362,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
; } const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return
+ return

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

@@ -382,7 +371,6 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
- -
; + ; } _renderPhaseShowKey() { - 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."); - } - 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")}
@@ -430,7 +408,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
@@ -495,7 +473,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { 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.", )} {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 index 01b9c9c7c8..49b103ecf7 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -1,6 +1,6 @@ /* Copyright 2018, 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. @@ -16,6 +16,7 @@ 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'; @@ -24,15 +25,14 @@ import { _t } from '../../../../languageHandler'; import Modal from '../../../../Modal'; const PHASE_LOADING = 0; -const PHASE_RESTORE_KEY_BACKUP = 1; -const PHASE_MIGRATE = 2; -const PHASE_PASSPHRASE = 3; -const PHASE_PASSPHRASE_CONFIRM = 4; -const PHASE_SHOWKEY = 5; -const PHASE_KEEPITSAFE = 6; -const PHASE_STORING = 7; -const PHASE_DONE = 8; -const PHASE_OPTOUT_CONFIRM = 9; +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. @@ -52,6 +52,17 @@ function selectText(target) { * 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); @@ -67,15 +78,25 @@ export default class CreateSecretStorageDialog extends React.PureComponent { copied: false, downloaded: false, zxcvbnResult: null, - setPassPhrase: false, 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); } @@ -83,25 +104,67 @@ export default class CreateSecretStorageDialog extends React.PureComponent { async _fetchBackupInfo() { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); - const backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo); + 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 phase = backupInfo ? - (backupSigStatus.usable ? PHASE_MIGRATE : PHASE_RESTORE_KEY_BACKUP) : - PHASE_PASSPHRASE; + 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; } - _onMigrateNextClick = () => { - this._bootstrapSecretStorage(); + _onUseKeyBackupChange = (enabled) => { + this.setState({ + useKeyBackup: enabled, + }); + } + + _onMigrateFormSubmit = (e) => { + e.preventDefault(); + if (this.state.backupSigStatus.usable) { + this._bootstrapSecretStorage(); + } else { + this._restoreBackup(); + } } _onCopyClick = () => { @@ -127,37 +190,74 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }); } + _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 { - const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - await cli.bootstrapSecretStorage({ - authUploadDeviceSigningKeys: async (makeRequest) => { - const { finished } = Modal.createTrackedDialog( - 'Cross-signing keys dialog', '', InteractiveAuthDialog, - { - title: _t("Send cross-signing keys to homeserver"), - matrixClient: MatrixClientPeg.get(), - makeRequest, - }, - ); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } - }, - createSecretStorageKey: async () => this._keyInfo, - keyBackupInfo: this.state.backupInfo, - }); + 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) { - this.setState({ error: 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); } } @@ -170,16 +270,26 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.props.onFinished(true); } - _onRestoreKeyBackupClick = () => { + _restoreBackup = async () => { const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); - Modal.createTrackedDialog( - 'Restore Backup', '', RestoreKeyBackupDialog, null, null, - /* priority = */ false, /* static = */ true, + 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(); + } } - _onOptOutClick = () => { - this.setState({phase: PHASE_OPTOUT_CONFIRM}); + _onSkipSetupClick = () => { + this.setState({phase: PHASE_CONFIRM_SKIP}); } _onSetUpClick = () => { @@ -198,49 +308,42 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }); } - _onPassPhraseNextClick = () => { - this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); - } + _onPassPhraseNextClick = async (e) => { + e.preventDefault(); - _onPassPhraseKeyPress = async (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 () => { const [keyInfo, encodedRecoveryKey] = await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase); this._keyInfo = keyInfo; this._encodedRecoveryKey = encodedRecoveryKey; this.setState({ - setPassPhrase: true, copied: false, downloaded: false, phase: PHASE_SHOWKEY, }); } - _onPassPhraseConfirmKeyPress = (e) => { - if (e.key === 'Enter' && this.state.passPhrase === this.state.passPhraseConfirm) { - this._onPassPhraseConfirmNextClick(); - } - } - _onSetAgainClick = () => { this.setState({ passPhrase: '', @@ -285,21 +388,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE; } - _renderPhaseRestoreKeyBackup() { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return

-

{_t( - "Key Backup is enabled on your account but has not been set " + - "up from this session. To set up secret storage, " + - "restore your key backup.", - )}

- - -
; + _onAccountPasswordChange = (e) => { + this.setState({ + accountPassword: e.target.value, + }); } _renderPhaseMigrate() { @@ -309,22 +401,59 @@ export default class CreateSecretStorageDialog extends React.PureComponent { // it automatically. // https://github.com/vector-im/riot-web/issues/11696 const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return
+ 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( - "Secret Storage will be set up using your existing key backup details. " + - "Your secret storage passphrase and recovery key will be the same as " + - "they were for your key backup.", + "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; @@ -332,14 +461,19 @@ export default class CreateSecretStorageDialog extends React.PureComponent { if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) { helpText = _t("Great! This passphrase looks strong enough."); } else { - const suggestions = []; - for (let i = 0; i < this.state.zxcvbnResult.feedback.suggestions.length; ++i) { - suggestions.push(
{this.state.zxcvbnResult.feedback.suggestions[i]}
); - } - const suggestionBlock =
{suggestions.length > 0 ? suggestions : _t("Keep going...")}
; + // 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 =
- {this.state.zxcvbnResult.feedback.warning} {suggestionBlock}
; } @@ -348,53 +482,62 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
; } - return
+ return

{_t( - "Warning: You should only set up secret storage from a trusted computer.", {}, - { b: sub => {sub} }, + "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( - "We'll use secret storage to optionally store an encrypted copy of " + - "your cross-signing identity for verifying other devices and message " + - "keys on our server. Protect your access to encrypted messages with a " + - "passphrase to keep it secure.", + "Secure your encryption keys with a passphrase. For maximum security " + + "this should be different to your account password:", )}

-

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

-
-
- -
- {strengthMeter} - {helpText} -
+
+ +
+ {strengthMeter} + {helpText}
- + + + > + +
{_t("Advanced")} -

+
-
; + ; } _renderPhasePassPhraseConfirm() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const Field = sdk.getComponent('views.elements.Field'); let matchText; if (this.state.passPhraseConfirm === this.state.passPhrase) { @@ -412,7 +555,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { let passPhraseMatch = null; if (matchText) { - passPhraseMatch =
+ passPhraseMatch =
{matchText}
@@ -422,71 +565,64 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
; } const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return
+ return

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

-
-
-
- -
+
+ +
{passPhraseMatch}
- -
; + > + + + ; } _renderPhaseShowKey() { - let bodyText; - if (this.state.setPassPhrase) { - bodyText = _t( - "As a safety net, you can use it to restore your access to encrypted " + - "messages if you forget your passphrase.", - ); - } else { - bodyText = _t( - "As a safety net, you can use it to restore your access to encrypted " + - "messages.", - ); - } - + 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 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")}
{this._encodedRecoveryKey}
- - +
@@ -514,7 +650,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
  • {_t("Save it on a USB key or backup drive", {}, {b: s => {s}})}
  • {_t("Copy it to your personal cloud storage", {}, {b: s => {s}})}
  • - @@ -533,7 +669,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

    {_t( - "Your access to encrypted messages is now protected.", + "You can now verify your other devices, " + + "and other users to keep your chats safe.", )}

    ; } - _renderPhaseOptOutConfirm() { + _renderPhaseSkipConfirm() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
    {_t( - "Without setting up secret storage, you won't be able to restore your " + - "access to encrypted messages or your cross-signing identity for " + - "verifying other devices if you log out or use another device.", + "Without completing security on this session, it won’t have " + + "access to encrypted messages.", )} - - +
    ; } _titleForPhase(phase) { switch (phase) { - case PHASE_RESTORE_KEY_BACKUP: - return _t('Restore your Key Backup'); case PHASE_MIGRATE: - return _t('Migrate from Key Backup'); + return _t('Upgrade your encryption'); case PHASE_PASSPHRASE: - return _t('Secure your encrypted messages with a passphrase'); + return _t('Set up encryption'); case PHASE_PASSPHRASE_CONFIRM: - return _t('Confirm your passphrase'); - case PHASE_OPTOUT_CONFIRM: - return _t('Warning!'); + return _t('Confirm passphrase'); + case PHASE_CONFIRM_SKIP: + return _t('Are you sure?'); 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_STORING: - return _t('Storing secrets...'); + return _t('Setting up keys'); case PHASE_DONE: - return _t('Success!'); + return _t("You're done!"); default: - return null; + return ''; } } @@ -605,9 +738,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { case PHASE_LOADING: content = this._renderBusyPhase(); break; - case PHASE_RESTORE_KEY_BACKUP: - content = this._renderPhaseRestoreKeyBackup(); - break; case PHASE_MIGRATE: content = this._renderPhaseMigrate(); break; @@ -629,17 +759,24 @@ export default class CreateSecretStorageDialog extends React.PureComponent { case PHASE_DONE: content = this._renderPhaseDone(); break; - case PHASE_OPTOUT_CONFIRM: - content = this._renderPhaseOptOutConfirm(); + 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/RoomProvider.js b/src/autocomplete/RoomProvider.js index b28c79ac54..a0f670e769 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -23,7 +23,6 @@ import AutocompleteProvider from './AutocompleteProvider'; import {MatrixClientPeg} from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; -import {getDisplayAliasForRoom} from '../Rooms'; import * as sdk from '../index'; import _sortBy from 'lodash/sortBy'; import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; @@ -40,11 +39,19 @@ 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'], }); } @@ -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/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.js index b4b1b80163..898991f4f2 100644 --- a/src/components/structures/ContextMenu.js +++ b/src/components/structures/ContextMenu.js @@ -255,7 +255,7 @@ export class ContextMenu extends React.Component { if (chevronFace === 'top' || chevronFace === 'bottom') { chevronOffset.left = props.chevronOffset; - } else { + } else if (position.top !== undefined) { const target = position.top; // By default, no adjustment is made diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 61b3d2d4b9..f8c03be864 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -19,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 {Filter} from 'matrix-js-sdk'; import * as sdk from '../../index'; import {MatrixClientPeg} from '../../MatrixClientPeg'; +import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from '../../languageHandler'; /* @@ -29,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, @@ -40,42 +44,147 @@ const FilePanel = createReactClass({ }; }, - componentDidMount: 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; + + 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!"); } @@ -111,6 +220,7 @@ const FilePanel = createReactClass({ manageReadMarkers={false} timelineSet={this.state.timelineSet} showUrlPreview = {false} + onPaginationRequest={this.onPaginationRequest} tileShape="file_grid" resizeNotifier={this.props.resizeNotifier} empty={_t('There are no visible files in this room')} diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 5ae0699a2f..af90fbbe83 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -481,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) { @@ -726,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; } @@ -821,10 +821,10 @@ export default createReactClass({ {_t( "Want more than a community? Get your own server", {}, { - a: sub => {sub}, + a: sub => {sub}, }, )} - +
    ; diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 53bb990e26..f4adb5751f 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -20,7 +20,7 @@ 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 * as sdk from '../../index'; @@ -161,6 +161,7 @@ export default createReactClass({ _authStateUpdated: function(stageType, stageState) { const oldStage = this.state.authStage; this.setState({ + busy: false, authStage: stageType, stageState: stageState, errorText: stageState.error, @@ -184,11 +185,13 @@ 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() { diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index 8a7d10e5b5..f5e0bca67e 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -129,9 +129,6 @@ const LeftPanel = createReactClass({ if (!this.focusedElement) return; switch (ev.key) { - case Key.TAB: - this._onMoveFocus(ev, ev.shiftKey); - break; case Key.ARROW_UP: this._onMoveFocus(ev, true, true); break; diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 9597f99cd2..20217548b7 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -585,7 +585,8 @@ const LoggedInView = createReactClass({ limitType={usageLimitEvent.getContent().limit_type} />; } else if (this.props.showCookieBar && - this.props.config.piwik + this.props.config.piwik && + navigator.doNotTrack !== "1" ) { const policyUrl = this.props.config.piwik.policyUrl || null; topBar = ; diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 3ac8a93e3d..bc11e66d2c 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -53,6 +53,7 @@ import createRoom from "../../createRoom"; import KeyRequestHandler from '../../KeyRequestHandler'; import { _t, getCurrentLanguage } from '../../languageHandler'; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; +import ThemeController from "../../settings/controllers/ThemeController"; import { startAnyRegistrationFlow } from "../../Registration.js"; import { messageForSyncError } from '../../utils/ErrorUtils'; import ResizeNotifier from "../../utils/ResizeNotifier"; @@ -64,6 +65,7 @@ import { ThemeWatcher } from "../../theme"; import { storeRoomAliasInCache } from '../../RoomAliasCache'; import { defer } from "../../utils/promise"; import ToastStore from "../../stores/ToastStore"; +import * as StorageManager from "../../utils/StorageManager"; /** constants for MatrixChat.state.view */ export const VIEWS = { @@ -89,12 +91,15 @@ export const VIEWS = { // showing flow to trust this new device with cross-signing COMPLETE_SECURITY: 6, + // flow to setup SSSS / cross-signing on this account + E2E_SETUP: 7, + // we are logged in with an active matrix client. - LOGGED_IN: 7, + LOGGED_IN: 8, // We are logged out (invalid token) but have our local state again. The user // should log back in to rehydrate the client. - SOFT_LOGOUT: 8, + SOFT_LOGOUT: 9, }; // Actions that are redirected through the onboarding process prior to being @@ -253,6 +258,9 @@ export default createReactClass({ // logout page. Lifecycle.loadSession({}); } + + this._accountPassword = null; + this._accountPasswordTimer = null; }, componentDidMount: function() { @@ -349,6 +357,8 @@ export default createReactClass({ window.removeEventListener("focus", this.onFocus); window.removeEventListener('resize', this.handleResize); this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize); + + if (this._accountPasswordTimer !== null) clearTimeout(this._accountPasswordTimer); }, componentWillUpdate: function(props, state) { @@ -498,6 +508,8 @@ export default createReactClass({ view: VIEWS.LOGIN, }); this.notifyNewScreen('login'); + ThemeController.isLogin = true; + this._themeWatcher.recheck(); break; case 'start_post_registration': this.setState({ @@ -547,13 +559,19 @@ export default createReactClass({ case 'view_user_info': this._viewUser(payload.userId, payload.subAction); break; - case 'view_room': + case 'view_room': { // Takes either a room ID or room alias: if switching to a room the client is already // known to be in (eg. user clicks on a room in the recents panel), supply the ID // If the user is clicking on a room in the context of the alias being presented // to them, supply the room alias. If both are supplied, the room ID will be ignored. - this._viewRoom(payload); + const promise = this._viewRoom(payload); + if (payload.deferred_action) { + promise.then(() => { + dis.dispatch(payload.deferred_action); + }); + } break; + } case 'view_prev_room': this._viewNextRoom(-1); break; @@ -657,7 +675,9 @@ export default createReactClass({ if ( !Lifecycle.isSoftLogout() && this.state.view !== VIEWS.LOGIN && - this.state.view !== VIEWS.COMPLETE_SECURITY + this.state.view !== VIEWS.REGISTER && + this.state.view !== VIEWS.COMPLETE_SECURITY && + this.state.view !== VIEWS.E2E_SETUP ) { this._onLoggedIn(); } @@ -750,6 +770,8 @@ export default createReactClass({ } this.setStateForNewView(newState); + ThemeController.isLogin = true; + this._themeWatcher.recheck(); this.notifyNewScreen('register'); }, @@ -846,7 +868,7 @@ export default createReactClass({ waitFor = this.firstSyncPromise.promise; } - waitFor.then(() => { + return waitFor.then(() => { let presentedId = roomInfo.room_alias || roomInfo.room_id; const room = MatrixClientPeg.get().getRoom(roomInfo.room_id); if (room) { @@ -869,7 +891,7 @@ export default createReactClass({ presentedId += "/" + roomInfo.event_id; } newState.ready = true; - this.setState(newState, ()=>{ + this.setState(newState, () => { this.notifyNewScreen('room/' + presentedId); }); }); @@ -900,6 +922,8 @@ export default createReactClass({ view: VIEWS.WELCOME, }); this.notifyNewScreen('welcome'); + ThemeController.isLogin = true; + this._themeWatcher.recheck(); }, _viewHome: function() { @@ -909,6 +933,8 @@ export default createReactClass({ }); this._setPage(PageTypes.HomePage); this.notifyNewScreen('home'); + ThemeController.isLogin = false; + this._themeWatcher.recheck(); }, _viewUser: function(userId, subAction) { @@ -961,9 +987,9 @@ export default createReactClass({ const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog); - const [shouldCreate, createOpts] = await modal.finished; + const [shouldCreate, opts] = await modal.finished; if (shouldCreate) { - createRoom({createOpts}); + createRoom(opts); } }, @@ -988,6 +1014,10 @@ export default createReactClass({ // needs to be reset so that they can revisit /user/.. // (and trigger // `_chatCreateOrReuse` again) go_welcome_on_cancel: true, + screen_after: { + screen: `user/${this.props.config.welcomeUserId}`, + params: { action: 'chat' }, + }, }); return; } @@ -1155,8 +1185,17 @@ export default createReactClass({ * Called when a new logged in session has started */ _onLoggedIn: async function() { + ThemeController.isLogin = false; this.setStateForNewView({ view: VIEWS.LOGGED_IN }); - if (MatrixClientPeg.currentUserIsJustRegistered()) { + // If a specific screen is set to be shown after login, show that above + // all else, as it probably means the user clicked on something already. + if (this._screenAfterLogin && this._screenAfterLogin.screen) { + this.showScreen( + this._screenAfterLogin.screen, + this._screenAfterLogin.params, + ); + this._screenAfterLogin = null; + } else if (MatrixClientPeg.currentUserIsJustRegistered()) { MatrixClientPeg.setJustRegisteredUserId(null); if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) { @@ -1174,6 +1213,8 @@ export default createReactClass({ } else { this._showScreenAfterLogin(); } + + StorageManager.tryPersistStorage(); }, _showScreenAfterLogin: function() { @@ -1221,6 +1262,8 @@ export default createReactClass({ }); this.subTitleStatus = ''; this._setPageSubtitle(); + ThemeController.isLogin = true; + this._themeWatcher.recheck(); }, /** @@ -1350,7 +1393,8 @@ export default createReactClass({ cancelButton: _t('Dismiss'), onFinished: (confirmed) => { if (confirmed) { - window.open(consentUri, '_blank'); + const wnd = window.open(consentUri, '_blank'); + wnd.opener = null; } }, }, null, true); @@ -1453,7 +1497,6 @@ export default createReactClass({ if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { cli.on("crypto.verification.request", request => { - console.log(`MatrixChat got a .request ${request.channel.transactionId}`, request.event.getRoomId()); if (request.pending) { ToastStore.sharedInstance().addOrReplaceToast({ key: 'verifreq_' + request.channel.transactionId, @@ -1725,6 +1768,10 @@ export default createReactClass({ this.showScreen("forgot_password"); }, + onRegisterFlowComplete: function(credentials, password) { + return this.onUserCompletedLoginFlow(credentials, password); + }, + // returns a promise which resolves to the new MatrixClient onRegistered: function(credentials) { return Lifecycle.setLoggedIn(credentials); @@ -1813,7 +1860,15 @@ export default createReactClass({ this._loggedInView = ref; }, - async onUserCompletedLoginFlow(credentials) { + async onUserCompletedLoginFlow(credentials, password) { + this._accountPassword = password; + // self-destruct the password after 5mins + if (this._accountPasswordTimer !== null) clearTimeout(this._accountPasswordTimer); + this._accountPasswordTimer = setTimeout(() => { + this._accountPassword = null; + this._accountPasswordTimer = null; + }, 60 * 5 * 1000); + // Wait for the client to be logged in (but not started) // which is enough to ask the server about account data. const loggedIn = new Promise(resolve => { @@ -1827,7 +1882,7 @@ export default createReactClass({ }); // Create and start the client in the background - Lifecycle.setLoggedIn(credentials); + const setLoggedInPromise = Lifecycle.setLoggedIn(credentials); await loggedIn; const cli = MatrixClientPeg.get(); @@ -1843,17 +1898,30 @@ export default createReactClass({ try { masterKeyInStorage = !!await cli.getAccountDataFromServer("m.cross_signing.master"); } catch (e) { - if (e.errcode !== "M_NOT_FOUND") throw e; + if (e.errcode !== "M_NOT_FOUND") { + console.warn("Secret storage account data check failed", e); + } } if (masterKeyInStorage) { + // Auto-enable cross-signing for the new session when key found in + // secret storage. + SettingsStore.setFeatureEnabled("feature_cross_signing", true); this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY }); + } else if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + // This will only work if the feature is set to 'enable' in the config, + // since it's too early in the lifecycle for users to have turned the + // labs flag on. + this.setStateForNewView({ view: VIEWS.E2E_SETUP }); } else { this._onLoggedIn(); } + + return setLoggedInPromise; }, - onCompleteSecurityFinished() { + // complete security / e2e setup has finished + onCompleteSecurityE2eSetupFinished() { this._onLoggedIn(); }, @@ -1873,7 +1941,15 @@ export default createReactClass({ const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity'); view = ( + ); + } else if (this.state.view === VIEWS.E2E_SETUP) { + const E2eSetup = sdk.getComponent('structures.auth.E2eSetup'); + view = ( + ); } else if (this.state.view === VIEWS.POST_REGISTRATION) { @@ -1940,9 +2016,10 @@ export default createReactClass({ email={this.props.startingFragmentQueryParams.email} brand={this.props.config.brand} makeRegistrationUrl={this._makeRegistrationUrl} - onLoggedIn={this.onRegistered} + onLoggedIn={this.onRegisterFlowComplete} onLoginClick={this.onLoginClick} onServerConfigChange={this.onServerConfigChange} + defaultDeviceDisplayName={this.props.defaultDeviceDisplayName} {...this.getServerProperties()} /> ); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 4ad75eb700..a2ac93d282 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -28,6 +28,7 @@ import {MatrixClientPeg} from '../../MatrixClientPeg'; import SettingsStore from '../../settings/SettingsStore'; import {_t} from "../../languageHandler"; import {haveTileForEvent} from "../views/rooms/EventTile"; +import {textForEvent} from "../../TextForEvent"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = ['m.sticker', 'm.room.message']; @@ -115,6 +116,7 @@ export default class MessagePanel extends React.Component { // previous positions the read marker has been in, so we can // display 'ghost' read markers that are animating away ghostReadMarkers: [], + showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), }; // opaque readreceipt info for each userId; used by ReadReceiptMarker @@ -164,6 +166,9 @@ export default class MessagePanel extends React.Component { this._readMarkerNode = createRef(); this._whoIsTyping = createRef(); this._scrollPanel = createRef(); + + this._showTypingNotificationsWatcherRef = + SettingsStore.watchSetting("showTypingNotifications", null, this.onShowTypingNotificationsChange); } componentDidMount() { @@ -172,6 +177,7 @@ export default class MessagePanel extends React.Component { componentWillUnmount() { this._isMounted = false; + SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef); } componentDidUpdate(prevProps, prevState) { @@ -184,6 +190,12 @@ export default class MessagePanel extends React.Component { } } + onShowTypingNotificationsChange = () => { + this.setState({ + showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), + }); + }; + /* get the DOM node representing the given event */ getNodeForEventId(eventId) { if (!this.eventNodes) { @@ -402,10 +414,6 @@ export default class MessagePanel extends React.Component { }; _getEventTiles() { - const DateSeparator = sdk.getComponent('messages.DateSeparator'); - const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); - this.eventNodes = {}; let i; @@ -447,184 +455,48 @@ export default class MessagePanel extends React.Component { this._readReceiptsByEvent = this._getReadReceiptsByShownEvent(); } + let grouper = null; + for (i = 0; i < this.props.events.length; i++) { const mxEv = this.props.events[i]; const eventId = mxEv.getId(); const last = (mxEv === lastShownEvent); - // Wrap initial room creation events into an EventListSummary - // Grouping only events sent by the same user that sent the `m.room.create` and only until - // the first non-state event or membership event which is not regarding the sender of the `m.room.create` event - const shouldGroup = (ev) => { - if (ev.getType() === "m.room.member" - && (ev.getStateKey() !== mxEv.getSender() || ev.getContent()["membership"] !== "join")) { - return false; - } - if (ev.isState() && ev.getSender() === mxEv.getSender()) { - return true; - } - return false; - }; - if (mxEv.getType() === "m.room.create") { - let summaryReadMarker = null; - const ts1 = mxEv.getTs(); - - if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) { - const dateSeparator =
  • ; - ret.push(dateSeparator); + if (grouper) { + if (grouper.shouldGroup(mxEv)) { + grouper.add(mxEv); + continue; + } else { + // not part of group, so get the group tiles, close the + // group, and continue like a normal event + ret.push(...grouper.getTiles()); + prevEvent = grouper.getNewPrevEvent(); + grouper = null; } - - // If RM event is the first in the summary, append the RM after the summary - summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId()); - - // If this m.room.create event should be shown (room upgrade) then show it before the summary - if (this._shouldShowEvent(mxEv)) { - // pass in the mxEv as prevEvent as well so no extra DateSeparator is rendered - ret.push(...this._getTilesForEvent(mxEv, mxEv, false)); - } - - const summarisedEvents = []; // Don't add m.room.create here as we don't want it inside the summary - for (;i + 1 < this.props.events.length; i++) { - const collapsedMxEv = this.props.events[i + 1]; - - // Ignore redacted/hidden member events - if (!this._shouldShowEvent(collapsedMxEv)) { - // If this hidden event is the RM and in or at end of a summary put RM after the summary. - summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId()); - continue; - } - - if (!shouldGroup(collapsedMxEv) || this._wantsDateSeparator(mxEv, collapsedMxEv.getDate())) { - break; - } - - // If RM event is in the summary, mark it as such and the RM will be appended after the summary. - summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId()); - - summarisedEvents.push(collapsedMxEv); - } - - // At this point, i = the index of the last event in the summary sequence - const eventTiles = summarisedEvents.map((e) => { - // In order to prevent DateSeparators from appearing in the expanded form - // of EventListSummary, render each member event as if the previous - // one was itself. This way, the timestamp of the previous event === the - // timestamp of the current event, and no DateSeparator is inserted. - return this._getTilesForEvent(e, e, e === lastShownEvent); - }).reduce((a, b) => a.concat(b), []); - - // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one - const ev = this.props.events[i]; - ret.push( - { eventTiles } - ); - - if (summaryReadMarker) { - ret.push(summaryReadMarker); - } - - prevEvent = mxEv; - continue; } - const wantTile = this._shouldShowEvent(mxEv); - - // Wrap consecutive member events in a ListSummary, ignore if redacted - if (isMembershipChange(mxEv) && wantTile) { - let summaryReadMarker = null; - const ts1 = mxEv.getTs(); - // Ensure that the key of the MemberEventListSummary does not change with new - // member events. This will prevent it from being re-created unnecessarily, and - // instead will allow new props to be provided. In turn, the shouldComponentUpdate - // method on MELS can be used to prevent unnecessary renderings. - // - // Whilst back-paginating with a MELS at the top of the panel, prevEvent will be null, - // so use the key "membereventlistsummary-initial". Otherwise, use the ID of the first - // membership event, which will not change during forward pagination. - const key = "membereventlistsummary-" + (prevEvent ? mxEv.getId() : "initial"); - - if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) { - const dateSeparator =
  • ; - ret.push(dateSeparator); + for (const Grouper of groupers) { + if (Grouper.canStartGroup(this, mxEv)) { + grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent); } - - // If RM event is the first in the MELS, append the RM after MELS - summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId()); - - const summarisedEvents = [mxEv]; - for (;i + 1 < this.props.events.length; i++) { - const collapsedMxEv = this.props.events[i + 1]; - - // Ignore redacted/hidden member events - if (!this._shouldShowEvent(collapsedMxEv)) { - // If this hidden event is the RM and in or at end of a MELS put RM after MELS. - summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId()); - continue; - } - - if (!isMembershipChange(collapsedMxEv) || - this._wantsDateSeparator(mxEv, collapsedMxEv.getDate())) { - break; - } - - // If RM event is in MELS mark it as such and the RM will be appended after MELS. - summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId()); - - summarisedEvents.push(collapsedMxEv); - } - - let highlightInMels = false; - - // At this point, i = the index of the last event in the summary sequence - let eventTiles = summarisedEvents.map((e) => { - if (e.getId() === this.props.highlightedEventId) { - highlightInMels = true; - } - // In order to prevent DateSeparators from appearing in the expanded form - // of MemberEventListSummary, render each member event as if the previous - // one was itself. This way, the timestamp of the previous event === the - // timestamp of the current event, and no DateSeparator is inserted. - return this._getTilesForEvent(e, e, e === lastShownEvent); - }).reduce((a, b) => a.concat(b), []); - - if (eventTiles.length === 0) { - eventTiles = null; - } - - ret.push( - { eventTiles } - ); - - if (summaryReadMarker) { - ret.push(summaryReadMarker); - } - - prevEvent = mxEv; - continue; } + if (!grouper) { + const wantTile = this._shouldShowEvent(mxEv); + if (wantTile) { + // make sure we unpack the array returned by _getTilesForEvent, + // otherwise react will auto-generate keys and we will end up + // replacing all of the DOM elements every time we paginate. + ret.push(...this._getTilesForEvent(prevEvent, mxEv, last)); + prevEvent = mxEv; + } - if (wantTile) { - // make sure we unpack the array returned by _getTilesForEvent, - // otherwise react will auto-generate keys and we will end up - // replacing all of the DOM elements every time we paginate. - ret.push(...this._getTilesForEvent(prevEvent, mxEv, last)); - prevEvent = mxEv; + const readMarker = this._readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex); + if (readMarker) ret.push(readMarker); } + } - const readMarker = this._readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex); - if (readMarker) ret.push(readMarker); + if (grouper) { + ret.push(...grouper.getTiles()); } return ret; @@ -906,7 +778,7 @@ export default class MessagePanel extends React.Component { ); let whoIsTyping; - if (this.props.room && !this.props.tileShape) { + if (this.props.room && !this.props.tileShape && this.state.showTypingNotifications) { whoIsTyping = (, + ); + } + + // If this m.room.create event should be shown (room upgrade) then show it before the summary + if (panel._shouldShowEvent(createEvent)) { + // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered + ret.push(...panel._getTilesForEvent(createEvent, createEvent, false)); + } + + for (const ejected of this.ejectedEvents) { + ret.push(...panel._getTilesForEvent( + createEvent, ejected, createEvent === lastShownEvent, + )); + } + + const eventTiles = this.events.map((e) => { + // In order to prevent DateSeparators from appearing in the expanded form + // of EventListSummary, render each member event as if the previous + // one was itself. This way, the timestamp of the previous event === the + // timestamp of the current event, and no DateSeparator is inserted. + return panel._getTilesForEvent(e, e, e === lastShownEvent); + }).reduce((a, b) => a.concat(b), []); + // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one + const ev = this.events[this.events.length - 1]; + ret.push( + + { eventTiles } + , + ); + + if (this.readMarker) { + ret.push(this.readMarker); + } + + return ret; + } + + getNewPrevEvent() { + return this.createEvent; + } +} + +// Wrap consecutive member events in a ListSummary, ignore if redacted +class MemberGrouper { + static canStartGroup = function(panel, ev) { + return panel._shouldShowEvent(ev) && isMembershipChange(ev); + } + + constructor(panel, ev, prevEvent, lastShownEvent) { + this.panel = panel; + this.readMarker = panel._readMarkerForEvent(ev.getId()); + this.events = [ev]; + this.prevEvent = prevEvent; + this.lastShownEvent = lastShownEvent; + } + + shouldGroup(ev) { + if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) { + return false; + } + return isMembershipChange(ev); + } + + add(ev) { + if (ev.getType() === 'm.room.member') { + // We'll just double check that it's worth our time to do so, through an + // ugly hack. If textForEvent returns something, we should group it for + // rendering but if it doesn't then we'll exclude it. + const renderText = textForEvent(ev); + if (!renderText || renderText.trim().length === 0) return; // quietly ignore + } + this.readMarker = this.readMarker || this.panel._readMarkerForEvent(ev.getId()); + this.events.push(ev); + } + + getTiles() { + // If we don't have any events to group, don't even try to group them. The logic + // below assumes that we have a group of events to deal with, but we might not if + // the events we were supposed to group were redacted. + if (!this.events || !this.events.length) return []; + + const DateSeparator = sdk.getComponent('messages.DateSeparator'); + const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); + + const panel = this.panel; + const lastShownEvent = this.lastShownEvent; + const ret = []; + + if (panel._wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { + const ts = this.events[0].getTs(); + ret.push( +
  • , + ); + } + + // Ensure that the key of the MemberEventListSummary does not change with new + // member events. This will prevent it from being re-created unnecessarily, and + // instead will allow new props to be provided. In turn, the shouldComponentUpdate + // method on MELS can be used to prevent unnecessary renderings. + // + // Whilst back-paginating with a MELS at the top of the panel, prevEvent will be null, + // so use the key "membereventlistsummary-initial". Otherwise, use the ID of the first + // membership event, which will not change during forward pagination. + const key = "membereventlistsummary-" + ( + this.prevEvent ? this.events[0].getId() : "initial" + ); + + let highlightInMels; + let eventTiles = this.events.map((e) => { + if (e.getId() === panel.props.highlightedEventId) { + highlightInMels = true; + } + // In order to prevent DateSeparators from appearing in the expanded form + // of MemberEventListSummary, render each member event as if the previous + // one was itself. This way, the timestamp of the previous event === the + // timestamp of the current event, and no DateSeparator is inserted. + return panel._getTilesForEvent(e, e, e === lastShownEvent); + }).reduce((a, b) => a.concat(b), []); + + if (eventTiles.length === 0) { + eventTiles = null; + } + + ret.push( + + { eventTiles } + , + ); + + if (this.readMarker) { + ret.push(this.readMarker); + } + + return ret; + } + + getNewPrevEvent() { + return this.events[0]; + } +} + +// all the grouper classes that we use +const groupers = [CreationGrouper, MemberGrouper]; diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index dca89d0c35..8d25116827 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -48,6 +48,7 @@ export default class RightPanel extends React.Component { phase: this._getPhaseFromProps(), isUserPrivilegedInGroup: null, member: this._getUserForPanel(), + verificationRequest: RightPanelStore.getSharedInstance().roomPanelPhaseParams.verificationRequest, }; this.onAction = this.onAction.bind(this); this.onRoomStateMember = this.onRoomStateMember.bind(this); @@ -68,15 +69,35 @@ export default class RightPanel extends React.Component { return this.props.user || lastParams['member']; } + // gets the current phase from the props and also maybe the store _getPhaseFromProps() { const rps = RightPanelStore.getSharedInstance(); + const userForPanel = this._getUserForPanel(); if (this.props.groupId) { if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) { dis.dispatch({action: "set_right_panel_phase", phase: RIGHT_PANEL_PHASES.GroupMemberList}); return RIGHT_PANEL_PHASES.GroupMemberList; } return rps.groupPanelPhase; - } else if (this._getUserForPanel()) { + } else if (userForPanel) { + // XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state + // from its props and some from a store, except if the contents of the store changes + // while it's mounted in which case it replaces all of its state with that of the store, + // except it uses a dispatch instead of a normal store listener? + // Unfortunately rewriting this would almost certainly break showing the right panel + // in some of the many cases, and I don't have time to re-architect it and test all + // the flows now, so adding yet another special case so if the store thinks there is + // a verification going on for the member we're displaying, we show that, otherwise + // we race if a verification is started while the panel isn't displayed because we're + // not mounted in time to get the dispatch. + // Until then, let this code serve as a warning from history. + if ( + rps.roomPanelPhaseParams.member && + userForPanel.userId === rps.roomPanelPhaseParams.member.userId && + rps.roomPanelPhaseParams.verificationRequest + ) { + return rps.roomPanelPhase; + } return RIGHT_PANEL_PHASES.RoomMemberInfo; } else { if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.roomPanelPhase)) { @@ -161,6 +182,7 @@ export default class RightPanel extends React.Component { member: payload.member, event: payload.event, verificationRequest: payload.verificationRequest, + verificationRequestPromise: payload.verificationRequestPromise, }); } } @@ -169,7 +191,6 @@ export default class RightPanel extends React.Component { const MemberList = sdk.getComponent('rooms.MemberList'); const MemberInfo = sdk.getComponent('rooms.MemberInfo'); const UserInfo = sdk.getComponent('right_panel.UserInfo'); - const EncryptionPanel = sdk.getComponent('right_panel.EncryptionPanel'); const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo'); const NotificationPanel = sdk.getComponent('structures.NotificationPanel'); const FilePanel = sdk.getComponent('structures.FilePanel'); @@ -181,64 +202,83 @@ export default class RightPanel extends React.Component { let panel =
    ; - if (this.props.roomId && this.state.phase === RIGHT_PANEL_PHASES.RoomMemberList) { - panel = ; - } else if (this.props.groupId && this.state.phase === RIGHT_PANEL_PHASES.GroupMemberList) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupRoomList) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo) { - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { - const onClose = () => { - dis.dispatch({ - action: "view_user", - member: null, - }); - }; - panel = ; - } else { - panel = ; - } - } else if (this.state.phase === RIGHT_PANEL_PHASES.Room3pidMemberInfo) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupMemberInfo) { - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { - const onClose = () => { - dis.dispatch({ - action: "view_user", - member: null, - }); - }; - panel = ; - } else { - panel = ( - ; + } + break; + case RIGHT_PANEL_PHASES.GroupMemberList: + if (this.props.groupId) { + panel = ; + } + break; + case RIGHT_PANEL_PHASES.GroupRoomList: + panel = ; + break; + case RIGHT_PANEL_PHASES.RoomMemberInfo: + case RIGHT_PANEL_PHASES.EncryptionPanel: + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + const onClose = () => { + dis.dispatch({ + action: "view_user", + member: this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel ? this.state.member : null, + }); + }; + panel = ; + } else { + panel = ; + } + break; + case RIGHT_PANEL_PHASES.Room3pidMemberInfo: + panel = ; + break; + case RIGHT_PANEL_PHASES.GroupMemberInfo: + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + const onClose = () => { + dis.dispatch({ + action: "view_user", + member: null, + }); + }; + panel = - ); - } - } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupRoomInfo) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.NotificationPanel) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.FilePanel) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel) { - panel = ; + key={this.state.member.userId} + onClose={onClose} />; + } else { + panel = ( + + ); + } + break; + case RIGHT_PANEL_PHASES.GroupRoomInfo: + panel = ; + break; + case RIGHT_PANEL_PHASES.NotificationPanel: + panel = ; + break; + case RIGHT_PANEL_PHASES.FilePanel: + panel = ; + break; } const classes = classNames("mx_RightPanel", "mx_fadable", { @@ -248,7 +288,7 @@ export default class RightPanel extends React.Component { }); return ( -
    -
    {serviceName} {summary}{termDoc[termsLang].name} + {termDoc[termsLang].name} - { _t("You are currently blacklisting unverified devices; to send " + - "messages to these devices you must verify them.") } + { _t("You are currently blacklisting unverified sessions; to send " + + "messages to these sessions you must verify them.") } ); } else { @@ -141,7 +141,7 @@ export default createReactClass({

    { _t("We recommend you go through the verification process " + - "for each device to confirm they belong to their legitimate owner, " + + "for each session to confirm they belong to their legitimate owner, " + "but you can resend the message without verifying if you prefer.") }

    @@ -165,15 +165,15 @@ export default createReactClass({ return (

    - { _t('"%(RoomName)s" contains devices that you haven\'t seen before.', {RoomName: this.props.room.name}) } + { _t('"%(RoomName)s" contains sessions that you haven\'t seen before.', {RoomName: this.props.room.name}) }

    { warning } - { _t("Unknown devices") }: + { _t("Unknown sessions") }:
    diff --git a/src/components/views/dialogs/VerificationRequestDialog.js b/src/components/views/dialogs/VerificationRequestDialog.js new file mode 100644 index 0000000000..abcf925f96 --- /dev/null +++ b/src/components/views/dialogs/VerificationRequestDialog.js @@ -0,0 +1,55 @@ +/* +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 PropTypes from 'prop-types'; +import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import * as sdk from '../../../index'; +import { _t } from '../../../languageHandler'; + +export default class VerificationRequestDialog extends React.Component { + static propTypes = { + verificationRequest: PropTypes.object.isRequired, + onFinished: PropTypes.func.isRequired, + }; + + constructor(...args) { + super(...args); + this.onFinished = this.onFinished.bind(this); + } + + render() { + const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); + const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel"); + return + + ; + } + + onFinished() { + this.props.verificationRequest.cancel(); + this.props.onFinished(); + } +} diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js index 77fdee5e8a..438806bf82 100644 --- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js @@ -16,12 +16,12 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import { MatrixClient } from 'matrix-js-sdk'; import Modal from '../../../../Modal'; import { _t } from '../../../../languageHandler'; -import {Key} from "../../../../Keyboard"; import { accessSecretStorage } from '../../../../CrossSigningManager'; const RESTORE_TYPE_PASSPHRASE = 0; @@ -32,6 +32,16 @@ const RESTORE_TYPE_SECRET_STORAGE = 2; * Dialog for restoring e2e keys from a backup and the user's recovery key */ export default class RestoreKeyBackupDialog extends React.PureComponent { + static propTypes = { + // if false, will close the dialog as soon as the restore completes succesfully + // default: true + showSummary: PropTypes.bool, + }; + + static defaultProps = { + showSummary: true, + }; + constructor(props) { super(props); this.state = { @@ -96,6 +106,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword( this.state.passPhrase, undefined, undefined, this.state.backupInfo, ); + if (!this.props.showSummary) { + this.props.onFinished(true); + return; + } this.setState({ loading: false, recoverInfo, @@ -110,6 +124,8 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { } _onRecoveryKeyNext = async () => { + if (!this.state.recoveryKeyValid) return; + this.setState({ loading: true, restoreError: null, @@ -119,6 +135,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey( this.state.recoveryKey, undefined, undefined, this.state.backupInfo, ); + if (!this.props.showSummary) { + this.props.onFinished(true); + return; + } this.setState({ loading: false, recoverInfo, @@ -138,18 +158,6 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { }); } - _onPassPhraseKeyPress = (e) => { - if (e.key === Key.ENTER) { - this._onPassPhraseNext(); - } - } - - _onRecoveryKeyKeyPress = (e) => { - if (e.key === Key.ENTER && this.state.recoveryKeyValid) { - this._onRecoveryKeyNext(); - } - } - async _restoreWithSecretStorage() { this.setState({ loading: true, @@ -229,7 +237,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { } else if (this.state.restoreError) { if (this.state.restoreError.errcode === MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY) { if (this.state.restoreType === RESTORE_TYPE_RECOVERYKEY) { - title = _t("Recovery Key Mismatch"); + title = _t("Recovery key mismatch"); content =

    {_t( "Backup could not be decrypted with this key: " + @@ -237,7 +245,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { )}

    ; } else { - title = _t("Incorrect Recovery Passphrase"); + title = _t("Incorrect recovery passphrase"); content =

    {_t( "Backup could not be decrypted with this passphrase: " + @@ -253,7 +261,8 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { title = _t("Error"); content = _t("No backup found!"); } else if (this.state.recoverInfo) { - title = _t("Backup Restored"); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + title = _t("Backup restored"); let failedToDecrypt; if (this.state.recoverInfo.total > this.state.recoverInfo.imported) { failedToDecrypt =

    {_t( @@ -264,11 +273,16 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { content =

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

    {failedToDecrypt} +
    ; } else if (backupHasPassphrase && !this.state.forceRecoveryKey) { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - title = _t("Enter Recovery Passphrase"); + title = _t("Enter recovery passphrase"); content =

    {_t( "Warning: you should only set up key backup " + @@ -280,21 +294,22 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { "messaging by entering your recovery passphrase.", )}

    -
    + - -
    + {_t( "If you've forgotten your recovery passphrase you can "+ "use your recovery key or " + @@ -315,7 +330,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { })}
    ; } else { - title = _t("Enter Recovery Key"); + title = _t("Enter recovery key"); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -346,7 +361,6 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
    diff --git a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js index c976eb81d0..e3a7d7f532 100644 --- a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js @@ -1,6 +1,6 @@ /* Copyright 2018, 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. @@ -21,7 +21,6 @@ import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import { _t } from '../../../../languageHandler'; -import { Key } from "../../../../Keyboard"; /* * Access Secure Secret Storage by requesting the user's passphrase. @@ -68,7 +67,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent { }); } - _onPassPhraseNext = async () => { + _onPassPhraseNext = async (e) => { + e.preventDefault(); + + if (this.state.passPhrase.length <= 0) return; + this.setState({ keyMatches: null }); const input = { passphrase: this.state.passPhrase }; const keyMatches = await this.props.checkPrivateKey(input); @@ -79,7 +82,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent { } } - _onRecoveryKeyNext = async () => { + _onRecoveryKeyNext = async (e) => { + e.preventDefault(); + + if (!this.state.recoveryKeyValid) return; + this.setState({ keyMatches: null }); const input = { recoveryKey: this.state.recoveryKey }; const keyMatches = await this.props.checkPrivateKey(input); @@ -97,18 +104,6 @@ export default class AccessSecretStorageDialog extends React.PureComponent { }); } - _onPassPhraseKeyPress = (e) => { - if (e.key === Key.ENTER && this.state.passPhrase.length > 0) { - this._onPassPhraseNext(); - } - } - - _onRecoveryKeyKeyPress = (e) => { - if (e.key === Key.ENTER && this.state.recoveryKeyValid) { - this._onRecoveryKeyNext(); - } - } - render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); @@ -135,7 +130,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { )}
    ; } else { - keyStatus =
    ; + keyStatus =
    ; } content =
    @@ -146,26 +141,28 @@ export default class AccessSecretStorageDialog extends React.PureComponent { )}

    {_t( "Access your secure message history and your cross-signing " + - "identity for verifying other devices by entering your passphrase.", + "identity for verifying other sessions by entering your passphrase.", )}

    -
    - + {keyStatus} - -
    + {_t( "If you've forgotten your passphrase you can "+ "use your recovery key or " + @@ -192,11 +189,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { let keyStatus; if (this.state.recoveryKey.length === 0) { - keyStatus =
    ; - } else if (this.state.recoveryKeyValid) { - keyStatus =
    - {"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")} -
    ; + keyStatus =
    ; } else if (this.state.keyMatches === false) { keyStatus =
    {"\uD83D\uDC4E "}{_t( @@ -204,6 +197,10 @@ export default class AccessSecretStorageDialog extends React.PureComponent { "entered the correct recovery key.", )}
    ; + } else if (this.state.recoveryKeyValid) { + keyStatus =
    + {"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")} +
    ; } else { keyStatus =
    {"\uD83D\uDC4E "}{_t("Not a valid recovery key")} @@ -218,25 +215,25 @@ export default class AccessSecretStorageDialog extends React.PureComponent { )}

    {_t( "Access your secure message history and your cross-signing " + - "identity for verifying other devices by entering your recovery key.", + "identity for verifying other sessions by entering your recovery key.", )}

    -
    +
    {keyStatus} - -
    + {_t( "If you've forgotten your recovery key you can "+ "." diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index b12ace708d..20d98f5e23 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -552,7 +552,7 @@ export default class AppTile extends React.Component { // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getSafeUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), - { target: '_blank', href: this._getSafeUrl(), rel: 'noopener'}).click(); + { target: '_blank', href: this._getSafeUrl(), rel: 'noreferrer noopener'}).click(); } _onReloadWidgetClick() { diff --git a/src/components/views/elements/DialogButtons.js b/src/components/views/elements/DialogButtons.js index 4e47e73052..9223b5ade8 100644 --- a/src/components/views/elements/DialogButtons.js +++ b/src/components/views/elements/DialogButtons.js @@ -34,12 +34,19 @@ export default createReactClass({ // A node to insert into the cancel button instead of default "Cancel" cancelButton: PropTypes.node, + // If true, make the primary button a form submit button (input type="submit") + primaryIsSubmit: PropTypes.bool, + // onClick handler for the primary button. - onPrimaryButtonClick: PropTypes.func.isRequired, + onPrimaryButtonClick: PropTypes.func, // should there be a cancel button? default: true hasCancel: PropTypes.bool, + // The class of the cancel button, only used if a cancel button is + // enabled + cancelButtonClass: PropTypes.node, + // onClick handler for the cancel button. onCancel: PropTypes.func, @@ -69,16 +76,26 @@ export default createReactClass({ primaryButtonClassName += " " + this.props.primaryButtonClass; } let cancelButton; + if (this.props.cancelButton || this.props.hasCancel) { - cancelButton = ; } + return (
    { cancelButton } { this.props.children } -