Merge branch 'develop' into florianduros/encryption-tab

florianduros/encryption-tab
Florian Duros 2024-12-19 13:13:30 +01:00
commit c805cd8260
No known key found for this signature in database
GPG Key ID: A5BBB4041B493F15
46 changed files with 1047 additions and 919 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Fixtures>({
// eslint-disable-next-line no-empty-pattern
startHomeserverOpts: async ({}, use) => {
await use("dehydration");

View File

@ -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<Fixtures>({
// 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) => {

View File

@ -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()) {

View File

@ -60,7 +60,7 @@ interface CredentialsWithDisplayName extends Credentials {
displayName: string;
}
export const test = base.extend<{
export interface Fixtures {
axe: AxeBuilder;
checkA11y: () => Promise<void>;
@ -124,7 +124,9 @@ export const test = base.extend<{
slidingSyncProxy: ProxyInstance;
labsFlags: string[];
webserver: Webserver;
}>({
}
export const test = base.extend<Fixtures>({
config: CONFIG_JSON,
page: async ({ context, page, config, labsFlags }, use) => {
await context.route(`http://localhost:8080/config.json*`, async (route) => {

View File

@ -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<Omit<HomeserverConfig, "dockerUrl">> {
const templateDir = path.join(__dirname, "templates", opts.template);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -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<boolean> {
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!");

View File

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

View File

@ -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<IProps, IState> {
}
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<IProps, IState> {
};
/**
* 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 = (
<div>
<p>{_t("auth|logout_dialog|setup_secure_backup_description_1")}</p>
@ -254,7 +265,8 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
case BackupStatus.NO_BACKUP:
case BackupStatus.SERVER_BACKUP_BUT_DISABLED:
case BackupStatus.ERROR:
return this.renderSetupBackupDialog();
case BackupStatus.BACKUP_NO_RECOVERY:
return this.renderSetupRecoveryMethod();
}
}
}

View File

@ -128,7 +128,8 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
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();
}
}

View File

@ -310,78 +310,78 @@ export default function RoomHeader({
</BodyText>
</Box>
</button>
<Flex align="center" gap="var(--cpd-space-2x)">
{additionalButtons?.map((props) => {
const label = props.label();
return (
<Tooltip label={label} key={props.id}>
<IconButton
aria-label={label}
onClick={(event) => {
event.stopPropagation();
props.onClick();
}}
>
{typeof props.icon === "function" ? props.icon() : props.icon}
</IconButton>
</Tooltip>
);
})}
{additionalButtons?.map((props) => {
const label = props.label();
{isViewingCall && <CallGuestLinkButton room={room} />}
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
joinCallButton
) : (
<>
{!isVideoRoom && videoCallButton}
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
</>
)}
<Tooltip label={_t("right_panel|room_summary_card|title")}>
<IconButton
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
}}
aria-label={_t("right_panel|room_summary_card|title")}
>
<RoomInfoIcon />
</IconButton>
</Tooltip>
{showChatButton && <VideoRoomChatButton room={room} />}
<Tooltip label={_t("common|threads")}>
<IconButton
indicator={notificationLevelToIndicator(threadNotifications)}
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel);
PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt);
}}
aria-label={_t("common|threads")}
>
<ThreadsIcon />
</IconButton>
</Tooltip>
{notificationsEnabled && (
<Tooltip label={_t("notifications|enable_prompt_toast_title")}>
return (
<Tooltip label={label} key={props.id}>
<IconButton
indicator={notificationLevelToIndicator(globalNotificationState.level)}
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
aria-label={label}
onClick={(event) => {
event.stopPropagation();
props.onClick();
}}
aria-label={_t("notifications|enable_prompt_toast_title")}
>
<NotificationsIcon />
{typeof props.icon === "function" ? props.icon() : props.icon}
</IconButton>
</Tooltip>
)}
</Flex>
);
})}
{isViewingCall && <CallGuestLinkButton room={room} />}
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
joinCallButton
) : (
<>
{!isVideoRoom && videoCallButton}
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
</>
)}
{showChatButton && <VideoRoomChatButton room={room} />}
<Tooltip label={_t("common|threads")}>
<IconButton
indicator={notificationLevelToIndicator(threadNotifications)}
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel);
PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt);
}}
aria-label={_t("common|threads")}
>
<ThreadsIcon />
</IconButton>
</Tooltip>
{notificationsEnabled && (
<Tooltip label={_t("notifications|enable_prompt_toast_title")}>
<IconButton
indicator={notificationLevelToIndicator(globalNotificationState.level)}
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
}}
aria-label={_t("notifications|enable_prompt_toast_title")}
>
<NotificationsIcon />
</IconButton>
</Tooltip>
)}
<Tooltip label={_t("right_panel|room_summary_card|title")}>
<IconButton
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
}}
aria-label={_t("right_panel|room_summary_card|title")}
>
<RoomInfoIcon />
</IconButton>
</Tooltip>
{!isDirectMessage && (
<BodyText as="div" size="sm" weight="medium">
<FacePile

View File

@ -214,7 +214,7 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
<SettingsSubsectionText>
{this.state.enabling ? <InlineSpinner /> : _t("settings|security|message_search_failed")}
</SettingsSubsectionText>
{EventIndexPeg.error && (
{EventIndexPeg.error ? (
<SettingsSubsectionText>
<details>
<summary>{_t("common|advanced")}</summary>
@ -230,7 +230,7 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
</p>
</details>
</SettingsSubsectionText>
)}
) : undefined}
</>
);
}

View File

@ -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? <a>Reset all</a>",
"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": {

View File

@ -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"
}
}
},

View File

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

View File

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

View File

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

View File

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

View File

@ -1003,7 +1003,9 @@ describe("<MatrixChat />", () => {
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);
});

View File

@ -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();

View File

@ -375,55 +375,73 @@ describe("<TextualBody />", () => {
});
});
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();
});
});
});

View File

@ -42,111 +42,106 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
</div>
</div>
</button>
<div
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
<button
aria-labelledby=":r16c:"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<button
aria-labelledby=":r16c:"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</div>
</button>
<button
aria-disabled="true"
aria-label="There's no one here to call"
aria-labelledby=":r16h:"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</div>
</button>
<button
aria-disabled="true"
aria-label="There's no one here to call"
aria-labelledby=":r16h:"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%; --cpd-color-icon-tertiary: var(--cpd-color-icon-disabled);"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%; --cpd-color-icon-tertiary: var(--cpd-color-icon-disabled);"
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527c0 .285-.11.537-.33.756-.22.22-.472.33-.756.33a15.97 15.97 0 0 1-6.57-1.105 16.223 16.223 0 0 1-5.563-3.663 16.084 16.084 0 0 1-3.653-5.573 16.313 16.313 0 0 1-1.115-6.56c0-.285.11-.537.33-.757.22-.22.471-.329.755-.329l3.528.039a1.069 1.069 0 0 1 1.085.93l.543 3.954c.026.181.013.349-.039.504a1.088 1.088 0 0 1-.271.426l-1.64 1.64c.337.672.721 1.308 1.154 1.909.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444c.6.433 1.237.817 1.909 1.153l1.64-1.64a1.08 1.08 0 0 1 .426-.27c.155-.052.323-.065.504-.04l3.954.543a1.069 1.069 0 0 1 .93 1.085Z"
/>
</svg>
</div>
</button>
<button
aria-label="Room info"
aria-labelledby=":r16m:"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
<path
d="m20.958 16.374.039 3.527c0 .285-.11.537-.33.756-.22.22-.472.33-.756.33a15.97 15.97 0 0 1-6.57-1.105 16.223 16.223 0 0 1-5.563-3.663 16.084 16.084 0 0 1-3.653-5.573 16.313 16.313 0 0 1-1.115-6.56c0-.285.11-.537.33-.757.22-.22.471-.329.755-.329l3.528.039a1.069 1.069 0 0 1 1.085.93l.543 3.954c.026.181.013.349-.039.504a1.088 1.088 0 0 1-.271.426l-1.64 1.64c.337.672.721 1.308 1.154 1.909.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444c.6.433 1.237.817 1.909 1.153l1.64-1.64a1.08 1.08 0 0 1 .426-.27c.155-.052.323-.065.504-.04l3.954.543a1.069 1.069 0 0 1 .93 1.085Z"
/>
</svg>
</div>
</button>
<button
aria-label="Threads"
aria-labelledby=":r16m:"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17a.97.97 0 0 0 .713-.288A.968.968 0 0 0 13 16v-4a.968.968 0 0 0-.287-.713A.968.968 0 0 0 12 11a.968.968 0 0 0-.713.287A.968.968 0 0 0 11 12v4c0 .283.096.52.287.712.192.192.43.288.713.288Zm0-8c.283 0 .52-.096.713-.287A.967.967 0 0 0 13 8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 13a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
/>
</svg>
</div>
</button>
<button
aria-label="Threads"
aria-labelledby=":r16r:"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
<path
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2Zm3 7h10a.97.97 0 0 0 .712-.287A.967.967 0 0 0 18 9a.967.967 0 0 0-.288-.713A.968.968 0 0 0 17 8H7a.968.968 0 0 0-.713.287A.968.968 0 0 0 6 9c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 4h6c.283 0 .52-.096.713-.287A.968.968 0 0 0 14 13a.968.968 0 0 0-.287-.713A.968.968 0 0 0 13 12H7a.967.967 0 0 0-.713.287A.968.968 0 0 0 6 13c0 .283.096.52.287.713.192.191.43.287.713.287Z"
/>
</svg>
</div>
</button>
<button
aria-label="Room info"
aria-labelledby=":r16r:"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2Zm3 7h10a.97.97 0 0 0 .712-.287A.967.967 0 0 0 18 9a.967.967 0 0 0-.288-.713A.968.968 0 0 0 17 8H7a.968.968 0 0 0-.713.287A.968.968 0 0 0 6 9c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 4h6c.283 0 .52-.096.713-.287A.968.968 0 0 0 14 13a.968.968 0 0 0-.287-.713A.968.968 0 0 0 13 12H7a.967.967 0 0 0-.713.287A.968.968 0 0 0 6 13c0 .283.096.52.287.713.192.191.43.287.713.287Z"
/>
</svg>
</div>
</button>
</div>
<path
d="M12 17a.97.97 0 0 0 .713-.288A.968.968 0 0 0 13 16v-4a.968.968 0 0 0-.287-.713A.968.968 0 0 0 12 11a.968.968 0 0 0-.713.287A.968.968 0 0 0 11 12v4c0 .283.096.52.287.712.192.192.43.288.713.288Zm0-8c.283 0 .52-.096.713-.287A.967.967 0 0 0 13 8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 13a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
/>
</svg>
</div>
</button>
</header>
</DocumentFragment>
`;

View File

@ -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 = () => (
<MatrixClientContext.Provider value={mockClient}>
<SDKContext.Provider value={stores}>
<SetIntegrationManager />
</SDKContext.Provider>
<SetIntegrationManager />
</MatrixClientContext.Provider>
);

View File

@ -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(<ToastContainer />);
});
it("should render the se up recovery toast", async () => {
showToast(Kind.SET_UP_RECOVERY);
await expect(screen.findByText("Set up recovery")).resolves.toBeInTheDocument();
});
});

View File

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