Merge remote-tracking branch 'origin/develop' into hs/bridge-info-pretty

This commit is contained in:
Half-Shot 2020-01-28 11:22:02 +00:00
commit 7c0a461cbb
124 changed files with 4572 additions and 1159 deletions

View file

@ -1,8 +1,10 @@
steps: steps:
- label: ":eslint: JS Lint" - label: ":eslint: JS Lint"
command: command:
# We fetch the develop js-sdk to get our latest eslint rules
- "echo '--- Install js-sdk'" - "echo '--- Install js-sdk'"
- "./scripts/ci/install-deps.sh" - "./scripts/ci/install-deps.sh --ignore-scripts"
- "echo '+++ Lint'"
- "yarn lint:js" - "yarn lint:js"
plugins: plugins:
- docker#v3.0.1: - docker#v3.0.1:
@ -10,8 +12,9 @@ steps:
- label: ":eslint: TS Lint" - label: ":eslint: TS Lint"
command: command:
- "echo '--- Install js-sdk'" - "echo '--- Install'"
- "./scripts/ci/install-deps.sh" - "yarn install --ignore-scripts"
- "echo '+++ Lint'"
- "yarn lint:ts" - "yarn lint:ts"
plugins: plugins:
- docker#v3.0.1: - docker#v3.0.1:
@ -19,12 +22,21 @@ steps:
- label: ":eslint: Types Lint" - label: ":eslint: Types Lint"
command: command:
- "echo '--- Install js-sdk'" - "echo '--- Install'"
- "./scripts/ci/install-deps.sh" - "yarn install --ignore-scripts"
- "echo '+++ Lint'"
- "yarn lint:types" - "yarn lint:types"
plugins: plugins:
- docker#v3.0.1: - docker#v3.0.1:
image: "node:12" image: "node:12"
- label: ":stylelint: Style Lint"
command:
- "echo '--- Install'"
- "yarn install --ignore-scripts"
- "yarn lint:style"
plugins:
- docker#v3.0.1:
image: "node:12"
- label: ":jest: Tests" - label: ":jest: Tests"
agents: agents:
@ -33,13 +45,11 @@ steps:
queue: "medium" queue: "medium"
command: command:
- "echo '--- Install js-sdk'" - "echo '--- Install js-sdk'"
# TODO: Remove hacky chmod for BuildKite # We don't use the babel-ed output for anything so we can --ignore-scripts
- "chmod +x ./scripts/ci/*.sh" # to save transpiling the files. We run the transpile step explicitly in
- "chmod +x ./scripts/*" # the 'build' job.
- "echo '--- Installing Dependencies'" - "./scripts/ci/install-deps.sh --ignore-scripts"
- "./scripts/ci/install-deps.sh" - "yarn run reskindex"
- "echo '--- Running initial build steps'"
- "yarn build"
- "echo '+++ Running Tests'" - "echo '+++ Running Tests'"
- "yarn test" - "yarn test"
plugins: plugins:
@ -48,10 +58,8 @@ steps:
- label: "🛠 Build" - label: "🛠 Build"
command: command:
- "echo '--- Install js-sdk'" - "echo '+++ Install & Build'"
- "./scripts/ci/install-deps.sh" - "yarn install"
- "echo '+++ Building Project'"
- "yarn build"
plugins: plugins:
- docker#v3.0.1: - docker#v3.0.1:
image: "node:12" image: "node:12"
@ -62,20 +70,19 @@ steps:
# e2e tests otherwise take +-8min # e2e tests otherwise take +-8min
queue: "xlarge" queue: "xlarge"
command: command:
# TODO: Remove hacky chmod for BuildKite
- "echo '--- Setup'"
- "chmod +x ./scripts/ci/*.sh"
- "chmod +x ./scripts/*"
- "echo '--- Install js-sdk'" - "echo '--- Install js-sdk'"
- "./scripts/ci/install-deps.sh" - "./scripts/ci/install-deps.sh --ignore-scripts"
- "echo '--- Running initial build steps'"
- "yarn build"
- "echo '+++ Running Tests'" - "echo '+++ Running Tests'"
- "./scripts/ci/end-to-end-tests.sh" - "./scripts/ci/end-to-end-tests.sh"
plugins: plugins:
- docker#v3.0.1: - docker#v3.0.1:
image: "matrixdotorg/riotweb-ci-e2etests-env:latest" image: "matrixdotorg/riotweb-ci-e2etests-env:latest"
propagate-environment: true propagate-environment: true
workdir: "/workdir/matrix-react-sdk"
retry:
automatic:
- exit_status: 1 # retry end-to-end tests once as Puppeteer sometimes fails
limit: 1
- label: "🔧 Riot Tests" - label: "🔧 Riot Tests"
agents: agents:
@ -83,32 +90,18 @@ steps:
# webpack loves to gorge itself on resources. # webpack loves to gorge itself on resources.
queue: "medium" queue: "medium"
command: 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'" - "echo '+++ Running Tests'"
- "./scripts/ci/riot-unit-tests.sh" - "./scripts/ci/riot-unit-tests.sh"
env:
CHROME_BIN: "/usr/bin/google-chrome-stable"
plugins: plugins:
- docker#v3.0.1: - docker#v3.0.1:
image: "node:10" image: "node:10"
propagate-environment: true propagate-environment: true
workdir: "/workdir/matrix-react-sdk"
- label: "🌐 i18n" - label: "🌐 i18n"
command: command:
- "echo '--- Fetching Dependencies'" - "echo '--- Fetching Dependencies'"
- "yarn install" - "yarn install --ignore-scripts"
- "echo '+++ Testing i18n output'" - "echo '+++ Testing i18n output'"
- "yarn diff-i18n" - "yarn diff-i18n"
plugins: plugins:

View file

@ -1,3 +1,177 @@
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) 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) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.6-rc.2...v1.7.6)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "1.7.6", "version": "2.0.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -31,7 +31,7 @@
"typings": "./lib/index.d.ts", "typings": "./lib/index.d.ts",
"matrix_src_main": "./src/index.js", "matrix_src_main": "./src/index.js",
"scripts": { "scripts": {
"prepublish": "yarn build", "prepare": "yarn build",
"i18n": "matrix-gen-i18n", "i18n": "matrix-gen-i18n",
"prunei18n": "matrix-prune-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", "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" "test:e2e": "./test/end-to-end-tests/run.sh --riot-url http://localhost:8080"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.8.3",
"blueimp-canvas-to-blob": "^3.5.0", "blueimp-canvas-to-blob": "^3.5.0",
"browser-encrypt-attachment": "^0.3.0", "browser-encrypt-attachment": "^0.3.0",
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
@ -79,7 +80,7 @@
"is-ip": "^2.0.0", "is-ip": "^2.0.0",
"linkifyjs": "^2.1.6", "linkifyjs": "^2.1.6",
"lodash": "^4.17.14", "lodash": "^4.17.14",
"matrix-js-sdk": "3.0.0", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"pako": "^1.0.5", "pako": "^1.0.5",
"png-chunks-extract": "^1.0.0", "png-chunks-extract": "^1.0.0",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
@ -108,13 +109,12 @@
"@babel/plugin-proposal-numeric-separator": "^7.7.4", "@babel/plugin-proposal-numeric-separator": "^7.7.4",
"@babel/plugin-proposal-object-rest-spread": "^7.7.4", "@babel/plugin-proposal-object-rest-spread": "^7.7.4",
"@babel/plugin-transform-flow-comments": "^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-env": "^7.7.6",
"@babel/preset-flow": "^7.7.4", "@babel/preset-flow": "^7.7.4",
"@babel/preset-react": "^7.7.4", "@babel/preset-react": "^7.7.4",
"@babel/preset-typescript": "^7.7.4", "@babel/preset-typescript": "^7.7.4",
"@babel/register": "^7.7.4", "@babel/register": "^7.7.4",
"@babel/runtime": "^7.7.6",
"@peculiar/webcrypto": "^1.0.22", "@peculiar/webcrypto": "^1.0.22",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0", "babel-jest": "^24.9.0",

View file

@ -338,6 +338,14 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
margin-bottom: 10px; margin-bottom: 10px;
} }
.mx_Dialog_titleImage {
vertical-align: middle;
width: 25px;
height: 25px;
margin-left: -2px;
margin-right: 4px;
}
.mx_Dialog_title { .mx_Dialog_title {
font-size: 22px; font-size: 22px;
line-height: 36px; line-height: 36px;
@ -378,7 +386,13 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
text-align: right; 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; @mixin mx_DialogButton;
margin-left: 0px; margin-left: 0px;
margin-right: 8px; margin-right: 8px;
@ -394,27 +408,32 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
margin-right: 0px; 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; @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); 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; color: $accent-fg-color;
background-color: $accent-color; background-color: $accent-color;
min-width: 156px; 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; background-color: $warning-color;
border: solid 1px $warning-color; border: solid 1px $warning-color;
color: $accent-fg-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; background-color: $light-fg-color;
border: solid 1px $light-fg-color; border: solid 1px $light-fg-color;
opacity: 0.7; opacity: 0.7;

View file

@ -36,6 +36,7 @@
@import "./views/auth/_AuthHeader.scss"; @import "./views/auth/_AuthHeader.scss";
@import "./views/auth/_AuthHeaderLogo.scss"; @import "./views/auth/_AuthHeaderLogo.scss";
@import "./views/auth/_AuthPage.scss"; @import "./views/auth/_AuthPage.scss";
@import "./views/auth/_CompleteSecurityBody.scss";
@import "./views/auth/_CountryDropdown.scss"; @import "./views/auth/_CountryDropdown.scss";
@import "./views/auth/_InteractiveAuthEntryComponents.scss"; @import "./views/auth/_InteractiveAuthEntryComponents.scss";
@import "./views/auth/_LanguageSelector.scss"; @import "./views/auth/_LanguageSelector.scss";
@ -152,6 +153,7 @@
@import "./views/rooms/_EditMessageComposer.scss"; @import "./views/rooms/_EditMessageComposer.scss";
@import "./views/rooms/_EntityTile.scss"; @import "./views/rooms/_EntityTile.scss";
@import "./views/rooms/_EventTile.scss"; @import "./views/rooms/_EventTile.scss";
@import "./views/rooms/_InviteOnlyIcon.scss";
@import "./views/rooms/_JumpToBottomButton.scss"; @import "./views/rooms/_JumpToBottomButton.scss";
@import "./views/rooms/_LinkPreviewWidget.scss"; @import "./views/rooms/_LinkPreviewWidget.scss";
@import "./views/rooms/_MemberDeviceInfo.scss"; @import "./views/rooms/_MemberDeviceInfo.scss";

View file

@ -63,7 +63,7 @@ limitations under the License.
} }
.mx_GroupHeader_editButton::before { .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 { .mx_GroupHeader_shareButton::before {

View file

@ -51,8 +51,8 @@ limitations under the License.
&.mx_Toast_hasIcon { &.mx_Toast_hasIcon {
&::after { &::after {
content: ""; content: "";
width: 21px; width: 22px;
height: 20px; height: 22px;
grid-column: 1; grid-column: 1;
grid-row: 1; grid-row: 1;
mask-size: 100%; mask-size: 100%;

View file

@ -22,7 +22,7 @@ limitations under the License.
.mx_CompleteSecurity_headerIcon { .mx_CompleteSecurity_headerIcon {
width: 24px; width: 24px;
height: 24px; height: 24px;
margin: 0 4px; margin-right: 4px;
position: relative; position: relative;
} }

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,12 +17,12 @@ limitations under the License.
.mx_AuthBody { .mx_AuthBody {
width: 500px; width: 500px;
font-size: 12px;
color: $authpage-secondary-color;
background-color: $authpage-body-bg-color; background-color: $authpage-body-bg-color;
border-radius: 0 4px 4px 0; border-radius: 0 4px 4px 0;
padding: 25px 60px; padding: 25px 60px;
box-sizing: border-box; box-sizing: border-box;
font-size: 12px;
color: $authpage-secondary-color;
h2 { h2 {
font-size: 24px; font-size: 24px;

View file

@ -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;
}
}

View file

@ -210,4 +210,19 @@ limitations under the License.
.mx_InviteDialog { .mx_InviteDialog {
// Prevent the dialog from jumping around randomly when elements change. // Prevent the dialog from jumping around randomly when elements change.
height: 590px; 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;
} }

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2018 New Vector Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -22,7 +22,7 @@ limitations under the License.
.mx_CreateSecretStorageDialog_primaryContainer { .mx_CreateSecretStorageDialog_primaryContainer {
/* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */
padding: 20px; padding-top: 20px;
} }
.mx_CreateSecretStorageDialog_primaryContainer::after { .mx_CreateSecretStorageDialog_primaryContainer::after {
@ -36,9 +36,13 @@ limitations under the License.
align-items: flex-start; align-items: flex-start;
} }
.mx_Field.mx_CreateSecretStorageDialog_passPhraseField {
margin-top: 0px;
}
.mx_CreateSecretStorageDialog_passPhraseHelp { .mx_CreateSecretStorageDialog_passPhraseHelp {
flex: 1; flex: 1;
height: 85px; height: 64px;
margin-left: 20px; margin-left: 20px;
font-size: 80%; font-size: 80%;
} }
@ -47,16 +51,8 @@ limitations under the License.
width: 100%; 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 { .mx_CreateSecretStorageDialog_passPhraseMatch {
width: 200px;
margin-left: 20px; margin-left: 20px;
} }
@ -82,6 +78,10 @@ limitations under the License.
align-items: center; align-items: center;
} }
.mx_CreateSecretStorageDialog_recoveryKeyButtons .mx_AccessibleButton {
margin-right: 10px;
}
.mx_CreateSecretStorageDialog_recoveryKeyButtons button { .mx_CreateSecretStorageDialog_recoveryKeyButtons button {
flex: 1; flex: 1;
white-space: nowrap; white-space: nowrap;

View file

@ -23,15 +23,23 @@ limitations under the License.
font-size: 12px; font-size: 12px;
.mx_UserInfo_cancel { .mx_UserInfo_cancel {
cursor: pointer;
position: absolute;
top: 0;
border-radius: 4px;
background-color: $dark-panel-bg-color;
margin: 9px;
z-index: 1; // render on top of the right panel
div {
height: 16px; height: 16px;
width: 16px; width: 16px;
padding: 10px 0 10px 10px; padding: 4px;
cursor: pointer;
mask-image: url('$(res)/img/minimise.svg'); mask-image: url('$(res)/img/minimise.svg');
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: 16px center; mask-position: 7px center;
background-color: $rightpanel-button-color; background-color: $rightpanel-button-color;
position: absolute; }
} }
h2 { h2 {
@ -95,7 +103,7 @@ limitations under the License.
justify-content: center; justify-content: center;
// override the calculated sizes so that the letter isn't HUGE // override the calculated sizes so that the letter isn't HUGE
font-size: 26px !important; font-size: 56px !important;
width: 100% !important; width: 100% !important;
} }

View file

@ -367,6 +367,11 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
opacity: 1; opacity: 1;
} }
.mx_EventTile_e2eIcon_unknown {
background-image: url('$(res)/img/e2e/warning.svg');
opacity: 1;
}
.mx_EventTile_e2eIcon_unencrypted { .mx_EventTile_e2eIcon_unencrypted {
background-image: url('$(res)/img/e2e/warning.svg'); background-image: url('$(res)/img/e2e/warning.svg');
opacity: 1; opacity: 1;
@ -415,7 +420,8 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
} }
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line, .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; padding-left: 60px;
} }
@ -427,8 +433,13 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
border-left: $e2e-unverified-color 5px solid; 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_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; padding-left: 78px;
} }
@ -439,14 +450,16 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) // 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_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; left: 3px;
width: auto; width: auto;
} }
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) // 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_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; display: block;
left: 41px; left: 41px;
} }

View file

