diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index daac3bfed8..2b3603549d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,6 +16,11 @@ on: options: - staging.element.io - app.element.io + skip-checks: + description: Skip CI on the tagged commit + required: true + default: false + type: boolean concurrency: ${{ inputs.site || 'staging.element.io' }} permissions: {} jobs: @@ -75,6 +80,7 @@ jobs: - name: Wait for other steps to succeed uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork + if: inputs.skip-checks != true with: ref: ${{ github.sha }} running-workflow-name: "Deploy to Cloudflare Pages" diff --git a/CHANGELOG.md b/CHANGELOG.md index 17d5f5f13e..d5e000f494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +Changes in [1.11.89](https://github.com/element-hq/element-web/releases/tag/v1.11.89) (2024-12-18) +================================================================================================== +This is a patch release to fix a bug which could prevent loading stored crypto state from storage, and also to fix URL previews when switching back to a room. + +## 🐛 Bug Fixes + +* Upgrade matrix-sdk-crypto-wasm to 1.11.0 (https://github.com/matrix-org/matrix-js-sdk/pull/4593) +* Fix url preview display ([#28766](https://github.com/element-hq/element-web/pull/28766)). + + Changes in [1.11.88](https://github.com/element-hq/element-web/releases/tag/v1.11.88) (2024-12-17) ================================================================================================== ## ✨ Features diff --git a/package.json b/package.json index 6d082f9586..999d1237f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.11.88", + "version": "1.11.89", "description": "A feature-rich client for Matrix.org", "author": "New Vector Ltd.", "repository": { @@ -282,7 +282,7 @@ "terser-webpack-plugin": "^5.3.9", "ts-node": "^10.9.1", "ts-prune": "^0.10.3", - "typescript": "5.6.3", + "typescript": "5.7.2", "util": "^0.12.5", "web-streams-polyfill": "^4.0.0", "webpack": "^5.89.0", diff --git a/playwright/e2e/crypto/backups.spec.ts b/playwright/e2e/crypto/backups.spec.ts index da162474fa..d174cc89e5 100644 --- a/playwright/e2e/crypto/backups.spec.ts +++ b/playwright/e2e/crypto/backups.spec.ts @@ -9,6 +9,8 @@ Please see LICENSE files in the repository root for full details. import { type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; +import { test as masTest, registerAccountMas } from "../oidc"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; async function expectBackupVersionToBe(page: Page, version: string) { await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText( @@ -18,6 +20,32 @@ async function expectBackupVersionToBe(page: Page, version: string) { await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version); } +masTest.describe("Encryption state after registration", () => { + masTest.skip(isDendrite, "does not yet support MAS"); + + masTest("Key backup is enabled by default", async ({ page, mailhog, app }) => { + await page.goto("/#/login"); + await page.getByRole("button", { name: "Continue" }).click(); + await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!"); + + await app.settings.openUserSettings("Security & Privacy"); + expect(page.getByText("This session is backing up your keys.")).toBeVisible(); + }); + + masTest("user is prompted to set up recovery", async ({ page, mailhog, app }) => { + await page.goto("/#/login"); + await page.getByRole("button", { name: "Continue" }).click(); + await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!"); + + await page.getByRole("button", { name: "Add room" }).click(); + await page.getByRole("menuitem", { name: "New room" }).click(); + await page.getByRole("textbox", { name: "Name" }).fill("test room"); + await page.getByRole("button", { name: "Create room" }).click(); + + await expect(page.getByRole("heading", { name: "Set up recovery" })).toBeVisible(); + }); +}); + test.describe("Backups", () => { test.use({ displayName: "Hanako", diff --git a/playwright/e2e/crypto/dehydration.spec.ts b/playwright/e2e/crypto/dehydration.spec.ts index 590ab774b5..a247bed180 100644 --- a/playwright/e2e/crypto/dehydration.spec.ts +++ b/playwright/e2e/crypto/dehydration.spec.ts @@ -8,11 +8,11 @@ Please see LICENSE files in the repository root for full details. import { Locator, type Page } from "@playwright/test"; -import { test as base, expect } from "../../element-web-test"; +import { test as base, expect, Fixtures } from "../../element-web-test"; import { viewRoomSummaryByName } from "../right-panel/utils"; import { isDendrite } from "../../plugins/homeserver/dendrite"; -const test = base.extend({ +const test = base.extend({ // eslint-disable-next-line no-empty-pattern startHomeserverOpts: async ({}, use) => { await use("dehydration"); diff --git a/playwright/e2e/crypto/migration.spec.ts b/playwright/e2e/crypto/migration.spec.ts index 048b39f06a..03464d12c4 100644 --- a/playwright/e2e/crypto/migration.spec.ts +++ b/playwright/e2e/crypto/migration.spec.ts @@ -9,9 +9,9 @@ Please see LICENSE files in the repository root for full details. import path from "path"; import { readFile } from "node:fs/promises"; -import { expect, test as base } from "../../element-web-test"; +import { expect, Fixtures, test as base } from "../../element-web-test"; -const test = base.extend({ +const test = base.extend({ // Replace the `user` fixture with one which populates the indexeddb data before starting the app. user: async ({ context, pageWithCredentials: page, credentials }, use) => { await page.route(`/test_indexeddb_cryptostore_dump/*`, async (route, request) => { diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index 971508b25b..0cd0736218 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -71,7 +71,9 @@ test.describe("Room Header", () => { // Assert the size of buttons on RoomHeader are specified and the buttons are not compressed // Note these assertions do not check the size of mx_LegacyRoomHeader_name button - const buttons = header.locator(".mx_Flex").getByRole("button"); + const buttons = header.getByRole("button").filter({ + has: page.locator("svg"), + }); await expect(buttons).toHaveCount(5); for (const button of await buttons.all()) { diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 76e57e33f7..6ac0b7226a 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -60,7 +60,7 @@ interface CredentialsWithDisplayName extends Credentials { displayName: string; } -export const test = base.extend<{ +export interface Fixtures { axe: AxeBuilder; checkA11y: () => Promise; @@ -124,7 +124,9 @@ export const test = base.extend<{ slidingSyncProxy: ProxyInstance; labsFlags: string[]; webserver: Webserver; -}>({ +} + +export const test = base.extend({ config: CONFIG_JSON, page: async ({ context, page, config, labsFlags }, use) => { await context.route(`http://localhost:8080/config.json*`, async (route) => { diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index fc09b6a22b..863e236c69 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand"; // Docker tag to use for synapse docker image. // We target a specific digest as every now and then a Synapse update will break our CI. // This digest is updated by the playwright-image-updates.yaml workflow periodically. -const DOCKER_TAG = "develop@sha256:ef3d491214fa380918c736d9aa720992fb58829ce5c06fa3ca36d357fa1df75d"; +const DOCKER_TAG = "develop@sha256:c965896a4865479ab2628807ebf6d9c742586f3b6185a56f10077a408f1c7c3b"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png index afc5d53fab..4ba22a5220 100644 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png index ce15e3e151..ef6112da1d 100644 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-long-name-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png index bd31e502d7..ed8c75104f 100644 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png index 348db69cfc..00b271004e 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png index 42ee5a0acb..8f11c831db 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png index 92532e3d9c..6365543947 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/configured-room-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png index 1e50cd3c0f..d8a5ae4056 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-line-inline-start-margin-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png index b0960a1188..58c844a54d 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png index a7637b6b94..d8e6da9f8f 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-compact-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png index a609a4cd0d..e1a4e6ef06 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png index fe50abef0c..032a8c1118 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/event-tiles-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png index ac6dadc962..b31eae03f6 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-and-messages-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png index 8e833be308..1c7265ca62 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-bubble-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png index 3e9e78ca99..33ef04df3c 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-emote-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png index 1e50cd3c0f..d8a5ae4056 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-irc-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png index b81a9d68a8..608b17051d 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-modern-layout-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png index 58ba6c5703..06aa02cdf8 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/expanded-gels-redaction-placeholder-linux.png differ diff --git a/src/CreateCrossSigning.ts b/src/CreateCrossSigning.ts index e67e030f60..c38f1a3dd5 100644 --- a/src/CreateCrossSigning.ts +++ b/src/CreateCrossSigning.ts @@ -16,18 +16,19 @@ import { _t } from "./languageHandler"; import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog"; /** - * Determine if the homeserver allows uploading device keys with only password auth. + * Determine if the homeserver allows uploading device keys with only password auth, or with no auth at + * all (ie. if the homeserver supports MSC3967). * @param cli The Matrix Client to use - * @returns True if the homeserver allows uploading device keys with only password auth, otherwise false + * @returns True if the homeserver allows uploading device keys with only password auth or with no auth + * at all, otherwise false */ async function canUploadKeysWithPasswordOnly(cli: MatrixClient): Promise { try { await cli.uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys); - // 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. - logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); - return false; + // If we get here, it's because the server is allowing us to upload keys without + // auth the first time due to MSC3967. Therefore, yes, we can upload keys + // (with or without password, technically, but that's fine). + return true; } catch (error) { if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { logger.log("uploadDeviceSigningKeys advertised no flows!"); diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 84d83827da..e34af95962 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -295,21 +295,29 @@ export default class DeviceListener { await crypto.getUserDeviceInfo([cli.getSafeUserId()]); // cross signing isn't enabled - nag to enable it - // There are 2 different toasts for: + // There are 3 different toasts for: if (!(await crypto.getCrossSigningKeyId()) && (await crypto.userHasCrossSigningKeys())) { - // Cross-signing on account but this device doesn't trust the master key (verify this session) + // Toast 1. Cross-signing on account but this device doesn't trust the master key (verify this session) showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); this.checkKeyBackupStatus(); } else { - // No cross-signing or key backup on account (set up encryption) - await cli.waitForClientWellKnown(); - if (isSecureBackupRequired(cli) && isLoggedIn()) { - // If we're meant to set up, and Secure Backup is required, - // trigger the flow directly without a toast once logged in. - hideSetupEncryptionToast(); - accessSecretStorage(); + const backupInfo = await this.getKeyBackupInfo(); + if (backupInfo) { + // Toast 2: Key backup is enabled but recovery (4S) is not set up: prompt user to set up recovery. + // Since we now enable key backup at registration time, this will be the common case for + // new users. + showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); } else { - showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); + // Toast 3: No cross-signing or key backup on account (set up encryption) + await cli.waitForClientWellKnown(); + if (isSecureBackupRequired(cli) && isLoggedIn()) { + // If we're meant to set up, and Secure Backup is required, + // trigger the flow directly without a toast once logged in. + hideSetupEncryptionToast(); + accessSecretStorage(); + } else { + showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); + } } } } diff --git a/src/components/views/dialogs/LogoutDialog.tsx b/src/components/views/dialogs/LogoutDialog.tsx index 4731c593bc..af8f91533e 100644 --- a/src/components/views/dialogs/LogoutDialog.tsx +++ b/src/components/views/dialogs/LogoutDialog.tsx @@ -38,6 +38,9 @@ enum BackupStatus { /** there is a backup on the server but we are not backing up to it */ SERVER_BACKUP_BUT_DISABLED, + /** Key backup is set up but recovery (4s) is not */ + BACKUP_NO_RECOVERY, + /** backup is not set up locally and there is no backup on the server */ NO_BACKUP, @@ -104,7 +107,11 @@ export default class LogoutDialog extends React.Component { } if ((await crypto.getActiveSessionBackupVersion()) !== null) { - this.setState({ backupStatus: BackupStatus.BACKUP_ACTIVE }); + if (await crypto.isSecretStorageReady()) { + this.setState({ backupStatus: BackupStatus.BACKUP_ACTIVE }); + } else { + this.setState({ backupStatus: BackupStatus.BACKUP_NO_RECOVERY }); + } return; } @@ -164,13 +171,17 @@ export default class LogoutDialog extends React.Component { }; /** - * Show a dialog prompting the user to set up key backup. + * Show a dialog prompting the user to set up their recovery method. * - * Either there is no backup at all ({@link BackupStatus.NO_BACKUP}), there is a backup on the server but - * we are not connected to it ({@link BackupStatus.SERVER_BACKUP_BUT_DISABLED}), or we were unable to pull the - * backup data ({@link BackupStatus.ERROR}). In all three cases, we should prompt the user to set up key backup. + * Either: + * * There is no backup at all ({@link BackupStatus.NO_BACKUP}) + * * There is a backup set up but recovery (4s) is not ({@link BackupStatus.BACKUP_NO_RECOVERY}) + * * There is a backup on the server but we are not connected to it ({@link BackupStatus.SERVER_BACKUP_BUT_DISABLED}) + * * We were unable to pull the backup data ({@link BackupStatus.ERROR}). + * + * In all four cases, we should prompt the user to set up a method of recovery. */ - private renderSetupBackupDialog(): React.ReactNode { + private renderSetupRecoveryMethod(): React.ReactNode { const description = (

{_t("auth|logout_dialog|setup_secure_backup_description_1")}

@@ -254,7 +265,8 @@ export default class LogoutDialog extends React.Component { case BackupStatus.NO_BACKUP: case BackupStatus.SERVER_BACKUP_BUT_DISABLED: case BackupStatus.ERROR: - return this.renderSetupBackupDialog(); + case BackupStatus.BACKUP_NO_RECOVERY: + return this.renderSetupRecoveryMethod(); } } } diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index ae99754cba..242feff6d4 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -128,7 +128,8 @@ export default class TextualBody extends React.Component { if (!this.props.editState) { const stoppedEditing = prevProps.editState && !this.props.editState; const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId; - if (messageWasEdited || stoppedEditing) { + const urlPreviewChanged = prevProps.showUrlPreview !== this.props.showUrlPreview; + if (messageWasEdited || stoppedEditing || urlPreviewChanged) { this.applyFormatting(); } } diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index d133587fc9..4c39a2db18 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -310,78 +310,78 @@ export default function RoomHeader({ - - {additionalButtons?.map((props) => { - const label = props.label(); - return ( - - { - event.stopPropagation(); - props.onClick(); - }} - > - {typeof props.icon === "function" ? props.icon() : props.icon} - - - ); - })} + {additionalButtons?.map((props) => { + const label = props.label(); - {isViewingCall && } - - {hasActiveCallSession && !isConnectedToCall && !isViewingCall ? ( - joinCallButton - ) : ( - <> - {!isVideoRoom && videoCallButton} - {!useElementCallExclusively && !isVideoRoom && voiceCallButton} - - )} - - - { - evt.stopPropagation(); - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary); - }} - aria-label={_t("right_panel|room_summary_card|title")} - > - - - - - {showChatButton && } - - - { - evt.stopPropagation(); - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel); - PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt); - }} - aria-label={_t("common|threads")} - > - - - - {notificationsEnabled && ( - + return ( + { - evt.stopPropagation(); - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel); + aria-label={label} + onClick={(event) => { + event.stopPropagation(); + props.onClick(); }} - aria-label={_t("notifications|enable_prompt_toast_title")} > - + {typeof props.icon === "function" ? props.icon() : props.icon} - )} - + ); + })} + + {isViewingCall && } + + {hasActiveCallSession && !isConnectedToCall && !isViewingCall ? ( + joinCallButton + ) : ( + <> + {!isVideoRoom && videoCallButton} + {!useElementCallExclusively && !isVideoRoom && voiceCallButton} + + )} + + {showChatButton && } + + + { + evt.stopPropagation(); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel); + PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt); + }} + aria-label={_t("common|threads")} + > + + + + {notificationsEnabled && ( + + { + evt.stopPropagation(); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel); + }} + aria-label={_t("notifications|enable_prompt_toast_title")} + > + + + + )} + + + { + evt.stopPropagation(); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary); + }} + aria-label={_t("right_panel|room_summary_card|title")} + > + + + + {!isDirectMessage && ( { {this.state.enabling ? : _t("settings|security|message_search_failed")} - {EventIndexPeg.error && ( + {EventIndexPeg.error ? (
{_t("common|advanced")} @@ -230,7 +230,7 @@ export default class EventIndexPanel extends React.Component<{}, IState> {

- )} + ) : undefined} ); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a49d391d36..e91cad29b7 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -915,6 +915,9 @@ "warning": "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." }, "reset_all_button": "Forgotten or lost all recovery methods? Reset all", + "set_up_recovery": "Set up recovery", + "set_up_recovery_later": "Not now", + "set_up_recovery_toast_description": "Generate a recovery key that can be used to restore your encrypted message history in case you lose access to your devices.", "set_up_toast_description": "Safeguard against losing access to encrypted messages & data", "set_up_toast_title": "Set up Secure Backup", "setup_secure_backup": { diff --git a/src/i18n/strings/pl.json b/src/i18n/strings/pl.json index 66ac807080..ef1134e0c0 100644 --- a/src/i18n/strings/pl.json +++ b/src/i18n/strings/pl.json @@ -2115,7 +2115,8 @@ "show_less": "Pokaż mniej", "show_n_more": { "one": "Pokaż %(count)s więcej", - "other": "Pokaż %(count)s więcej" + "few": "Pokaż %(count)s więcej", + "many": "Pokaż %(count)s więcej" }, "show_previews": "Pokazuj podgląd wiadomości", "sort_by": "Sortuj według", @@ -3689,7 +3690,8 @@ "close": "Zamknij podgląd", "show_n_more": { "one": "Pokaż %(count)s inny podgląd", - "other": "Pokaż %(count)s innych podglądów" + "few": "Pokaż %(count)s inne podglądy", + "many": "Pokaż %(count)s innych podglądów" } } }, diff --git a/src/stores/InitialCryptoSetupStore.ts b/src/stores/InitialCryptoSetupStore.ts index 0c2e49f5ca..5554a15d26 100644 --- a/src/stores/InitialCryptoSetupStore.ts +++ b/src/stores/InitialCryptoSetupStore.ts @@ -114,8 +114,15 @@ export class InitialCryptoSetupStore extends EventEmitter { this.emit("update"); try { + // Create the user's cross-signing keys await createCrossSigning(this.client, this.isTokenLogin, this.stores.accountPasswordStore.getPassword()); + // Check for any existing backup and enable key backup if there isn't one + const currentKeyBackup = await cryptoApi.checkKeyBackupAndEnable(); + if (currentKeyBackup === null) { + await cryptoApi.resetKeyBackup(); + } + this.reset(); this.status = "complete"; diff --git a/src/toasts/SetupEncryptionToast.ts b/src/toasts/SetupEncryptionToast.ts index 0dd54bb18f..406b51cf16 100644 --- a/src/toasts/SetupEncryptionToast.ts +++ b/src/toasts/SetupEncryptionToast.ts @@ -23,15 +23,19 @@ const getTitle = (kind: Kind): string => { switch (kind) { case Kind.SET_UP_ENCRYPTION: return _t("encryption|set_up_toast_title"); + case Kind.SET_UP_RECOVERY: + return _t("encryption|set_up_recovery"); case Kind.VERIFY_THIS_SESSION: return _t("encryption|verify_toast_title"); } }; -const getIcon = (kind: Kind): string => { +const getIcon = (kind: Kind): string | undefined => { switch (kind) { case Kind.SET_UP_ENCRYPTION: return "secure_backup"; + case Kind.SET_UP_RECOVERY: + return undefined; case Kind.VERIFY_THIS_SESSION: return "verification_warning"; } @@ -41,22 +45,49 @@ const getSetupCaption = (kind: Kind): string => { switch (kind) { case Kind.SET_UP_ENCRYPTION: return _t("action|continue"); + case Kind.SET_UP_RECOVERY: + return _t("action|continue"); case Kind.VERIFY_THIS_SESSION: return _t("action|verify"); } }; +const getSecondaryButtonLabel = (kind: Kind): string => { + switch (kind) { + case Kind.SET_UP_RECOVERY: + return _t("encryption|set_up_recovery_later"); + case Kind.SET_UP_ENCRYPTION: + case Kind.VERIFY_THIS_SESSION: + return _t("encryption|verification|unverified_sessions_toast_reject"); + } +}; + const getDescription = (kind: Kind): string => { switch (kind) { case Kind.SET_UP_ENCRYPTION: return _t("encryption|set_up_toast_description"); + case Kind.SET_UP_RECOVERY: + return _t("encryption|set_up_recovery_toast_description"); case Kind.VERIFY_THIS_SESSION: return _t("encryption|verify_toast_description"); } }; +/** + * The kind of toast to show. + */ export enum Kind { + /** + * Prompt the user to set up encryption + */ SET_UP_ENCRYPTION = "set_up_encryption", + /** + * Prompt the user to set up a recovery key + */ + SET_UP_RECOVERY = "set_up_recovery", + /** + * Prompt the user to verify this session + */ VERIFY_THIS_SESSION = "verify_this_session", } @@ -64,6 +95,11 @@ const onReject = (): void => { DeviceListener.sharedInstance().dismissEncryptionSetup(); }; +/** + * Show a toast prompting the user for some action related to setting up their encryption. + * + * @param kind The kind of toast to show + */ export const showToast = (kind: Kind): void => { if ( ModuleRunner.instance.extensions.cryptoSetup.setupEncryptionNeeded({ @@ -101,15 +137,17 @@ export const showToast = (kind: Kind): void => { description: getDescription(kind), primaryLabel: getSetupCaption(kind), onPrimaryClick: onAccept, - secondaryLabel: _t("encryption|verification|unverified_sessions_toast_reject"), + secondaryLabel: getSecondaryButtonLabel(kind), onSecondaryClick: onReject, - destructive: "secondary", }, component: GenericToast, priority: kind === Kind.VERIFY_THIS_SESSION ? 95 : 40, }); }; +/** + * Hide the encryption setup toast if it is currently being shown. + */ export const hideToast = (): void => { ToastStore.sharedInstance().dismissToast(TOAST_KEY); }; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 883d1fa7eb..3b78a4389e 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -137,6 +137,7 @@ export function createTestClient(): MatrixClient { restoreKeyBackupWithPassphrase: jest.fn(), loadSessionBackupPrivateKeyFromSecretStorage: jest.fn(), storeSessionBackupPrivateKey: jest.fn(), + checkKeyBackupAndEnable: jest.fn().mockResolvedValue(null), getKeyBackupInfo: jest.fn().mockResolvedValue(null), getEncryptionInfoForEvent: jest.fn().mockResolvedValue(null), getCrossSigningStatus: jest.fn().mockResolvedValue({ diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index ad7f14e119..1c8fe1a1c7 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -352,13 +352,13 @@ describe("DeviceListener", () => { mockCrypto!.getCrossSigningKeyId.mockResolvedValue("abc"); }); - it("shows set up encryption toast when user has a key backup available", async () => { + it("shows set up recovery toast when user has a key backup available", async () => { // non falsy response mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo); await createAndStart(); expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.SET_UP_ENCRYPTION, + SetupEncryptionToast.Kind.SET_UP_RECOVERY, ); }); }); diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index fd17ccf583..d5db4f190a 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -1003,7 +1003,9 @@ describe("", () => { userHasCrossSigningKeys: jest.fn().mockResolvedValue(false), // This needs to not finish immediately because we need to test the screen appears bootstrapCrossSigning: jest.fn().mockImplementation(() => bootstrapDeferred.promise), + resetKeyBackup: jest.fn(), isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false), + checkKeyBackupAndEnable: jest.fn().mockResolvedValue(null), }; loginClient.getCrypto.mockReturnValue(mockCrypto as any); }); diff --git a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index 1e0ed2248b..6e9fb7fa36 100644 --- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -45,113 +45,108 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
-
- -
+ + - + - + - + + + +
@@ -263,113 +258,108 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
-
- -
+ + - + - + - + + + +
@@ -566,113 +556,108 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
-
- -
+ + - + - + - + + + +
@@ -946,113 +931,108 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
-
- -
+ + - + - + - + + + +
@@ -1334,113 +1314,108 @@ exports[`RoomView should not display the timeline when the room encryption is lo
-
- -
+ + - + - + - + + + +
@@ -1545,113 +1520,108 @@ exports[`RoomView should not display the timeline when the room encryption is lo
-
- -
+ + - + - + - + + + +
@@ -1929,86 +1899,81 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
-
- -
+ + - + - + + + +
diff --git a/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx b/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx index 0557e538d0..98f758ebbd 100644 --- a/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx @@ -42,12 +42,20 @@ describe("LogoutDialog", () => { expect(rendered.container).toMatchSnapshot(); }); - it("shows a regular dialog if backups are working", async () => { + it("shows a regular dialog if backups and recovery are working", async () => { mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1"); + mockCrypto.isSecretStorageReady.mockResolvedValue(true); const rendered = renderComponent(); await rendered.findByText("Are you sure you want to sign out?"); }); + it("prompts user to set up recovery if backups are enabled but recovery isn't", async () => { + mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1"); + mockCrypto.isSecretStorageReady.mockResolvedValue(false); + const rendered = renderComponent(); + await rendered.findByText("You'll lose access to your encrypted messages"); + }); + it("Prompts user to connect backup if there is a backup on the server", async () => { mockCrypto.getKeyBackupInfo.mockResolvedValue({} as KeyBackupInfo); const rendered = renderComponent(); diff --git a/test/unit-tests/components/views/messages/TextualBody-test.tsx b/test/unit-tests/components/views/messages/TextualBody-test.tsx index c7ffc4ed93..14b9453575 100644 --- a/test/unit-tests/components/views/messages/TextualBody-test.tsx +++ b/test/unit-tests/components/views/messages/TextualBody-test.tsx @@ -375,55 +375,73 @@ describe("", () => { }); }); - it("renders url previews correctly", () => { - languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]); + describe("url preview", () => { + let matrixClient: MatrixClient; - const matrixClient = getMockClientWithEventEmitter({ - getRoom: () => mkStubRoom("room_id", "room name", undefined), - getAccountData: (): MatrixClient | undefined => undefined, - getUrlPreview: (url: string) => new Promise(() => {}), - isGuest: () => false, - mxcUrlToHttp: (s: string) => s, + beforeEach(() => { + languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]); + matrixClient = getMockClientWithEventEmitter({ + getRoom: () => mkStubRoom("room_id", "room name", undefined), + getAccountData: (): MatrixClient | undefined => undefined, + getUrlPreview: (url: string) => new Promise(() => {}), + isGuest: () => false, + mxcUrlToHttp: (s: string) => s, + }); + DMRoomMap.makeShared(defaultMatrixClient); }); - DMRoomMap.makeShared(defaultMatrixClient); - const ev = mkRoomTextMessage("Visit https://matrix.org/"); - const { container, rerender } = getComponent( - { mxEvent: ev, showUrlPreview: true, onHeightChanged: jest.fn() }, - matrixClient, - ); + it("renders url previews correctly", () => { + const ev = mkRoomTextMessage("Visit https://matrix.org/"); + const { container, rerender } = getComponent( + { mxEvent: ev, showUrlPreview: true, onHeightChanged: jest.fn() }, + matrixClient, + ); - expect(container).toHaveTextContent(ev.getContent().body); - expect(container.querySelector("a")).toHaveAttribute("href", "https://matrix.org/"); + expect(container).toHaveTextContent(ev.getContent().body); + expect(container.querySelector("a")).toHaveAttribute("href", "https://matrix.org/"); - // simulate an event edit and check the transition from the old URL preview to the new one - const ev2 = mkEvent({ - type: "m.room.message", - room: "room_id", - user: "sender", - content: { - "m.new_content": { - body: "Visit https://vector.im/ and https://riot.im/", - msgtype: "m.text", + // simulate an event edit and check the transition from the old URL preview to the new one + const ev2 = mkEvent({ + type: "m.room.message", + room: "room_id", + user: "sender", + content: { + "m.new_content": { + body: "Visit https://vector.im/ and https://riot.im/", + msgtype: "m.text", + }, }, - }, - event: true, + event: true, + }); + jest.spyOn(ev, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3)); + ev.makeReplaced(ev2); + + getComponent( + { mxEvent: ev, showUrlPreview: true, onHeightChanged: jest.fn(), replacingEventId: ev.getId() }, + matrixClient, + rerender, + ); + + expect(container).toHaveTextContent(ev2.getContent()["m.new_content"].body + "(edited)"); + + const links = ["https://vector.im/", "https://riot.im/"]; + const anchorNodes = container.querySelectorAll("a"); + Array.from(anchorNodes).forEach((node, index) => { + expect(node).toHaveAttribute("href", links[index]); + }); }); - jest.spyOn(ev, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3)); - ev.makeReplaced(ev2); - getComponent( - { mxEvent: ev, showUrlPreview: true, onHeightChanged: jest.fn(), replacingEventId: ev.getId() }, - matrixClient, - rerender, - ); + it("should listen to showUrlPreview change", () => { + const ev = mkRoomTextMessage("Visit https://matrix.org/"); - expect(container).toHaveTextContent(ev2.getContent()["m.new_content"].body + "(edited)"); + const { container, rerender } = getComponent( + { mxEvent: ev, showUrlPreview: false, onHeightChanged: jest.fn() }, + matrixClient, + ); + expect(container.querySelector(".mx_LinkPreviewGroup")).toBeNull(); - const links = ["https://vector.im/", "https://riot.im/"]; - const anchorNodes = container.querySelectorAll("a"); - Array.from(anchorNodes).forEach((node, index) => { - expect(node).toHaveAttribute("href", links[index]); + getComponent({ mxEvent: ev, showUrlPreview: true, onHeightChanged: jest.fn() }, matrixClient, rerender); + expect(container.querySelector(".mx_LinkPreviewGroup")).toBeTruthy(); }); }); }); diff --git a/test/unit-tests/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap b/test/unit-tests/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap index a6f412a3ac..3db3fb67fb 100644 --- a/test/unit-tests/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap @@ -42,111 +42,106 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
-
- -
+ + - + - + - + + + + `; diff --git a/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx b/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx index 5c77e88d93..54c2aff979 100644 --- a/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx +++ b/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx @@ -11,7 +11,6 @@ import { fireEvent, render, screen, waitFor, within } from "jest-matrix-react"; import { logger } from "matrix-js-sdk/src/logger"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { SDKContext, SdkContextClass } from "../../../../../src/contexts/SDKContext"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { UIFeature } from "../../../../../src/settings/UIFeature"; import { @@ -35,13 +34,9 @@ describe("SetIntegrationManager", () => { deleteThreePid: jest.fn(), }); - let stores!: SdkContextClass; - const getComponent = () => ( - - - + ); diff --git a/test/unit-tests/toasts/SetupEncryptionToast-test.tsx b/test/unit-tests/toasts/SetupEncryptionToast-test.tsx new file mode 100644 index 0000000000..5ce3fab9ae --- /dev/null +++ b/test/unit-tests/toasts/SetupEncryptionToast-test.tsx @@ -0,0 +1,24 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { render, screen } from "jest-matrix-react"; + +import ToastContainer from "../../../src/components/structures/ToastContainer"; +import { Kind, showToast } from "../../../src/toasts/SetupEncryptionToast"; + +describe("SetupEncryptionToast", () => { + beforeEach(() => { + render(); + }); + + it("should render the se up recovery toast", async () => { + showToast(Kind.SET_UP_RECOVERY); + + await expect(screen.findByText("Set up recovery")).resolves.toBeInTheDocument(); + }); +}); diff --git a/yarn.lock b/yarn.lock index bf2b3480a5..35e7ac04b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8595,9 +8595,9 @@ murmurhash-js@^1.0.0: integrity sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw== nanoid@^3.3.7: - version "3.3.7" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" - integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + version "3.3.8" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== natural-compare@^1.4.0: version "1.4.0" @@ -11539,10 +11539,10 @@ typed-array-length@^1.0.6: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" -typescript@5.6.3: - version "5.6.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.3.tgz#5f3449e31c9d94febb17de03cc081dd56d81db5b" - integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== +typescript@5.7.2: + version "5.7.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.2.tgz#3169cf8c4c8a828cde53ba9ecb3d2b1d5dd67be6" + integrity sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg== ua-parser-js@^1.0.2: version "1.0.39"