diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml
index a8ce1273fb..4bc69a76bd 100644
--- a/.buildkite/pipeline.yaml
+++ b/.buildkite/pipeline.yaml
@@ -1,8 +1,10 @@
 steps:
   - label: ":eslint: JS Lint"
     command:
+      # We fetch the develop js-sdk to get our latest eslint rules
       - "echo '--- Install js-sdk'"
-      - "./scripts/ci/install-deps.sh"
+      - "./scripts/ci/install-deps.sh --ignore-scripts"
+      - "echo '+++ Lint'"
       - "yarn lint:js"
     plugins:
       - docker#v3.0.1:
@@ -10,8 +12,9 @@ steps:
 
   - label: ":eslint: TS Lint"
     command:
-      - "echo '--- Install js-sdk'"
-      - "./scripts/ci/install-deps.sh"
+      - "echo '--- Install'"
+      - "yarn install --ignore-scripts"
+      - "echo '+++ Lint'"
       - "yarn lint:ts"
     plugins:
       - docker#v3.0.1:
@@ -19,12 +22,21 @@ steps:
 
   - label: ":eslint: Types Lint"
     command:
-      - "echo '--- Install js-sdk'"
-      - "./scripts/ci/install-deps.sh"
+      - "echo '--- Install'"
+      - "yarn install --ignore-scripts"
+      - "echo '+++ Lint'"
       - "yarn lint:types"
     plugins:
       - docker#v3.0.1:
           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"
     agents:
@@ -33,13 +45,11 @@ steps:
       queue: "medium"
     command:
       - "echo '--- Install js-sdk'"
-      # TODO: Remove hacky chmod for BuildKite
-      - "chmod +x ./scripts/ci/*.sh"
-      - "chmod +x ./scripts/*"
-      - "echo '--- Installing Dependencies'"
-      - "./scripts/ci/install-deps.sh"
-      - "echo '--- Running initial build steps'"
-      - "yarn build"
+      # We don't use the babel-ed output for anything so we can --ignore-scripts
+      # to save transpiling the files. We run the transpile step explicitly in
+      # the 'build' job.
+      - "./scripts/ci/install-deps.sh --ignore-scripts"
+      - "yarn run reskindex"
       - "echo '+++ Running Tests'"
       - "yarn test"
     plugins:
@@ -48,10 +58,8 @@ steps:
 
   - label: "🛠 Build"
     command:
-      - "echo '--- Install js-sdk'"
-      - "./scripts/ci/install-deps.sh"
-      - "echo '+++ Building Project'"
-      - "yarn build"
+      - "echo '+++ Install & Build'"
+      - "yarn install"
     plugins:
       - docker#v3.0.1:
           image: "node:12"
@@ -62,20 +70,19 @@ steps:
       # e2e tests otherwise take +-8min
       queue: "xlarge"
     command:
-      # TODO: Remove hacky chmod for BuildKite
-      - "echo '--- Setup'"
-      - "chmod +x ./scripts/ci/*.sh"
-      - "chmod +x ./scripts/*"
       - "echo '--- Install js-sdk'"
-      - "./scripts/ci/install-deps.sh"
-      - "echo '--- Running initial build steps'"
-      - "yarn build"
+      - "./scripts/ci/install-deps.sh --ignore-scripts"
       - "echo '+++ Running Tests'"
       - "./scripts/ci/end-to-end-tests.sh"
     plugins:
       - docker#v3.0.1:
           image: "matrixdotorg/riotweb-ci-e2etests-env:latest"
           propagate-environment: true
+          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"
     agents:
@@ -83,32 +90,18 @@ steps:
       # webpack loves to gorge itself on resources.
       queue: "medium"
     command:
-      # Install chrome
-      - "echo '--- Installing Chrome'"
-      - "wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -"
-      - "sh -c 'echo \"deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main\" >> /etc/apt/sources.list.d/google.list'"
-      - "apt-get update"
-      - "apt-get install -y google-chrome-stable"
-      # TODO: Remove hacky chmod for BuildKite
-      - "chmod +x ./scripts/ci/*.sh"
-      - "chmod +x ./scripts/*"
-      - "echo '--- Installing Dependencies'"
-      - "./scripts/ci/install-deps.sh"
-      - "echo '--- Running initial build steps'"
-      - "yarn build"
       - "echo '+++ Running Tests'"
       - "./scripts/ci/riot-unit-tests.sh"
-    env:
-      CHROME_BIN: "/usr/bin/google-chrome-stable"
     plugins:
       - docker#v3.0.1:
           image: "node:10"
           propagate-environment: true
+          workdir: "/workdir/matrix-react-sdk"
 
   - label: "🌐 i18n"
     command:
       - "echo '--- Fetching Dependencies'"
-      - "yarn install"
+      - "yarn install --ignore-scripts"
       - "echo '+++ Testing i18n output'"
       - "yarn diff-i18n"
     plugins:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3ae2711e25..881669a1a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
 ===================================================================================================
 [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.7.6-rc.2...v1.7.6)