@ -0,0 +1,38 @@
/*
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_InviteOnlyIcon {
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;
}
}

View file

@ -76,6 +76,8 @@ limitations under the License.
left: 60px; left: 60px;
margin-right: 0; // Counteract the E2EIcon class margin-right: 0; // Counteract the E2EIcon class
margin-left: 3px; // Counteract the E2EIcon class margin-left: 3px; // Counteract the E2EIcon class
width: 12px;
height: 12px;
} }
.mx_MessageComposer_noperm_error { .mx_MessageComposer_noperm_error {

View file

@ -19,7 +19,12 @@ limitations under the License.
border-bottom: 1px solid $primary-hairline-color; border-bottom: 1px solid $primary-hairline-color;
.mx_E2EIcon { .mx_E2EIcon {
margin: 0 5px; margin: 0;
position: absolute;
bottom: -1px;
right: -2px;
height: 10px;
width: 10px;
} }
} }
@ -171,6 +176,7 @@ limitations under the License.
width: 28px; width: 28px;
height: 28px; height: 28px;
margin: 0 7px; margin: 0 7px;
position: relative;
} }
.mx_RoomHeader_avatar .mx_BaseAvatar_image { .mx_RoomHeader_avatar .mx_BaseAvatar_image {
@ -263,24 +269,3 @@ limitations under the License.
.mx_RoomHeader_pinsIndicatorUnread { .mx_RoomHeader_pinsIndicatorUnread {
background-color: $pinned-unread-color; 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;
}
}

View file

@ -123,6 +123,11 @@ limitations under the License.
& > * { & > * {
margin-top: 12px; 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;
}
} }
} }

View file

@ -98,6 +98,19 @@ limitations under the License.
z-index: 2; 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: 10px;
width: 10px;
display: block;
position: absolute;
bottom: -1px;
right: -2px;
z-index: 1;
margin: 0;
}
.mx_RoomTile_name { .mx_RoomTile_name {
font-size: 14px; font-size: 14px;
padding: 0 6px; padding: 0 6px;
@ -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, .mx_RoomTile_menuDisplayed,
// or on keyboard focus of room tile // 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_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover {
.mx_RoomTile_menuButton { .mx_RoomTile_menuButton {
display: block; display: block;
@ -201,30 +215,7 @@ limitations under the License.
flex: 1; flex: 1;
} }
.mx_RoomTile.mx_RoomTile.mx_RoomTile_isPrivate .mx_RoomTile_name { .mx_InviteOnlyIcon + .mx_RoomTile_nameContainer .mx_RoomTile_name {
// Scoot the padding in a bit from 6px to make it look better // Scoot the padding in a bit from 6px to make it look better
padding-left: 3px; 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;
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

View file

@ -224,6 +224,7 @@ $copy-button-url: "$(res)/img/icon_copy_message.svg";
// e2e // e2e
$e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
$e2e-unknown-color: #e8bf37;
$e2e-unverified-color: #e8bf37; $e2e-unverified-color: #e8bf37;
$e2e-warning-color: #ba6363; $e2e-warning-color: #ba6363;

View file

@ -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

View file

@ -21,15 +21,16 @@ handle_error() {
trap 'handle_error' ERR trap 'handle_error' ERR
RIOT_WEB_DIR=riot-web
REACT_SDK_DIR=`pwd`
echo "--- Building Riot" 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 # run end to end tests
pushd test/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 # PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh
# CHROME_PATH=$(which google-chrome-stable) ./run.sh # CHROME_PATH=$(which google-chrome-stable) ./run.sh
echo "--- Install synapse & other dependencies" echo "--- Install synapse & other dependencies"

View file

@ -6,9 +6,9 @@ scripts/fetchdep.sh matrix-org matrix-js-sdk
pushd matrix-js-sdk pushd matrix-js-sdk
yarn link yarn link
yarn install yarn install $@
yarn build yarn build
popd popd
yarn link matrix-js-sdk yarn link matrix-js-sdk
yarn install yarn install $@

31
scripts/ci/layered-riot-web.sh Executable file
View file

@ -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

View file

@ -6,9 +6,7 @@
set -ev set -ev
RIOT_WEB_DIR=riot-web scripts/ci/layered-riot-web.sh
cd ../riot-web
scripts/ci/build.sh yarn build:genfiles # so the tests can run. Faster version of `build`
pushd "$RIOT_WEB_DIR"
yarn test yarn test
popd

View file

@ -17,7 +17,7 @@ clone() {
if [ -n "$branch" ] if [ -n "$branch" ]
then then
echo "Trying to use $org/$repo#$branch" 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 fi
} }

92
src/AsyncWrapper.js Normal file
View file

@ -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 <Component {...this.props} />;
} else if (this.state.error) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <BaseDialog onFinished={this.props.onFinished}
title={_t("Error")}
>
{_t("Unable to load! Check your network connectivity and try again.")}
<DialogButtons primaryButton={_t("Dismiss")}
onPrimaryButtonClick={this._onWrapperCancelClick}
hasCancel={false}
/>
</BaseDialog>;
} else {
// show a spinner until the component is loaded.
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
},
});

View file

@ -20,10 +20,13 @@ import * as sdk from './index';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import ToastStore from './stores/ToastStore'; import ToastStore from './stores/ToastStore';
function toastKey(device) { function toastKey(deviceId) {
return 'newsession_' + device.deviceId; return 'newsession_' + deviceId;
} }
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
const THIS_DEVICE_TOAST_KEY = 'setupencryption';
export default class DeviceListener { export default class DeviceListener {
static sharedInstance() { static sharedInstance() {
if (!global.mx_DeviceListener) global.mx_DeviceListener = new DeviceListener(); if (!global.mx_DeviceListener) global.mx_DeviceListener = new DeviceListener();
@ -31,44 +34,120 @@ export default class DeviceListener {
} }
constructor() { 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') // device IDs for which the user has dismissed the verify toast ('Later')
this._dismissed = new Set(); 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() { start() {
MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated); MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged); MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged);
this.recheck(); MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
this._recheck();
} }
stop() { stop() {
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated); MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged); MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged);
} }
this._dismissed.clear(); this._dismissed.clear();
} }
dismissVerification(deviceId) { dismissVerification(deviceId) {
this._dismissed.add(deviceId); this._dismissed.add(deviceId);
this.recheck(); this._recheck();
}
dismissEncryptionSetup() {
this._dismissedThisDeviceToast = true;
this._recheck();
} }
_onDevicesUpdated = (users) => { _onDevicesUpdated = (users) => {
if (!users.includes(MatrixClientPeg.get().getUserId())) return; if (!users.includes(MatrixClientPeg.get().getUserId())) return;
this.recheck(); this._recheck();
} }
_onDeviceVerificationChanged = (users) => { _onDeviceVerificationChanged = (users) => {
if (!users.includes(MatrixClientPeg.get().getUserId())) return; if (!users.includes(MatrixClientPeg.get().getUserId())) return;
this.recheck(); 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; if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return;
const cli = MatrixClientPeg.get(); 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()); const devices = await cli.getStoredDevicesForUser(cli.getUserId());
for (const device of devices) { for (const device of devices) {
@ -76,16 +155,24 @@ export default class DeviceListener {
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId); const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
if (deviceTrust.isCrossSigningVerified() || this._dismissed.has(device.deviceId)) { if (deviceTrust.isCrossSigningVerified() || this._dismissed.has(device.deviceId)) {
ToastStore.sharedInstance().dismissToast(toastKey(device)); ToastStore.sharedInstance().dismissToast(toastKey(device.deviceId));
} else { } else {
this._activeNagToasts.add(device.deviceId);
ToastStore.sharedInstance().addOrReplaceToast({ ToastStore.sharedInstance().addOrReplaceToast({
key: toastKey(device), key: toastKey(device.deviceId),
title: _t("New Session"), title: _t("New Session"),
icon: "verification_warning", icon: "verification_warning",
props: {deviceId: device.deviceId}, props: {deviceId: device.deviceId},
component: sdk.getComponent("toasts.NewSessionToast"), component: sdk.getComponent("toasts.NewSessionToast"),
}); });
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;
} }
} }

View file

@ -592,8 +592,11 @@ async function startMatrixClient(startSyncing=true) {
Mjolnir.sharedInstance().start(); Mjolnir.sharedInstance().start();
if (startSyncing) { 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 EventIndexPeg.init();
await MatrixClientPeg.start();
} else { } else {
console.warn("Caller requested only auxiliary services be started"); console.warn("Caller requested only auxiliary services be started");
await MatrixClientPeg.assign(); await MatrixClientPeg.assign();

View file

@ -91,7 +91,7 @@ export default class Markdown {
return true; return true;
} }
toHTML() { toHTML({ externalLinks = false } = {}) {
const renderer = new commonmark.HtmlRenderer({ const renderer = new commonmark.HtmlRenderer({
safe: false, 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', 'noopener']);
}
this.tag('a', attrs);
} else {
this.tag('/a');
}
};
renderer.html_inline = html_if_tag_allowed; renderer.html_inline = html_if_tag_allowed;

View file

@ -217,7 +217,7 @@ class _MatrixClientPeg {
timelineSupport: true, timelineSupport: true,
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false), forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'), fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'),
verificationMethods: [verificationMethods.SAS], verificationMethods: [verificationMethods.SAS, verificationMethods.QR_CODE_SHOW],
unstableClientRelationAggregation: true, unstableClientRelationAggregation: true,
identityServer: new IdentityAuthClient(), identityServer: new IdentityAuthClient(),
}; };

View file

@ -17,87 +17,14 @@ limitations under the License.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import Analytics from './Analytics'; import Analytics from './Analytics';
import * as sdk from './index';
import dis from './dispatcher'; 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 DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; 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 <Component {...this.props} />;
} else if (this.state.error) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <BaseDialog onFinished={this.props.onFinished}
title={_t("Error")}
>
{_t("Unable to load! Check your network connectivity and try again.")}
<DialogButtons primaryButton={_t("Dismiss")}
onPrimaryButtonClick={this._onWrapperCancelClick}
hasCancel={false}
/>
</BaseDialog>;
} else {
// show a spinner until the component is loaded.
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
},
});
class ModalManager { class ModalManager {
constructor() { constructor() {
this._counter = 0; this._counter = 0;

View file

@ -20,13 +20,8 @@ import React from 'react';
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import MultiInviter from './utils/MultiInviter'; import MultiInviter from './utils/MultiInviter';
import Modal from './Modal'; import Modal from './Modal';
import { getAddressType } from './UserAddress';
import createRoom from './createRoom';
import * as sdk from './'; import * as sdk from './';
import dis from './dispatcher';
import DMRoomMap from './utils/DMRoomMap';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import SettingsStore from "./settings/SettingsStore";
import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog";
/** /**
@ -44,64 +39,21 @@ export function inviteMultipleToRoom(roomId, addrs) {
} }
export function showStartChatInviteDialog() { export function showStartChatInviteDialog() {
if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) { // This dialog handles the room creation internally - we don't need to worry about it.
// This new dialog handles the room creation internally - we don't need to worry about it.
const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Start DM', '', InviteDialog, {kind: KIND_DM}, 'Start DM', '', InviteDialog, {kind: KIND_DM},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true, /*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);
} }
export function showRoomInviteDialog(roomId) { export function showRoomInviteDialog(roomId) {
if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) { // This dialog handles the room creation internally - we don't need to worry about it.
// This new dialog handles the room creation internally - we don't need to worry about it.
const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId}, 'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true, /*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);
} }
/** /**
@ -122,60 +74,6 @@ export function isValid3pidInvite(event) {
return true; 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) { export function inviteUsersToRoom(roomId, userIds) {
return inviteMultipleToRoom(roomId, userIds).then((result) => { return inviteMultipleToRoom(roomId, userIds).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId); 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) { function _showAnyInviteErrors(addrs, room, inviter) {
// Show user any errors // Show user any errors
const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error'); const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error');
@ -243,15 +123,3 @@ function _showAnyInviteErrors(addrs, room, inviter) {
return addrs; 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;
}

View file

@ -81,6 +81,8 @@ class Command {
} }
run(roomId, args) { 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); return this.runFn.bind(this)(roomId, args);
} }
@ -905,25 +907,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} roomId The room in which the command was performed.
* @param {string} input The raw text input by the user. * @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. * processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command. * 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 // trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands // IRC-style commands
input = input.replace(/\s+$/, ''); input = input.replace(/\s+$/, '');
if (input[0] !== '/') return null; // not a command if (input[0] !== '/') return null; // not a command
const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/);
let cmd; let cmd;
let args; let args;
if (bits) { if (bits) {
cmd = bits[1].substring(1).toLowerCase(); cmd = bits[1].substring(1).toLowerCase();
args = bits[3]; args = bits[2];
} else { } else {
cmd = input; cmd = input;
} }
@ -932,11 +934,6 @@ export function processCommandInput(roomId, input) {
cmd = aliases[cmd]; cmd = aliases[cmd];
} }
if (CommandMap[cmd]) { if (CommandMap[cmd]) {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` return () => CommandMap[cmd].run(roomId, args);
if (!CommandMap[cmd].runFn) return null;
return CommandMap[cmd].run(roomId, args);
} else {
return reject(_t('Unrecognised command:') + ' ' + input);
} }
} }

View file

@ -275,6 +275,8 @@ function textForRoomAliasesEvent(ev) {
// This feels a bit overkill though, and it's not clear the i18n really needs it // 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. // 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 senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAliases = ev.getPrevContent().aliases || []; const oldAliases = ev.getPrevContent().aliases || [];
const newAliases = ev.getContent().aliases || []; const newAliases = ev.getContent().aliases || [];
@ -287,18 +289,40 @@ function textForRoomAliasesEvent(ev) {
} }
if (addedAliases.length && !removedAliases.length) { 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.', { return _t('%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.', {
senderName: senderName, senderName: senderName,
count: addedAliases.length, count: addedAliases.length,
addedAddresses: addedAliases.join(', '), addedAddresses: addedAliases.join(', '),
}); });
} else if (!addedAliases.length && removedAliases.length) { } 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.', { return _t('%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.', {
senderName: senderName, senderName: senderName,
count: removedAliases.length, count: removedAliases.length,
removedAddresses: removedAliases.join(', '), removedAddresses: removedAliases.join(', '),
}); });
} else { } 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( return _t(
'%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.', { '%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.', {
senderName: senderName, senderName: senderName,
@ -420,10 +444,19 @@ function textForHistoryVisibilityEvent(event) {
function textForEncryptionEvent(event) { function textForEncryptionEvent(event) {
const senderName = event.sender ? event.sender.name : event.getSender(); const senderName = event.sender ? event.sender.name : event.getSender();
return _t('%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).', { if (event.getContent().algorithm === "m.megolm.v1.aes-sha2") {
return _t('%(senderName)s turned on end-to-end encryption.', {
senderName,
});
}
return _t(
'%(senderName)s turned on end-to-end encryption ' +
'(unrecognised algorithm %(algorithm)s).',
{
senderName, senderName,
algorithm: event.getContent().algorithm, algorithm: event.getContent().algorithm,
}); },
);
} }
// Currently will only display a change if a user's power level is changed // Currently will only display a change if a user's power level is changed

View file

@ -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 <RovingTabIndexContext.Provider value={context}>
{ children({onKeyDownHandler}) }
</RovingTabIndexContext.Provider>;
};
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});
};

View file

@ -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 (
<BaseDialog onFinished={this.props.onFinished} title={_t("Are you sure?")}>
{_t("If disabled, messages from encrypted rooms won't appear in search results.")}
{this.state.disabling ? <Spinner /> : <div />}
<DialogButtons
primaryButton={_t('Disable')}
onPrimaryButtonClick={this._onDisable}
primaryButtonClass="danger"
cancelButtonClass="warning"
onCancel={this.props.onFinished}
disabled={this.state.disabling}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,154 @@
/*
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 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,
roomCount: 0,
currentRoom: null,
};
}
async updateCurrentRoom(room) {
const eventIndex = EventIndexPeg.get();
const stats = await eventIndex.getStats();
let currentRoom = null;
if (room) currentRoom = room.name;
this.setState({
eventIndexSize: stats.size,
roomCount: stats.roomCount,
eventCount: stats.eventCount,
currentRoom: currentRoom,
});
}
componentWillUnmount(): void {
const eventIndex = EventIndexPeg.get();
if (eventIndex !== null) {
eventIndex.removeListener("changedCheckpoint", this.updateCurrentRoom.bind(this));
}
}
async componentWillMount(): void {
let eventIndexSize = 0;
let roomCount = 0;
let eventCount = 0;
let currentRoom = null;
const eventIndex = EventIndexPeg.get();
if (eventIndex !== null) {
eventIndex.on("changedCheckpoint", this.updateCurrentRoom.bind(this));
const stats = await eventIndex.getStats();
eventIndexSize = stats.size;
roomCount = stats.roomCount;
eventCount = stats.eventCount;
const room = eventIndex.currentRoom();
if (room) currentRoom = room.name;
}
this.setState({
eventIndexSize,
eventCount,
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);
}
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 eventIndexingSettings = (
<div>
{
_t( "Riot is securely caching encrypted messages locally for them " +
"to appear in search results:",
)
}
<div className='mx_SettingsTab_subsectionText'>
{_t("Space used:")} {formatBytes(this.state.eventIndexSize, 0)}<br />
{_t("Indexed messages:")} {formatCountLong(this.state.eventCount)}<br />
{_t("Number of rooms:")} {formatCountLong(this.state.roomCount)}<br />
{crawlerState}<br />
</div>
</div>
);
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_ManageEventIndexDialog'
onFinished={this.props.onFinished}
title={_t("Message search")}
>
{eventIndexingSettings}
<DialogButtons
primaryButton={_t("Done")}
onPrimaryButtonClick={this.props.onFinished}
primaryButtonClass="primary"
cancelButton={_t("Disable")}
onCancel={this._onDisable}
cancelButtonClass="danger"
/>
</BaseDialog>
);
}
}

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2018, 2019 New Vector Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../../index'; import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import { scorePassword } from '../../../../utils/PasswordScorer'; import { scorePassword } from '../../../../utils/PasswordScorer';
@ -52,6 +53,15 @@ function selectText(target) {
* Secret Storage in account data. * Secret Storage in account data.
*/ */
export default class CreateSecretStorageDialog extends React.PureComponent { export default class CreateSecretStorageDialog extends React.PureComponent {
static propTypes = {
hasCancel: PropTypes.bool,
accountPassword: PropTypes.string,
};
defaultProps = {
hasCancel: true,
};
constructor(props) { constructor(props) {
super(props); super(props);
@ -70,12 +80,24 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
setPassPhrase: false, setPassPhrase: false,
backupInfo: null, backupInfo: null,
backupSigStatus: 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,
// set if we are 'upgrading' encryption (making an SSSS store from
// an existing key backup secret).
doingUpgrade: null,
}; };
this._fetchBackupInfo(); this._fetchBackupInfo();
this._queryKeyUploadAuth();
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
} }
componentWillUnmount() { componentWillUnmount() {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
if (this._setZxcvbnResultTimeout !== null) { if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout); clearTimeout(this._setZxcvbnResultTimeout);
} }
@ -83,7 +105,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
async _fetchBackupInfo() { async _fetchBackupInfo() {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); 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 ? const phase = backupInfo ?
(backupSigStatus.usable ? PHASE_MIGRATE : PHASE_RESTORE_KEY_BACKUP) : (backupSigStatus.usable ? PHASE_MIGRATE : PHASE_RESTORE_KEY_BACKUP) :
@ -93,14 +118,41 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
phase, phase,
backupInfo, backupInfo,
backupSigStatus, backupSigStatus,
// remember this after this phase so we can use appropriate copy
doingUpgrade: phase === PHASE_MIGRATE,
}); });
} }
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 = () => {
this._fetchBackupInfo();
}
_collectRecoveryKeyNode = (n) => { _collectRecoveryKeyNode = (n) => {
this._recoveryKeyNode = n; this._recoveryKeyNode = n;
} }
_onMigrateNextClick = () => { _onMigrateFormSubmit = (e) => {
e.preventDefault();
this._bootstrapSecretStorage(); this._bootstrapSecretStorage();
} }
@ -127,16 +179,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}); });
} }
_bootstrapSecretStorage = async () => { _doBootstrapUIAuth = async (makeRequest) => {
this.setState({ if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) {
phase: PHASE_STORING, await makeRequest({
error: null, 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,
}); });
const cli = MatrixClientPeg.get(); } else {
try {
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
await cli.bootstrapSecretStorage({
authUploadDeviceSigningKeys: async (makeRequest) => {
const { finished } = Modal.createTrackedDialog( const { finished } = Modal.createTrackedDialog(
'Cross-signing keys dialog', '', InteractiveAuthDialog, 'Cross-signing keys dialog', '', InteractiveAuthDialog,
{ {
@ -149,7 +205,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
if (!confirmed) { if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled"); throw new Error("Cross-signing key upload auth canceled");
} }
}, }
}
_bootstrapSecretStorage = async () => {
this.setState({
phase: PHASE_STORING,
error: null,
});
const cli = MatrixClientPeg.get();
try {
await cli.bootstrapSecretStorage({
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
createSecretStorageKey: async () => this._keyInfo, createSecretStorageKey: async () => this._keyInfo,
keyBackupInfo: this.state.backupInfo, keyBackupInfo: this.state.backupInfo,
}); });
@ -157,7 +226,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
phase: PHASE_DONE, phase: PHASE_DONE,
}); });
} catch (e) { } catch (e) {
if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) {
this.setState({
accountPasswordCorrect: false,
phase: PHASE_MIGRATE,
});
} else {
this.setState({ error: e }); this.setState({ error: e });
}
console.error("Error bootstrapping secret storage", e); console.error("Error bootstrapping secret storage", e);
} }
} }
@ -173,7 +249,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
_onRestoreKeyBackupClick = () => { _onRestoreKeyBackupClick = () => {
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Restore Backup', '', RestoreKeyBackupDialog, null, null, 'Restore Backup', '', RestoreKeyBackupDialog, {showSummary: false}, null,
/* priority = */ false, /* static = */ true, /* priority = */ false, /* static = */ true,
); );
} }
@ -285,6 +361,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE; return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE;
} }
_onAccountPasswordChange = (e) => {
this.setState({
accountPassword: e.target.value,
});
}
_renderPhaseRestoreKeyBackup() { _renderPhaseRestoreKeyBackup() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div> return <div>
@ -309,22 +391,47 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
// it automatically. // it automatically.
// https://github.com/vector-im/riot-web/issues/11696 // https://github.com/vector-im/riot-web/issues/11696
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div> const Field = sdk.getComponent('views.elements.Field');
let authPrompt;
if (this.state.canUploadKeysWithPasswordOnly) {
authPrompt = <div>
<div>{_t("Enter your account password to confirm the upgrade:")}</div>
<div><Field type="password"
id="mx_CreateSecretStorage_accountPassword"
label={_t("Password")}
value={this.state.accountPassword}
onChange={this._onAccountPasswordChange}
flagInvalid={this.state.accountPasswordCorrect === false}
autoFocus={true}
/></div>
</div>;
} else {
authPrompt = <p>
{_t("You'll need to authenticate with the server to confirm the upgrade.")}
</p>;
}
return <form onSubmit={this._onMigrateFormSubmit}>
<p>{_t( <p>{_t(
"Secret Storage will be set up using your existing key backup details. " + "Upgrade this device to allow it to verify other devices, " +
"Your secret storage passphrase and recovery key will be the same as " + "granting them access to encrypted messages and marking them " +
"they were for your key backup.", "as trusted for other users.",
)}</p> )}</p>
<div>{authPrompt}</div>
<DialogButtons primaryButton={_t('Next')} <DialogButtons primaryButton={_t('Next')}
onPrimaryButtonClick={this._onMigrateNextClick} primaryIsSubmit={true}
hasCancel={true} hasCancel={true}
onCancel={this._onCancel} onCancel={this._onCancel}
primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword}
/> />
</div>; </form>;
} }
_renderPhasePassPhrase() { _renderPhasePassPhrase() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let strengthMeter; let strengthMeter;
let helpText; let helpText;
@ -350,25 +457,21 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
return <div> return <div>
<p>{_t( <p>{_t(
"<b>Warning</b>: You should only set up secret storage from a trusted computer.", {}, "Set up encryption on this device to allow it to verify other devices, " +
{ b: sub => <b>{sub}</b> }, "granting them access to encrypted messages and marking them as trusted for other users.",
)}</p> )}</p>
<p>{_t( <p>{_t(
"We'll use secret storage to optionally store an encrypted copy of " + "Secure your encryption keys with a passphrase. For maximum security " +
"your cross-signing identity for verifying other devices and message " + "this should be different to your account password:",
"keys on our server. Protect your access to encrypted messages with a " +
"passphrase to keep it secure.",
)}</p> )}</p>
<p>{_t("For maximum security, this should be different from your account password.")}</p>
<div className="mx_CreateSecretStorageDialog_primaryContainer">
<div className="mx_CreateSecretStorageDialog_passPhraseContainer"> <div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<input type="password" <Field type="password"
className="mx_CreateSecretStorageDialog_passPhraseField"
onChange={this._onPassPhraseChange} onChange={this._onPassPhraseChange}
onKeyPress={this._onPassPhraseKeyPress} onKeyPress={this._onPassPhraseKeyPress}
value={this.state.passPhrase} value={this.state.passPhrase}
className="mx_CreateSecretStorageDialog_passPhraseInput" label={_t("Enter a passphrase")}
placeholder={_t("Enter a passphrase...")}
autoFocus={true} autoFocus={true}
/> />
<div className="mx_CreateSecretStorageDialog_passPhraseHelp"> <div className="mx_CreateSecretStorageDialog_passPhraseHelp">
@ -376,25 +479,30 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
{helpText} {helpText}
</div> </div>
</div> </div>
</div>
<DialogButtons primaryButton={_t('Next')} <DialogButtons primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onPassPhraseNextClick} onPrimaryButtonClick={this._onPassPhraseNextClick}
hasCancel={false} hasCancel={false}
disabled={!this._passPhraseIsValid()} disabled={!this._passPhraseIsValid()}
/> >
<button type="button"
onClick={this._onCancel}
className="danger"
>{_t("Skip")}</button>
</DialogButtons>
<details> <details>
<summary>{_t("Advanced")}</summary> <summary>{_t("Advanced")}</summary>
<p><button onClick={this._onSkipPassPhraseClick} > <p><AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} >
{_t("Set up with a recovery key")} {_t("Set up with a recovery key")}
</button></p> </AccessibleButton></p>
</details> </details>
</div>; </div>;
} }
_renderPhasePassPhraseConfirm() { _renderPhasePassPhraseConfirm() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Field = sdk.getComponent('views.elements.Field');
let matchText; let matchText;
if (this.state.passPhraseConfirm === this.state.passPhrase) { if (this.state.passPhraseConfirm === this.state.passPhrase) {
@ -412,7 +520,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
let passPhraseMatch = null; let passPhraseMatch = null;
if (matchText) { if (matchText) {
passPhraseMatch = <div className="mx_CreateSecretStorageDialog_passPhraseMatch"> passPhraseMatch = <div>
<div>{matchText}</div> <div>{matchText}</div>
<div> <div>
<AccessibleButton element="span" className="mx_linkButton" onClick={this._onSetAgainClick}> <AccessibleButton element="span" className="mx_linkButton" onClick={this._onSetAgainClick}>
@ -424,28 +532,32 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div> return <div>
<p>{_t( <p>{_t(
"Please enter your passphrase a second time to confirm.", "Enter your passphrase a second time to confirm it.",
)}</p> )}</p>
<div className="mx_CreateSecretStorageDialog_primaryContainer">
<div className="mx_CreateSecretStorageDialog_passPhraseContainer"> <div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<div> <Field type="password"
<input type="password" id="mx_CreateSecretStorageDialog_passPhraseField"
onChange={this._onPassPhraseConfirmChange} onChange={this._onPassPhraseConfirmChange}
onKeyPress={this._onPassPhraseConfirmKeyPress} onKeyPress={this._onPassPhraseConfirmKeyPress}
value={this.state.passPhraseConfirm} value={this.state.passPhraseConfirm}
className="mx_CreateSecretStorageDialog_passPhraseInput" className="mx_CreateSecretStorageDialog_passPhraseField"
placeholder={_t("Repeat your passphrase...")} label={_t("Confirm your passphrase")}
autoFocus={true} autoFocus={true}
/> />
</div> <div className="mx_CreateSecretStorageDialog_passPhraseMatch">
{passPhraseMatch} {passPhraseMatch}
</div> </div>
</div> </div>
<DialogButtons primaryButton={_t('Next')} <DialogButtons primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onPassPhraseConfirmNextClick} onPrimaryButtonClick={this._onPassPhraseConfirmNextClick}
hasCancel={false} hasCancel={false}
disabled={this.state.passPhrase !== this.state.passPhraseConfirm} disabled={this.state.passPhrase !== this.state.passPhraseConfirm}
/> >
<button type="button"
onClick={this._onCancel}
className="danger"
>{_t("Skip")}</button>
</DialogButtons>
</div>; </div>;
} }
@ -463,6 +575,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
); );
} }
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <div> return <div>
<p>{_t( <p>{_t(
"Your recovery key is a safety net - you can use it to restore " + "Your recovery key is a safety net - you can use it to restore " +
@ -481,12 +594,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<code ref={this._collectRecoveryKeyNode}>{this._encodedRecoveryKey}</code> <code ref={this._collectRecoveryKeyNode}>{this._encodedRecoveryKey}</code>
</div> </div>
<div className="mx_CreateSecretStorageDialog_recoveryKeyButtons"> <div className="mx_CreateSecretStorageDialog_recoveryKeyButtons">
<button className="mx_Dialog_primary" onClick={this._onCopyClick}> <AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onCopyClick}>
{_t("Copy to clipboard")} {_t("Copy to clipboard")}
</button> </AccessibleButton>
<button className="mx_Dialog_primary" onClick={this._onDownloadClick}> <AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onDownloadClick}>
{_t("Download")} {_t("Download")}
</button> </AccessibleButton>
</div> </div>
</div> </div>
</div> </div>
@ -533,7 +646,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div> return <div>
<p>{_t( <p>{_t(
"Your access to encrypted messages is now protected.", "This device can now verify other devices, granting them access " +
"to encrypted messages and marking them as trusted for other users.",
)}</p>
<p>{_t(
"Verify other users in their profile.",
)}</p> )}</p>
<DialogButtons primaryButton={_t('OK')} <DialogButtons primaryButton={_t('OK')}
onPrimaryButtonClick={this._onDone} onPrimaryButtonClick={this._onDone}
@ -564,11 +681,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
case PHASE_RESTORE_KEY_BACKUP: case PHASE_RESTORE_KEY_BACKUP:
return _t('Restore your Key Backup'); return _t('Restore your Key Backup');
case PHASE_MIGRATE: case PHASE_MIGRATE:
return _t('Migrate from Key Backup'); return _t('Upgrade your encryption');
case PHASE_PASSPHRASE: case PHASE_PASSPHRASE:
return _t('Secure your encrypted messages with a passphrase'); return _t('Set up encryption');
case PHASE_PASSPHRASE_CONFIRM: case PHASE_PASSPHRASE_CONFIRM:
return _t('Confirm your passphrase'); return _t('Confirm passphrase');
case PHASE_OPTOUT_CONFIRM: case PHASE_OPTOUT_CONFIRM:
return _t('Warning!'); return _t('Warning!');
case PHASE_SHOWKEY: case PHASE_SHOWKEY:
@ -578,9 +695,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
case PHASE_STORING: case PHASE_STORING:
return _t('Storing secrets...'); return _t('Storing secrets...');
case PHASE_DONE: case PHASE_DONE:
return _t('Success!'); return this.state.doingUpgrade ? _t('Encryption upgraded') : _t('Encryption setup complete');
default: default:
return null; return '';
} }
} }
@ -635,11 +752,17 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
} }
} }
let headerImage;
if (this._titleForPhase(this.state.phase)) {
headerImage = require("../../../../../res/img/e2e/normal.svg");
}
return ( return (
<BaseDialog className='mx_CreateSecretStorageDialog' <BaseDialog className='mx_CreateSecretStorageDialog'
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={this._titleForPhase(this.state.phase)} title={this._titleForPhase(this.state.phase)}
hasCancel={[PHASE_PASSPHRASE, PHASE_DONE].includes(this.state.phase)} headerImage={headerImage}
hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)}
> >
<div> <div>
{content} {content}

View file

@ -19,9 +19,10 @@ import React from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk'; import {Filter} from 'matrix-js-sdk';
import * as sdk from '../../index'; import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg'; import {MatrixClientPeg} from '../../MatrixClientPeg';
import EventIndexPeg from "../../indexing/EventIndexPeg";
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
/* /*
@ -29,6 +30,9 @@ import { _t } from '../../languageHandler';
*/ */
const FilePanel = createReactClass({ const FilePanel = createReactClass({
displayName: 'FilePanel', 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: { propTypes: {
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
@ -40,18 +44,78 @@ const FilePanel = createReactClass({
}; };
}, },
componentDidMount: function() { onRoomTimeline(ev, room, toStartOfTimeline, removed, data) {
this.updateTimelineSet(this.props.roomId); 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(); const client = MatrixClientPeg.get();
const room = client.getRoom(roomId);
this.noRoom = !room; await this.updateTimelineSet(this.props.roomId);
if (room) { if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return;
const filter = new Matrix.Filter(client.credentials.userId);
// 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.bind(this));
client.on('Event.decrypted', this.onEventDecrypted.bind(this));
}
},
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.bind(this));
client.removeListener('Event.decrypted', this.onEventDecrypted.bind(this));
}
},
async fetchFileEventsServer(room) {
const client = MatrixClientPeg.get();
const filter = new Filter(client.credentials.userId);
filter.setDefinition( filter.setDefinition(
{ {
"room": { "room": {
@ -65,17 +129,62 @@ const FilePanel = createReactClass({
}, },
); );
// FIXME: we shouldn't be doing this every time we change room - see comment above. const filterId = await client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter);
client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then(
(filterId)=>{
filter.filterId = filterId; filter.filterId = filterId;
const timelineSet = room.getOrCreateFilteredTimelineSet(filter); 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) {
let timelineSet;
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 }); this.setState({ timelineSet: timelineSet });
}, } catch (error) {
(error)=>{
console.error("Failed to get or create file panel filter", error); console.error("Failed to get or create file panel filter", error);
}, }
);
} else { } else {
console.error("Failed to add filtered timelineSet for FilePanel as no room!"); console.error("Failed to add filtered timelineSet for FilePanel as no room!");
} }
@ -111,6 +220,7 @@ const FilePanel = createReactClass({
manageReadMarkers={false} manageReadMarkers={false}
timelineSet={this.state.timelineSet} timelineSet={this.state.timelineSet}
showUrlPreview = {false} showUrlPreview = {false}
onPaginationRequest={this.onPaginationRequest}
tileShape="file_grid" tileShape="file_grid"
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
empty={_t('There are no visible files in this room')} empty={_t('There are no visible files in this room')}

View file

@ -20,7 +20,7 @@ import React, {createRef} from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {getEntryComponentForLoginType} from '../views/auth/InteractiveAuthEntryComponents'; import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
import * as sdk from '../../index'; import * as sdk from '../../index';

View file

@ -129,9 +129,6 @@ const LeftPanel = createReactClass({
if (!this.focusedElement) return; if (!this.focusedElement) return;
switch (ev.key) { switch (ev.key) {
case Key.TAB:
this._onMoveFocus(ev, ev.shiftKey);
break;
case Key.ARROW_UP: case Key.ARROW_UP:
this._onMoveFocus(ev, true, true); this._onMoveFocus(ev, true, true);
break; break;

View file

@ -89,12 +89,15 @@ export const VIEWS = {
// showing flow to trust this new device with cross-signing // showing flow to trust this new device with cross-signing
COMPLETE_SECURITY: 6, COMPLETE_SECURITY: 6,
// flow to setup SSSS / cross-signing on this account
E2E_SETUP: 7,
// we are logged in with an active matrix client. // 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 // We are logged out (invalid token) but have our local state again. The user
// should log back in to rehydrate the client. // 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 // Actions that are redirected through the onboarding process prior to being
@ -253,6 +256,9 @@ export default createReactClass({
// logout page. // logout page.
Lifecycle.loadSession({}); Lifecycle.loadSession({});
} }
this._accountPassword = null;
this._accountPasswordTimer = null;
}, },
componentDidMount: function() { componentDidMount: function() {
@ -349,6 +355,8 @@ export default createReactClass({
window.removeEventListener("focus", this.onFocus); window.removeEventListener("focus", this.onFocus);
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize); this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize);
if (this._accountPasswordTimer !== null) clearTimeout(this._accountPasswordTimer);
}, },
componentWillUpdate: function(props, state) { componentWillUpdate: function(props, state) {
@ -657,7 +665,9 @@ export default createReactClass({
if ( if (
!Lifecycle.isSoftLogout() && !Lifecycle.isSoftLogout() &&
this.state.view !== VIEWS.LOGIN && 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(); this._onLoggedIn();
} }
@ -961,9 +971,9 @@ export default createReactClass({
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog); const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog);
const [shouldCreate, createOpts] = await modal.finished; const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) { if (shouldCreate) {
createRoom({createOpts}); createRoom(opts);
} }
}, },
@ -1453,7 +1463,6 @@ export default createReactClass({
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
cli.on("crypto.verification.request", request => { cli.on("crypto.verification.request", request => {
console.log(`MatrixChat got a .request ${request.channel.transactionId}`, request.event.getRoomId());
if (request.pending) { if (request.pending) {
ToastStore.sharedInstance().addOrReplaceToast({ ToastStore.sharedInstance().addOrReplaceToast({
key: 'verifreq_' + request.channel.transactionId, key: 'verifreq_' + request.channel.transactionId,
@ -1725,6 +1734,10 @@ export default createReactClass({
this.showScreen("forgot_password"); this.showScreen("forgot_password");
}, },
onRegisterFlowComplete: function(credentials, password) {
return this.onUserCompletedLoginFlow(credentials, password);
},
// returns a promise which resolves to the new MatrixClient // returns a promise which resolves to the new MatrixClient
onRegistered: function(credentials) { onRegistered: function(credentials) {
return Lifecycle.setLoggedIn(credentials); return Lifecycle.setLoggedIn(credentials);
@ -1813,7 +1826,14 @@ export default createReactClass({
this._loggedInView = ref; 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) // Wait for the client to be logged in (but not started)
// which is enough to ask the server about account data. // which is enough to ask the server about account data.
const loggedIn = new Promise(resolve => { const loggedIn = new Promise(resolve => {
@ -1827,7 +1847,7 @@ export default createReactClass({
}); });
// Create and start the client in the background // Create and start the client in the background
Lifecycle.setLoggedIn(credentials); const setLoggedInPromise = Lifecycle.setLoggedIn(credentials);
await loggedIn; await loggedIn;
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
@ -1848,12 +1868,20 @@ export default createReactClass({
if (masterKeyInStorage) { if (masterKeyInStorage) {
this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY }); 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 { } else {
this._onLoggedIn(); this._onLoggedIn();
} }
return setLoggedInPromise;
}, },
onCompleteSecurityFinished() { // complete security / e2e setup has finished
onCompleteSecurityE2eSetupFinished() {
this._onLoggedIn(); this._onLoggedIn();
}, },
@ -1873,7 +1901,15 @@ export default createReactClass({
const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity'); const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity');
view = ( view = (
<CompleteSecurity <CompleteSecurity
onFinished={this.onCompleteSecurityFinished} onFinished={this.onCompleteSecurityE2eSetupFinished}
/>
);
} else if (this.state.view === VIEWS.E2E_SETUP) {
const E2eSetup = sdk.getComponent('structures.auth.E2eSetup');
view = (
<E2eSetup
onFinished={this.onCompleteSecurityE2eSetupFinished}
accountPassword={this._accountPassword}
/> />
); );
} else if (this.state.view === VIEWS.POST_REGISTRATION) { } else if (this.state.view === VIEWS.POST_REGISTRATION) {
@ -1940,7 +1976,7 @@ export default createReactClass({
email={this.props.startingFragmentQueryParams.email} email={this.props.startingFragmentQueryParams.email}
brand={this.props.config.brand} brand={this.props.config.brand}
makeRegistrationUrl={this._makeRegistrationUrl} makeRegistrationUrl={this._makeRegistrationUrl}
onLoggedIn={this.onRegistered} onLoggedIn={this.onRegisterFlowComplete}
onLoginClick={this.onLoginClick} onLoginClick={this.onLoginClick}
onServerConfigChange={this.onServerConfigChange} onServerConfigChange={this.onServerConfigChange}
{...this.getServerProperties()} {...this.getServerProperties()}

View file

@ -31,6 +31,7 @@ import PropTypes from 'prop-types';
import RoomTile from "../views/rooms/RoomTile"; import RoomTile from "../views/rooms/RoomTile";
import LazyRenderList from "../views/elements/LazyRenderList"; import LazyRenderList from "../views/elements/LazyRenderList";
import {_t} from "../../languageHandler"; import {_t} from "../../languageHandler";
import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex";
// turn this on for drop & drag console debugging galore // turn this on for drop & drag console debugging galore
const debug = false; const debug = false;
@ -141,10 +142,6 @@ export default class RoomSubList extends React.PureComponent {
onHeaderKeyDown = (ev) => { onHeaderKeyDown = (ev) => {
switch (ev.key) { switch (ev.key) {
case Key.TAB:
// Prevent LeftPanel handling Tab if focus is on the sublist header itself
ev.stopPropagation();
break;
case Key.ARROW_LEFT: case Key.ARROW_LEFT:
// On ARROW_LEFT collapse the room sublist // On ARROW_LEFT collapse the room sublist
if (!this.state.hidden && !this.props.forceExpand) { if (!this.state.hidden && !this.props.forceExpand) {
@ -263,33 +260,6 @@ export default class RoomSubList extends React.PureComponent {
const subListNotifCount = subListNotifications.count; const subListNotifCount = subListNotifications.count;
const subListNotifHighlight = subListNotifications.highlight; const subListNotifHighlight = subListNotifications.highlight;
let badge;
if (!this.props.collapsed) {
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
if (subListNotifCount > 0) {
badge = (
<AccessibleButton className={badgeClasses} onClick={this._onNotifBadgeClick} aria-label={_t("Jump to first unread room.")}>
<div>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.props.list.length) {
// no notifications but highlight anyway because this is an invite badge
badge = (
<AccessibleButton className={badgeClasses} onClick={this._onInviteBadgeClick} aria-label={_t("Jump to first invite.")}>
<div>
{ this.props.list.length }
</div>
</AccessibleButton>
);
}
}
// When collapsed, allow a long hover on the header to show user // When collapsed, allow a long hover on the header to show user
// the full tag name and room count // the full tag name and room count
let title; let title;
@ -305,17 +275,6 @@ export default class RoomSubList extends React.PureComponent {
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />; <IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
} }
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
const len = this.props.list.length + this.props.extraTiles.length; const len = this.props.list.length + this.props.extraTiles.length;
let chevron; let chevron;
if (len) { if (len) {
@ -327,14 +286,68 @@ export default class RoomSubList extends React.PureComponent {
chevron = (<div className={chevronClasses} />); chevron = (<div className={chevronClasses} />);
} }
return <RovingTabIndexWrapper inputRef={this._headerButton}>
{({onFocus, isActive, ref}) => {
const tabIndex = isActive ? 0 : -1;
let badge;
if (!this.props.collapsed) {
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
if (subListNotifCount > 0) {
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
onClick={this._onNotifBadgeClick}
aria-label={_t("Jump to first unread room.")}
>
<div>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.props.list.length) {
// no notifications but highlight anyway because this is an invite badge
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
onClick={this._onInviteBadgeClick}
aria-label={_t("Jump to first invite.")}
>
<div>
{ this.props.list.length }
</div>
</AccessibleButton>
);
}
}
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
tabIndex={tabIndex}
onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
return ( return (
<div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}> <div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
<AccessibleButton <AccessibleButton
onFocus={onFocus}
tabIndex={tabIndex}
inputRef={ref}
onClick={this.onClick} onClick={this.onClick}
className="mx_RoomSubList_label" className="mx_RoomSubList_label"
tabIndex={0}
aria-expanded={!isCollapsed} aria-expanded={!isCollapsed}
inputRef={this._headerButton}
role="treeitem" role="treeitem"
aria-level="1" aria-level="1"
> >
@ -346,6 +359,8 @@ export default class RoomSubList extends React.PureComponent {
{ addRoomButton } { addRoomButton }
</div> </div>
); );
} }
</RovingTabIndexWrapper>;
} }
checkOverflow = () => { checkOverflow = () => {

View file

@ -766,7 +766,7 @@ export default createReactClass({
onUserVerificationChanged: function(userId, _trustStatus) { onUserVerificationChanged: function(userId, _trustStatus) {
const room = this.state.room; const room = this.state.room;
if (!room.currentState.getMember(userId)) { if (!room || !room.currentState.getMember(userId)) {
return; return;
} }
this._updateE2EStatus(room); this._updateE2EStatus(room);
@ -796,6 +796,7 @@ export default createReactClass({
return; return;
} }
// Duplication between here and _updateE2eStatus in RoomTile
/* At this point, the user has encryption on and cross-signing on */ /* At this point, the user has encryption on and cross-signing on */
const e2eMembers = await room.getEncryptionTargetMembers(); const e2eMembers = await room.getEncryptionTargetMembers();
const verified = []; const verified = [];
@ -810,12 +811,12 @@ export default createReactClass({
debuglog("e2e verified", verified, "unverified", unverified); debuglog("e2e verified", verified, "unverified", unverified);
/* Check all verified user devices. */ /* Check all verified user devices. */
for (const userId of verified) { for (const userId of [...verified, cli.getUserId()]) {
const devices = await cli.getStoredDevicesForUser(userId); const devices = await cli.getStoredDevicesForUser(userId);
const allDevicesVerified = devices.every(({deviceId}) => { const anyDeviceNotVerified = devices.some(({deviceId}) => {
return cli.checkDeviceTrust(userId, deviceId).isVerified(); return !cli.checkDeviceTrust(userId, deviceId).isVerified();
}); });
if (!allDevicesVerified) { if (anyDeviceNotVerified) {
this.setState({ this.setState({
e2eStatus: "warning", e2eStatus: "warning",
}); });
@ -1367,6 +1368,41 @@ export default createReactClass({
}); });
}, },
onRejectAndIgnoreClick: async function() {
this.setState({
rejecting: true,
});
const cli = MatrixClientPeg.get();
try {
const myMember = this.state.room.getMember(cli.getUserId());
const inviteEvent = myMember.events.member;
const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers();
ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk
await cli.setIgnoredUsers(ignoredUsers);
await cli.leave(this.state.roomId);
dis.dispatch({ action: 'view_next_room' });
this.setState({
rejecting: false,
});
} catch (error) {
console.error("Failed to reject invite: %s", error);
const msg = error.message ? error.message : JSON.stringify(error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, {
title: _t("Failed to reject invite"),
description: msg,
});
self.setState({
rejecting: false,
rejectError: error,
});
}
},
onRejectThreepidInviteButtonClicked: function(ev) { onRejectThreepidInviteButtonClicked: function(ev) {
// We can reject 3pid invites in the same way that we accept them, // We can reject 3pid invites in the same way that we accept them,
// using /leave rather than /join. In the short term though, we // using /leave rather than /join. In the short term though, we
@ -1671,9 +1707,11 @@ export default createReactClass({
return ( return (
<div className="mx_RoomView"> <div className="mx_RoomView">
<ErrorBoundary> <ErrorBoundary>
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked} <RoomPreviewBar
onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick} onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked} onRejectClick={this.onRejectButtonClicked}
onRejectAndIgnoreClick={this.onRejectAndIgnoreClick}
inviterName={inviterName} inviterName={inviterName}
canPreview={false} canPreview={false}
joining={this.state.joining} joining={this.state.joining}

View file

@ -877,11 +877,14 @@ export default createReactClass({
// TODO: the classnames on the div and ol could do with being updated to // TODO: the classnames on the div and ol could do with being updated to
// reflect the fact that we don't necessarily contain a list of messages. // reflect the fact that we don't necessarily contain a list of messages.
// it's not obvious why we have a separate div and ol anyway. // it's not obvious why we have a separate div and ol anyway.
// give the <ol> an explicit role=list because Safari+VoiceOver seems to think an ordered-list with
// list-style-type: none; is no longer a list
return (<AutoHideScrollbar wrappedRef={this._collectScroll} return (<AutoHideScrollbar wrappedRef={this._collectScroll}
onScroll={this.onScroll} onScroll={this.onScroll}
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}> className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper"> <div className="mx_RoomView_messageListWrapper">
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite"> <ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
{ this.props.children } { this.props.children }
</ol> </ol>
</div> </div>

View file

@ -133,7 +133,9 @@ export default createReactClass({
return null; return null;
} }
const clearButton = (!this.state.blurred || this.state.searchTerm) ? const clearButton = (!this.state.blurred || this.state.searchTerm) ?
(<AccessibleButton key="button" (<AccessibleButton
key="button"
tabIndex={-1}
className="mx_SearchBox_closeButton" className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button"); } }> onClick={ () => {this._clearSearch("button"); } }>
</AccessibleButton>) : undefined; </AccessibleButton>) : undefined;

View file

@ -94,6 +94,10 @@ const TimelinePanel = createReactClass({
// callback which is called when the read-up-to mark is updated. // callback which is called when the read-up-to mark is updated.
onReadMarkerUpdated: PropTypes.func, onReadMarkerUpdated: PropTypes.func,
// callback which is called when we wish to paginate the timeline
// window.
onPaginationRequest: PropTypes.func,
// maximum number of events to show in a timeline // maximum number of events to show in a timeline
timelineCap: PropTypes.number, timelineCap: PropTypes.number,
@ -338,6 +342,14 @@ const TimelinePanel = createReactClass({
} }
}, },
onPaginationRequest(timelineWindow, direction, size) {
if (this.props.onPaginationRequest) {
return this.props.onPaginationRequest(timelineWindow, direction, size);
} else {
return timelineWindow.paginate(direction, size);
}
},
// set off a pagination request. // set off a pagination request.
onMessageListFillRequest: function(backwards) { onMessageListFillRequest: function(backwards) {
if (!this._shouldPaginate()) return Promise.resolve(false); if (!this._shouldPaginate()) return Promise.resolve(false);
@ -360,7 +372,7 @@ const TimelinePanel = createReactClass({
debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards); debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards);
this.setState({[paginatingKey]: true}); this.setState({[paginatingKey]: true});
return this._timelineWindow.paginate(dir, PAGINATE_SIZE).then((r) => { return this.onPaginationRequest(this._timelineWindow, dir, PAGINATE_SIZE).then((r) => {
if (this.unmounted) { return; } if (this.unmounted) { return; }
debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r); debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r);

View file

@ -23,9 +23,11 @@ export default class ToastContainer extends React.Component {
constructor() { constructor() {
super(); super();
this.state = {toasts: ToastStore.sharedInstance().getToasts()}; this.state = {toasts: ToastStore.sharedInstance().getToasts()};
}
componentDidMount() { // Start listening here rather than in componentDidMount because
// toasts may dismiss themselves in their didMount if they find
// they're already irrelevant by the time they're mounted, and
// our own componentDidMount is too late.
ToastStore.sharedInstance().on('update', this._onToastStoreUpdate); ToastStore.sharedInstance().on('update', this._onToastStoreUpdate);
} }

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {TopLeftMenu} from '../views/context_menus/TopLeftMenu'; import TopLeftMenu from '../views/context_menus/TopLeftMenu';
import BaseAvatar from '../views/avatars/BaseAvatar'; import BaseAvatar from '../views/avatars/BaseAvatar';
import {MatrixClientPeg} from '../../MatrixClientPeg'; import {MatrixClientPeg} from '../../MatrixClientPeg';
import * as Avatar from '../../Avatar'; import * as Avatar from '../../Avatar';

View file

@ -35,7 +35,21 @@ export default class CompleteSecurity extends React.Component {
this.state = { this.state = {
phase: PHASE_INTRO, phase: PHASE_INTRO,
// this serves dual purpose as the object for the request logic and
// the presence of it insidicating that we're in 'verify mode'.
// Because of the latter, it lives in the state.
verificationRequest: null,
}; };
MatrixClientPeg.get().on("crypto.verification.request", this.onVerificationRequest);
}
componentWillUnmount() {
if (this.state.verificationRequest) {
this.state.verificationRequest.off("change", this.onVerificationRequestChange);
}
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("crypto.verification.request", this.onVerificationRequest);
}
} }
onStartClick = async () => { onStartClick = async () => {
@ -44,14 +58,38 @@ export default class CompleteSecurity extends React.Component {
await accessSecretStorage(async () => { await accessSecretStorage(async () => {
await cli.checkOwnCrossSigningTrust(); await cli.checkOwnCrossSigningTrust();
}); });
if (cli.getCrossSigningId()) {
this.setState({ this.setState({
phase: PHASE_DONE, phase: PHASE_DONE,
}); });
}
} catch (e) { } catch (e) {
// this will throw if the user hits cancel, so ignore // this will throw if the user hits cancel, so ignore
} }
} }
onVerificationRequest = (request) => {
if (request.otherUserId !== MatrixClientPeg.get().getUserId()) return;
if (this.state.verificationRequest) {
this.state.verificationRequest.off("change", this.onVerificationRequestChange);
}
request.on("change", this.onVerificationRequestChange);
this.setState({
verificationRequest: request,
});
}
onVerificationRequestChange = () => {
if (this.state.verificationRequest.cancelled) {
this.state.verificationRequest.off("change", this.onVerificationRequestChange);
this.setState({
verificationRequest: null,
});
}
}
onSkipClick = () => { onSkipClick = () => {
this.setState({ this.setState({
phase: PHASE_CONFIRM_SKIP, phase: PHASE_CONFIRM_SKIP,
@ -74,8 +112,7 @@ export default class CompleteSecurity extends React.Component {
render() { render() {
const AuthPage = sdk.getComponent("auth.AuthPage"); const AuthPage = sdk.getComponent("auth.AuthPage");
const AuthHeader = sdk.getComponent("auth.AuthHeader"); const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
const AuthBody = sdk.getComponent("auth.AuthBody");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const { const {
@ -85,7 +122,13 @@ export default class CompleteSecurity extends React.Component {
let icon; let icon;
let title; let title;
let body; let body;
if (phase === PHASE_INTRO) {
if (this.state.verificationRequest) {
const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog");
body = <IncomingSasDialog verifier={this.state.verificationRequest.verifier}
onFinished={this.props.onFinished}
/>;
} else if (phase === PHASE_INTRO) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>; icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
title = _t("Complete security"); title = _t("Complete security");
body = ( body = (
@ -161,8 +204,7 @@ export default class CompleteSecurity extends React.Component {
return ( return (
<AuthPage> <AuthPage>
<AuthHeader /> <CompleteSecurityBody>
<AuthBody>
<h2 className="mx_CompleteSecurity_header"> <h2 className="mx_CompleteSecurity_header">
{icon} {icon}
{title} {title}
@ -170,7 +212,7 @@ export default class CompleteSecurity extends React.Component {
<div className="mx_CompleteSecurity_body"> <div className="mx_CompleteSecurity_body">
{body} {body}
</div> </div>
</AuthBody> </CompleteSecurityBody>
</AuthPage> </AuthPage>
); );
} }

View file

@ -0,0 +1,50 @@
/*
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 AsyncWrapper from '../../../AsyncWrapper';
import * as sdk from '../../../index';
export default class E2eSetup extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
accountPassword: PropTypes.string,
};
constructor() {
super();
// awkwardly indented because https://github.com/eslint/eslint/issues/11310
this._createStorageDialogPromise =
import("../../../async-components/views/dialogs/secretstorage/CreateSecretStorageDialog");
}
render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
return (
<AuthPage>
<CompleteSecurityBody>
<AsyncWrapper prom={this._createStorageDialogPromise}
hasCancel={false}
onFinished={this.props.onFinished}
accountPassword={this.props.accountPassword}
/>
</CompleteSecurityBody>
</AuthPage>
);
}
}

View file

@ -58,6 +58,11 @@ export default createReactClass({
displayName: 'Login', displayName: 'Login',
propTypes: { propTypes: {
// Called when the user has logged in. Params:
// - The object returned by the login API
// - The user's password, if applicable, (may be cached in memory for a
// short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired, onLoggedIn: PropTypes.func.isRequired,
// If true, the component will consider itself busy. // If true, the component will consider itself busy.
@ -181,7 +186,7 @@ export default createReactClass({
username, phoneCountry, phoneNumber, password, username, phoneCountry, phoneNumber, password,
).then((data) => { ).then((data) => {
this.setState({serverIsAlive: true}); // it must be, we logged in. this.setState({serverIsAlive: true}); // it must be, we logged in.
this.props.onLoggedIn(data); this.props.onLoggedIn(data, password);
}, (error) => { }, (error) => {
if (this._unmounted) { if (this._unmounted) {
return; return;

View file

@ -45,7 +45,13 @@ export default createReactClass({
displayName: 'Registration', displayName: 'Registration',
propTypes: { propTypes: {
// Called when the user has logged in. Params:
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
// - The user's password, if available and applicable (may be cached in memory
// for a short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired, onLoggedIn: PropTypes.func.isRequired,
clientSecret: PropTypes.string, clientSecret: PropTypes.string,
sessionId: PropTypes.string, sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired, makeRegistrationUrl: PropTypes.func.isRequired,
@ -348,7 +354,7 @@ export default createReactClass({
homeserverUrl: this.state.matrixClient.getHomeserverUrl(), homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(), identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
accessToken: response.access_token, accessToken: response.access_token,
}); }, this.state.formVals.password);
this._setupPushers(cli); this._setupPushers(cli);
// we're still busy until we get unmounted: don't show the registration form again // we're still busy until we get unmounted: don't show the registration form again

View file

@ -62,7 +62,7 @@ export default createReactClass({
console.log("Loading recaptcha script..."); console.log("Loading recaptcha script...");
window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();}; window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();};
let protocol = global.location.protocol; let protocol = global.location.protocol;
if (protocol === "vector:") { if (protocol !== "http:") {
protocol = "https:"; protocol = "https:";
} }
const scriptTag = document.createElement('script'); const scriptTag = document.createElement('script');

View file

@ -0,0 +1,27 @@
/*
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.
*/
'use strict';
import React from 'react';
export default class CompleteSecurityBody extends React.PureComponent {
render() {
return <div className="mx_CompleteSecurityBody">
{ this.props.children }
</div>;
}
}

View file

@ -641,7 +641,7 @@ const AuthEntryComponents = [
TermsAuthEntry, TermsAuthEntry,
]; ];
export function getEntryComponentForLoginType(loginType) { export default function getEntryComponentForLoginType(loginType) {
for (const c of AuthEntryComponents) { for (const c of AuthEntryComponents) {
if (c.LOGIN_TYPE == loginType) { if (c.LOGIN_TYPE == loginType) {
return c; return c;

View file

@ -306,7 +306,7 @@ export default createReactClass({
return ( return (
<div> <div>
<MenuItem className="mx_RoomTileContextMenu_tag_field" onClick={this._onClickSettings}> <MenuItem className="mx_RoomTileContextMenu_tag_field" onClick={this._onClickSettings}>
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icons-settings-room.svg")} width="15" height="15" alt="" /> <img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/feather-customised/settings.svg")} width="15" height="15" alt="" />
{ _t('Settings') } { _t('Settings') }
</MenuItem> </MenuItem>
</div> </div>

View file

@ -27,7 +27,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {MenuItem} from "../../structures/ContextMenu"; import {MenuItem} from "../../structures/ContextMenu";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
export class TopLeftMenu extends React.Component { export default class TopLeftMenu extends React.Component {
static propTypes = { static propTypes = {
displayName: PropTypes.string.isRequired, displayName: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,

View file

@ -65,6 +65,9 @@ export default createReactClass({
// Title for the dialog. // Title for the dialog.
title: PropTypes.node.isRequired, title: PropTypes.node.isRequired,
// Path to an icon to put in the header
headerImage: PropTypes.string,
// children should be the content of the dialog // children should be the content of the dialog
children: PropTypes.node, children: PropTypes.node,
@ -110,6 +113,13 @@ export default createReactClass({
); );
} }
let headerImage;
if (this.props.headerImage) {
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage}
alt=""
/>;
}
return ( return (
<MatrixClientContext.Provider value={this._matrixClient}> <MatrixClientContext.Provider value={this._matrixClient}>
<FocusLock <FocusLock
@ -135,6 +145,7 @@ export default createReactClass({
'mx_Dialog_headerWithButton': !!this.props.headerButton, 'mx_Dialog_headerWithButton': !!this.props.headerButton,
})}> })}>
<div className={classNames('mx_Dialog_title', this.props.titleClass)} id='mx_BaseDialog_title'> <div className={classNames('mx_Dialog_title', this.props.titleClass)} id='mx_BaseDialog_title'>
{headerImage}
{ this.props.title } { this.props.title }
</div> </div>
{ this.props.headerButton } { this.props.headerButton }

View file

@ -1,5 +1,6 @@
/* /*
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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -44,13 +45,13 @@ export default createReactClass({
}, },
_roomCreateOptions() { _roomCreateOptions() {
const createOpts = {}; const opts = {};
const createOpts = opts.createOpts = {};
createOpts.name = this.state.name; createOpts.name = this.state.name;
if (this.state.isPublic) { if (this.state.isPublic) {
createOpts.visibility = "public"; createOpts.visibility = "public";
createOpts.preset = "public_chat"; createOpts.preset = "public_chat";
// to prevent createRoom from enabling guest access opts.guestAccess = false;
createOpts['initial_state'] = [];
const {alias} = this.state; const {alias} = this.state;
const localPart = alias.substr(1, alias.indexOf(":") - 1); const localPart = alias.substr(1, alias.indexOf(":") - 1);
createOpts['room_alias_name'] = localPart; createOpts['room_alias_name'] = localPart;
@ -61,7 +62,7 @@ export default createReactClass({
if (this.state.noFederate) { if (this.state.noFederate) {
createOpts.creation_content = {'m.federate': false}; createOpts.creation_content = {'m.federate': false};
} }
return createOpts; return opts;
}, },
componentDidMount() { componentDidMount() {

View file

@ -33,6 +33,7 @@ import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize"; import {humanizeTime} from "../../../utils/humanize";
import createRoom from "../../../createRoom"; import createRoom from "../../../createRoom";
import {inviteMultipleToRoom} from "../../../RoomInvite"; import {inviteMultipleToRoom} from "../../../RoomInvite";
import SettingsStore from '../../../settings/SettingsStore';
export const KIND_DM = "dm"; export const KIND_DM = "dm";
export const KIND_INVITE = "invite"; export const KIND_INVITE = "invite";
@ -337,19 +338,31 @@ export default class InviteDialog extends React.PureComponent {
const recents = []; const recents = [];
for (const userId in rooms) { for (const userId in rooms) {
// Filter out user IDs that are already in the room / should be excluded // Filter out user IDs that are already in the room / should be excluded
if (excludedTargetIds.includes(userId)) continue; if (excludedTargetIds.includes(userId)) {
console.warn(`[Invite:Recents] Excluding ${userId} from recents`);
continue;
}
const room = rooms[userId]; const room = rooms[userId];
const member = room.getMember(userId); const member = room.getMember(userId);
if (!member) continue; // just skip people who don't have memberships for some reason if (!member) {
// just skip people who don't have memberships for some reason
console.warn(`[Invite:Recents] ${userId} is missing a member object in their own DM (${room.roomId})`);
continue;
}
const lastEventTs = room.timeline && room.timeline.length const lastEventTs = room.timeline && room.timeline.length
? room.timeline[room.timeline.length - 1].getTs() ? room.timeline[room.timeline.length - 1].getTs()
: 0; : 0;
if (!lastEventTs) continue; // something weird is going on with this room if (!lastEventTs) {
// something weird is going on with this room
console.warn(`[Invite:Recents] ${userId} (${room.roomId}) has a weird last timestamp: ${lastEventTs}`);
continue;
}
recents.push({userId, user: member, lastActive: lastEventTs}); recents.push({userId, user: member, lastActive: lastEventTs});
} }
if (!recents) console.warn("[Invite:Recents] No recents to suggest!");
// Sort the recents by last active to save us time later // Sort the recents by last active to save us time later
recents.sort((a, b) => b.lastActive - a.lastActive); recents.sort((a, b) => b.lastActive - a.lastActive);
@ -493,7 +506,7 @@ export default class InviteDialog extends React.PureComponent {
return false; return false;
} }
_startDm = () => { _startDm = async () => {
this.setState({busy: true}); this.setState({busy: true});
const targetIds = this.state.targets.map(t => t.userId); const targetIds = this.state.targets.map(t => t.userId);
@ -510,14 +523,31 @@ export default class InviteDialog extends React.PureComponent {
return; return;
} }
const createRoomOptions = {};
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
// Check whether all users have uploaded device keys before.
// If so, enable encryption in the new room.
const client = MatrixClientPeg.get();
const usersToDevicesMap = await client.downloadKeys(targetIds);
const allHaveDeviceKeys = Object.values(usersToDevicesMap).every(devices => {
// `devices` is an object of the form { deviceId: deviceInfo, ... }.
return Object.keys(devices).length > 0;
});
if (allHaveDeviceKeys) {
createRoomOptions.encryption = true;
}
}
// Check if it's a traditional DM and create the room if required. // Check if it's a traditional DM and create the room if required.
// TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM // TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM
let createRoomPromise = Promise.resolve(); let createRoomPromise = Promise.resolve();
if (targetIds.length === 1) { if (targetIds.length === 1) {
createRoomPromise = createRoom({dmUserId: targetIds[0]}); createRoomOptions.dmUserId = targetIds[0];
createRoomPromise = createRoom(createRoomOptions);
} else { } else {
// Create a boring room and try to invite the targets manually. // Create a boring room and try to invite the targets manually.
createRoomPromise = createRoom().then(roomId => { createRoomPromise = createRoom(createRoomOptions).then(roomId => {
return inviteMultipleToRoom(roomId, targetIds); return inviteMultipleToRoom(roomId, targetIds);
}).then(result => { }).then(result => {
if (this._shouldAbortAfterInviteError(result)) { if (this._shouldAbortAfterInviteError(result)) {
@ -586,13 +616,36 @@ export default class InviteDialog extends React.PureComponent {
clearTimeout(this._debounceTimer); clearTimeout(this._debounceTimer);
} }
this._debounceTimer = setTimeout(async () => { this._debounceTimer = setTimeout(async () => {
MatrixClientPeg.get().searchUserDirectory({term}).then(r => { MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
if (term !== this.state.filterText) { if (term !== this.state.filterText) {
// Discard the results - we were probably too slow on the server-side to make // Discard the results - we were probably too slow on the server-side to make
// these results useful. This is a race we want to avoid because we could overwrite // these results useful. This is a race we want to avoid because we could overwrite
// more accurate results. // more accurate results.
return; return;
} }
if (!r.results) r.results = [];
// While we're here, try and autocomplete a search result for the mxid itself
// if there's no matches (and the input looks like a mxid).
if (term[0] === '@' && term.indexOf(':') > 1 && r.results.length === 0) {
try {
const profile = await MatrixClientPeg.get().getProfileInfo(term);
if (profile) {
// If we have a profile, we have enough information to assume that
// the mxid can be invited - add it to the list
r.results.push({
user_id: term,
display_name: profile['displayname'],
avatar_url: profile['avatar_url'],
});
}
} catch (e) {
console.warn("Non-fatal error trying to make an invite for a user ID");
console.warn(e);
}
}
this.setState({ this.setState({
serverResultsMixin: r.results.map(u => ({ serverResultsMixin: r.results.map(u => ({
userId: u.user_id, userId: u.user_id,
@ -672,11 +725,16 @@ export default class InviteDialog extends React.PureComponent {
}; };
_toggleMember = (member: Member) => { _toggleMember = (member: Member) => {
let filterText = this.state.filterText;
const targets = this.state.targets.map(t => t); // cheap clone for mutation const targets = this.state.targets.map(t => t); // cheap clone for mutation
const idx = targets.indexOf(member); const idx = targets.indexOf(member);
if (idx >= 0) targets.splice(idx, 1); if (idx >= 0) {
else targets.push(member); targets.splice(idx, 1);
this.setState({targets}); } else {
targets.push(member);
filterText = ""; // clear the filter when the user accepts a suggestion
}
this.setState({targets, filterText});
}; };
_removeMember = (member: Member) => { _removeMember = (member: Member) => {
@ -876,7 +934,7 @@ export default class InviteDialog extends React.PureComponent {
key={"input"} key={"input"}
rows={1} rows={1}
onChange={this._updateFilter} onChange={this._updateFilter}
defaultValue={this.state.filterText} value={this.state.filterText}
ref={this._editorRef} ref={this._editorRef}
onPaste={this._onPaste} onPaste={this._onPaste}
/> />
@ -944,7 +1002,7 @@ export default class InviteDialog extends React.PureComponent {
title = _t("Direct Messages"); title = _t("Direct Messages");
helpText = _t( helpText = _t(
"If you can't find someone, ask them for their username, or share your " + "If you can't find someone, ask them for their username, share your " +
"username (%(userId)s) or <a>profile link</a>.", "username (%(userId)s) or <a>profile link</a>.",
{userId}, {userId},
{a: (sub) => <a href={makeUserPermalink(userId)} rel="noopener" target="_blank">{sub}</a>}, {a: (sub) => <a href={makeUserPermalink(userId)} rel="noopener" target="_blank">{sub}</a>},
@ -970,7 +1028,7 @@ export default class InviteDialog extends React.PureComponent {
title={title} title={title}
> >
<div className='mx_InviteDialog_content'> <div className='mx_InviteDialog_content'>
<p>{helpText}</p> <p className='mx_InviteDialog_helpText'>{helpText}</p>
<div className='mx_InviteDialog_addressBar'> <div className='mx_InviteDialog_addressBar'>
{this._renderEditor()} {this._renderEditor()}
<div className='mx_InviteDialog_buttonAndSpinner'> <div className='mx_InviteDialog_buttonAndSpinner'>
@ -987,9 +1045,11 @@ export default class InviteDialog extends React.PureComponent {
</div> </div>
{this._renderIdentityServerWarning()} {this._renderIdentityServerWarning()}
<div className='error'>{this.state.errorText}</div> <div className='error'>{this.state.errorText}</div>
<div className='mx_InviteDialog_userSections'>
{this._renderSection('recents')} {this._renderSection('recents')}
{this._renderSection('suggestions')} {this._renderSection('suggestions')}
</div> </div>
</div>
</BaseDialog> </BaseDialog>
); );
} }

View file

@ -20,6 +20,8 @@ import { _t } from '../../../languageHandler';
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import {MatrixEvent} from "matrix-js-sdk"; import {MatrixEvent} from "matrix-js-sdk";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import SdkConfig from '../../../SdkConfig';
import Markdown from '../../../Markdown';
/* /*
* A dialog for reporting an event. * A dialog for reporting an event.
@ -95,6 +97,15 @@ export default class ReportEventDialog extends PureComponent {
); );
} }
const adminMessageMD =
SdkConfig.get().reportEvent &&
SdkConfig.get().reportEvent.adminMessageMD;
let adminMessage;
if (adminMessageMD) {
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
}
return ( return (
<BaseDialog <BaseDialog
className="mx_BugReportDialog" className="mx_BugReportDialog"
@ -110,7 +121,7 @@ export default class ReportEventDialog extends PureComponent {
"administrator will not be able to read the message text or view any files or images.") "administrator will not be able to read the message text or view any files or images.")
} }
</p> </p>
{adminMessage}
<Field <Field
id="mx_ReportEventDialog_reason" id="mx_ReportEventDialog_reason"
className="mx_ReportEventDialog_reason" className="mx_ReportEventDialog_reason"

View file

@ -16,6 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../../index'; import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import { MatrixClient } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
@ -32,6 +33,16 @@ const RESTORE_TYPE_SECRET_STORAGE = 2;
* Dialog for restoring e2e keys from a backup and the user's recovery key * Dialog for restoring e2e keys from a backup and the user's recovery key
*/ */
export default class RestoreKeyBackupDialog extends React.PureComponent { 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,
};
defaultProps = {
showSummary: true,
};
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
@ -96,6 +107,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword( const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword(
this.state.passPhrase, undefined, undefined, this.state.backupInfo, this.state.passPhrase, undefined, undefined, this.state.backupInfo,
); );
if (!this.props.showSummary) {
this.props.onFinished(true);
return;
}
this.setState({ this.setState({
loading: false, loading: false,
recoverInfo, recoverInfo,
@ -119,6 +134,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey( const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey(
this.state.recoveryKey, undefined, undefined, this.state.backupInfo, this.state.recoveryKey, undefined, undefined, this.state.backupInfo,
); );
if (!this.props.showSummary) {
this.props.onFinished(true);
return;
}
this.setState({ this.setState({
loading: false, loading: false,
recoverInfo, recoverInfo,
@ -253,6 +272,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
title = _t("Error"); title = _t("Error");
content = _t("No backup found!"); content = _t("No backup found!");
} else if (this.state.recoverInfo) { } else if (this.state.recoverInfo) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
title = _t("Backup Restored"); title = _t("Backup Restored");
let failedToDecrypt; let failedToDecrypt;
if (this.state.recoverInfo.total > this.state.recoverInfo.imported) { if (this.state.recoverInfo.total > this.state.recoverInfo.imported) {
@ -264,6 +284,11 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
content = <div> content = <div>
<p>{_t("Restored %(sessionCount)s session keys", {sessionCount: this.state.recoverInfo.imported})}</p> <p>{_t("Restored %(sessionCount)s session keys", {sessionCount: this.state.recoverInfo.imported})}</p>
{failedToDecrypt} {failedToDecrypt}
<DialogButtons primaryButton={_t('OK')}
onPrimaryButtonClick={this._onDone}
hasCancel={false}
focus={true}
/>
</div>; </div>;
} else if (backupHasPassphrase && !this.state.forceRecoveryKey) { } else if (backupHasPassphrase && !this.state.forceRecoveryKey) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');

View file

@ -34,12 +34,19 @@ export default createReactClass({
// A node to insert into the cancel button instead of default "Cancel" // A node to insert into the cancel button instead of default "Cancel"
cancelButton: PropTypes.node, 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. // onClick handler for the primary button.
onPrimaryButtonClick: PropTypes.func.isRequired, onPrimaryButtonClick: PropTypes.func,
// should there be a cancel button? default: true // should there be a cancel button? default: true
hasCancel: PropTypes.bool, 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. // onClick handler for the cancel button.
onCancel: PropTypes.func, onCancel: PropTypes.func,
@ -69,16 +76,26 @@ export default createReactClass({
primaryButtonClassName += " " + this.props.primaryButtonClass; primaryButtonClassName += " " + this.props.primaryButtonClass;
} }
let cancelButton; let cancelButton;
if (this.props.cancelButton || this.props.hasCancel) { if (this.props.cancelButton || this.props.hasCancel) {
cancelButton = <button onClick={this._onCancelClick} disabled={this.props.disabled}> cancelButton = <button
// important: the default type is 'submit' and this button comes before the
// primary in the DOM so will get form submissions unless we make it not a submit.
type="button"
onClick={this._onCancelClick}
className={this.props.cancelButtonClass}
disabled={this.props.disabled}
>
{ this.props.cancelButton || _t("Cancel") } { this.props.cancelButton || _t("Cancel") }
</button>; </button>;
} }
return ( return (
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
{ cancelButton } { cancelButton }
{ this.props.children } { this.props.children }
<button className={primaryButtonClassName} <button type={this.props.primaryIsSubmit ? 'submit' : 'button'}
className={primaryButtonClassName}
onClick={this.props.onPrimaryButtonClick} onClick={this.props.onPrimaryButtonClick}
autoFocus={this.props.focus} autoFocus={this.props.focus}
disabled={this.props.disabled || this.props.primaryDisabled} disabled={this.props.disabled || this.props.primaryDisabled}

View file

@ -0,0 +1,56 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import {replaceableComponent} from "../../../../utils/replaceableComponent";
import * as qs from "qs";
import QRCode from "qrcode-react";
@replaceableComponent("views.elements.crypto.VerificationQRCode")
export default class VerificationQRCode extends React.PureComponent {
static propTypes = {
// Common for all kinds of QR codes
keys: PropTypes.array.isRequired, // array of [Key ID, Base64 Key] pairs
action: PropTypes.string.isRequired,
keyholderUserId: PropTypes.string.isRequired,
// User verification use case only
secret: PropTypes.string,
otherUserKey: PropTypes.string, // Base64 key being verified
requestEventId: PropTypes.string,
};
static defaultProps = {
action: "verify",
};
render() {
const query = {
request: this.props.requestEventId,
action: this.props.action,
other_user_key: this.props.otherUserKey,
secret: this.props.secret,
};
for (const key of this.props.keys) {
query[`key_${key[0]}`] = key[1];
}
const uri = `https://matrix.to/#/${this.props.keyholderUserId}?${qs.stringify(query)}`;
return <QRCode value={uri} size={256} logoWidth={48} logo={require("../../../../../res/img/matrix-m.svg")} />;
}
}

View file

@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as recent from './recent'; import * as recent from '../../../emojipicker/recent';
import {DATA_BY_CATEGORY, getEmojiFromUnicode} from "../../../emoji"; import {DATA_BY_CATEGORY, getEmojiFromUnicode} from "../../../emoji";
export const CATEGORY_HEADER_HEIGHT = 22; export const CATEGORY_HEADER_HEIGHT = 22;

View file

@ -26,6 +26,7 @@ import classNames from 'classnames';
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu"; import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
// XXX this class copies a lot from RoomTile.js // XXX this class copies a lot from RoomTile.js
export default createReactClass({ export default createReactClass({
@ -127,7 +128,8 @@ export default createReactClass({
'mx_RoomTile_badgeShown': this.state.badgeHover || isMenuDisplayed, 'mx_RoomTile_badgeShown': this.state.badgeHover || isMenuDisplayed,
}); });
const label = <div title={this.props.group.groupId} className={nameClasses} dir="auto"> // XXX: this is a workaround for Firefox giving this div a tabstop :( [tabIndex]
const label = <div title={this.props.group.groupId} className={nameClasses} tabIndex={-1} dir="auto">
{ groupName } { groupName }
</div>; </div>;
@ -137,16 +139,6 @@ export default createReactClass({
}); });
const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!'; const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!';
const badge = (
<ContextMenuButton
className={badgeClasses}
onClick={this.onContextMenuButtonClick}
label={_t("Options")}
isExpanded={isMenuDisplayed}
>
{ badgeContent }
</ContextMenuButton>
);
let tooltip; let tooltip;
if (this.props.collapsed && this.state.hover) { if (this.props.collapsed && this.state.hover) {
@ -171,7 +163,12 @@ export default createReactClass({
} }
return <React.Fragment> return <React.Fragment>
<RovingTabIndexWrapper>
{({onFocus, isActive, ref}) =>
<AccessibleButton <AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
className={classes} className={classes}
onClick={this.onClick} onClick={this.onClick}
onMouseEnter={this.onMouseEnter} onMouseEnter={this.onMouseEnter}
@ -183,10 +180,20 @@ export default createReactClass({
</div> </div>
<div className="mx_RoomTile_nameContainer"> <div className="mx_RoomTile_nameContainer">
{ label } { label }
{ badge } <ContextMenuButton
className={badgeClasses}
onClick={this.onContextMenuButtonClick}
label={_t("Options")}
isExpanded={isMenuDisplayed}
tabIndex={isActive ? 0 : -1}
>
{ badgeContent }
</ContextMenuButton>
</div> </div>
{ tooltip } { tooltip }
</AccessibleButton> </AccessibleButton>
}
</RovingTabIndexWrapper>
{ contextMenu } { contextMenu }
</React.Fragment>; </React.Fragment>;

View file

@ -93,7 +93,7 @@ export default class MKeyVerificationConclusion extends React.Component {
} }
if (title) { if (title) {
const subtitle = userLabelForEventRoom(request.otherUserId, mxEvent); const subtitle = userLabelForEventRoom(request.otherUserId, mxEvent.getRoomId());
const classes = classNames("mx_EventTile_bubble", "mx_KeyVerification", "mx_KeyVerification_icon", { const classes = classNames("mx_EventTile_bubble", "mx_KeyVerification", "mx_KeyVerification_icon", {
mx_KeyVerification_icon_verified: request.done, mx_KeyVerification_icon_verified: request.done,
}); });

View file

@ -85,7 +85,7 @@ export default class MKeyVerificationRequest extends React.Component {
if (userId === myUserId) { if (userId === myUserId) {
return _t("You accepted"); return _t("You accepted");
} else { } else {
return _t("%(name)s accepted", {name: getNameForEventRoom(userId, this.props.mxEvent)}); return _t("%(name)s accepted", {name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId())});
} }
} }
@ -95,7 +95,7 @@ export default class MKeyVerificationRequest extends React.Component {
if (userId === myUserId) { if (userId === myUserId) {
return _t("You cancelled"); return _t("You cancelled");
} else { } else {
return _t("%(name)s cancelled", {name: getNameForEventRoom(userId, this.props.mxEvent)}); return _t("%(name)s cancelled", {name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId())});
} }
} }
@ -128,10 +128,11 @@ export default class MKeyVerificationRequest extends React.Component {
} }
if (!request.initiatedByMe) { if (!request.initiatedByMe) {
const name = getNameForEventRoom(request.requestingUserId, mxEvent.getRoomId());
title = (<div className="mx_KeyVerification_title">{ title = (<div className="mx_KeyVerification_title">{
_t("%(name)s wants to verify", {name: getNameForEventRoom(request.requestingUserId, mxEvent)})}</div>); _t("%(name)s wants to verify", {name})}</div>);
subtitle = (<div className="mx_KeyVerification_subtitle">{ subtitle = (<div className="mx_KeyVerification_subtitle">{
userLabelForEventRoom(request.requestingUserId, mxEvent)}</div>); userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId())}</div>);
if (request.requested && !request.observeOnly) { if (request.requested && !request.observeOnly) {
stateNode = (<div className="mx_KeyVerification_buttons"> stateNode = (<div className="mx_KeyVerification_buttons">
<FormButton kind="danger" onClick={this._onRejectClicked} label={_t("Decline")} /> <FormButton kind="danger" onClick={this._onRejectClicked} label={_t("Decline")} />
@ -142,7 +143,7 @@ export default class MKeyVerificationRequest extends React.Component {
title = (<div className="mx_KeyVerification_title">{ title = (<div className="mx_KeyVerification_title">{
_t("You sent a verification request")}</div>); _t("You sent a verification request")}</div>);
subtitle = (<div className="mx_KeyVerification_subtitle">{ subtitle = (<div className="mx_KeyVerification_subtitle">{
userLabelForEventRoom(request.receivingUserId, mxEvent)}</div>); userLabelForEventRoom(request.receivingUserId, mxEvent.getRoomId())}</div>);
} }
if (title) { if (title) {

View file

@ -82,7 +82,7 @@ const _getE2EStatus = (cli, userId, devices) => {
return "warning"; return "warning";
}; };
function openDMForUser(matrixClient, userId) { async function openDMForUser(matrixClient, userId) {
const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId); const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId);
const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => { const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
const room = matrixClient.getRoom(roomId); const room = matrixClient.getRoom(roomId);
@ -100,9 +100,27 @@ function openDMForUser(matrixClient, userId) {
action: 'view_room', action: 'view_room',
room_id: lastActiveRoom.roomId, room_id: lastActiveRoom.roomId,
}); });
} else { return;
createRoom({dmUserId: userId});
} }
const createRoomOptions = {
dmUserId: userId,
};
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
// Check whether all users have uploaded device keys before.
// If so, enable encryption in the new room.
const usersToDevicesMap = await matrixClient.downloadKeys([userId]);
const allHaveDeviceKeys = Object.values(usersToDevicesMap).every(devices => {
// `devices` is an object of the form { deviceId: deviceInfo, ... }.
return Object.keys(devices).length > 0;
});
if (allHaveDeviceKeys) {
createRoomOptions.encryption = true;
}
}
createRoom(createRoomOptions);
} }
function useIsEncrypted(cli, room) { function useIsEncrypted(cli, room) {
@ -1219,10 +1237,9 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
let closeButton; let closeButton;
if (onClose) { if (onClose) {
closeButton = <AccessibleButton closeButton = <AccessibleButton className="mx_UserInfo_cancel" onClick={onClose} title={_t('Close')}>
className="mx_UserInfo_cancel" <div />
onClick={onClose} </AccessibleButton>;
title={_t('Close')} />;
} }
const memberDetails = ( const memberDetails = (
@ -1308,15 +1325,18 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
userTrust.isVerified(); userTrust.isVerified();
const isMe = user.userId === cli.getUserId(); const isMe = user.userId === cli.getUserId();
let verifyButton; let verifyButton;
if (!userVerified && !isMe) { if (isRoomEncrypted && !userVerified && !isMe) {
verifyButton = <AccessibleButton className="mx_UserInfo_verify" onClick={() => verifyUser(user)}> verifyButton = <AccessibleButton className="mx_UserInfo_verify" onClick={() => verifyUser(user)}>
{_t("Verify")} {_t("Verify")}
</AccessibleButton>; </AccessibleButton>;
} }
const devicesSection = <DevicesSection let devicesSection;
if (isRoomEncrypted) {
devicesSection = <DevicesSection
loading={devices === undefined} loading={devices === undefined}
devices={devices} userId={user.userId} />; devices={devices} userId={user.userId} />;
}
const securitySection = ( const securitySection = (
<div className="mx_UserInfo_container"> <div className="mx_UserInfo_container">
@ -1335,6 +1355,7 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
return ( return (
<div className="mx_UserInfo" role="tabpanel"> <div className="mx_UserInfo" role="tabpanel">
<AutoHideScrollbar className="mx_UserInfo_scrollContainer">
{ closeButton } { closeButton }
{ avatarElement } { avatarElement }
@ -1360,7 +1381,6 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
</div> </div>
</div> } </div> }
<AutoHideScrollbar className="mx_UserInfo_scrollContainer">
{ securitySection } { securitySection }
<UserOptionsSection <UserOptionsSection
devices={devices} devices={devices}

View file

@ -17,6 +17,9 @@ limitations under the License.
import React from 'react'; import React from 'react';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import {verificationMethods} from 'matrix-js-sdk/src/crypto'; import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import VerificationQRCode from "../elements/crypto/VerificationQRCode";
import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
export default class VerificationPanel extends React.PureComponent { export default class VerificationPanel extends React.PureComponent {
constructor(props) { constructor(props) {
@ -36,7 +39,8 @@ export default class VerificationPanel extends React.PureComponent {
renderStatus() { renderStatus() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Spinner = sdk.getComponent('elements.Spinner'); const Spinner = sdk.getComponent('elements.Spinner');
const {request} = this.props; const {request: req} = this.props;
const request: VerificationRequest = req;
if (request.requested) { if (request.requested) {
return (<p>Waiting for {request.otherUserId} to accept ... <Spinner /></p>); return (<p>Waiting for {request.otherUserId} to accept ... <Spinner /></p>);
@ -44,6 +48,24 @@ export default class VerificationPanel extends React.PureComponent {
const verifyButton = <AccessibleButton kind="primary" onClick={this._startSAS}> const verifyButton = <AccessibleButton kind="primary" onClick={this._startSAS}>
Verify by emoji Verify by emoji
</AccessibleButton>; </AccessibleButton>;
const crossSigningInfo = MatrixClientPeg.get().getStoredCrossSigningForUser(request.otherUserId);
const myKeyId = MatrixClientPeg.get().getCrossSigningId();
if (request.requestEvent && request.requestEvent.getId() && crossSigningInfo) {
const qrCodeKeys = [
[MatrixClientPeg.get().getDeviceId(), MatrixClientPeg.get().getDeviceEd25519Key()],
[myKeyId, myKeyId],
];
const qrCode = <VerificationQRCode
keyholderUserId={MatrixClientPeg.get().getUserId()}
requestEventId={request.requestEvent.getId()}
otherUserKey={crossSigningInfo.getId("master")}
secret={request.encodedSharedSecret}
keys={qrCodeKeys}
/>;
return (<p>{request.otherUserId} is ready, start {verifyButton} or have them scan: {qrCode}</p>);
}
return (<p>{request.otherUserId} is ready, start {verifyButton}</p>); return (<p>{request.otherUserId} is ready, start {verifyButton}</p>);
} else if (request.started) { } else if (request.started) {
if (this.state.sasWaitingForOtherParty) { if (this.state.sasWaitingForOtherParty) {

View file

@ -209,6 +209,7 @@ export default class BasicMessageEditor extends React.Component {
const range = getRangeForSelection(this._editorRef, model, selection); const range = getRangeForSelection(this._editorRef, model, selection);
const selectedParts = range.parts.map(p => p.serialize()); const selectedParts = range.parts.map(p => p.serialize());
event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts)); event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts));
event.clipboardData.setData("text/plain", text); // so plain copy/paste works
if (type === "cut") { if (type === "cut") {
// Remove the text, updating the model as appropriate // Remove the text, updating the model as appropriate
this._modifiedFlag = true; this._modifiedFlag = true;

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,76 +15,102 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useState} from "react";
import PropTypes from "prop-types";
import classNames from 'classnames'; import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import SettingsStore from '../../../settings/SettingsStore';
export default function(props) { import {_t, _td} from '../../../languageHandler';
const { isUser } = props; import {useFeatureEnabled} from "../../../hooks/useSettings";
const isNormal = props.status === "normal"; import AccessibleButton from "../elements/AccessibleButton";
const isWarning = props.status === "warning"; import Tooltip from "../elements/Tooltip";
const isVerified = props.status === "verified";
const e2eIconClasses = classNames({ export const E2E_STATE = {
VERIFIED: "verified",
WARNING: "warning",
UNKNOWN: "unknown",
NORMAL: "normal",
};
const crossSigningUserTitles = {
[E2E_STATE.WARNING]: _td("This user has not verified all of their devices."),
[E2E_STATE.NORMAL]: _td("You have not verified this user. This user has verified all of their devices."),
[E2E_STATE.VERIFIED]: _td("You have verified this user. This user has verified all of their devices."),
};
const crossSigningRoomTitles = {
[E2E_STATE.WARNING]: _td("Someone is using an unknown device"),
[E2E_STATE.NORMAL]: _td("This room is end-to-end encrypted"),
[E2E_STATE.VERIFIED]: _td("Everyone in this room is verified"),
};
const legacyUserTitles = {
[E2E_STATE.WARNING]: _td("Some devices for this user are not trusted"),
[E2E_STATE.VERIFIED]: _td("All devices for this user are trusted"),
};
const legacyRoomTitles = {
[E2E_STATE.WARNING]: _td("Some devices in this encrypted room are not trusted"),
[E2E_STATE.VERIFIED]: _td("All devices in this encrypted room are trusted"),
};
const E2EIcon = ({isUser, status, className, size, onClick}) => {
const [hover, setHover] = useState(false);
const classes = classNames({
mx_E2EIcon: true, mx_E2EIcon: true,
mx_E2EIcon_warning: isWarning, mx_E2EIcon_warning: status === E2E_STATE.WARNING,
mx_E2EIcon_normal: isNormal, mx_E2EIcon_normal: status === E2E_STATE.NORMAL,
mx_E2EIcon_verified: isVerified, mx_E2EIcon_verified: status === E2E_STATE.VERIFIED,
}, props.className); }, className);
let e2eTitle; let e2eTitle;
const crossSigning = useFeatureEnabled("feature_cross_signing");
const crossSigning = SettingsStore.isFeatureEnabled("feature_cross_signing");
if (crossSigning && isUser) { if (crossSigning && isUser) {
if (isWarning) { e2eTitle = crossSigningUserTitles[status];
e2eTitle = _t(
"This user has not verified all of their devices.",
);
} else if (isNormal) {
e2eTitle = _t(
"You have not verified this user. " +
"This user has verified all of their devices.",
);
} else if (isVerified) {
e2eTitle = _t(
"You have verified this user. " +
"This user has verified all of their devices.",
);
}
} else if (crossSigning && !isUser) { } else if (crossSigning && !isUser) {
if (isWarning) { e2eTitle = crossSigningRoomTitles[status];
e2eTitle = _t(
"Some users in this encrypted room are not verified by you or " +
"they have not verified their own devices.",
);
} else if (isVerified) {
e2eTitle = _t(
"All users in this encrypted room are verified by you and " +
"they have verified their own devices.",
);
}
} else if (!crossSigning && isUser) { } else if (!crossSigning && isUser) {
if (isWarning) { e2eTitle = legacyUserTitles[status];
e2eTitle = _t("Some devices for this user are not trusted");
} else if (isVerified) {
e2eTitle = _t("All devices for this user are trusted");
}
} else if (!crossSigning && !isUser) { } else if (!crossSigning && !isUser) {
if (isWarning) { e2eTitle = legacyRoomTitles[status];
e2eTitle = _t("Some devices in this encrypted room are not trusted");
} else if (isVerified) {
e2eTitle = _t("All devices in this encrypted room are trusted");
}
} }
let style = null; let style;
if (props.size) { if (size) {
style = {width: `${props.size}px`, height: `${props.size}px`}; style = {width: `${size}px`, height: `${size}px`};
} }
const icon = (<div className={e2eIconClasses} style={style} title={e2eTitle} />); const onMouseOver = () => setHover(true);
if (props.onClick) { const onMouseOut = () => setHover(false);
return (<AccessibleButton onClick={props.onClick}>{ icon }</AccessibleButton>);
} else { let tip;
return icon; if (hover) {
tip = <Tooltip label={e2eTitle ? _t(e2eTitle) : ""} />;
} }
if (onClick) {
return (
<AccessibleButton
onClick={onClick}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
className={classes}
style={style}
>
{ tip }
</AccessibleButton>
);
} }
return <div onMouseOver={onMouseOver} onMouseOut={onMouseOut} className={classes} style={style}>
{ tip }
</div>;
};
E2EIcon.propTypes = {
isUser: PropTypes.bool,
status: PropTypes.oneOf(Object.values(E2E_STATE)),
className: PropTypes.string,
size: PropTypes.number,
onClick: PropTypes.func,
};
export default E2EIcon;

View file

@ -33,6 +33,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {ALL_RULE_TYPES} from "../../../mjolnir/BanList"; import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
import * as ObjectUtils from "../../../ObjectUtils"; import * as ObjectUtils from "../../../ObjectUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {E2E_STATE} from "./E2EIcon";
const eventTileTypes = { const eventTileTypes = {
'm.room.message': 'messages.MessageEvent', 'm.room.message': 'messages.MessageEvent',
@ -235,6 +236,7 @@ export default createReactClass({
this._suppressReadReceiptAnimation = false; this._suppressReadReceiptAnimation = false;
const client = this.context; const client = this.context;
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged); client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this._onDecrypted); this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
if (this.props.showReactions) { if (this.props.showReactions) {
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated); this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
@ -260,6 +262,7 @@ export default createReactClass({
componentWillUnmount: function() { componentWillUnmount: function() {
const client = this.context; const client = this.context;
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted); this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
if (this.props.showReactions) { if (this.props.showReactions) {
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated); this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
@ -282,18 +285,56 @@ export default createReactClass({
} }
}, },
onUserVerificationChanged: function(userId, _trustStatus) {
if (userId === this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent);
}
},
_verifyEvent: async function(mxEvent) { _verifyEvent: async function(mxEvent) {
if (!mxEvent.isEncrypted()) { if (!mxEvent.isEncrypted()) {
return; return;
} }
// If we directly trust the device, short-circuit here
const verified = await this.context.isEventSenderVerified(mxEvent); const verified = await this.context.isEventSenderVerified(mxEvent);
if (verified) {
this.setState({ this.setState({
verified: verified, verified: E2E_STATE.VERIFIED,
}, () => { }, () => {
// Decryption may have caused a change in size // Decryption may have caused a change in size
this.props.onHeightChanged(); this.props.onHeightChanged();
}); });
return;
}
// If cross-signing is off, the old behaviour is to scream at the user
// as if they've done something wrong, which they haven't
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) {
this.setState({
verified: E2E_STATE.WARNING,
}, this.props.onHeightChanged);
return;
}
if (!this.context.checkUserTrust(mxEvent.getSender()).isCrossSigningVerified()) {
this.setState({
verified: E2E_STATE.NORMAL,
}, this.props.onHeightChanged);
return;
}
const eventSenderTrust = await this.context.checkEventSenderTrust(mxEvent);
if (!eventSenderTrust) {
this.setState({
verified: E2E_STATE.UNKNOWN,
}, this.props.onHeightChanged); // Decryption may have cause a change in size
return;
}
this.setState({
verified: eventSenderTrust.isVerified() ? E2E_STATE.VERIFIED : E2E_STATE.WARNING,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
}, },
_propsEqual: function(objA, objB) { _propsEqual: function(objA, objB) {
@ -473,8 +514,12 @@ export default createReactClass({
// event is encrypted, display padlock corresponding to whether or not it is verified // event is encrypted, display padlock corresponding to whether or not it is verified
if (ev.isEncrypted()) { if (ev.isEncrypted()) {
if (this.state.verified) { if (this.state.verified === E2E_STATE.NORMAL) {
return; // no icon if we've not even cross-signed the user
} else if (this.state.verified === E2E_STATE.VERIFIED) {
return; // no icon for verified return; // no icon for verified
} else if (this.state.verified === E2E_STATE.UNKNOWN) {
return (<E2ePadlockUnknown />);
} else { } else {
return (<E2ePadlockUnverified />); return (<E2ePadlockUnverified />);
} }
@ -527,6 +572,7 @@ export default createReactClass({
console.error("EventTile attempted to get relations for an event without an ID"); console.error("EventTile attempted to get relations for an event without an ID");
// Use event's special `toJSON` method to log key data. // Use event's special `toJSON` method to log key data.
console.log(JSON.stringify(this.props.mxEvent, null, 4)); console.log(JSON.stringify(this.props.mxEvent, null, 4));
console.trace("Stacktrace for https://github.com/vector-im/riot-web/issues/11120");
} }
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction"); return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
}, },
@ -604,8 +650,9 @@ export default createReactClass({
mx_EventTile_last: this.props.last, mx_EventTile_last: this.props.last,
mx_EventTile_contextual: this.props.contextual, mx_EventTile_contextual: this.props.contextual,
mx_EventTile_actionBarFocused: this.state.actionBarFocused, mx_EventTile_actionBarFocused: this.state.actionBarFocused,
mx_EventTile_verified: !isBubbleMessage && this.state.verified === true, mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2E_STATE.VERIFIED,
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === false, mx_EventTile_unverified: !isBubbleMessage && this.state.verified === E2E_STATE.WARNING,
mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2E_STATE.UNKNOWN,
mx_EventTile_bad: isEncryptionFailure, mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === 'm.emote', mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_redacted: isRedacted, mx_EventTile_redacted: isRedacted,
@ -901,6 +948,12 @@ function E2ePadlockUnencrypted(props) {
); );
} }
function E2ePadlockUnknown(props) {
return (
<E2ePadlock title={_t("Encrypted by a deleted device")} icon="unknown" {...props} />
);
}
class E2ePadlock extends React.Component { class E2ePadlock extends React.Component {
static propTypes = { static propTypes = {
icon: PropTypes.string.isRequired, icon: PropTypes.string.isRequired,

View file

@ -0,0 +1,51 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
export default class InviteOnlyIcon extends React.Component {
constructor() {
super();
this.state = {
hover: false,
};
}
onHoverStart = () => {
this.setState({hover: true});
};
onHoverEnd = () => {
this.setState({hover: false});
};
render() {
const Tooltip = sdk.getComponent("elements.Tooltip");
let tooltip;
if (this.state.hover) {
tooltip = <Tooltip className="mx_InviteOnlyIcon_tooltip" label={_t("Invite only")} dir="auto" />;
}
return (<div className="mx_InviteOnlyIcon"
onMouseEnter={this.onHoverStart}
onMouseLeave={this.onHoverEnd}
>
{ tooltip }
</div>);
}
}

View file

@ -26,6 +26,7 @@ import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks'; import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
import ContentMessages from '../../../ContentMessages'; import ContentMessages from '../../../ContentMessages';
import E2EIcon from './E2EIcon'; import E2EIcon from './E2EIcon';
import SettingsStore from "../../../settings/SettingsStore";
function ComposerAvatar(props) { function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@ -168,7 +169,6 @@ export default class MessageComposer extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.onInputStateChanged = this.onInputStateChanged.bind(this); this.onInputStateChanged = this.onInputStateChanged.bind(this);
this.onEvent = this.onEvent.bind(this);
this._onRoomStateEvents = this._onRoomStateEvents.bind(this); this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this); this._onTombstoneClick = this._onTombstoneClick.bind(this);
@ -182,11 +182,6 @@ export default class MessageComposer extends React.Component {
} }
componentDidMount() { componentDidMount() {
// N.B. using 'event' rather than 'RoomEvents' otherwise the crypto handler
// for 'event' fires *after* 'RoomEvent', and our room won't have yet been
// marked as encrypted.
// XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something.
MatrixClientPeg.get().on("event", this.onEvent);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._waitForOwnMember(); this._waitForOwnMember();
@ -210,7 +205,6 @@ export default class MessageComposer extends React.Component {
componentWillUnmount() { componentWillUnmount() {
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("event", this.onEvent);
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents); MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
} }
if (this._roomStoreToken) { if (this._roomStoreToken) {
@ -218,13 +212,6 @@ export default class MessageComposer extends React.Component {
} }
} }
onEvent(event) {
if (event.getType() !== 'm.room.encryption') return;
if (event.getRoomId() !== this.props.room.roomId) return;
// TODO: put (encryption state??) in state
this.forceUpdate();
}
_onRoomStateEvents(ev, state) { _onRoomStateEvents(ev, state) {
if (ev.getRoomId() !== this.props.room.roomId) return; if (ev.getRoomId() !== this.props.room.roomId) return;
@ -282,21 +269,36 @@ export default class MessageComposer extends React.Component {
} }
renderPlaceholderText() { renderPlaceholderText() {
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
if (this.state.isQuoting) { if (this.state.isQuoting) {
if (roomIsEncrypted) { if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
} else {
return _t('Send a reply…');
}
} else {
if (this.props.e2eStatus) {
return _t('Send an encrypted message…');
} else {
return _t('Send a message…');
}
}
} else {
if (this.state.isQuoting) {
if (this.props.e2eStatus) {
return _t('Send an encrypted reply…'); return _t('Send an encrypted reply…');
} else { } else {
return _t('Send a reply (unencrypted)…'); return _t('Send a reply (unencrypted)…');
} }
} else { } else {
if (roomIsEncrypted) { if (this.props.e2eStatus) {
return _t('Send an encrypted message…'); return _t('Send an encrypted message…');
} else { } else {
return _t('Send a message (unencrypted)…'); return _t('Send a message (unencrypted)…');
} }
} }
} }
}
render() { render() {
const controls = [ const controls = [

View file

@ -363,7 +363,7 @@ export default class RoomBreadcrumbs extends React.Component {
} }
let dmIndicator; let dmIndicator;
if (this._isDmRoom(r.room)) { if (this._isDmRoom(r.room) && !SettingsStore.isFeatureEnabled("feature_cross_signing")) {
dmIndicator = <img dmIndicator = <img
src={require("../../../../res/img/icon_person.svg")} src={require("../../../../res/img/icon_person.svg")}
className="mx_RoomBreadcrumbs_dmIndicator" className="mx_RoomBreadcrumbs_dmIndicator"

View file

@ -31,7 +31,9 @@ import ManageIntegsButton from '../elements/ManageIntegsButton';
import {CancelButton} from './SimpleRoomHeader'; import {CancelButton} from './SimpleRoomHeader';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
import DMRoomMap from '../../../utils/DMRoomMap';
import E2EIcon from './E2EIcon'; import E2EIcon from './E2EIcon';
import InviteOnlyIcon from './InviteOnlyIcon';
export default createReactClass({ export default createReactClass({
displayName: 'RoomHeader', displayName: 'RoomHeader',
@ -160,13 +162,16 @@ export default createReactClass({
<E2EIcon status={this.props.e2eStatus} /> : <E2EIcon status={this.props.e2eStatus} /> :
undefined; undefined;
const dmUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
const joinRules = this.props.room && this.props.room.currentState.getStateEvents("m.room.join_rules", ""); const joinRules = this.props.room && this.props.room.currentState.getStateEvents("m.room.join_rules", "");
const joinRule = joinRules && joinRules.getContent().join_rule; const joinRule = joinRules && joinRules.getContent().join_rule;
const joinRuleClass = classNames("mx_RoomHeader_PrivateIcon", let privateIcon;
{"mx_RoomHeader_isPrivate": joinRule === "invite"}); // Don't show an invite-only icon for DMs. Users know they're invite-only.
const privateIcon = SettingsStore.isFeatureEnabled("feature_cross_signing") ? if (!dmUserId && SettingsStore.isFeatureEnabled("feature_cross_signing")) {
<div className={joinRuleClass} /> : if (joinRule == "invite") {
undefined; privateIcon = <InviteOnlyIcon />;
}
}
if (this.props.onCancelClick) { if (this.props.onCancelClick) {
cancelButton = <CancelButton onClick={this.props.onCancelClick} />; cancelButton = <CancelButton onClick={this.props.onCancelClick} />;
@ -310,8 +315,7 @@ export default createReactClass({
return ( return (
<div className="mx_RoomHeader light-panel"> <div className="mx_RoomHeader light-panel">
<div className="mx_RoomHeader_wrapper"> <div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div> <div className="mx_RoomHeader_avatar">{ roomAvatar }{ e2eIcon }</div>
{ e2eIcon }
{ privateIcon } { privateIcon }
{ name } { name }
{ topicElement } { topicElement }

View file

@ -39,6 +39,7 @@ import * as sdk from "../../../index";
import * as Receipt from "../../../utils/Receipt"; import * as Receipt from "../../../utils/Receipt";
import {Resizer} from '../../../resizer'; import {Resizer} from '../../../resizer';
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2'; import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
const HIDE_CONFERENCE_CHANS = true; const HIDE_CONFERENCE_CHANS = true;
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/; const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
@ -718,7 +719,7 @@ export default createReactClass({
}, },
{ {
list: this.state.lists['im.vector.fake.direct'], list: this.state.lists['im.vector.fake.direct'],
label: _t('People'), label: _t('Direct Messages'),
tagName: "im.vector.fake.direct", tagName: "im.vector.fake.direct",
order: "recent", order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'), incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'),
@ -776,10 +777,12 @@ export default createReactClass({
const subListComponents = this._mapSubListProps(subLists); const subListComponents = this._mapSubListProps(subLists);
const {resizeNotifier, collapsed, searchFilter, ConferenceHandler, ...props} = this.props; // eslint-disable-line const {resizeNotifier, collapsed, searchFilter, ConferenceHandler, onKeyDown, ...props} = this.props; // eslint-disable-line
return ( return (
<div <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({onKeyDownHandler}) => <div
{...props} {...props}
onKeyDown={onKeyDownHandler}
ref={this._collectResizeContainer} ref={this._collectResizeContainer}
className="mx_RoomList" className="mx_RoomList"
role="tree" role="tree"
@ -788,7 +791,8 @@ export default createReactClass({
onMouseLeave={this.onMouseLeave} onMouseLeave={this.onMouseLeave}
> >
{ subListComponents } { subListComponents }
</div> </div> }
</RovingTabIndexProvider>
); );
}, },
}); });

View file

@ -49,6 +49,7 @@ export default createReactClass({
propTypes: { propTypes: {
onJoinClick: PropTypes.func, onJoinClick: PropTypes.func,
onRejectClick: PropTypes.func, onRejectClick: PropTypes.func,
onRejectAndIgnoreClick: PropTypes.func,
onForgetClick: PropTypes.func, onForgetClick: PropTypes.func,
// if inviterName is specified, the preview bar will shown an invite to the room. // if inviterName is specified, the preview bar will shown an invite to the room.
// You should also specify onRejectClick if specifiying inviterName // You should also specify onRejectClick if specifiying inviterName
@ -282,6 +283,7 @@ export default createReactClass({
render: function() { render: function() {
const Spinner = sdk.getComponent('elements.Spinner'); const Spinner = sdk.getComponent('elements.Spinner');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let showSpinner = false; let showSpinner = false;
let darkStyle = false; let darkStyle = false;
@ -292,6 +294,7 @@ export default createReactClass({
let secondaryActionHandler; let secondaryActionHandler;
let secondaryActionLabel; let secondaryActionLabel;
let footer; let footer;
const extraComponents = [];
const messageCase = this._getMessageCase(); const messageCase = this._getMessageCase();
switch (messageCase) { switch (messageCase) {
@ -469,6 +472,14 @@ export default createReactClass({
primaryActionHandler = this.props.onJoinClick; primaryActionHandler = this.props.onJoinClick;
secondaryActionLabel = _t("Reject"); secondaryActionLabel = _t("Reject");
secondaryActionHandler = this.props.onRejectClick; secondaryActionHandler = this.props.onRejectClick;
if (this.props.onRejectAndIgnoreClick) {
extraComponents.push(
<AccessibleButton kind="secondary" onClick={this.props.onRejectAndIgnoreClick} key="ignore">
{ _t("Reject & Ignore user") }
</AccessibleButton>,
);
}
break; break;
} }
case MessageCase.ViewingRoom: { case MessageCase.ViewingRoom: {
@ -505,8 +516,6 @@ export default createReactClass({
} }
} }
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let subTitleElements; let subTitleElements;
if (subTitle) { if (subTitle) {
if (!Array.isArray(subTitle)) { if (!Array.isArray(subTitle)) {
@ -554,6 +563,7 @@ export default createReactClass({
</div> </div>
<div className="mx_RoomPreviewBar_actions"> <div className="mx_RoomPreviewBar_actions">
{ secondaryButton } { secondaryButton }
{ extraComponents }
{ primaryButton } { primaryButton }
</div> </div>
<div className="mx_RoomPreviewBar_footer"> <div className="mx_RoomPreviewBar_footer">

View file

@ -32,6 +32,11 @@ import ActiveRoomObserver from '../../../ActiveRoomObserver';
import RoomViewStore from '../../../stores/RoomViewStore'; import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
import E2EIcon from './E2EIcon';
import InviteOnlyIcon from './InviteOnlyIcon';
// eslint-disable-next-line camelcase
import rate_limited_func from '../../../ratelimitedfunc';
export default createReactClass({ export default createReactClass({
displayName: 'RoomTile', displayName: 'RoomTile',
@ -69,6 +74,7 @@ export default createReactClass({
notificationCount: this.props.room.getUnreadNotificationCount(), notificationCount: this.props.room.getUnreadNotificationCount(),
selected: this.props.room.roomId === RoomViewStore.getRoomId(), selected: this.props.room.roomId === RoomViewStore.getRoomId(),
statusMessage: this._getStatusMessage(), statusMessage: this._getStatusMessage(),
e2eStatus: null,
}); });
}, },
@ -101,6 +107,83 @@ export default createReactClass({
return statusUser._unstable_statusMessage; return statusUser._unstable_statusMessage;
}, },
onRoomStateMember: function(ev, state, member) {
// we only care about leaving users
// because trust state will change if someone joins a megolm session anyway
if (member.membership !== "leave") {
return;
}
// ignore members in other rooms
if (member.roomId !== this.props.room.roomId) {
return;
}
this._updateE2eStatus();
},
onUserVerificationChanged: function(userId, _trustStatus) {
if (!this.props.room.getMember(userId)) {
// Not in this room
return;
}
this._updateE2eStatus();
},
onRoomTimeline: function(ev, room) {
if (!room) return;
if (room.roomId != this.props.room.roomId) return;
if (ev.getType() !== "m.room.encryption") return;
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
this.onFindingRoomToBeEncrypted();
},
onFindingRoomToBeEncrypted: function() {
const cli = MatrixClientPeg.get();
cli.on("RoomState.members", this.onRoomStateMember);
cli.on("userTrustStatusChanged", this.onUserVerificationChanged);
this._updateE2eStatus();
},
_updateE2eStatus: async function() {
const cli = MatrixClientPeg.get();
if (!cli.isRoomEncrypted(this.props.room.roomId)) {
return;
}
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) {
return;
}
// Duplication between here and _updateE2eStatus in RoomView
const e2eMembers = await this.props.room.getEncryptionTargetMembers();
const verified = [];
const unverified = [];
e2eMembers.map(({userId}) => userId)
.filter((userId) => userId !== cli.getUserId())
.forEach((userId) => {
(cli.checkUserTrust(userId).isCrossSigningVerified() ?
verified : unverified).push(userId);
});
/* Check all verified user devices. */
for (const userId of [...verified, cli.getUserId()]) {
const devices = await cli.getStoredDevicesForUser(userId);
const allDevicesVerified = devices.every(({deviceId}) => {
return cli.checkDeviceTrust(userId, deviceId).isVerified();
});
if (!allDevicesVerified) {
this.setState({
e2eStatus: "warning",
});
return;
}
}
this.setState({
e2eStatus: unverified.length === 0 ? "verified" : "normal",
});
},
onRoomName: function(room) { onRoomName: function(room) {
if (room !== this.props.room) return; if (room !== this.props.room) return;
this.setState({ this.setState({
@ -150,10 +233,19 @@ export default createReactClass({
}, },
componentDidMount: function() { componentDidMount: function() {
/* We bind here rather than in the definition because otherwise we wind up with the
method only being callable once every 500ms across all instances, which would be wrong */
this._updateE2eStatus = rate_limited_func(this._updateE2eStatus, 500);
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
cli.on("accountData", this.onAccountData); cli.on("accountData", this.onAccountData);
cli.on("Room.name", this.onRoomName); cli.on("Room.name", this.onRoomName);
cli.on("RoomState.events", this.onJoinRule); cli.on("RoomState.events", this.onJoinRule);
if (cli.isRoomEncrypted(this.props.room.roomId)) {
this.onFindingRoomToBeEncrypted();
} else {
cli.on("Room.timeline", this.onRoomTimeline);
}
ActiveRoomObserver.addListener(this.props.room.roomId, this._onActiveRoomChange); ActiveRoomObserver.addListener(this.props.room.roomId, this._onActiveRoomChange);
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
@ -171,6 +263,9 @@ export default createReactClass({
MatrixClientPeg.get().removeListener("accountData", this.onAccountData); MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
cli.removeListener("RoomState.events", this.onJoinRule); cli.removeListener("RoomState.events", this.onJoinRule);
cli.removeListener("RoomState.members", this.onRoomStateMember);
cli.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
cli.removeListener("Room.timeline", this.onRoomTimeline);
} }
ActiveRoomObserver.removeListener(this.props.room.roomId, this._onActiveRoomChange); ActiveRoomObserver.removeListener(this.props.room.roomId, this._onActiveRoomChange);
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
@ -317,7 +412,6 @@ export default createReactClass({
'mx_RoomTile_noBadges': !badges, 'mx_RoomTile_noBadges': !badges,
'mx_RoomTile_transparent': this.props.transparent, 'mx_RoomTile_transparent': this.props.transparent,
'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed, 'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
'mx_RoomTile_isPrivate': this.state.joinRule == "invite" && !dmUserId,
}); });
const avatarClasses = classNames({ const avatarClasses = classNames({
@ -352,7 +446,8 @@ export default createReactClass({
}); });
subtextLabel = subtext ? <span className="mx_RoomTile_subtext">{ subtext }</span> : null; subtextLabel = subtext ? <span className="mx_RoomTile_subtext">{ subtext }</span> : null;
label = <div title={name} className={nameClasses} dir="auto">{ name }</div>; // XXX: this is a workaround for Firefox giving this div a tabstop :( [tabIndex]
label = <div title={name} className={nameClasses} tabIndex={-1} dir="auto">{ name }</div>;
} else if (this.state.hover) { } else if (this.state.hover) {
const Tooltip = sdk.getComponent("elements.Tooltip"); const Tooltip = sdk.getComponent("elements.Tooltip");
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto" />; tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto" />;
@ -383,7 +478,9 @@ export default createReactClass({
let dmIndicator; let dmIndicator;
let dmOnline; let dmOnline;
if (dmUserId) { /* Post-cross-signing we don't show DM indicators at all, instead relying on user
context to let them know when that is. */
if (dmUserId && !SettingsStore.isFeatureEnabled("feature_cross_signing")) {
dmIndicator = <img dmIndicator = <img
src={require("../../../../res/img/icon_person.svg")} src={require("../../../../res/img/icon_person.svg")}
className="mx_RoomTile_dm" className="mx_RoomTile_dm"
@ -428,12 +525,23 @@ export default createReactClass({
let privateIcon = null; let privateIcon = null;
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
privateIcon = <div className="mx_RoomTile_PrivateIcon" />; if (this.state.joinRule == "invite" && !dmUserId) {
privateIcon = <InviteOnlyIcon />;
}
}
let e2eIcon = null;
if (this.state.e2eStatus) {
e2eIcon = <E2EIcon status={this.state.e2eStatus} className="mx_RoomTile_e2eIcon" />;
} }
return <React.Fragment> return <React.Fragment>
<RovingTabIndexWrapper>
{({onFocus, isActive, ref}) =>
<AccessibleButton <AccessibleButton
tabIndex="0" onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
className={classes} className={classes}
onClick={this.onClick} onClick={this.onClick}
onMouseEnter={this.onMouseEnter} onMouseEnter={this.onMouseEnter}
@ -447,6 +555,7 @@ export default createReactClass({
<div className="mx_RoomTile_avatar_container"> <div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24} /> <RoomAvatar room={this.props.room} width={24} height={24} />
{ dmIndicator } { dmIndicator }
{ e2eIcon }
</div> </div>
</div> </div>
{ privateIcon } { privateIcon }
@ -462,6 +571,8 @@ export default createReactClass({
{ /* { incomingCallBox } */ } { /* { incomingCallBox } */ }
{ tooltip } { tooltip }
</AccessibleButton> </AccessibleButton>
}
</RovingTabIndexWrapper>
{ contextMenu } { contextMenu }
</React.Fragment>; </React.Fragment>;

View file

@ -24,6 +24,8 @@ import {
containsEmote, containsEmote,
stripEmoteCommand, stripEmoteCommand,
unescapeMessage, unescapeMessage,
startsWith,
stripPrefix,
} from '../../../editor/serialize'; } from '../../../editor/serialize';
import {CommandPartCreator} from '../../../editor/parts'; import {CommandPartCreator} from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer"; import BasicMessageComposer from "./BasicMessageComposer";
@ -33,7 +35,7 @@ import ReplyThread from "../elements/ReplyThread";
import {parseEvent} from '../../../editor/deserialize'; import {parseEvent} from '../../../editor/deserialize';
import {findEditableEvent} from '../../../utils/EventUtils'; import {findEditableEvent} from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager"; import SendHistoryManager from "../../../SendHistoryManager";
import {processCommandInput} from '../../../SlashCommands'; import {getCommand} from '../../../SlashCommands';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import {_t, _td} from '../../../languageHandler'; import {_t, _td} from '../../../languageHandler';
@ -56,11 +58,15 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
} }
} }
function createMessageContent(model, permalinkCreator) { // exported for tests
export function createMessageContent(model, permalinkCreator) {
const isEmote = containsEmote(model); const isEmote = containsEmote(model);
if (isEmote) { if (isEmote) {
model = stripEmoteCommand(model); model = stripEmoteCommand(model);
} }
if (startsWith(model, "//")) {
model = stripPrefix(model, "/");
}
model = unescapeMessage(model); model = unescapeMessage(model);
const repliedToEvent = RoomViewStore.getQuotingEvent(); const repliedToEvent = RoomViewStore.getQuotingEvent();
@ -175,20 +181,21 @@ export default class SendMessageComposer extends React.Component {
const parts = this.model.parts; const parts = this.model.parts;
const firstPart = parts[0]; const firstPart = parts[0];
if (firstPart) { if (firstPart) {
if (firstPart.type === "command") { if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true; return true;
} }
// be extra resilient when somehow the AutocompleteWrapperModel or // be extra resilient when somehow the AutocompleteWrapperModel or
// CommandPartCreator fails to insert a command part, so we don't send // CommandPartCreator fails to insert a command part, so we don't send
// a command as a message // a command as a message
if (firstPart.text.startsWith("/") && (firstPart.type === "plain" || firstPart.type === "pill-candidate")) { if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
return true; return true;
} }
} }
return false; return false;
} }
async _runSlashCommand() { _getSlashCommand() {
const commandText = this.model.parts.reduce((text, part) => { const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command // use mxid to textify user pills in a command
if (part.type === "user-pill") { if (part.type === "user-pill") {
@ -196,9 +203,11 @@ export default class SendMessageComposer extends React.Component {
} }
return text + part.text; return text + part.text;
}, ""); }, "");
const cmd = processCommandInput(this.props.room.roomId, commandText); return [getCommand(this.props.room.roomId, commandText), commandText];
}
if (cmd) { async _runSlashCommand(fn) {
const cmd = fn();
let error = cmd.error; let error = cmd.error;
if (cmd.promise) { if (cmd.promise) {
try { try {
@ -231,15 +240,49 @@ export default class SendMessageComposer extends React.Component {
console.log("Command success."); console.log("Command success.");
} }
} }
}
_sendMessage() { async _sendMessage() {
if (this.model.isEmpty) { if (this.model.isEmpty) {
return; return;
} }
let shouldSend = true;
if (!containsEmote(this.model) && this._isSlashCommand()) { if (!containsEmote(this.model) && this._isSlashCommand()) {
this._runSlashCommand(); const [cmd, commandText] = this._getSlashCommand();
if (cmd) {
shouldSend = false;
this._runSlashCommand(cmd);
} else { } else {
// ask the user if their unknown command should be sent as a message
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
title: _t("Unknown Command"),
description: <div>
<p>
{ _t("Unrecognised command: %(commandText)s", {commandText}) }
</p>
<p>
{ _t("You can use <code>/help</code> to list available commands. " +
"Did you mean to send this as a message?", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
<p>
{ _t("Hint: Begin your message with <code>//</code> to start it with a slash.", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
</div>,
button: _t('Send as message'),
});
const [sendAnyway] = await finished;
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
}
}
if (shouldSend) {
const isReply = !!RoomViewStore.getQuotingEvent(); const isReply = !!RoomViewStore.getQuotingEvent();
const {roomId} = this.props.room; const {roomId} = this.props.room;
const content = createMessageContent(this.model, this.props.permalinkCreator); const content = createMessageContent(this.model, this.props.permalinkCreator);
@ -253,6 +296,7 @@ export default class SendMessageComposer extends React.Component {
}); });
} }
} }
this.sendHistoryManager.save(this.model); this.sendHistoryManager.save(this.model);
// clear composer // clear composer
this.model.reset([]); this.model.reset([]);

View file

@ -0,0 +1,187 @@
/*
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 { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from '../../../Modal';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import {formatBytes, formatCountLong} from "../../../utils/FormattingUtils";
import EventIndexPeg from "../../../indexing/EventIndexPeg";
export default class EventIndexPanel extends React.Component {
constructor() {
super();
this.state = {
enabling: false,
eventIndexSize: 0,
roomCount: 0,
eventIndexingEnabled:
SettingsStore.getValueAt(SettingLevel.DEVICE, 'enableEventIndexing'),
};
}
async updateCurrentRoom(room) {
const eventIndex = EventIndexPeg.get();
const stats = await eventIndex.getStats();
this.setState({
eventIndexSize: stats.size,
roomCount: stats.roomCount,
});
}
componentWillUnmount(): void {
const eventIndex = EventIndexPeg.get();
if (eventIndex !== null) {
eventIndex.removeListener("changedCheckpoint", this.updateCurrentRoom.bind(this));
}
}
async componentWillMount(): void {
this.updateState();
}
async updateState() {
const eventIndex = EventIndexPeg.get();
const eventIndexingEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, 'enableEventIndexing');
const enabling = false;
let eventIndexSize = 0;
let roomCount = 0;
if (eventIndex !== null) {
eventIndex.on("changedCheckpoint", this.updateCurrentRoom.bind(this));
const stats = await eventIndex.getStats();
eventIndexSize = stats.size;
roomCount = stats.roomCount;
}
this.setState({
enabling,
eventIndexSize,
roomCount,
eventIndexingEnabled,
});
}
_onManage = async () => {
Modal.createTrackedDialogAsync('Message search', 'Message search',
import('../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog'),
{
onFinished: () => {},
}, null, /* priority = */ false, /* static = */ true,
);
}
_onEnable = async () => {
this.setState({
enabling: true,
});
await EventIndexPeg.initEventIndex();
await EventIndexPeg.get().addInitialCheckpoints();
await EventIndexPeg.get().startCrawler();
await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, true);
await this.updateState();
}
render() {
let eventIndexingSettings = null;
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
if (EventIndexPeg.get() !== null) {
eventIndexingSettings = (
<div>
<div className='mx_SettingsTab_subsectionText'>
{_t( "Securely cache encrypted messages locally for them " +
"to appear in search results, using ")
} {formatBytes(this.state.eventIndexSize, 0)}
{_t( " to store messages from ")}
{formatCountLong(this.state.roomCount)} {_t("rooms.")}
</div>
<div>
<AccessibleButton kind="primary" onClick={this._onManage}>
{_t("Manage")}
</AccessibleButton>
</div>
</div>
);
} else if (!this.state.eventIndexingEnabled && EventIndexPeg.supportIsInstalled()) {
eventIndexingSettings = (
<div>
<div className='mx_SettingsTab_subsectionText'>
{_t( "Securely cache encrypted messages locally for them to " +
"appear in search results.")}
</div>
<div>
<AccessibleButton kind="primary" disabled={this.state.enabling}
onClick={this._onEnable}>
{_t("Enable")}
</AccessibleButton>
{this.state.enabling ? <InlineSpinner /> : <div />}
</div>
</div>
);
} else if (EventIndexPeg.platformHasSupport() && !EventIndexPeg.supportIsInstalled()) {
const nativeLink = (
"https://github.com/vector-im/riot-web/blob/develop/" +
"docs/native-node-modules.md#" +
"adding-seshat-for-search-in-e2e-encrypted-rooms"
);
eventIndexingSettings = (
<div>
{
_t( "Riot is missing some components required for securely " +
"caching encrypted messages locally. If you'd like to " +
"experiment with this feature, build a custom Riot Desktop " +
"with <nativeLink>search components added</nativeLink>.",
{},
{
'nativeLink': (sub) => <a href={nativeLink} target="_blank"
rel="noopener">{sub}</a>,
},
)
}
</div>
);
} else {
eventIndexingSettings = (
<div>
{
_t( "Riot can't securely cache encrypted messages locally " +
"while running in a web browser. Use <riotLink>Riot Desktop</riotLink> " +
"for encrypted messages to appear in search results.",
{},
{
'riotLink': (sub) => <a href="https://riot.im/download/desktop"
target="_blank" rel="noopener">{sub}</a>,
},
)
}
</div>
);
}
return eventIndexingSettings;
}
}

View file

@ -70,7 +70,16 @@ export default class GeneralUserSettingsTab extends React.Component {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const serverSupportsSeparateAddAndBind = await cli.doesServerSupportSeparateAddAndBind(); const serverSupportsSeparateAddAndBind = await cli.doesServerSupportSeparateAddAndBind();
this.setState({serverSupportsSeparateAddAndBind});
const capabilities = await cli.getCapabilities(); // this is cached
const changePasswordCap = capabilities['m.change_password'];
// You can change your password so long as the capability isn't explicitly disabled. The implicit
// behaviour is you can change your password when the capability is missing or has not-false as
// the enabled flag value.
const canChangePassword = !changePasswordCap || changePasswordCap['enabled'] !== false;
this.setState({serverSupportsSeparateAddAndBind, canChangePassword});
this._getThreepidState(); this._getThreepidState();
} }
@ -280,7 +289,7 @@ export default class GeneralUserSettingsTab extends React.Component {
const PhoneNumbers = sdk.getComponent("views.settings.account.PhoneNumbers"); const PhoneNumbers = sdk.getComponent("views.settings.account.PhoneNumbers");
const Spinner = sdk.getComponent("views.elements.Spinner"); const Spinner = sdk.getComponent("views.elements.Spinner");
const passwordChangeForm = ( let passwordChangeForm = (
<ChangePassword <ChangePassword
className="mx_GeneralUserSettingsTab_changePassword" className="mx_GeneralUserSettingsTab_changePassword"
rowClassName="" rowClassName=""
@ -314,11 +323,18 @@ export default class GeneralUserSettingsTab extends React.Component {
threepidSection = <Spinner />; threepidSection = <Spinner />;
} }
let passwordChangeText = _t("Set a new account password...");
if (!this.state.canChangePassword) {
// Just don't show anything if you can't do anything.
passwordChangeText = null;
passwordChangeForm = null;
}
return ( return (
<div className="mx_SettingsTab_section mx_GeneralUserSettingsTab_accountSection"> <div className="mx_SettingsTab_section mx_GeneralUserSettingsTab_accountSection">
<span className="mx_SettingsTab_subheading">{_t("Account")}</span> <span className="mx_SettingsTab_subheading">{_t("Account")}</span>
<p className="mx_SettingsTab_subsectionText"> <p className="mx_SettingsTab_subsectionText">
{_t("Set a new account password...")} {passwordChangeText}
</p> </p>
{passwordChangeForm} {passwordChangeForm}
{threepidSection} {threepidSection}

View file

@ -21,15 +21,10 @@ import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import SdkConfig from "../../../../../SdkConfig"; import SdkConfig from "../../../../../SdkConfig";
import createRoom from "../../../../../createRoom"; import createRoom from "../../../../../createRoom";
import packageJson from "../../../../../../package.json";
import Modal from "../../../../../Modal"; import Modal from "../../../../../Modal";
import * as sdk from "../../../../../"; import * as sdk from "../../../../../";
import PlatformPeg from "../../../../../PlatformPeg"; import PlatformPeg from "../../../../../PlatformPeg";
// if this looks like a release, use the 'version' from package.json; else use
// the git sha. Prepend version with v, to look like riot-web version
const REACT_SDK_VERSION = 'dist' in packageJson ? packageJson.version : packageJson.gitHead || '<local>';
// Simple method to help prettify GH Release Tags and Commit Hashes. // Simple method to help prettify GH Release Tags and Commit Hashes.
const semVerRegex = /^v?(\d+\.\d+\.\d+(?:-rc.+)?)(?:-(?:\d+-g)?([0-9a-fA-F]+))?(?:-dirty)?$/i; const semVerRegex = /^v?(\d+\.\d+\.\d+(?:-rc.+)?)(?:-(?:\d+-g)?([0-9a-fA-F]+))?(?:-dirty)?$/i;
const ghVersionLabel = function(repo, token='') { const ghVersionLabel = function(repo, token='') {
@ -188,9 +183,6 @@ export default class HelpUserSettingsTab extends React.Component {
); );
} }
const reactSdkVersion = REACT_SDK_VERSION !== '<local>'
? ghVersionLabel('matrix-org/matrix-react-sdk', REACT_SDK_VERSION)
: REACT_SDK_VERSION;
const vectorVersion = this.state.vectorVersion const vectorVersion = this.state.vectorVersion
? ghVersionLabel('vector-im/riot-web', this.state.vectorVersion) ? ghVersionLabel('vector-im/riot-web', this.state.vectorVersion)
: 'unknown'; : 'unknown';
@ -243,7 +235,6 @@ export default class HelpUserSettingsTab extends React.Component {
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'> <div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
<span className='mx_SettingsTab_subheading'>{_t("Versions")}</span> <span className='mx_SettingsTab_subheading'>{_t("Versions")}</span>
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>
{_t("matrix-react-sdk version:")} {reactSdkVersion}<br />
{_t("riot-web version:")} {vectorVersion}<br /> {_t("riot-web version:")} {vectorVersion}<br />
{_t("olm version:")} {olmVersion}<br /> {_t("olm version:")} {olmVersion}<br />
{updateButton} {updateButton}

View file

@ -170,6 +170,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
return ( return (
<div className="mx_SettingsTab mx_PreferencesUserSettingsTab"> <div className="mx_SettingsTab mx_PreferencesUserSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Preferences")}</div> <div className="mx_SettingsTab_heading">{_t("Preferences")}</div>
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Composer")}</span> <span className="mx_SettingsTab_subheading">{_t("Composer")}</span>
{this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)} {this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}

View file

@ -242,6 +242,7 @@ export default class SecurityUserSettingsTab extends React.Component {
render() { render() {
const DevicesPanel = sdk.getComponent('views.settings.DevicesPanel'); const DevicesPanel = sdk.getComponent('views.settings.DevicesPanel');
const SettingsFlag = sdk.getComponent('views.elements.SettingsFlag'); const SettingsFlag = sdk.getComponent('views.elements.SettingsFlag');
const EventIndexPanel = sdk.getComponent('views.settings.EventIndexPanel');
const KeyBackupPanel = sdk.getComponent('views.settings.KeyBackupPanel'); const KeyBackupPanel = sdk.getComponent('views.settings.KeyBackupPanel');
const keyBackup = ( const keyBackup = (
@ -253,6 +254,16 @@ export default class SecurityUserSettingsTab extends React.Component {
</div> </div>
); );
let eventIndex;
if (SettingsStore.isFeatureEnabled("feature_event_indexing")) {
eventIndex = (
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Message search")}</span>
<EventIndexPanel />
</div>
);
}
// XXX: There's no such panel in the current cross-signing designs, but // XXX: There's no such panel in the current cross-signing designs, but
// it's useful to have for testing the feature. If there's no interest // it's useful to have for testing the feature. If there's no interest
// in having advanced details here once all flows are implemented, we // in having advanced details here once all flows are implemented, we
@ -281,6 +292,7 @@ export default class SecurityUserSettingsTab extends React.Component {
</div> </div>
</div> </div>
{keyBackup} {keyBackup}
{eventIndex}
{crossSigning} {crossSigning}
{this._renderCurrentDeviceInfo()} {this._renderCurrentDeviceInfo()}
<div className='mx_SettingsTab_section'> <div className='mx_SettingsTab_section'>

View file

@ -32,7 +32,7 @@ export default class VerifySessionToast extends React.PureComponent {
DeviceListener.sharedInstance().dismissVerification(this.props.deviceId); DeviceListener.sharedInstance().dismissVerification(this.props.deviceId);
}; };
_onVerifyClick = async () => { _onReviewClick = async () => {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog');
@ -47,10 +47,10 @@ export default class VerifySessionToast extends React.PureComponent {
render() { render() {
const FormButton = sdk.getComponent("elements.FormButton"); const FormButton = sdk.getComponent("elements.FormButton");
return (<div> return (<div>
<div className="mx_Toast_description">{_t("Other users may not trust it")}</div> <div className="mx_Toast_description">{_t("Review & verify your new session")}</div>
<div className="mx_Toast_buttons" aria-live="off"> <div className="mx_Toast_buttons" aria-live="off">
<FormButton label={_t("Later")} kind="danger" onClick={this._onLaterClick} /> <FormButton label={_t("Later")} kind="danger" onClick={this._onLaterClick} />
<FormButton label={_t("Verify")} onClick={this._onVerifyClick} /> <FormButton label={_t("Review")} onClick={this._onReviewClick} />
</div> </div>
</div>); </div>);
} }

View file

@ -0,0 +1,68 @@
/*
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 * as sdk from "../../../index";
import { _t } from '../../../languageHandler';
import DeviceListener from '../../../DeviceListener';
import { accessSecretStorage } from '../../../CrossSigningManager';
export default class SetupEncryptionToast extends React.PureComponent {
static propTypes = {
toastKey: PropTypes.string.isRequired,
kind: PropTypes.oneOf(['set_up_encryption', 'verify_this_session', 'upgrade_encryption']).isRequired,
};
_onLaterClick = () => {
DeviceListener.sharedInstance().dismissEncryptionSetup();
};
_onSetupClick = async () => {
accessSecretStorage();
};
getDescription() {
switch (this.props.kind) {
case 'set_up_encryption':
case 'upgrade_encryption':
return _t('Verify your other devices easier');
case 'verify_this_session':
return _t('Other users may not trust it');
}
}
getSetupCaption() {
switch (this.props.kind) {
case 'set_up_encryption':
case 'upgrade_encryption':
return _t('Upgrade');
case 'verify_this_session':
return _t('Verify');
}
}
render() {
const FormButton = sdk.getComponent("elements.FormButton");
return (<div>
<div className="mx_Toast_description">{this.getDescription()}</div>
<div className="mx_Toast_buttons" aria-live="off">
<FormButton label={_t("Later")} kind="danger" onClick={this._onLaterClick} />
<FormButton label={this.getSetupCaption()} onClick={this._onSetupClick} />
</div>
</div>);
}
}

View file

@ -23,6 +23,7 @@ import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases";
import {userLabelForEventRoom} from "../../../utils/KeyVerificationStateObserver"; import {userLabelForEventRoom} from "../../../utils/KeyVerificationStateObserver";
import dis from "../../../dispatcher"; import dis from "../../../dispatcher";
import ToastStore from "../../../stores/ToastStore"; import ToastStore from "../../../stores/ToastStore";
import Modal from "../../../Modal";
export default class VerificationRequestToast extends React.PureComponent { export default class VerificationRequestToast extends React.PureComponent {
constructor(props) { constructor(props) {
@ -38,6 +39,13 @@ export default class VerificationRequestToast extends React.PureComponent {
this.setState({counter}); this.setState({counter});
}, 1000); }, 1000);
request.on("change", this._checkRequestIsPending); request.on("change", this._checkRequestIsPending);
// We should probably have a separate class managing the active verification toasts,
// rather than monitoring this in the toast component itself, since we'll get problems
// like the toasdt not going away when the verification is cancelled unless it's the
// one on the top (ie. the one that's mounted).
// As a quick & dirty fix, check the toast is still relevant when it mounts (this prevents
// a toast hanging around after logging in if you did a verification as part of login).
this._checkRequestIsPending();
} }
componentWillUnmount() { componentWillUnmount() {
@ -65,22 +73,27 @@ export default class VerificationRequestToast extends React.PureComponent {
accept = async () => { accept = async () => {
ToastStore.sharedInstance().dismissToast(this.props.toastKey); ToastStore.sharedInstance().dismissToast(this.props.toastKey);
const {request} = this.props; const {request} = this.props;
const {event} = request;
// no room id for to_device requests // no room id for to_device requests
if (event.getRoomId()) { try {
if (request.channel.roomId) {
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: event.getRoomId(), room_id: request.channel.roomId,
should_peek: false, should_peek: false,
}); });
}
try {
await request.accept(); await request.accept();
dis.dispatch({ dis.dispatch({
action: "set_right_panel_phase", action: "set_right_panel_phase",
phase: RIGHT_PANEL_PHASES.EncryptionPanel, phase: RIGHT_PANEL_PHASES.EncryptionPanel,
refireParams: {verificationRequest: request}, refireParams: {verificationRequest: request},
}); });
} else if (request.channel.deviceId && request.verifier) {
// show to_device verifications in dialog still
const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog");
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
verifier: request.verifier,
}, null, /* priority = */ false, /* static = */ true);
}
} catch (err) { } catch (err) {
console.error(err.message); console.error(err.message);
} }
@ -89,13 +102,13 @@ export default class VerificationRequestToast extends React.PureComponent {
render() { render() {
const FormButton = sdk.getComponent("elements.FormButton"); const FormButton = sdk.getComponent("elements.FormButton");
const {request} = this.props; const {request} = this.props;
const {event} = request;
const userId = request.otherUserId; const userId = request.otherUserId;
let nameLabel = event.getRoomId() ? userLabelForEventRoom(userId, event) : userId; const roomId = request.channel.roomId;
let nameLabel = roomId ? userLabelForEventRoom(userId, roomId) : userId;
// for legacy to_device verification requests // for legacy to_device verification requests
if (nameLabel === userId) { if (nameLabel === userId) {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const user = client.getUser(event.getSender()); const user = client.getUser(userId);
if (user && user.displayName) { if (user && user.displayName) {
nameLabel = _t("%(name)s (%(userId)s)", {name: user.displayName, userId}); nameLabel = _t("%(name)s (%(userId)s)", {name: user.displayName, userId});
} }

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -32,6 +32,10 @@ import {getAddressType} from "./UserAddress";
* @param {object=} opts.createOpts set of options to pass to createRoom call. * @param {object=} opts.createOpts set of options to pass to createRoom call.
* @param {bool=} opts.spinner True to show a modal spinner while the room is created. * @param {bool=} opts.spinner True to show a modal spinner while the room is created.
* Default: True * Default: True
* @param {bool=} opts.guestAccess Whether to enable guest access.
* Default: True
* @param {bool=} opts.encryption Whether to enable encryption.
* Default: False
* *
* @returns {Promise} which resolves to the room id, or null if the * @returns {Promise} which resolves to the room id, or null if the
* action was aborted or failed. * action was aborted or failed.
@ -39,6 +43,8 @@ import {getAddressType} from "./UserAddress";
export default function createRoom(opts) { export default function createRoom(opts) {
opts = opts || {}; opts = opts || {};
if (opts.spinner === undefined) opts.spinner = true; if (opts.spinner === undefined) opts.spinner = true;
if (opts.guestAccess === undefined) opts.guestAccess = true;
if (opts.encryption === undefined) opts.encryption = false;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
@ -77,18 +83,30 @@ export default function createRoom(opts) {
opts.andView = true; opts.andView = true;
} }
createOpts.initial_state = createOpts.initial_state || [];
// Allow guests by default since the room is private and they'd // Allow guests by default since the room is private and they'd
// need an invite. This means clicking on a 3pid invite email can // need an invite. This means clicking on a 3pid invite email can
// actually drop you right in to a chat. // actually drop you right in to a chat.
createOpts.initial_state = createOpts.initial_state || [ if (opts.guestAccess) {
{ createOpts.initial_state.push({
type: 'm.room.guest_access',
state_key: '',
content: { content: {
guest_access: 'can_join', guest_access: 'can_join',
}, },
type: 'm.room.guest_access', });
}
if (opts.encryption) {
createOpts.initial_state.push({
type: 'm.room.encryption',
state_key: '', state_key: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
}, },
]; });
}
let modal; let modal;
if (opts.spinner) modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); if (opts.spinner) modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');

View file

@ -250,7 +250,7 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) {
} }
export function parsePlainTextMessage(body, partCreator, isQuotedMessage) { export function parsePlainTextMessage(body, partCreator, isQuotedMessage) {
const lines = body.split("\n"); const lines = body.split(/\r\n|\r|\n/g); // split on any new-line combination not just \n, collapses \r\n
const parts = lines.reduce((parts, line, i) => { const parts = lines.reduce((parts, line, i) => {
if (isQuotedMessage) { if (isQuotedMessage) {
parts.push(partCreator.plain(QUOTE_LINE_PREFIX)); parts.push(partCreator.plain(QUOTE_LINE_PREFIX));

View file

@ -100,27 +100,71 @@ export function formatRangeAsCode(range) {
replaceRangeAndExpandSelection(range, parts); replaceRangeAndExpandSelection(range, parts);
} }
// parts helper methods
const isBlank = part => !part.text || !/\S/.test(part.text);
const isNL = part => part.type === "newline";
export function toggleInlineFormat(range, prefix, suffix = prefix) { export function toggleInlineFormat(range, prefix, suffix = prefix) {
const {model, parts} = range; const {model, parts} = range;
const {partCreator} = model; const {partCreator} = model;
const isFormatted = parts.length && // compute paragraph [start, end] indexes
parts[0].text.startsWith(prefix) && const paragraphIndexes = [];
parts[parts.length - 1].text.endsWith(suffix); let startIndex = 0;
// start at i=2 because we look at i and up to two parts behind to detect paragraph breaks at their end
for (let i = 2; i < parts.length; i++) {
// paragraph breaks can be denoted in a multitude of ways,
// - 2 newline parts in sequence
// - newline part, plain(<empty or just spaces>), newline part
// bump startIndex onto the first non-blank after the paragraph ending
if (isBlank(parts[i - 2]) && isNL(parts[i - 1]) && !isNL(parts[i]) && !isBlank(parts[i])) {
startIndex = i;
}
// if at a paragraph break, store the indexes of the paragraph
if (isNL(parts[i - 1]) && isNL(parts[i])) {
paragraphIndexes.push([startIndex, i - 1]);
startIndex = i + 1;
} else if (isNL(parts[i - 2]) && isBlank(parts[i - 1]) && isNL(parts[i])) {
paragraphIndexes.push([startIndex, i - 2]);
startIndex = i + 1;
}
}
const lastNonEmptyPart = parts.map(isBlank).lastIndexOf(false);
// If we have not yet included the final paragraph then add it now
if (startIndex <= lastNonEmptyPart) {
paragraphIndexes.push([startIndex, lastNonEmptyPart + 1]);
}
// keep track of how many things we have inserted as an offset:=0
let offset = 0;
paragraphIndexes.forEach(([startIndex, endIndex]) => {
// for each paragraph apply the same rule
const base = startIndex + offset;
const index = endIndex + offset;
const isFormatted = (index - base > 0) &&
parts[base].text.startsWith(prefix) &&
parts[index - 1].text.endsWith(suffix);
if (isFormatted) { if (isFormatted) {
// remove prefix and suffix // remove prefix and suffix
const partWithoutPrefix = parts[0].serialize(); const partWithoutPrefix = parts[base].serialize();
partWithoutPrefix.text = partWithoutPrefix.text.substr(prefix.length); partWithoutPrefix.text = partWithoutPrefix.text.substr(prefix.length);
parts[0] = partCreator.deserializePart(partWithoutPrefix); parts[base] = partCreator.deserializePart(partWithoutPrefix);
const partWithoutSuffix = parts[parts.length - 1].serialize(); const partWithoutSuffix = parts[index - 1].serialize();
const suffixPartText = partWithoutSuffix.text; const suffixPartText = partWithoutSuffix.text;
partWithoutSuffix.text = suffixPartText.substring(0, suffixPartText.length - suffix.length); partWithoutSuffix.text = suffixPartText.substring(0, suffixPartText.length - suffix.length);
parts[parts.length - 1] = partCreator.deserializePart(partWithoutSuffix); parts[index - 1] = partCreator.deserializePart(partWithoutSuffix);
} else { } else {
parts.unshift(partCreator.plain(prefix)); parts.splice(index, 0, partCreator.plain(suffix)); // splice in the later one first to not change offset
parts.push(partCreator.plain(suffix)); parts.splice(base, 0, partCreator.plain(prefix));
offset += 2; // offset index to account for the two items we just spliced in
} }
});
replaceRangeAndExpandSelection(range, parts); replaceRangeAndExpandSelection(range, parts);
} }

View file

@ -61,18 +61,26 @@ export function textSerialize(model) {
} }
export function containsEmote(model) { export function containsEmote(model) {
return startsWith(model, "/me ");
}
export function startsWith(model, prefix) {
const firstPart = model.parts[0]; const firstPart = model.parts[0];
// part type will be "plain" while editing, // part type will be "plain" while editing,
// and "command" while composing a message. // and "command" while composing a message.
return firstPart && return firstPart &&
(firstPart.type === "plain" || firstPart.type === "command") && (firstPart.type === "plain" || firstPart.type === "command") &&
firstPart.text.startsWith("/me "); firstPart.text.startsWith(prefix);
} }
export function stripEmoteCommand(model) { export function stripEmoteCommand(model) {
// trim "/me " // trim "/me "
return stripPrefix(model, "/me ");
}
export function stripPrefix(model, prefix) {
model = model.clone(); model = model.clone();
model.removeText({index: 0, offset: 0}, 4); model.removeText({index: 0, offset: 0}, prefix.length);
return model; return model;
} }

View file

@ -79,13 +79,13 @@ EMOJIBASE.forEach(emoji => {
}); });
/** /**
* Strips variation selectors from a string * Strips variation selectors from the end of given string
* NB. Skin tone modifers are not variation selectors: * NB. Skin tone modifiers are not variation selectors:
* this function does not touch them. (Should it?) * this function does not touch them. (Should it?)
* *
* @param {string} str string to strip * @param {string} str string to strip
* @returns {string} stripped string * @returns {string} stripped string
*/ */
function stripVariation(str) { function stripVariation(str) {
return str.replace(/[\uFE00-\uFE0F]/, ""); return str.replace(/[\uFE00-\uFE0F]$/, "");
} }

52
src/hooks/useSettings.js Normal file
View file

@ -0,0 +1,52 @@
/*
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 {useEffect, useState} from "react";
import SettingsStore from '../settings/SettingsStore';
// Hook to fetch the value of a setting and dynamically update when it changes
export const useSettingValue = (settingName, roomId = null, excludeDefault = false) => {
const [value, setValue] = useState(SettingsStore.getValue(settingName, roomId, excludeDefault));
useEffect(() => {
const ref = SettingsStore.watchSetting(settingName, roomId, () => {
setValue(SettingsStore.getValue(settingName, roomId, excludeDefault));
});
// clean-up
return () => {
SettingsStore.unwatchSetting(ref);
};
}, [settingName, roomId, excludeDefault]);
return value;
};
// Hook to fetch whether a feature is enabled and dynamically update when that changes
export const useFeatureEnabled = (featureName, roomId = null) => {
const [enabled, setEnabled] = useState(SettingsStore.isFeatureEnabled(featureName, roomId));
useEffect(() => {
const ref = SettingsStore.watchSetting(featureName, roomId, () => {
setEnabled(SettingsStore.isFeatureEnabled(featureName, roomId));
});
// clean-up
return () => {
SettingsStore.unwatchSetting(ref);
};
}, [featureName, roomId]);
return enabled;
};

View file

@ -21,6 +21,9 @@
"Analytics": "Analytics", "Analytics": "Analytics",
"The information being sent to us to help make Riot.im better includes:": "The information being sent to us to help make Riot.im better includes:", "The information being sent to us to help make Riot.im better includes:": "The information being sent to us to help make Riot.im better includes:",
"Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.", "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.",
"Error": "Error",
"Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.",
"Dismiss": "Dismiss",
"Call Failed": "Call Failed", "Call Failed": "Call Failed",
"There are unknown devices in this room: if you proceed without verifying them, it will be possible for someone to eavesdrop on your call.": "There are unknown devices in this room: if you proceed without verifying them, it will be possible for someone to eavesdrop on your call.", "There are unknown devices in this room: if you proceed without verifying them, it will be possible for someone to eavesdrop on your call.": "There are unknown devices in this room: if you proceed without verifying them, it will be possible for someone to eavesdrop on your call.",
"Review Devices": "Review Devices", "Review Devices": "Review Devices",
@ -85,6 +88,9 @@
"%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(time)s", "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(time)s",
"%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s", "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s",
"%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s", "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s",
"Verify this session": "Verify this session",
"Encryption upgrade available": "Encryption upgrade available",
"Set up encryption": "Set up encryption",
"New Session": "New Session", "New Session": "New Session",
"Who would you like to add to this community?": "Who would you like to add to this community?", "Who would you like to add to this community?": "Who would you like to add to this community?",
"Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID", "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID",
@ -105,9 +111,6 @@
"This action requires accessing the default identity server <server /> to validate an email address or phone number, but the server does not have any terms of service.": "This action requires accessing the default identity server <server /> to validate an email address or phone number, but the server does not have any terms of service.", "This action requires accessing the default identity server <server /> to validate an email address or phone number, but the server does not have any terms of service.": "This action requires accessing the default identity server <server /> to validate an email address or phone number, but the server does not have any terms of service.",
"Only continue if you trust the owner of the server.": "Only continue if you trust the owner of the server.", "Only continue if you trust the owner of the server.": "Only continue if you trust the owner of the server.",
"Trust": "Trust", "Trust": "Trust",
"Error": "Error",
"Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.",
"Dismiss": "Dismiss",
"Riot does not have permission to send you notifications - please check your browser settings": "Riot does not have permission to send you notifications - please check your browser settings", "Riot does not have permission to send you notifications - please check your browser settings": "Riot does not have permission to send you notifications - please check your browser settings",
"Riot was not given permission to send notifications - please try again": "Riot was not given permission to send notifications - please try again", "Riot was not given permission to send notifications - please try again": "Riot was not given permission to send notifications - please try again",
"Unable to enable Notifications": "Unable to enable Notifications", "Unable to enable Notifications": "Unable to enable Notifications",
@ -121,15 +124,8 @@
"Moderator": "Moderator", "Moderator": "Moderator",
"Admin": "Admin", "Admin": "Admin",
"Custom (%(level)s)": "Custom (%(level)s)", "Custom (%(level)s)": "Custom (%(level)s)",
"Start a chat": "Start a chat",
"Who would you like to communicate with?": "Who would you like to communicate with?",
"Email, name or Matrix ID": "Email, name or Matrix ID",
"Start Chat": "Start Chat",
"Invite new room members": "Invite new room members",
"Send Invites": "Send Invites",
"Failed to start chat": "Failed to start chat",
"Operation failed": "Operation failed",
"Failed to invite": "Failed to invite", "Failed to invite": "Failed to invite",
"Operation failed": "Operation failed",
"Failed to invite users to the room:": "Failed to invite users to the room:", "Failed to invite users to the room:": "Failed to invite users to the room:",
"Failed to invite the following users to the %(roomName)s room:": "Failed to invite the following users to the %(roomName)s room:", "Failed to invite the following users to the %(roomName)s room:": "Failed to invite the following users to the %(roomName)s room:",
"You need to be logged in.": "You need to be logged in.", "You need to be logged in.": "You need to be logged in.",
@ -200,7 +196,6 @@
"Sends the given message coloured as a rainbow": "Sends the given message coloured as a rainbow", "Sends the given message coloured as a rainbow": "Sends the given message coloured as a rainbow",
"Sends the given emote coloured as a rainbow": "Sends the given emote coloured as a rainbow", "Sends the given emote coloured as a rainbow": "Sends the given emote coloured as a rainbow",
"Displays list of commands with usages and descriptions": "Displays list of commands with usages and descriptions", "Displays list of commands with usages and descriptions": "Displays list of commands with usages and descriptions",
"Unrecognised command:": "Unrecognised command:",
"Reason": "Reason", "Reason": "Reason",
"%(targetName)s accepted the invitation for %(displayName)s.": "%(targetName)s accepted the invitation for %(displayName)s.", "%(targetName)s accepted the invitation for %(displayName)s.": "%(targetName)s accepted the invitation for %(displayName)s.",
"%(targetName)s accepted an invitation.": "%(targetName)s accepted an invitation.", "%(targetName)s accepted an invitation.": "%(targetName)s accepted an invitation.",
@ -236,10 +231,13 @@
"%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s disabled flair for %(groups)s in this room.", "%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s disabled flair for %(groups)s in this room.",
"%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.", "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.",
"%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.", "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.",
"%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room|other": "%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room",
"%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|other": "%(senderName)s added %(addedAddresses)s as addresses for this room.", "%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|other": "%(senderName)s added %(addedAddresses)s as addresses for this room.",
"%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|one": "%(senderName)s added %(addedAddresses)s as an address for this room.", "%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|one": "%(senderName)s added %(addedAddresses)s as an address for this room.",
"%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room|other": "%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room",
"%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.|other": "%(senderName)s removed %(removedAddresses)s as addresses for this room.", "%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.|other": "%(senderName)s removed %(removedAddresses)s as addresses for this room.",
"%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.|one": "%(senderName)s removed %(removedAddresses)s as an address for this room.", "%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.|one": "%(senderName)s removed %(removedAddresses)s as an address for this room.",
"%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room": "%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room",
"%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.": "%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.", "%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.": "%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.",
"%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.", "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.",
"%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.", "%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.",
@ -261,7 +259,8 @@
"%(senderName)s made future room history visible to all room members.": "%(senderName)s made future room history visible to all room members.", "%(senderName)s made future room history visible to all room members.": "%(senderName)s made future room history visible to all room members.",
"%(senderName)s made future room history visible to anyone.": "%(senderName)s made future room history visible to anyone.", "%(senderName)s made future room history visible to anyone.": "%(senderName)s made future room history visible to anyone.",
"%(senderName)s made future room history visible to unknown (%(visibility)s).": "%(senderName)s made future room history visible to unknown (%(visibility)s).", "%(senderName)s made future room history visible to unknown (%(visibility)s).": "%(senderName)s made future room history visible to unknown (%(visibility)s).",
"%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).": "%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).", "%(senderName)s turned on end-to-end encryption.": "%(senderName)s turned on end-to-end encryption.",
"%(senderName)s turned on end-to-end encryption (unrecognised algorithm %(algorithm)s).": "%(senderName)s turned on end-to-end encryption (unrecognised algorithm %(algorithm)s).",
"%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s": "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s", "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s": "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s",
"%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s changed the power level of %(powerLevelDiffText)s.", "%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s changed the power level of %(powerLevelDiffText)s.",
"%(senderName)s changed the pinned messages for the room.": "%(senderName)s changed the pinned messages for the room.", "%(senderName)s changed the pinned messages for the room.": "%(senderName)s changed the pinned messages for the room.",
@ -373,7 +372,6 @@
"Render simple counters in room header": "Render simple counters in room header", "Render simple counters in room header": "Render simple counters in room header",
"Multiple integration managers": "Multiple integration managers", "Multiple integration managers": "Multiple integration managers",
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
"New invite dialog": "New invite dialog",
"Show a presence dot next to DMs in the room list": "Show a presence dot next to DMs in the room list", "Show a presence dot next to DMs in the room list": "Show a presence dot next to DMs in the room list",
"Enable cross-signing to verify per-user instead of per-device (in development)": "Enable cross-signing to verify per-user instead of per-device (in development)", "Enable cross-signing to verify per-user instead of per-device (in development)": "Enable cross-signing to verify per-user instead of per-device (in development)",
"Enable local event indexing and E2EE search (requires restart)": "Enable local event indexing and E2EE search (requires restart)", "Enable local event indexing and E2EE search (requires restart)": "Enable local event indexing and E2EE search (requires restart)",
@ -416,6 +414,7 @@
"Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)", "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)",
"Send read receipts for messages (requires compatible homeserver to disable)": "Send read receipts for messages (requires compatible homeserver to disable)", "Send read receipts for messages (requires compatible homeserver to disable)": "Send read receipts for messages (requires compatible homeserver to disable)",
"Show previews/thumbnails for images": "Show previews/thumbnails for images", "Show previews/thumbnails for images": "Show previews/thumbnails for images",
"Enable message search in encrypted rooms": "Enable message search in encrypted rooms",
"Collecting app version information": "Collecting app version information", "Collecting app version information": "Collecting app version information",
"Collecting logs": "Collecting logs", "Collecting logs": "Collecting logs",
"Uploading report": "Uploading report", "Uploading report": "Uploading report",
@ -514,8 +513,12 @@
"Headphones": "Headphones", "Headphones": "Headphones",
"Folder": "Folder", "Folder": "Folder",
"Pin": "Pin", "Pin": "Pin",
"Other users may not trust it": "Other users may not trust it", "Review & verify your new session": "Review & verify your new session",
"Later": "Later", "Later": "Later",
"Review": "Review",
"Verify your other devices easier": "Verify your other devices easier",
"Other users may not trust it": "Other users may not trust it",
"Upgrade": "Upgrade",
"Verify": "Verify", "Verify": "Verify",
"Decline (%(counter)s)": "Decline (%(counter)s)", "Decline (%(counter)s)": "Decline (%(counter)s)",
"Accept <policyLink /> to continue:": "Accept <policyLink /> to continue:", "Accept <policyLink /> to continue:": "Accept <policyLink /> to continue:",
@ -563,6 +566,14 @@
"Failed to set display name": "Failed to set display name", "Failed to set display name": "Failed to set display name",
"Disable Notifications": "Disable Notifications", "Disable Notifications": "Disable Notifications",
"Enable Notifications": "Enable Notifications", "Enable Notifications": "Enable Notifications",
"Securely cache encrypted messages locally for them to appear in search results, using ": "Securely cache encrypted messages locally for them to appear in search results, using ",
" to store messages from ": " to store messages from ",
"rooms.": "rooms.",
"Manage": "Manage",
"Securely cache encrypted messages locally for them to appear in search results.": "Securely cache encrypted messages locally for them to appear in search results.",
"Enable": "Enable",
"Riot is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom Riot Desktop with <nativeLink>search components added</nativeLink>.": "Riot is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom Riot Desktop with <nativeLink>search components added</nativeLink>.",
"Riot can't securely cache encrypted messages locally while running in a web browser. Use <riotLink>Riot Desktop</riotLink> for encrypted messages to appear in search results.": "Riot can't securely cache encrypted messages locally while running in a web browser. Use <riotLink>Riot Desktop</riotLink> for encrypted messages to appear in search results.",
"Connecting to integration manager...": "Connecting to integration manager...", "Connecting to integration manager...": "Connecting to integration manager...",
"Cannot connect to integration manager": "Cannot connect to integration manager", "Cannot connect to integration manager": "Cannot connect to integration manager",
"The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.",
@ -669,8 +680,8 @@
"Profile": "Profile", "Profile": "Profile",
"Email addresses": "Email addresses", "Email addresses": "Email addresses",
"Phone numbers": "Phone numbers", "Phone numbers": "Phone numbers",
"Account": "Account",
"Set a new account password...": "Set a new account password...", "Set a new account password...": "Set a new account password...",
"Account": "Account",
"Language and region": "Language and region", "Language and region": "Language and region",
"Theme": "Theme", "Theme": "Theme",
"Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.", "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.",
@ -694,7 +705,6 @@
"Clear cache and reload": "Clear cache and reload", "Clear cache and reload": "Clear cache and reload",
"FAQ": "FAQ", "FAQ": "FAQ",
"Versions": "Versions", "Versions": "Versions",
"matrix-react-sdk version:": "matrix-react-sdk version:",
"riot-web version:": "riot-web version:", "riot-web version:": "riot-web version:",
"olm version:": "olm version:", "olm version:": "olm version:",
"Homeserver is": "Homeserver is", "Homeserver is": "Homeserver is",
@ -757,6 +767,7 @@
"Accept all %(invitedRooms)s invites": "Accept all %(invitedRooms)s invites", "Accept all %(invitedRooms)s invites": "Accept all %(invitedRooms)s invites",
"Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites", "Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites",
"Key backup": "Key backup", "Key backup": "Key backup",
"Message search": "Message search",
"Cross-signing": "Cross-signing", "Cross-signing": "Cross-signing",
"Security & Privacy": "Security & Privacy", "Security & Privacy": "Security & Privacy",
"Devices": "Devices", "Devices": "Devices",
@ -888,8 +899,9 @@
"This user has not verified all of their devices.": "This user has not verified all of their devices.", "This user has not verified all of their devices.": "This user has not verified all of their devices.",
"You have not verified this user. This user has verified all of their devices.": "You have not verified this user. This user has verified all of their devices.", "You have not verified this user. This user has verified all of their devices.": "You have not verified this user. This user has verified all of their devices.",
"You have verified this user. This user has verified all of their devices.": "You have verified this user. This user has verified all of their devices.", "You have verified this user. This user has verified all of their devices.": "You have verified this user. This user has verified all of their devices.",
"Some users in this encrypted room are not verified by you or they have not verified their own devices.": "Some users in this encrypted room are not verified by you or they have not verified their own devices.", "Someone is using an unknown device": "Someone is using an unknown device",
"All users in this encrypted room are verified by you and they have verified their own devices.": "All users in this encrypted room are verified by you and they have verified their own devices.", "This room is end-to-end encrypted": "This room is end-to-end encrypted",
"Everyone in this room is verified": "Everyone in this room is verified",
"Some devices for this user are not trusted": "Some devices for this user are not trusted", "Some devices for this user are not trusted": "Some devices for this user are not trusted",
"All devices for this user are trusted": "All devices for this user are trusted", "All devices for this user are trusted": "All devices for this user are trusted",
"Some devices in this encrypted room are not trusted": "Some devices in this encrypted room are not trusted", "Some devices in this encrypted room are not trusted": "Some devices in this encrypted room are not trusted",
@ -907,7 +919,9 @@
"This message cannot be decrypted": "This message cannot be decrypted", "This message cannot be decrypted": "This message cannot be decrypted",
"Encrypted by an unverified device": "Encrypted by an unverified device", "Encrypted by an unverified device": "Encrypted by an unverified device",
"Unencrypted": "Unencrypted", "Unencrypted": "Unencrypted",
"Encrypted by a deleted device": "Encrypted by a deleted device",
"Please select the destination room for this message": "Please select the destination room for this message", "Please select the destination room for this message": "Please select the destination room for this message",
"Invite only": "Invite only",
"Scroll to bottom of page": "Scroll to bottom of page", "Scroll to bottom of page": "Scroll to bottom of page",
"Close preview": "Close preview", "Close preview": "Close preview",
"device id: ": "device id: ", "device id: ": "device id: ",
@ -946,6 +960,7 @@
"Invite": "Invite", "Invite": "Invite",
"Share Link to User": "Share Link to User", "Share Link to User": "Share Link to User",
"User Options": "User Options", "User Options": "User Options",
"Start a chat": "Start a chat",
"Direct chats": "Direct chats", "Direct chats": "Direct chats",
"Remove recent messages": "Remove recent messages", "Remove recent messages": "Remove recent messages",
"Unmute": "Unmute", "Unmute": "Unmute",
@ -964,8 +979,10 @@
"Hangup": "Hangup", "Hangup": "Hangup",
"Upload file": "Upload file", "Upload file": "Upload file",
"Send an encrypted reply…": "Send an encrypted reply…", "Send an encrypted reply…": "Send an encrypted reply…",
"Send a reply (unencrypted)…": "Send a reply (unencrypted)…", "Send a reply…": "Send a reply…",
"Send an encrypted message…": "Send an encrypted message…", "Send an encrypted message…": "Send an encrypted message…",
"Send a message…": "Send a message…",
"Send a reply (unencrypted)…": "Send a reply (unencrypted)…",
"Send a message (unencrypted)…": "Send a message (unencrypted)…", "Send a message (unencrypted)…": "Send a message (unencrypted)…",
"The conversation continues here.": "The conversation continues here.", "The conversation continues here.": "The conversation continues here.",
"This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.", "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.",
@ -1012,7 +1029,7 @@
"Community Invites": "Community Invites", "Community Invites": "Community Invites",
"Invites": "Invites", "Invites": "Invites",
"Favourites": "Favourites", "Favourites": "Favourites",
"People": "People", "Direct Messages": "Direct Messages",
"Start chat": "Start chat", "Start chat": "Start chat",
"Rooms": "Rooms", "Rooms": "Rooms",
"Low priority": "Low priority", "Low priority": "Low priority",
@ -1049,6 +1066,7 @@
"Do you want to join %(roomName)s?": "Do you want to join %(roomName)s?", "Do you want to join %(roomName)s?": "Do you want to join %(roomName)s?",
"<userName/> invited you": "<userName/> invited you", "<userName/> invited you": "<userName/> invited you",
"Reject": "Reject", "Reject": "Reject",
"Reject & Ignore user": "Reject & Ignore user",
"You're previewing %(roomName)s. Want to join it?": "You're previewing %(roomName)s. Want to join it?", "You're previewing %(roomName)s. Want to join it?": "You're previewing %(roomName)s. Want to join it?",
"%(roomName)s can't be previewed. Do you want to join it?": "%(roomName)s can't be previewed. Do you want to join it?", "%(roomName)s can't be previewed. Do you want to join it?": "%(roomName)s can't be previewed. Do you want to join it?",
"%(roomName)s does not exist.": "%(roomName)s does not exist.", "%(roomName)s does not exist.": "%(roomName)s does not exist.",
@ -1079,6 +1097,11 @@
"Server error": "Server error", "Server error": "Server error",
"Command error": "Command error", "Command error": "Command error",
"Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.", "Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.",
"Unknown Command": "Unknown Command",
"Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s",
"You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?",
"Hint: Begin your message with <code>//</code> to start it with a slash.": "Hint: Begin your message with <code>//</code> to start it with a slash.",
"Send as message": "Send as message",
"Failed to connect to integration manager": "Failed to connect to integration manager", "Failed to connect to integration manager": "Failed to connect to integration manager",
"You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled",
"Add some now": "Add some now", "Add some now": "Add some now",
@ -1464,8 +1487,7 @@
"Recent Conversations": "Recent Conversations", "Recent Conversations": "Recent Conversations",
"Suggestions": "Suggestions", "Suggestions": "Suggestions",
"Recently Direct Messaged": "Recently Direct Messaged", "Recently Direct Messaged": "Recently Direct Messaged",
"Direct Messages": "Direct Messages", "If you can't find someone, ask them for their username, share your username (%(userId)s) or <a>profile link</a>.": "If you can't find someone, ask them for their username, share your username (%(userId)s) or <a>profile link</a>.",
"If you can't find someone, ask them for their username, or share your username (%(userId)s) or <a>profile link</a>.": "If you can't find someone, ask them for their username, or share your username (%(userId)s) or <a>profile link</a>.",
"Go": "Go", "Go": "Go",
"If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.": "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.", "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.": "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.",
"You added a new device '%(displayName)s', which is requesting encryption keys.": "You added a new device '%(displayName)s', which is requesting encryption keys.", "You added a new device '%(displayName)s', which is requesting encryption keys.": "You added a new device '%(displayName)s', which is requesting encryption keys.",
@ -1510,7 +1532,6 @@
"Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.", "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.",
"This usually only affects how the room is processed on the server. If you're having problems with your Riot, please <a>report a bug</a>.": "This usually only affects how the room is processed on the server. If you're having problems with your Riot, please <a>report a bug</a>.", "This usually only affects how the room is processed on the server. If you're having problems with your Riot, please <a>report a bug</a>.": "This usually only affects how the room is processed on the server. If you're having problems with your Riot, please <a>report a bug</a>.",
"You'll upgrade this room from <oldVersion /> to <newVersion />.": "You'll upgrade this room from <oldVersion /> to <newVersion />.", "You'll upgrade this room from <oldVersion /> to <newVersion />.": "You'll upgrade this room from <oldVersion /> to <newVersion />.",
"Upgrade": "Upgrade",
"Sign out and remove encryption keys?": "Sign out and remove encryption keys?", "Sign out and remove encryption keys?": "Sign out and remove encryption keys?",
"Clear Storage and Sign Out": "Clear Storage and Sign Out", "Clear Storage and Sign Out": "Clear Storage and Sign Out",
"Send Logs": "Send Logs", "Send Logs": "Send Logs",
@ -1973,18 +1994,19 @@
"Import": "Import", "Import": "Import",
"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.": "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.", "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.": "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.",
"Restore": "Restore", "Restore": "Restore",
"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.": "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.", "Enter your account password to confirm the upgrade:": "Enter your account password to confirm the upgrade:",
"You'll need to authenticate with the server to confirm the upgrade.": "You'll need to authenticate with the server to confirm the upgrade.",
"Upgrade this device to allow it to verify other devices, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade this device to allow it to verify other devices, granting them access to encrypted messages and marking them as trusted for other users.",
"Great! This passphrase looks strong enough.": "Great! This passphrase looks strong enough.", "Great! This passphrase looks strong enough.": "Great! This passphrase looks strong enough.",
"<b>Warning</b>: You should only set up secret storage from a trusted computer.": "<b>Warning</b>: You should only set up secret storage from a trusted computer.", "Set up encryption on this device to allow it to verify other devices, granting them access to encrypted messages and marking them as trusted for other users.": "Set up encryption on this device to allow it to verify other devices, granting them access to encrypted messages and marking them as trusted for other users.",
"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.": "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:": "Secure your encryption keys with a passphrase. For maximum security this should be different to your account password:",
"For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.", "Enter a passphrase": "Enter a passphrase",
"Enter a passphrase...": "Enter a passphrase...",
"Set up with a recovery key": "Set up with a recovery key", "Set up with a recovery key": "Set up with a recovery key",
"That matches!": "That matches!", "That matches!": "That matches!",
"That doesn't match.": "That doesn't match.", "That doesn't match.": "That doesn't match.",
"Go back to set it again.": "Go back to set it again.", "Go back to set it again.": "Go back to set it again.",
"Please enter your passphrase a second time to confirm.": "Please enter your passphrase a second time to confirm.", "Enter your passphrase a second time to confirm it.": "Enter your passphrase a second time to confirm it.",
"Repeat your passphrase...": "Repeat your passphrase...", "Confirm your passphrase": "Confirm your passphrase",
"As a safety net, you can use it to restore your access to encrypted messages if you forget your passphrase.": "As a safety net, you can use it to restore your access to encrypted messages if you forget your passphrase.", "As a safety net, you can use it to restore your access to encrypted messages if you forget your passphrase.": "As a safety net, you can use it to restore your access to encrypted messages if you forget your passphrase.",
"As a safety net, you can use it to restore your access to encrypted messages.": "As a safety net, you can use it to restore your access to encrypted messages.", "As a safety net, you can use it to restore your access to encrypted messages.": "As a safety net, you can use it to restore your access to encrypted messages.",
"Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your passphrase.": "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your passphrase.", "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your passphrase.": "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your passphrase.",
@ -1997,21 +2019,25 @@
"<b>Print it</b> and store it somewhere safe": "<b>Print it</b> and store it somewhere safe", "<b>Print it</b> and store it somewhere safe": "<b>Print it</b> and store it somewhere safe",
"<b>Save it</b> on a USB key or backup drive": "<b>Save it</b> on a USB key or backup drive", "<b>Save it</b> on a USB key or backup drive": "<b>Save it</b> on a USB key or backup drive",
"<b>Copy it</b> to your personal cloud storage": "<b>Copy it</b> to your personal cloud storage", "<b>Copy it</b> to your personal cloud storage": "<b>Copy it</b> to your personal cloud storage",
"Your access to encrypted messages is now protected.": "Your access to encrypted messages is now protected.", "This device can now verify other devices, granting them access to encrypted messages and marking them as trusted for other users.": "This device can now verify other devices, granting them access to encrypted messages and marking them as trusted for other users.",
"Verify other users in their profile.": "Verify other users in their profile.",
"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 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 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 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.",
"Set up secret storage": "Set up secret storage", "Set up secret storage": "Set up secret storage",
"Restore your Key Backup": "Restore your Key Backup", "Restore your Key Backup": "Restore your Key Backup",
"Migrate from Key Backup": "Migrate from Key Backup", "Upgrade your encryption": "Upgrade your encryption",
"Secure your encrypted messages with a passphrase": "Secure your encrypted messages with a passphrase",
"Confirm your passphrase": "Confirm your passphrase",
"Recovery key": "Recovery key", "Recovery key": "Recovery key",
"Keep it safe": "Keep it safe", "Keep it safe": "Keep it safe",
"Storing secrets...": "Storing secrets...", "Storing secrets...": "Storing secrets...",
"Success!": "Success!", "Encryption upgraded": "Encryption upgraded",
"Encryption setup complete": "Encryption setup complete",
"Unable to set up secret storage": "Unable to set up secret storage", "Unable to set up secret storage": "Unable to set up secret storage",
"Retry": "Retry", "Retry": "Retry",
"We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.": "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.", "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.": "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.",
"For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.",
"Enter a passphrase...": "Enter a passphrase...",
"Set up with a Recovery Key": "Set up with a Recovery Key", "Set up with a Recovery Key": "Set up with a Recovery Key",
"Please enter your passphrase a second time to confirm.": "Please enter your passphrase a second time to confirm.",
"Repeat your passphrase...": "Repeat your passphrase...",
"As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.": "As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.", "As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.": "As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.",
"As a safety net, you can use it to restore your encrypted message history.": "As a safety net, you can use it to restore your encrypted message history.", "As a safety net, you can use it to restore your encrypted message history.": "As a safety net, you can use it to restore your encrypted message history.",
"Your keys are being backed up (the first backup could take a few minutes).": "Your keys are being backed up (the first backup could take a few minutes).", "Your keys are being backed up (the first backup could take a few minutes).": "Your keys are being backed up (the first backup could take a few minutes).",
@ -2019,6 +2045,7 @@
"Set up Secure Message Recovery": "Set up Secure Message Recovery", "Set up Secure Message Recovery": "Set up Secure Message Recovery",
"Secure your backup with a passphrase": "Secure your backup with a passphrase", "Secure your backup with a passphrase": "Secure your backup with a passphrase",
"Starting backup...": "Starting backup...", "Starting backup...": "Starting backup...",
"Success!": "Success!",
"Create Key Backup": "Create Key Backup", "Create Key Backup": "Create Key Backup",
"Unable to create key backup": "Unable to create key backup", "Unable to create key backup": "Unable to create key backup",
"Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.", "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.",
@ -2035,6 +2062,14 @@
"This device has detected that your recovery passphrase and key for Secure Messages have been removed.": "This device has detected that your recovery passphrase and key for Secure Messages have been removed.", "This device has detected that your recovery passphrase and key for Secure Messages have been removed.": "This device has detected that your recovery passphrase and key for Secure Messages have been removed.",
"If you did this accidentally, you can setup Secure Messages on this device which will re-encrypt this device's message history with a new recovery method.": "If you did this accidentally, you can setup Secure Messages on this device which will re-encrypt this device's message history with a new recovery method.", "If you did this accidentally, you can setup Secure Messages on this device which will re-encrypt this device's message history with a new recovery method.": "If you did this accidentally, you can setup Secure Messages on this device which will re-encrypt this device's message history with a new recovery method.",
"If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.", "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.",
"If disabled, messages from encrypted rooms won't appear in search results.": "If disabled, messages from encrypted rooms won't appear in search results.",
"Disable": "Disable",
"Not currently downloading messages for any room.": "Not currently downloading messages for any room.",
"Downloading mesages for %(currentRoom)s.": "Downloading mesages for %(currentRoom)s.",
"Riot is securely caching encrypted messages locally for them to appear in search results:": "Riot is securely caching encrypted messages locally for them to appear in search results:",
"Space used:": "Space used:",
"Indexed messages:": "Indexed messages:",
"Number of rooms:": "Number of rooms:",
"Failed to set direct chat tag": "Failed to set direct chat tag", "Failed to set direct chat tag": "Failed to set direct chat tag",
"Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room", "Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room",
"Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room" "Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room"

Some files were not shown because too many files have changed in this diff Show more