diff --git a/package.json b/package.json
index aa2cf8bf8b..78bbb5b4c6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "matrix-react-sdk",
-  "version": "1.7.6",
+  "version": "2.0.0",
   "description": "SDK for matrix.org using React",
   "author": "matrix.org",
   "repository": {
@@ -31,7 +31,7 @@
   "typings": "./lib/index.d.ts",
   "matrix_src_main": "./src/index.js",
   "scripts": {
-    "prepublish": "yarn build",
+    "prepare": "yarn build",
     "i18n": "matrix-gen-i18n",
     "prunei18n": "matrix-prune-i18n",
     "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json",
@@ -54,6 +54,7 @@
     "test:e2e": "./test/end-to-end-tests/run.sh --riot-url http://localhost:8080"
   },
   "dependencies": {
+    "@babel/runtime": "^7.8.3",
     "blueimp-canvas-to-blob": "^3.5.0",
     "browser-encrypt-attachment": "^0.3.0",
     "browser-request": "^0.3.3",
@@ -79,7 +80,7 @@
     "is-ip": "^2.0.0",
     "linkifyjs": "^2.1.6",
     "lodash": "^4.17.14",
-    "matrix-js-sdk": "3.0.0",
+    "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
     "pako": "^1.0.5",
     "png-chunks-extract": "^1.0.0",
     "prop-types": "^15.5.8",
@@ -108,13 +109,12 @@
     "@babel/plugin-proposal-numeric-separator": "^7.7.4",
     "@babel/plugin-proposal-object-rest-spread": "^7.7.4",
     "@babel/plugin-transform-flow-comments": "^7.7.4",
-    "@babel/plugin-transform-runtime": "^7.7.6",
+    "@babel/plugin-transform-runtime": "^7.8.3",
     "@babel/preset-env": "^7.7.6",
     "@babel/preset-flow": "^7.7.4",
     "@babel/preset-react": "^7.7.4",
     "@babel/preset-typescript": "^7.7.4",
     "@babel/register": "^7.7.4",
-    "@babel/runtime": "^7.7.6",
     "@peculiar/webcrypto": "^1.0.22",
     "babel-eslint": "^10.0.3",
     "babel-jest": "^24.9.0",
diff --git a/res/css/_common.scss b/res/css/_common.scss
index 51d985efb7..e062e0bd73 100644
--- a/res/css/_common.scss
+++ b/res/css/_common.scss
@@ -338,6 +338,14 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
     margin-bottom: 10px;
 }
 
+.mx_Dialog_titleImage {
+    vertical-align: middle;
+    width: 25px;
+    height: 25px;
+    margin-left: -2px;
+    margin-right: 4px;
+}
+
 .mx_Dialog_title {
     font-size: 22px;
     line-height: 36px;
@@ -378,7 +386,13 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
     text-align: right;
 }
 
-.mx_Dialog button, .mx_Dialog input[type="submit"] {
+/* XXX: Our button style are a mess: buttons that happen to appear in dialogs get special styles applied
+ * to them that no button anywhere else in the app gets by default. In practice, buttons in other places
+ * in the app look the same by being AccessibleButtons, or possibly by having explict button classes.
+ * We should go through and have one consistent set of styles for buttons throughout the app.
+ * For now, I am duplicating the selectors here for mx_Dialog and mx_DialogButtons.
+ */
+.mx_Dialog button, .mx_Dialog input[type="submit"], .mx_Dialog_buttons button, .mx_Dialog_buttons input[type="submit"] {
     @mixin mx_DialogButton;
     margin-left: 0px;
     margin-right: 8px;
@@ -394,27 +408,32 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
     margin-right: 0px;
 }
 
-.mx_Dialog button:hover, .mx_Dialog input[type="submit"]:hover {
+.mx_Dialog button:hover, .mx_Dialog input[type="submit"]:hover, .mx_Dialog_buttons button:hover, .mx_Dialog_buttons input[type="submit"]:hover {
     @mixin mx_DialogButton_hover;
 }
 
-.mx_Dialog button:focus, .mx_Dialog input[type="submit"]:focus {
+.mx_Dialog button:focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:focus, .mx_Dialog_buttons input[type="submit"]:focus {
     filter: brightness($focus-brightness);
 }
 
-.mx_Dialog button.mx_Dialog_primary, .mx_Dialog input[type="submit"].mx_Dialog_primary {
+.mx_Dialog button.mx_Dialog_primary, .mx_Dialog input[type="submit"].mx_Dialog_primary, .mx_Dialog_buttons button.mx_Dialog_primary, .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
     color: $accent-fg-color;
     background-color: $accent-color;
     min-width: 156px;
 }
 
-.mx_Dialog button.danger, .mx_Dialog input[type="submit"].danger {
+.mx_Dialog button.danger, .mx_Dialog input[type="submit"].danger, .mx_Dialog_buttons button.danger, .mx_Dialog_buttons input[type="submit"].danger {
     background-color: $warning-color;
     border: solid 1px $warning-color;
     color: $accent-fg-color;
 }
 
-.mx_Dialog button:disabled, .mx_Dialog input[type="submit"]:disabled {
+.mx_Dialog button.warning, .mx_Dialog input[type="submit"].warning {
+    border: solid 1px $warning-color;
+    color: $warning-color;
+}
+
+.mx_Dialog button:disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:disabled, .mx_Dialog_buttons input[type="submit"]:disabled {
     background-color: $light-fg-color;
     border: solid 1px $light-fg-color;
     opacity: 0.7;
diff --git a/res/css/_components.scss b/res/css/_components.scss
index f6a680c438..a527352461 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -36,6 +36,7 @@
 @import "./views/auth/_AuthHeader.scss";
 @import "./views/auth/_AuthHeaderLogo.scss";
 @import "./views/auth/_AuthPage.scss";
+@import "./views/auth/_CompleteSecurityBody.scss";
 @import "./views/auth/_CountryDropdown.scss";
 @import "./views/auth/_InteractiveAuthEntryComponents.scss";
 @import "./views/auth/_LanguageSelector.scss";
@@ -152,6 +153,7 @@
 @import "./views/rooms/_EditMessageComposer.scss";
 @import "./views/rooms/_EntityTile.scss";
 @import "./views/rooms/_EventTile.scss";
+@import "./views/rooms/_InviteOnlyIcon.scss";
 @import "./views/rooms/_JumpToBottomButton.scss";
 @import "./views/rooms/_LinkPreviewWidget.scss";
 @import "./views/rooms/_MemberDeviceInfo.scss";
diff --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss
index 4ec53a3c9a..517b8b1922 100644
--- a/res/css/structures/_GroupView.scss
+++ b/res/css/structures/_GroupView.scss
@@ -63,7 +63,7 @@ limitations under the License.
 }
 
 .mx_GroupHeader_editButton::before {
-    mask-image: url('$(res)/img/icons-settings-room.svg');
+    mask-image: url('$(res)/img/feather-customised/settings.svg');
 }
 
 .mx_GroupHeader_shareButton::before {
diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss
index 5634a97c53..5b5c49f357 100644
--- a/res/css/structures/_ToastContainer.scss
+++ b/res/css/structures/_ToastContainer.scss
@@ -51,8 +51,8 @@ limitations under the License.
         &.mx_Toast_hasIcon {
             &::after {
                 content: "";
-                width: 21px;
-                height: 20px;
+                width: 22px;
+                height: 22px;
                 grid-column: 1;
                 grid-row: 1;
                 mask-size: 100%;
diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss
index c258ce4ec7..2bf51d9574 100644
--- a/res/css/structures/auth/_CompleteSecurity.scss
+++ b/res/css/structures/auth/_CompleteSecurity.scss
@@ -22,7 +22,7 @@ limitations under the License.
 .mx_CompleteSecurity_headerIcon {
     width: 24px;
     height: 24px;
-    margin: 0 4px;
+    margin-right: 4px;
     position: relative;
 }
 
diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss
index b05629003e..7c5b008535 100644
--- a/res/css/views/auth/_AuthBody.scss
+++ b/res/css/views/auth/_AuthBody.scss
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 New Vector Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -16,12 +17,12 @@ limitations under the License.
 
 .mx_AuthBody {
     width: 500px;
+    font-size: 12px;
+    color: $authpage-secondary-color;
     background-color: $authpage-body-bg-color;
     border-radius: 0 4px 4px 0;
     padding: 25px 60px;
     box-sizing: border-box;
-    font-size: 12px;
-    color: $authpage-secondary-color;
 
     h2 {
         font-size: 24px;
diff --git a/res/css/views/auth/_CompleteSecurityBody.scss b/res/css/views/auth/_CompleteSecurityBody.scss
new file mode 100644
index 0000000000..c7860fbe74
--- /dev/null
+++ b/res/css/views/auth/_CompleteSecurityBody.scss
@@ -0,0 +1,42 @@
+/*
+Copyright 2019 New Vector Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_CompleteSecurityBody {
+    width: 600px;
+    color: $authpage-primary-color;
+    background-color: $authpage-body-bg-color;
+    border-radius: 4px;
+    padding: 20px;
+    box-sizing: border-box;
+
+    h2 {
+        font-size: 24px;
+        font-weight: 600;
+        margin-top: 0;
+    }
+
+    h3 {
+        font-size: 14px;
+        font-weight: 600;
+    }
+
+    a:link,
+    a:hover,
+    a:visited {
+        @mixin mx_Dialog_link;
+    }
+}
diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss
index 221ad7d48c..71fab50339 100644
--- a/res/css/views/dialogs/_InviteDialog.scss
+++ b/res/css/views/dialogs/_InviteDialog.scss
@@ -210,4 +210,19 @@ limitations under the License.
 .mx_InviteDialog {
     // Prevent the dialog from jumping around randomly when elements change.
     height: 590px;
+    padding-left: 20px; // the design wants some padding on the left
+}
+
+.mx_InviteDialog_userSections {
+    margin-top: 10px;
+    overflow-y: auto;
+    padding-right: 45px;
+    height: 455px; // mx_InviteDialog's height minus some for the upper elements
+}
+
+// Right margin for the design. We could apply this to the whole dialog, but then the scrollbar
+// for the user section gets weird.
+.mx_InviteDialog_helpText,
+.mx_InviteDialog_addressBar {
+    margin-right: 45px;
 }
diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss
index 5899abdf73..53e82670e1 100644
--- a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss
+++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss
@@ -1,6 +1,6 @@
 /*
 Copyright 2018 New Vector Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -22,7 +22,7 @@ limitations under the License.
 
 .mx_CreateSecretStorageDialog_primaryContainer {
     /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */
-    padding: 20px;
+    padding-top: 20px;
 }
 
 .mx_CreateSecretStorageDialog_primaryContainer::after {
@@ -36,9 +36,13 @@ limitations under the License.
     align-items: flex-start;
 }
 
+.mx_Field.mx_CreateSecretStorageDialog_passPhraseField {
+    margin-top: 0px;
+}
+
 .mx_CreateSecretStorageDialog_passPhraseHelp {
     flex: 1;
-    height: 85px;
+    height: 64px;
     margin-left: 20px;
     font-size: 80%;
 }
@@ -47,16 +51,8 @@ limitations under the License.
     width: 100%;
 }
 
-.mx_CreateSecretStorageDialog_passPhraseInput {
-    flex: none;
-    width: 250px;
-    border: 1px solid $accent-color;
-    border-radius: 5px;
-    padding: 10px;
-    margin-bottom: 1em;
-}
-
 .mx_CreateSecretStorageDialog_passPhraseMatch {
+    width: 200px;
     margin-left: 20px;
 }
 
@@ -82,6 +78,10 @@ limitations under the License.
     align-items: center;
 }
 
+.mx_CreateSecretStorageDialog_recoveryKeyButtons .mx_AccessibleButton {
+    margin-right: 10px;
+}
+
 .mx_CreateSecretStorageDialog_recoveryKeyButtons button {
     flex: 1;
     white-space: nowrap;
diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss
index e87fe06a94..ad6254f57c 100644
--- a/res/css/views/right_panel/_UserInfo.scss
+++ b/res/css/views/right_panel/_UserInfo.scss
@@ -23,15 +23,23 @@ limitations under the License.
     font-size: 12px;
 
     .mx_UserInfo_cancel {
-        height: 16px;
-        width: 16px;
-        padding: 10px 0 10px 10px;
         cursor: pointer;
-        mask-image: url('$(res)/img/minimise.svg');
-        mask-repeat: no-repeat;
-        mask-position: 16px center;
-        background-color: $rightpanel-button-color;
         position: absolute;
+        top: 0;
+        border-radius: 4px;
+        background-color: $dark-panel-bg-color;
+        margin: 9px;
+        z-index: 1; // render on top of the right panel
+
+        div {
+            height: 16px;
+            width: 16px;
+            padding: 4px;
+            mask-image: url('$(res)/img/minimise.svg');
+            mask-repeat: no-repeat;
+            mask-position: 7px center;
+            background-color: $rightpanel-button-color;
+        }
     }
 
     h2 {
@@ -95,7 +103,7 @@ limitations under the License.
         justify-content: center;
 
         // override the calculated sizes so that the letter isn't HUGE
-        font-size: 26px !important;
+        font-size: 56px !important;
         width: 100% !important;
     }
 
diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss
index fbac1e932a..d292c729dd 100644
--- a/res/css/views/rooms/_EventTile.scss
+++ b/res/css/views/rooms/_EventTile.scss
@@ -367,6 +367,11 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
     opacity: 1;
 }
 
+.mx_EventTile_e2eIcon_unknown {
+    background-image: url('$(res)/img/e2e/warning.svg');
+    opacity: 1;
+}
+
 .mx_EventTile_e2eIcon_unencrypted {
     background-image: url('$(res)/img/e2e/warning.svg');
     opacity: 1;
@@ -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_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;
 }
 
@@ -427,8 +433,13 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
     border-left: $e2e-unverified-color 5px solid;
 }
 
+.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line {
+    border-left: $e2e-unknown-color 5px solid;
+}
+
 .mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line,
-.mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line {
+.mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line,
+.mx_EventTile:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line {
     padding-left: 78px;
 }
 
@@ -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)
 .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp,
-.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp {
+.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp,
+.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp {
     left: 3px;
     width: auto;
 }
 
 // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
 .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon,
-.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon {
+.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon,
+.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > .mx_EventTile_e2eIcon {
     display: block;
     left: 41px;
 }
diff --git a/res/css/views/rooms/_InviteOnlyIcon.scss b/res/css/views/rooms/_InviteOnlyIcon.scss
new file mode 100644
index 0000000000..e70586bb73
--- /dev/null
+++ b/res/css/views/rooms/_InviteOnlyIcon.scss
@@ -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;
+    }
+}
diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss
index 5efca51844..fae9d0dfe3 100644
--- a/res/css/views/rooms/_MessageComposer.scss
+++ b/res/css/views/rooms/_MessageComposer.scss
@@ -76,6 +76,8 @@ limitations under the License.
     left: 60px;
     margin-right: 0; // Counteract the E2EIcon class
     margin-left: 3px; // Counteract the E2EIcon class
+    width: 12px;
+    height: 12px;
 }
 
 .mx_MessageComposer_noperm_error {
diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss
index 45b9733faa..6f0377b29c 100644
--- a/res/css/views/rooms/_RoomHeader.scss
+++ b/res/css/views/rooms/_RoomHeader.scss
@@ -19,7 +19,12 @@ limitations under the License.
     border-bottom: 1px solid $primary-hairline-color;
 
     .mx_E2EIcon {
-        margin: 0 5px;
+        margin: 0;
+        position: absolute;
+        bottom: -1px;
+        right: -2px;
+        height: 10px;
+        width: 10px;
     }
 }
 
@@ -171,6 +176,7 @@ limitations under the License.
     width: 28px;
     height: 28px;
     margin: 0 7px;
+    position: relative;
 }
 
 .mx_RoomHeader_avatar .mx_BaseAvatar_image {
@@ -263,24 +269,3 @@ limitations under the License.
 .mx_RoomHeader_pinsIndicatorUnread {
     background-color: $pinned-unread-color;
 }
-
-.mx_RoomHeader_PrivateIcon.mx_RoomHeader_isPrivate {
-    width: 12px;
-    height: 12px;
-    position: relative;
-    display: block !important;
-
-    &::before {
-        background-color: $roomtile-name-color;
-        mask-image: url('$(res)/img/feather-customised/lock-solid.svg');
-        mask-position: center;
-        mask-repeat: no-repeat;
-        mask-size: contain;
-        content: '';
-        position: absolute;
-        top: 0;
-        bottom: 0;
-        left: 0;
-        right: 0;
-    }
-}
diff --git a/res/css/views/rooms/_RoomPreviewBar.scss b/res/css/views/rooms/_RoomPreviewBar.scss
index c7d03e3523..85b6916226 100644
--- a/res/css/views/rooms/_RoomPreviewBar.scss
+++ b/res/css/views/rooms/_RoomPreviewBar.scss
@@ -117,12 +117,17 @@ limitations under the License.
     .mx_RoomPreviewBar_actions {
         flex-direction: column-reverse;
         .mx_AccessibleButton {
-            padding: 7px 50px;//extra wide
+            padding: 7px 50px; //extra wide
         }
 
         & > * {
             margin-top: 12px;
         }
+        .mx_AccessibleButton.mx_AccessibleButton_kind_primary {
+            // to account for the padding of the primary button which causes inconsistent look between
+            // subsequent secondary (text) buttons
+            margin-bottom: 7px;
+        }
     }
 }
 
diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss
index cb1137bb2f..a24fdf2629 100644
--- a/res/css/views/rooms/_RoomTile.scss
+++ b/res/css/views/rooms/_RoomTile.scss
@@ -98,6 +98,19 @@ limitations under the License.
     z-index: 2;
 }
 
+// Note we match .mx_E2EIcon to make sure this matches more tightly than just
+// .mx_E2EIcon on its own
+.mx_RoomTile_e2eIcon.mx_E2EIcon {
+    height: 10px;
+    width: 10px;
+    display: block;
+    position: absolute;
+    bottom: -1px;
+    right: -2px;
+    z-index: 1;
+    margin: 0;
+}
+
 .mx_RoomTile_name {
     font-size: 14px;
     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,
 // or on keyboard focus of room tile
-.mx_RoomTile.focus-visible:focus-within,
+.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:focus-within,
+// or on pointer hover
 .mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover {
     .mx_RoomTile_menuButton {
         display: block;
@@ -201,30 +215,7 @@ limitations under the License.
     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
     padding-left: 3px;
 }
-
-.mx_RoomTile.mx_RoomTile_isPrivate .mx_RoomTile_PrivateIcon {
-    width: 12px;
-    height: 12px;
-    position: relative;
-    display: block !important;
-    // Align the padlock with unencrypted room names
-    margin-left: 6px;
-
-    &::before {
-        background-color: $roomtile-name-color;
-        mask-image: url('$(res)/img/feather-customised/lock-solid.svg');
-        mask-position: center;
-        mask-repeat: no-repeat;
-        mask-size: contain;
-        content: '';
-        position: absolute;
-        top: 0;
-        bottom: 0;
-        left: 0;
-        right: 0;
-    }
-}
diff --git a/res/img/icons-settings-room.svg b/res/img/icons-settings-room.svg
deleted file mode 100644
index 421eefdefa..0000000000
--- a/res/img/icons-settings-room.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8.00545 1.06618C7.82268 1.06619 7.66831 1.20186 7.64482 1.38311L7.4704 2.72959C6.80076 2.8906 6.17084 3.15423 5.59936 3.50402L4.52364 2.67618C4.37886 2.56476 4.1739 2.57804 4.04471 2.7072L3.34289 3.40902L2.63925 4.11266C2.51008 4.24184 2.49678 4.44679 2.60818 4.59158L3.43624 5.66766C3.08773 6.23834 2.82293 6.86706 2.66158 7.53692L1.31647 7.71288C1.13542 7.73658 1.00002 7.89085 1 8.07345V9.06618V10.0607C1.00002 10.2435 1.13564 10.3978 1.31687 10.4214L2.66158 10.5957C2.8226 11.2654 3.08622 11.8954 3.43602 12.4669L2.60818 13.5426C2.49671 13.6875 2.51016 13.8927 2.6396 14.0218L3.34255 14.723H3.34324L4.04436 15.4259C4.17359 15.5555 4.37893 15.5688 4.52385 15.4571L5.59949 14.6278C6.17069 14.9777 6.80053 15.2425 7.47045 15.4039L7.64489 16.7486C7.6684 16.9298 7.82272 17.0655 8.00545 17.0655H9H9.99273C10.1755 17.0655 10.3298 16.9299 10.3533 16.7487L10.5278 15.4039C11.1977 15.2425 11.8275 14.9777 12.3987 14.6278L13.4744 15.4571C13.6193 15.5688 13.8246 15.5555 13.9539 15.4259L14.655 14.723H14.6553H14.6557L15.3586 14.0218C15.4881 13.8927 15.5015 13.6875 15.39 13.5426L14.5622 12.4669C14.912 11.8954 15.1756 11.2655 15.3366 10.5958L16.6831 10.4214C16.8644 10.3979 17 10.2435 17 10.0607V9.06618V8.07345C17 7.89085 16.8646 7.73657 16.6835 7.71288L15.3366 7.53686C15.1756 6.86843 14.9119 6.23874 14.5622 5.66732L15.39 4.59158C15.5014 4.4468 15.4881 4.24185 15.359 4.11266L14.6553 3.40902L13.9535 2.7072C13.8243 2.57805 13.6194 2.56478 13.4746 2.67618L12.3989 3.50402C11.8274 3.15422 11.1974 2.89054 10.5278 2.72953L10.3533 1.38311C10.3299 1.20186 10.1755 1.0662 9.99273 1.06618H9H8.00545V1.06618ZM8.325 1.79345H9H9.67318L9.83936 3.07652C9.85943 3.23139 9.97613 3.35616 10.1293 3.38652C10.8891 3.53711 11.5952 3.83463 12.2159 4.25016C12.3456 4.337 12.5163 4.33137 12.64 4.23618L13.6651 3.44731L14.1411 3.92328L14.6189 4.40112L13.83 5.42623C13.7348 5.54994 13.7292 5.72057 13.816 5.85027C14.2315 6.471 14.5291 7.17724 14.6797 7.93521C14.7101 8.08816 14.8346 8.2047 14.9893 8.22492L16.2727 8.39265V9.06618V9.74118L14.9897 9.90736C14.8348 9.92745 14.7101 10.0441 14.6797 10.1973C14.5291 10.9571 14.2315 11.6632 13.816 12.2839C13.7292 12.4136 13.7348 12.5842 13.83 12.7079L14.6186 13.7327L14.1414 14.2087C14.1412 14.2087 14.141 14.2087 14.1407 14.2087L13.6649 14.6857L12.6402 13.8957C12.5165 13.8003 12.3457 13.7946 12.2159 13.8815C11.5948 14.2973 10.8886 14.5965 10.1293 14.747C9.97618 14.7774 9.85952 14.9021 9.83944 15.0569L9.67324 16.3383H9H8.325L8.1588 15.057C8.13872 14.9021 8.02205 14.7774 7.86891 14.747C7.10964 14.5965 6.40344 14.2973 5.78233 13.8815C5.65252 13.7946 5.48172 13.8003 5.358 13.8957L4.33329 14.6857L3.85749 14.2087C3.85727 14.2087 3.85704 14.2087 3.85682 14.2087L3.3796 13.7327L4.16818 12.708C4.26337 12.5843 4.26901 12.4136 4.18222 12.2839C3.76667 11.6632 3.46911 10.9571 3.31853 10.1973C3.28818 10.0442 3.16346 9.92749 3.00864 9.90738L1.72727 9.7412V9.06618V8.39259L3.00904 8.22494C3.16369 8.20468 3.2882 8.08805 3.31853 7.93505C3.46898 7.17588 3.76816 6.47136 4.1826 5.8497C4.26901 5.72004 4.26321 5.54973 4.16818 5.42624L3.37931 4.40112L3.85715 3.92328L4.33313 3.44732L5.35824 4.23618C5.48195 4.33137 5.65261 4.337 5.78233 4.25016C6.40305 3.83463 7.10913 3.53712 7.86891 3.38653C8.02207 3.35614 8.13874 3.23138 8.1588 3.07653L8.325 1.79346V1.79345Z" fill="#9FA9BA"/>
-<path d="M8.00545 1.06618L8.00546 1.16618L8.10545 1.16618V1.06618H8.00545ZM7.64482 1.38311L7.54565 1.37026L7.54565 1.37027L7.64482 1.38311ZM7.4704 2.72959L7.49378 2.82682L7.56073 2.81072L7.56957 2.74244L7.4704 2.72959ZM5.59936 3.50402L5.53838 3.58327L5.59289 3.62523L5.65157 3.58931L5.59936 3.50402ZM4.52364 2.67618L4.46265 2.75543L4.46265 2.75543L4.52364 2.67618ZM4.04471 2.7072L3.97401 2.63649L3.974 2.63649L4.04471 2.7072ZM2.63925 4.11266L2.56854 4.04195L2.56854 4.04195L2.63925 4.11266ZM2.60818 4.59158L2.52893 4.65256L2.52893 4.65256L2.60818 4.59158ZM3.43624 5.66766L3.52158 5.71978L3.55739 5.66113L3.51549 5.60667L3.43624 5.66766ZM2.66158 7.53692L2.67455 7.63607L2.74271 7.62716L2.7588 7.56034L2.66158 7.53692ZM1.31647 7.71288L1.3035 7.61373L1.30349 7.61373L1.31647 7.71288ZM1 8.07345L0.9 8.07344V8.07345H1ZM1 10.0607H0.9V10.0608L1 10.0607ZM1.31687 10.4214L1.304 10.5205L1.30401 10.5205L1.31687 10.4214ZM2.66158 10.5957L2.75881 10.5724L2.74272 10.5054L2.67444 10.4966L2.66158 10.5957ZM3.43602 12.4669L3.51527 12.5279L3.55722 12.4733L3.52131 12.4147L3.43602 12.4669ZM2.60818 13.5426L2.52893 13.4816L2.52892 13.4816L2.60818 13.5426ZM2.6396 14.0218L2.56898 14.0926L2.56898 14.0926L2.6396 14.0218ZM3.34255 14.723L3.27193 14.7938L3.3012 14.823H3.34255V14.723ZM3.34324 14.723L3.41404 14.6523L3.38473 14.623H3.34324V14.723ZM4.04436 15.4259L3.97356 15.4965L3.97357 15.4965L4.04436 15.4259ZM4.52385 15.4571L4.5849 15.5363L4.58491 15.5363L4.52385 15.4571ZM5.59949 14.6278L5.65173 14.5425L5.59298 14.5065L5.53843 14.5486L5.59949 14.6278ZM7.47045 15.4039L7.56962 15.391L7.56077 15.3228L7.49387 15.3067L7.47045 15.4039ZM7.64489 16.7486L7.74406 16.7358L7.64489 16.7486ZM8.00545 17.0655L8.00543 17.1655H8.00545V17.0655ZM9.99273 17.0655V17.1655H9.99274L9.99273 17.0655ZM10.3533 16.7487L10.4525 16.7615V16.7615L10.3533 16.7487ZM10.5278 15.4039L10.5044 15.3067L10.4375 15.3228L10.4286 15.391L10.5278 15.4039ZM12.3987 14.6278L12.4598 14.5486L12.4053 14.5065L12.3465 14.5425L12.3987 14.6278ZM13.4744 15.4571L13.4133 15.5363L13.4133 15.5363L13.4744 15.4571ZM13.9539 15.4259L14.0246 15.4966L14.0247 15.4965L13.9539 15.4259ZM14.655 14.723V14.623H14.6135L14.5842 14.6523L14.655 14.723ZM14.6557 14.723V14.823H14.697L14.7263 14.7938L14.6557 14.723ZM15.3586 14.0218L15.4293 14.0926L15.4293 14.0926L15.3586 14.0218ZM15.39 13.5426L15.4693 13.4817L15.4693 13.4816L15.39 13.5426ZM14.5622 12.4669L14.4769 12.4147L14.441 12.4734L14.4829 12.5279L14.5622 12.4669ZM15.3366 10.5958L15.3238 10.4966L15.2555 10.5055L15.2394 10.5724L15.3366 10.5958ZM16.6831 10.4214L16.696 10.5206L16.696 10.5205L16.6831 10.4214ZM17 10.0607L17.1 10.0607V10.0607H17ZM17 8.07345H17.1V8.07344L17 8.07345ZM16.6835 7.71288L16.6965 7.61373L16.6965 7.61373L16.6835 7.71288ZM15.3366 7.53686L15.2394 7.56028L15.2555 7.62711L15.3236 7.63602L15.3366 7.53686ZM14.5622 5.66732L14.4829 5.60633L14.441 5.66085L14.4769 5.71952L14.5622 5.66732ZM15.39 4.59158L15.4693 4.65257L15.4693 4.65257L15.39 4.59158ZM15.359 4.11266L15.4297 4.04196L15.4297 4.04195L15.359 4.11266ZM14.6553 3.40902L14.5846 3.47973H14.5846L14.6553 3.40902ZM13.9535 2.7072L14.0242 2.63649L14.0242 2.63648L13.9535 2.7072ZM13.4746 2.67618L13.4136 2.59693L13.4136 2.59693L13.4746 2.67618ZM12.3989 3.50402L12.3466 3.58931L12.4053 3.62523L12.4598 3.58327L12.3989 3.50402ZM10.5278 2.72953L10.4286 2.74238L10.4375 2.81067L10.5044 2.82676L10.5278 2.72953ZM10.3533 1.38311L10.4525 1.37027L10.4525 1.37026L10.3533 1.38311ZM9.99273 1.06618L9.99274 0.966181H9.99273V1.06618ZM8.00545 1.06618V0.966181H7.90545V1.06618H8.00545ZM8.325 1.79345V1.69345H8.225V1.79345H8.325ZM9.67318 1.79345L9.77235 1.78061L9.76107 1.69345H9.67318V1.79345ZM9.83936 3.07652L9.74019 3.08937V3.08937L9.83936 3.07652ZM10.1293 3.38652L10.1099 3.48461L10.1099 3.48462L10.1293 3.38652ZM12.2159 4.25016L12.2715 4.16706L12.2715 4.16706L12.2159 4.25016ZM12.64 4.23618L12.701 4.31543L12.701 4.31543L12.64 4.23618ZM13.6651 3.44731L13.7358 3.3766L13.6737 3.3145L13.6041 3.36806L13.6651 3.44731ZM14.1411 3.92328L14.2118 3.85257L14.2118 3.85257L14.1411 3.92328ZM14.6189 4.40112L14.6982 4.46211L14.7517 4.39251L14.6896 4.33041L14.6189 4.40112ZM13.83 5.42623L13.7508 5.36525L13.7507 5.36526L13.83 5.42623ZM13.816 5.85027L13.7329 5.90589L13.7329 5.9059L13.816 5.85027ZM14.6797 7.93521L14.5816 7.95469L14.5816 7.9547L14.6797 7.93521ZM14.9893 8.22492L14.9763 8.32408L14.9763 8.32408L14.9893 8.22492ZM16.2727 8.39265H16.3727V8.30486L16.2857 8.29349L16.2727 8.39265ZM16.2727 9.74118L16.2856 9.84035L16.3727 9.82906V9.74118H16.2727ZM14.9897 9.90736L14.9768 9.80819L14.9768 9.80819L14.9897 9.90736ZM14.6797 10.1973L14.5816 10.1779V10.1779L14.6797 10.1973ZM13.816 12.2839L13.7329 12.2283L13.7329 12.2283L13.816 12.2839ZM13.83 12.7079L13.7507 12.7689L13.7508 12.7689L13.83 12.7079ZM14.6186 13.7327L14.6893 13.8035L14.7515 13.7414L14.6979 13.6717L14.6186 13.7327ZM14.1414 14.2087L14.1413 14.3087L14.1827 14.3087L14.212 14.2795L14.1414 14.2087ZM14.1407 14.2087L14.1406 14.1087L14.0992 14.1087L14.0699 14.1381L14.1407 14.2087ZM13.6649 14.6857L13.6039 14.7649L13.6736 14.8187L13.7358 14.7563L13.6649 14.6857ZM12.6402 13.8957L12.7013 13.8165L12.7013 13.8165L12.6402 13.8957ZM12.2159 13.8815L12.1603 13.7984L12.1603 13.7984L12.2159 13.8815ZM10.1293 14.747L10.1099 14.6489L10.1098 14.6489L10.1293 14.747ZM9.83944 15.0569L9.74027 15.0441L9.74027 15.0441L9.83944 15.0569ZM9.67324 16.3383V16.4383H9.7611L9.77241 16.3511L9.67324 16.3383ZM8.325 16.3383L8.22583 16.3511L8.23713 16.4383H8.325V16.3383ZM8.1588 15.057L8.05963 15.0698L8.05963 15.0698L8.1588 15.057ZM7.86891 14.747L7.88837 14.6489L7.88835 14.6489L7.86891 14.747ZM5.78233 13.8815L5.83796 13.7984L5.83795 13.7984L5.78233 13.8815ZM5.358 13.8957L5.29695 13.8165L5.29694 13.8165L5.358 13.8957ZM4.33329 14.6857L4.26249 14.7564L4.32465 14.8187L4.39435 14.7649L4.33329 14.6857ZM3.85749 14.2087L3.92829 14.1381L3.89903 14.1087L3.85758 14.1087L3.85749 14.2087ZM3.85682 14.2087L3.7862 14.2795L3.81551 14.3087L3.85691 14.3087L3.85682 14.2087ZM3.3796 13.7327L3.30035 13.6717L3.24672 13.7414L3.30898 13.8035L3.3796 13.7327ZM4.16818 12.708L4.24743 12.769L4.24743 12.7689L4.16818 12.708ZM4.18222 12.2839L4.26533 12.2283L4.26532 12.2283L4.18222 12.2839ZM3.31853 10.1973L3.22044 10.2168V10.2168L3.31853 10.1973ZM3.00864 9.90738L3.02152 9.80821L3.0215 9.80821L3.00864 9.90738ZM1.72727 9.7412H1.62727V9.82907L1.71441 9.84037L1.72727 9.7412ZM1.72727 8.39259L1.7143 8.29344L1.62727 8.30482V8.39259H1.72727ZM3.00904 8.22494L3.02201 8.32409L3.02203 8.32409L3.00904 8.22494ZM3.31853 7.93505L3.41662 7.95449L3.31853 7.93505ZM4.1826 5.8497L4.26581 5.90517L4.26582 5.90516L4.1826 5.8497ZM4.16818 5.42624L4.24743 5.36525V5.36525L4.16818 5.42624ZM3.37931 4.40112L3.3086 4.33041L3.2465 4.39251L3.30006 4.46211L3.37931 4.40112ZM3.85715 3.92328L3.78644 3.85257L3.78643 3.85257L3.85715 3.92328ZM4.33313 3.44732L4.39411 3.36807L4.32452 3.31451L4.26242 3.37661L4.33313 3.44732ZM5.35824 4.23618L5.29725 4.31543L5.29725 4.31543L5.35824 4.23618ZM5.78233 4.25016L5.7267 4.16706L5.7267 4.16707L5.78233 4.25016ZM7.86891 3.38653L7.88835 3.48462L7.88837 3.48462L7.86891 3.38653ZM8.1588 3.07653L8.25797 3.08937V3.08937L8.1588 3.07653ZM8.325 1.79346L8.425 1.80641V1.79346H8.325ZM8.00545 0.966181C7.77242 0.966194 7.5756 1.13916 7.54565 1.37026L7.74399 1.39597C7.76102 1.26455 7.87294 1.16619 8.00546 1.16618L8.00545 0.966181ZM7.54565 1.37027L7.37123 2.71674L7.56957 2.74244L7.74399 1.39596L7.54565 1.37027ZM7.44702 2.63236C6.7671 2.79584 6.12746 3.06353 5.54716 3.41873L5.65157 3.58931C6.21421 3.24493 6.83442 2.98536 7.49378 2.82682L7.44702 2.63236ZM5.66035 3.42477L4.58462 2.59693L4.46265 2.75543L5.53838 3.58327L5.66035 3.42477ZM4.58463 2.59693C4.40004 2.45488 4.13872 2.47181 3.97401 2.63649L4.11541 2.77792C4.20908 2.68428 4.35768 2.67465 4.46265 2.75543L4.58463 2.59693ZM3.974 2.63649L3.27218 3.33831L3.4136 3.47973L4.11542 2.77792L3.974 2.63649ZM3.27218 3.33831L2.56854 4.04195L2.70997 4.18337L3.4136 3.47973L3.27218 3.33831ZM2.56854 4.04195C2.40385 4.20665 2.3869 4.46796 2.52893 4.65256L2.68744 4.5306C2.60667 4.42563 2.61631 4.27703 2.70997 4.18337L2.56854 4.04195ZM2.52893 4.65256L3.35698 5.72864L3.51549 5.60667L2.68743 4.53059L2.52893 4.65256ZM3.35089 5.61554C2.9971 6.19487 2.72821 6.83326 2.56436 7.5135L2.7588 7.56034C2.91764 6.90087 3.17836 6.2818 3.52158 5.71978L3.35089 5.61554ZM2.64861 7.43776L1.3035 7.61373L1.32944 7.81204L2.67455 7.63607L2.64861 7.43776ZM1.30349 7.61373C1.07266 7.64394 0.900021 7.84064 0.9 8.07344L1.1 8.07346C1.10001 7.94107 1.19819 7.82922 1.32945 7.81204L1.30349 7.61373ZM0.9 8.07345V9.06618H1.1V8.07345H0.9ZM0.9 9.06618V10.0607H1.1V9.06618H0.9ZM0.9 10.0608C0.90002 10.2938 1.07294 10.4905 1.304 10.5205L1.32974 10.3222C1.19835 10.3051 1.10001 10.1932 1.1 10.0607L0.9 10.0608ZM1.30401 10.5205L2.64872 10.6949L2.67444 10.4966L1.32973 10.3222L1.30401 10.5205ZM2.56435 10.6191C2.72784 11.2991 2.99552 11.9388 3.35073 12.5191L3.52131 12.4147C3.17692 11.852 2.91736 11.2318 2.75881 10.5724L2.56435 10.6191ZM3.35677 12.4059L2.52893 13.4816L2.68743 13.6036L3.51527 12.5279L3.35677 12.4059ZM2.52892 13.4816C2.38679 13.6664 2.40394 13.928 2.56898 14.0926L2.71022 13.951C2.61638 13.8574 2.60662 13.7087 2.68745 13.6036L2.52892 13.4816ZM2.56898 14.0926L3.27193 14.7938L3.41317 14.6522L2.71022 13.951L2.56898 14.0926ZM3.34255 14.823H3.34324V14.623H3.34255V14.823ZM3.27243 14.7936L3.97356 15.4965L4.11517 15.3553L3.41404 14.6523L3.27243 14.7936ZM3.97357 15.4965C4.13832 15.6617 4.40013 15.6787 4.5849 15.5363L4.46281 15.3779C4.35773 15.4589 4.20885 15.4492 4.11516 15.3553L3.97357 15.4965ZM4.58491 15.5363L5.66055 14.707L5.53843 14.5486L4.46279 15.3779L4.58491 15.5363ZM5.54726 14.7131C6.12721 15.0683 6.76675 15.3373 7.44704 15.5011L7.49387 15.3067C6.83431 15.1478 6.21417 14.887 5.65173 14.5425L5.54726 14.7131ZM7.37129 15.4167L7.54572 16.7615L7.74406 16.7358L7.56962 15.391L7.37129 15.4167ZM7.54572 16.7615C7.57569 16.9925 7.77245 17.1655 8.00543 17.1655L8.00548 16.9655C7.87299 16.9655 7.7611 16.8672 7.74406 16.7358L7.54572 16.7615ZM8.00545 17.1655H9V16.9655H8.00545V17.1655ZM9 17.1655H9.99273V16.9655H9V17.1655ZM9.99274 17.1655C10.2257 17.1655 10.4225 16.9926 10.4525 16.7615L10.2542 16.7358C10.2371 16.8672 10.1252 16.9655 9.99272 16.9655L9.99274 17.1655ZM10.4525 16.7615L10.627 15.4167L10.4286 15.391L10.2542 16.7358L10.4525 16.7615ZM10.5512 15.5011C11.2315 15.3373 11.871 15.0683 12.451 14.7131L12.3465 14.5425C11.7841 14.887 11.1639 15.1478 10.5044 15.3067L10.5512 15.5011ZM12.3377 14.707L13.4133 15.5363L13.5354 15.3779L12.4598 14.5486L12.3377 14.707ZM13.4133 15.5363C13.5981 15.6787 13.8599 15.6617 14.0246 15.4966L13.8831 15.3553C13.7894 15.4492 13.6405 15.4589 13.5354 15.3779L13.4133 15.5363ZM14.0247 15.4965L14.7258 14.7936L14.5842 14.6523L13.8831 15.3553L14.0247 15.4965ZM14.655 14.823H14.6553V14.623H14.655V14.823ZM14.6553 14.823H14.6557V14.623H14.6553V14.823ZM14.7263 14.7938L15.4293 14.0926L15.288 13.951L14.5851 14.6522L14.7263 14.7938ZM15.4293 14.0926C15.5943 13.928 15.6114 13.6664 15.4693 13.4817L15.3107 13.6036C15.3916 13.7087 15.3818 13.8574 15.288 13.951L15.4293 14.0926ZM15.4693 13.4816L14.6414 12.4059L14.4829 12.5279L15.3108 13.6036L15.4693 13.4816ZM14.6475 12.5191C15.0027 11.9388 15.2704 11.2991 15.4339 10.6192L15.2394 10.5724C15.0809 11.2318 14.8213 11.852 14.4769 12.4147L14.6475 12.5191ZM15.3495 10.695L16.696 10.5206L16.6703 10.3222L15.3238 10.4966L15.3495 10.695ZM16.696 10.5205C16.9271 10.4906 17.1 10.2938 17.1 10.0607L16.9 10.0607C16.9 10.1932 16.8017 10.3052 16.6703 10.3222L16.696 10.5205ZM17.1 10.0607V9.06618H16.9V10.0607H17.1ZM17.1 9.06618V8.07345H16.9V9.06618H17.1ZM17.1 8.07344C17.1 7.84063 16.9273 7.64393 16.6965 7.61373L16.6705 7.81204C16.8018 7.82921 16.9 7.94107 16.9 8.07346L17.1 8.07344ZM16.6965 7.61373L15.3495 7.43771L15.3236 7.63602L16.6706 7.81204L16.6965 7.61373ZM15.4338 7.51345C15.2703 6.83474 15.0026 6.19535 14.6475 5.61511L14.4769 5.71952C14.8212 6.28213 15.0808 6.90212 15.2394 7.56028L15.4338 7.51345ZM14.6414 5.7283L15.4693 4.65257L15.3108 4.53059L14.4829 5.60633L14.6414 5.7283ZM15.4693 4.65257C15.6113 4.46798 15.5944 4.20666 15.4297 4.04196L15.2882 4.18336C15.3819 4.27703 15.3915 4.42563 15.3107 4.53059L15.4693 4.65257ZM15.4297 4.04195L14.726 3.33831L14.5846 3.47973L15.2883 4.18337L15.4297 4.04195ZM14.726 3.33831L14.0242 2.63649L13.8828 2.77792L14.5846 3.47973L14.726 3.33831ZM14.0242 2.63648C13.8595 2.47182 13.5982 2.45489 13.4136 2.59693L13.5356 2.75543C13.6405 2.67466 13.7891 2.68429 13.8828 2.77793L14.0242 2.63648ZM13.4136 2.59693L12.3379 3.42477L12.4598 3.58327L13.5356 2.75543L13.4136 2.59693ZM12.4511 3.41873C11.8707 3.06353 11.2311 2.79579 10.5512 2.6323L10.5044 2.82676C11.1638 2.9853 11.784 3.24491 12.3466 3.58931L12.4511 3.41873ZM10.627 2.71669L10.4525 1.37027L10.2542 1.39596L10.4286 2.74238L10.627 2.71669ZM10.4525 1.37026C10.4226 1.13917 10.2258 0.966206 9.99274 0.966181L9.99272 1.16618C10.1252 1.1662 10.2371 1.26455 10.2542 1.39597L10.4525 1.37026ZM9.99273 0.966181H9V1.16618H9.99273V0.966181ZM9 0.966181H8.00545V1.16618H9V0.966181ZM7.90545 1.06618V1.06618H8.10545V1.06618H7.90545ZM8.325 1.89345H9V1.69345H8.325V1.89345ZM9 1.89345H9.67318V1.69345H9V1.89345ZM9.57401 1.8063L9.74019 3.08937L9.93854 3.06368L9.77235 1.78061L9.57401 1.8063ZM9.74019 3.08937C9.76578 3.28682 9.91456 3.4459 10.1099 3.48461L10.1488 3.28843C10.0377 3.26642 9.95308 3.17596 9.93853 3.06367L9.74019 3.08937ZM10.1099 3.48462C10.8566 3.63261 11.5504 3.92497 12.1603 4.33326L12.2715 4.16706C11.64 3.74429 10.9217 3.44161 10.1488 3.28843L10.1099 3.48462ZM12.1603 4.33326C12.3257 4.44397 12.5433 4.4368 12.701 4.31543L12.579 4.15692C12.4893 4.22594 12.3656 4.23002 12.2715 4.16706L12.1603 4.33326ZM12.701 4.31543L13.7261 3.52656L13.6041 3.36806L12.579 4.15693L12.701 4.31543ZM13.5944 3.51802L14.0704 3.99399L14.2118 3.85257L13.7358 3.3766L13.5944 3.51802ZM14.0704 3.99399L14.5482 4.47183L14.6896 4.33041L14.2118 3.85257L14.0704 3.99399ZM14.5397 4.34013L13.7508 5.36525L13.9092 5.48722L14.6982 4.46211L14.5397 4.34013ZM13.7507 5.36526C13.6294 5.52297 13.6222 5.74052 13.7329 5.90589L13.8991 5.79465C13.8362 5.70061 13.8403 5.5769 13.9093 5.48721L13.7507 5.36526ZM13.7329 5.9059C14.1412 6.51583 14.4337 7.20981 14.5816 7.95469L14.7778 7.91573C14.6246 7.14467 14.3219 6.42617 13.8991 5.79464L13.7329 5.9059ZM14.5816 7.9547C14.6204 8.14971 14.7792 8.2983 14.9763 8.32408L15.0022 8.12576C14.8901 8.11111 14.7998 8.02661 14.7778 7.91572L14.5816 7.9547ZM14.9763 8.32408L16.2598 8.4918L16.2857 8.29349L15.0022 8.12576L14.9763 8.32408ZM16.1727 8.39265V9.06618H16.3727V8.39265H16.1727ZM16.1727 9.06618V9.74118H16.3727V9.06618H16.1727ZM16.2599 9.64201L14.9768 9.80819L15.0025 10.0065L16.2856 9.84035L16.2599 9.64201ZM14.9768 9.80819C14.7794 9.83381 14.6203 9.98259 14.5816 10.1779L14.7778 10.2168C14.7998 10.1057 14.8903 10.0211 15.0025 10.0065L14.9768 9.80819ZM14.5816 10.1779C14.4336 10.9246 14.1412 11.6184 13.7329 12.2283L13.8991 12.3395C14.3219 11.708 14.6246 10.9897 14.7778 10.2167L14.5816 10.1779ZM13.7329 12.2283C13.6222 12.3937 13.6294 12.6112 13.7507 12.7689L13.9093 12.647C13.8403 12.5573 13.8362 12.4336 13.8991 12.3395L13.7329 12.2283ZM13.7508 12.7689L14.5394 13.7937L14.6979 13.6717L13.9092 12.647L13.7508 12.7689ZM14.548 13.6619L14.0708 14.1379L14.212 14.2795L14.6893 13.8035L14.548 13.6619ZM14.1415 14.1087C14.1412 14.1087 14.1409 14.1087 14.1406 14.1087L14.1408 14.3087C14.141 14.3087 14.1412 14.3087 14.1413 14.3087L14.1415 14.1087ZM14.0699 14.1381L13.5941 14.6151L13.7358 14.7563L14.2115 14.2793L14.0699 14.1381ZM13.726 14.6065L12.7013 13.8165L12.5792 13.9749L13.6039 14.7649L13.726 14.6065ZM12.7013 13.8165C12.5435 13.6949 12.3258 13.6876 12.1603 13.7984L12.2715 13.9646C12.3656 13.9016 12.4895 13.9057 12.5792 13.9749L12.7013 13.8165ZM12.1603 13.7984C11.5499 14.207 10.8559 14.5011 10.1099 14.6489L10.1488 14.8451C10.9213 14.692 11.6397 14.3875 12.2715 13.9646L12.1603 13.7984ZM10.1098 14.6489C9.91461 14.6877 9.76588 14.8467 9.74027 15.0441L9.93861 15.0698C9.95317 14.9576 10.0377 14.8671 10.1488 14.8451L10.1098 14.6489ZM9.74027 15.0441L9.57407 16.3254L9.77241 16.3511L9.93861 15.0698L9.74027 15.0441ZM9.67324 16.2383H9V16.4383H9.67324V16.2383ZM9 16.2383H8.325V16.4383H9V16.2383ZM8.42417 16.3254L8.25797 15.0441L8.05963 15.0698L8.22583 16.3511L8.42417 16.3254ZM8.25797 15.0441C8.23237 14.8467 8.08362 14.6877 7.88837 14.6489L7.84945 14.8451C7.96048 14.8671 8.04507 14.9576 8.05963 15.0698L8.25797 15.0441ZM7.88835 14.6489C7.14232 14.5011 6.44837 14.207 5.83796 13.7984L5.7267 13.9646C6.3585 14.3875 7.07695 14.692 7.84947 14.8451L7.88835 14.6489ZM5.83795 13.7984C5.67245 13.6876 5.45469 13.6949 5.29695 13.8165L5.41905 13.9749C5.50875 13.9057 5.63259 13.9016 5.7267 13.9646L5.83795 13.7984ZM5.29694 13.8165L4.27223 14.6066L4.39435 14.7649L5.41906 13.9749L5.29694 13.8165ZM4.40409 14.6151L3.92829 14.1381L3.78669 14.2793L4.26249 14.7564L4.40409 14.6151ZM3.85758 14.1087C3.8573 14.1087 3.85701 14.1087 3.85673 14.1087L3.85691 14.3087C3.85707 14.3087 3.85724 14.3087 3.8574 14.3087L3.85758 14.1087ZM3.92744 14.1379L3.45022 13.6619L3.30898 13.8035L3.7862 14.2795L3.92744 14.1379ZM3.45885 13.7937L4.24743 12.769L4.08893 12.647L3.30035 13.6717L3.45885 13.7937ZM4.24743 12.7689C4.36879 12.6112 4.37599 12.3937 4.26533 12.2283L4.09911 12.3395C4.16204 12.4336 4.15794 12.5573 4.08893 12.647L4.24743 12.7689ZM4.26532 12.2283C3.85701 11.6184 3.5646 10.9246 3.41662 10.1779L3.22044 10.2168C3.37361 10.9897 3.67634 11.708 4.09912 12.3396L4.26532 12.2283ZM3.41662 10.1779C3.37792 9.98263 3.21892 9.83385 3.02152 9.80821L2.99576 10.0065C3.10801 10.0211 3.19843 10.1057 3.22044 10.2168L3.41662 10.1779ZM3.0215 9.80821L1.74013 9.64203L1.71441 9.84037L2.99577 10.0065L3.0215 9.80821ZM1.82727 9.7412V9.06618H1.62727V9.7412H1.82727ZM1.82727 9.06618V8.39259H1.62727V9.06618H1.82727ZM1.74024 8.49175L3.02201 8.32409L2.99607 8.12578L1.7143 8.29344L1.74024 8.49175ZM3.02203 8.32409C3.21921 8.29826 3.37796 8.14956 3.41662 7.95449L3.22044 7.91561C3.19845 8.02654 3.10818 8.11109 2.99605 8.12578L3.02203 8.32409ZM3.41662 7.95449C3.56444 7.20861 3.8584 6.51628 4.26581 5.90517L4.09939 5.79424C3.67793 6.42644 3.37352 7.14314 3.22044 7.91561L3.41662 7.95449ZM4.26582 5.90516C4.37599 5.73984 4.36859 5.5227 4.24743 5.36525L4.08893 5.48722C4.15783 5.57676 4.16204 5.70024 4.09939 5.79425L4.26582 5.90516ZM4.24743 5.36525L3.45856 4.34014L3.30006 4.46211L4.08893 5.48722L4.24743 5.36525ZM3.45002 4.47184L3.92786 3.99399L3.78643 3.85257L3.3086 4.33041L3.45002 4.47184ZM3.92786 3.99399L4.40384 3.51803L4.26242 3.37661L3.78644 3.85257L3.92786 3.99399ZM4.27214 3.52657L5.29725 4.31543L5.41922 4.15693L4.39411 3.36807L4.27214 3.52657ZM5.29725 4.31543C5.45498 4.4368 5.67258 4.44398 5.83796 4.33326L5.7267 4.16707C5.63265 4.23003 5.50891 4.22595 5.41922 4.15693L5.29725 4.31543ZM5.83796 4.33326C6.44787 3.92497 7.14167 3.63261 7.88835 3.48462L7.84947 3.28843C7.07659 3.44162 6.35824 3.7443 5.7267 4.16706L5.83796 4.33326ZM7.88837 3.48462C8.08364 3.44588 8.2324 3.2868 8.25797 3.08937L8.05963 3.06368C8.04508 3.17595 7.9605 3.26641 7.84945 3.28844L7.88837 3.48462ZM8.25797 3.08937L8.42417 1.8063L8.22583 1.78061L8.05963 3.06368L8.25797 3.08937ZM8.425 1.79346V1.79345H8.225V1.79346H8.425Z" fill="#9FA9BA"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8.99964 5.42982C6.99417 5.42982 5.36328 7.06258 5.36328 9.06618C5.36328 11.0698 6.99417 12.7026 8.99964 12.7026C11.0035 12.7026 12.636 11.07 12.636 9.06618C12.636 7.06234 11.0035 5.42982 8.99964 5.42982ZM8.99964 6.15709C10.6103 6.15709 11.9087 7.45548 11.9087 9.06618C11.9087 10.6769 10.6103 11.9753 8.99964 11.9753C7.38692 11.9753 6.09055 10.6771 6.09055 9.06618C6.09055 7.45523 7.38692 6.15709 8.99964 6.15709Z" fill="#9FA9BA"/>
-<path d="M8.99964 5.32982C6.93891 5.32982 5.26328 7.00738 5.26328 9.06618H5.46328C5.46328 7.11777 7.04943 5.52982 8.99964 5.52982V5.32982ZM5.26328 9.06618C5.26328 11.125 6.93891 12.8026 8.99964 12.8026V12.6026C7.04943 12.6026 5.46328 11.0146 5.46328 9.06618H5.26328ZM8.99964 12.8026C11.0587 12.8026 12.736 11.1253 12.736 9.06618H12.536C12.536 11.0148 10.9483 12.6026 8.99964 12.6026V12.8026ZM12.736 9.06618C12.736 7.00711 11.0587 5.32982 8.99964 5.32982V5.52982C10.9483 5.52982 12.536 7.11756 12.536 9.06618H12.736ZM8.99964 6.25709C10.5551 6.25709 11.8087 7.51071 11.8087 9.06618H12.0087C12.0087 7.40025 10.6656 6.05709 8.99964 6.05709V6.25709ZM11.8087 9.06618C11.8087 10.6217 10.5551 11.8753 8.99964 11.8753V12.0753C10.6656 12.0753 12.0087 10.7321 12.0087 9.06618H11.8087ZM8.99964 11.8753C7.44219 11.8753 6.19055 10.622 6.19055 9.06618H5.99055C5.99055 10.7323 7.33165 12.0753 8.99964 12.0753V11.8753ZM6.19055 9.06618C6.19055 7.51042 7.44219 6.25709 8.99964 6.25709V6.05709C7.33165 6.05709 5.99055 7.40004 5.99055 9.06618H6.19055Z" fill="#9FA9BA"/>
-</svg>
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index 288fb3cadc..c868c81549 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -224,6 +224,7 @@ $copy-button-url: "$(res)/img/icon_copy_message.svg";
 
 // e2e
 $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
+$e2e-unknown-color: #e8bf37;
 $e2e-unverified-color: #e8bf37;
 $e2e-warning-color: #ba6363;
 
diff --git a/scripts/ci/build.sh b/scripts/ci/build.sh
deleted file mode 100755
index 0b1fa23093..0000000000
--- a/scripts/ci/build.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/bin/bash
-#
-# script which is run by the CI build (after `yarn test`).
-#
-# clones riot-web develop and runs the tests against our version of react-sdk.
-
-set -ev
-
-RIOT_WEB_DIR=riot-web
-REACT_SDK_DIR=`pwd`
-
-yarn link
-
-scripts/fetchdep.sh vector-im riot-web
-
-pushd "$RIOT_WEB_DIR"
-
-yarn link matrix-js-sdk
-yarn link matrix-react-sdk
-
-yarn install
-
-yarn build
-
-popd
diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/end-to-end-tests.sh
index a592888292..9bdb512940 100755
--- a/scripts/ci/end-to-end-tests.sh
+++ b/scripts/ci/end-to-end-tests.sh
@@ -21,15 +21,16 @@ handle_error() {
 
 trap 'handle_error' ERR
 
-RIOT_WEB_DIR=riot-web
-REACT_SDK_DIR=`pwd`
-
 
 echo "--- Building Riot"
-scripts/ci/build.sh
+scripts/ci/layered-riot-web.sh
+cd ../riot-web
+riot_web_dir=`pwd`
+CI_PACKAGE=true yarn build
+cd ../matrix-react-sdk
 # run end to end tests
 pushd test/end-to-end-tests
-ln -s $REACT_SDK_DIR/$RIOT_WEB_DIR riot/riot-web
+ln -s $riot_web_dir riot/riot-web
 # PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh
 # CHROME_PATH=$(which google-chrome-stable) ./run.sh
 echo "--- Install synapse & other dependencies"
diff --git a/scripts/ci/install-deps.sh b/scripts/ci/install-deps.sh
index a2e2e59a45..14b5fc5393 100755
--- a/scripts/ci/install-deps.sh
+++ b/scripts/ci/install-deps.sh
@@ -6,9 +6,9 @@ scripts/fetchdep.sh matrix-org matrix-js-sdk
 
 pushd matrix-js-sdk
 yarn link
-yarn install
+yarn install $@
 yarn build
 popd
 
 yarn link matrix-js-sdk
-yarn install
+yarn install $@
diff --git a/scripts/ci/layered-riot-web.sh b/scripts/ci/layered-riot-web.sh
new file mode 100755
index 0000000000..f58794b451
--- /dev/null
+++ b/scripts/ci/layered-riot-web.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+# Creates an environment similar to one that riot-web would expect for
+# development. This means going one directory up (and assuming we're in
+# a directory like /workdir/matrix-react-sdk) and putting riot-web and
+# the js-sdk there.
+
+cd ../  # Assume we're at something like /workdir/matrix-react-sdk
+
+# Set up the js-sdk first
+matrix-react-sdk/scripts/fetchdep.sh matrix-org matrix-js-sdk
+pushd matrix-js-sdk
+yarn link
+yarn install
+popd
+
+# Now set up the react-sdk
+pushd matrix-react-sdk
+yarn link matrix-js-sdk
+yarn link
+yarn install
+popd
+
+# Finally, set up riot-web
+matrix-react-sdk/scripts/fetchdep.sh vector-im riot-web
+pushd riot-web
+yarn link matrix-js-sdk
+yarn link matrix-react-sdk
+yarn install
+yarn build:res
+popd
diff --git a/scripts/ci/riot-unit-tests.sh b/scripts/ci/riot-unit-tests.sh
index 215af13030..337c0fe6c3 100755
--- a/scripts/ci/riot-unit-tests.sh
+++ b/scripts/ci/riot-unit-tests.sh
@@ -6,9 +6,7 @@
 
 set -ev
 
-RIOT_WEB_DIR=riot-web
-
-scripts/ci/build.sh
-pushd "$RIOT_WEB_DIR"
+scripts/ci/layered-riot-web.sh
+cd ../riot-web
+yarn build:genfiles # so the tests can run. Faster version of `build`
 yarn test
-popd
diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh
index f82752bfc5..0142305797 100755
--- a/scripts/fetchdep.sh
+++ b/scripts/fetchdep.sh
@@ -17,7 +17,7 @@ clone() {
     if [ -n "$branch" ]
     then
         echo "Trying to use $org/$repo#$branch"
-        git clone git://github.com/$org/$repo.git $repo --branch "$branch" && exit 0
+        git clone git://github.com/$org/$repo.git $repo --branch "$branch" --depth 1 && exit 0
     fi
 }
 
diff --git a/src/AsyncWrapper.js b/src/AsyncWrapper.js
new file mode 100644
index 0000000000..b7b81688e1
--- /dev/null
+++ b/src/AsyncWrapper.js
@@ -0,0 +1,92 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import createReactClass from 'create-react-class';
+import * as sdk from './index';
+import PropTypes from 'prop-types';
+import { _t } from './languageHandler';
+
+/**
+ * Wrap an asynchronous loader function with a react component which shows a
+ * spinner until the real component loads.
+ */
+export default createReactClass({
+    propTypes: {
+        /** A promise which resolves with the real component
+         */
+        prom: PropTypes.object.isRequired,
+    },
+
+    getInitialState: function() {
+        return {
+            component: null,
+            error: null,
+        };
+    },
+
+    componentWillMount: function() {
+        this._unmounted = false;
+        // XXX: temporary logging to try to diagnose
+        // https://github.com/vector-im/riot-web/issues/3148
+        console.log('Starting load of AsyncWrapper for modal');
+        this.props.prom.then((result) => {
+            if (this._unmounted) {
+                return;
+            }
+            // Take the 'default' member if it's there, then we support
+            // passing in just an import()ed module, since ES6 async import
+            // always returns a module *namespace*.
+            const component = result.default ? result.default : result;
+            this.setState({component});
+        }).catch((e) => {
+            console.warn('AsyncWrapper promise failed', e);
+            this.setState({error: e});
+        });
+    },
+
+    componentWillUnmount: function() {
+        this._unmounted = true;
+    },
+
+    _onWrapperCancelClick: function() {
+        this.props.onFinished(false);
+    },
+
+    render: function() {
+        if (this.state.component) {
+            const Component = this.state.component;
+            return <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 />;
+        }
+    },
+});
+
diff --git a/src/DeviceListener.js b/src/DeviceListener.js
index a4c5785db4..630d1a61c0 100644
--- a/src/DeviceListener.js
+++ b/src/DeviceListener.js
@@ -20,10 +20,13 @@ import * as sdk from './index';
 import { _t } from './languageHandler';
 import ToastStore from './stores/ToastStore';
 
-function toastKey(device) {
-    return 'newsession_' + device.deviceId;
+function toastKey(deviceId) {
+    return 'newsession_' + deviceId;
 }
 
+const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
+const THIS_DEVICE_TOAST_KEY = 'setupencryption';
+
 export default class DeviceListener {
     static sharedInstance() {
         if (!global.mx_DeviceListener) global.mx_DeviceListener = new DeviceListener();
@@ -31,44 +34,120 @@ export default class DeviceListener {
     }
 
     constructor() {
+        // set of device IDs we're currently showing toasts for
+        this._activeNagToasts = new Set();
         // device IDs for which the user has dismissed the verify toast ('Later')
         this._dismissed = new Set();
+        // has the user dismissed any of the various nag toasts to setup encryption on this device?
+        this._dismissedThisDeviceToast = false;
+
+        // cache of the key backup info
+        this._keyBackupInfo = null;
+        this._keyBackupFetchedAt = null;
     }
 
     start() {
         MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated);
         MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged);
-        this.recheck();
+        MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
+        this._recheck();
     }
 
     stop() {
         if (MatrixClientPeg.get()) {
             MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated);
             MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged);
+            MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged);
         }
         this._dismissed.clear();
     }
 
     dismissVerification(deviceId) {
         this._dismissed.add(deviceId);
-        this.recheck();
+        this._recheck();
+    }
+
+    dismissEncryptionSetup() {
+        this._dismissedThisDeviceToast = true;
+        this._recheck();
     }
 
     _onDevicesUpdated = (users) => {
         if (!users.includes(MatrixClientPeg.get().getUserId())) return;
-        this.recheck();
+        this._recheck();
     }
 
     _onDeviceVerificationChanged = (users) => {
         if (!users.includes(MatrixClientPeg.get().getUserId())) return;
-        this.recheck();
+        this._recheck();
     }
 
-    async recheck() {
+    _onUserTrustStatusChanged = (userId, trustLevel) => {
+        if (userId !== MatrixClientPeg.get().getUserId()) return;
+        this._recheck();
+    }
+
+    // The server doesn't tell us when key backup is set up, so we poll
+    // & cache the result
+    async _getKeyBackupInfo() {
+        const now = (new Date()).getTime();
+        if (!this._keyBackupInfo || this._keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) {
+            this._keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
+            this._keyBackupFetchedAt = now;
+        }
+        return this._keyBackupInfo;
+    }
+
+    async _recheck() {
         if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return;
         const cli = MatrixClientPeg.get();
 
-        if (!cli.isCryptoEnabled()) return false;
+        if (!cli.isCryptoEnabled()) return;
+        if (!cli.getCrossSigningId()) {
+            if (this._dismissedThisDeviceToast) {
+                ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
+                return;
+            }
+
+            // cross signing isn't enabled - nag to enable it
+            // There are 3 different toasts for:
+            if (cli.getStoredCrossSigningForUser(cli.getUserId())) {
+                // Cross-signing on account but this device doesn't trust the master key (verify this session)
+                ToastStore.sharedInstance().addOrReplaceToast({
+                    key: THIS_DEVICE_TOAST_KEY,
+                    title: _t("Verify this session"),
+                    icon: "verification_warning",
+                    props: {kind: 'verify_this_session'},
+                    component: sdk.getComponent("toasts.SetupEncryptionToast"),
+                });
+            } else {
+                const backupInfo = await this._getKeyBackupInfo();
+                if (backupInfo) {
+                    // No cross-signing on account but key backup available (upgrade encryption)
+                    ToastStore.sharedInstance().addOrReplaceToast({
+                        key: THIS_DEVICE_TOAST_KEY,
+                        title: _t("Encryption upgrade available"),
+                        icon: "verification_warning",
+                        props: {kind: 'upgrade_encryption'},
+                        component: sdk.getComponent("toasts.SetupEncryptionToast"),
+                    });
+                } else {
+                    // No cross-signing or key backup on account (set up encryption)
+                    ToastStore.sharedInstance().addOrReplaceToast({
+                        key: THIS_DEVICE_TOAST_KEY,
+                        title: _t("Set up encryption"),
+                        icon: "verification_warning",
+                        props: {kind: 'set_up_encryption'},
+                        component: sdk.getComponent("toasts.SetupEncryptionToast"),
+                    });
+                }
+            }
+            return;
+        } else {
+            ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
+        }
+
+        const newActiveToasts = new Set();
 
         const devices = await cli.getStoredDevicesForUser(cli.getUserId());
         for (const device of devices) {
@@ -76,16 +155,24 @@ export default class DeviceListener {
 
             const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
             if (deviceTrust.isCrossSigningVerified() || this._dismissed.has(device.deviceId)) {
-                ToastStore.sharedInstance().dismissToast(toastKey(device));
+                ToastStore.sharedInstance().dismissToast(toastKey(device.deviceId));
             } else {
+                this._activeNagToasts.add(device.deviceId);
                 ToastStore.sharedInstance().addOrReplaceToast({
-                    key: toastKey(device),
+                    key: toastKey(device.deviceId),
                     title: _t("New Session"),
                     icon: "verification_warning",
                     props: {deviceId: device.deviceId},
                     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;
     }
 }
diff --git a/src/Lifecycle.js b/src/Lifecycle.js
index 1603c73d25..7488488dd8 100644
--- a/src/Lifecycle.js
+++ b/src/Lifecycle.js
@@ -592,8 +592,11 @@ async function startMatrixClient(startSyncing=true) {
     Mjolnir.sharedInstance().start();
 
     if (startSyncing) {
-        await MatrixClientPeg.start();
+        // The client might want to populate some views with events from the
+        // index (e.g. the FilePanel), therefore initialize the event index
+        // before the client.
         await EventIndexPeg.init();
+        await MatrixClientPeg.start();
     } else {
         console.warn("Caller requested only auxiliary services be started");
         await MatrixClientPeg.assign();
diff --git a/src/Markdown.js b/src/Markdown.js
index acfea52100..437ceec88b 100644
--- a/src/Markdown.js
+++ b/src/Markdown.js
@@ -91,7 +91,7 @@ export default class Markdown {
         return true;
     }
 
-    toHTML() {
+    toHTML({ externalLinks = false } = {}) {
         const renderer = new commonmark.HtmlRenderer({
             safe: false,
 
@@ -125,6 +125,24 @@ export default class Markdown {
             }
         };
 
+        renderer.link = function(node, entering) {
+            const attrs = this.attrs(node);
+            if (entering) {
+                attrs.push(['href', this.esc(node.destination)]);
+                if (node.title) {
+                    attrs.push(['title', this.esc(node.title)]);
+                }
+                // Modified link behaviour to treat them all as external and
+                // thus opening in a new tab.
+                if (externalLinks) {
+                    attrs.push(['target', '_blank']);
+                    attrs.push(['rel', 'noopener']);
+                }
+                this.tag('a', attrs);
+            } else {
+                this.tag('/a');
+            }
+        };
 
         renderer.html_inline = html_if_tag_allowed;
 
diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js
index dbc570c872..450bec8e77 100644
--- a/src/MatrixClientPeg.js
+++ b/src/MatrixClientPeg.js
@@ -217,7 +217,7 @@ class _MatrixClientPeg {
             timelineSupport: true,
             forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
             fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'),
-            verificationMethods: [verificationMethods.SAS],
+            verificationMethods: [verificationMethods.SAS, verificationMethods.QR_CODE_SHOW],
             unstableClientRelationAggregation: true,
             identityServer: new IdentityAuthClient(),
         };
diff --git a/src/Modal.js b/src/Modal.js
index 29d3af2e74..b6215b2b5a 100644
--- a/src/Modal.js
+++ b/src/Modal.js
@@ -17,87 +17,14 @@ limitations under the License.
 
 import React from 'react';
 import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import createReactClass from 'create-react-class';
 import Analytics from './Analytics';
-import * as sdk from './index';
 import dis from './dispatcher';
-import { _t } from './languageHandler';
-import {defer} from "./utils/promise";
+import {defer} from './utils/promise';
+import AsyncWrapper from './AsyncWrapper';
 
 const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
 const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
 
-/**
- * Wrap an asynchronous loader function with a react component which shows a
- * spinner until the real component loads.
- */
-const AsyncWrapper = createReactClass({
-    propTypes: {
-        /** A promise which resolves with the real component
-         */
-        prom: PropTypes.object.isRequired,
-    },
-
-    getInitialState: function() {
-        return {
-            component: null,
-            error: null,
-        };
-    },
-
-    componentWillMount: function() {
-        this._unmounted = false;
-        // XXX: temporary logging to try to diagnose
-        // https://github.com/vector-im/riot-web/issues/3148
-        console.log('Starting load of AsyncWrapper for modal');
-        this.props.prom.then((result) => {
-            if (this._unmounted) {
-                return;
-            }
-            // Take the 'default' member if it's there, then we support
-            // passing in just an import()ed module, since ES6 async import
-            // always returns a module *namespace*.
-            const component = result.default ? result.default : result;
-            this.setState({component});
-        }).catch((e) => {
-            console.warn('AsyncWrapper promise failed', e);
-            this.setState({error: e});
-        });
-    },
-
-    componentWillUnmount: function() {
-        this._unmounted = true;
-    },
-
-    _onWrapperCancelClick: function() {
-        this.props.onFinished(false);
-    },
-
-    render: function() {
-        if (this.state.component) {
-            const Component = this.state.component;
-            return <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 {
     constructor() {
         this._counter = 0;
diff --git a/src/RoomInvite.js b/src/RoomInvite.js
index 2eccf69b0f..839d677069 100644
--- a/src/RoomInvite.js
+++ b/src/RoomInvite.js
@@ -20,13 +20,8 @@ import React from 'react';
 import {MatrixClientPeg} from './MatrixClientPeg';
 import MultiInviter from './utils/MultiInviter';
 import Modal from './Modal';
-import { getAddressType } from './UserAddress';
-import createRoom from './createRoom';
 import * as sdk from './';
-import dis from './dispatcher';
-import DMRoomMap from './utils/DMRoomMap';
 import { _t } from './languageHandler';
-import SettingsStore from "./settings/SettingsStore";
 import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog";
 
 /**
@@ -44,64 +39,21 @@ export function inviteMultipleToRoom(roomId, addrs) {
 }
 
 export function showStartChatInviteDialog() {
-    if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) {
-        // This new dialog handles the room creation internally - we don't need to worry about it.
-        const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
-        Modal.createTrackedDialog(
-            'Start DM', '', InviteDialog, {kind: KIND_DM},
-            /*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
-        );
-        return;
-    }
-
-    const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
-
-    Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, {
-        title: _t('Start a chat'),
-        description: _t("Who would you like to communicate with?"),
-        placeholder: (validAddressTypes) => {
-            // The set of valid address type can be mutated inside the dialog
-            // when you first have no IS but agree to use one in the dialog.
-            if (validAddressTypes.includes('email')) {
-                return _t("Email, name or Matrix ID");
-            }
-            return _t("Name or Matrix ID");
-        },
-        validAddressTypes: ['mx-user-id', 'email'],
-        button: _t("Start Chat"),
-        onFinished: _onStartDmFinished,
-    }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
+    // This dialog handles the room creation internally - we don't need to worry about it.
+    const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
+    Modal.createTrackedDialog(
+        'Start DM', '', InviteDialog, {kind: KIND_DM},
+        /*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
+    );
 }
 
 export function showRoomInviteDialog(roomId) {
-    if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) {
-        // This new dialog handles the room creation internally - we don't need to worry about it.
-        const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
-        Modal.createTrackedDialog(
-            'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId},
-            /*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
-        );
-        return;
-    }
-
-    const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
-
-    Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, {
-        title: _t('Invite new room members'),
-        button: _t('Send Invites'),
-        placeholder: (validAddressTypes) => {
-            // The set of valid address type can be mutated inside the dialog
-            // when you first have no IS but agree to use one in the dialog.
-            if (validAddressTypes.includes('email')) {
-                return _t("Email, name or Matrix ID");
-            }
-            return _t("Name or Matrix ID");
-        },
-        validAddressTypes: ['mx-user-id', 'email'],
-        onFinished: (shouldInvite, addrs) => {
-            _onRoomInviteFinished(roomId, shouldInvite, addrs);
-        },
-    }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
+    // This dialog handles the room creation internally - we don't need to worry about it.
+    const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
+    Modal.createTrackedDialog(
+        'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId},
+        /*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
+    );
 }
 
 /**
@@ -122,60 +74,6 @@ export function isValid3pidInvite(event) {
     return true;
 }
 
-// TODO: Canonical DMs replaces this
-function _onStartDmFinished(shouldInvite, addrs) {
-    if (!shouldInvite) return;
-
-    const addrTexts = addrs.map((addr) => addr.address);
-
-    if (_isDmChat(addrTexts)) {
-        const rooms = _getDirectMessageRooms(addrTexts[0]);
-        if (rooms.length > 0) {
-            // A Direct Message room already exists for this user, so reuse it
-            dis.dispatch({
-                action: 'view_room',
-                room_id: rooms[0],
-                should_peek: false,
-                joining: false,
-            });
-        } else {
-            // Start a new DM chat
-            createRoom({dmUserId: addrTexts[0]}).catch((err) => {
-                const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
-                Modal.createTrackedDialog('Failed to start chat', '', ErrorDialog, {
-                    title: _t("Failed to start chat"),
-                    description: ((err && err.message) ? err.message : _t("Operation failed")),
-                });
-            });
-        }
-    } else if (addrTexts.length === 1) {
-        // Start a new DM chat
-        createRoom({dmUserId: addrTexts[0]}).catch((err) => {
-            const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
-            Modal.createTrackedDialog('Failed to start chat', '', ErrorDialog, {
-                title: _t("Failed to start chat"),
-                description: ((err && err.message) ? err.message : _t("Operation failed")),
-            });
-        });
-    } else {
-        // Start multi user chat
-        let room;
-        createRoom().then((roomId) => {
-            room = MatrixClientPeg.get().getRoom(roomId);
-            return inviteMultipleToRoom(roomId, addrTexts);
-        }).then((result) => {
-            return _showAnyInviteErrors(result.states, room, result.inviter);
-        }).catch((err) => {
-            console.error(err.stack);
-            const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
-            Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
-                title: _t("Failed to invite"),
-                description: ((err && err.message) ? err.message : _t("Operation failed")),
-            });
-        });
-    }
-}
-
 export function inviteUsersToRoom(roomId, userIds) {
     return inviteMultipleToRoom(roomId, userIds).then((result) => {
         const room = MatrixClientPeg.get().getRoom(roomId);
@@ -190,24 +88,6 @@ export function inviteUsersToRoom(roomId, userIds) {
     });
 }
 
-function _onRoomInviteFinished(roomId, shouldInvite, addrs) {
-    if (!shouldInvite) return;
-
-    const addrTexts = addrs.map((addr) => addr.address);
-
-    // Invite new users to a room
-    inviteUsersToRoom(roomId, addrTexts);
-}
-
-// TODO: Immutable DMs replaces this
-function _isDmChat(addrTexts) {
-    if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx-user-id') {
-        return true;
-    } else {
-        return false;
-    }
-}
-
 function _showAnyInviteErrors(addrs, room, inviter) {
     // Show user any errors
     const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error');
@@ -243,15 +123,3 @@ function _showAnyInviteErrors(addrs, room, inviter) {
 
     return addrs;
 }
-
-function _getDirectMessageRooms(addr) {
-    const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
-    const dmRooms = dmRoomMap.getDMRoomsForUserId(addr);
-    const rooms = dmRooms.filter((dmRoom) => {
-        const room = MatrixClientPeg.get().getRoom(dmRoom);
-        if (room) {
-            return room.getMyMembership() === 'join';
-        }
-    });
-    return rooms;
-}
diff --git a/src/SlashCommands.js b/src/SlashCommands.js
index 20b8ba76da..2eb34576ac 100644
--- a/src/SlashCommands.js
+++ b/src/SlashCommands.js
@@ -81,6 +81,8 @@ class Command {
     }
 
     run(roomId, args) {
+        // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
+        if (!this.runFn) return;
         return this.runFn.bind(this)(roomId, args);
     }
 
@@ -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} input The raw text input by the user.
- * @return {Object|null} An object with the property 'error' if there was an error
+ * @return {null|function(): Object} Function returning an object with the property 'error' if there was an error
  * processing the command, or 'promise' if a request was sent out.
  * Returns null if the input didn't match a command.
  */
-export function processCommandInput(roomId, input) {
+export function getCommand(roomId, input) {
     // trim any trailing whitespace, as it can confuse the parser for
     // IRC-style commands
     input = input.replace(/\s+$/, '');
     if (input[0] !== '/') return null; // not a command
 
-    const bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
+    const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/);
     let cmd;
     let args;
     if (bits) {
         cmd = bits[1].substring(1).toLowerCase();
-        args = bits[3];
+        args = bits[2];
     } else {
         cmd = input;
     }
@@ -932,11 +934,6 @@ export function processCommandInput(roomId, input) {
         cmd = aliases[cmd];
     }
     if (CommandMap[cmd]) {
-        // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
-        if (!CommandMap[cmd].runFn) return null;
-
-        return CommandMap[cmd].run(roomId, args);
-    } else {
-        return reject(_t('Unrecognised command:') + ' ' + input);
+        return () => CommandMap[cmd].run(roomId, args);
     }
 }
diff --git a/src/TextForEvent.js b/src/TextForEvent.js
index a0d088affb..cdfea45ad7 100644
--- a/src/TextForEvent.js
+++ b/src/TextForEvent.js
@@ -275,6 +275,8 @@ function textForRoomAliasesEvent(ev) {
     // This feels a bit overkill though, and it's not clear the i18n really needs it
     // so instead it's landing as a simple textual event.
 
+    const maxShown = 3;
+
     const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
     const oldAliases = ev.getPrevContent().aliases || [];
     const newAliases = ev.getContent().aliases || [];
@@ -287,18 +289,40 @@ function textForRoomAliasesEvent(ev) {
     }
 
     if (addedAliases.length && !removedAliases.length) {
+        if (addedAliases.length > maxShown) {
+            return _t("%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room", {
+                senderName: senderName,
+                count: addedAliases.length - maxShown,
+                addedAddresses: addedAliases.slice(0, maxShown).join(', '),
+            });
+        }
         return _t('%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.', {
             senderName: senderName,
             count: addedAliases.length,
             addedAddresses: addedAliases.join(', '),
         });
     } else if (!addedAliases.length && removedAliases.length) {
+        if (removedAliases.length > maxShown) {
+            return _t("%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room", {
+                senderName: senderName,
+                count: removedAliases.length - maxShown,
+                removedAddresses: removedAliases.slice(0, maxShown).join(', '),
+            });
+        }
         return _t('%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.', {
             senderName: senderName,
             count: removedAliases.length,
             removedAddresses: removedAliases.join(', '),
         });
     } else {
+        const combined = addedAliases.length + removedAliases.length;
+        if (combined > maxShown) {
+            return _t("%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room", {
+                senderName: senderName,
+                countAdded: addedAliases.length,
+                countRemoved: removedAliases.length,
+            });
+        }
         return _t(
             '%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.', {
                 senderName: senderName,
@@ -420,10 +444,19 @@ function textForHistoryVisibilityEvent(event) {
 
 function textForEncryptionEvent(event) {
     const senderName = event.sender ? event.sender.name : event.getSender();
-    return _t('%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).', {
-        senderName,
-        algorithm: event.getContent().algorithm,
-    });
+    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,
+            algorithm: event.getContent().algorithm,
+        },
+    );
 }
 
 // Currently will only display a change if a user's power level is changed
diff --git a/src/accessibility/RovingTabIndex.js b/src/accessibility/RovingTabIndex.js
new file mode 100644
index 0000000000..b481f08fe2
--- /dev/null
+++ b/src/accessibility/RovingTabIndex.js
@@ -0,0 +1,224 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, {
+    createContext,
+    useCallback,
+    useContext,
+    useLayoutEffect,
+    useMemo,
+    useRef,
+    useReducer,
+} from "react";
+import PropTypes from "prop-types";
+import {Key} from "../Keyboard";
+
+/**
+ * Module to simplify implementing the Roving TabIndex accessibility technique
+ *
+ * Wrap the Widget in an RovingTabIndexContextProvider
+ * and then for all buttons make use of useRovingTabIndex or RovingTabIndexWrapper.
+ * The code will keep track of which tabIndex was most recently focused and expose that information as `isActive` which
+ * can then be used to only set the tabIndex to 0 as expected by the roving tabindex technique.
+ * When the active button gets unmounted the closest button will be chosen as expected.
+ * Initially the first button to mount will be given active state.
+ *
+ * https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
+ */
+
+const DOCUMENT_POSITION_PRECEDING = 2;
+
+const RovingTabIndexContext = createContext({
+    state: {
+        activeRef: null,
+        refs: [], // list of refs in DOM order
+    },
+    dispatch: () => {},
+});
+RovingTabIndexContext.displayName = "RovingTabIndexContext";
+
+// TODO use a TypeScript type here
+const types = {
+    REGISTER: "REGISTER",
+    UNREGISTER: "UNREGISTER",
+    SET_FOCUS: "SET_FOCUS",
+};
+
+const reducer = (state, action) => {
+    switch (action.type) {
+        case types.REGISTER: {
+            if (state.refs.length === 0) {
+                // Our list of refs was empty, set activeRef to this first item
+                return {
+                    ...state,
+                    activeRef: action.payload.ref,
+                    refs: [action.payload.ref],
+                };
+            }
+
+            if (state.refs.includes(action.payload.ref)) {
+                return state; // already in refs, this should not happen
+            }
+
+            // find the index of the first ref which is not preceding this one in DOM order
+            let newIndex = state.refs.findIndex(ref => {
+                return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING;
+            });
+
+            if (newIndex < 0) {
+                newIndex = state.refs.length; // append to the end
+            }
+
+            // update the refs list
+            return {
+                ...state,
+                refs: [
+                    ...state.refs.slice(0, newIndex),
+                    action.payload.ref,
+                    ...state.refs.slice(newIndex),
+                ],
+            };
+        }
+        case types.UNREGISTER: {
+            // filter out the ref which we are removing
+            const refs = state.refs.filter(r => r !== action.payload.ref);
+
+            if (refs.length === state.refs.length) {
+                return state; // already removed, this should not happen
+            }
+
+            if (state.activeRef === action.payload.ref) {
+                // we just removed the active ref, need to replace it
+                // pick the ref which is now in the index the old ref was in
+                const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
+                return {
+                    ...state,
+                    activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex],
+                    refs,
+                };
+            }
+
+            // update the refs list
+            return {
+                ...state,
+                refs,
+            };
+        }
+        case types.SET_FOCUS: {
+            // update active ref
+            return {
+                ...state,
+                activeRef: action.payload.ref,
+            };
+        }
+        default:
+            return state;
+    }
+};
+
+export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => {
+    const [state, dispatch] = useReducer(reducer, {
+        activeRef: null,
+        refs: [],
+    });
+
+    const context = useMemo(() => ({state, dispatch}), [state]);
+
+    const onKeyDownHandler = useCallback((ev) => {
+        let handled = false;
+        if (handleHomeEnd) {
+            // check if we actually have any items
+            switch (ev.key) {
+                case Key.HOME:
+                    handled = true;
+                    // move focus to first item
+                    if (context.state.refs.length > 0) {
+                        context.state.refs[0].current.focus();
+                    }
+                    break;
+                case Key.END:
+                    handled = true;
+                    // move focus to last item
+                    if (context.state.refs.length > 0) {
+                        context.state.refs[context.state.refs.length - 1].current.focus();
+                    }
+                    break;
+            }
+        }
+
+        if (handled) {
+            ev.preventDefault();
+            ev.stopPropagation();
+        } else if (onKeyDown) {
+            return onKeyDown(ev);
+        }
+    }, [context.state, onKeyDown, handleHomeEnd]);
+
+    return <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});
+};
+
diff --git a/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js
new file mode 100644
index 0000000000..120b086ef6
--- /dev/null
+++ b/src/async-components/views/dialogs/eventindex/DisableEventIndexDialog.js
@@ -0,0 +1,73 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import * as sdk from '../../../../index';
+import PropTypes from 'prop-types';
+import dis from "../../../../dispatcher";
+import { _t } from '../../../../languageHandler';
+
+import SettingsStore, {SettingLevel} from "../../../../settings/SettingsStore";
+import EventIndexPeg from "../../../../indexing/EventIndexPeg";
+
+/*
+ * Allows the user to disable the Event Index.
+ */
+export default class DisableEventIndexDialog extends React.Component {
+    static propTypes = {
+        onFinished: PropTypes.func.isRequired,
+    }
+
+    constructor(props) {
+        super(props);
+
+        this.state = {
+            disabling: false,
+        };
+    }
+
+    _onDisable = async () => {
+        this.setState({
+            disabling: true,
+        });
+
+        await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false);
+        await EventIndexPeg.deleteEventIndex();
+        this.props.onFinished();
+        dis.dispatch({ action: 'view_user_settings' });
+    }
+
+    render() {
+        const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
+        const Spinner = sdk.getComponent('elements.Spinner');
+        const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
+
+        return (
+            <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>
+        );
+    }
+}
diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js
new file mode 100644
index 0000000000..b7ea87b1b2
--- /dev/null
+++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js
@@ -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>
+        );
+    }
+}
diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js
index 01b9c9c7c8..0867cae6f4 100644
--- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js
+++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js
@@ -1,6 +1,6 @@
 /*
 Copyright 2018, 2019 New Vector Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@ limitations under the License.
 */
 
 import React from 'react';
+import PropTypes from 'prop-types';
 import * as sdk from '../../../../index';
 import {MatrixClientPeg} from '../../../../MatrixClientPeg';
 import { scorePassword } from '../../../../utils/PasswordScorer';
@@ -52,6 +53,15 @@ function selectText(target) {
  * Secret Storage in account data.
  */
 export default class CreateSecretStorageDialog extends React.PureComponent {
+    static propTypes = {
+        hasCancel: PropTypes.bool,
+        accountPassword: PropTypes.string,
+    };
+
+    defaultProps = {
+        hasCancel: true,
+    };
+
     constructor(props) {
         super(props);
 
@@ -70,12 +80,24 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
             setPassPhrase: false,
             backupInfo: null,
             backupSigStatus: null,
+            // does the server offer a UI auth flow with just m.login.password
+            // for /keys/device_signing/upload?
+            canUploadKeysWithPasswordOnly: null,
+            accountPassword: props.accountPassword,
+            accountPasswordCorrect: null,
+            // set if we are 'upgrading' encryption (making an SSSS store from
+            // an existing key backup secret).
+            doingUpgrade: null,
         };
 
         this._fetchBackupInfo();
+        this._queryKeyUploadAuth();
+
+        MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
     }
 
     componentWillUnmount() {
+        MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
         if (this._setZxcvbnResultTimeout !== null) {
             clearTimeout(this._setZxcvbnResultTimeout);
         }
@@ -83,7 +105,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
 
     async _fetchBackupInfo() {
         const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
-        const backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo);
+        const backupSigStatus = (
+            // we may not have started crypto yet, in which case we definitely don't trust the backup
+            MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo)
+        );
 
         const phase = backupInfo ?
             (backupSigStatus.usable ? PHASE_MIGRATE : PHASE_RESTORE_KEY_BACKUP) :
@@ -93,14 +118,41 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
             phase,
             backupInfo,
             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) => {
         this._recoveryKeyNode = n;
     }
 
-    _onMigrateNextClick = () => {
+    _onMigrateFormSubmit = (e) => {
+        e.preventDefault();
         this._bootstrapSecretStorage();
     }
 
@@ -127,29 +179,46 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
         });
     }
 
+    _doBootstrapUIAuth = async (makeRequest) => {
+        if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) {
+            await makeRequest({
+                type: 'm.login.password',
+                identifier: {
+                    type: 'm.id.user',
+                    user: MatrixClientPeg.get().getUserId(),
+                },
+                // https://github.com/matrix-org/synapse/issues/5665
+                user: MatrixClientPeg.get().getUserId(),
+                password: this.state.accountPassword,
+            });
+        } else {
+            const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
+            const { finished } = Modal.createTrackedDialog(
+                'Cross-signing keys dialog', '', InteractiveAuthDialog,
+                {
+                    title: _t("Send cross-signing keys to homeserver"),
+                    matrixClient: MatrixClientPeg.get(),
+                    makeRequest,
+                },
+            );
+            const [confirmed] = await finished;
+            if (!confirmed) {
+                throw new Error("Cross-signing key upload auth canceled");
+            }
+        }
+    }
+
     _bootstrapSecretStorage = async () => {
         this.setState({
             phase: PHASE_STORING,
             error: null,
         });
+
         const cli = MatrixClientPeg.get();
+
         try {
-            const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
             await cli.bootstrapSecretStorage({
-                authUploadDeviceSigningKeys: async (makeRequest) => {
-                    const { finished } = Modal.createTrackedDialog(
-                        'Cross-signing keys dialog', '', InteractiveAuthDialog,
-                        {
-                            title: _t("Send cross-signing keys to homeserver"),
-                            matrixClient: MatrixClientPeg.get(),
-                            makeRequest,
-                        },
-                    );
-                    const [confirmed] = await finished;
-                    if (!confirmed) {
-                        throw new Error("Cross-signing key upload auth canceled");
-                    }
-                },
+                authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
                 createSecretStorageKey: async () => this._keyInfo,
                 keyBackupInfo: this.state.backupInfo,
             });
@@ -157,7 +226,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
                 phase: PHASE_DONE,
             });
         } catch (e) {
-            this.setState({ error: e });
+            if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) {
+                this.setState({
+                    accountPasswordCorrect: false,
+                    phase: PHASE_MIGRATE,
+                });
+            } else {
+                this.setState({ error: e });
+            }
             console.error("Error bootstrapping secret storage", e);
         }
     }
@@ -173,7 +249,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
     _onRestoreKeyBackupClick = () => {
         const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
         Modal.createTrackedDialog(
-            'Restore Backup', '', RestoreKeyBackupDialog, null, null,
+            'Restore Backup', '', RestoreKeyBackupDialog, {showSummary: false}, null,
             /* 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;
     }
 
+    _onAccountPasswordChange = (e) => {
+        this.setState({
+            accountPassword: e.target.value,
+        });
+    }
+
     _renderPhaseRestoreKeyBackup() {
         const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
         return <div>
@@ -309,22 +391,47 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
         // it automatically.
         // https://github.com/vector-im/riot-web/issues/11696
         const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
-        return <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(
-                "Secret Storage will be set up using your existing key backup details. " +
-                "Your secret storage passphrase and recovery key will be the same as " +
-                "they were for your key backup.",
+                "Upgrade this device to allow it to verify other devices, " +
+                "granting them access to encrypted messages and marking them " +
+                "as trusted for other users.",
             )}</p>
+            <div>{authPrompt}</div>
             <DialogButtons primaryButton={_t('Next')}
-                onPrimaryButtonClick={this._onMigrateNextClick}
+                primaryIsSubmit={true}
                 hasCancel={true}
                 onCancel={this._onCancel}
+                primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword}
             />
-        </div>;
+        </form>;
     }
 
     _renderPhasePassPhrase() {
         const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
+        const Field = sdk.getComponent('views.elements.Field');
+        const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
 
         let strengthMeter;
         let helpText;
@@ -350,51 +457,52 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
 
         return <div>
             <p>{_t(
-                "<b>Warning</b>: You should only set up secret storage from a trusted computer.", {},
-                { b: sub => <b>{sub}</b> },
+                "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.",
             )}</p>
             <p>{_t(
-                "We'll use secret storage to optionally store an encrypted copy of " +
-                "your cross-signing identity for verifying other devices and message " +
-                "keys on our server. Protect your access to encrypted messages with a " +
-                "passphrase to keep it secure.",
+                "Secure your encryption keys with a passphrase. For maximum security " +
+                "this should be different to your account password:",
             )}</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">
-                    <input type="password"
-                        onChange={this._onPassPhraseChange}
-                        onKeyPress={this._onPassPhraseKeyPress}
-                        value={this.state.passPhrase}
-                        className="mx_CreateSecretStorageDialog_passPhraseInput"
-                        placeholder={_t("Enter a passphrase...")}
-                        autoFocus={true}
-                    />
-                    <div className="mx_CreateSecretStorageDialog_passPhraseHelp">
-                        {strengthMeter}
-                        {helpText}
-                    </div>
+            <div className="mx_CreateSecretStorageDialog_passPhraseContainer">
+                <Field type="password"
+                    className="mx_CreateSecretStorageDialog_passPhraseField"
+                    onChange={this._onPassPhraseChange}
+                    onKeyPress={this._onPassPhraseKeyPress}
+                    value={this.state.passPhrase}
+                    label={_t("Enter a passphrase")}
+                    autoFocus={true}
+                />
+                <div className="mx_CreateSecretStorageDialog_passPhraseHelp">
+                    {strengthMeter}
+                    {helpText}
                 </div>
             </div>
 
-            <DialogButtons primaryButton={_t('Next')}
+            <DialogButtons primaryButton={_t('Continue')}
                 onPrimaryButtonClick={this._onPassPhraseNextClick}
                 hasCancel={false}
                 disabled={!this._passPhraseIsValid()}
-            />
+            >
+                <button type="button"
+                    onClick={this._onCancel}
+                    className="danger"
+                >{_t("Skip")}</button>
+            </DialogButtons>
 
             <details>
                 <summary>{_t("Advanced")}</summary>
-                <p><button onClick={this._onSkipPassPhraseClick} >
+                <p><AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} >
                     {_t("Set up with a recovery key")}
-                </button></p>
+                </AccessibleButton></p>
             </details>
         </div>;
     }
 
     _renderPhasePassPhraseConfirm() {
         const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
+        const Field = sdk.getComponent('views.elements.Field');
 
         let matchText;
         if (this.state.passPhraseConfirm === this.state.passPhrase) {
@@ -412,7 +520,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
 
         let passPhraseMatch = null;
         if (matchText) {
-            passPhraseMatch = <div className="mx_CreateSecretStorageDialog_passPhraseMatch">
+            passPhraseMatch = <div>
                 <div>{matchText}</div>
                 <div>
                     <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');
         return <div>
             <p>{_t(
-                "Please enter your passphrase a second time to confirm.",
+                "Enter your passphrase a second time to confirm it.",
             )}</p>
-            <div className="mx_CreateSecretStorageDialog_primaryContainer">
-                <div className="mx_CreateSecretStorageDialog_passPhraseContainer">
-                    <div>
-                        <input type="password"
-                            onChange={this._onPassPhraseConfirmChange}
-                            onKeyPress={this._onPassPhraseConfirmKeyPress}
-                            value={this.state.passPhraseConfirm}
-                            className="mx_CreateSecretStorageDialog_passPhraseInput"
-                            placeholder={_t("Repeat your passphrase...")}
-                            autoFocus={true}
-                        />
-                    </div>
+            <div className="mx_CreateSecretStorageDialog_passPhraseContainer">
+                <Field type="password"
+                    id="mx_CreateSecretStorageDialog_passPhraseField"
+                    onChange={this._onPassPhraseConfirmChange}
+                    onKeyPress={this._onPassPhraseConfirmKeyPress}
+                    value={this.state.passPhraseConfirm}
+                    className="mx_CreateSecretStorageDialog_passPhraseField"
+                    label={_t("Confirm your passphrase")}
+                    autoFocus={true}
+                />
+                <div className="mx_CreateSecretStorageDialog_passPhraseMatch">
                     {passPhraseMatch}
                 </div>
             </div>
-            <DialogButtons primaryButton={_t('Next')}
+            <DialogButtons primaryButton={_t('Continue')}
                 onPrimaryButtonClick={this._onPassPhraseConfirmNextClick}
                 hasCancel={false}
                 disabled={this.state.passPhrase !== this.state.passPhraseConfirm}
-            />
+            >
+                <button type="button"
+                    onClick={this._onCancel}
+                    className="danger"
+                >{_t("Skip")}</button>
+            </DialogButtons>
         </div>;
     }
 
@@ -463,6 +575,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
             );
         }
 
+        const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
         return <div>
             <p>{_t(
                 "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>
                     </div>
                     <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")}
-                        </button>
-                        <button className="mx_Dialog_primary" onClick={this._onDownloadClick}>
+                        </AccessibleButton>
+                        <AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onDownloadClick}>
                             {_t("Download")}
-                        </button>
+                        </AccessibleButton>
                     </div>
                 </div>
             </div>
@@ -533,7 +646,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
         const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
         return <div>
             <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>
             <DialogButtons primaryButton={_t('OK')}
                 onPrimaryButtonClick={this._onDone}
@@ -564,11 +681,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
             case PHASE_RESTORE_KEY_BACKUP:
                 return _t('Restore your Key Backup');
             case PHASE_MIGRATE:
-                return _t('Migrate from Key Backup');
+                return _t('Upgrade your encryption');
             case PHASE_PASSPHRASE:
-                return _t('Secure your encrypted messages with a passphrase');
+                return _t('Set up encryption');
             case PHASE_PASSPHRASE_CONFIRM:
-                return _t('Confirm your passphrase');
+                return _t('Confirm passphrase');
             case PHASE_OPTOUT_CONFIRM:
                 return _t('Warning!');
             case PHASE_SHOWKEY:
@@ -578,9 +695,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
             case PHASE_STORING:
                 return _t('Storing secrets...');
             case PHASE_DONE:
-                return _t('Success!');
+                return this.state.doingUpgrade ? _t('Encryption upgraded') : _t('Encryption setup complete');
             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 (
             <BaseDialog className='mx_CreateSecretStorageDialog'
                 onFinished={this.props.onFinished}
                 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>
                 {content}
diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js
index 61b3d2d4b9..4c02f925fc 100644
--- a/src/components/structures/FilePanel.js
+++ b/src/components/structures/FilePanel.js
@@ -19,9 +19,10 @@ import React from 'react';
 import createReactClass from 'create-react-class';
 import PropTypes from 'prop-types';
 
-import Matrix from 'matrix-js-sdk';
+import {Filter} from 'matrix-js-sdk';
 import * as sdk from '../../index';
 import {MatrixClientPeg} from '../../MatrixClientPeg';
+import EventIndexPeg from "../../indexing/EventIndexPeg";
 import { _t } from '../../languageHandler';
 
 /*
@@ -29,6 +30,9 @@ import { _t } from '../../languageHandler';
  */
 const FilePanel = createReactClass({
     displayName: 'FilePanel',
+    // This is used to track if a decrypted event was a live event and should be
+    // added to the timeline.
+    decryptingEvents: new Set(),
 
     propTypes: {
         roomId: PropTypes.string.isRequired,
@@ -40,42 +44,147 @@ const FilePanel = createReactClass({
         };
     },
 
-    componentDidMount: function() {
-        this.updateTimelineSet(this.props.roomId);
+    onRoomTimeline(ev, room, toStartOfTimeline, removed, data) {
+        if (room.roomId !== this.props.roomId) return;
+        if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
+
+        if (ev.isBeingDecrypted()) {
+            this.decryptingEvents.add(ev.getId());
+        } else {
+            this.addEncryptedLiveEvent(ev);
+        }
     },
 
-    updateTimelineSet: function(roomId) {
+    onEventDecrypted(ev, err) {
+        if (ev.getRoomId() !== this.props.roomId) return;
+        const eventId = ev.getId();
+
+        if (!this.decryptingEvents.delete(eventId)) return;
+        if (err) return;
+
+        this.addEncryptedLiveEvent(ev);
+    },
+
+    addEncryptedLiveEvent(ev, toStartOfTimeline) {
+        if (!this.state.timelineSet) return;
+
+        const timeline = this.state.timelineSet.getLiveTimeline();
+        if (ev.getType() !== "m.room.message") return;
+        if (["m.file", "m.image", "m.video", "m.audio"].indexOf(ev.getContent().msgtype) == -1) {
+            return;
+        }
+
+        if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) {
+            this.state.timelineSet.addEventToTimeline(ev, timeline, false);
+        }
+    },
+
+    async componentDidMount() {
+        const client = MatrixClientPeg.get();
+
+        await this.updateTimelineSet(this.props.roomId);
+
+        if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return;
+
+        // The timelineSets filter makes sure that encrypted events that contain
+        // URLs never get added to the timeline, even if they are live events.
+        // These methods are here to manually listen for such events and add
+        // them despite the filter's best efforts.
+        //
+        // We do this only for encrypted rooms and if an event index exists,
+        // this could be made more general in the future or the filter logic
+        // could be fixed.
+        if (EventIndexPeg.get() !== null) {
+            client.on('Room.timeline', this.onRoomTimeline.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(
+            {
+                "room": {
+                    "timeline": {
+                        "contains_url": true,
+                        "types": [
+                            "m.room.message",
+                        ],
+                    },
+                },
+            },
+        );
+
+        const filterId = await client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter);
+        filter.filterId = filterId;
+        const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
+
+        return timelineSet;
+    },
+
+    onPaginationRequest(timelineWindow, direction, limit) {
+        const client = MatrixClientPeg.get();
+        const eventIndex = EventIndexPeg.get();
+        const roomId = this.props.roomId;
+
+        const room = client.getRoom(roomId);
+
+        // We override the pagination request for encrypted rooms so that we ask
+        // the event index to fulfill the pagination request. Asking the server
+        // to paginate won't ever work since the server can't correctly filter
+        // out events containing URLs
+        if (client.isRoomEncrypted(roomId) && eventIndex !== null) {
+            return eventIndex.paginateTimelineWindow(room, timelineWindow, direction, limit);
+        } else {
+            return timelineWindow.paginate(direction, limit);
+        }
+    },
+
+    async updateTimelineSet(roomId: string) {
         const client = MatrixClientPeg.get();
         const room = client.getRoom(roomId);
+        const eventIndex = EventIndexPeg.get();
 
         this.noRoom = !room;
 
         if (room) {
-            const filter = new Matrix.Filter(client.credentials.userId);
-            filter.setDefinition(
-                {
-                    "room": {
-                        "timeline": {
-                            "contains_url": true,
-                            "types": [
-                                "m.room.message",
-                            ],
-                        },
-                    },
-                },
-            );
+            let timelineSet;
 
-            // FIXME: we shouldn't be doing this every time we change room - see comment above.
-            client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then(
-                (filterId)=>{
-                    filter.filterId = filterId;
-                    const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
-                    this.setState({ timelineSet: timelineSet });
-                },
-                (error)=>{
-                    console.error("Failed to get or create file panel filter", error);
-                },
-            );
+            try {
+                timelineSet = await this.fetchFileEventsServer(room);
+
+                // If this room is encrypted the file panel won't be populated
+                // correctly since the defined filter doesn't support encrypted
+                // events and the server can't check if encrypted events contain
+                // URLs.
+                //
+                // This is where our event index comes into place, we ask the
+                // event index to populate the timelineSet for us. This call
+                // will add 10 events to the live timeline of the set. More can
+                // be requested using pagination.
+                if (client.isRoomEncrypted(roomId) && eventIndex !== null) {
+                    const timeline = timelineSet.getLiveTimeline();
+                    await eventIndex.populateFileTimeline(timelineSet, timeline, room, 10);
+                }
+
+                this.setState({ timelineSet: timelineSet });
+            } catch (error) {
+                console.error("Failed to get or create file panel filter", error);
+            }
         } else {
             console.error("Failed to add filtered timelineSet for FilePanel as no room!");
         }
@@ -111,6 +220,7 @@ const FilePanel = createReactClass({
                         manageReadMarkers={false}
                         timelineSet={this.state.timelineSet}
                         showUrlPreview = {false}
+                        onPaginationRequest={this.onPaginationRequest}
                         tileShape="file_grid"
                         resizeNotifier={this.props.resizeNotifier}
                         empty={_t('There are no visible files in this room')}
diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js
index 53bb990e26..3d63029b06 100644
--- a/src/components/structures/InteractiveAuth.js
+++ b/src/components/structures/InteractiveAuth.js
@@ -20,7 +20,7 @@ import React, {createRef} from 'react';
 import createReactClass from 'create-react-class';
 import PropTypes from 'prop-types';
 
-import {getEntryComponentForLoginType} from '../views/auth/InteractiveAuthEntryComponents';
+import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
 
 import * as sdk from '../../index';
 
diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js
index 8a7d10e5b5..f5e0bca67e 100644
--- a/src/components/structures/LeftPanel.js
+++ b/src/components/structures/LeftPanel.js
@@ -129,9 +129,6 @@ const LeftPanel = createReactClass({
         if (!this.focusedElement) return;
 
         switch (ev.key) {
-            case Key.TAB:
-                this._onMoveFocus(ev, ev.shiftKey);
-                break;
             case Key.ARROW_UP:
                 this._onMoveFocus(ev, true, true);
                 break;
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js
index 3ac8a93e3d..133d74db45 100644
--- a/src/components/structures/MatrixChat.js
+++ b/src/components/structures/MatrixChat.js
@@ -89,12 +89,15 @@ export const VIEWS = {
     // showing flow to trust this new device with cross-signing
     COMPLETE_SECURITY: 6,
 
+    // flow to setup SSSS / cross-signing on this account
+    E2E_SETUP: 7,
+
     // we are logged in with an active matrix client.
-    LOGGED_IN: 7,
+    LOGGED_IN: 8,
 
     // We are logged out (invalid token) but have our local state again. The user
     // should log back in to rehydrate the client.
-    SOFT_LOGOUT: 8,
+    SOFT_LOGOUT: 9,
 };
 
 // Actions that are redirected through the onboarding process prior to being
@@ -253,6 +256,9 @@ export default createReactClass({
             // logout page.
             Lifecycle.loadSession({});
         }
+
+        this._accountPassword = null;
+        this._accountPasswordTimer = null;
     },
 
     componentDidMount: function() {
@@ -349,6 +355,8 @@ export default createReactClass({
         window.removeEventListener("focus", this.onFocus);
         window.removeEventListener('resize', this.handleResize);
         this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize);
+
+        if (this._accountPasswordTimer !== null) clearTimeout(this._accountPasswordTimer);
     },
 
     componentWillUpdate: function(props, state) {
@@ -657,7 +665,9 @@ export default createReactClass({
                 if (
                     !Lifecycle.isSoftLogout() &&
                     this.state.view !== VIEWS.LOGIN &&
-                    this.state.view !== VIEWS.COMPLETE_SECURITY
+                    this.state.view !== VIEWS.REGISTER &&
+                    this.state.view !== VIEWS.COMPLETE_SECURITY &&
+                    this.state.view !== VIEWS.E2E_SETUP
                 ) {
                     this._onLoggedIn();
                 }
@@ -961,9 +971,9 @@ export default createReactClass({
         const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
         const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog);
 
-        const [shouldCreate, createOpts] = await modal.finished;
+        const [shouldCreate, opts] = await modal.finished;
         if (shouldCreate) {
-            createRoom({createOpts});
+            createRoom(opts);
         }
     },
 
@@ -1453,7 +1463,6 @@ export default createReactClass({
 
         if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
             cli.on("crypto.verification.request", request => {
-                console.log(`MatrixChat got a .request ${request.channel.transactionId}`, request.event.getRoomId());
                 if (request.pending) {
                     ToastStore.sharedInstance().addOrReplaceToast({
                         key: 'verifreq_' + request.channel.transactionId,
@@ -1725,6 +1734,10 @@ export default createReactClass({
         this.showScreen("forgot_password");
     },
 
+    onRegisterFlowComplete: function(credentials, password) {
+        return this.onUserCompletedLoginFlow(credentials, password);
+    },
+
     // returns a promise which resolves to the new MatrixClient
     onRegistered: function(credentials) {
         return Lifecycle.setLoggedIn(credentials);
@@ -1813,7 +1826,14 @@ export default createReactClass({
         this._loggedInView = ref;
     },
 
-    async onUserCompletedLoginFlow(credentials) {
+    async onUserCompletedLoginFlow(credentials, password) {
+        this._accountPassword = password;
+        // self-destruct the password after 5mins
+        if (this._accountPasswordTimer !== null) clearTimeout(this._accountPasswordTimer);
+        this._accountPasswordTimer = setTimeout(() => {
+            this._accountPassword = null;
+            this._accountPasswordTimer = null;
+        }, 60 * 5 * 1000);
         // Wait for the client to be logged in (but not started)
         // which is enough to ask the server about account data.
         const loggedIn = new Promise(resolve => {
@@ -1827,7 +1847,7 @@ export default createReactClass({
         });
 
         // Create and start the client in the background
-        Lifecycle.setLoggedIn(credentials);
+        const setLoggedInPromise = Lifecycle.setLoggedIn(credentials);
         await loggedIn;
 
         const cli = MatrixClientPeg.get();
@@ -1848,12 +1868,20 @@ export default createReactClass({
 
         if (masterKeyInStorage) {
             this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY });
+        } else if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
+            // This will only work if the feature is set to 'enable' in the config,
+            // since it's too early in the lifecycle for users to have turned the
+            // labs flag on.
+            this.setStateForNewView({ view: VIEWS.E2E_SETUP });
         } else {
             this._onLoggedIn();
         }
+
+        return setLoggedInPromise;
     },
 
-    onCompleteSecurityFinished() {
+    // complete security / e2e setup has finished
+    onCompleteSecurityE2eSetupFinished() {
         this._onLoggedIn();
     },
 
@@ -1873,7 +1901,15 @@ export default createReactClass({
             const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity');
             view = (
                 <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) {
@@ -1940,7 +1976,7 @@ export default createReactClass({
                     email={this.props.startingFragmentQueryParams.email}
                     brand={this.props.config.brand}
                     makeRegistrationUrl={this._makeRegistrationUrl}
-                    onLoggedIn={this.onRegistered}
+                    onLoggedIn={this.onRegisterFlowComplete}
                     onLoginClick={this.onLoginClick}
                     onServerConfigChange={this.onServerConfigChange}
                     {...this.getServerProperties()}
diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js
index c2a644287d..600b418fe0 100644
--- a/src/components/structures/RoomSubList.js
+++ b/src/components/structures/RoomSubList.js
@@ -31,6 +31,7 @@ import PropTypes from 'prop-types';
 import RoomTile from "../views/rooms/RoomTile";
 import LazyRenderList from "../views/elements/LazyRenderList";
 import {_t} from "../../languageHandler";
+import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex";
 
 // turn this on for drop & drag console debugging galore
 const debug = false;
@@ -141,10 +142,6 @@ export default class RoomSubList extends React.PureComponent {
 
     onHeaderKeyDown = (ev) => {
         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:
                 // On ARROW_LEFT collapse the room sublist
                 if (!this.state.hidden && !this.props.forceExpand) {
@@ -263,33 +260,6 @@ export default class RoomSubList extends React.PureComponent {
         const subListNotifCount = subListNotifications.count;
         const subListNotifHighlight = subListNotifications.highlight;
 
-        let badge;
-        if (!this.props.collapsed) {
-            const badgeClasses = classNames({
-                'mx_RoomSubList_badge': true,
-                'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
-            });
-            // 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
         // the full tag name and room count
         let title;
@@ -305,17 +275,6 @@ export default class RoomSubList extends React.PureComponent {
                 <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;
         let chevron;
         if (len) {
@@ -327,25 +286,81 @@ export default class RoomSubList extends React.PureComponent {
             chevron = (<div className={chevronClasses} />);
         }
 
-        return (
-            <div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
-                <AccessibleButton
-                    onClick={this.onClick}
-                    className="mx_RoomSubList_label"
-                    tabIndex={0}
-                    aria-expanded={!isCollapsed}
-                    inputRef={this._headerButton}
-                    role="treeitem"
-                    aria-level="1"
-                >
-                    { chevron }
-                    <span>{this.props.label}</span>
-                    { incomingCall }
-                </AccessibleButton>
-                { badge }
-                { addRoomButton }
-            </div>
-        );
+        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 (
+                    <div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
+                        <AccessibleButton
+                            onFocus={onFocus}
+                            tabIndex={tabIndex}
+                            inputRef={ref}
+                            onClick={this.onClick}
+                            className="mx_RoomSubList_label"
+                            aria-expanded={!isCollapsed}
+                            role="treeitem"
+                            aria-level="1"
+                        >
+                            { chevron }
+                            <span>{this.props.label}</span>
+                            { incomingCall }
+                        </AccessibleButton>
+                        { badge }
+                        { addRoomButton }
+                    </div>
+                );
+            } }
+        </RovingTabIndexWrapper>;
     }
 
     checkOverflow = () => {
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index 9b02f6d503..2d669f9243 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -766,7 +766,7 @@ export default createReactClass({
 
     onUserVerificationChanged: function(userId, _trustStatus) {
         const room = this.state.room;
-        if (!room.currentState.getMember(userId)) {
+        if (!room || !room.currentState.getMember(userId)) {
             return;
         }
         this._updateE2EStatus(room);
@@ -796,6 +796,7 @@ export default createReactClass({
             return;
         }
 
+        // Duplication between here and _updateE2eStatus in RoomTile
         /* At this point, the user has encryption on and cross-signing on */
         const e2eMembers = await room.getEncryptionTargetMembers();
         const verified = [];
@@ -810,12 +811,12 @@ export default createReactClass({
         debuglog("e2e verified", verified, "unverified", unverified);
 
         /* Check all verified user devices. */
-        for (const userId of verified) {
+        for (const userId of [...verified, cli.getUserId()]) {
             const devices = await cli.getStoredDevicesForUser(userId);
-            const allDevicesVerified = devices.every(({deviceId}) => {
-                return cli.checkDeviceTrust(userId, deviceId).isVerified();
+            const anyDeviceNotVerified = devices.some(({deviceId}) => {
+                return !cli.checkDeviceTrust(userId, deviceId).isVerified();
             });
-            if (!allDevicesVerified) {
+            if (anyDeviceNotVerified) {
                 this.setState({
                     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) {
         // We can reject 3pid invites in the same way that we accept them,
         // using /leave rather than /join. In the short term though, we
@@ -1671,9 +1707,11 @@ export default createReactClass({
                 return (
                     <div className="mx_RoomView">
                         <ErrorBoundary>
-                            <RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
+                            <RoomPreviewBar
+                                onJoinClick={this.onJoinButtonClicked}
                                 onForgetClick={this.onForgetClick}
                                 onRejectClick={this.onRejectButtonClicked}
+                                onRejectAndIgnoreClick={this.onRejectAndIgnoreClick}
                                 inviterName={inviterName}
                                 canPreview={false}
                                 joining={this.state.joining}
diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js
index bc7c400949..5121dd3f9d 100644
--- a/src/components/structures/ScrollPanel.js
+++ b/src/components/structures/ScrollPanel.js
@@ -877,11 +877,14 @@ export default createReactClass({
         // TODO: the classnames on the div and ol could do with being updated to
         // reflect the fact that we don't necessarily contain a list of messages.
         // it's not obvious why we have a separate div and ol anyway.
+
+        // give the <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}
                 onScroll={this.onScroll}
                 className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
                     <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 }
                         </ol>
                     </div>
diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js
index 3be2f65dc5..873efb64c2 100644
--- a/src/components/structures/SearchBox.js
+++ b/src/components/structures/SearchBox.js
@@ -133,9 +133,11 @@ export default createReactClass({
             return null;
         }
         const clearButton = (!this.state.blurred || this.state.searchTerm) ?
-            (<AccessibleButton key="button"
-                    className="mx_SearchBox_closeButton"
-                    onClick={ () => {this._clearSearch("button"); } }>
+            (<AccessibleButton
+                key="button"
+                tabIndex={-1}
+                className="mx_SearchBox_closeButton"
+                onClick={ () => {this._clearSearch("button"); } }>
             </AccessibleButton>) : undefined;
 
         // show a shorter placeholder when blurred, if requested
diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js
index bd13981d1f..65fb00c305 100644
--- a/src/components/structures/TimelinePanel.js
+++ b/src/components/structures/TimelinePanel.js
@@ -94,6 +94,10 @@ const TimelinePanel = createReactClass({
         // callback which is called when the read-up-to mark is updated.
         onReadMarkerUpdated: PropTypes.func,
 
+        // callback which is called when we wish to paginate the timeline
+        // window.
+        onPaginationRequest: PropTypes.func,
+
         // maximum number of events to show in a timeline
         timelineCap: PropTypes.number,
 
@@ -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.
     onMessageListFillRequest: function(backwards) {
         if (!this._shouldPaginate()) return Promise.resolve(false);
@@ -360,7 +372,7 @@ const TimelinePanel = createReactClass({
         debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards);
         this.setState({[paginatingKey]: true});
 
-        return this._timelineWindow.paginate(dir, PAGINATE_SIZE).then((r) => {
+        return this.onPaginationRequest(this._timelineWindow, dir, PAGINATE_SIZE).then((r) => {
             if (this.unmounted) { return; }
 
             debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r);
diff --git a/src/components/structures/ToastContainer.js b/src/components/structures/ToastContainer.js
index 8a05f62e61..283fbdd96a 100644
--- a/src/components/structures/ToastContainer.js
+++ b/src/components/structures/ToastContainer.js
@@ -23,9 +23,11 @@ export default class ToastContainer extends React.Component {
     constructor() {
         super();
         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);
     }
 
diff --git a/src/components/structures/TopLeftMenuButton.js b/src/components/structures/TopLeftMenuButton.js
index 967805d099..ebd7aaae89 100644
--- a/src/components/structures/TopLeftMenuButton.js
+++ b/src/components/structures/TopLeftMenuButton.js
@@ -17,7 +17,7 @@ limitations under the License.
 
 import React from 'react';
 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 {MatrixClientPeg} from '../../MatrixClientPeg';
 import * as Avatar from '../../Avatar';
diff --git a/src/components/structures/auth/CompleteSecurity.js b/src/components/structures/auth/CompleteSecurity.js
index b64f368908..29d8207d0a 100644
--- a/src/components/structures/auth/CompleteSecurity.js
+++ b/src/components/structures/auth/CompleteSecurity.js
@@ -35,7 +35,21 @@ export default class CompleteSecurity extends React.Component {
 
         this.state = {
             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 () => {
@@ -44,14 +58,38 @@ export default class CompleteSecurity extends React.Component {
             await accessSecretStorage(async () => {
                 await cli.checkOwnCrossSigningTrust();
             });
-            this.setState({
-                phase: PHASE_DONE,
-            });
+
+            if (cli.getCrossSigningId()) {
+                this.setState({
+                    phase: PHASE_DONE,
+                });
+            }
         } catch (e) {
             // 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 = () => {
         this.setState({
             phase: PHASE_CONFIRM_SKIP,
@@ -74,8 +112,7 @@ export default class CompleteSecurity extends React.Component {
 
     render() {
         const AuthPage = sdk.getComponent("auth.AuthPage");
-        const AuthHeader = sdk.getComponent("auth.AuthHeader");
-        const AuthBody = sdk.getComponent("auth.AuthBody");
+        const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
         const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
 
         const {
@@ -85,7 +122,13 @@ export default class CompleteSecurity extends React.Component {
         let icon;
         let title;
         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>;
             title = _t("Complete security");
             body = (
@@ -161,8 +204,7 @@ export default class CompleteSecurity extends React.Component {
 
         return (
             <AuthPage>
-                <AuthHeader />
-                <AuthBody>
+                <CompleteSecurityBody>
                     <h2 className="mx_CompleteSecurity_header">
                         {icon}
                         {title}
@@ -170,7 +212,7 @@ export default class CompleteSecurity extends React.Component {
                     <div className="mx_CompleteSecurity_body">
                         {body}
                     </div>
-                </AuthBody>
+                </CompleteSecurityBody>
             </AuthPage>
         );
     }
diff --git a/src/components/structures/auth/E2eSetup.js b/src/components/structures/auth/E2eSetup.js
new file mode 100644
index 0000000000..9b390d24cc
--- /dev/null
+++ b/src/components/structures/auth/E2eSetup.js
@@ -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>
+        );
+    }
+}
diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.js
index 7bc2dbcbae..c8b2a1ea9c 100644
--- a/src/components/structures/auth/Login.js
+++ b/src/components/structures/auth/Login.js
@@ -58,6 +58,11 @@ export default createReactClass({
     displayName: 'Login',
 
     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,
 
         // If true, the component will consider itself busy.
@@ -181,7 +186,7 @@ export default createReactClass({
             username, phoneCountry, phoneNumber, password,
         ).then((data) => {
             this.setState({serverIsAlive: true}); // it must be, we logged in.
-            this.props.onLoggedIn(data);
+            this.props.onLoggedIn(data, password);
         }, (error) => {
             if (this._unmounted) {
                 return;
diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js
index fdf2f51e00..171d3ada26 100644
--- a/src/components/structures/auth/Registration.js
+++ b/src/components/structures/auth/Registration.js
@@ -45,7 +45,13 @@ export default createReactClass({
     displayName: 'Registration',
 
     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,
+
         clientSecret: PropTypes.string,
         sessionId: PropTypes.string,
         makeRegistrationUrl: PropTypes.func.isRequired,
@@ -348,7 +354,7 @@ export default createReactClass({
                 homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
                 identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
                 accessToken: response.access_token,
-            });
+            }, this.state.formVals.password);
 
             this._setupPushers(cli);
             // we're still busy until we get unmounted: don't show the registration form again
diff --git a/src/components/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.js
index 2da837f029..efcc450067 100644
--- a/src/components/views/auth/CaptchaForm.js
+++ b/src/components/views/auth/CaptchaForm.js
@@ -62,7 +62,7 @@ export default createReactClass({
             console.log("Loading recaptcha script...");
             window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();};
             let protocol = global.location.protocol;
-            if (protocol === "vector:") {
+            if (protocol !== "http:") {
                 protocol = "https:";
             }
             const scriptTag = document.createElement('script');
diff --git a/src/components/views/auth/CompleteSecurityBody.js b/src/components/views/auth/CompleteSecurityBody.js
new file mode 100644
index 0000000000..d757de9fe0
--- /dev/null
+++ b/src/components/views/auth/CompleteSecurityBody.js
@@ -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>;
+    }
+}
diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js
index 869e81c1f7..801420da95 100644
--- a/src/components/views/auth/InteractiveAuthEntryComponents.js
+++ b/src/components/views/auth/InteractiveAuthEntryComponents.js
@@ -641,7 +641,7 @@ const AuthEntryComponents = [
     TermsAuthEntry,
 ];
 
-export function getEntryComponentForLoginType(loginType) {
+export default function getEntryComponentForLoginType(loginType) {
     for (const c of AuthEntryComponents) {
         if (c.LOGIN_TYPE == loginType) {
             return c;
diff --git a/src/components/views/context_menus/RoomTileContextMenu.js b/src/components/views/context_menus/RoomTileContextMenu.js
index 6e2bd8ebf5..2d8dec29c7 100644
--- a/src/components/views/context_menus/RoomTileContextMenu.js
+++ b/src/components/views/context_menus/RoomTileContextMenu.js
@@ -306,7 +306,7 @@ export default createReactClass({
         return (
             <div>
                 <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') }
                 </MenuItem>
             </div>
diff --git a/src/components/views/context_menus/TopLeftMenu.js b/src/components/views/context_menus/TopLeftMenu.js
index 528e4790c2..51ec202b90 100644
--- a/src/components/views/context_menus/TopLeftMenu.js
+++ b/src/components/views/context_menus/TopLeftMenu.js
@@ -27,7 +27,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
 import {MenuItem} from "../../structures/ContextMenu";
 import * as sdk from "../../../index";
 
-export class TopLeftMenu extends React.Component {
+export default class TopLeftMenu extends React.Component {
     static propTypes = {
         displayName: PropTypes.string.isRequired,
         userId: PropTypes.string.isRequired,
diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js
index 19f22a15ad..9238024b60 100644
--- a/src/components/views/dialogs/BaseDialog.js
+++ b/src/components/views/dialogs/BaseDialog.js
@@ -65,6 +65,9 @@ export default createReactClass({
         // Title for the dialog.
         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: 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 (
             <MatrixClientContext.Provider value={this._matrixClient}>
                 <FocusLock
@@ -135,6 +145,7 @@ export default createReactClass({
                         'mx_Dialog_headerWithButton': !!this.props.headerButton,
                     })}>
                         <div className={classNames('mx_Dialog_title', this.props.titleClass)} id='mx_BaseDialog_title'>
+                            {headerImage}
                             { this.props.title }
                         </div>
                         { this.props.headerButton }
diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js
index 288074a891..9380226381 100644
--- a/src/components/views/dialogs/CreateRoomDialog.js
+++ b/src/components/views/dialogs/CreateRoomDialog.js
@@ -1,5 +1,6 @@
 /*
 Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -44,13 +45,13 @@ export default createReactClass({
     },
 
     _roomCreateOptions() {
-        const createOpts = {};
+        const opts = {};
+        const createOpts = opts.createOpts = {};
         createOpts.name = this.state.name;
         if (this.state.isPublic) {
             createOpts.visibility = "public";
             createOpts.preset = "public_chat";
-            // to prevent createRoom from enabling guest access
-            createOpts['initial_state'] = [];
+            opts.guestAccess = false;
             const {alias} = this.state;
             const localPart = alias.substr(1, alias.indexOf(":") - 1);
             createOpts['room_alias_name'] = localPart;
@@ -61,7 +62,7 @@ export default createReactClass({
         if (this.state.noFederate) {
             createOpts.creation_content = {'m.federate': false};
         }
-        return createOpts;
+        return opts;
     },
 
     componentDidMount() {
diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js
index 703b0b5121..de11dbf9fa 100644
--- a/src/components/views/dialogs/InviteDialog.js
+++ b/src/components/views/dialogs/InviteDialog.js
@@ -33,6 +33,7 @@ import Modal from "../../../Modal";
 import {humanizeTime} from "../../../utils/humanize";
 import createRoom from "../../../createRoom";
 import {inviteMultipleToRoom} from "../../../RoomInvite";
+import SettingsStore from '../../../settings/SettingsStore';
 
 export const KIND_DM = "dm";
 export const KIND_INVITE = "invite";
@@ -337,19 +338,31 @@ export default class InviteDialog extends React.PureComponent {
         const recents = [];
         for (const userId in rooms) {
             // 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 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
                 ? room.timeline[room.timeline.length - 1].getTs()
                 : 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});
         }
+        if (!recents) console.warn("[Invite:Recents] No recents to suggest!");
 
         // Sort the recents by last active to save us time later
         recents.sort((a, b) => b.lastActive - a.lastActive);
@@ -493,7 +506,7 @@ export default class InviteDialog extends React.PureComponent {
         return false;
     }
 
-    _startDm = () => {
+    _startDm = async () => {
         this.setState({busy: true});
         const targetIds = this.state.targets.map(t => t.userId);
 
@@ -510,14 +523,31 @@ export default class InviteDialog extends React.PureComponent {
             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.
         // TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM
         let createRoomPromise = Promise.resolve();
         if (targetIds.length === 1) {
-            createRoomPromise = createRoom({dmUserId: targetIds[0]});
+            createRoomOptions.dmUserId = targetIds[0];
+            createRoomPromise = createRoom(createRoomOptions);
         } else {
             // Create a boring room and try to invite the targets manually.
-            createRoomPromise = createRoom().then(roomId => {
+            createRoomPromise = createRoom(createRoomOptions).then(roomId => {
                 return inviteMultipleToRoom(roomId, targetIds);
             }).then(result => {
                 if (this._shouldAbortAfterInviteError(result)) {
@@ -586,13 +616,36 @@ export default class InviteDialog extends React.PureComponent {
             clearTimeout(this._debounceTimer);
         }
         this._debounceTimer = setTimeout(async () => {
-            MatrixClientPeg.get().searchUserDirectory({term}).then(r => {
+            MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
                 if (term !== this.state.filterText) {
                     // 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
                     // more accurate results.
                     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({
                     serverResultsMixin: r.results.map(u => ({
                         userId: u.user_id,
@@ -672,11 +725,16 @@ export default class InviteDialog extends React.PureComponent {
     };
 
     _toggleMember = (member: Member) => {
+        let filterText = this.state.filterText;
         const targets = this.state.targets.map(t => t); // cheap clone for mutation
         const idx = targets.indexOf(member);
-        if (idx >= 0) targets.splice(idx, 1);
-        else targets.push(member);
-        this.setState({targets});
+        if (idx >= 0) {
+            targets.splice(idx, 1);
+        } else {
+            targets.push(member);
+            filterText = ""; // clear the filter when the user accepts a suggestion
+        }
+        this.setState({targets, filterText});
     };
 
     _removeMember = (member: Member) => {
@@ -876,7 +934,7 @@ export default class InviteDialog extends React.PureComponent {
                 key={"input"}
                 rows={1}
                 onChange={this._updateFilter}
-                defaultValue={this.state.filterText}
+                value={this.state.filterText}
                 ref={this._editorRef}
                 onPaste={this._onPaste}
             />
@@ -944,7 +1002,7 @@ export default class InviteDialog extends React.PureComponent {
 
             title = _t("Direct Messages");
             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>.",
                 {userId},
                 {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}
             >
                 <div className='mx_InviteDialog_content'>
-                    <p>{helpText}</p>
+                    <p className='mx_InviteDialog_helpText'>{helpText}</p>
                     <div className='mx_InviteDialog_addressBar'>
                         {this._renderEditor()}
                         <div className='mx_InviteDialog_buttonAndSpinner'>
@@ -987,8 +1045,10 @@ export default class InviteDialog extends React.PureComponent {
                     </div>
                     {this._renderIdentityServerWarning()}
                     <div className='error'>{this.state.errorText}</div>
-                    {this._renderSection('recents')}
-                    {this._renderSection('suggestions')}
+                    <div className='mx_InviteDialog_userSections'>
+                        {this._renderSection('recents')}
+                        {this._renderSection('suggestions')}
+                    </div>
                 </div>
             </BaseDialog>
         );
diff --git a/src/components/views/dialogs/ReportEventDialog.js b/src/components/views/dialogs/ReportEventDialog.js
index e77bb0693b..99853582dd 100644
--- a/src/components/views/dialogs/ReportEventDialog.js
+++ b/src/components/views/dialogs/ReportEventDialog.js
@@ -20,6 +20,8 @@ import { _t } from '../../../languageHandler';
 import PropTypes from "prop-types";
 import {MatrixEvent} from "matrix-js-sdk";
 import {MatrixClientPeg} from "../../../MatrixClientPeg";
+import SdkConfig from '../../../SdkConfig';
+import Markdown from '../../../Markdown';
 
 /*
  * 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 (
             <BaseDialog
                 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.")
                         }
                     </p>
-
+                    {adminMessage}
                     <Field
                         id="mx_ReportEventDialog_reason"
                         className="mx_ReportEventDialog_reason"
diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js
index 77fdee5e8a..0c432ba542 100644
--- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js
+++ b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js
@@ -16,6 +16,7 @@ limitations under the License.
 */
 
 import React from 'react';
+import PropTypes from 'prop-types';
 import * as sdk from '../../../../index';
 import {MatrixClientPeg} from '../../../../MatrixClientPeg';
 import { 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
  */
 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) {
         super(props);
         this.state = {
@@ -96,6 +107,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
             const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword(
                 this.state.passPhrase, undefined, undefined, this.state.backupInfo,
             );
+            if (!this.props.showSummary) {
+                this.props.onFinished(true);
+                return;
+            }
             this.setState({
                 loading: false,
                 recoverInfo,
@@ -119,6 +134,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
             const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey(
                 this.state.recoveryKey, undefined, undefined, this.state.backupInfo,
             );
+            if (!this.props.showSummary) {
+                this.props.onFinished(true);
+                return;
+            }
             this.setState({
                 loading: false,
                 recoverInfo,
@@ -253,6 +272,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
             title = _t("Error");
             content = _t("No backup found!");
         } else if (this.state.recoverInfo) {
+            const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
             title = _t("Backup Restored");
             let failedToDecrypt;
             if (this.state.recoverInfo.total > this.state.recoverInfo.imported) {
@@ -264,6 +284,11 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
             content = <div>
                 <p>{_t("Restored %(sessionCount)s session keys", {sessionCount: this.state.recoverInfo.imported})}</p>
                 {failedToDecrypt}
+                <DialogButtons primaryButton={_t('OK')}
+                    onPrimaryButtonClick={this._onDone}
+                    hasCancel={false}
+                    focus={true}
+                />
             </div>;
         } else if (backupHasPassphrase && !this.state.forceRecoveryKey) {
             const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
diff --git a/src/components/views/elements/DialogButtons.js b/src/components/views/elements/DialogButtons.js
index 4e47e73052..ee15bfc3f2 100644
--- a/src/components/views/elements/DialogButtons.js
+++ b/src/components/views/elements/DialogButtons.js
@@ -34,12 +34,19 @@ export default createReactClass({
         // A node to insert into the cancel button instead of default "Cancel"
         cancelButton: PropTypes.node,
 
+        // If true, make the primary button a form submit button (input type="submit")
+        primaryIsSubmit: PropTypes.bool,
+
         // onClick handler for the primary button.
-        onPrimaryButtonClick: PropTypes.func.isRequired,
+        onPrimaryButtonClick: PropTypes.func,
 
         // should there be a cancel button? default: true
         hasCancel: PropTypes.bool,
 
+        // The class of the cancel button, only used if a cancel button is
+        // enabled
+        cancelButtonClass: PropTypes.node,
+
         // onClick handler for the cancel button.
         onCancel: PropTypes.func,
 
@@ -69,16 +76,26 @@ export default createReactClass({
             primaryButtonClassName += " " + this.props.primaryButtonClass;
         }
         let cancelButton;
+
         if (this.props.cancelButton || this.props.hasCancel) {
-            cancelButton = <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") }
             </button>;
         }
+
         return (
             <div className="mx_Dialog_buttons">
                 { cancelButton }
                 { this.props.children }
-                <button className={primaryButtonClassName}
+                <button type={this.props.primaryIsSubmit ? 'submit' : 'button'}
+                    className={primaryButtonClassName}
                     onClick={this.props.onPrimaryButtonClick}
                     autoFocus={this.props.focus}
                     disabled={this.props.disabled || this.props.primaryDisabled}
diff --git a/src/components/views/elements/crypto/VerificationQRCode.js b/src/components/views/elements/crypto/VerificationQRCode.js
new file mode 100644
index 0000000000..1cb5647317
--- /dev/null
+++ b/src/components/views/elements/crypto/VerificationQRCode.js
@@ -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")} />;
+    }
+}
diff --git a/src/components/views/emojipicker/EmojiPicker.js b/src/components/views/emojipicker/EmojiPicker.js
index 1f508ceedc..ca8f0c0565 100644
--- a/src/components/views/emojipicker/EmojiPicker.js
+++ b/src/components/views/emojipicker/EmojiPicker.js
@@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
 import * as sdk from '../../../index';
 import { _t } from '../../../languageHandler';
 
-import * as recent from './recent';
+import * as recent from '../../../emojipicker/recent';
 import {DATA_BY_CATEGORY, getEmojiFromUnicode} from "../../../emoji";
 
 export const CATEGORY_HEADER_HEIGHT = 22;
diff --git a/src/components/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js
index 808cf17d55..91c930525d 100644
--- a/src/components/views/groups/GroupInviteTile.js
+++ b/src/components/views/groups/GroupInviteTile.js
@@ -26,6 +26,7 @@ import classNames from 'classnames';
 import {MatrixClientPeg} from "../../../MatrixClientPeg";
 import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu";
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
+import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
 
 // XXX this class copies a lot from RoomTile.js
 export default createReactClass({
@@ -127,7 +128,8 @@ export default createReactClass({
             '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 }
         </div>;
 
@@ -137,16 +139,6 @@ export default createReactClass({
         });
 
         const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!';
-        const badge = (
-            <ContextMenuButton
-                className={badgeClasses}
-                onClick={this.onContextMenuButtonClick}
-                label={_t("Options")}
-                isExpanded={isMenuDisplayed}
-            >
-                { badgeContent }
-            </ContextMenuButton>
-        );
 
         let tooltip;
         if (this.props.collapsed && this.state.hover) {
@@ -171,22 +163,37 @@ export default createReactClass({
         }
 
         return <React.Fragment>
-            <AccessibleButton
-                className={classes}
-                onClick={this.onClick}
-                onMouseEnter={this.onMouseEnter}
-                onMouseLeave={this.onMouseLeave}
-                onContextMenu={this.onContextMenu}
-            >
-                <div className="mx_RoomTile_avatar">
-                    { av }
-                </div>
-                <div className="mx_RoomTile_nameContainer">
-                    { label }
-                    { badge }
-                </div>
-                { tooltip }
-            </AccessibleButton>
+            <RovingTabIndexWrapper>
+                {({onFocus, isActive, ref}) =>
+                    <AccessibleButton
+                        onFocus={onFocus}
+                        tabIndex={isActive ? 0 : -1}
+                        inputRef={ref}
+                        className={classes}
+                        onClick={this.onClick}
+                        onMouseEnter={this.onMouseEnter}
+                        onMouseLeave={this.onMouseLeave}
+                        onContextMenu={this.onContextMenu}
+                    >
+                        <div className="mx_RoomTile_avatar">
+                            { av }
+                        </div>
+                        <div className="mx_RoomTile_nameContainer">
+                            { label }
+                            <ContextMenuButton
+                                className={badgeClasses}
+                                onClick={this.onContextMenuButtonClick}
+                                label={_t("Options")}
+                                isExpanded={isMenuDisplayed}
+                                tabIndex={isActive ? 0 : -1}
+                            >
+                                { badgeContent }
+                            </ContextMenuButton>
+                        </div>
+                        { tooltip }
+                    </AccessibleButton>
+                }
+            </RovingTabIndexWrapper>
 
             { contextMenu }
         </React.Fragment>;
diff --git a/src/components/views/messages/MKeyVerificationConclusion.js b/src/components/views/messages/MKeyVerificationConclusion.js
index f51b97786b..a17dcd8ab0 100644
--- a/src/components/views/messages/MKeyVerificationConclusion.js
+++ b/src/components/views/messages/MKeyVerificationConclusion.js
@@ -93,7 +93,7 @@ export default class MKeyVerificationConclusion extends React.Component {
         }
 
         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", {
                 mx_KeyVerification_icon_verified: request.done,
             });
diff --git a/src/components/views/messages/MKeyVerificationRequest.js b/src/components/views/messages/MKeyVerificationRequest.js
index ae793556d8..49f871d16e 100644
--- a/src/components/views/messages/MKeyVerificationRequest.js
+++ b/src/components/views/messages/MKeyVerificationRequest.js
@@ -85,7 +85,7 @@ export default class MKeyVerificationRequest extends React.Component {
         if (userId === myUserId) {
             return _t("You accepted");
         } 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) {
             return _t("You cancelled");
         } 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) {
+            const name = getNameForEventRoom(request.requestingUserId, mxEvent.getRoomId());
             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">{
-                userLabelForEventRoom(request.requestingUserId, mxEvent)}</div>);
+                userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId())}</div>);
             if (request.requested && !request.observeOnly) {
                 stateNode = (<div className="mx_KeyVerification_buttons">
                     <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">{
                 _t("You sent a verification request")}</div>);
             subtitle = (<div className="mx_KeyVerification_subtitle">{
-                userLabelForEventRoom(request.receivingUserId, mxEvent)}</div>);
+                userLabelForEventRoom(request.receivingUserId, mxEvent.getRoomId())}</div>);
         }
 
         if (title) {
diff --git a/src/components/views/right_panel/UserInfo.js b/src/components/views/right_panel/UserInfo.js
index a0819be472..051f92cc9c 100644
--- a/src/components/views/right_panel/UserInfo.js
+++ b/src/components/views/right_panel/UserInfo.js
@@ -82,7 +82,7 @@ const _getE2EStatus = (cli, userId, devices) => {
     return "warning";
 };
 
-function openDMForUser(matrixClient, userId) {
+async function openDMForUser(matrixClient, userId) {
     const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId);
     const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
         const room = matrixClient.getRoom(roomId);
@@ -100,9 +100,27 @@ function openDMForUser(matrixClient, userId) {
             action: 'view_room',
             room_id: lastActiveRoom.roomId,
         });
-    } else {
-        createRoom({dmUserId: userId});
+        return;
     }
+
+    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) {
@@ -1219,10 +1237,9 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
 
     let closeButton;
     if (onClose) {
-        closeButton = <AccessibleButton
-            className="mx_UserInfo_cancel"
-            onClick={onClose}
-            title={_t('Close')} />;
+        closeButton = <AccessibleButton className="mx_UserInfo_cancel" onClick={onClose} title={_t('Close')}>
+            <div />
+        </AccessibleButton>;
     }
 
     const memberDetails = (
@@ -1308,15 +1325,18 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
         userTrust.isVerified();
     const isMe = user.userId === cli.getUserId();
     let verifyButton;
-    if (!userVerified && !isMe) {
+    if (isRoomEncrypted && !userVerified && !isMe) {
         verifyButton = <AccessibleButton className="mx_UserInfo_verify" onClick={() => verifyUser(user)}>
             {_t("Verify")}
         </AccessibleButton>;
     }
 
-    const devicesSection = <DevicesSection
-        loading={devices === undefined}
-        devices={devices} userId={user.userId} />;
+    let devicesSection;
+    if (isRoomEncrypted) {
+        devicesSection = <DevicesSection
+            loading={devices === undefined}
+            devices={devices} userId={user.userId} />;
+    }
 
     const securitySection = (
         <div className="mx_UserInfo_container">
@@ -1335,32 +1355,32 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
 
     return (
         <div className="mx_UserInfo" role="tabpanel">
-            { closeButton }
-            { avatarElement }
-
-            <div className="mx_UserInfo_container">
-                <div className="mx_UserInfo_profile">
-                    <div>
-                        <h2 aria-label={displayName}>
-                            { e2eIcon }
-                            { displayName }
-                        </h2>
-                    </div>
-                    <div>{ user.userId }</div>
-                    <div className="mx_UserInfo_profileStatus">
-                        {presenceLabel}
-                        {statusLabel}
-                    </div>
-                </div>
-            </div>
-
-            { memberDetails && <div className="mx_UserInfo_container mx_UserInfo_memberDetailsContainer">
-                <div className="mx_UserInfo_memberDetails">
-                    { memberDetails }
-                </div>
-            </div> }
-
             <AutoHideScrollbar className="mx_UserInfo_scrollContainer">
+                { closeButton }
+                { avatarElement }
+
+                <div className="mx_UserInfo_container">
+                    <div className="mx_UserInfo_profile">
+                        <div>
+                            <h2 aria-label={displayName}>
+                                { e2eIcon }
+                                { displayName }
+                            </h2>
+                        </div>
+                        <div>{ user.userId }</div>
+                        <div className="mx_UserInfo_profileStatus">
+                            {presenceLabel}
+                            {statusLabel}
+                        </div>
+                    </div>
+                </div>
+
+                { memberDetails && <div className="mx_UserInfo_container mx_UserInfo_memberDetailsContainer">
+                    <div className="mx_UserInfo_memberDetails">
+                        { memberDetails }
+                    </div>
+                </div> }
+
                 { securitySection }
                 <UserOptionsSection
                     devices={devices}
diff --git a/src/components/views/right_panel/VerificationPanel.js b/src/components/views/right_panel/VerificationPanel.js
index 0d28e1568f..a75afd154a 100644
--- a/src/components/views/right_panel/VerificationPanel.js
+++ b/src/components/views/right_panel/VerificationPanel.js
@@ -17,6 +17,9 @@ limitations under the License.
 import React from 'react';
 import * as sdk from '../../../index';
 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 {
     constructor(props) {
@@ -36,7 +39,8 @@ export default class VerificationPanel extends React.PureComponent {
     renderStatus() {
         const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
         const Spinner = sdk.getComponent('elements.Spinner');
-        const {request} = this.props;
+        const {request: req} = this.props;
+        const request: VerificationRequest = req;
 
         if (request.requested) {
             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}>
                 Verify by emoji
             </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>);
         } else if (request.started) {
             if (this.state.sasWaitingForOtherParty) {
diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js
index 73c3d961ee..d2b6f8cbc8 100644
--- a/src/components/views/rooms/BasicMessageComposer.js
+++ b/src/components/views/rooms/BasicMessageComposer.js
@@ -209,6 +209,7 @@ export default class BasicMessageEditor extends React.Component {
             const range = getRangeForSelection(this._editorRef, model, selection);
             const selectedParts = range.parts.map(p => p.serialize());
             event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts));
+            event.clipboardData.setData("text/plain", text); // so plain copy/paste works
             if (type === "cut") {
                 // Remove the text, updating the model as appropriate
                 this._modifiedFlag = true;
diff --git a/src/components/views/rooms/E2EIcon.js b/src/components/views/rooms/E2EIcon.js
index 545d1fd7ed..df5fe204d4 100644
--- a/src/components/views/rooms/E2EIcon.js
+++ b/src/components/views/rooms/E2EIcon.js
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 New Vector Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,76 +15,102 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import React, {useState} from "react";
+import PropTypes from "prop-types";
 import classNames from 'classnames';
-import { _t } from '../../../languageHandler';
-import AccessibleButton from '../elements/AccessibleButton';
-import SettingsStore from '../../../settings/SettingsStore';
 
-export default function(props) {
-    const { isUser } = props;
-    const isNormal = props.status === "normal";
-    const isWarning = props.status === "warning";
-    const isVerified = props.status === "verified";
-    const e2eIconClasses = classNames({
+import {_t, _td} from '../../../languageHandler';
+import {useFeatureEnabled} from "../../../hooks/useSettings";
+import AccessibleButton from "../elements/AccessibleButton";
+import Tooltip from "../elements/Tooltip";
+
+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_warning: isWarning,
-        mx_E2EIcon_normal: isNormal,
-        mx_E2EIcon_verified: isVerified,
-    }, props.className);
+        mx_E2EIcon_warning: status === E2E_STATE.WARNING,
+        mx_E2EIcon_normal: status === E2E_STATE.NORMAL,
+        mx_E2EIcon_verified: status === E2E_STATE.VERIFIED,
+    }, className);
+
     let e2eTitle;
-
-    const crossSigning = SettingsStore.isFeatureEnabled("feature_cross_signing");
+    const crossSigning = useFeatureEnabled("feature_cross_signing");
     if (crossSigning && isUser) {
-        if (isWarning) {
-            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.",
-            );
-        }
+        e2eTitle = crossSigningUserTitles[status];
     } else if (crossSigning && !isUser) {
-        if (isWarning) {
-            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.",
-            );
-        }
+        e2eTitle = crossSigningRoomTitles[status];
     } else if (!crossSigning && isUser) {
-        if (isWarning) {
-            e2eTitle = _t("Some devices for this user are not trusted");
-        } else if (isVerified) {
-            e2eTitle = _t("All devices for this user are trusted");
-        }
+        e2eTitle = legacyUserTitles[status];
     } else if (!crossSigning && !isUser) {
-        if (isWarning) {
-            e2eTitle = _t("Some devices in this encrypted room are not trusted");
-        } else if (isVerified) {
-            e2eTitle = _t("All devices in this encrypted room are trusted");
-        }
+        e2eTitle = legacyRoomTitles[status];
     }
 
-    let style = null;
-    if (props.size) {
-        style = {width: `${props.size}px`, height: `${props.size}px`};
+    let style;
+    if (size) {
+        style = {width: `${size}px`, height: `${size}px`};
     }
 
-    const icon = (<div className={e2eIconClasses} style={style} title={e2eTitle} />);
-    if (props.onClick) {
-        return (<AccessibleButton onClick={props.onClick}>{ icon }</AccessibleButton>);
-    } else {
-        return icon;
+    const onMouseOver = () => setHover(true);
+    const onMouseOut = () => setHover(false);
+
+    let tip;
+    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;
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index dce4dc8a93..7d07e03b58 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -33,6 +33,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
 import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
 import * as ObjectUtils from "../../../ObjectUtils";
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
+import {E2E_STATE} from "./E2EIcon";
 
 const eventTileTypes = {
     'm.room.message': 'messages.MessageEvent',
@@ -235,6 +236,7 @@ export default createReactClass({
         this._suppressReadReceiptAnimation = false;
         const client = this.context;
         client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
+        client.on("userTrustStatusChanged", this.onUserVerificationChanged);
         this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
         if (this.props.showReactions) {
             this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
@@ -260,6 +262,7 @@ export default createReactClass({
     componentWillUnmount: function() {
         const client = this.context;
         client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
+        client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
         this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
         if (this.props.showReactions) {
             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) {
         if (!mxEvent.isEncrypted()) {
             return;
         }
 
+        // If we directly trust the device, short-circuit here
         const verified = await this.context.isEventSenderVerified(mxEvent);
+        if (verified) {
+            this.setState({
+                verified: E2E_STATE.VERIFIED,
+            }, () => {
+                // Decryption may have caused a change in size
+                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: verified,
-        }, () => {
-            // Decryption may have caused a change in size
-            this.props.onHeightChanged();
-        });
+            verified: eventSenderTrust.isVerified() ? E2E_STATE.VERIFIED : E2E_STATE.WARNING,
+        }, this.props.onHeightChanged); // Decryption may have caused a change in size
     },
 
     _propsEqual: function(objA, objB) {
@@ -473,8 +514,12 @@ export default createReactClass({
 
         // event is encrypted, display padlock corresponding to whether or not it is verified
         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
+            } else if (this.state.verified === E2E_STATE.UNKNOWN) {
+                return (<E2ePadlockUnknown />);
             } else {
                 return (<E2ePadlockUnverified />);
             }
@@ -527,6 +572,7 @@ export default createReactClass({
             console.error("EventTile attempted to get relations for an event without an ID");
             // Use event's special `toJSON` method to log key data.
             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");
     },
@@ -604,8 +650,9 @@ export default createReactClass({
             mx_EventTile_last: this.props.last,
             mx_EventTile_contextual: this.props.contextual,
             mx_EventTile_actionBarFocused: this.state.actionBarFocused,
-            mx_EventTile_verified: !isBubbleMessage && this.state.verified === true,
-            mx_EventTile_unverified: !isBubbleMessage && this.state.verified === false,
+            mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2E_STATE.VERIFIED,
+            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_emote: msgtype === 'm.emote',
             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 {
     static propTypes = {
         icon: PropTypes.string.isRequired,
diff --git a/src/components/views/rooms/InviteOnlyIcon.js b/src/components/views/rooms/InviteOnlyIcon.js
new file mode 100644
index 0000000000..5afaa7f0f2
--- /dev/null
+++ b/src/components/views/rooms/InviteOnlyIcon.js
@@ -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>);
+    }
+}
diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js
index 8d36f02d02..53e10fa750 100644
--- a/src/components/views/rooms/MessageComposer.js
+++ b/src/components/views/rooms/MessageComposer.js
@@ -26,6 +26,7 @@ import Stickerpicker from './Stickerpicker';
 import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
 import ContentMessages from '../../../ContentMessages';
 import E2EIcon from './E2EIcon';
+import SettingsStore from "../../../settings/SettingsStore";
 
 function ComposerAvatar(props) {
     const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@@ -168,7 +169,6 @@ export default class MessageComposer extends React.Component {
     constructor(props) {
         super(props);
         this.onInputStateChanged = this.onInputStateChanged.bind(this);
-        this.onEvent = this.onEvent.bind(this);
         this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
         this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
         this._onTombstoneClick = this._onTombstoneClick.bind(this);
@@ -182,11 +182,6 @@ export default class MessageComposer extends React.Component {
     }
 
     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);
         this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
         this._waitForOwnMember();
@@ -210,7 +205,6 @@ export default class MessageComposer extends React.Component {
 
     componentWillUnmount() {
         if (MatrixClientPeg.get()) {
-            MatrixClientPeg.get().removeListener("event", this.onEvent);
             MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
         }
         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) {
         if (ev.getRoomId() !== this.props.room.roomId) return;
 
@@ -282,18 +269,33 @@ export default class MessageComposer extends React.Component {
     }
 
     renderPlaceholderText() {
-        const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
-        if (this.state.isQuoting) {
-            if (roomIsEncrypted) {
-                return _t('Send an encrypted reply…');
+        if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
+            if (this.state.isQuoting) {
+                if (this.props.e2eStatus) {
+                    return _t('Send an encrypted reply…');
+                } else {
+                    return _t('Send a reply…');
+                }
             } else {
-                return _t('Send a reply (unencrypted)…');
+                if (this.props.e2eStatus) {
+                    return _t('Send an encrypted message…');
+                } else {
+                    return _t('Send a message…');
+                }
             }
         } else {
-            if (roomIsEncrypted) {
-                return _t('Send an encrypted message…');
+            if (this.state.isQuoting) {
+                if (this.props.e2eStatus) {
+                    return _t('Send an encrypted reply…');
+                } else {
+                    return _t('Send a reply (unencrypted)…');
+                }
             } else {
-                return _t('Send a message (unencrypted)…');
+                if (this.props.e2eStatus) {
+                    return _t('Send an encrypted message…');
+                } else {
+                    return _t('Send a message (unencrypted)…');
+                }
             }
         }
     }
diff --git a/src/components/views/rooms/RoomBreadcrumbs.js b/src/components/views/rooms/RoomBreadcrumbs.js
index 5a15a7518b..f9408d3259 100644
--- a/src/components/views/rooms/RoomBreadcrumbs.js
+++ b/src/components/views/rooms/RoomBreadcrumbs.js
@@ -363,7 +363,7 @@ export default class RoomBreadcrumbs extends React.Component {
             }
 
             let dmIndicator;
-            if (this._isDmRoom(r.room)) {
+            if (this._isDmRoom(r.room) && !SettingsStore.isFeatureEnabled("feature_cross_signing")) {
                 dmIndicator = <img
                     src={require("../../../../res/img/icon_person.svg")}
                     className="mx_RoomBreadcrumbs_dmIndicator"
diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js
index 09f3fd489f..3c0f2e3d41 100644
--- a/src/components/views/rooms/RoomHeader.js
+++ b/src/components/views/rooms/RoomHeader.js
@@ -31,7 +31,9 @@ import ManageIntegsButton from '../elements/ManageIntegsButton';
 import {CancelButton} from './SimpleRoomHeader';
 import SettingsStore from "../../../settings/SettingsStore";
 import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
+import DMRoomMap from '../../../utils/DMRoomMap';
 import E2EIcon from './E2EIcon';
+import InviteOnlyIcon from './InviteOnlyIcon';
 
 export default createReactClass({
     displayName: 'RoomHeader',
@@ -160,13 +162,16 @@ export default createReactClass({
             <E2EIcon status={this.props.e2eStatus} /> :
             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 joinRule = joinRules && joinRules.getContent().join_rule;
-        const joinRuleClass = classNames("mx_RoomHeader_PrivateIcon",
-                                         {"mx_RoomHeader_isPrivate": joinRule === "invite"});
-        const privateIcon = SettingsStore.isFeatureEnabled("feature_cross_signing") ?
-            <div className={joinRuleClass} /> :
-            undefined;
+        let privateIcon;
+        // Don't show an invite-only icon for DMs. Users know they're invite-only.
+        if (!dmUserId && SettingsStore.isFeatureEnabled("feature_cross_signing")) {
+            if (joinRule == "invite") {
+                privateIcon = <InviteOnlyIcon />;
+            }
+        }
 
         if (this.props.onCancelClick) {
             cancelButton = <CancelButton onClick={this.props.onCancelClick} />;
@@ -310,8 +315,7 @@ export default createReactClass({
         return (
             <div className="mx_RoomHeader light-panel">
                 <div className="mx_RoomHeader_wrapper">
-                    <div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
-                    { e2eIcon }
+                    <div className="mx_RoomHeader_avatar">{ roomAvatar }{ e2eIcon }</div>
                     { privateIcon }
                     { name }
                     { topicElement }
diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js
index 5c12b027a4..f41400ecfc 100644
--- a/src/components/views/rooms/RoomList.js
+++ b/src/components/views/rooms/RoomList.js
@@ -39,6 +39,7 @@ import * as sdk from "../../../index";
 import * as Receipt from "../../../utils/Receipt";
 import {Resizer} from '../../../resizer';
 import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
+import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
 
 const HIDE_CONFERENCE_CHANS = true;
 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'],
-                label: _t('People'),
+                label: _t('Direct Messages'),
                 tagName: "im.vector.fake.direct",
                 order: "recent",
                 incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'),
@@ -776,19 +777,22 @@ export default createReactClass({
 
         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 (
-            <div
-                {...props}
-                ref={this._collectResizeContainer}
-                className="mx_RoomList"
-                role="tree"
-                aria-label={_t("Rooms")}
-                onMouseMove={this.onMouseMove}
-                onMouseLeave={this.onMouseLeave}
-            >
-                { subListComponents }
-            </div>
+            <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
+                {({onKeyDownHandler}) => <div
+                    {...props}
+                    onKeyDown={onKeyDownHandler}
+                    ref={this._collectResizeContainer}
+                    className="mx_RoomList"
+                    role="tree"
+                    aria-label={_t("Rooms")}
+                    onMouseMove={this.onMouseMove}
+                    onMouseLeave={this.onMouseLeave}
+                >
+                    { subListComponents }
+                </div> }
+            </RovingTabIndexProvider>
         );
     },
 });
diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js
index cbc992d67f..9af06190f7 100644
--- a/src/components/views/rooms/RoomPreviewBar.js
+++ b/src/components/views/rooms/RoomPreviewBar.js
@@ -49,6 +49,7 @@ export default createReactClass({
     propTypes: {
         onJoinClick: PropTypes.func,
         onRejectClick: PropTypes.func,
+        onRejectAndIgnoreClick: PropTypes.func,
         onForgetClick: PropTypes.func,
         // if inviterName is specified, the preview bar will shown an invite to the room.
         // You should also specify onRejectClick if specifiying inviterName
@@ -282,6 +283,7 @@ export default createReactClass({
 
     render: function() {
         const Spinner = sdk.getComponent('elements.Spinner');
+        const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
 
         let showSpinner = false;
         let darkStyle = false;
@@ -292,6 +294,7 @@ export default createReactClass({
         let secondaryActionHandler;
         let secondaryActionLabel;
         let footer;
+        const extraComponents = [];
 
         const messageCase = this._getMessageCase();
         switch (messageCase) {
@@ -469,6 +472,14 @@ export default createReactClass({
                 primaryActionHandler = this.props.onJoinClick;
                 secondaryActionLabel = _t("Reject");
                 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;
             }
             case MessageCase.ViewingRoom: {
@@ -505,8 +516,6 @@ export default createReactClass({
             }
         }
 
-        const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
-
         let subTitleElements;
         if (subTitle) {
             if (!Array.isArray(subTitle)) {
@@ -554,6 +563,7 @@ export default createReactClass({
                 </div>
                 <div className="mx_RoomPreviewBar_actions">
                     { secondaryButton }
+                    { extraComponents }
                     { primaryButton }
                 </div>
                 <div className="mx_RoomPreviewBar_footer">
diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js
index 8c67be3b2a..41d43476ea 100644
--- a/src/components/views/rooms/RoomTile.js
+++ b/src/components/views/rooms/RoomTile.js
@@ -32,6 +32,11 @@ import ActiveRoomObserver from '../../../ActiveRoomObserver';
 import RoomViewStore from '../../../stores/RoomViewStore';
 import SettingsStore from "../../../settings/SettingsStore";
 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({
     displayName: 'RoomTile',
@@ -69,6 +74,7 @@ export default createReactClass({
             notificationCount: this.props.room.getUnreadNotificationCount(),
             selected: this.props.room.roomId === RoomViewStore.getRoomId(),
             statusMessage: this._getStatusMessage(),
+            e2eStatus: null,
         });
     },
 
@@ -101,6 +107,83 @@ export default createReactClass({
         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) {
         if (room !== this.props.room) return;
         this.setState({
@@ -150,10 +233,19 @@ export default createReactClass({
     },
 
     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();
         cli.on("accountData", this.onAccountData);
         cli.on("Room.name", this.onRoomName);
         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);
         this.dispatcherRef = dis.register(this.onAction);
 
@@ -171,6 +263,9 @@ export default createReactClass({
             MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
             MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
             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);
         dis.unregister(this.dispatcherRef);
@@ -317,7 +412,6 @@ export default createReactClass({
             'mx_RoomTile_noBadges': !badges,
             'mx_RoomTile_transparent': this.props.transparent,
             'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
-            'mx_RoomTile_isPrivate': this.state.joinRule == "invite" && !dmUserId,
         });
 
         const avatarClasses = classNames({
@@ -352,7 +446,8 @@ export default createReactClass({
             });
 
             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) {
             const Tooltip = sdk.getComponent("elements.Tooltip");
             tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto" />;
@@ -383,7 +478,9 @@ export default createReactClass({
 
         let dmIndicator;
         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
                 src={require("../../../../res/img/icon_person.svg")}
                 className="mx_RoomTile_dm"
@@ -428,40 +525,54 @@ export default createReactClass({
 
         let privateIcon = null;
         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>
-            <AccessibleButton
-                tabIndex="0"
-                className={classes}
-                onClick={this.onClick}
-                onMouseEnter={this.onMouseEnter}
-                onMouseLeave={this.onMouseLeave}
-                onContextMenu={this.onContextMenu}
-                aria-label={ariaLabel}
-                aria-selected={this.state.selected}
-                role="treeitem"
-            >
-                <div className={avatarClasses}>
-                    <div className="mx_RoomTile_avatar_container">
-                        <RoomAvatar room={this.props.room} width={24} height={24} />
-                        { dmIndicator }
-                    </div>
-                </div>
-                { privateIcon }
-                <div className="mx_RoomTile_nameContainer">
-                    <div className="mx_RoomTile_labelContainer">
-                        { label }
-                        { subtextLabel }
-                    </div>
-                    { dmOnline }
-                    { contextMenuButton }
-                    { badge }
-                </div>
-                { /* { incomingCallBox } */ }
-                { tooltip }
-            </AccessibleButton>
+            <RovingTabIndexWrapper>
+                {({onFocus, isActive, ref}) =>
+                    <AccessibleButton
+                        onFocus={onFocus}
+                        tabIndex={isActive ? 0 : -1}
+                        inputRef={ref}
+                        className={classes}
+                        onClick={this.onClick}
+                        onMouseEnter={this.onMouseEnter}
+                        onMouseLeave={this.onMouseLeave}
+                        onContextMenu={this.onContextMenu}
+                        aria-label={ariaLabel}
+                        aria-selected={this.state.selected}
+                        role="treeitem"
+                    >
+                        <div className={avatarClasses}>
+                            <div className="mx_RoomTile_avatar_container">
+                                <RoomAvatar room={this.props.room} width={24} height={24} />
+                                { dmIndicator }
+                                { e2eIcon }
+                            </div>
+                        </div>
+                        { privateIcon }
+                        <div className="mx_RoomTile_nameContainer">
+                            <div className="mx_RoomTile_labelContainer">
+                                { label }
+                                { subtextLabel }
+                            </div>
+                            { dmOnline }
+                            { contextMenuButton }
+                            { badge }
+                        </div>
+                        { /* { incomingCallBox } */ }
+                        { tooltip }
+                    </AccessibleButton>
+                }
+            </RovingTabIndexWrapper>
 
             { contextMenu }
         </React.Fragment>;
diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js
index e3b794b1d0..63e58bf738 100644
--- a/src/components/views/rooms/SendMessageComposer.js
+++ b/src/components/views/rooms/SendMessageComposer.js
@@ -24,6 +24,8 @@ import {
     containsEmote,
     stripEmoteCommand,
     unescapeMessage,
+    startsWith,
+    stripPrefix,
 } from '../../../editor/serialize';
 import {CommandPartCreator} from '../../../editor/parts';
 import BasicMessageComposer from "./BasicMessageComposer";
@@ -33,7 +35,7 @@ import ReplyThread from "../elements/ReplyThread";
 import {parseEvent} from '../../../editor/deserialize';
 import {findEditableEvent} from '../../../utils/EventUtils';
 import SendHistoryManager from "../../../SendHistoryManager";
-import {processCommandInput} from '../../../SlashCommands';
+import {getCommand} from '../../../SlashCommands';
 import * as sdk from '../../../index';
 import Modal from '../../../Modal';
 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);
     if (isEmote) {
         model = stripEmoteCommand(model);
     }
+    if (startsWith(model, "//")) {
+        model = stripPrefix(model, "/");
+    }
     model = unescapeMessage(model);
     const repliedToEvent = RoomViewStore.getQuotingEvent();
 
@@ -175,20 +181,21 @@ export default class SendMessageComposer extends React.Component {
         const parts = this.model.parts;
         const firstPart = parts[0];
         if (firstPart) {
-            if (firstPart.type === "command") {
+            if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
                 return true;
             }
             // be extra resilient when somehow the AutocompleteWrapperModel or
             // CommandPartCreator fails to insert a command part, so we don't send
             // 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 false;
     }
 
-    async _runSlashCommand() {
+    _getSlashCommand() {
         const commandText = this.model.parts.reduce((text, part) => {
             // use mxid to textify user pills in a command
             if (part.type === "user-pill") {
@@ -196,50 +203,86 @@ export default class SendMessageComposer extends React.Component {
             }
             return text + part.text;
         }, "");
-        const cmd = processCommandInput(this.props.room.roomId, commandText);
+        return [getCommand(this.props.room.roomId, commandText), commandText];
+    }
 
-        if (cmd) {
-            let error = cmd.error;
-            if (cmd.promise) {
-                try {
-                    await cmd.promise;
-                } catch (err) {
-                    error = err;
-                }
+    async _runSlashCommand(fn) {
+        const cmd = fn();
+        let error = cmd.error;
+        if (cmd.promise) {
+            try {
+                await cmd.promise;
+            } catch (err) {
+                error = err;
             }
-            if (error) {
-                console.error("Command failure: %s", error);
-                const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
-                // assume the error is a server error when the command is async
-                const isServerError = !!cmd.promise;
-                const title = isServerError ? _td("Server error") : _td("Command error");
+        }
+        if (error) {
+            console.error("Command failure: %s", error);
+            const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+            // assume the error is a server error when the command is async
+            const isServerError = !!cmd.promise;
+            const title = isServerError ? _td("Server error") : _td("Command error");
 
-                let errText;
-                if (typeof error === 'string') {
-                    errText = error;
-                } else if (error.message) {
-                    errText = error.message;
-                } else {
-                    errText = _t("Server unavailable, overloaded, or something else went wrong.");
-                }
-
-                Modal.createTrackedDialog(title, '', ErrorDialog, {
-                    title: _t(title),
-                    description: errText,
-                });
+            let errText;
+            if (typeof error === 'string') {
+                errText = error;
+            } else if (error.message) {
+                errText = error.message;
             } else {
-                console.log("Command success.");
+                errText = _t("Server unavailable, overloaded, or something else went wrong.");
             }
+
+            Modal.createTrackedDialog(title, '', ErrorDialog, {
+                title: _t(title),
+                description: errText,
+            });
+        } else {
+            console.log("Command success.");
         }
     }
 
-    _sendMessage() {
+    async _sendMessage() {
         if (this.model.isEmpty) {
             return;
         }
+
+        let shouldSend = true;
+
         if (!containsEmote(this.model) && this._isSlashCommand()) {
-            this._runSlashCommand();
-        } else {
+            const [cmd, commandText] = this._getSlashCommand();
+            if (cmd) {
+                shouldSend = false;
+                this._runSlashCommand(cmd);
+            } 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 {roomId} = this.props.room;
             const content = createMessageContent(this.model, this.props.permalinkCreator);
@@ -253,6 +296,7 @@ export default class SendMessageComposer extends React.Component {
                 });
             }
         }
+
         this.sendHistoryManager.save(this.model);
         // clear composer
         this.model.reset([]);
diff --git a/src/components/views/settings/EventIndexPanel.js b/src/components/views/settings/EventIndexPanel.js
new file mode 100644
index 0000000000..68faa53e53
--- /dev/null
+++ b/src/components/views/settings/EventIndexPanel.js
@@ -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;
+    }
+}
diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js
index b9eaa3efa3..2d56df6be4 100644
--- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js
@@ -70,7 +70,16 @@ export default class GeneralUserSettingsTab extends React.Component {
         const cli = MatrixClientPeg.get();
 
         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();
     }
@@ -280,7 +289,7 @@ export default class GeneralUserSettingsTab extends React.Component {
         const PhoneNumbers = sdk.getComponent("views.settings.account.PhoneNumbers");
         const Spinner = sdk.getComponent("views.elements.Spinner");
 
-        const passwordChangeForm = (
+        let passwordChangeForm = (
             <ChangePassword
                 className="mx_GeneralUserSettingsTab_changePassword"
                 rowClassName=""
@@ -314,11 +323,18 @@ export default class GeneralUserSettingsTab extends React.Component {
             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 (
             <div className="mx_SettingsTab_section mx_GeneralUserSettingsTab_accountSection">
                 <span className="mx_SettingsTab_subheading">{_t("Account")}</span>
                 <p className="mx_SettingsTab_subsectionText">
-                    {_t("Set a new account password...")}
+                    {passwordChangeText}
                 </p>
                 {passwordChangeForm}
                 {threepidSection}
diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js b/src/components/views/settings/tabs/user/HelpUserSettingsTab.js
index ab71de86b9..a245c7c7b9 100644
--- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.js
@@ -21,15 +21,10 @@ import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
 import AccessibleButton from "../../../elements/AccessibleButton";
 import SdkConfig from "../../../../../SdkConfig";
 import createRoom from "../../../../../createRoom";
-import packageJson from "../../../../../../package.json";
 import Modal from "../../../../../Modal";
 import * as sdk from "../../../../../";
 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.
 const semVerRegex = /^v?(\d+\.\d+\.\d+(?:-rc.+)?)(?:-(?:\d+-g)?([0-9a-fA-F]+))?(?:-dirty)?$/i;
 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
             ? ghVersionLabel('vector-im/riot-web', this.state.vectorVersion)
             : 'unknown';
@@ -243,7 +235,6 @@ export default class HelpUserSettingsTab extends React.Component {
                 <div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
                     <span className='mx_SettingsTab_subheading'>{_t("Versions")}</span>
                     <div className='mx_SettingsTab_subsectionText'>
-                        {_t("matrix-react-sdk version:")} {reactSdkVersion}<br />
                         {_t("riot-web version:")} {vectorVersion}<br />
                         {_t("olm version:")} {olmVersion}<br />
                         {updateButton}
diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js
index db5b95cb4c..bd1b7c2ca4 100644
--- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js
@@ -170,6 +170,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
         return (
             <div className="mx_SettingsTab mx_PreferencesUserSettingsTab">
                 <div className="mx_SettingsTab_heading">{_t("Preferences")}</div>
+
                 <div className="mx_SettingsTab_section">
                     <span className="mx_SettingsTab_subheading">{_t("Composer")}</span>
                     {this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js
index 5eadfc234a..eb5f346714 100644
--- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js
@@ -242,6 +242,7 @@ export default class SecurityUserSettingsTab extends React.Component {
     render() {
         const DevicesPanel = sdk.getComponent('views.settings.DevicesPanel');
         const SettingsFlag = sdk.getComponent('views.elements.SettingsFlag');
+        const EventIndexPanel = sdk.getComponent('views.settings.EventIndexPanel');
 
         const KeyBackupPanel = sdk.getComponent('views.settings.KeyBackupPanel');
         const keyBackup = (
@@ -253,6 +254,16 @@ export default class SecurityUserSettingsTab extends React.Component {
             </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
         // 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
@@ -281,6 +292,7 @@ export default class SecurityUserSettingsTab extends React.Component {
                     </div>
                 </div>
                 {keyBackup}
+                {eventIndex}
                 {crossSigning}
                 {this._renderCurrentDeviceInfo()}
                 <div className='mx_SettingsTab_section'>
diff --git a/src/components/views/toasts/NewSessionToast.js b/src/components/views/toasts/NewSessionToast.js
index f83326121b..3b60f59131 100644
--- a/src/components/views/toasts/NewSessionToast.js
+++ b/src/components/views/toasts/NewSessionToast.js
@@ -32,7 +32,7 @@ export default class VerifySessionToast extends React.PureComponent {
         DeviceListener.sharedInstance().dismissVerification(this.props.deviceId);
     };
 
-    _onVerifyClick = async () => {
+    _onReviewClick = async () => {
         const cli = MatrixClientPeg.get();
         const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog');
 
@@ -47,10 +47,10 @@ export default class VerifySessionToast extends React.PureComponent {
     render() {
         const FormButton = sdk.getComponent("elements.FormButton");
         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">
                 <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>);
     }
diff --git a/src/components/views/toasts/SetupEncryptionToast.js b/src/components/views/toasts/SetupEncryptionToast.js
new file mode 100644
index 0000000000..841ee66ac7
--- /dev/null
+++ b/src/components/views/toasts/SetupEncryptionToast.js
@@ -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>);
+    }
+}
diff --git a/src/components/views/toasts/VerificationRequestToast.js b/src/components/views/toasts/VerificationRequestToast.js
index 479a3e3f93..b2d1aaf59d 100644
--- a/src/components/views/toasts/VerificationRequestToast.js
+++ b/src/components/views/toasts/VerificationRequestToast.js
@@ -23,6 +23,7 @@ import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases";
 import {userLabelForEventRoom} from "../../../utils/KeyVerificationStateObserver";
 import dis from "../../../dispatcher";
 import ToastStore from "../../../stores/ToastStore";
+import Modal from "../../../Modal";
 
 export default class VerificationRequestToast extends React.PureComponent {
     constructor(props) {
@@ -38,6 +39,13 @@ export default class VerificationRequestToast extends React.PureComponent {
             this.setState({counter});
         }, 1000);
         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() {
@@ -65,22 +73,27 @@ export default class VerificationRequestToast extends React.PureComponent {
     accept = async () => {
         ToastStore.sharedInstance().dismissToast(this.props.toastKey);
         const {request} = this.props;
-        const {event} = request;
         // no room id for to_device requests
-        if (event.getRoomId()) {
-            dis.dispatch({
-                action: 'view_room',
-                room_id: event.getRoomId(),
-                should_peek: false,
-            });
-        }
         try {
-            await request.accept();
-            dis.dispatch({
-                action: "set_right_panel_phase",
-                phase: RIGHT_PANEL_PHASES.EncryptionPanel,
-                refireParams: {verificationRequest: request},
-            });
+            if (request.channel.roomId) {
+                dis.dispatch({
+                    action: 'view_room',
+                    room_id: request.channel.roomId,
+                    should_peek: false,
+                });
+                await request.accept();
+                dis.dispatch({
+                    action: "set_right_panel_phase",
+                    phase: RIGHT_PANEL_PHASES.EncryptionPanel,
+                    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) {
             console.error(err.message);
         }
@@ -89,13 +102,13 @@ export default class VerificationRequestToast extends React.PureComponent {
     render() {
         const FormButton = sdk.getComponent("elements.FormButton");
         const {request} = this.props;
-        const {event} = request;
         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
         if (nameLabel === userId) {
             const client = MatrixClientPeg.get();
-            const user = client.getUser(event.getSender());
+            const user = client.getUser(userId);
             if (user && user.displayName) {
                 nameLabel = _t("%(name)s (%(userId)s)", {name: user.displayName, userId});
             }
diff --git a/src/createRoom.js b/src/createRoom.js
index cde9e8b03e..c25b618dc6 100644
--- a/src/createRoom.js
+++ b/src/createRoom.js
@@ -1,6 +1,6 @@
 /*
 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");
 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 {bool=} opts.spinner True to show a modal spinner while the room is created.
  *     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
  * action was aborted or failed.
@@ -39,6 +43,8 @@ import {getAddressType} from "./UserAddress";
 export default function createRoom(opts) {
     opts = opts || {};
     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 Loader = sdk.getComponent("elements.Spinner");
@@ -77,18 +83,30 @@ export default function createRoom(opts) {
         opts.andView = true;
     }
 
+    createOpts.initial_state = createOpts.initial_state || [];
+
     // Allow guests by default since the room is private and they'd
     // need an invite. This means clicking on a 3pid invite email can
     // 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: {
                 guest_access: 'can_join',
             },
-            type: 'm.room.guest_access',
+        });
+    }
+
+    if (opts.encryption) {
+        createOpts.initial_state.push({
+            type: 'm.room.encryption',
             state_key: '',
-        },
-    ];
+            content: {
+                algorithm: 'm.megolm.v1.aes-sha2',
+            },
+        });
+    }
 
     let modal;
     if (opts.spinner) modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js
index 1fdbf9490c..190963f357 100644
--- a/src/editor/deserialize.js
+++ b/src/editor/deserialize.js
@@ -250,7 +250,7 @@ function parseHtmlMessage(html, 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) => {
         if (isQuotedMessage) {
             parts.push(partCreator.plain(QUOTE_LINE_PREFIX));
diff --git a/src/editor/operations.js b/src/editor/operations.js
index e2661faf59..d677d7016c 100644
--- a/src/editor/operations.js
+++ b/src/editor/operations.js
@@ -100,27 +100,71 @@ export function formatRangeAsCode(range) {
     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) {
     const {model, parts} = range;
     const {partCreator} = model;
 
-    const isFormatted = parts.length &&
-        parts[0].text.startsWith(prefix) &&
-        parts[parts.length - 1].text.endsWith(suffix);
+    // compute paragraph [start, end] indexes
+    const paragraphIndexes = [];
+    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
 
-    if (isFormatted) {
-        // remove prefix and suffix
-        const partWithoutPrefix = parts[0].serialize();
-        partWithoutPrefix.text = partWithoutPrefix.text.substr(prefix.length);
-        parts[0] = partCreator.deserializePart(partWithoutPrefix);
+        // 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;
+        }
 
-        const partWithoutSuffix = parts[parts.length - 1].serialize();
-        const suffixPartText = partWithoutSuffix.text;
-        partWithoutSuffix.text = suffixPartText.substring(0, suffixPartText.length - suffix.length);
-        parts[parts.length - 1] = partCreator.deserializePart(partWithoutSuffix);
-    } else {
-        parts.unshift(partCreator.plain(prefix));
-        parts.push(partCreator.plain(suffix));
+        // 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) {
+            // remove prefix and suffix
+            const partWithoutPrefix = parts[base].serialize();
+            partWithoutPrefix.text = partWithoutPrefix.text.substr(prefix.length);
+            parts[base] = partCreator.deserializePart(partWithoutPrefix);
+
+            const partWithoutSuffix = parts[index - 1].serialize();
+            const suffixPartText = partWithoutSuffix.text;
+            partWithoutSuffix.text = suffixPartText.substring(0, suffixPartText.length - suffix.length);
+            parts[index - 1] = partCreator.deserializePart(partWithoutSuffix);
+        } else {
+            parts.splice(index, 0, partCreator.plain(suffix)); // splice in the later one first to not change offset
+            parts.splice(base, 0, partCreator.plain(prefix));
+            offset += 2; // offset index to account for the two items we just spliced in
+        }
+    });
+
     replaceRangeAndExpandSelection(range, parts);
 }
diff --git a/src/editor/serialize.js b/src/editor/serialize.js
index a55eed97da..ba380f2809 100644
--- a/src/editor/serialize.js
+++ b/src/editor/serialize.js
@@ -61,18 +61,26 @@ export function textSerialize(model) {
 }
 
 export function containsEmote(model) {
+    return startsWith(model, "/me ");
+}
+
+export function startsWith(model, prefix) {
     const firstPart = model.parts[0];
     // part type will be "plain" while editing,
     // and "command" while composing a message.
     return firstPart &&
         (firstPart.type === "plain" || firstPart.type === "command") &&
-        firstPart.text.startsWith("/me ");
+        firstPart.text.startsWith(prefix);
 }
 
 export function stripEmoteCommand(model) {
     // trim "/me "
+    return stripPrefix(model, "/me ");
+}
+
+export function stripPrefix(model, prefix) {
     model = model.clone();
-    model.removeText({index: 0, offset: 0}, 4);
+    model.removeText({index: 0, offset: 0}, prefix.length);
     return model;
 }
 
diff --git a/src/emoji.js b/src/emoji.js
index 125864e381..20b05531ca 100644
--- a/src/emoji.js
+++ b/src/emoji.js
@@ -79,13 +79,13 @@ EMOJIBASE.forEach(emoji => {
 });
 
 /**
- * Strips variation selectors from a string
- * NB. Skin tone modifers are not variation selectors:
+ * Strips variation selectors from the end of given string
+ * NB. Skin tone modifiers are not variation selectors:
  * this function does not touch them. (Should it?)
  *
  * @param {string} str string to strip
  * @returns {string} stripped string
  */
 function stripVariation(str) {
-    return str.replace(/[\uFE00-\uFE0F]/, "");
+    return str.replace(/[\uFE00-\uFE0F]$/, "");
 }
diff --git a/src/components/views/emojipicker/recent.js b/src/emojipicker/recent.js
similarity index 100%
rename from src/components/views/emojipicker/recent.js
rename to src/emojipicker/recent.js
diff --git a/src/hooks/useSettings.js b/src/hooks/useSettings.js
new file mode 100644
index 0000000000..151a6369de
--- /dev/null
+++ b/src/hooks/useSettings.js
@@ -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;
+};
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 61dcd90638..d23a5af28e 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -21,6 +21,9 @@
     "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:",
     "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",
     "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",
@@ -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 %(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",
+    "Verify this session": "Verify this session",
+    "Encryption upgrade available": "Encryption upgrade available",
+    "Set up encryption": "Set up encryption",
     "New Session": "New Session",
     "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",
@@ -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.",
     "Only continue if you trust the owner of the server.": "Only continue if you trust the owner of the server.",
     "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 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",
@@ -121,15 +124,8 @@
     "Moderator": "Moderator",
     "Admin": "Admin",
     "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",
+    "Operation failed": "Operation failed",
     "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:",
     "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 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",
-    "Unrecognised command:": "Unrecognised command:",
     "Reason": "Reason",
     "%(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.",
@@ -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 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.",
+    "%(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.|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.|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 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.",
@@ -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 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 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",
     "%(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.",
@@ -373,7 +372,6 @@
     "Render simple counters in room header": "Render simple counters in room header",
     "Multiple integration managers": "Multiple integration managers",
     "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",
     "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)",
@@ -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)",
     "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",
+    "Enable message search in encrypted rooms": "Enable message search in encrypted rooms",
     "Collecting app version information": "Collecting app version information",
     "Collecting logs": "Collecting logs",
     "Uploading report": "Uploading report",
@@ -514,8 +513,12 @@
     "Headphones": "Headphones",
     "Folder": "Folder",
     "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",
+    "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",
     "Decline (%(counter)s)": "Decline (%(counter)s)",
     "Accept <policyLink /> to continue:": "Accept <policyLink /> to continue:",
@@ -563,6 +566,14 @@
     "Failed to set display name": "Failed to set display name",
     "Disable Notifications": "Disable 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...",
     "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.",
@@ -669,8 +680,8 @@
     "Profile": "Profile",
     "Email addresses": "Email addresses",
     "Phone numbers": "Phone numbers",
-    "Account": "Account",
     "Set a new account password...": "Set a new account password...",
+    "Account": "Account",
     "Language and region": "Language and region",
     "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.",
@@ -694,7 +705,6 @@
     "Clear cache and reload": "Clear cache and reload",
     "FAQ": "FAQ",
     "Versions": "Versions",
-    "matrix-react-sdk version:": "matrix-react-sdk version:",
     "riot-web version:": "riot-web version:",
     "olm version:": "olm version:",
     "Homeserver is": "Homeserver is",
@@ -757,6 +767,7 @@
     "Accept all %(invitedRooms)s invites": "Accept all %(invitedRooms)s invites",
     "Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites",
     "Key backup": "Key backup",
+    "Message search": "Message search",
     "Cross-signing": "Cross-signing",
     "Security & Privacy": "Security & Privacy",
     "Devices": "Devices",
@@ -888,8 +899,9 @@
     "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 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.",
-    "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.",
+    "Someone is using an unknown device": "Someone is using an unknown device",
+    "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",
     "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",
@@ -907,7 +919,9 @@
     "This message cannot be decrypted": "This message cannot be decrypted",
     "Encrypted by an unverified device": "Encrypted by an unverified device",
     "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",
+    "Invite only": "Invite only",
     "Scroll to bottom of page": "Scroll to bottom of page",
     "Close preview": "Close preview",
     "device id: ": "device id: ",
@@ -946,6 +960,7 @@
     "Invite": "Invite",
     "Share Link to User": "Share Link to User",
     "User Options": "User Options",
+    "Start a chat": "Start a chat",
     "Direct chats": "Direct chats",
     "Remove recent messages": "Remove recent messages",
     "Unmute": "Unmute",
@@ -964,8 +979,10 @@
     "Hangup": "Hangup",
     "Upload file": "Upload file",
     "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 a message…": "Send a message…",
+    "Send a reply (unencrypted)…": "Send a reply (unencrypted)…",
     "Send a message (unencrypted)…": "Send a message (unencrypted)…",
     "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.",
@@ -1012,7 +1029,7 @@
     "Community Invites": "Community Invites",
     "Invites": "Invites",
     "Favourites": "Favourites",
-    "People": "People",
+    "Direct Messages": "Direct Messages",
     "Start chat": "Start chat",
     "Rooms": "Rooms",
     "Low priority": "Low priority",
@@ -1049,6 +1066,7 @@
     "Do you want to join %(roomName)s?": "Do you want to join %(roomName)s?",
     "<userName/> invited you": "<userName/> invited you",
     "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?",
     "%(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.",
@@ -1079,6 +1097,11 @@
     "Server error": "Server error",
     "Command error": "Command error",
     "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",
     "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled",
     "Add some now": "Add some now",
@@ -1464,8 +1487,7 @@
     "Recent Conversations": "Recent Conversations",
     "Suggestions": "Suggestions",
     "Recently Direct Messaged": "Recently Direct Messaged",
-    "Direct Messages": "Direct Messages",
-    "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>.",
+    "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>.",
     "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>.",
     "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.",
     "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 />.",
-    "Upgrade": "Upgrade",
     "Sign out and remove encryption keys?": "Sign out and remove encryption keys?",
     "Clear Storage and Sign Out": "Clear Storage and Sign Out",
     "Send Logs": "Send Logs",
@@ -1973,18 +1994,19 @@
     "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.",
     "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.",
-    "<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.",
-    "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.",
-    "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 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.",
+    "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:",
+    "Enter a passphrase": "Enter a passphrase",
     "Set up with a recovery key": "Set up with a recovery key",
     "That matches!": "That matches!",
     "That doesn't match.": "That doesn't match.",
     "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.",
-    "Repeat your passphrase...": "Repeat your passphrase...",
+    "Enter your passphrase a second time to confirm it.": "Enter your passphrase a second time to confirm it.",
+    "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.": "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.",
@@ -1997,21 +2019,25 @@
     "<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>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.",
     "Set up secret storage": "Set up secret storage",
     "Restore your Key Backup": "Restore your Key Backup",
-    "Migrate from Key Backup": "Migrate from Key Backup",
-    "Secure your encrypted messages with a passphrase": "Secure your encrypted messages with a passphrase",
-    "Confirm your passphrase": "Confirm your passphrase",
+    "Upgrade your encryption": "Upgrade your encryption",
     "Recovery key": "Recovery key",
     "Keep it safe": "Keep it safe",
     "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",
     "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.",
+    "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",
+    "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.": "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).",
@@ -2019,6 +2045,7 @@
     "Set up Secure Message Recovery": "Set up Secure Message Recovery",
     "Secure your backup with a passphrase": "Secure your backup with a passphrase",
     "Starting backup...": "Starting backup...",
+    "Success!": "Success!",
     "Create Key Backup": "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.",
@@ -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.",
     "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 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 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"
diff --git a/src/i18n/strings/eo.json b/src/i18n/strings/eo.json
index 4ac519d16a..243aebb52d 100644
--- a/src/i18n/strings/eo.json
+++ b/src/i18n/strings/eo.json
@@ -1851,7 +1851,7 @@
     "Error unsubscribing from list": "Eraris malabono de la listo",
     "Please try again or view your console for hints.": "Bonvolu reprovi aŭ serĉi helpilojn en via konzolo.",
     "You have not ignored anyone.": "Vi neniun malatentis.",
-    "You are not subscribed to any lists": "Vi neniun liston abonis.",
+    "You are not subscribed to any lists": "Vi neniun liston abonis",
     "Unsubscribe": "Malaboni",
     "View rules": "Montri regulojn",
     "You are currently subscribed to:": "Vi nun abonas:",
@@ -1867,5 +1867,28 @@
     "Widget ID": "Identigilo de fenestraĵo",
     "Widgets do not use message encryption.": "Fenestraĵoj ne uzas ĉifradon de mesaĝoj.",
     "Widget added by": "Fenestraĵon aldonis",
-    "This widget may use cookies.": "Ĉi tiu fenestraĵo povas uzi kuketojn."
+    "This widget may use cookies.": "Ĉi tiu fenestraĵo povas uzi kuketojn.",
+    "%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room|other": "%(senderName)s aldonis %(addedAddresses)s kaj %(count)s aliajn adresojn al ĉi tiu ĉambro",
+    "%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room|other": "%(senderName)s forigis %(removedAddresses)s kaj %(count)s aliajn adresojn el ĉi tiu ĉambro",
+    "%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room": "%(senderName)s forigis %(countRemoved)s kaj aldonis %(countAdded)s adresojn al ĉi tiu ĉambro",
+    "%(senderName)s turned on end-to-end encryption.": "%(senderName)s ŝaltis tutvojan ĉifradon.",
+    "%(senderName)s turned on end-to-end encryption (unrecognised algorithm %(algorithm)s).": "%(senderName)s ŝaltis tutvojan ĉifradon (nerekonita algoritmo %(algorithm)s).",
+    "a few seconds ago": "antaŭ kelkaj sekundoj",
+    "about a minute ago": "antaŭ ĉirkaŭ minuto",
+    "%(num)s minutes ago": "antaŭ %(num)s minutoj",
+    "about an hour ago": "antaŭ ĉirkaŭ horo",
+    "%(num)s hours ago": "antaŭ %(num)s horoj",
+    "about a day ago": "antaŭ ĉirkaŭ tago",
+    "%(num)s days ago": "antaŭ %(num)s tagoj",
+    "a few seconds from now": "kelkajn sekundojn de nun",
+    "about a minute from now": "ĉirkaŭ minuton de nun",
+    "%(num)s minutes from now": "%(num)s minutojn de nun",
+    "about an hour from now": "ĉirkaŭ horon de nun",
+    "%(num)s hours from now": "%(num)s horojn de nun",
+    "about a day from now": "ĉirkaŭ tagon de nun",
+    "%(num)s days from now": "%(num)s tagojn de nun",
+    "Lock": "Seruro",
+    "Other users may not trust it": "Aliaj uzantoj eble ne kredas ĝin",
+    "Later": "Pli poste",
+    "Verify": "Kontroli"
 }
diff --git a/src/i18n/strings/eu.json b/src/i18n/strings/eu.json
index 97cef0c70d..1ab4bd15b1 100644
--- a/src/i18n/strings/eu.json
+++ b/src/i18n/strings/eu.json
@@ -1617,7 +1617,7 @@
     "Resend edit": "Birbidali edizioa",
     "Resend %(unsentCount)s reaction(s)": "Birbidali %(unsentCount)s erreakzio",
     "Resend removal": "Birbidali kentzeko agindua",
-    "Forgotten your password?": "Pasahitza ahaztu duzu?",
+    "Forgotten your password?": "Pasahitza ahaztuta?",
     "Sign in and regain access to your account.": "Hasi saioa eta berreskuratu zure kontua.",
     "You're signed out": "Saioa amaitu duzu",
     "Clear personal data": "Garbitu datu pertsonalak",
@@ -2074,5 +2074,41 @@
     "Done": "Egina",
     "Without completing security on this device, it won’t have access to encrypted messages.": "Gailu honetan segurtasuna osatu ezean, ez du zifratutako mezuetara sarbiderik izango.",
     "Go Back": "Joan atzera",
-    "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.": "Biltegi sekretua oraingo gakoen babeskopiaren xehetasunak erabiliz ezarriko da. Zure biltegi sekretuaren pasa-esaldia eta berreskuratze gakoa zure gakoen babes-kopiarako zenerabiltzanak izango dira."
+    "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.": "Biltegi sekretua oraingo gakoen babeskopiaren xehetasunak erabiliz ezarriko da. Zure biltegi sekretuaren pasa-esaldia eta berreskuratze gakoa zure gakoen babes-kopiarako zenerabiltzanak izango dira.",
+    "%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room|other": "%(senderName)s erabiltzaileak %(addedAddresses)s helbideak eta beste %(count)s gehitu dizkio gela honi",
+    "%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room|other": "%(senderName)s erabiltzaileak %(removedAddresses)s helbideak eta beste %(count)s kendu dizkio gela honi",
+    "%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room": "%(senderName)s erabiltzaileak %(countRemoved)s helbide kendu eta %(countAdded)s gehitu dizkio gela honi",
+    "%(senderName)s turned on end-to-end encryption.": "%(senderName)s erabiltzaileak muturretik muturrera zifratzea aktibatu du.",
+    "%(senderName)s turned on end-to-end encryption (unrecognised algorithm %(algorithm)s).": "%(senderName)s erabiltzaileak muturretik muturrera zifratzea gaitu du (%(algorithm)s algoritmo ezezaguna).",
+    "Someone is using an unknown device": "Baten bat gailu ezezagun bat erabiltzen ari da",
+    "This room is end-to-end encrypted": "Gela hau muturretik muturrera zifratuta dago",
+    "Everyone in this room is verified": "Gelako guztiak egiaztatuta daude",
+    "Encrypted by a deleted device": "Ezabatutako gailu batek zifratua",
+    "Invite only": "Gonbidapenez besterik ez",
+    "Send a reply…": "Bidali erantzuna…",
+    "Send a message…": "Bidali mezua…",
+    "Reject & Ignore user": "Ukatu eta ezikusi erabiltzailea",
+    "Unknown Command": "Agindu ezezaguna",
+    "Unrecognised command: %(commandText)s": "Agindu ezezaguna: %(commandText)s",
+    "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "<code>/help</code> erabili dezakezu agindu erabilgarrien zerrenda ikusteko. Ala mezu gisa bidali nahi zenuen hau?",
+    "Hint: Begin your message with <code>//</code> to start it with a slash.": "Aholkua: Hasi zure mezua <code>//</code> idatziz barra batekin hasi nahi baduzu.",
+    "Send as message": "Bidali mezu gisa",
+    "Verify User": "Egiaztatu erabiltzailea",
+    "For extra security, verify this user by checking a one-time code on both of your devices.": "Segurtasun gehiagorako, egiaztatu erabiltzaile hau aldi-bakarrerako kode bat bi gailuetan egiaztatuz.",
+    "For maximum security, do this in person.": "Segurtasun gorenerako, egin hau aurrez aurre.",
+    "Start Verification": "Hasi egiaztaketa",
+    "If you can't find someone, ask them for their username, share your username (%(userId)s) or <a>profile link</a>.": "Ez baduzu baten bat aurkitzen, eskatu bere erabiltzaile-izena, partekatu zurea (%(userId)s) edo partekatu <a>profilaren esteka</a>.",
+    "Enter your account password to confirm the upgrade:": "Sartu zure kontuaren pasa-hitza eguneraketa baieztatzeko:",
+    "You'll need to authenticate with the server to confirm the upgrade.": "Zerbitzariarekin autentifikatu beharko duzu eguneraketa baieztatzeko.",
+    "Upgrade this device to allow it to verify other devices, granting them access to encrypted messages and marking them as trusted for other users.": "Eguneratu gailu hau honek beste gailuak egiaztatu ahal izateko, horrela zifratutako mezuetara sarbidea emanez eta beste erabiltzaileentzat fidagarri gisa markatzeko.",
+    "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.": "Ezarri zifratzea gailu honetan honek beste gailuak egiaztatu ahal izateko, horrela zifratutako mezuetara sarbidea emanez eta beste erabiltzaileentzat fidagarri gisa markatzeko.",
+    "Secure your encryption keys with a passphrase. For maximum security this should be different to your account password:": "Babestu zure zifratze gakoak pasa-esaldi batekin. Segurtasun gorenerako hau eta zure kontuaren pasahitza desberdinak izan beharko lukete:",
+    "Enter a passphrase": "Sartu pasa-esaldia",
+    "Enter your passphrase a second time to confirm it.": "Sartu zure pasa-esaldia berriro hau baieztatzeko.",
+    "This device can now verify other devices, granting them access to encrypted messages and marking them as trusted for other users.": "Gailu honek beste gailuak egiaztatu ditzake, horrela zifratutako mezuetara sarbidea emanez eta beste erabiltzaileentzat fidagarri gisa markatuz.",
+    "Verify other users in their profile.": "Egiaztatu beste erabiltzaileak bere profiletan.",
+    "Upgrade your encryption": "Eguneratu zure zifratzea",
+    "Set up encryption": "Ezarri zifratzea",
+    "Encryption upgraded": "Zifratzea eguneratuta",
+    "Encryption setup complete": "Zifratzearen ezarpena egina"
 }
diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json
index d1c4f7379c..1ddcc55544 100644
--- a/src/i18n/strings/fr.json
+++ b/src/i18n/strings/fr.json
@@ -2075,5 +2075,41 @@
     "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.": "Le coffre secret sera configuré en utilisant les détails existants de votre sauvegarde de clés. Votre phrase de passe et votre clé de récupération seront les mêmes que celles de votre sauvegarde de clés.",
     "New Session": "Nouvelle session",
     "Other users may not trust it": "D’autres utilisateurs pourraient ne pas lui faire confiance",
-    "Later": "Plus tard"
+    "Later": "Plus tard",
+    "Verify User": "Vérifier l’utilisateur",
+    "For extra security, verify this user by checking a one-time code on both of your devices.": "Pour une meilleure sécurité, vérifiez cet utilisateur en comparant un code à usage unique sur vos deux appareils.",
+    "For maximum security, do this in person.": "Pour une sécurité maximale, faites-le en personne.",
+    "Start Verification": "Commencer la vérification",
+    "Encrypted by a deleted device": "Chiffré par un appareil supprimé",
+    "Unknown Command": "Commande inconnue",
+    "Unrecognised command: %(commandText)s": "Commande non reconnue : %(commandText)s",
+    "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "Vous pouvez utiliser <code>/help</code> pour obtenir la liste des commandes disponibles. Vouliez-vous envoyer un message ?",
+    "Hint: Begin your message with <code>//</code> to start it with a slash.": "Astuce : Votre message doit démarrer par <code>//</code> pour commencer par une barre oblique.",
+    "Send as message": "Envoyer comme message",
+    "%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room|other": "%(senderName)s a ajouté %(addedAddresses)s et %(count)s autres adresses à ce salon",
+    "%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room|other": "%(senderName)s a supprimé %(removedAddresses)s et %(count)s autres adresses de ce salon",
+    "%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room": "%(senderName)s a supprimé %(countRemoved)s et ajouté %(countAdded)s adresses à ce salon",
+    "Reject & Ignore user": "Rejeter et ignorer l’utilisateur",
+    "Enter your account password to confirm the upgrade:": "Saisissez le mot de passe de votre compte pour confirmer la mise à niveau :",
+    "You'll need to authenticate with the server to confirm the upgrade.": "Vous devrez vous identifier avec le serveur pour confirmer la mise à niveau.",
+    "Upgrade this device to allow it to verify other devices, granting them access to encrypted messages and marking them as trusted for other users.": "Mettez à niveau cet appareil pour lui permettre de vérifier d’autres appareils, qui pourront alors accéder aux messages chiffrés et seront vus comme fiables par les autres utilisateurs.",
+    "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.": "Configurez le chiffrement sur cet appareil pour lui permettre de vérifier d’autres appareils, qui pourront alors accéder aux messages chiffrés et seront vus comme fiables par les autres utilisateurs.",
+    "Secure your encryption keys with a passphrase. For maximum security this should be different to your account password:": "Sécurisez vos clés de chiffrement avec une phrase de passe. Pour une sécurité maximale, elle devrait être différente du mot de passe de votre compte :",
+    "Enter a passphrase": "Saisissez une phrase de passe",
+    "Enter your passphrase a second time to confirm it.": "Saisissez votre phrase de passe une seconde fois pour la confirmer.",
+    "This device can now verify other devices, granting them access to encrypted messages and marking them as trusted for other users.": "Cet appareil peut à présent vérifier d’autres appareils, qui pourront alors accéder aux messages chiffrés et seront vus comme fiables par les autres utilisateurs.",
+    "Verify other users in their profile.": "Vérifiez d’autres utilisateurs dans leur profil.",
+    "Upgrade your encryption": "Mettre à niveau votre chiffrement",
+    "Set up encryption": "Configurer le chiffrement",
+    "Encryption upgraded": "Chiffrement mis à niveau",
+    "Encryption setup complete": "Configuration du chiffrement terminé",
+    "%(senderName)s turned on end-to-end encryption.": "%(senderName)s a activé le chiffrement de bout en bout.",
+    "%(senderName)s turned on end-to-end encryption (unrecognised algorithm %(algorithm)s).": "%(senderName)s a activé le chiffrement de bout en bout (algorithme %(algorithm)s non reconnu).",
+    "Someone is using an unknown device": "Quelqu'un utilise un appareil inconnu",
+    "This room is end-to-end encrypted": "Ce salon est chiffré de bout en bout",
+    "Everyone in this room is verified": "Tout le monde dans ce salon est vérifié",
+    "Invite only": "Uniquement sur invitation",
+    "Send a reply…": "Envoyer une réponse…",
+    "Send a message…": "Envoyer un message…",
+    "If you can't find someone, ask them for their username, share your username (%(userId)s) or <a>profile link</a>.": "Si vous n’arrivez pas à trouver quelqu’un, demandez-lui son nom d’utilisateur, partagez votre nom d’utilisateur (%(userId)s) ou votre <a>lien de profil</a>."
 }
diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json
index e5b9f637f1..d2b877a8de 100644
--- a/src/i18n/strings/hu.json
+++ b/src/i18n/strings/hu.json
@@ -138,7 +138,7 @@
     "Failed to toggle moderator status": "Moderátor státuszt nem sikerült átállítani",
     "Failed to unban": "Kizárás visszavonása sikertelen",
     "Failed to upload profile picture!": "Profil kép feltöltése sikertelen!",
-    "Failed to verify email address: make sure you clicked the link in the email": "E-mail cím ellenőrzése sikertelen: ellenőrizd, hogy az e-mailnél lévő linkre rákattintottál",
+    "Failed to verify email address: make sure you clicked the link in the email": "E-mail cím ellenőrzése sikertelen: ellenőrizd, hogy az e-mailben lévő hivatkozásra kattintottál",
     "Failure to create room": "Szoba létrehozása sikertelen",
     "Favourites": "Kedvencek",
     "Fill screen": "Képernyő kitöltése",
@@ -355,7 +355,7 @@
     "You need to be able to invite users to do that.": "Hogy ezt csinálhasd meg kell tudnod hívni felhasználókat.",
     "You need to be logged in.": "Be kell jelentkezz.",
     "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "Ez az e-mail cím, úgy néz ki, nincs összekötve a Matrix azonosítóval ezen a Matrix szerveren.",
-    "Your password was successfully changed. You will not receive push notifications on other devices until you log back in to them": "A jelszavadat sikeresen megváltoztattuk. Nem kapsz \"push\" értesítéseket amíg a többi eszközön vissza nem jelentkezel",
+    "Your password was successfully changed. You will not receive push notifications on other devices until you log back in to them": "A jelszavadat sikeresen megváltoztattuk. Addig nem fogsz leküldéses értesítéseket kapni, amíg a többi eszközön vissza nem jelentkezel",
     "You seem to be in a call, are you sure you want to quit?": "Úgy tűnik hívásban vagy, biztosan kilépsz?",
     "You seem to be uploading files, are you sure you want to quit?": "Úgy tűnik fájlokat töltesz fel, biztosan kilépsz?",
     "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.": "Nem leszel képes visszavonni ezt a változtatást mivel a felhasználót ugyanarra a szintre emeled amin te vagy.",
@@ -451,7 +451,7 @@
     "Username available": "Szabad felhasználói név",
     "Username not available": "A felhasználói név foglalt",
     "Something went wrong!": "Valami tönkrement!",
-    "If you already have a Matrix account you can <a>log in</a> instead.": "Ha már van Matrix fiókod akkor <a>beléphetsz</a> helyette.",
+    "If you already have a Matrix account you can <a>log in</a> instead.": "Ha már van Matrix fiókod, akkor <a>beléphetsz</a> helyette.",
     "Your browser does not support the required cryptography extensions": "A böngésződ nem támogatja a szükséges titkosítási kiterjesztést",
     "Not a valid Riot keyfile": "Nem érvényes Riot kulcsfájl",
     "Authentication check failed: incorrect password?": "Azonosítás sikertelen: hibás jelszó?",
@@ -929,7 +929,7 @@
     "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "A %(homeserverDomain)s szerver használatának folytatásához el kell olvasnod és el kell fogadnod az általános szerződési feltételeket.",
     "Review terms and conditions": "Általános Szerződési Feltételek elolvasása",
     "To continue, please enter your password:": "Folytatáshoz add meg a jelszavad:",
-    "This will make your account permanently unusable. You will not be able to log in, and no one will be able to re-register the same user ID. This will cause your account to leave all rooms it is participating in, and it will remove your account details from your identity server. <b>This action is irreversible.</b>": "Ez végleg használhatatlanná teszi a fiókodat. Ezután nem fogsz tudni bejelentkezni, és más sem tud majd ezzel az azonosítóval fiókot létrehozni. Minden szobából amibe beléptél ki fogsz lépni, és törölni fogja minden fiók adatod az \"identity\" szerverről. <b>Ez a művelet visszafordíthatatlan.</b>",
+    "This will make your account permanently unusable. You will not be able to log in, and no one will be able to re-register the same user ID. This will cause your account to leave all rooms it is participating in, and it will remove your account details from your identity server. <b>This action is irreversible.</b>": "Ez végleg használhatatlanná teszi a fiókodat. Ezután nem fogsz tudni bejelentkezni, és más sem tud majd ezzel az azonosítóval fiókot létrehozni. Minden szobából amibe beléptél ki fogsz lépni, és törölni fogja minden fiók adatod az személyazonosságod biztosító szerverről. <b>Ez a művelet visszafordíthatatlan.</b>",
     "Deactivating your account <b>does not by default cause us to forget messages you have sent.</b> If you would like us to forget your messages, please tick the box below.": "A fiókod felfüggesztése <b>nem jelenti alapértelmezetten azt, hogy az általad küldött üzenetek elfelejtődnek.</b> Ha törölni szeretnéd az általad küldött üzeneteket, pipáld be a jelölőnégyzetet alul.",
     "Message visibility in Matrix is similar to email. Our forgetting your messages means that messages you have sent will not be shared with any new or unregistered users, but registered users who already have access to these messages will still have access to their copy.": "Az üzenetek láthatósága a Matrix-ban hasonlít az emailhez. Az általad küldött üzenet törlése azt jelenti, hogy nem osztjuk meg új-, vagy vendég felhasználóval de a már regisztrált felhasználók akik már hozzáfértek az üzenethez továbbra is elérik a saját másolatukat.",
     "Please forget all messages I have sent when my account is deactivated (<b>Warning:</b> this will cause future users to see an incomplete view of conversations)": "Kérlek töröld az összes általam küldött üzenetet amikor a fiókomat felfüggesztem (<b>Figyelem:</b> ez azt eredményezheti, hogy a jövőbeni felhasználók csak részleges beszélgetést látnak majd)",
@@ -981,7 +981,7 @@
     "Please <a>contact your service administrator</a> to get this limit increased.": "A korlát emelése érdekében kérlek <a>vedd fel a kapcsolatot a szolgáltatás adminisztrátorával</a>.",
     "This homeserver has hit its Monthly Active User limit so <b>some users will not be able to log in</b>.": "Ez a Matrix szerver elérte a havi aktív felhasználói korlátját <b>néhány felhasználó nem fog tudni bejelentkezni</b>.",
     "This homeserver has exceeded one of its resource limits so <b>some users will not be able to log in</b>.": "Ez a Matrix szerver túllépte valamelyik erőforrás korlátját így <b>néhány felhasználó nem tud majd bejelentkezni</b>.",
-    "Upgrade Room Version": "Szoba verziójának frissítése",
+    "Upgrade Room Version": "Szoba verziójának fejlesztése",
     "Create a new room with the same name, description and avatar": "Készíts egy új szobát ugyanazzal a névvel, leírással és profilképpel",
     "Update any local room aliases to point to the new room": "Állíts át minden helyi alternatív nevet erre a szobára",
     "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "A felhasználóknak tiltsd meg, hogy a régi szobában beszélgessenek. Küldj egy üzenetet amiben megkéred a felhasználókat, hogy menjenek át az új szobába",
@@ -996,9 +996,9 @@
     "The conversation continues here.": "A beszélgetés itt folytatódik.",
     "This room is a continuation of another conversation.": "Ebben a szobában folytatódik egy másik beszélgetés.",
     "Click here to see older messages.": "Ide kattintva megnézheted a régi üzeneteket.",
-    "Failed to upgrade room": "A szoba frissítése sikertelen",
-    "The room upgrade could not be completed": "A szoba frissítését nem sikerült befejezni",
-    "Upgrade this room to version %(version)s": "A szoba frissítése %(version)s verzióra",
+    "Failed to upgrade room": "A szoba fejlesztése sikertelen",
+    "The room upgrade could not be completed": "A szoba fejlesztését nem sikerült befejezni",
+    "Upgrade this room to version %(version)s": "A szoba fejlesztése %(version)s verzióra",
     "Forces the current outbound group session in an encrypted room to be discarded": "A jelenlegi csoport munkamenet törlését kikényszeríti a titkosított szobában",
     "Registration Required": "Regisztrációt igényel",
     "You need to register to do this. Would you like to register now?": "Hogy ezt megtedd regisztrálnod kell. Szeretnél regisztrálni?",
@@ -1053,7 +1053,7 @@
     "<b>Print it</b> and store it somewhere safe": "<b>Nyomtad ki</b> és tárold biztonságos helyen",
     "<b>Save it</b> on a USB key or backup drive": "<b>Mentsd el</b> egy Pendrive-ra vagy a biztonsági mentésekhez",
     "<b>Copy it</b> to your personal cloud storage": "<b>Másold fel</b> a személyes felhődbe",
-    "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.": "A Biztonságos Üzenet Visszaállítás beállítása nélkül ha kijelentkezel vagy másik eszközt használsz, akkor nem tudod visszaállítani a régi titkosított üzeneteidet.",
+    "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.": "A Biztonságos üzenet-visszaállítás beállítása nélkül ha kijelentkezel vagy másik eszközt használsz, akkor nem tudod visszaállítani a régi titkosított üzeneteidet.",
     "Set up Secure Message Recovery": "Biztonságos Üzenet Visszaállítás beállítása",
     "Keep it safe": "Tartsd biztonságban",
     "Create Key Backup": "Kulcs mentés készítése",
@@ -1140,9 +1140,9 @@
     "Invite anyway and never warn me again": "Mindenképpen meghív és ne figyelmeztess többet",
     "Invite anyway": "Mindenképpen meghív",
     "Whether or not you're logged in (we don't record your username)": "Se akkor ha bejelentkezel se akkor ha nem; mi nem tároljuk a felhasználói nevedet",
-    "Upgrades a room to a new version": "Szoba frissítése új verzióra",
+    "Upgrades a room to a new version": "Szoba fejlesztése új verzióra",
     "Sets the room name": "Szobanév beállítása",
-    "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s frissítette a szobát.",
+    "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s fejlesztette a szobát.",
     "%(displayName)s is typing …": "%(displayName)s gépel …",
     "%(names)s and %(count)s others are typing …|other": "%(names)s és %(count)s másik gépelnek …",
     "%(names)s and %(count)s others are typing …|one": "%(names)s és még valaki gépelnek …",
@@ -1156,7 +1156,7 @@
     "Show avatars in user and room mentions": "Profilkép mutatása a felhasználó és szoba említéseknél",
     "Enable big emoji in chat": "Nagy Emojik engedélyezése a csevegésekben",
     "Send typing notifications": "Gépelés visszajelzés küldése",
-    "Enable Community Filter Panel": "Közösségi szűrő panel engedélyezése",
+    "Enable Community Filter Panel": "Közösségi szűrő panel bekapcsolása",
     "Messages containing my username": "Üzenetek amik a nevemet tartalmazzák",
     "The other party cancelled the verification.": "A másik fél törölte az ellenőrzést.",
     "Verified!": "Ellenőrizve!",
@@ -1460,9 +1460,9 @@
     "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s visszavonta %(targetDisplayName)s a szobába való belépéséhez szükséges meghívóját.",
     "Enable desktop notifications for this device": "Asztali értesítések engedélyezése ehhez az eszközhöz",
     "Enable audible notifications for this device": "Hallható értesítések engedélyezése ehhez az eszközhöz",
-    "Upgrade this room to the recommended room version": "A szoba frissítése a javasolt verzióra",
+    "Upgrade this room to the recommended room version": "A szoba fejlesztése a javasolt verzióra",
     "This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.": "A szoba verziója:  <roomVersion />, amit a Matrix szerver <i>instabilnak</i> tekint.",
-    "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "A szoba frissítése bezárja ezt a szobát és új, frissített verzióval ugyanezen a néven létrehoz egy újat.",
+    "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "A szoba fejlesztése bezárja ezt a szobát és új, frissített verzióval ugyanezen a néven létrehoz egy újat.",
     "Failed to revoke invite": "A meghívó visszavonása sikertelen",
     "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.": "A meghívót nem lehet visszavonni. Vagy a szervernek átmenetileg problémái vannak vagy nincs megfelelő jogosultságod a meghívó visszavonásához.",
     "Revoke invite": "Meghívó visszavonása",
@@ -1507,7 +1507,7 @@
     "Sends the given message coloured as a rainbow": "A megadott üzenetet szivárvány színben küldi el",
     "Sends the given emote coloured as a rainbow": "A megadott hangulatjelet szivárvány színben küldi el",
     "The user's homeserver does not support the version of the room.": "A felhasználó matrix szervere nem támogatja a megadott szoba verziót.",
-    "When rooms are upgraded": "Ha a szobák frissültek",
+    "When rooms are upgraded": "Ha a szobák fejlesztésre kerülnek",
     "This device is <b>not backing up your keys</b>, but you do have an existing backup you can restore from and add to going forward.": "Ez az eszköz <b>nem menti el a kulcsaidat</b>, de létezik mentés amit visszaállíthatsz és folytathatod.",
     "Connect this device to key backup before signing out to avoid losing any keys that may only be on this device.": "Csatlakozz ezzel az eszközzel a kulcs mentéshez kilépés előtt, hogy ne veszíts el kulcsot ami esetleg csak ezen az eszközön van meg.",
     "Connect this device to Key Backup": "Csatlakozz ezzel az eszközzel a Kulcs Mentéshez",
@@ -1538,7 +1538,7 @@
     "This room doesn't exist. Are you sure you're at the right place?": "Ez a szoba nem létezik. Biztos, hogy jó helyen vagy?",
     "Try again later, or ask a room admin to check if you have access.": "Próbálkozz később vagy kérd meg a szoba adminisztrátorát, hogy nézze meg van-e hozzáférésed.",
     "%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please <issueLink>submit a bug report</issueLink>.": "Amikor a szobát próbáltuk elérni ezt a hibaüzenetet kaptuk: %(errcode)s. Ha úgy gondolod, hogy ez egy hiba légy szíves<issueLink>nyiss egy hibajegyet</issueLink>.",
-    "This room has already been upgraded.": "Ez a szoba már frissült.",
+    "This room has already been upgraded.": "Ez a szoba már fejlesztve van.",
     "Rotate Left": "Balra forgat",
     "Rotate Right": "Jobbra forgat",
     "View Servers in Room": "Szerverek megjelenítése a szobában",
@@ -1690,7 +1690,7 @@
     "If you don't want to use <server /> to discover and be discoverable by existing contacts you know, enter another identity server below.": "Ha felkutatásra és, hogy más ismerősök megtalálhassanak, nem akarod használni ezt a szervert: <server />, akkor adjál meg másik azonosítási szervert alább.",
     "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Azonosítási szerver használata nem kötelező. Ha úgy döntesz, hogy az azonosítási szervert nem használod más felhasználók nem találnak rád és másokat sem tudsz e-mail cím vagy telefonszám alapján meghívni.",
     "Do not use an identity server": "Az azonosítási szerver mellőzése",
-    "Upgrade the room": "Szoba frissítése",
+    "Upgrade the room": "Szoba fejlesztése",
     "Set an email for account recovery. Use email or phone to optionally be discoverable by existing contacts.": "E-mail cím beállítása a fiók visszaállításához. E-mail cím vagy telefonszám, hogy ismerősök megtalálhassanak.",
     "Set an email for account recovery. Use email to optionally be discoverable by existing contacts.": "E-mail cím beállítása a fiók visszaállításához. E-mail cím, hogy ismerősök megtalálhassanak.",
     "Enter your custom homeserver URL <a>What does this mean?</a>": "Add meg a matrix szervered URL-jét <a>Mit jelent ez?</a>",
@@ -1930,12 +1930,12 @@
     "This message cannot be decrypted": "Ezt az üzenetet nem lehet visszafejteni",
     "Unencrypted": "Titkosítatlan",
     "Automatically invite users": "Felhasználók automatikus meghívása",
-    "Upgrade private room": "Privát szoba frissítése",
-    "Upgrade public room": "Nyilvános szoba frissítése",
+    "Upgrade private room": "Privát szoba fejlesztése",
+    "Upgrade public room": "Nyilvános szoba fejlesztése",
     "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "A szoba frissítése nem egyszerű művelet, általában a szoba hibás működése, hiányzó funkció vagy biztonsági sérülékenység esetén javasolt.",
     "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>.": "Ez általában a szoba szerver oldali kezelésében jelent változást. Ha a Riotban van problémád, kérlek <a>küldj egy hibajelentést</a>.",
-    "You'll upgrade this room from <oldVersion /> to <newVersion />.": "<oldVersion /> verzióról <newVersion /> verzióra frissíted a szobát.",
-    "Upgrade": "Frissítés",
+    "You'll upgrade this room from <oldVersion /> to <newVersion />.": "<oldVersion /> verzióról <newVersion /> verzióra fejleszted a szobát.",
+    "Upgrade": "Fejlesztés",
     "Notification settings": "Értesítések beállítása",
     "User Status": "Felhasználó állapota",
     "Reactions": "Reakciók",
@@ -2076,5 +2076,35 @@
     "Something went wrong trying to invite the users.": "Valami nem sikerült a felhasználók meghívásával.",
     "We couldn't invite those users. Please check the users you want to invite and try again.": "Ezeket a felhasználókat nem tudtuk meghívni. Ellenőrizd azokat a felhasználókat akiket meg szeretnél hívni és próbáld újra.",
     "Recently Direct Messaged": "Nemrég küldött Közvetlen Üzenetek",
-    "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.": "Ha nem találsz valakit, akkor kérdezd meg a felhasználói nevét (pl.: @felhasználó:szerver.com) vagy <a>oszd meg ezt a szobát</a>."
+    "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.": "Ha nem találsz valakit, akkor kérdezd meg a felhasználói nevét (pl.: @felhasználó:szerver.com) vagy <a>oszd meg ezt a szobát</a>.",
+    "Verify User": "Felhasználó ellenőrzése",
+    "For extra security, verify this user by checking a one-time code on both of your devices.": "A biztonság fokozásáért ellenőrizd ezt a felhasználót egy egyszeri kód egyeztetésével mindkettőtök készülékén.",
+    "For maximum security, do this in person.": "A legnagyobb biztonság érdekében ezt személyesen tedd meg.",
+    "Start Verification": "Ellenőrzés elindítása",
+    "Encrypted by a deleted device": "Egy már törölt eszköz titkosította",
+    "Unknown Command": "Ismeretlen Parancs",
+    "Unrecognised command: %(commandText)s": "Ismeretlen parancs: %(commandText)s",
+    "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "Használhatod a <code>/help</code>-et az elérhető parancsok kilistázásához. Ezt üzenetként akartad küldeni?",
+    "Hint: Begin your message with <code>//</code> to start it with a slash.": "Tipp: Ez üzenetedet kezd ezzel: <code>//</code>, ha perjellel szeretnéd kezdeni.",
+    "Send as message": "Üzenet küldése",
+    "%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room|other": "%(senderName)s szoba címnek, %(count)s másikkal együtt, hozzáadta: %(addedAddresses)s",
+    "%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room|other": "%(senderName)s %(count)s másikkal együtt törölte a szoba címek közül: %(removedAddresses)s",
+    "%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room": "%(senderName)s %(countRemoved)s darabot törölt és %(countAdded)s darabot hozzáadott a szoba címekhez",
+    "Someone is using an unknown device": "Valaki ismeretlen eszközt használ",
+    "This room is end-to-end encrypted": "Ez a szoba végpontok közötti titkosítást használ",
+    "Everyone in this room is verified": "A szobába mindenki ellenőrizve van",
+    "Invite only": "Csak meghívóval",
+    "Send a reply…": "Válasz küldése…",
+    "Send a message…": "Üzenet küldése…",
+    "Reject & Ignore user": "Felhasználó elutasítása és figyelmen kívül hagyása",
+    "If you can't find someone, ask them for their username, share your username (%(userId)s) or <a>profile link</a>.": "Ha nem találsz valakit, akkor kérdezd meg a felhasználói nevét, áruld el neki a felhasználói nevedet (%(userId)s) vagy a <a>profil hivatkozásodat</a>.",
+    "Enter your account password to confirm the upgrade:": "A fejlesztés megerősítéséhez add meg a fiók jelszavadat:",
+    "You'll need to authenticate with the server to confirm the upgrade.": "Azonosítanod kell magad a szerveren a fejlesztés megerősítéséhez.",
+    "Enter a passphrase": "Jelmondat bevitele",
+    "Enter your passphrase a second time to confirm it.": "Add meg a jelmondatot másodszor is a biztonság kedvéért.",
+    "Verify other users in their profile.": "Más felhasználók ellenőrzése a profiljukban.",
+    "Upgrade your encryption": "Titkosításod fejlesztése",
+    "Set up encryption": "Titkosítás beállítása",
+    "Encryption upgraded": "Titkosítás fejlesztve",
+    "Encryption setup complete": "Titkosítás beállítása kész"
 }
diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json
index bb35bd6d69..c8844fb2bb 100644
--- a/src/i18n/strings/it.json
+++ b/src/i18n/strings/it.json
@@ -2055,5 +2055,57 @@
     "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.": "Il Backup Chiavi è attivo sul tuo account ma non è stato impostato da questa sessione. Per impostare un archivio segreto, ripristina il tuo backup chiavi.",
     "Restore": "Ripristina",
     "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": "L'archivio segreto verrà impostato usando i dettagli esistenti del backup chiavi. La password dell'archivio segreto e la chiave di recupero saranno le stesse del backup chiavi",
-    "Restore your Key Backup": "Ripristina il tuo Backup Chiavi"
+    "Restore your Key Backup": "Ripristina il tuo Backup Chiavi",
+    "New Session": "Nuova sessione",
+    "New invite dialog": "Nuova finestra di invito",
+    "Other users may not trust it": "Altri utenti potrebbero non fidarsi",
+    "Later": "Più tardi",
+    "Failed to invite the following users to chat: %(csvUsers)s": "Impossibile invitare i seguenti utenti alla chat: %(csvUsers)s",
+    "We couldn't create your DM. Please check the users you want to invite and try again.": "Impossibile creare il messaggio diretto. Ricontrolla gli utenti che vuoi invitare e riprova.",
+    "Something went wrong trying to invite the users.": "Qualcosa è andato storto provando ad invitare gli utenti.",
+    "We couldn't invite those users. Please check the users you want to invite and try again.": "Impossibile invitare quegli utenti. Ricontrolla gli utenti che vuoi invitare e riprova.",
+    "Recently Direct Messaged": "Contattati direttamente di recente",
+    "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.": "Se non riesci a trovare qualcuno, chiedi il nome utente (es. @utente:server.it) o <a>condividi questa stanza</a>.",
+    "Complete security": "Sicurezza completa",
+    "Verify this session to grant it access to encrypted messages.": "Verifica questa sessione per concederle accesso ai messaggi cifrati.",
+    "Start": "Inizia",
+    "Session verified": "Sessione verificata",
+    "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "La tua sessione ora è verificata. Ha accesso ai tuoi messaggi cifrati e gli altri utenti la vedranno come fidata.",
+    "Done": "Fatto",
+    "Without completing security on this device, it won’t have access to encrypted messages.": "Se non completi la sicurezza su questo dispositivo, esso non avrà accesso ai messaggi cifrati.",
+    "Go Back": "Torna",
+    "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.": "L'archivio segreto verrà impostato usando i dettagli del backup chiavi esistente. La password dell'archivio segreto e la chiave di ripristino saranno le stesse del backup chiavi.",
+    "Encrypted by a deleted device": "Cifrato da un dispositivo eliminato",
+    "Verify User": "Verifica utente",
+    "For extra security, verify this user by checking a one-time code on both of your devices.": "Per maggiore sicurezza, verifica questo utente controllando un codice univoco sui vostri dispositivi.",
+    "For maximum security, do this in person.": "Per massima sicurezza, fatelo di persona.",
+    "Start Verification": "Inizia la verifica",
+    "%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room|other": "%(senderName)s ha aggiunto %(addedAddresses)s e %(count)s altri indirizzi a questa stanza",
+    "%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room|other": "%(senderName)s ha rimosso %(removedAddresses)s e %(count)s altri indirizzi da questa stanza",
+    "%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room": "%(senderName)s ha rimosso %(countRemoved)s e aggiunto %(countAdded)s indirizzi a questa stanza",
+    "Someone is using an unknown device": "Qualcuno sta usando un dispositivo sconosciuto",
+    "This room is end-to-end encrypted": "Questa stanza è cifrata end-to-end",
+    "Everyone in this room is verified": "Tutti in questa stanza sono verificati",
+    "Invite only": "Solo a invito",
+    "Send a reply…": "Invia risposta…",
+    "Send a message…": "Invia un messaggio…",
+    "Reject & Ignore user": "Rifiuta e ignora l'utente",
+    "Unknown Command": "Comando sconosciuto",
+    "Unrecognised command: %(commandText)s": "Comando non riconosciuto: %(commandText)s",
+    "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "Puoi usare <code>/help</code> per elencare i comandi disponibili. Volevo forse inviarlo come messaggio?",
+    "Hint: Begin your message with <code>//</code> to start it with a slash.": "Suggerimento: anteponi al tuo messaggio <code>//</code> per farlo iniziare con uno slash.",
+    "Send as message": "Invia come messaggio",
+    "Enter your account password to confirm the upgrade:": "Inserisci la password del tuo account per confermare l'aggiornamento:",
+    "You'll need to authenticate with the server to confirm the upgrade.": "Dovrai autenticarti con il server per confermare l'aggiornamento.",
+    "Upgrade this device to allow it to verify other devices, granting them access to encrypted messages and marking them as trusted for other users.": "Aggiorna il dispositivo per consentirgli di verificare altri dispositivi, dando loro accesso ai messaggi cifrati e contrassegnandoli come fidati per gli altri utenti.",
+    "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.": "Imposta la cifratura sul dispositivo per consentirgli di verificare altri dispositivi, dando loro accesso ai messaggi cifrati e contrassegnandoli come fidati per gli altri utenti.",
+    "Secure your encryption keys with a passphrase. For maximum security this should be different to your account password:": "Proteggi le chiavi di cifratura con una password. Per massima sicurezza questa dovrebbe essere diversa da quella del tuo account:",
+    "Enter a passphrase": "Inserisci una password",
+    "Enter your passphrase a second time to confirm it.": "Inserisci di nuovo la tua password per confermarla.",
+    "This device can now verify other devices, granting them access to encrypted messages and marking them as trusted for other users.": "Questo dispositivo ora può verificare altri dispositivi, dando loro accesso ai messaggi cifrati e contrassegnandoli come fidati per gli altri utenti.",
+    "Verify other users in their profile.": "Verifica gli altri utenti nel loro profilo.",
+    "Upgrade your encryption": "Aggiorna la tua cifratura",
+    "Set up encryption": "Imposta la cifratura",
+    "Encryption upgraded": "Cifratura aggiornata",
+    "Encryption setup complete": "Impostazione cifratura completata"
 }
diff --git a/src/i18n/strings/ja.json b/src/i18n/strings/ja.json
index 55cc8782ef..787dcbc4ca 100644
--- a/src/i18n/strings/ja.json
+++ b/src/i18n/strings/ja.json
@@ -601,7 +601,7 @@
     "Copied!": "コピーされました!",
     "Failed to copy": "コピーに失敗しました",
     "Add an Integration": "統合を追加する",
-    "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "サードパーティのサイトに移動して、%(integationsUrl)s で使用するためにアカウントを認証できるようになります。続行しますか?",
+    "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "サードパーティのサイトに移動して、%(integrationsUrl)s で使用するためにアカウントを認証できるようになります。続行しますか?",
     "Removed or unknown message type": "削除されたまたは未知のメッセージタイプ",
     "Message removed by %(userId)s": "%(userId)s によってメッセージが削除されました",
     "Message removed": "メッセージが削除された",
@@ -630,7 +630,7 @@
     "Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "%(roomName)s を %(groupId)s から削除してもよろしいですか?",
     "Removing a room from the community will also remove it from the community page.": "コミュニティから部屋を削除すると、コミュニティページからもその部屋が削除されます。",
     "Failed to remove room from community": "コミュニティからの部屋の削除に失敗しました",
-    "Failed to remove '%(roomName)s' from %(groupId)s": "%(groupName)s から '%(roomName)s' を削除できませんでした",
+    "Failed to remove '%(roomName)s' from %(groupId)s": "%(groupId)s から '%(roomName)s' を削除できませんでした",
     "Something went wrong!": "何かが間違っていた!",
     "The visibility of '%(roomName)s' in %(groupId)s could not be updated.": "%(groupId)s の '%(roomName)s' の表示を更新できませんでした。",
     "Visibility in Room List": "ルームリストの可視性",
@@ -668,7 +668,7 @@
     "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)s が参加しました",
     "%(oneUser)sjoined %(count)s times|other": "%(oneUser)s が %(count)s 回参加しました",
     "%(oneUser)sjoined %(count)s times|one": "%(oneUser)s が参加しました",
-    "%(severalUsers)sleft %(count)s times|other": "%(severalUers)s は %(count)s 回退出しました",
+    "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)s は %(count)s 回退出しました",
     "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)s は退出しました",
     "%(oneUser)sleft %(count)s times|other": "%(oneUser)s は %(count)s 回退出しました",
     "%(oneUser)sleft %(count)s times|one": "%(oneUser)s は退出しました",
diff --git a/src/i18n/strings/sq.json b/src/i18n/strings/sq.json
index e833009d59..3c04ff85c0 100644
--- a/src/i18n/strings/sq.json
+++ b/src/i18n/strings/sq.json
@@ -1917,11 +1917,11 @@
     "%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s bëri një thirrje zanore. (e pambuluar nga ky shfletues)",
     "%(senderName)s placed a video call.": "%(senderName)s bëri një thirrje video.",
     "%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s bëri një thirrje video. (e pambuluar nga ky shfletues)",
-    "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)": "Aktivizoni <em>cross-signing</em> për të verifikuar me bazë përdorues në vend se me bazë pajisje (në zhvillim)",
     "Enable local event indexing and E2EE search (requires restart)": "Aktivizoni indeksim aktesh vendore dhe kërkim E2EE (lyp rinisje)",
     "Match system theme": "Përputhe me temën e sistemit",
-    "Send cross-signing keys to homeserver": "",
-    "Cross-signing public keys:": "",
+    "Send cross-signing keys to homeserver": "Dërgo te shërbyesi Home kyçe <em>cross-signing</em>",
+    "Cross-signing public keys:": "Kyçe publikë për <em>cross-signing</em>:",
     "on device": "në pajisje",
     "not found": "s’u gjet",
     "in secret storage": "në depozitë të fshehtë",
@@ -2016,5 +2016,88 @@
     "Connected to <channelIcon /> <channelName /> on <networkIcon /> <networkName />": "Lidhur me <channelIcon /> <channelName /> në <networkIcon /> <networkName />",
     "Connected via %(protocolName)s": "Lidhur përmes %(protocolName)s",
     "Bridge Info": "Të dhëna Ure",
-    "Below is a list of bridges connected to this room.": "Më poshtë keni një listë urash të lidhura në këtë dhomë."
+    "Below is a list of bridges connected to this room.": "Më poshtë keni një listë urash të lidhura në këtë dhomë.",
+    "New Session": "Sesion i Ri",
+    "%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room|other": "%(senderName)s shtoi %(addedAddresses)s dhe dhe %(count)s adresa të tjera te kjo dhomë",
+    "%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room|other": "%(senderName)s hoqi %(removedAddresses)s dhe %(count)s adresa të tjera nga kjo dhomë",
+    "%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room": "%(senderName)s hoqi %(countRemoved)s dhe shtoi %(countAdded)s adresa te kjo dhomë",
+    "%(senderName)s turned on end-to-end encryption.": "%(senderName)s aktivizoi fshehtëzimin skaj-më-skaj.",
+    "%(senderName)s turned on end-to-end encryption (unrecognised algorithm %(algorithm)s).": "%(senderName)s aktivizoi fshehtëzimin skaj-më-skaj (algoritëm jo i pranuar %(algorithm)s).",
+    "a few seconds ago": "pak sekonda më parë",
+    "about a minute ago": "rreth një minutë më parë",
+    "%(num)s minutes ago": "%(num)s minuta më parë",
+    "about an hour ago": "rreth një orë më parë",
+    "%(num)s hours ago": "%(num)s orë më parë",
+    "about a day ago": "rreth një ditë më parë",
+    "%(num)s days ago": "%(num)s ditë më parë",
+    "a few seconds from now": "pak sekonda nga tani",
+    "about a minute from now": "rreth një minutë nga tani",
+    "%(num)s minutes from now": "%(num)s minuta nga tani",
+    "about an hour from now": "rreth një orë nga tani",
+    "%(num)s hours from now": "%(num)s orë nga tani",
+    "about a day from now": "rreth një ditë nga tani",
+    "%(num)s days from now": "%(num)s ditë nga tani",
+    "Show a presence dot next to DMs in the room list": "Shfaqni një pikë pranie në krah DM-sh te lista e dhomave",
+    "Other users may not trust it": "Përdorues të tjerë mund të mos e besojnë",
+    "Later": "Më vonë",
+    "Cross-signing and secret storage are enabled.": "<em>Cross-signing</em> dhe depozitimi i fshehtë janë aktivizuar.",
+    "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this device.": "Llogaria juaj ka një identitet <em>cross-signing</em> në depozitë të fshehtë, por s’është ende i besuar në këtë pajisje.",
+    "Cross-signing and secret storage are not yet set up.": "<em>Cross-signing</em> dhe depozitimi i fshehtë s’janë ujdisur ende.",
+    "Cross-signing private keys:": "Kyçe privatë për <em>cross-signing</em>:",
+    "Backup key stored in secret storage, but this feature is not enabled on this device. Please enable cross-signing in Labs to modify key backup state.": "Kyçi i kopjeruajtjeve u depozitua në depozitë të fshehtë, po kjo veçori s’është e aktivizuar në këtë pajisje. Ju lutemi, aktivizoni në Labs <em>cross-signing</em> që të modifikoni gjendje kopjeruatjeje kyçesh.",
+    "Labs": "Labs",
+    "Complete": "E plotë",
+    "Someone is using an unknown device": "Dikush po përdor një pajisje të panjohur",
+    "This room is end-to-end encrypted": "Kjo dhomë është e fshehtëzuar skaj-më-skaj",
+    "Everyone in this room is verified": "Gjithkush në këtë dhomë është verifikuar",
+    "Encrypted by a deleted device": "Fshehtëzuar nga një pajisje e fshirë",
+    "Invite only": "Vetëm me ftesa",
+    "Send a reply…": "Dërgoni një përgjigje…",
+    "Send a message…": "Dërgoni një mesazh…",
+    "Reject & Ignore user": "Hidhe poshtë & Shpërfille përdoruesin",
+    "Unknown Command": "Urdhër i Panjohur",
+    "Unrecognised command: %(commandText)s": "Urdhër Jo i Pranuar: %(commandText)s",
+    "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "Mund të përdorni <code>/help</code> që të shfaqen urdhrat e gatshëm. Donit vërtet ta dërgoni këtë si një mesazh?",
+    "Hint: Begin your message with <code>//</code> to start it with a slash.": "Ndihmëz: Fillojeni mesazhin tuaj me <code>//</code> që të nisë me një pjerrake.",
+    "Send as message": "Dërgoni një mesazh",
+    "Verify User": "Verifikoni Përdoruesin",
+    "For extra security, verify this user by checking a one-time code on both of your devices.": "Për siguri ekstra, verifikojeni këtë përdorues duke kontrolluar në të dyja pajisjet tuaja një kod njëpërdorimsh.",
+    "For maximum security, do this in person.": "Për siguri maksimum, bëjeni këtë ju vetë.",
+    "Start Verification": "Fillo Verifikimin",
+    "Failed to invite the following users to chat: %(csvUsers)s": "S’u arrit të ftoheshin për bisedë përdoruesit vijues: %(csvUsers)s",
+    "We couldn't create your DM. Please check the users you want to invite and try again.": "S’e krijuam dot DM-në tuaj. Ju lutemi, kontrolloni përdoruesit që doni të ftoni dhe riprovoni.",
+    "Something went wrong trying to invite the users.": "Diç shkoi ters teksa provohej të ftoheshin përdoruesit.",
+    "We couldn't invite those users. Please check the users you want to invite and try again.": "S’i ftuam dot këta përdorues. Ju lutemi, kontrolloni përdoruesit që doni të ftoni dhe riprovoni.",
+    "Failed to find the following users": "S’u arrit të gjendeshin përdoruesit vijues",
+    "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "Përdoruesit vijues mund të mos ekzistojnë ose janë të pavlefshëm, dhe s’mund të ftohen: %(csvNames)s",
+    "Suggestions": "Sugjerime",
+    "If you can't find someone, ask them for their username, share your username (%(userId)s) or <a>profile link</a>.": "Nëse s’gjeni dot dikë, kërkojini emrin e tij të përdoruesit, tregojuni emrin tuaj të përdoruesit (%(userId)s) ose <a>lidhjen e profilit</a>.",
+    "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.": "Nëse s’gjeni dot dikë, kërkojini emrin e tij të përdoruesit (p.sh., @përdorues:shërbyes.com) ose <a>tregojuni këtë dhomë</a>.",
+    "Access your secure message history and your cross-signing identity for verifying other devices by entering your passphrase.": "Për verifikim pajisjesh të tjera përmes dhënies së frazëkalimit tuaj, hyni te historiku i mesazheve tuaj të sigurt dhe identiteti juaj për <em>cross-signing</em>.",
+    "Access your secure message history and your cross-signing identity for verifying other devices by entering your recovery key.": "Për verifikim pajisjesh të tjera përmes dhënies së kyçit tuaj të rimarrjes, hyni te historiku i mesazheve tuaj të sigurt dhe identiteti juaj për <em>cross-signing</em>.",
+    "Complete security": "Siguri të plotë",
+    "Verify this session to grant it access to encrypted messages.": "Verifikojeni këtë sesion që t’i akordohet hyrje te mesazhe të fshehtëzuar.",
+    "Start": "Nise",
+    "Session verified": "Sesion i verifikuar",
+    "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Sesioni juaj i ri tani është i verifikuar. Ka hyrje te mesazhet tuaj të fshehtëzuar dhe përdoruesit e tjerë do ta shohin si të besuar.",
+    "Done": "U bë",
+    "Without completing security on this device, it won’t have access to encrypted messages.": "Pa plotësuar sigurinë në këtë pajisje, s’do të ketë hyrje te mesazhe të fshehtëzuar.",
+    "Go Back": "Shko Mbrapsht",
+    "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.": "Kopjeruajtja e Kyçeve është e aktivizuar në llogarinë tuaj, por nuk është ujdisur që nga ky sesion. Që të ujdisni depozitim të fshehtë, riktheni kopjeruajtjen tuaj të kyçeve.",
+    "Restore": "Riktheje",
+    "Enter your account password to confirm the upgrade:": "Që të ripohohet përmirësimi, jepni fjalëkalimin e llogarisë tuaj:",
+    "You'll need to authenticate with the server to confirm the upgrade.": "Do t’ju duhet të bëni mirëfilltësimin me shërbyesin që të ripohohet përmirësimi.",
+    "Upgrade this device to allow it to verify other devices, granting them access to encrypted messages and marking them as trusted for other users.": "Përmirësojeni këtë pajisje për ta lejuar të verifikojë pajisje të tjera, duke u akorduar hyrje te mesazhe të fshehtëzuar dhe duke u vënë shenjë si të besuara për përdorues të tjerë.",
+    "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.": "Ujdisni fshehtëzim në këtë pajisje që ta lejoni të verifikojë pajisje të tjera, duke u akorduar atyre hyrje te mesazhe të fshehtëzuar dhe duke u vënë shenjë atyre si të besuara për përdorues të tjerë.",
+    "Secure your encryption keys with a passphrase. For maximum security this should be different to your account password:": "Sigurojini kyçet tuaj të fshehtëzimit me një frazëkalim. Për siguri maksimale, ky do të duhej të ishte i ndryshëm nga fjalëkalimi për llogarinë tuaj:",
+    "Enter a passphrase": "Jepni një frazëkalim",
+    "Enter your passphrase a second time to confirm it.": "Që të ripohohet, jepeni edhe një herë frazëkalimin tuaj.",
+    "This device can now verify other devices, granting them access to encrypted messages and marking them as trusted for other users.": "Kjo pajisje mund të verifikojë pajisje të tjera, duke u akorduar hyrje te mesazhe të fshehtëzuar dhe duke u vënë shenjë si të besuara për përdorues të tjerë.",
+    "Verify other users in their profile.": "Verifikoni përdorues të tjerë në profilin e tyre.",
+    "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.": "Pa ujdisur depozitim të fshehtë, s’do të jeni në gjendje të rimerrni hyrje te mesazhe të fshehtëzuar apo te identiteti juaj <em>cross-signing</em> për verifikim pajisjesh të tjera, nëse dilni nga llogaria juaj ose përdorni një pajisje tjetër.",
+    "Restore your Key Backup": "Riktheni Kopjeruajtjen tuaj të Kyçeve",
+    "Upgrade your encryption": "Përmirësoni fshehtëzimin tuaj",
+    "Set up encryption": "Ujdisni fshehtëzim",
+    "Encryption upgraded": "U përmirësua fshehtëzimi",
+    "Encryption setup complete": "Ujdisje fshehtëzimit e plotësuar"
 }
diff --git a/src/i18n/strings/tr.json b/src/i18n/strings/tr.json
index 81f0522922..257cb27b42 100644
--- a/src/i18n/strings/tr.json
+++ b/src/i18n/strings/tr.json
@@ -1422,5 +1422,121 @@
     "Enable Community Filter Panel": "Toluluk Filtre Panelini Aç",
     "Match system theme": "Sistem temasıyla eşle",
     "Allow Peer-to-Peer for 1:1 calls": "1:1 çağrılar için eşten-eşe izin ver",
-    "Missing media permissions, click the button below to request.": "Medya izinleri eksik, alttaki butona tıkayarak talep edin."
+    "Missing media permissions, click the button below to request.": "Medya izinleri eksik, alttaki butona tıkayarak talep edin.",
+    "Credits": "Katkıda Bulunanlar",
+    "Clear cache and reload": "Belleği temizle ve yeniden yükle",
+    "Customise your experience with experimental labs features. <a>Learn more</a>.": "Deneysel laboratuar özellikler ile deneyiminizi özelleştirebilirsiniz. <a>Daha fazla</a>.",
+    "Ignored/Blocked": "Yoksayılan/Bloklanan",
+    "Error adding ignored user/server": "Yoksayılan kullanıcı/sunucu eklenirken hata",
+    "Error subscribing to list": "Listeye abone olunurken hata",
+    "Error removing ignored user/server": "Yoksayılan kullanıcı/sunucu silinirken hata",
+    "Error unsubscribing from list": "Listeden abonelikten çıkılırken hata",
+    "You are not subscribed to any lists": "Herhangi bir listeye aboneliğiniz bulunmuyor",
+    "⚠ These settings are meant for advanced users.": "⚠ Bu ayarlar ileri düzey kullanıcılar içindir.",
+    "Unignore": "Yoksayma",
+    "This bridge was provisioned by <user />": "Bu körpü <user /> tarafından provize edildi",
+    "Connected to <channelIcon /> <channelName /> on <networkIcon /> <networkName />": "<networkIcon /> <networkName /> ağındaki <channelIcon /> <channelName /> kanala bağlandı",
+    "Below is a list of bridges connected to this room.": "Bu odaya bağlanmış köprülerin bir listesi alttadır.",
+    "Change room avatar": "Oda resmini değiştir",
+    "Members only (since the point in time of selecting this option)": "Sadece üyeler ( bu seçeneği seçtiğinizden itibaren)",
+    "Unable to revoke sharing for email address": "E-posta adresi paylaşımı kaldırılamadı",
+    "Unable to revoke sharing for phone number": "Telefon numarası paylaşımı kaldırılamıyor",
+    "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Bu sayfadaki oda, kullanıcı veya grup ID si gibi betimleyici bilgiler sunucuya gönderilmeden önce silindi.",
+    "Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.": "Çağrıların sağlıklı bir şekide yapılabilmesi için lütfen anasunucunuzun (<code>%(homeserverDomain)s</code>) yöneticisinden bir TURN sunucusu yapılandırmasını isteyin.",
+    "%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)s kullanıcıları isimlerini %(count)s kez değiştirdiler",
+    "%(oneUser)schanged their name %(count)s times|other": "%(oneUser)s ismini %(count)s kez değiştirdi",
+    "%(severalUsers)smade no changes %(count)s times|one": "%(severalUsers)s değişiklik yapmadı",
+    "%(oneUser)smade no changes %(count)s times|other": "%(oneUser)s %(count)s kez değişiklik yapmadı",
+    "%(oneUser)smade no changes %(count)s times|one": "%(oneUser)s değişiklik yapmadı",
+    "Room alias": "Oda lakabı",
+    "Please provide a room alias": "Lütfen bir oda lakabı belirtin",
+    "This alias is available to use": "Bu lakap kullanmaya uygun",
+    "This alias is already in use": "Bu lakap zaten kullanımda",
+    "And %(count)s more...|other": "ve %(count)s kez daha...",
+    "Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatif olarak,<code>turn.matrix.org</code> adresindeki herkese açık sunucuyu kullanmayı deneyebilirsiniz. Fakat bu güvenilir olmayabilir. IP adresiniz bu sunucu ile paylaşılacaktır. Ayarlardan yönetebilirsiniz.",
+    "An error ocurred whilst trying to remove the widget from the room": "Görsel bileşen odadan silinmeye çalışılırken bir hata oluştu",
+    "Minimize apps": "Uygulamaları küçült",
+    "Maximize apps": "Uygulamaları büyült",
+    "Popout widget": "Görsel bileşeni göster",
+    "Please <newIssueLink>create a new issue</newIssueLink> on GitHub so that we can investigate this bug.": "Lütfen GitHub’da <newIssueLink>Yeni bir talep</newIssueLink> oluşturun ki bu hatayı inceleyebilelim.",
+    "Rotate counter-clockwise": "Saat yönünün tersine döndür",
+    "Language Dropdown": "Dil Listesi",
+    "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)s, %(count)s kez ayrıldı",
+    "%(oneUser)sleft %(count)s times|other": "%(oneUser)s %(count)s kez ayrıldı",
+    "%(severalUsers)sjoined and left %(count)s times|other": "%(severalUsers)s %(count)s kez katılıp ve ayrıldı",
+    "%(oneUser)sjoined and left %(count)s times|other": "%(oneUser)s %(count)s kez katıldı ve ayrıldı",
+    "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)s ayrıldı ve yeniden katıldı",
+    "%(severalUsers)srejected their invitations %(count)s times|other": "%(severalUsers)s %(count)s kez davetlerini reddetti",
+    "%(severalUsers)srejected their invitations %(count)s times|one": "%(severalUsers)s davetlerini reddetti",
+    "%(oneUser)srejected their invitation %(count)s times|one": "%(oneUser)s davetlerini reddetti",
+    "%(severalUsers)shad their invitations withdrawn %(count)s times|one": "%(severalUsers)s davetlerini geri çekti",
+    "%(oneUser)shad their invitation withdrawn %(count)s times|other": "%(oneUser)s davetini %(count)s kez geri çekti",
+    "%(oneUser)shad their invitation withdrawn %(count)s times|one": "%(oneUser)s davetini geri çekti",
+    "were banned %(count)s times|other": "%(count)s kez yasaklandı",
+    "were banned %(count)s times|one": "yasaklandı",
+    "was banned %(count)s times|other": "%(count)s kez yasaklandı",
+    "was banned %(count)s times|one": "yasaklandı",
+    "were unbanned %(count)s times|other": "%(count)s kez yasak kaldırıldı",
+    "were unbanned %(count)s times|one": "yasak kaldırıldı",
+    "was unbanned %(count)s times|other": "%(count)s kez yasak kaldırıldı",
+    "was unbanned %(count)s times|one": "yasak kaldırıldı",
+    "%(severalUsers)schanged their avatar %(count)s times|one": "%(severalUsers)s resimlerini değiştirdiler",
+    "%(oneUser)schanged their avatar %(count)s times|other": "%(oneUser)s %(count)s kez resmini değiştirdi",
+    "%(oneUser)schanged their avatar %(count)s times|one": "%(oneUser)s resmini değiştirdi",
+    "%(severalUsers)smade no changes %(count)s times|other": "%(severalUsers)s %(count)s kez hiç bir değişiklik yapmadı",
+    "Try using one of the following valid address types: %(validTypesList)s.": "Takip eden geçerli adres tiplerinden birini kullanmayı deneyin: %(validTypesList)s.",
+    "Use an identity server to invite by email. <default>Use the default (%(defaultIdentityServerName)s)</default> or manage in <settings>Settings</settings>.": "E-posta ile davet etmek için bir kimlik sunucusu kullan. <default> Varsayılanı kullan (%(defaultIdentityServerName)s</default> ya da <settings>Ayarlar</settings> kullanarak yönetin.",
+    "Use an identity server to invite by email. Manage in <settings>Settings</settings>.": "E-posta ile davet için bir kimlik sunucu kullan. <settings>Ayarlar</settings> dan yönet.",
+    "The following users may not exist": "Belirtilen kullanıcılar mevcut olmayabilir",
+    "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Altta belirtilen Matrix ID li profiller bulunamıyor - Onları yinede davet etmek ister misiniz?",
+    "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Lütfen neyin yanlış gittiğini bize bildirin ya da en güzeli problemi tanımlayan bir GitHub talebi oluşturun.",
+    "Before submitting logs, you must <a>create a GitHub issue</a> to describe your problem.": "Logları göndermeden önce, probleminizi betimleyen <a>bir GitHub talebi oluşturun</a>.",
+    "Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Topluluk ID leri sadece a-z, 0-9 ya da '=_-./' karakterlerini içerebilir",
+    "Set a room alias to easily share your room with other people.": "Odanızı diğer kişilerle kolayca paylaşabilmek için bir oda lakabı ayarların.",
+    "Create a public room": "Halka açık bir oda oluşturun",
+    "Make this room public": "Bu odayı halka açık yap",
+    "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of Riot to do this": "Sohbet tarihçesini kaybetmemek için, çıkmadan önce odanızın anahtarlarını dışarıya aktarın. Bunu yapabilmek için Riotun daha yeni sürümü gerekli.  Ulaşmak için geri gitmeye ihtiyacınız var",
+    "Continue With Encryption Disabled": "Şifreleme Kapalı Şekilde Devam Et",
+    "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "%(fileName)s dosyası anasunucunun yükleme boyutu limitini aşıyor",
+    "Double check that your server supports the room version chosen and try again.": "Seçtiğiniz oda sürümünün sunucunuz tarafından desteklenip desteklenmediğini iki kez kontrol edin ve yeniden deneyin.",
+    "Changes your avatar in this current room only": "Sadece bu odadaki resminizi değiştirin",
+    "Please supply a https:// or http:// widget URL": "Lütfen bir https:// ya da http:// olarak bir görsel bileşen URL i belirtin",
+    "Sends the given emote coloured as a rainbow": "Verilen ifadeyi bir gökkuşağı gibi renklendirilmiş olarak gönderin",
+    "%(oldDisplayName)s changed their display name to %(displayName)s.": "%(oldDisplayName)s ekran adını %(displayName)s olarak değiştirdi.",
+    "%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s odayı adresi bilen herkesin girebileceği şekilde halka açık hale getirdi.",
+    "%(senderDisplayName)s changed the join rule to %(rule)s": "%(senderDisplayName)s katılma kuralını %(rule)s şeklinde değiştirdi",
+    "%(senderDisplayName)s changed guest access to %(rule)s": "%(senderDisplayName)s misafir erişim kuralını %(rule)s şeklinde değiştirdi",
+    "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s bu odanın ana adresini %(address)s olarak ayarladı.",
+    "%(senderName)s placed a voice call.": "%(senderName)s bir çağrı yaptı.",
+    "%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s bir çağrı başlattı. (Bu tarayıcı tarafından desteklenmiyor)",
+    "%(senderName)s placed a video call.": "%(senderName)s bir görüntülü çağrı yaptı.",
+    "%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s bir görüntülü çağrı yaptı. (bu tarayıcı tarafından desteklenmiyor)",
+    "Ask your Riot admin to check <a>your config</a> for incorrect or duplicate entries.": "Riot yöneticinize <a>yapılandırmanızın</a> hatalı ve mükerrer girdilerini kontrol etmesi için talepte bulunun.",
+    "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Kayıt olabilirsiniz, fakat kimlik sunucunuz çevrimiçi olana kadar bazı özellikler mevcut olmayacak. Bu uyarıyı sürekli görüyorsanız, yapılandırmanızı kontrol edin veya sunucu yöneticinizle iletişime geçin.",
+    "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Parolanızı sıfırlayabilirsiniz, fakat kimlik sunucunuz çevrimiçi olana kadar bazı özellikler mevcut olmayacak. Bu uyarıyı sürekli görüyorsanız, yapılandırmanızı kontrol edin veya sunucu yöneticinizle iletişime geçin.",
+    "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Oturum açabilirsiniz, fakat kimlik sunucunuz çevrimiçi olana kadar bazı özellikler mevcut olmayacak. Bu uyarıyı sürekli görüyorsanız, yapılandırmanızı kontrol edin veya sunucu yöneticinizle iletişime geçin.",
+    "a few seconds from now": "şu andan itibaren bir kaç saniye",
+    "about a minute from now": "şu andan itibaren yaklaşık bir dakika",
+    "%(num)s minutes from now": "şu andan itibaren %(num)s dakika",
+    "about an hour from now": "şu andan itibaren yaklaşık bir saat",
+    "%(num)s hours from now": "şu andan itibaren %(num)s saat",
+    "about a day from now": "şu andan itibaren yaklaşık bir gün",
+    "%(num)s days from now": "şu andan itibaren %(num)s gün",
+    "The user must be unbanned before they can be invited.": "Kullanıcının davet edilebilmesi için öncesinde yasağının kaldırılması gereklidir.",
+    "Repeats like \"abcabcabc\" are only slightly harder to guess than \"abc\"": "“abcabcabc” gibi tekrarlar “abc” yi tahmin etmekten çok az daha zor olur",
+    "Sequences like abc or 6543 are easy to guess": "abc veya 6543 gibi diziler tahmin için oldukça kolaydır",
+    "Common names and surnames are easy to guess": "Yaygın isimleri ve soyisimleri tahmin etmek oldukça kolay",
+    "Enable cross-signing to verify per-user instead of per-device (in development)": "Her cihaz yerine her kullanıcıyı doğrulamak için çarpraz-imzalamayı aç (geliştiriliyor)",
+    "Show info about bridges in room settings": "Oda ayarlarındaki köprülerin bilgilerini göster",
+    "Show a placeholder for removed messages": "Silinen mesajlar için bir yer tutucu göster",
+    "Show display name changes": "Ekran isim değişikliklerini göster",
+    "Enable URL previews for this room (only affects you)": "Bu oda için URL önizlemeyi aç (sadece sizi etkiler)",
+    "Enable URL previews by default for participants in this room": "Bu odadaki katılımcılar için URL önizlemeyi varsayılan olarak açık hale getir",
+    "Enable widget screenshots on supported widgets": "Desteklenen görsel bileşenlerde anlık görüntüleri aç",
+    "Show recently visited rooms above the room list": "En son ziyaret edilen odaları oda listesinin en üstünde göster",
+    "Show hidden events in timeline": "Zaman çizelgesinde gizli olayları göster",
+    "Encrypted messages in one-to-one chats": "Birebir sohbetlerdeki şifrelenmiş mesajlar",
+    "Encrypted messages in group chats": "Grup sohbetlerdeki şifrelenmiş mesajlar",
+    "This is your list of users/servers you have blocked - don't leave the room!": "Bu sizin engellediğiniz kullanıcılar/sunucular listeniz - odadan ayrılmayın!",
+    "Got It": "Anlaşıldı"
 }
diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json
index dbd8319b8a..4a70441317 100644
--- a/src/i18n/strings/zh_Hant.json
+++ b/src/i18n/strings/zh_Hant.json
@@ -2075,5 +2075,41 @@
     "Something went wrong trying to invite the users.": "在嘗試邀請使用者時發生錯誤。",
     "We couldn't invite those users. Please check the users you want to invite and try again.": "我們無法邀請那些使用者。請檢查您想要邀請的使用者並再試一次。",
     "Recently Direct Messaged": "最近傳送過直接訊息",
-    "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.": "如果您找不到某人,請詢問他們的使用者名稱(範例:@user:server.com)或<a>分享此聊天室</a>。"
+    "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.": "如果您找不到某人,請詢問他們的使用者名稱(範例:@user:server.com)或<a>分享此聊天室</a>。",
+    "Verify User": "驗證使用者",
+    "For extra security, verify this user by checking a one-time code on both of your devices.": "為了提高安全性,請透過檢查您兩個裝置上的一次性代碼來驗證此使用者。",
+    "For maximum security, do this in person.": "為了取得最強的安全性,請親自進行。",
+    "Start Verification": "開始驗證",
+    "Encrypted by a deleted device": "被已刪除的裝置加密",
+    "Unknown Command": "未知的指令",
+    "Unrecognised command: %(commandText)s": "無法識別的指令:%(commandText)s",
+    "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "您可以使用 <code>/help</code> 來列出可用的指令。您是要傳送此訊息嗎?",
+    "Hint: Begin your message with <code>//</code> to start it with a slash.": "提示:以 <code>//</code> 開頭讓您的訊息傳送時可以用斜線開頭。",
+    "Send as message": "以訊息傳送",
+    "%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room|other": "%(senderName)s 向此聊天室新增了 %(addedAddresses)s 與其他 %(count)s 個地址",
+    "%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room|other": "%(senderName)s 從此聊天室移除了 %(removedAddresses)s 與其他 %(count)s 個地址",
+    "%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room": "%(senderName)s 對此聊天室移除了 %(countRemoved)s 個並新增了 %(countAdded)s 地址到此聊天室",
+    "%(senderName)s turned on end-to-end encryption.": "%(senderName)s 開啟了端到端加密。",
+    "%(senderName)s turned on end-to-end encryption (unrecognised algorithm %(algorithm)s).": "%(senderName)s 開啟了端到端加密(無法識別的演算法 %(algorithm)s)。",
+    "Someone is using an unknown device": "某人正在使用未知的裝置",
+    "This room is end-to-end encrypted": "此聊天室已端到端加密",
+    "Everyone in this room is verified": "此聊天室中每個人都已驗證",
+    "Invite only": "僅邀請",
+    "Send a reply…": "傳送回覆……",
+    "Send a message…": "傳送訊息……",
+    "Reject & Ignore user": "回絕並忽略使用者",
+    "If you can't find someone, ask them for their username, share your username (%(userId)s) or <a>profile link</a>.": "如果您找不到某人,請詢問他們以取得他們的使用者名稱,分享您的使用者名稱 (%(userId)s) 或<a>簡介連結</a>。",
+    "Enter your account password 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.": "升級此裝置以允許其驗證其他裝置,並允許存取加密訊息以及將它們標記為受其他使用者信任。",
+    "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.": "在此裝置上設定加密以允許其驗證其他裝置,並允許存取加密訊息以及將它們標記為受其他使用者信任。",
+    "Secure your encryption keys with a passphrase. For maximum security this should be different to your account password:": "使用通關密語保護您的加密金鑰。為了取得最強的安全性,此通關密語應與您的帳號密碼不同:",
+    "Enter a passphrase": "輸入通關密語",
+    "Enter your passphrase a second time to confirm it.": "輸入您的通關密語兩次以確認。",
+    "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.": "透過他們的簡介驗證其他使用者。",
+    "Upgrade your encryption": "升級您的加密",
+    "Set up encryption": "設定加密",
+    "Encryption upgraded": "加密已升級",
+    "Encryption setup complete": "加密設定完成"
 }
diff --git a/src/indexing/BaseEventIndexManager.js b/src/indexing/BaseEventIndexManager.js
index 5e8ca668ad..8ebaddc3ab 100644
--- a/src/indexing/BaseEventIndexManager.js
+++ b/src/indexing/BaseEventIndexManager.js
@@ -62,11 +62,24 @@ export interface SearchArgs {
     room_id: ?string;
 }
 
-export interface HistoricEvent {
+export interface EventAndProfile {
     event: MatrixEvent;
     profile: MatrixProfile;
 }
 
+export interface LoadArgs {
+    roomId: string;
+    limit: number;
+    fromEvent: string;
+    direction: string;
+}
+
+export interface IndexStats {
+    size: number;
+    event_count: number;
+    room_count: number;
+}
+
 /**
  * Base class for classes that provide platform-specific event indexing.
  *
@@ -117,6 +130,16 @@ export default class BaseEventIndexManager {
         throw new Error("Unimplemented");
     }
 
+    /**
+     * Get statistical information of the index.
+     *
+     * @return {Promise<IndexStats>} A promise that will resolve to the index
+     * statistics.
+     */
+    async getStats(): Promise<IndexStats> {
+        throw new Error("Unimplemented");
+    }
+
     /**
      * Commit the previously queued up events to the index.
      *
@@ -145,7 +168,7 @@ export default class BaseEventIndexManager {
      *
      * This is used to add a batch of events to the index.
      *
-     * @param {[HistoricEvent]} events The list of events and profiles that
+     * @param {[EventAndProfile]} events The list of events and profiles that
      * should be added to the event index.
      * @param {[CrawlerCheckpoint]} checkpoint A new crawler checkpoint that
      * should be stored in the index which should be used to continue crawling
@@ -158,7 +181,7 @@ export default class BaseEventIndexManager {
      * were already added to the index, false otherwise.
      */
     async addHistoricEvents(
-        events: [HistoricEvent],
+        events: [EventAndProfile],
         checkpoint: CrawlerCheckpoint | null,
         oldCheckpoint: CrawlerCheckpoint | null,
     ): Promise<bool> {
@@ -201,6 +224,26 @@ export default class BaseEventIndexManager {
         throw new Error("Unimplemented");
     }
 
+    /** Load events that contain an mxc URL to a file from the index.
+     *
+     * @param  {object} args Arguments object for the method.
+     * @param  {string} args.roomId The ID of the room for which the events
+     * should be loaded.
+     * @param  {number} args.limit The maximum number of events to return.
+     * @param  {string} args.fromEvent An event id of a previous event returned
+     * by this method. Passing this means that we are going to continue loading
+     * events from this point in the history.
+     * @param  {string} args.direction The direction to which we should continue
+     * loading events from. This is used only if fromEvent is used as well.
+     *
+     * @return {Promise<[EventAndProfile]>} A promise that will resolve to an
+     * array of Matrix events that contain mxc URLs accompanied with the
+     * historic profile of the sender.
+     */
+    async loadFileEvents(args: LoadArgs): Promise<[EventAndProfile]> {
+        throw new Error("Unimplemented");
+    }
+
     /**
      * close our event index.
      *
diff --git a/src/indexing/EventIndex.js b/src/indexing/EventIndex.js
index c912e31fa5..9628920cd7 100644
--- a/src/indexing/EventIndex.js
+++ b/src/indexing/EventIndex.js
@@ -16,20 +16,26 @@ limitations under the License.
 
 import PlatformPeg from "../PlatformPeg";
 import {MatrixClientPeg} from "../MatrixClientPeg";
+import {EventTimeline, RoomMember} from 'matrix-js-sdk';
+import {sleep} from "../utils/promise";
+import {EventEmitter} from "events";
 
 /*
  * Event indexing class that wraps the platform specific event indexing.
  */
-export default class EventIndex {
+export default class EventIndex extends EventEmitter {
     constructor() {
+        super();
         this.crawlerCheckpoints = [];
-        // The time that the crawler will wait between /rooms/{room_id}/messages
-        // requests
-        this._crawlerTimeout = 3000;
+        // The time in ms that the crawler will wait loop iterations if there
+        // have not been any checkpoints to consume in the last iteration.
+        this._crawlerIdleTime = 5000;
+        this._crawlerSleepTime = 3000;
         // The maximum number of events our crawler should fetch in a single
         // crawl.
         this._eventsPerCrawl = 100;
         this._crawler = null;
+        this._currentCheckpoint = null;
         this.liveEventsForIndex = new Set();
     }
 
@@ -64,59 +70,62 @@ export default class EventIndex {
         client.removeListener('Room.timelineReset', this.onTimelineReset);
     }
 
+    /**
+     * Get crawler checkpoints for the encrypted rooms and store them in the index.
+     */
+    async addInitialCheckpoints() {
+        const indexManager = PlatformPeg.get().getEventIndexingManager();
+        const client = MatrixClientPeg.get();
+        const rooms = client.getRooms();
+
+        const isRoomEncrypted = (room) => {
+            return client.isRoomEncrypted(room.roomId);
+        };
+
+        // We only care to crawl the encrypted rooms, non-encrypted
+        // rooms can use the search provided by the homeserver.
+        const encryptedRooms = rooms.filter(isRoomEncrypted);
+
+        console.log("EventIndex: Adding initial crawler checkpoints");
+
+        // Gather the prev_batch tokens and create checkpoints for
+        // our message crawler.
+        await Promise.all(encryptedRooms.map(async (room) => {
+            const timeline = room.getLiveTimeline();
+            const token = timeline.getPaginationToken("b");
+
+            console.log("EventIndex: Got token for indexer",
+                        room.roomId, token);
+
+            const backCheckpoint = {
+                roomId: room.roomId,
+                token: token,
+                direction: "b",
+            };
+
+            const forwardCheckpoint = {
+                roomId: room.roomId,
+                token: token,
+                direction: "f",
+            };
+
+            await indexManager.addCrawlerCheckpoint(backCheckpoint);
+            await indexManager.addCrawlerCheckpoint(forwardCheckpoint);
+            this.crawlerCheckpoints.push(backCheckpoint);
+            this.crawlerCheckpoints.push(forwardCheckpoint);
+        }));
+    }
+
     onSync = async (state, prevState, data) => {
         const indexManager = PlatformPeg.get().getEventIndexingManager();
 
         if (prevState === "PREPARED" && state === "SYNCING") {
-            const addInitialCheckpoints = async () => {
-                const client = MatrixClientPeg.get();
-                const rooms = client.getRooms();
-
-                const isRoomEncrypted = (room) => {
-                    return client.isRoomEncrypted(room.roomId);
-                };
-
-                // We only care to crawl the encrypted rooms, non-encrypted.
-                // rooms can use the search provided by the homeserver.
-                const encryptedRooms = rooms.filter(isRoomEncrypted);
-
-                console.log("EventIndex: Adding initial crawler checkpoints");
-
-                // Gather the prev_batch tokens and create checkpoints for
-                // our message crawler.
-                await Promise.all(encryptedRooms.map(async (room) => {
-                    const timeline = room.getLiveTimeline();
-                    const token = timeline.getPaginationToken("b");
-
-                    console.log("EventIndex: Got token for indexer",
-                                room.roomId, token);
-
-                    const backCheckpoint = {
-                        roomId: room.roomId,
-                        token: token,
-                        direction: "b",
-                    };
-
-                    const forwardCheckpoint = {
-                        roomId: room.roomId,
-                        token: token,
-                        direction: "f",
-                    };
-
-                    await indexManager.addCrawlerCheckpoint(backCheckpoint);
-                    await indexManager.addCrawlerCheckpoint(forwardCheckpoint);
-                    this.crawlerCheckpoints.push(backCheckpoint);
-                    this.crawlerCheckpoints.push(forwardCheckpoint);
-                }));
-            };
-
             // If our indexer is empty we're most likely running Riot the
             // first time with indexing support or running it with an
             // initial sync. Add checkpoints to crawl our encrypted rooms.
             const eventIndexWasEmpty = await indexManager.isEventIndexEmpty();
-            if (eventIndexWasEmpty) await addInitialCheckpoints();
+            if (eventIndexWasEmpty) await this.addInitialCheckpoints();
 
-            // Start our crawler.
             this.startCrawler();
             return;
         }
@@ -170,7 +179,9 @@ export default class EventIndex {
             return;
         }
 
-        const e = ev.toJSON().decrypted;
+        const jsonEvent = ev.toJSON();
+        const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
+
         const profile = {
             displayname: ev.sender.rawDisplayName,
             avatar_url: ev.sender.getMxcAvatarUrl(),
@@ -179,13 +190,11 @@ export default class EventIndex {
         indexManager.addEventToIndex(e, profile);
     }
 
-    async crawlerFunc() {
-        // TODO either put this in a better place or find a library provided
-        // method that does this.
-        const sleep = async (ms) => {
-            return new Promise(resolve => setTimeout(resolve, ms));
-        };
+    emitNewCheckpoint() {
+        this.emit("changedCheckpoint", this.currentRoom());
+    }
 
+    async crawlerFunc() {
         let cancelled = false;
 
         console.log("EventIndex: Started crawler function");
@@ -199,11 +208,27 @@ export default class EventIndex {
             cancelled = true;
         };
 
+        let idle = false;
+
         while (!cancelled) {
             // This is a low priority task and we don't want to spam our
             // homeserver with /messages requests so we set a hefty timeout
             // here.
-            await sleep(this._crawlerTimeout);
+            let sleepTime = this._crawlerSleepTime;
+
+            // Don't let the user configure a lower sleep time than 100 ms.
+            sleepTime = Math.max(sleepTime, 100);
+
+            if (idle) {
+                sleepTime = this._crawlerIdleTime;
+            }
+
+            if (this._currentCheckpoint !== null) {
+                this._currentCheckpoint = null;
+                this.emitNewCheckpoint();
+            }
+
+            await sleep(sleepTime);
 
             console.log("EventIndex: Running the crawler loop.");
 
@@ -216,9 +241,15 @@ export default class EventIndex {
             /// There is no checkpoint available currently, one may appear if
             // a sync with limited room timelines happens, so go back to sleep.
             if (checkpoint === undefined) {
+                idle = true;
                 continue;
             }
 
+            this._currentCheckpoint = checkpoint;
+            this.emitNewCheckpoint();
+
+            idle = false;
+
             console.log("EventIndex: crawling using checkpoint", checkpoint);
 
             // We have a checkpoint, let us fetch some messages, again, very
@@ -238,6 +269,11 @@ export default class EventIndex {
                 continue;
             }
 
+            if (cancelled) {
+                this.crawlerCheckpoints.push(checkpoint);
+                break;
+            }
+
             if (res.chunk.length === 0) {
                 console.log("EventIndex: Done with the checkpoint", checkpoint);
                 // We got to the start/end of our timeline, lets just
@@ -305,10 +341,7 @@ export default class EventIndex {
             // consume.
             const events = filteredEvents.map((ev) => {
                 const jsonEvent = ev.toJSON();
-
-                let e;
-                if (ev.isEncrypted()) e = jsonEvent.decrypted;
-                else e = jsonEvent;
+                const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
 
                 let profile = {};
                 if (e.sender in profiles) profile = profiles[e.sender];
@@ -406,4 +439,223 @@ export default class EventIndex {
         const indexManager = PlatformPeg.get().getEventIndexingManager();
         return indexManager.searchEventIndex(searchArgs);
     }
+
+    /**
+     * Load events that contain URLs from the event index.
+     *
+     * @param {Room} room The room for which we should fetch events containing
+     * URLs
+     *
+     * @param {number} limit The maximum number of events to fetch.
+     *
+     * @param {string} fromEvent From which event should we continue fetching
+     * events from the index. This is only needed if we're continuing to fill
+     * the timeline, e.g. if we're paginating. This needs to be set to a event
+     * id of an event that was previously fetched with this function.
+     *
+     * @param {string} direction The direction in which we will continue
+     * fetching events. EventTimeline.BACKWARDS to continue fetching events that
+     * are older than the event given in fromEvent, EventTimeline.FORWARDS to
+     * fetch newer events.
+     *
+     * @returns {Promise<MatrixEvent[]>} Resolves to an array of events that
+     * contain URLs.
+     */
+    async loadFileEvents(room, limit = 10, fromEvent = null, direction = EventTimeline.BACKWARDS) {
+        const client = MatrixClientPeg.get();
+        const indexManager = PlatformPeg.get().getEventIndexingManager();
+
+        const loadArgs = {
+            roomId: room.roomId,
+            limit: limit,
+        };
+
+        if (fromEvent) {
+            loadArgs.fromEvent = fromEvent;
+            loadArgs.direction = direction;
+        }
+
+        let events;
+
+        // Get our events from the event index.
+        try {
+            events = await indexManager.loadFileEvents(loadArgs);
+        } catch (e) {
+            console.log("EventIndex: Error getting file events", e);
+            return [];
+        }
+
+        const eventMapper = client.getEventMapper();
+
+        // Turn the events into MatrixEvent objects.
+        const matrixEvents = events.map(e => {
+            const matrixEvent = eventMapper(e.event);
+
+            const member = new RoomMember(room.roomId, matrixEvent.getSender());
+
+            // We can't really reconstruct the whole room state from our
+            // EventIndex to calculate the correct display name. Use the
+            // disambiguated form always instead.
+            member.name = e.profile.displayname + " (" + matrixEvent.getSender() + ")";
+
+            // This is sets the avatar URL.
+            const memberEvent = eventMapper(
+                {
+                    content: {
+                        membership: "join",
+                        avatar_url: e.profile.avatar_url,
+                        displayname: e.profile.displayname,
+                    },
+                    type: "m.room.member",
+                    event_id: matrixEvent.getId() + ":eventIndex",
+                    room_id: matrixEvent.getRoomId(),
+                    sender: matrixEvent.getSender(),
+                    origin_server_ts: matrixEvent.getTs(),
+                    state_key: matrixEvent.getSender(),
+                },
+            );
+
+            // We set this manually to avoid emitting RoomMember.membership and
+            // RoomMember.name events.
+            member.events.member = memberEvent;
+            matrixEvent.sender = member;
+
+            return matrixEvent;
+        });
+
+        return matrixEvents;
+    }
+
+    /**
+     * Fill a timeline with events that contain URLs.
+     *
+     * @param {TimelineSet} timelineSet The TimelineSet the Timeline belongs to,
+     * used to check if we're adding duplicate events.
+     *
+     * @param {Timeline} timeline The Timeline which should be filed with
+     * events.
+     *
+     * @param {Room} room The room for which we should fetch events containing
+     * URLs
+     *
+     * @param {number} limit The maximum number of events to fetch.
+     *
+     * @param {string} fromEvent From which event should we continue fetching
+     * events from the index. This is only needed if we're continuing to fill
+     * the timeline, e.g. if we're paginating. This needs to be set to a event
+     * id of an event that was previously fetched with this function.
+     *
+     * @param {string} direction The direction in which we will continue
+     * fetching events. EventTimeline.BACKWARDS to continue fetching events that
+     * are older than the event given in fromEvent, EventTimeline.FORWARDS to
+     * fetch newer events.
+     *
+     * @returns {Promise<boolean>} Resolves to true if events were added to the
+     * timeline, false otherwise.
+     */
+    async populateFileTimeline(timelineSet, timeline, room, limit = 10,
+                               fromEvent = null, direction = EventTimeline.BACKWARDS) {
+        const matrixEvents = await this.loadFileEvents(room, limit, fromEvent, direction);
+
+        // If this is a normal fill request, not a pagination request, we need
+        // to get our events in the BACKWARDS direction but populate them in the
+        // forwards direction.
+        // This needs to happen because a fill request might come with an
+        // exisitng timeline e.g. if you close and re-open the FilePanel.
+        if (fromEvent === null) {
+            matrixEvents.reverse();
+            direction = direction == EventTimeline.BACKWARDS ? EventTimeline.FORWARDS: EventTimeline.BACKWARDS;
+        }
+
+        // Add the events to the timeline of the file panel.
+        matrixEvents.forEach(e => {
+            if (!timelineSet.eventIdToTimeline(e.getId())) {
+                timelineSet.addEventToTimeline(e, timeline, direction == EventTimeline.BACKWARDS);
+            }
+        });
+
+        // Set the pagination token to the oldest event that we retrieved.
+        if (matrixEvents.length > 0) {
+            timeline.setPaginationToken(matrixEvents[matrixEvents.length - 1].getId(), EventTimeline.BACKWARDS);
+            return true;
+        } else {
+            timeline.setPaginationToken("", EventTimeline.BACKWARDS);
+            return false;
+        }
+    }
+
+    /**
+     * Emulate a TimelineWindow pagination() request with the event index as the event source
+     *
+     * Might not fetch events from the index if the timeline already contains
+     * events that the window isn't showing.
+     *
+     * @param {Room} room The room for which we should fetch events containing
+     * URLs
+     *
+     * @param {TimelineWindow} timelineWindow The timeline window that should be
+     * populated with new events.
+     *
+     * @param {string} direction The direction in which we should paginate.
+     * EventTimeline.BACKWARDS to paginate back, EventTimeline.FORWARDS to
+     * paginate forwards.
+     *
+     * @param {number} limit The maximum number of events to fetch while
+     * paginating.
+     *
+     * @returns {Promise<boolean>} Resolves to a boolean which is true if more
+     * events were successfully retrieved.
+     */
+    paginateTimelineWindow(room, timelineWindow, direction, limit) {
+        const tl = timelineWindow.getTimelineIndex(direction);
+
+        if (!tl) return Promise.resolve(false);
+        if (tl.pendingPaginate) return tl.pendingPaginate;
+
+        if (timelineWindow.extend(direction, limit)) {
+            return Promise.resolve(true);
+        }
+
+        const paginationMethod = async (timelineWindow, timeline, room, direction, limit) => {
+            const timelineSet = timelineWindow._timelineSet;
+            const token = timeline.timeline.getPaginationToken(direction);
+
+            const ret = await this.populateFileTimeline(timelineSet, timeline.timeline, room, limit, token, direction);
+
+            timeline.pendingPaginate = null;
+            timelineWindow.extend(direction, limit);
+
+            return ret;
+        };
+
+        const paginationPromise = paginationMethod(timelineWindow, tl, room, direction, limit);
+        tl.pendingPaginate = paginationPromise;
+
+        return paginationPromise;
+    }
+
+    async getStats() {
+        const indexManager = PlatformPeg.get().getEventIndexingManager();
+        return indexManager.getStats();
+    }
+
+    /**
+     * Get the room that we are currently crawling.
+     *
+     * @returns {Room} A MatrixRoom that is being currently crawled, null
+     * if no room is currently being crawled.
+     */
+    currentRoom() {
+        if (this._currentCheckpoint === null && this.crawlerCheckpoints.length === 0) {
+            return null;
+        }
+
+        const client = MatrixClientPeg.get();
+
+        if (this._currentCheckpoint !== null) {
+            return client.getRoom(this._currentCheckpoint.roomId);
+        } else {
+            return client.getRoom(this.crawlerCheckpoints[0].roomId);
+        }
+    }
 }
diff --git a/src/indexing/EventIndexPeg.js b/src/indexing/EventIndexPeg.js
index 3746591b1f..067aea3a7f 100644
--- a/src/indexing/EventIndexPeg.js
+++ b/src/indexing/EventIndexPeg.js
@@ -21,17 +21,19 @@ limitations under the License.
 
 import PlatformPeg from "../PlatformPeg";
 import EventIndex from "../indexing/EventIndex";
-import SettingsStore from '../settings/SettingsStore';
+import SettingsStore, {SettingLevel} from '../settings/SettingsStore';
 
 class EventIndexPeg {
     constructor() {
         this.index = null;
+        this._supportIsInstalled = false;
     }
 
     /**
-     * Create a new EventIndex and initialize it if the platform supports it.
+     * Initialize the EventIndexPeg and if event indexing is enabled initialize
+     * the event index.
      *
-     * @return {Promise<bool>} A promise that will resolve to true if an
+     * @return {Promise<boolean>} A promise that will resolve to true if an
      * EventIndex was successfully initialized, false otherwise.
      */
     async init() {
@@ -40,12 +42,33 @@ class EventIndexPeg {
         }
 
         const indexManager = PlatformPeg.get().getEventIndexingManager();
-        if (!indexManager || await indexManager.supportsEventIndexing() !== true) {
-            console.log("EventIndex: Platform doesn't support event indexing,",
-                        "not initializing.");
+        if (!indexManager) {
+            console.log("EventIndex: Platform doesn't support event indexing, not initializing.");
             return false;
         }
 
+        this._supportIsInstalled = await indexManager.supportsEventIndexing();
+
+        if (!this.supportIsInstalled()) {
+            console.log("EventIndex: Event indexing isn't installed for the platform, not initializing.");
+            return false;
+        }
+
+        if (!SettingsStore.getValueAt(SettingLevel.DEVICE, 'enableEventIndexing')) {
+            console.log("EventIndex: Event indexing is disabled, not initializing");
+            return false;
+        }
+
+        return this.initEventIndex();
+    }
+
+    /**
+     * Initialize the event index.
+     *
+     * @returns {boolean} True if the event index was succesfully initialized,
+     * false otherwise.
+     */
+    async initEventIndex() {
         const index = new EventIndex();
 
         try {
@@ -60,6 +83,29 @@ class EventIndexPeg {
         return true;
     }
 
+    /**
+     * Check if the current platform has support for event indexing.
+     *
+     * @return {boolean} True if it has support, false otherwise. Note that this
+     * does not mean that support is installed.
+     */
+    platformHasSupport(): boolean {
+        return PlatformPeg.get().getEventIndexingManager() !== null;
+    }
+
+    /**
+     * Check if event indexing support is installed for the platfrom.
+     *
+     * Event indexing might require additional optional modules to be installed,
+     * this tells us if those are installed. Note that this should only be
+     * called after the init() method was called.
+     *
+     * @return {boolean} True if support is installed, false otherwise.
+     */
+    supportIsInstalled(): boolean {
+        return this._supportIsInstalled;
+    }
+
     /**
      * Get the current event index.
      *
@@ -69,6 +115,11 @@ class EventIndexPeg {
         return this.index;
     }
 
+    start() {
+        if (this.index === null) return;
+        this.index.startCrawler();
+    }
+
     stop() {
         if (this.index === null) return;
         this.index.stopCrawler();
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index eacf63e55d..37a211e905 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -128,12 +128,6 @@ export const SETTINGS = {
         supportedLevels: LEVELS_FEATURE,
         default: false,
     },
-    "feature_ftue_dms": {
-        isFeature: true,
-        displayName: _td("New invite dialog"),
-        supportedLevels: LEVELS_FEATURE,
-        default: false,
-    },
     "feature_presence_in_room_list": {
         isFeature: true,
         displayName: _td("Show a presence dot next to DMs in the room list"),
@@ -486,4 +480,9 @@ export const SETTINGS = {
         supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
         default: RIGHT_PANEL_PHASES.GroupMemberList,
     },
+    "enableEventIndexing": {
+        supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
+        displayName: _td("Enable message search in encrypted rooms"),
+        default: true,
+    },
 };
diff --git a/src/settings/SettingsStore.js b/src/settings/SettingsStore.js
index 9501bac205..b1d61197a0 100644
--- a/src/settings/SettingsStore.js
+++ b/src/settings/SettingsStore.js
@@ -145,7 +145,7 @@ export default class SettingsStore {
             callbackFn(originalSettingName, changedInRoomId, atLevel, newValAtLevel, newValue);
         };
 
-        console.log(`Starting watcher for ${settingName}@${roomId || '<null room>'}`);
+        console.log(`Starting watcher for ${settingName}@${roomId || '<null room>'} as ID ${watcherId}`);
         SettingsStore._watchers[watcherId] = localizedCallback;
         defaultWatchManager.watchSetting(settingName, roomId, localizedCallback);
 
@@ -159,8 +159,12 @@ export default class SettingsStore {
      * to cancel.
      */
     static unwatchSetting(watcherReference) {
-        if (!SettingsStore._watchers[watcherReference]) return;
+        if (!SettingsStore._watchers[watcherReference]) {
+            console.warn(`Ending non-existent watcher ID ${watcherReference}`);
+            return;
+        }
 
+        console.log(`Ending watcher ID ${watcherReference}`);
         defaultWatchManager.unwatchSetting(SettingsStore._watchers[watcherReference]);
         delete SettingsStore._watchers[watcherReference];
     }
diff --git a/src/utils/FormattingUtils.js b/src/utils/FormattingUtils.js
index 1fd7d00feb..b932214530 100644
--- a/src/utils/FormattingUtils.js
+++ b/src/utils/FormattingUtils.js
@@ -30,6 +30,31 @@ export function formatCount(count) {
    return (count / 1000000000).toFixed(1) + "B"; // 10B is enough for anyone, right? :S
 }
 
+/**
+ * Format a count showing the whole number but making it a bit more readable.
+ * e.g: 1000 => 1,000
+ */
+export function formatCountLong(count) {
+    const formatter = new Intl.NumberFormat();
+    return formatter.format(count)
+}
+
+/**
+ * format a size in bytes into a human readable form
+ * e.g: 1024 -> 1.00 KB
+ */
+export function formatBytes(bytes, decimals = 2) {
+    if (bytes === 0) return '0 Bytes';
+
+    const k = 1024;
+    const dm = decimals < 0 ? 0 : decimals;
+    const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+    return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+}
+
 /**
  * format a key into groups of 4 characters, for easier visual inspection
  *
diff --git a/src/utils/KeyVerificationStateObserver.js b/src/utils/KeyVerificationStateObserver.js
index 1a35319186..a29d2ea1aa 100644
--- a/src/utils/KeyVerificationStateObserver.js
+++ b/src/utils/KeyVerificationStateObserver.js
@@ -17,16 +17,15 @@ limitations under the License.
 import {MatrixClientPeg} from '../MatrixClientPeg';
 import { _t } from '../languageHandler';
 
-export function getNameForEventRoom(userId, mxEvent) {
-    const roomId = mxEvent.getRoomId();
+export function getNameForEventRoom(userId, roomId) {
     const client = MatrixClientPeg.get();
     const room = client.getRoom(roomId);
-    const member = room.getMember(userId);
+    const member = room && room.getMember(userId);
     return member ? member.name : userId;
 }
 
-export function userLabelForEventRoom(userId, mxEvent) {
-    const name = getNameForEventRoom(userId, mxEvent);
+export function userLabelForEventRoom(userId, roomId) {
+    const name = getNameForEventRoom(userId, roomId);
     if (name !== userId) {
         return _t("%(name)s (%(userId)s)", {name, userId});
     } else {
diff --git a/test/accessibility/RovingTabIndex-test.js b/test/accessibility/RovingTabIndex-test.js
new file mode 100644
index 0000000000..8be4a2976c
--- /dev/null
+++ b/test/accessibility/RovingTabIndex-test.js
@@ -0,0 +1,121 @@
+/*
+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 Adapter from "enzyme-adapter-react-16";
+import { configure, mount } from "enzyme";
+
+import {
+    RovingTabIndexProvider,
+    RovingTabIndexWrapper,
+    useRovingTabIndex,
+} from "../../src/accessibility/RovingTabIndex";
+
+configure({ adapter: new Adapter() });
+
+const Button = (props) => {
+    const [onFocus, isActive, ref] = useRovingTabIndex();
+    return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
+};
+
+const checkTabIndexes = (buttons, expectations) => {
+    expect(buttons.length).toBe(expectations.length);
+    for (let i = 0; i < buttons.length; i++) {
+        expect(buttons.at(i).prop("tabIndex")).toBe(expectations[i]);
+    }
+};
+
+// give the buttons keys for the fibre reconciler to not treat them all as the same
+const button1 = <Button key={1}>a</Button>;
+const button2 = <Button key={2}>b</Button>;
+const button3 = <Button key={3}>c</Button>;
+const button4 = <Button key={4}>d</Button>;
+
+describe("RovingTabIndex", () => {
+    it("RovingTabIndexProvider renders children as expected", () => {
+        const wrapper = mount(<RovingTabIndexProvider>
+            {() => <div><span>Test</span></div>}
+        </RovingTabIndexProvider>);
+        expect(wrapper.text()).toBe("Test");
+        expect(wrapper.html()).toBe('<div><span>Test</span></div>');
+    });
+
+    it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => {
+        const wrapper = mount(<RovingTabIndexProvider>
+            {() => <React.Fragment>
+                { button1 }
+                { button2 }
+                { button3 }
+            </React.Fragment>}
+        </RovingTabIndexProvider>);
+
+        // should begin with 0th being active
+        checkTabIndexes(wrapper.find("button"), [0, -1, -1]);
+
+        // focus on 2nd button and test it is the only active one
+        wrapper.find("button").at(2).simulate("focus");
+        wrapper.update();
+        checkTabIndexes(wrapper.find("button"), [-1, -1, 0]);
+
+        // focus on 1st button and test it is the only active one
+        wrapper.find("button").at(1).simulate("focus");
+        wrapper.update();
+        checkTabIndexes(wrapper.find("button"), [-1, 0, -1]);
+
+        // check that the active button does not change even on an explicit blur event
+        wrapper.find("button").at(1).simulate("blur");
+        wrapper.update();
+        checkTabIndexes(wrapper.find("button"), [-1, 0, -1]);
+
+        // update the children, it should remain on the same button
+        wrapper.setProps({
+            children: () => [button1, button4, button2, button3],
+        });
+        wrapper.update();
+        checkTabIndexes(wrapper.find("button"), [-1, -1, 0, -1]);
+
+        // update the children, remove the active button, it should move to the next one
+        wrapper.setProps({
+            children: () => [button1, button4, button3],
+        });
+        wrapper.update();
+        checkTabIndexes(wrapper.find("button"), [-1, -1, 0]);
+    });
+
+    it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => {
+        const wrapper = mount(<RovingTabIndexProvider>
+            {() => <React.Fragment>
+                { button1 }
+                { button2 }
+                <RovingTabIndexWrapper>
+                    {({onFocus, isActive, ref}) =>
+                        <button onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref}>.</button>
+                    }
+                </RovingTabIndexWrapper>
+            </React.Fragment>}
+        </RovingTabIndexProvider>);
+
+        // should begin with 0th being active
+        checkTabIndexes(wrapper.find("button"), [0, -1, -1]);
+
+        // focus on 2nd button and test it is the only active one
+        wrapper.find("button").at(2).simulate("focus");
+        wrapper.update();
+        checkTabIndexes(wrapper.find("button"), [-1, -1, 0]);
+    });
+});
+
+
diff --git a/test/components/views/rooms/SendMessageComposer-test.js b/test/components/views/rooms/SendMessageComposer-test.js
new file mode 100644
index 0000000000..d5a143a1fb
--- /dev/null
+++ b/test/components/views/rooms/SendMessageComposer-test.js
@@ -0,0 +1,83 @@
+/*
+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 RoomViewStore from "../../../../src/stores/RoomViewStore";
+import {createMessageContent} from "../../../../src/components/views/rooms/SendMessageComposer";
+import EditorModel from "../../../../src/editor/model";
+import {createPartCreator, createRenderer} from "../../../editor/mock";
+
+jest.mock("../../../../src/stores/RoomViewStore");
+
+describe('<SendMessageComposer/>', () => {
+    describe("createMessageContent", () => {
+        RoomViewStore.getQuotingEvent.mockReturnValue(false);
+        const permalinkCreator = jest.fn();
+
+        it("sends plaintext messages correctly", () => {
+            const model = new EditorModel([], createPartCreator(), createRenderer());
+            model.update("hello world", "insertText", {offset: 11, atNodeEnd: true});
+
+            const content = createMessageContent(model, permalinkCreator);
+
+            expect(content).toEqual({
+                body: "hello world",
+                msgtype: "m.text",
+            });
+        });
+
+        it("sends markdown messages correctly", () => {
+            const model = new EditorModel([], createPartCreator(), createRenderer());
+            model.update("hello *world*", "insertText", {offset: 13, atNodeEnd: true});
+
+            const content = createMessageContent(model, permalinkCreator);
+
+            expect(content).toEqual({
+                body: "hello *world*",
+                msgtype: "m.text",
+                format: "org.matrix.custom.html",
+                formatted_body: "hello <em>world</em>",
+            });
+        });
+
+        it("strips /me from messages and marks them as m.emote accordingly", () => {
+            const model = new EditorModel([], createPartCreator(), createRenderer());
+            model.update("/me blinks __quickly__", "insertText", {offset: 22, atNodeEnd: true});
+
+            const content = createMessageContent(model, permalinkCreator);
+
+            expect(content).toEqual({
+                body: "blinks __quickly__",
+                msgtype: "m.emote",
+                format: "org.matrix.custom.html",
+                formatted_body: "blinks <strong>quickly</strong>",
+            });
+        });
+
+        it("allows sending double-slash escaped slash commands correctly", () => {
+            const model = new EditorModel([], createPartCreator(), createRenderer());
+            model.update("//dev/null is my favourite place", "insertText", {offset: 32, atNodeEnd: true});
+
+            const content = createMessageContent(model, permalinkCreator);
+
+            expect(content).toEqual({
+                body: "/dev/null is my favourite place",
+                msgtype: "m.text",
+            });
+        });
+    });
+});
+
+
diff --git a/test/editor/mock.js b/test/editor/mock.js
index bb1a51d14b..6de65cf23d 100644
--- a/test/editor/mock.js
+++ b/test/editor/mock.js
@@ -67,3 +67,13 @@ export function createPartCreator(completions = []) {
     };
     return new PartCreator(new MockRoom(), new MockClient(), autoCompleteCreator);
 }
+
+export function createRenderer() {
+    const render = (c) => {
+        render.caret = c;
+        render.count += 1;
+    };
+    render.count = 0;
+    render.caret = null;
+    return render;
+}
diff --git a/test/editor/model-test.js b/test/editor/model-test.js
index 826dde3d68..2a3584d508 100644
--- a/test/editor/model-test.js
+++ b/test/editor/model-test.js
@@ -15,17 +15,7 @@ limitations under the License.
 */
 
 import EditorModel from "../../src/editor/model";
-import {createPartCreator} from "./mock";
-
-function createRenderer() {
-    const render = (c) => {
-        render.caret = c;
-        render.count += 1;
-    };
-    render.count = 0;
-    render.caret = null;
-    return render;
-}
+import {createPartCreator, createRenderer} from "./mock";
 
 describe('editor/model', function() {
     describe('plain text manipulation', function() {
diff --git a/test/editor/operations-test.js b/test/editor/operations-test.js
new file mode 100644
index 0000000000..90a9812306
--- /dev/null
+++ b/test/editor/operations-test.js
@@ -0,0 +1,190 @@
+/*
+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 EditorModel from "../../src/editor/model";
+import {createPartCreator, createRenderer} from "./mock";
+import {toggleInlineFormat} from "../../src/editor/operations";
+
+const SERIALIZED_NEWLINE = {"text": "\n", "type": "newline"};
+
+describe('editor/operations: formatting operations', () => {
+    describe('toggleInlineFormat', () => {
+        it('works for words', () => {
+            const renderer = createRenderer();
+            const pc = createPartCreator();
+            const model = new EditorModel([
+                pc.plain("hello world!"),
+            ], pc, renderer);
+
+            const range = model.startRange(model.positionForOffset(6, false),
+                model.positionForOffset(11, false));  // around "world"
+
+            expect(range.parts[0].text).toBe("world");
+            expect(model.serializeParts()).toEqual([{"text": "hello world!", "type": "plain"}]);
+            toggleInlineFormat(range, "_");
+            expect(model.serializeParts()).toEqual([{"text": "hello _world_!", "type": "plain"}]);
+        });
+
+        it('works for parts of words', () => {
+            const renderer = createRenderer();
+            const pc = createPartCreator();
+            const model = new EditorModel([
+                pc.plain("hello world!"),
+            ], pc, renderer);
+
+            const range = model.startRange(model.positionForOffset(7, false),
+                model.positionForOffset(10, false));  // around "orl"
+
+            expect(range.parts[0].text).toBe("orl");
+            expect(model.serializeParts()).toEqual([{"text": "hello world!", "type": "plain"}]);
+            toggleInlineFormat(range, "*");
+            expect(model.serializeParts()).toEqual([{"text": "hello w*orl*d!", "type": "plain"}]);
+        });
+
+        it('works for around pills', () => {
+            const renderer = createRenderer();
+            const pc = createPartCreator();
+            const model = new EditorModel([
+                pc.plain("hello there "),
+                pc.atRoomPill("@room"),
+                pc.plain(", how are you doing?"),
+            ], pc, renderer);
+
+            const range = model.startRange(model.positionForOffset(6, false),
+                model.positionForOffset(30, false));  // around "there @room, how are you"
+
+            expect(range.parts.map(p => p.text).join("")).toBe("there @room, how are you");
+            expect(model.serializeParts()).toEqual([
+                {"text": "hello there ", "type": "plain"},
+                {"text": "@room", "type": "at-room-pill"},
+                {"text": ", how are you doing?", "type": "plain"},
+            ]);
+            toggleInlineFormat(range, "_");
+            expect(model.serializeParts()).toEqual([
+                {"text": "hello _there ", "type": "plain"},
+                {"text": "@room", "type": "at-room-pill"},
+                {"text": ", how are you_ doing?", "type": "plain"},
+            ]);
+        });
+
+        it('works for a paragraph', () => {
+            const renderer = createRenderer();
+            const pc = createPartCreator();
+            const model = new EditorModel([
+                pc.plain("hello world,"),
+                pc.newline(),
+                pc.plain("how are you doing?"),
+            ], pc, renderer);
+
+            const range = model.startRange(model.positionForOffset(6, false),
+                model.positionForOffset(16, false));  // around "world,\nhow"
+
+            expect(range.parts.map(p => p.text).join("")).toBe("world,\nhow");
+            expect(model.serializeParts()).toEqual([
+                {"text": "hello world,", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                {"text": "how are you doing?", "type": "plain"},
+            ]);
+            toggleInlineFormat(range, "**");
+            expect(model.serializeParts()).toEqual([
+                {"text": "hello **world,", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                {"text": "how** are you doing?", "type": "plain"},
+            ]);
+        });
+
+        it('works for a paragraph with spurious breaks around it in selected range', () => {
+            const renderer = createRenderer();
+            const pc = createPartCreator();
+            const model = new EditorModel([
+                pc.newline(),
+                pc.newline(),
+                pc.plain("hello world,"),
+                pc.newline(),
+                pc.plain("how are you doing?"),
+                pc.newline(),
+                pc.newline(),
+            ], pc, renderer);
+
+            const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd());  // select-all
+
+            expect(range.parts.map(p => p.text).join("")).toBe("\n\nhello world,\nhow are you doing?\n\n");
+            expect(model.serializeParts()).toEqual([
+                SERIALIZED_NEWLINE,
+                SERIALIZED_NEWLINE,
+                {"text": "hello world,", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                {"text": "how are you doing?", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                SERIALIZED_NEWLINE,
+            ]);
+            toggleInlineFormat(range, "**");
+            expect(model.serializeParts()).toEqual([
+                SERIALIZED_NEWLINE,
+                SERIALIZED_NEWLINE,
+                {"text": "**hello world,", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                {"text": "how are you doing?**", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                SERIALIZED_NEWLINE,
+            ]);
+        });
+
+        it('works for multiple paragraph', () => {
+            const renderer = createRenderer();
+            const pc = createPartCreator();
+            const model = new EditorModel([
+                pc.plain("hello world,"),
+                pc.newline(),
+                pc.plain("how are you doing?"),
+                pc.newline(),
+                pc.newline(),
+                pc.plain("new paragraph"),
+            ], pc, renderer);
+
+            let range = model.startRange(model.positionForOffset(0, true), model.getPositionAtEnd()); // select-all
+
+            expect(model.serializeParts()).toEqual([
+                {"text": "hello world,", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                {"text": "how are you doing?", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                SERIALIZED_NEWLINE,
+                {"text": "new paragraph", "type": "plain"},
+            ]);
+            toggleInlineFormat(range, "__");
+            expect(model.serializeParts()).toEqual([
+                {"text": "__hello world,", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                {"text": "how are you doing?__", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                SERIALIZED_NEWLINE,
+                {"text": "__new paragraph__", "type": "plain"},
+            ]);
+            range = model.startRange(model.positionForOffset(0, true), model.getPositionAtEnd()); // select-all
+            console.log("RANGE", range.parts);
+            toggleInlineFormat(range, "__");
+            expect(model.serializeParts()).toEqual([
+                {"text": "hello world,", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                {"text": "how are you doing?", "type": "plain"},
+                SERIALIZED_NEWLINE,
+                SERIALIZED_NEWLINE,
+                {"text": "new paragraph", "type": "plain"},
+            ]);
+        });
+    });
+});
diff --git a/test/editor/range-test.js b/test/editor/range-test.js
index 53fb6cb765..b69ed9eb53 100644
--- a/test/editor/range-test.js
+++ b/test/editor/range-test.js
@@ -15,17 +15,7 @@ limitations under the License.
 */
 
 import EditorModel from "../../src/editor/model";
-import {createPartCreator} from "./mock";
-
-function createRenderer() {
-    const render = (c) => {
-        render.caret = c;
-        render.count += 1;
-    };
-    render.count = 0;
-    render.caret = null;
-    return render;
-}
+import {createPartCreator, createRenderer} from "./mock";
 
 const pillChannel = "#riot-dev:matrix.org";
 
diff --git a/test/end-to-end-tests/src/usecases/invite.js b/test/end-to-end-tests/src/usecases/invite.js
index 6bee5dfd6f..75ebc61a88 100644
--- a/test/end-to-end-tests/src/usecases/invite.js
+++ b/test/end-to-end-tests/src/usecases/invite.js
@@ -31,10 +31,11 @@ module.exports = async function invite(session, userId) {
     }
     const inviteButton = await session.query(".mx_MemberList_invite");
     await inviteButton.click();
-    const inviteTextArea = await session.query(".mx_AddressPickerDialog textarea");
+    const inviteTextArea = await session.query(".mx_InviteDialog_editor textarea");
     await inviteTextArea.type(userId);
-    await inviteTextArea.press("Enter");
-    const confirmButton = await session.query(".mx_Dialog_primary");
+    const selectUserItem = await session.query(".mx_InviteDialog_roomTile");
+    await selectUserItem.click();
+    const confirmButton = await session.query(".mx_InviteDialog_goButton");
     await confirmButton.click();
     session.log.done();
 };
diff --git a/yarn.lock b/yarn.lock
index 0177629cd8..b892ac44f6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -211,6 +211,13 @@
   dependencies:
     "@babel/types" "^7.7.4"
 
+"@babel/helper-module-imports@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz#7fe39589b39c016331b6b8c3f441e8f0b1419498"
+  integrity sha512-R0Bx3jippsbAEtzkpZ/6FIiuzOURPcMjHp+Z6xPe6DtApDJx+w7UYyOLanZqO8+wKR9G10s/FmHXvxaMd9s6Kg==
+  dependencies:
+    "@babel/types" "^7.8.3"
+
 "@babel/helper-module-transforms@^7.7.4", "@babel/helper-module-transforms@^7.7.5":
   version "7.7.5"
   resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.7.5.tgz#d044da7ffd91ec967db25cd6748f704b6b244835"
@@ -235,6 +242,11 @@
   resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz#bbb3fbee98661c569034237cc03967ba99b4f250"
   integrity sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA==
 
+"@babel/helper-plugin-utils@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670"
+  integrity sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==
+
 "@babel/helper-regex@^7.0.0", "@babel/helper-regex@^7.4.4":
   version "7.5.5"
   resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.5.5.tgz#0aa6824f7100a2e0e89c1527c23936c152cab351"
@@ -735,13 +747,13 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.0.0"
 
-"@babel/plugin-transform-runtime@^7.7.6":
-  version "7.7.6"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.7.6.tgz#4f2b548c88922fb98ec1c242afd4733ee3e12f61"
-  integrity sha512-tajQY+YmXR7JjTwRvwL4HePqoL3DYxpYXIHKVvrOIvJmeHe2y1w4tz5qz9ObUDC9m76rCzIMPyn4eERuwA4a4A==
+"@babel/plugin-transform-runtime@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.8.3.tgz#c0153bc0a5375ebc1f1591cb7eea223adea9f169"
+  integrity sha512-/vqUt5Yh+cgPZXXjmaG9NT8aVfThKk7G4OqkVhrXqwsC5soMn/qTCxs36rZ2QFhpfTJcjw4SNDIZ4RUb8OL4jQ==
   dependencies:
-    "@babel/helper-module-imports" "^7.7.4"
-    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/helper-module-imports" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.3"
     resolve "^1.8.1"
     semver "^5.5.1"
 
@@ -894,7 +906,7 @@
     pirates "^4.0.0"
     source-map-support "^0.5.16"
 
-"@babel/runtime@^7.0.0", "@babel/runtime@^7.7.6":
+"@babel/runtime@^7.0.0":
   version "7.7.6"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.6.tgz#d18c511121aff1b4f2cd1d452f1bac9601dd830f"
   integrity sha512-BWAJxpNVa0QlE5gZdWjSxXtemZyZ9RmrmVozxt3NUXeZhVIJ5ANyqmMc0JDrivBZyxUuQvFxlvH4OWWOogGfUw==
@@ -908,6 +920,13 @@
   dependencies:
     regenerator-runtime "^0.13.2"
 
+"@babel/runtime@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.3.tgz#0811944f73a6c926bb2ad35e918dcc1bfab279f1"
+  integrity sha512-fVHx1rzEmwB130VTkLnxR+HmxcTjGzH12LYQcFFoBwakMd3aOMD4OsRN7tGG/UOYE2ektgFrS8uACAoRk1CY0w==
+  dependencies:
+    regenerator-runtime "^0.13.2"
+
 "@babel/template@^7.4.0", "@babel/template@^7.7.4":
   version "7.7.4"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.7.4.tgz#428a7d9eecffe27deac0a98e23bf8e3675d2a77b"
@@ -941,6 +960,15 @@
     lodash "^4.17.13"
     to-fast-properties "^2.0.0"
 
+"@babel/types@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.3.tgz#5a383dffa5416db1b73dedffd311ffd0788fb31c"
+  integrity sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==
+  dependencies:
+    esutils "^2.0.2"
+    lodash "^4.17.13"
+    to-fast-properties "^2.0.0"
+
 "@cnakazawa/watch@^1.0.3":
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.3.tgz#099139eaec7ebf07a27c1786a3ff64f39464d2ef"
@@ -5732,11 +5760,11 @@ mathml-tag-names@^2.0.1:
   resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.1.tgz#6dff66c99d55ecf739ca53c492e626f1d12a33cc"
   integrity sha512-pWB896KPGSGkp1XtyzRBftpTzwSOL0Gfk0wLvxt4f2mgzjY19o0LxJ3U25vNWTzsh7da+KTbuXQoQ3lOJZ8WHw==
 
-matrix-js-sdk@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-3.0.0.tgz#97908f9eda9eeb3ba0333b7e474c45f2b258e50c"
-  integrity sha512-lzUMwJAZHw7Dk0K+rubqe6kEpy4+pJ+qCp8n6lisfdKfMDJXdNCkjiiXRnakM1ZD4PFYK8ju89+NfxlyhAAd4A==
+"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
+  version "4.0.0"
+  resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/21e4c597d9633aef606871cf9ffffaf039142be3"
   dependencies:
+    "@babel/runtime" "^7.8.3"
     another-json "^0.2.0"
     browser-request "^0.3.3"
     bs58 "^4.0.1"