Compare commits
4 Commits
andybalaam
...
langleyd/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c67cec3f56 | ||
|
|
8b1d0f5aff | ||
|
|
57f5832a63 | ||
|
|
c6b1a09b55 |
@@ -86,7 +86,7 @@
|
||||
"@fontsource/inter": "^5",
|
||||
"@formatjs/intl-segmenter": "^11.5.7",
|
||||
"@matrix-org/analytics-events": "^0.29.2",
|
||||
"@matrix-org/emojibase-bindings": "^1.3.4",
|
||||
"@matrix-org/emojibase-bindings": "^1.4.0",
|
||||
"@matrix-org/react-sdk-module-api": "^2.4.0",
|
||||
"@matrix-org/spec": "^1.7.0",
|
||||
"@sentry/browser": "^9.0.0",
|
||||
@@ -109,7 +109,7 @@
|
||||
"diff-dom": "^5.0.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"domutils": "^3.2.2",
|
||||
"emojibase-regex": "15.3.2",
|
||||
"emojibase-regex": "16.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"filesize": "10.1.6",
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { createBot, logIntoElement } from "./utils.ts";
|
||||
import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts";
|
||||
import { type Client } from "../../pages/client.ts";
|
||||
import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
|
||||
|
||||
@@ -35,20 +35,14 @@ test.describe("Dehydration", () => {
|
||||
|
||||
await app.closeDialog();
|
||||
|
||||
// Reset the key
|
||||
// Verify the device by resetting the key
|
||||
const settings = await app.settings.openUserSettings("Encryption");
|
||||
await settings.getByRole("button", { name: "Verify this device" }).click();
|
||||
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Set up recovery
|
||||
await page.getByRole("button", { name: "Set up recovery" }).click();
|
||||
await page.getByRole("button", { name: "Copy" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
const recoveryKey = await page.getByTestId("recoveryKey").innerText();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("textbox").fill(recoveryKey);
|
||||
await page.getByRole("button", { name: "Finish set up" }).click();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.getByRole("button", { name: "Done" }).click();
|
||||
|
||||
await expectDehydratedDeviceEnabled(app);
|
||||
|
||||
@@ -86,7 +80,7 @@ test.describe("Dehydration", () => {
|
||||
await expectDehydratedDeviceEnabled(app);
|
||||
});
|
||||
|
||||
test("Reset identity during login and set up recovery re-creates dehydrated device", async ({
|
||||
test("Reset recovery key during login re-creates dehydrated device", async ({
|
||||
page,
|
||||
homeserver,
|
||||
app,
|
||||
@@ -105,26 +99,16 @@ test.describe("Dehydration", () => {
|
||||
// Log in our client
|
||||
await logIntoElement(page, credentials);
|
||||
|
||||
// Oh no, we forgot our recovery key - reset our identity
|
||||
// Oh no, we forgot our recovery key
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Are you sure you want to reset your identity?" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await page.getByPlaceholder("Password").fill(credentials.password);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Proceed with reset" }).click();
|
||||
|
||||
// And set up recovery
|
||||
const settings = await app.settings.openUserSettings("Encryption");
|
||||
await settings.getByRole("button", { name: "Set up recovery" }).click();
|
||||
await settings.getByRole("button", { name: "Continue" }).click();
|
||||
const recoveryKey = await settings.getByTestId("recoveryKey").innerText();
|
||||
await settings.getByRole("button", { name: "Continue" }).click();
|
||||
await settings.getByRole("textbox").fill(recoveryKey);
|
||||
await settings.getByRole("button", { name: "Finish set up" }).click();
|
||||
await completeCreateSecretStorageDialog(page, { accountPassword: credentials.password });
|
||||
|
||||
// There should be a brand new dehydrated device
|
||||
await expectDehydratedDeviceEnabled(app);
|
||||
const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client);
|
||||
expect(dehydratedDeviceIds.length).toBe(1);
|
||||
expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]);
|
||||
});
|
||||
|
||||
test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => {
|
||||
|
||||
@@ -288,43 +288,6 @@ test.describe("Login", () => {
|
||||
await expect(h1).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("Can reset identity to become verified", async ({ page, homeserver, request, credentials }) => {
|
||||
// Log in
|
||||
const res = await request.post(`${homeserver.baseUrl}/_matrix/client/v3/keys/device_signing/upload`, {
|
||||
headers: { Authorization: `Bearer ${credentials.accessToken}` },
|
||||
data: DEVICE_SIGNING_KEYS_BODY,
|
||||
});
|
||||
if (res.status() / 100 !== 2) {
|
||||
console.log("Uploading dummy keys failed", await res.json());
|
||||
}
|
||||
expect(res.status() / 100).toEqual(2);
|
||||
|
||||
await page.goto("/");
|
||||
await login(page, homeserver, credentials);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
|
||||
|
||||
// Start the reset process
|
||||
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||
|
||||
// First try cancelling and restarting
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||
|
||||
// Then click outside the dialog and restart
|
||||
await page.getByRole("link", { name: "Powered by Matrix" }).click({ force: true });
|
||||
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||
|
||||
// Finally we actually continue
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByPlaceholder("Password").fill(credentials.password);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// We end up at the Home screen
|
||||
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
|
||||
await expect(page.getByRole("heading", { name: "Welcome Dave", exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,162 +19,126 @@ import {
|
||||
test.describe("Encryption tab", () => {
|
||||
test.use({ displayName: "Alice" });
|
||||
|
||||
test.describe("when encryption is set up", () => {
|
||||
let recoveryKey: GeneratedSecretStorageKey;
|
||||
let expectedBackupVersion: string;
|
||||
let recoveryKey: GeneratedSecretStorageKey;
|
||||
let expectedBackupVersion: string;
|
||||
|
||||
test.beforeEach(async ({ page, homeserver, credentials }) => {
|
||||
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
|
||||
const res = await createBot(page, homeserver, credentials);
|
||||
recoveryKey = res.recoveryKey;
|
||||
expectedBackupVersion = res.expectedBackupVersion;
|
||||
});
|
||||
test.beforeEach(async ({ page, homeserver, credentials }) => {
|
||||
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
|
||||
const res = await createBot(page, homeserver, credentials);
|
||||
recoveryKey = res.recoveryKey;
|
||||
expectedBackupVersion = res.expectedBackupVersion;
|
||||
});
|
||||
|
||||
test(
|
||||
"should show a 'Verify this device' button if the device is unverified",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, util }) => {
|
||||
const dialog = await util.openEncryptionTab();
|
||||
const content = util.getEncryptionTabContent();
|
||||
test(
|
||||
"should show a 'Verify this device' button if the device is unverified",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, util }) => {
|
||||
const dialog = await util.openEncryptionTab();
|
||||
const content = util.getEncryptionTabContent();
|
||||
|
||||
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
|
||||
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
|
||||
await expect(verifyButton).toBeVisible();
|
||||
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
|
||||
await verifyButton.click();
|
||||
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
|
||||
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
|
||||
await expect(verifyButton).toBeVisible();
|
||||
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
|
||||
await verifyButton.click();
|
||||
|
||||
await util.verifyDevice(recoveryKey);
|
||||
await util.verifyDevice(recoveryKey);
|
||||
|
||||
await expect(content).toMatchScreenshot("default-tab.png", {
|
||||
mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")],
|
||||
});
|
||||
await expect(content).toMatchScreenshot("default-tab.png", {
|
||||
mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")],
|
||||
});
|
||||
|
||||
// Check that our device is now cross-signed
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
// Check that our device is now cross-signed
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
},
|
||||
);
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
},
|
||||
);
|
||||
|
||||
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
|
||||
//
|
||||
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
|
||||
// We simulate this case by deleting the cached secrets in the indexedDB.
|
||||
test(
|
||||
"should prompt to enter the recovery key when the secrets are not cached locally",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, util }) => {
|
||||
await verifySession(app, recoveryKey.encodedPrivateKey);
|
||||
// We need to delete the cached secrets
|
||||
await deleteCachedSecrets(page);
|
||||
|
||||
await util.openEncryptionTab();
|
||||
// We ask the user to enter the recovery key
|
||||
const dialog = util.getEncryptionTabContent();
|
||||
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
|
||||
await expect(enterKeyButton).toBeVisible();
|
||||
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
|
||||
await enterKeyButton.click();
|
||||
|
||||
// Fill the recovery key
|
||||
await util.enterRecoveryKey(recoveryKey);
|
||||
await expect(dialog).toMatchScreenshot("default-tab.png", {
|
||||
mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")],
|
||||
});
|
||||
|
||||
// Check that our device is now cross-signed
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
},
|
||||
);
|
||||
|
||||
test("should display the reset identity panel when the user clicks on 'Forgot recovery key?'", async ({
|
||||
page,
|
||||
app,
|
||||
util,
|
||||
}) => {
|
||||
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
|
||||
//
|
||||
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
|
||||
// We simulate this case by deleting the cached secrets in the indexedDB.
|
||||
test(
|
||||
"should prompt to enter the recovery key when the secrets are not cached locally",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, util }) => {
|
||||
await verifySession(app, recoveryKey.encodedPrivateKey);
|
||||
// We need to delete the cached secrets
|
||||
await deleteCachedSecrets(page);
|
||||
|
||||
// The "Key storage is out sync" section is displayed and the user click on the "Forgot recovery key?" button
|
||||
await util.openEncryptionTab();
|
||||
// We ask the user to enter the recovery key
|
||||
const dialog = util.getEncryptionTabContent();
|
||||
await dialog.getByRole("button", { name: "Forgot recovery key?" }).click();
|
||||
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
|
||||
await expect(enterKeyButton).toBeVisible();
|
||||
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
|
||||
await enterKeyButton.click();
|
||||
|
||||
// The user is prompted to reset their identity
|
||||
await expect(
|
||||
dialog.getByText("Forgot your recovery key? You’ll need to reset your identity."),
|
||||
).toBeVisible();
|
||||
});
|
||||
// Fill the recovery key
|
||||
await util.enterRecoveryKey(recoveryKey);
|
||||
await expect(dialog).toMatchScreenshot("default-tab.png", {
|
||||
mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")],
|
||||
});
|
||||
|
||||
test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => {
|
||||
await verifySession(app, recoveryKey.encodedPrivateKey);
|
||||
await util.openEncryptionTab();
|
||||
// Check that our device is now cross-signed
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
|
||||
await page.getByRole("checkbox", { name: "Allow key storage" }).click();
|
||||
// Check that the current device is connected to key backup
|
||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
|
||||
).toBeVisible();
|
||||
test("should display the reset identity panel when the user clicks on 'Forgot recovery key?'", async ({
|
||||
page,
|
||||
app,
|
||||
util,
|
||||
}) => {
|
||||
await verifySession(app, recoveryKey.encodedPrivateKey);
|
||||
// We need to delete the cached secrets
|
||||
await deleteCachedSecrets(page);
|
||||
|
||||
await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png");
|
||||
// The "Key storage is out sync" section is displayed and the user click on the "Forgot recovery key?" button
|
||||
await util.openEncryptionTab();
|
||||
const dialog = util.getEncryptionTabContent();
|
||||
await dialog.getByRole("button", { name: "Forgot recovery key?" }).click();
|
||||
|
||||
const deleteRequestPromises = [
|
||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")),
|
||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")),
|
||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")),
|
||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")),
|
||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")),
|
||||
page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")),
|
||||
];
|
||||
|
||||
await page.getByRole("button", { name: "Delete key storage" }).click();
|
||||
|
||||
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
|
||||
|
||||
for (const prom of deleteRequestPromises) {
|
||||
const request = await prom;
|
||||
expect(request.method()).toBe("PUT");
|
||||
expect(request.postData()).toBe(JSON.stringify({}));
|
||||
}
|
||||
});
|
||||
// The user is prompted to reset their identity
|
||||
await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("when encryption is not set up", () => {
|
||||
test("'Verify this device' allows us to become verified", async ({
|
||||
page,
|
||||
user,
|
||||
credentials,
|
||||
app,
|
||||
}, workerInfo) => {
|
||||
const settings = await app.settings.openUserSettings("Encryption");
|
||||
test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => {
|
||||
await verifySession(app, recoveryKey.encodedPrivateKey);
|
||||
await util.openEncryptionTab();
|
||||
|
||||
// Initially, our device is not verified
|
||||
await expect(settings.getByRole("heading", { name: "Device not verified" })).toBeVisible();
|
||||
await page.getByRole("checkbox", { name: "Allow key storage" }).click();
|
||||
|
||||
// We will reset our identity
|
||||
await settings.getByRole("button", { name: "Verify this device" }).click();
|
||||
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
|
||||
).toBeVisible();
|
||||
|
||||
// First try cancelling and restarting
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||
await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png");
|
||||
|
||||
// Then click outside the dialog and restart
|
||||
await page.locator("li").filter({ hasText: "Encryption" }).click({ force: true });
|
||||
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||
const deleteRequestPromises = [
|
||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")),
|
||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")),
|
||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")),
|
||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")),
|
||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")),
|
||||
page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")),
|
||||
];
|
||||
|
||||
// Finally we actually continue
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: "Delete key storage" }).click();
|
||||
|
||||
// Now we are verified, so we see the Key storage toggle
|
||||
await expect(settings.getByRole("heading", { name: "Key storage" })).toBeVisible();
|
||||
});
|
||||
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
|
||||
|
||||
for (const prom of deleteRequestPromises) {
|
||||
const request = await prom;
|
||||
expect(request.method()).toBe("PUT");
|
||||
expect(request.postData()).toBe(JSON.stringify({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
@@ -147,12 +147,11 @@ export function avatarUrlForRoom(
|
||||
width?: number,
|
||||
height?: number,
|
||||
resizeMethod?: ResizeMethod,
|
||||
avatarMxcOverride?: string,
|
||||
): string | null {
|
||||
if (!room) return null; // null-guard
|
||||
const mxc = avatarMxcOverride ?? room.getMxcAvatarUrl();
|
||||
if (mxc) {
|
||||
const media = mediaFromMxc(mxc);
|
||||
|
||||
if (room.getMxcAvatarUrl()) {
|
||||
const media = mediaFromMxc(room.getMxcAvatarUrl() ?? undefined);
|
||||
if (width !== undefined && height !== undefined) {
|
||||
return media.getThumbnailOfSourceHttp(width, height, resizeMethod);
|
||||
}
|
||||
|
||||
@@ -75,9 +75,12 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
|
||||
} else if (phase === Phase.ConfirmSkip) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("common|are_you_sure");
|
||||
} else if (phase === Phase.Busy || phase === Phase.ConfirmReset) {
|
||||
} else if (phase === Phase.Busy) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("encryption|verification|after_new_login|verify_this_device");
|
||||
} else if (phase === Phase.ConfirmReset) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("encryption|verification|after_new_login|reset_confirmation");
|
||||
} else if (phase === Phase.Finished) {
|
||||
// SetupEncryptionBody will take care of calling onFinished, we don't need to do anything
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
@@ -19,7 +19,6 @@ import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStor
|
||||
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
|
||||
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
import { ResetIdentityDialog } from "../../views/dialogs/ResetIdentityDialog";
|
||||
|
||||
function keyHasPassphrase(keyInfo: SecretStorageKeyDescription): boolean {
|
||||
return Boolean(keyInfo.passphrase && keyInfo.passphrase.salt && keyInfo.passphrase.iterations);
|
||||
@@ -115,18 +114,12 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
||||
ev.preventDefault();
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.reset();
|
||||
Modal.createDialog(ResetIdentityDialog, {
|
||||
onReset: () => {
|
||||
// The user completed the reset process - close this dialog
|
||||
this.props.onFinished();
|
||||
this.onDoneClick();
|
||||
},
|
||||
onFinished: () => {
|
||||
// The user cancelled the reset dialog or click away - go back a step
|
||||
this.onResetBackClick();
|
||||
},
|
||||
variant: "confirm",
|
||||
});
|
||||
};
|
||||
|
||||
private onResetConfirmClick = (): void => {
|
||||
this.props.onFinished();
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.resetConfirm();
|
||||
};
|
||||
|
||||
private onResetBackClick = (): void => {
|
||||
@@ -164,7 +157,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
||||
<p>{_t("encryption|verification|no_key_or_device")}</p>
|
||||
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton kind="primary" onClick={this.onResetClick}>
|
||||
<AccessibleButton kind="primary" onClick={this.onResetConfirmClick}>
|
||||
{_t("encryption|verification|reset_proceed_prompt")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
@@ -253,7 +246,23 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (phase === Phase.Busy || phase === Phase.Loading || phase == Phase.ConfirmReset) {
|
||||
} else if (phase === Phase.ConfirmReset) {
|
||||
return (
|
||||
<div>
|
||||
<p>{_t("encryption|verification|verify_reset_warning_1")}</p>
|
||||
<p>{_t("encryption|verification|verify_reset_warning_2")}</p>
|
||||
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton kind="danger_outline" onClick={this.onResetConfirmClick}>
|
||||
{_t("encryption|verification|reset_proceed_prompt")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary" onClick={this.onResetBackClick}>
|
||||
{_t("action|go_back")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (phase === Phase.Busy || phase === Phase.Loading) {
|
||||
return <Spinner />;
|
||||
} else {
|
||||
logger.log(`SetupEncryptionBody: Unknown phase ${phase}`);
|
||||
|
||||
@@ -21,6 +21,11 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useStickyRoomList } from "./useStickyRoomList";
|
||||
|
||||
export interface RoomListViewState {
|
||||
/**
|
||||
* Whether the list of rooms is being loaded.
|
||||
*/
|
||||
isLoadingRooms: boolean;
|
||||
|
||||
/**
|
||||
* A list of rooms to be displayed in the left panel.
|
||||
*/
|
||||
@@ -98,6 +103,7 @@ export interface RoomListViewState {
|
||||
export function useRoomListViewModel(): RoomListViewState {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const {
|
||||
isLoadingRooms,
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
rooms: filteredRooms,
|
||||
@@ -120,6 +126,7 @@ export function useRoomListViewModel(): RoomListViewState {
|
||||
const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]);
|
||||
|
||||
return {
|
||||
isLoadingRooms,
|
||||
rooms,
|
||||
canCreateRoom,
|
||||
createRoom,
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface PrimaryFilter {
|
||||
|
||||
interface FilteredRooms {
|
||||
primaryFilters: PrimaryFilter[];
|
||||
isLoadingRooms: boolean;
|
||||
rooms: Room[];
|
||||
activateSecondaryFilter: (filter: SecondaryFilters) => void;
|
||||
activeSecondaryFilter: SecondaryFilters;
|
||||
@@ -115,6 +116,7 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
);
|
||||
|
||||
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
|
||||
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
||||
|
||||
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
|
||||
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
|
||||
@@ -135,6 +137,7 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
};
|
||||
|
||||
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
|
||||
setIsLoadingRooms(false);
|
||||
const filters = getAppliedFilters();
|
||||
updateRoomsFromStore(filters);
|
||||
});
|
||||
@@ -194,5 +197,12 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
|
||||
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]);
|
||||
|
||||
return { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter };
|
||||
return {
|
||||
isLoadingRooms,
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
rooms,
|
||||
activateSecondaryFilter,
|
||||
activeSecondaryFilter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,91 +6,156 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, type ComponentProps } from "react";
|
||||
import { type Room, RoomType, KnownMembership, EventType } from "matrix-js-sdk/src/matrix";
|
||||
import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types";
|
||||
import React, { type ComponentProps } from "react";
|
||||
import {
|
||||
type Room,
|
||||
RoomStateEvent,
|
||||
type MatrixEvent,
|
||||
EventType,
|
||||
RoomType,
|
||||
KnownMembership,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import BaseAvatar from "./BaseAvatar";
|
||||
import ImageView from "../elements/ImageView";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import Modal from "../../../Modal";
|
||||
import * as Avatar from "../../../Avatar";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import { type IOOBData } from "../../../stores/ThreepidInviteStore";
|
||||
import { LocalRoom } from "../../../models/LocalRoom";
|
||||
import { filterBoolean } from "../../../utils/arrays";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
|
||||
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
|
||||
// Room may be left unset here, but if it is,
|
||||
// oobData.avatarUrl should be set (else there
|
||||
// would be nowhere to get the avatar from)
|
||||
room?: Room;
|
||||
// Optional here.
|
||||
size?: ComponentProps<typeof BaseAvatar>["size"];
|
||||
oobData?: IOOBData & {
|
||||
oobData: IOOBData & {
|
||||
roomId?: string;
|
||||
};
|
||||
viewAvatarOnClick?: boolean;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => {
|
||||
const roomName = room?.name ?? oobData?.name ?? "?";
|
||||
const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, ""));
|
||||
const roomIdName = useRoomIdName(room, oobData);
|
||||
interface IState {
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
const showAvatarsOnInvites = useSettingValue("showAvatarsOnInvites", room?.roomId);
|
||||
export function idNameForRoom(room: Room): string {
|
||||
const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
// If the room is a DM, we use the other user's ID for the color hash
|
||||
// in order to match the room avatar with their avatar
|
||||
if (dmMapUserId) return dmMapUserId;
|
||||
|
||||
const onRoomAvatarClick = useCallback(() => {
|
||||
const avatarUrl = Avatar.avatarUrlForRoom(room ?? null);
|
||||
if (!avatarUrl) return;
|
||||
const params = {
|
||||
src: avatarUrl,
|
||||
name: room?.name,
|
||||
if (room instanceof LocalRoom && room.targets.length === 1) {
|
||||
return room.targets[0].userId;
|
||||
}
|
||||
|
||||
return room.roomId;
|
||||
}
|
||||
|
||||
export default class RoomAvatar extends React.Component<IProps, IState> {
|
||||
public static defaultProps = {
|
||||
size: "36px",
|
||||
oobData: {},
|
||||
};
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
urls: RoomAvatar.getImageUrls(this.props),
|
||||
};
|
||||
}
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
}, [room]);
|
||||
public componentDidMount(): void {
|
||||
MatrixClientPeg.safeGet().on(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
}
|
||||
|
||||
const urls = useMemo(() => {
|
||||
const myMembership = room?.getMyMembership();
|
||||
if (!showAvatarsOnInvites && (myMembership === KnownMembership.Invite || !myMembership)) {
|
||||
// The user has opted out of showing avatars, so return no urls here.
|
||||
return [];
|
||||
public componentWillUnmount(): void {
|
||||
MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
}
|
||||
|
||||
public static getDerivedStateFromProps(nextProps: IProps): IState {
|
||||
return {
|
||||
urls: RoomAvatar.getImageUrls(nextProps),
|
||||
};
|
||||
}
|
||||
|
||||
private onRoomStateEvents = (ev: MatrixEvent): void => {
|
||||
if (ev.getRoomId() !== this.props.room?.roomId || ev.getType() !== EventType.RoomAvatar) return;
|
||||
|
||||
this.setState({
|
||||
urls: RoomAvatar.getImageUrls(this.props),
|
||||
});
|
||||
};
|
||||
|
||||
private static getImageUrls(props: IProps): string[] {
|
||||
const myMembership = props.room?.getMyMembership();
|
||||
if (myMembership === KnownMembership.Invite || !myMembership) {
|
||||
if (SettingsStore.getValue("showAvatarsOnInvites") === false) {
|
||||
// The user has opted out of showing avatars, so return no urls here.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// parseInt ignores suffixes.
|
||||
const sizeInt = parseInt(size, 10);
|
||||
let oobAvatar: string | null = null;
|
||||
|
||||
if (oobData?.avatarUrl) {
|
||||
oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop");
|
||||
if (props.oobData.avatarUrl) {
|
||||
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(
|
||||
parseInt(props.size, 10),
|
||||
parseInt(props.size, 10),
|
||||
"crop",
|
||||
);
|
||||
}
|
||||
|
||||
return filterBoolean([
|
||||
oobAvatar, // highest priority
|
||||
Avatar.avatarUrlForRoom(
|
||||
room ?? null,
|
||||
sizeInt,
|
||||
sizeInt,
|
||||
"crop",
|
||||
avatarEvent?.getContent<RoomAvatarEventContent>().url,
|
||||
),
|
||||
RoomAvatar.getRoomAvatarUrl(props),
|
||||
]);
|
||||
}, [showAvatarsOnInvites, room, size, avatarEvent, oobData]);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseAvatar
|
||||
{...otherProps}
|
||||
size={size}
|
||||
type={(room?.getType() ?? oobData?.roomType) === RoomType.Space ? "square" : "round"}
|
||||
name={roomName}
|
||||
idName={roomIdName}
|
||||
urls={urls}
|
||||
onClick={viewAvatarOnClick && urls[0] ? onRoomAvatarClick : onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
private static getRoomAvatarUrl(props: IProps): string | null {
|
||||
if (!props.room) return null;
|
||||
|
||||
export default RoomAvatar;
|
||||
return Avatar.avatarUrlForRoom(props.room, parseInt(props.size, 10), parseInt(props.size, 10), "crop");
|
||||
}
|
||||
|
||||
private onRoomAvatarClick = (): void => {
|
||||
const avatarUrl = Avatar.avatarUrlForRoom(this.props.room ?? null, undefined, undefined, undefined);
|
||||
if (!avatarUrl) return;
|
||||
const params = {
|
||||
src: avatarUrl,
|
||||
name: this.props.room?.name,
|
||||
};
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
};
|
||||
|
||||
private get roomIdName(): string | undefined {
|
||||
const room = this.props.room;
|
||||
|
||||
if (room) {
|
||||
return idNameForRoom(room);
|
||||
} else {
|
||||
return this.props.oobData?.roomId;
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
|
||||
const roomName = room?.name ?? oobData.name ?? "?";
|
||||
|
||||
return (
|
||||
<BaseAvatar
|
||||
{...otherProps}
|
||||
type={(room?.getType() ?? this.props.oobData?.roomType) === RoomType.Space ? "square" : "round"}
|
||||
name={roomName}
|
||||
idName={this.roomIdName}
|
||||
urls={this.state.urls}
|
||||
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,14 +52,14 @@ function getDmMember(room: Room): RoomMember | null {
|
||||
return otherUserId ? room.getMember(otherUserId) : null;
|
||||
}
|
||||
|
||||
export const useDmMember = (room?: Room): RoomMember | null => {
|
||||
const [dmMember, setDmMember] = useState<RoomMember | null>(room ? getDmMember(room) : null);
|
||||
export const useDmMember = (room: Room): RoomMember | null => {
|
||||
const [dmMember, setDmMember] = useState<RoomMember | null>(getDmMember(room));
|
||||
const updateDmMember = (): void => {
|
||||
setDmMember(room ? getDmMember(room) : null);
|
||||
setDmMember(getDmMember(room));
|
||||
};
|
||||
|
||||
useEventEmitter(room?.currentState, RoomStateEvent.Members, updateDmMember);
|
||||
useEventEmitter(room?.client, ClientEvent.AccountData, updateDmMember);
|
||||
useEventEmitter(room.currentState, RoomStateEvent.Members, updateDmMember);
|
||||
useEventEmitter(room.client, ClientEvent.AccountData, updateDmMember);
|
||||
useEffect(updateDmMember, [room]);
|
||||
|
||||
return dmMember;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type MouseEventHandler } from "react";
|
||||
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { ResetIdentityBody, type ResetIdentityBodyVariant } from "../settings/encryption/ResetIdentityBody";
|
||||
|
||||
interface ResetIdentityDialogProps {
|
||||
/**
|
||||
* Called when the dialog closes.
|
||||
*/
|
||||
onFinished: () => void;
|
||||
|
||||
/**
|
||||
* Called when the identity is reset (before onFinished is called).
|
||||
*/
|
||||
onReset: MouseEventHandler<HTMLButtonElement>;
|
||||
|
||||
/**
|
||||
* Which variant of this dialog to show.
|
||||
*/
|
||||
variant: ResetIdentityBodyVariant;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog for resetting the identity of the current user.
|
||||
*/
|
||||
export function ResetIdentityDialog({ onFinished, onReset, variant }: ResetIdentityDialogProps): JSX.Element {
|
||||
const matrixClient = MatrixClientPeg.safeGet();
|
||||
|
||||
// Wrappers for ResetIdentityBody's callbacks so that onFinish gets called
|
||||
// whenever the reset is done, whether by completing successfully, or by
|
||||
// being cancelled
|
||||
const onResetWrapper: MouseEventHandler<HTMLButtonElement> = (...args) => {
|
||||
onReset(...args);
|
||||
onFinished();
|
||||
};
|
||||
return (
|
||||
<MatrixClientContext.Provider value={matrixClient}>
|
||||
<ResetIdentityBody onReset={onResetWrapper} onCancelClick={onFinished} variant={variant} />
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export default class RoomAvatarEvent extends React.Component<IProps> {
|
||||
className="mx_RoomAvatarEvent_avatar"
|
||||
onClick={this.onAvatarClick}
|
||||
>
|
||||
<RoomAvatar room={room ?? undefined} size="14px" oobData={oobData} />
|
||||
<RoomAvatar size="14px" oobData={oobData} />
|
||||
</AccessibleButton>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { createRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import { ContentHelpers, EventType, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { ContentHelpers, EventType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
@@ -15,8 +15,7 @@ import Field from "../elements/Field";
|
||||
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import AvatarSetting from "../settings/AvatarSetting";
|
||||
import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { LocalRoom } from "../../../models/LocalRoom";
|
||||
import { idNameForRoom } from "../avatars/RoomAvatar";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
@@ -37,19 +36,6 @@ interface IState {
|
||||
canSetAvatar: boolean;
|
||||
}
|
||||
|
||||
function idNameForRoom(room: Room): string {
|
||||
const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
// If the room is a DM, we use the other user's ID for the color hash
|
||||
// in order to match the room avatar with their avatar
|
||||
if (dmMapUserId) return dmMapUserId;
|
||||
|
||||
if (room instanceof LocalRoom && room.targets.length === 1) {
|
||||
return room.targets[0].userId;
|
||||
}
|
||||
|
||||
return room.roomId;
|
||||
}
|
||||
|
||||
// TODO: Merge with ProfileSettings?
|
||||
export default class RoomProfileSettings extends React.Component<IProps, IState> {
|
||||
private avatarUpload = createRef<HTMLInputElement>();
|
||||
|
||||
@@ -52,7 +52,6 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
||||
Icon={InviteIcon}
|
||||
disabled={disabled}
|
||||
aria-label={_t("action|invite")}
|
||||
type="button"
|
||||
/>
|
||||
</OptionalTooltip>
|
||||
);
|
||||
@@ -68,7 +67,6 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
||||
className="mx_MemberListHeaderView_invite_large"
|
||||
disabled={!vm.canInvite}
|
||||
onClick={vm.onInviteButtonClick}
|
||||
type="button"
|
||||
>
|
||||
{_t("action|invite")}
|
||||
</Button>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewM
|
||||
import { RoomList } from "./RoomList";
|
||||
import { EmptyRoomList } from "./EmptyRoomList";
|
||||
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
|
||||
import Spinner from "../../elements/Spinner";
|
||||
|
||||
/**
|
||||
* Host the room list and the (future) room filters
|
||||
@@ -18,11 +19,18 @@ import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
|
||||
export function RoomListView(): JSX.Element {
|
||||
const vm = useRoomListViewModel();
|
||||
const isRoomListEmpty = vm.rooms.length === 0;
|
||||
|
||||
let listBody;
|
||||
if (vm.isLoadingRooms) {
|
||||
listBody = <Spinner />;
|
||||
} else if (isRoomListEmpty) {
|
||||
listBody = <EmptyRoomList vm={vm} />;
|
||||
} else {
|
||||
listBody = <RoomList vm={vm} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<RoomListPrimaryFilters vm={vm} />
|
||||
{isRoomListEmpty ? <EmptyRoomList vm={vm} /> : <RoomList vm={vm} />}
|
||||
{listBody}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@ interface ResetIdentityBodyProps {
|
||||
/**
|
||||
* Called when the identity is reset.
|
||||
*/
|
||||
onReset: MouseEventHandler<HTMLButtonElement>;
|
||||
|
||||
onFinish: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Called when the cancel button is clicked.
|
||||
*/
|
||||
@@ -37,24 +36,22 @@ interface ResetIdentityBodyProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* "compromised" is shown when the user chose 'Reset cryptographic identity' explicitly in settings, usually because
|
||||
* they believe their identity has been compromised.
|
||||
* "compromised" is shown when the user chooses 'reset' explicitly in settings, usually because they believe their
|
||||
* identity has been compromised.
|
||||
*
|
||||
* "sync_failed" is shown when the user tried to recover their identity but the process failed, probably because
|
||||
* the required information is missing from recovery.
|
||||
*
|
||||
* "forgot" is shown when the user chose 'Forgot recovery key?' during `SetupEncryptionToast`.
|
||||
*
|
||||
* "confirm" is shown when the user chose 'Reset all' during `SetupEncryptionBody`.
|
||||
* "forgot" is shown when the user has just forgotten their passphrase.
|
||||
*/
|
||||
export type ResetIdentityBodyVariant = "compromised" | "forgot" | "sync_failed" | "confirm";
|
||||
export type ResetIdentityBodyVariant = "compromised" | "forgot" | "sync_failed";
|
||||
|
||||
/**
|
||||
* User interface component allowing the user to reset their cryptographic identity.
|
||||
*
|
||||
* Used by {@link ResetIdentityPanel}.
|
||||
*/
|
||||
export function ResetIdentityBody({ onCancelClick, onReset, variant }: ResetIdentityBodyProps): JSX.Element {
|
||||
export function ResetIdentityBody({ onCancelClick, onFinish, variant }: ResetIdentityBodyProps): JSX.Element {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
// After the user clicks "Continue", we disable the button so it can't be
|
||||
@@ -86,7 +83,7 @@ export function ResetIdentityBody({ onCancelClick, onReset, variant }: ResetIden
|
||||
await matrixClient
|
||||
.getCrypto()
|
||||
?.resetEncryption((makeRequest) => uiAuthCallback(matrixClient, makeRequest));
|
||||
onReset(evt);
|
||||
onFinish(evt);
|
||||
}}
|
||||
>
|
||||
{inProgress ? (
|
||||
@@ -116,10 +113,11 @@ export function ResetIdentityBody({ onCancelClick, onReset, variant }: ResetIden
|
||||
function titleForVariant(variant: ResetIdentityBodyVariant): string {
|
||||
switch (variant) {
|
||||
case "compromised":
|
||||
case "confirm":
|
||||
return _t("settings|encryption|advanced|breadcrumb_title");
|
||||
case "sync_failed":
|
||||
return _t("settings|encryption|advanced|breadcrumb_title_sync_failed");
|
||||
|
||||
default:
|
||||
case "forgot":
|
||||
return _t("settings|encryption|advanced|breadcrumb_title_forgot");
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ interface ResetIdentityPanelProps {
|
||||
/**
|
||||
* Called when the identity is reset.
|
||||
*/
|
||||
onReset: MouseEventHandler<HTMLButtonElement>;
|
||||
|
||||
onFinish: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
|
||||
*/
|
||||
@@ -33,7 +32,7 @@ interface ResetIdentityPanelProps {
|
||||
*
|
||||
* A thin wrapper around {@link ResetIdentityBody}, just adding breadcrumbs.
|
||||
*/
|
||||
export function ResetIdentityPanel({ onCancelClick, onReset, variant }: ResetIdentityPanelProps): JSX.Element {
|
||||
export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetIdentityPanelProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb
|
||||
@@ -42,7 +41,7 @@ export function ResetIdentityPanel({ onCancelClick, onReset, variant }: ResetIde
|
||||
pages={[_t("settings|encryption|title"), _t("settings|encryption|advanced|breadcrumb_page")]}
|
||||
onPageClick={onCancelClick}
|
||||
/>
|
||||
<ResetIdentityBody onReset={onReset} onCancelClick={onCancelClick} variant={variant} />
|
||||
<ResetIdentityBody onFinish={onFinish} onCancelClick={onCancelClick} variant={variant} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Props):
|
||||
<ResetIdentityPanel
|
||||
variant={findResetVariant(state)}
|
||||
onCancelClick={checkEncryptionState}
|
||||
onReset={checkEncryptionState}
|
||||
onFinish={checkEncryptionState}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useDmMember } from "../../components/views/avatars/WithPresenceIndicator.tsx";
|
||||
import { LocalRoom } from "../../models/LocalRoom.ts";
|
||||
|
||||
/**
|
||||
* Determine a stable ID for generating hash colours. If the room
|
||||
* is a DM (or local room), then the other user's ID will be used.
|
||||
* @param oobData - out-of-band information about the room
|
||||
* @returns An ID string, or undefined if the room and oobData are undefined.
|
||||
*/
|
||||
export function useRoomIdName(room?: Room, oobData?: { roomId?: string }): string | undefined {
|
||||
const dmMember = useDmMember(room);
|
||||
if (dmMember) {
|
||||
// If the room is a DM, we use the other user's ID for the color hash
|
||||
// in order to match the room avatar with their avatar
|
||||
return dmMember.userId;
|
||||
} else if (room instanceof LocalRoom && room.targets.length === 1) {
|
||||
return room.targets[0].userId;
|
||||
} else if (room) {
|
||||
return room.roomId;
|
||||
} else {
|
||||
return oobData?.roomId;
|
||||
}
|
||||
}
|
||||
@@ -992,6 +992,7 @@
|
||||
"accepting": "Accepting…",
|
||||
"after_new_login": {
|
||||
"device_verified": "Device verified",
|
||||
"reset_confirmation": "Really reset verification keys?",
|
||||
"skip_verification": "Skip verification for now",
|
||||
"unable_to_verify": "Unable to verify this device",
|
||||
"verify_this_device": "Verify this device"
|
||||
@@ -1062,6 +1063,8 @@
|
||||
"verify_emoji_prompt": "Verify by comparing unique emoji.",
|
||||
"verify_emoji_prompt_qr": "If you can't scan the code above, verify by comparing unique emoji.",
|
||||
"verify_later": "I'll verify later",
|
||||
"verify_reset_warning_1": "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.",
|
||||
"verify_reset_warning_2": "Please only proceed if you're sure you've lost all of your other devices and your Recovery Key.",
|
||||
"verify_using_device": "Verify with another device",
|
||||
"verify_using_key": "Verify with Recovery Key",
|
||||
"verify_using_key_or_phrase": "Verify with Recovery Key or Phrase",
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2018-2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { act } from "react";
|
||||
import { render } from "jest-matrix-react";
|
||||
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||
import { type Mocked } from "jest-mock";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { getMockClientWithEventEmitter } from "../../../../test-utils";
|
||||
import { ResetIdentityDialog } from "../../../../../src/components/views/dialogs/ResetIdentityDialog";
|
||||
|
||||
describe("ResetIdentityDialog", () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should call onReset and onFinished when we click Continue", async () => {
|
||||
const client = mockClient();
|
||||
|
||||
const onFinished = jest.fn();
|
||||
const onReset = jest.fn();
|
||||
const dialog = render(<ResetIdentityDialog onFinished={onFinished} onReset={onReset} variant="compromised" />);
|
||||
|
||||
await act(async () => dialog.getByRole("button", { name: "Continue" }).click());
|
||||
|
||||
expect(onReset).toHaveBeenCalled();
|
||||
expect(onFinished).toHaveBeenCalled();
|
||||
expect(client.getCrypto()?.resetEncryption).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should just call onFinished when we click Cancel", async () => {
|
||||
const client = mockClient();
|
||||
|
||||
const onFinished = jest.fn();
|
||||
const onReset = jest.fn();
|
||||
const dialog = render(<ResetIdentityDialog onFinished={onFinished} onReset={onReset} variant="compromised" />);
|
||||
|
||||
await act(async () => dialog.getByRole("button", { name: "Cancel" }).click());
|
||||
|
||||
expect(onFinished).toHaveBeenCalled();
|
||||
|
||||
expect(onReset).not.toHaveBeenCalled();
|
||||
expect(client.getCrypto()?.resetEncryption).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function mockClient(): Mocked<MatrixClient> {
|
||||
const mockCrypto = {
|
||||
resetEncryption: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as Mocked<CryptoApi>;
|
||||
|
||||
return getMockClientWithEventEmitter({
|
||||
getCrypto: jest.fn().mockReturnValue(mockCrypto),
|
||||
});
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2018-2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { act } from "react";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import { type Mocked } from "jest-mock";
|
||||
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import SetupEncryptionDialog from "../../../../../src/components/views/dialogs/security/SetupEncryptionDialog";
|
||||
import { getMockClientWithEventEmitter } from "../../../../test-utils";
|
||||
import { Phase, SetupEncryptionStore } from "../../../../../src/stores/SetupEncryptionStore";
|
||||
import Modal from "../../../../../src/Modal";
|
||||
|
||||
describe("SetupEncryptionDialog", () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should launch a dialog when I say Proceed, then be finished when I reset", async () => {
|
||||
mockClient();
|
||||
const store = new SetupEncryptionStore();
|
||||
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
|
||||
|
||||
// Given when you open the reset dialog we immediately reset
|
||||
jest.spyOn(Modal, "createDialog").mockImplementation((_, props) => {
|
||||
// Simulate doing the reset in the dialog
|
||||
props?.onReset();
|
||||
|
||||
return {
|
||||
close: jest.fn(),
|
||||
finished: Promise.resolve([]),
|
||||
};
|
||||
});
|
||||
|
||||
// When we launch the dialog and set it ready to start
|
||||
const onFinished = jest.fn();
|
||||
render(<SetupEncryptionDialog onFinished={onFinished} />);
|
||||
await act(async () => await store.fetchKeyInfo());
|
||||
expect(store.phase).toBe(Phase.Intro);
|
||||
|
||||
// And we hit the Proceed with reset button.
|
||||
// (The createDialog mock above simulates the user doing the reset)
|
||||
await act(async () => screen.getByRole("button", { name: "Proceed with reset" }).click());
|
||||
|
||||
// Then the phase has been set to Finished
|
||||
expect(store.phase).toBe(Phase.Finished);
|
||||
});
|
||||
|
||||
it("should launch a dialog when I say Proceed, then be ready when I cancel", async () => {
|
||||
mockClient();
|
||||
const store = new SetupEncryptionStore();
|
||||
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
|
||||
|
||||
// Given when you open the reset dialog we immediately reset
|
||||
jest.spyOn(Modal, "createDialog").mockImplementation((_, props) => {
|
||||
// Simulate doing the reset in the dialog
|
||||
props?.onFinished();
|
||||
|
||||
return {
|
||||
close: jest.fn(),
|
||||
finished: Promise.resolve([]),
|
||||
};
|
||||
});
|
||||
|
||||
// When we launch the dialog and set it ready to start
|
||||
const onFinished = jest.fn();
|
||||
render(<SetupEncryptionDialog onFinished={onFinished} />);
|
||||
await act(async () => await store.fetchKeyInfo());
|
||||
expect(store.phase).toBe(Phase.Intro);
|
||||
|
||||
// And we hit the Proceed with reset button.
|
||||
// (The createDialog mock above simulates the user hitting cancel)
|
||||
await act(async () => screen.getByRole("button", { name: "Proceed with reset" }).click());
|
||||
|
||||
// Then the phase has been set to Finished
|
||||
expect(store.phase).toBe(Phase.Intro);
|
||||
});
|
||||
});
|
||||
|
||||
function mockClient() {
|
||||
const mockCrypto = {
|
||||
getDeviceVerificationStatus: jest.fn().mockResolvedValue({
|
||||
crossSigningVerified: false,
|
||||
}),
|
||||
getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()),
|
||||
isCrossSigningReady: jest.fn().mockResolvedValue(true),
|
||||
isSecretStorageReady: jest.fn().mockResolvedValue(true),
|
||||
userHasCrossSigningKeys: jest.fn(),
|
||||
getActiveSessionBackupVersion: jest.fn(),
|
||||
getCrossSigningStatus: jest.fn().mockReturnValue({
|
||||
publicKeysOnDevice: true,
|
||||
privateKeysInSecretStorage: true,
|
||||
privateKeysCachedLocally: {
|
||||
masterKey: true,
|
||||
selfSigningKey: true,
|
||||
userSigningKey: true,
|
||||
},
|
||||
}),
|
||||
getSessionBackupPrivateKey: jest.fn(),
|
||||
isEncryptionEnabledInRoom: jest.fn(),
|
||||
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
|
||||
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
|
||||
} as unknown as Mocked<CryptoApi>;
|
||||
|
||||
const userId = "@user:server";
|
||||
|
||||
getMockClientWithEventEmitter({
|
||||
getCrypto: jest.fn().mockReturnValue(mockCrypto),
|
||||
getUserId: jest.fn().mockReturnValue(userId),
|
||||
secretStorage: { isStored: jest.fn().mockReturnValue({}) },
|
||||
});
|
||||
}
|
||||
@@ -7,19 +7,22 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { mocked } from "jest-mock";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import parse from "html-react-parser";
|
||||
|
||||
import { bodyToHtml, bodyToNode, formatEmojis, topicToHtml } from "../../src/HtmlUtils";
|
||||
import SettingsStore from "../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../src/settings/SettingLevel";
|
||||
import SdkConfig from "../../src/SdkConfig";
|
||||
|
||||
jest.mock("../../src/settings/SettingsStore");
|
||||
|
||||
const enableHtmlTopicFeature = () => {
|
||||
mocked(SettingsStore).getValue.mockImplementation((arg): any => {
|
||||
return arg === "feature_html_topic";
|
||||
});
|
||||
};
|
||||
|
||||
describe("topicToHtml", () => {
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
function getContent() {
|
||||
return screen.getByRole("contentinfo").children[0].innerHTML;
|
||||
}
|
||||
@@ -35,19 +38,19 @@ describe("topicToHtml", () => {
|
||||
});
|
||||
|
||||
it("converts literal HTML topic to HTML", async () => {
|
||||
SettingsStore.setValue("feature_html_topic", null, SettingLevel.DEVICE, true);
|
||||
enableHtmlTopicFeature();
|
||||
render(<div role="contentinfo">{topicToHtml("<b>pizza</b>", undefined, null, false)}</div>);
|
||||
expect(getContent()).toEqual("<b>pizza</b>");
|
||||
});
|
||||
|
||||
it("converts true HTML topic to HTML", async () => {
|
||||
SettingsStore.setValue("feature_html_topic", null, SettingLevel.DEVICE, true);
|
||||
enableHtmlTopicFeature();
|
||||
render(<div role="contentinfo">{topicToHtml("**pizza**", "<b>pizza</b>", null, false)}</div>);
|
||||
expect(getContent()).toEqual("<b>pizza</b>");
|
||||
});
|
||||
|
||||
it("converts true HTML topic with emoji to HTML", async () => {
|
||||
SettingsStore.setValue("feature_html_topic", null, SettingLevel.DEVICE, true);
|
||||
enableHtmlTopicFeature();
|
||||
render(<div role="contentinfo">{topicToHtml("**pizza** 🍕", "<b>pizza</b> 🍕", null, false)}</div>);
|
||||
expect(getContent()).toEqual('<b>pizza</b> <span class="mx_Emoji" title=":pizza:">🍕</span>');
|
||||
});
|
||||
@@ -104,12 +107,7 @@ describe("bodyToHtml", () => {
|
||||
|
||||
describe("feature_latex_maths", () => {
|
||||
beforeEach(() => {
|
||||
SettingsStore.setValue("feature_latex_maths", null, SettingLevel.DEVICE, true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
SdkConfig.reset();
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature === "feature_latex_maths");
|
||||
});
|
||||
|
||||
it("should render inline katex", () => {
|
||||
@@ -230,8 +228,4 @@ describe("bodyToNode", () => {
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -484,10 +484,6 @@ describe("<MatrixChat />", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
it("should persist login credentials", async () => {
|
||||
getComponent({ realQueryParams });
|
||||
|
||||
|
||||
@@ -50,8 +50,6 @@ import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
import ScrollPanel from "../../../../src/components/structures/ScrollPanel";
|
||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../src/dispatcher/actions";
|
||||
import { SettingLevel } from "../../../../src/settings/SettingLevel";
|
||||
import MatrixClientBackedController from "../../../../src/settings/controllers/MatrixClientBackedController";
|
||||
|
||||
// ScrollPanel calls this, but jsdom doesn't mock it for us
|
||||
HTMLDivElement.prototype.scrollBy = () => {};
|
||||
@@ -312,14 +310,18 @@ describe("TimelinePanel", () => {
|
||||
|
||||
describe("and sending receipts is disabled", () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure this setting is supported, otherwise it will use the default value.
|
||||
client.isVersionSupported.mockImplementation(async (v) => v === "v1.4");
|
||||
MatrixClientBackedController.matrixClient = client;
|
||||
SettingsStore.setValue("sendReadReceipts", null, SettingLevel.DEVICE, false);
|
||||
client.isVersionSupported.mockResolvedValue(true);
|
||||
client.doesServerSupportUnstableFeature.mockResolvedValue(true);
|
||||
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string): any => {
|
||||
if (setting === "sendReadReceipts") return false;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
mocked(SettingsStore.getValue).mockReset();
|
||||
});
|
||||
|
||||
it("should send a fully read marker and a private receipt", async () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { act, render, screen } from "jest-matrix-react";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import { mocked } from "jest-mock";
|
||||
import EventEmitter from "events";
|
||||
|
||||
@@ -76,19 +76,4 @@ describe("CompleteSecurity", () => {
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Skip verification for now" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Renders a warning if user hits Reset", async () => {
|
||||
// Given a store and a dialog based on it
|
||||
const store = new SetupEncryptionStore();
|
||||
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
|
||||
const panel = await act(() => render(<CompleteSecurity onFinished={() => {}} />));
|
||||
|
||||
// When we hit reset
|
||||
await act(async () => panel.getByRole("button", { name: "Proceed with reset" }).click());
|
||||
//await act(async () => store.reset());
|
||||
|
||||
// Then the title and button update
|
||||
expect(screen.getByRole("heading", { name: "Verify this device" })).toBeInTheDocument();
|
||||
expect(panel.getByRole("button", { name: "Continue" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { render, waitFor } from "jest-matrix-react";
|
||||
import { mocked } from "jest-mock";
|
||||
import { JoinRule, type MatrixClient, PendingEventOrdering, Room, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { JoinRule, type MatrixClient, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
@@ -79,7 +79,6 @@ describe("DecoratedRoomAvatar", () => {
|
||||
} as unknown as DMRoomMap;
|
||||
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
|
||||
jest.spyOn(DecoratedRoomAvatar.prototype as any, "getPresenceIcon").mockImplementation(() => "ONLINE");
|
||||
jest.spyOn(room, "getMember").mockReturnValue(new RoomMember(room.roomId, DM_USER_ID));
|
||||
|
||||
const { container, asFragment } = renderComponent();
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React from "react";
|
||||
import { render } from "jest-matrix-react";
|
||||
import { EventType, type MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import RoomAvatar from "../../../../../src/components/views/avatars/RoomAvatar";
|
||||
@@ -60,7 +60,6 @@ describe("RoomAvatar", () => {
|
||||
it("should render as expected for a DM room", () => {
|
||||
const userId = "@dm_user@example.com";
|
||||
const room = new Room("!room:example.com", client, client.getSafeUserId());
|
||||
room.getMember = jest.fn().mockImplementation(() => new RoomMember(room.roomId, userId));
|
||||
room.name = "DM room";
|
||||
mocked(DMRoomMap.shared().getUserIdForRoomId).mockReturnValue(userId);
|
||||
expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot();
|
||||
@@ -79,17 +78,6 @@ describe("RoomAvatar", () => {
|
||||
jest.spyOn(room, "getMxcAvatarUrl").mockImplementation(() => "mxc://example.com/foobar");
|
||||
room.name = "test room";
|
||||
room.updateMyMembership("invite");
|
||||
room.currentState.setStateEvents([
|
||||
new MatrixEvent({
|
||||
sender: "@sender:server",
|
||||
room_id: room.roomId,
|
||||
type: EventType.RoomAvatar,
|
||||
state_key: "",
|
||||
content: {
|
||||
url: "mxc://example.com/foobar",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot();
|
||||
});
|
||||
it("should not render an invite avatar if the user has disabled it", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
@@ -57,7 +57,6 @@ import * as UseCall from "../../../../../../src/hooks/useCall";
|
||||
import { SdkContextClass } from "../../../../../../src/contexts/SDKContext";
|
||||
import WidgetStore, { type IApp } from "../../../../../../src/stores/WidgetStore";
|
||||
import { UIFeature } from "../../../../../../src/settings/UIFeature";
|
||||
import { SettingLevel } from "../../../../../../src/settings/SettingLevel";
|
||||
|
||||
jest.mock("../../../../../../src/utils/ShieldUtils");
|
||||
jest.mock("../../../../../../src/hooks/right-panel/useCurrentPhase", () => ({
|
||||
@@ -100,7 +99,6 @@ describe("RoomHeader", () => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
it("renders the room header", () => {
|
||||
@@ -189,7 +187,9 @@ describe("RoomHeader", () => {
|
||||
|
||||
it("opens the notifications panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
SettingsStore.setValue("feature_notifications", null, SettingLevel.DEVICE, true);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => {
|
||||
if (name === "feature_notifications") return true;
|
||||
});
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
@@ -228,15 +228,7 @@ describe("RoomHeader", () => {
|
||||
|
||||
describe("UIFeature.Widgets enabled (default)", () => {
|
||||
beforeEach(() => {
|
||||
SdkConfig.put({
|
||||
setting_defaults: {
|
||||
[UIFeature.Widgets]: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SdkConfig.reset();
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature == UIFeature.Widgets);
|
||||
});
|
||||
|
||||
it("should show call buttons in a room with 2 members", () => {
|
||||
@@ -256,15 +248,7 @@ describe("RoomHeader", () => {
|
||||
|
||||
describe("UIFeature.Widgets disabled", () => {
|
||||
beforeEach(() => {
|
||||
SdkConfig.put({
|
||||
setting_defaults: {
|
||||
[UIFeature.Widgets]: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SdkConfig.reset();
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => false);
|
||||
});
|
||||
|
||||
it("should show call buttons in a room with 2 members", () => {
|
||||
@@ -284,15 +268,7 @@ describe("RoomHeader", () => {
|
||||
|
||||
describe("groups call disabled", () => {
|
||||
beforeEach(() => {
|
||||
SdkConfig.put({
|
||||
setting_defaults: {
|
||||
[UIFeature.Widgets]: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SdkConfig.reset();
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature == UIFeature.Widgets);
|
||||
});
|
||||
|
||||
it("you can't call if you're alone", () => {
|
||||
@@ -357,26 +333,15 @@ describe("RoomHeader", () => {
|
||||
|
||||
describe("group call enabled", () => {
|
||||
beforeEach(() => {
|
||||
SdkConfig.put({
|
||||
features: {
|
||||
feature_group_calls: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SdkConfig.reset();
|
||||
jest.restoreAllMocks();
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
||||
(feature) => feature === "feature_group_calls" || feature == UIFeature.Widgets,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders only the video call element", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockRoomMembers(room, 3);
|
||||
SdkConfig.add({
|
||||
element_call: {
|
||||
use_exclusively: true,
|
||||
},
|
||||
});
|
||||
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
|
||||
// allow element calls
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
|
||||
@@ -394,11 +359,7 @@ describe("RoomHeader", () => {
|
||||
});
|
||||
|
||||
it("can't call if there's an ongoing (pinned) call", () => {
|
||||
SdkConfig.add({
|
||||
element_call: {
|
||||
use_exclusively: true,
|
||||
},
|
||||
});
|
||||
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
|
||||
// allow element calls
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true);
|
||||
@@ -416,14 +377,7 @@ describe("RoomHeader", () => {
|
||||
it("clicking on ongoing (unpinned) call re-pins it", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockRoomMembers(room, 3);
|
||||
SdkConfig.add({
|
||||
setting_defaults: {
|
||||
[UIFeature.Widgets]: true,
|
||||
},
|
||||
features: {
|
||||
feature_group_calls: false,
|
||||
},
|
||||
});
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature == UIFeature.Widgets);
|
||||
// allow calls
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
|
||||
@@ -473,10 +427,8 @@ describe("RoomHeader", () => {
|
||||
jest.spyOn(room.currentState, "maySendStateEvent").mockReturnValue(true);
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
SdkConfig.add({
|
||||
element_call: {
|
||||
guest_spa_url: "https://guest_spa_url.com",
|
||||
},
|
||||
const guestSpaUrlMock = jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
|
||||
return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
|
||||
});
|
||||
const { container: containerNoInviteNotPublicCanUpgradeAccess } = render(
|
||||
<RoomHeader room={room} />,
|
||||
@@ -490,10 +442,8 @@ describe("RoomHeader", () => {
|
||||
jest.spyOn(room.currentState, "maySendStateEvent").mockReturnValue(false);
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
SdkConfig.add({
|
||||
element_call: {
|
||||
guest_spa_url: "https://guest_spa_url.com",
|
||||
},
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
|
||||
return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
|
||||
});
|
||||
const { container: containerNoInviteNotPublic } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(queryAllByLabelText(containerNoInviteNotPublic, "There's no one here to call")).toHaveLength(2);
|
||||
@@ -513,9 +463,8 @@ describe("RoomHeader", () => {
|
||||
const { container: containerInvitePublic } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(queryAllByLabelText(containerInvitePublic, "There's no one here to call")).toHaveLength(0);
|
||||
|
||||
// Clear guest_spa_url
|
||||
SdkConfig.reset();
|
||||
// last we can allow everything but without guest_spa_url nothing will work
|
||||
guestSpaUrlMock.mockRestore();
|
||||
const { container: containerAllAllowedButNoGuestSpaUrl } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(
|
||||
queryAllByLabelText(containerAllAllowedButNoGuestSpaUrl, "There's no one here to call"),
|
||||
@@ -694,10 +643,6 @@ describe("RoomHeader", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SdkConfig.reset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ShieldUtils.E2EStatus.Verified, "Verified"],
|
||||
[ShieldUtils.E2EStatus.Warning, "Untrusted"],
|
||||
@@ -710,11 +655,6 @@ describe("RoomHeader", () => {
|
||||
});
|
||||
|
||||
it("does not show the face pile for DMs", () => {
|
||||
SdkConfig.put({
|
||||
features: {
|
||||
feature_notifications: false,
|
||||
},
|
||||
});
|
||||
const { asFragment } = render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
@@ -811,7 +751,7 @@ describe("RoomHeader", () => {
|
||||
|
||||
describe("ask to join enabled", () => {
|
||||
it("does render the RoomKnocksBar", () => {
|
||||
SettingsStore.setValue("feature_ask_to_join", null, SettingLevel.DEVICE, true);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature === "feature_ask_to_join");
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([new RoomMember(room.roomId, "@foo")]);
|
||||
|
||||
@@ -55,7 +55,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
|
||||
style="--cpd-icon-button-size: 100%; --cpd-color-icon-tertiary: var(--cpd-color-icon-disabled);"
|
||||
>
|
||||
<svg
|
||||
aria-labelledby=":r1c8:"
|
||||
aria-labelledby=":r15i:"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -71,7 +71,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-label="There's no one here to call"
|
||||
aria-labelledby=":r1cd:"
|
||||
aria-labelledby=":r15n:"
|
||||
class="_icon-button_m2erp_8"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
@@ -96,7 +96,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-label="Threads"
|
||||
aria-labelledby=":r1ci:"
|
||||
aria-labelledby=":r15s:"
|
||||
class="_icon-button_m2erp_8"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
@@ -122,7 +122,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-label="Room info"
|
||||
aria-labelledby=":r1cn:"
|
||||
aria-labelledby=":r161:"
|
||||
class="_icon-button_m2erp_8"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("<EmptyRoomList />", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vm = {
|
||||
isLoadingRooms: false,
|
||||
rooms: [],
|
||||
primaryFilters: [],
|
||||
activateSecondaryFilter: jest.fn().mockReturnValue({}),
|
||||
|
||||
@@ -29,6 +29,7 @@ describe("<RoomList />", () => {
|
||||
matrixClient = stubClient();
|
||||
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
|
||||
vm = {
|
||||
isLoadingRooms: false,
|
||||
rooms,
|
||||
primaryFilters: [],
|
||||
activateSecondaryFilter: () => {},
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("<RoomListPrimaryFilters />", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vm = {
|
||||
isLoadingRooms: false,
|
||||
rooms: [],
|
||||
canCreateRoom: true,
|
||||
createRoom: jest.fn(),
|
||||
|
||||
@@ -24,6 +24,7 @@ jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListViewMode
|
||||
|
||||
describe("<RoomListView />", () => {
|
||||
const defaultValue: RoomListViewState = {
|
||||
isLoadingRooms: false,
|
||||
rooms: [],
|
||||
primaryFilters: [],
|
||||
activateSecondaryFilter: jest.fn().mockReturnValue({}),
|
||||
@@ -43,6 +44,16 @@ describe("<RoomListView />", () => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should render the loading room list", () => {
|
||||
mocked(useRoomListViewModel).mockReturnValue({
|
||||
...defaultValue,
|
||||
isLoadingRooms: true,
|
||||
});
|
||||
|
||||
const roomList = render(<RoomListView />);
|
||||
expect(roomList.container.querySelector(".mx_Spinner")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should render an empty room list", () => {
|
||||
mocked(useRoomListViewModel).mockReturnValue(defaultValue);
|
||||
|
||||
|
||||
@@ -114,46 +114,15 @@ exports[`<RoomListPanel /> should not render the RoomListSearch component when U
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
class="mx_Flex mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
class="mx_Spinner"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
No chats yet
|
||||
</span>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_description"
|
||||
>
|
||||
Get started by messaging someone
|
||||
</span>
|
||||
<div
|
||||
class="mx_Flex mx_EmptyRoomList_DefaultPlaceholder"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10 12q-1.65 0-2.825-1.175T6 8t1.175-2.825T10 4t2.825 1.175T14 8t-1.175 2.825T10 12m-8 6v-.8q0-.85.438-1.562.437-.713 1.162-1.088a14.8 14.8 0 0 1 3.15-1.163A13.8 13.8 0 0 1 10 13q1.65 0 3.25.387 1.6.388 3.15 1.163.724.375 1.163 1.087Q18 16.35 18 17.2v.8q0 .824-.587 1.413A1.93 1.93 0 0 1 16 20H4q-.824 0-1.412-.587A1.93 1.93 0 0 1 2 18m2 0h12v-.8a.97.97 0 0 0-.5-.85q-1.35-.675-2.725-1.012a11.6 11.6 0 0 0-5.55 0Q5.85 15.675 4.5 16.35a.97.97 0 0 0-.5.85zm6-8q.825 0 1.412-.588Q12 8.826 12 8q0-.824-.588-1.412A1.93 1.93 0 0 0 10 6q-.825 0-1.412.588A1.93 1.93 0 0 0 8 8q0 .825.588 1.412Q9.175 10 10 10m7 1h2v2q0 .424.288.713.287.287.712.287.424 0 .712-.287A.97.97 0 0 0 21 13v-2h2q.424 0 .712-.287A.97.97 0 0 0 24 10a.97.97 0 0 0-.288-.713A.97.97 0 0 0 23 9h-2V7a.97.97 0 0 0-.288-.713A.97.97 0 0 0 20 6a.97.97 0 0 0-.712.287A.97.97 0 0 0 19 7v2h-2a.97.97 0 0 0-.712.287A.97.97 0 0 0 16 10q0 .424.288.713.287.287.712.287"
|
||||
/>
|
||||
</svg>
|
||||
New message
|
||||
</button>
|
||||
</div>
|
||||
aria-label="Loading…"
|
||||
class="mx_Spinner_icon"
|
||||
data-testid="spinner"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</DocumentFragment>
|
||||
@@ -335,67 +304,15 @@ exports[`<RoomListPanel /> should render the RoomListSearch component when UICom
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
class="mx_Flex mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
class="mx_Spinner"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
No chats yet
|
||||
</span>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_description"
|
||||
>
|
||||
Get started by messaging someone or by creating a room
|
||||
</span>
|
||||
<div
|
||||
class="mx_Flex mx_EmptyRoomList_DefaultPlaceholder"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10 12q-1.65 0-2.825-1.175T6 8t1.175-2.825T10 4t2.825 1.175T14 8t-1.175 2.825T10 12m-8 6v-.8q0-.85.438-1.562.437-.713 1.162-1.088a14.8 14.8 0 0 1 3.15-1.163A13.8 13.8 0 0 1 10 13q1.65 0 3.25.387 1.6.388 3.15 1.163.724.375 1.163 1.087Q18 16.35 18 17.2v.8q0 .824-.587 1.413A1.93 1.93 0 0 1 16 20H4q-.824 0-1.412-.587A1.93 1.93 0 0 1 2 18m2 0h12v-.8a.97.97 0 0 0-.5-.85q-1.35-.675-2.725-1.012a11.6 11.6 0 0 0-5.55 0Q5.85 15.675 4.5 16.35a.97.97 0 0 0-.5.85zm6-8q.825 0 1.412-.588Q12 8.826 12 8q0-.824-.588-1.412A1.93 1.93 0 0 0 10 6q-.825 0-1.412.588A1.93 1.93 0 0 0 8 8q0 .825.588 1.412Q9.175 10 10 10m7 1h2v2q0 .424.288.713.287.287.712.287.424 0 .712-.287A.97.97 0 0 0 21 13v-2h2q.424 0 .712-.287A.97.97 0 0 0 24 10a.97.97 0 0 0-.288-.713A.97.97 0 0 0 23 9h-2V7a.97.97 0 0 0-.288-.713A.97.97 0 0 0 20 6a.97.97 0 0 0-.712.287A.97.97 0 0 0 19 7v2h-2a.97.97 0 0 0-.712.287A.97.97 0 0 0 16 10q0 .424.288.713.287.287.712.287"
|
||||
/>
|
||||
</svg>
|
||||
New message
|
||||
</button>
|
||||
<button
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.566 17-.944 4.094q-.086.406-.372.656t-.687.25q-.543 0-.887-.469a1.18 1.18 0 0 1-.2-1.031l.801-3.5H3.158q-.572 0-.916-.484a1.27 1.27 0 0 1-.2-1.078 1.12 1.12 0 0 1 1.116-.938H6.85l1.145-5h-3.12q-.57 0-.915-.484a1.27 1.27 0 0 1-.2-1.078A1.12 1.12 0 0 1 4.875 7h3.691l.945-4.094q.085-.406.372-.656.286-.25.686-.25.544 0 .887.469.345.468.2 1.031l-.8 3.5h4.578l.944-4.094q.085-.406.372-.656.286-.25.687-.25.543 0 .887.469t.2 1.031L17.723 7h3.119q.573 0 .916.484.343.485.2 1.079a1.12 1.12 0 0 1-1.116.937H17.15l-1.145 5h3.12q.57 0 .915.484.343.485.2 1.079a1.12 1.12 0 0 1-1.116.937h-3.691l-.944 4.094q-.087.406-.373.656t-.686.25q-.544 0-.887-.469a1.18 1.18 0 0 1-.2-1.031l.8-3.5zm.573-2.5h4.578l1.144-5h-4.578z"
|
||||
/>
|
||||
</svg>
|
||||
New room
|
||||
</button>
|
||||
</div>
|
||||
aria-label="Loading…"
|
||||
class="mx_Spinner_icon"
|
||||
data-testid="spinner"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</DocumentFragment>
|
||||
|
||||
@@ -24,9 +24,9 @@ describe("<ResetIdentityPanel />", () => {
|
||||
it("should reset the encryption when the continue button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const onReset = jest.fn();
|
||||
const onFinish = jest.fn();
|
||||
const { asFragment } = render(
|
||||
<ResetIdentityPanel variant="compromised" onReset={onReset} onCancelClick={jest.fn()} />,
|
||||
<ResetIdentityPanel variant="compromised" onFinish={onFinish} onCancelClick={jest.fn()} />,
|
||||
withClientContextRenderOptions(matrixClient),
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
@@ -43,22 +43,22 @@ describe("<ResetIdentityPanel />", () => {
|
||||
await sleep(0);
|
||||
|
||||
expect(matrixClient.getCrypto()!.resetEncryption).toHaveBeenCalled();
|
||||
expect(onReset).toHaveBeenCalled();
|
||||
expect(onFinish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display the 'forgot recovery key' variant correctly", async () => {
|
||||
const onReset = jest.fn();
|
||||
const onFinish = jest.fn();
|
||||
const { asFragment } = render(
|
||||
<ResetIdentityPanel variant="forgot" onReset={onReset} onCancelClick={jest.fn()} />,
|
||||
<ResetIdentityPanel variant="forgot" onFinish={onFinish} onCancelClick={jest.fn()} />,
|
||||
withClientContextRenderOptions(matrixClient),
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display the 'sync failed' variant correctly", async () => {
|
||||
const onReset = jest.fn();
|
||||
const onFinish = jest.fn();
|
||||
const { asFragment } = render(
|
||||
<ResetIdentityPanel variant="sync_failed" onReset={onReset} onCancelClick={jest.fn()} />,
|
||||
<ResetIdentityPanel variant="sync_failed" onFinish={onFinish} onCancelClick={jest.fn()} />,
|
||||
withClientContextRenderOptions(matrixClient),
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
@@ -37,10 +37,6 @@ describe("NotificatinSettingsTab", () => {
|
||||
NotificationSettingsTab.contextType = React.createContext<MatrixClient>(cli);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
it("should prevent »Settings« link click from bubbling up to radio buttons", async () => {
|
||||
const tab = renderTab();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
@@ -28,13 +28,13 @@ import {
|
||||
getMarkedUnreadState,
|
||||
setMarkedUnreadState,
|
||||
} from "../../../src/utils/notifications";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsServer } from "../../test-utils/client";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import { getMockClientWithEventEmitter } from "../../test-utils/client";
|
||||
import { mkMessage, stubClient } from "../../test-utils/test-utils";
|
||||
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
||||
import { NotificationLevel } from "../../../src/stores/notifications/NotificationLevel";
|
||||
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||
import MatrixClientBackedController from "../../../src/settings/controllers/MatrixClientBackedController";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
|
||||
jest.mock("../../../src/settings/SettingsStore");
|
||||
|
||||
describe("notifications", () => {
|
||||
let accountDataStore: Record<string, MatrixEvent> = {};
|
||||
@@ -44,7 +44,6 @@ describe("notifications", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsServer(),
|
||||
isGuest: jest.fn().mockReturnValue(false),
|
||||
getAccountData: jest.fn().mockImplementation((eventType) => accountDataStore[eventType]),
|
||||
setAccountData: jest.fn().mockImplementation((eventType, content) => {
|
||||
@@ -53,20 +52,10 @@ describe("notifications", () => {
|
||||
content,
|
||||
});
|
||||
}),
|
||||
isVersionSupported: jest.fn().mockImplementation(async (v) => v === "v1.4"),
|
||||
});
|
||||
|
||||
// Ensure unstable settings are supported, otherwise it will use the default value.
|
||||
MatrixClientBackedController.matrixClient = mockClient;
|
||||
accountDataStore = {};
|
||||
accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId!);
|
||||
// Disable all notifications
|
||||
deviceNotificationSettingsKeys.forEach((k) => SettingsStore.setValue(k, null, SettingLevel.DEVICE, false));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
SettingsStore.reset();
|
||||
mocked(SettingsStore).getValue.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe("createLocalNotification", () => {
|
||||
@@ -86,15 +75,10 @@ describe("notifications", () => {
|
||||
it.each(deviceNotificationSettingsKeys)(
|
||||
"unsilenced for existing sessions when %s setting is truthy",
|
||||
async (settingKey) => {
|
||||
// We need to spy `getValue` because setting these keys requires mocking
|
||||
// the platform to support notifications, which is out of scope for this test.
|
||||
const origFn = SettingsStore.getValue;
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name, ...args) => {
|
||||
if (name === settingKey) {
|
||||
return true;
|
||||
}
|
||||
return origFn(name, ...args);
|
||||
mocked(SettingsStore).getValue.mockImplementation((key): any => {
|
||||
return key === settingKey;
|
||||
});
|
||||
|
||||
await createLocalNotificationSettingsIfNeeded(mockClient);
|
||||
const event = mockClient.getAccountData(accountDataEventKey);
|
||||
expect(event?.getContent().is_silenced).toBe(false);
|
||||
@@ -132,6 +116,7 @@ describe("notifications", () => {
|
||||
const ROOM_ID = "123";
|
||||
const USER_ID = "@bob:example.org";
|
||||
let message: MatrixEvent;
|
||||
let sendReceiptsSetting = true;
|
||||
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
@@ -146,7 +131,9 @@ describe("notifications", () => {
|
||||
room.addLiveEvents([message], { addToState: true });
|
||||
sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockResolvedValue({});
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room]);
|
||||
SettingsStore.setValue("sendReadReceipts", null, SettingLevel.DEVICE, true);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
|
||||
return name === "sendReadReceipts" && sendReceiptsSetting;
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a request even if everything has been read", async () => {
|
||||
@@ -165,8 +152,11 @@ describe("notifications", () => {
|
||||
});
|
||||
|
||||
describe("when sendReadReceipts setting is disabled", () => {
|
||||
beforeEach(() => {
|
||||
sendReceiptsSetting = false;
|
||||
});
|
||||
|
||||
it("should send a private read receipt", async () => {
|
||||
SettingsStore.setValue("sendReadReceipts", null, SettingLevel.DEVICE, false);
|
||||
await clearRoomNotification(room, client);
|
||||
expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.ReadPrivate, true);
|
||||
});
|
||||
@@ -187,7 +177,9 @@ describe("notifications", () => {
|
||||
room = new Room(ROOM_ID, client, USER_ID);
|
||||
sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockResolvedValue({});
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room]);
|
||||
SettingsStore.setValue("sendReadReceipts", null, SettingLevel.DEVICE, true);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
|
||||
return name === "sendReadReceipts";
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send any requests if everything has been read", () => {
|
||||
@@ -220,7 +212,7 @@ describe("notifications", () => {
|
||||
room.addLiveEvents([message], { addToState: true });
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
|
||||
|
||||
SettingsStore.setValue("sendReadReceipts", null, SettingLevel.DEVICE, false);
|
||||
jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false);
|
||||
|
||||
await clearAllNotifications(client);
|
||||
|
||||
|
||||
40
yarn.lock
40
yarn.lock
@@ -2158,13 +2158,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.29.2.tgz#20d9877f11d5e411f1610f396f9e490673d6da50"
|
||||
integrity sha512-kpCdf6DBxgE7MbBbYr7FvahrktHHtiph3QN10I6nBAAPQ+hmR3aZHBECxjxLQ9RxvtBF9nlKK4bgy2YrNp6j3A==
|
||||
|
||||
"@matrix-org/emojibase-bindings@^1.3.4":
|
||||
version "1.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/emojibase-bindings/-/emojibase-bindings-1.3.4.tgz#b0dad8e8b8bbe433e419b59e38f933bcdaf9c271"
|
||||
integrity sha512-+nhBg0dxjy3U4/Tn6WIsnzqiqazc0pfStc2dkSBxDnc4xnimDB6vcIad53fUIsl7SeT50ake0hhnBJs0ZDDk6Q==
|
||||
"@matrix-org/emojibase-bindings@^1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/emojibase-bindings/-/emojibase-bindings-1.4.0.tgz#ad1f917b03cd1fcf049bc3de809beb6cbae78009"
|
||||
integrity sha512-5PsY183hHK04I8uBCIoyVvZefu/VJYB5YhoM7DAHn0WQtedn70ZCES9iUxcyMRFGzfwiiqd+ArsK8VwLN5JEVA==
|
||||
dependencies:
|
||||
emojibase "^15.3.1"
|
||||
emojibase-data "^15.3.1"
|
||||
emojibase "^16.0.0"
|
||||
emojibase-data "^16.0.3"
|
||||
|
||||
"@matrix-org/matrix-sdk-crypto-wasm@^14.0.1":
|
||||
version "14.0.1"
|
||||
@@ -3738,7 +3738,7 @@
|
||||
classnames "^2.5.1"
|
||||
vaul "^1.0.0"
|
||||
|
||||
"@vector-im/matrix-wysiwyg-wasm@link:../../../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm":
|
||||
"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
@@ -3747,7 +3747,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.38.3.tgz#cc54d8b3e9472bcd8e622126ba364ee31952cd8a"
|
||||
integrity sha512-fqo8P55Vc/t0vxpFar9RDJN5gKEjJmzrLo+O4piDbFda6VrRoqrWAtiu0Au0g6B4hRDPKIuFupk8v9Ja7q8Hvg==
|
||||
dependencies:
|
||||
"@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm"
|
||||
"@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm"
|
||||
|
||||
"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1":
|
||||
version "1.14.1"
|
||||
@@ -5941,20 +5941,20 @@ emoji-regex@^9.2.2:
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
|
||||
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
|
||||
|
||||
emojibase-data@^15.3.1:
|
||||
version "15.3.2"
|
||||
resolved "https://registry.yarnpkg.com/emojibase-data/-/emojibase-data-15.3.2.tgz#2742246bfe14f16a7829b42ca156dec09934cf85"
|
||||
integrity sha512-TpDyTDDTdqWIJixV5sTA6OQ0P0JfIIeK2tFRR3q56G9LK65ylAZ7z3KyBXokpvTTJ+mLUXQXbLNyVkjvnTLE+A==
|
||||
emojibase-data@^16.0.3:
|
||||
version "16.0.3"
|
||||
resolved "https://registry.yarnpkg.com/emojibase-data/-/emojibase-data-16.0.3.tgz#564ddfe11a2fdcba24975335f857dc85ee895027"
|
||||
integrity sha512-MopInVCDZeXvqBMPJxnvYUyKw9ImJZqIDr2sABo6acVSPev5IDYX+mf+0tsu96JJyc3INNvgIf06Eso7bdTX2Q==
|
||||
|
||||
emojibase-regex@15.3.2:
|
||||
version "15.3.2"
|
||||
resolved "https://registry.yarnpkg.com/emojibase-regex/-/emojibase-regex-15.3.2.tgz#5175231715b86d4b437754527288844a6c29318f"
|
||||
integrity sha512-ue6BVeb2qu33l97MkxcOoyMJlg6Tug3eTv2z1at+M9TjvlWKvdmAPvZIDG1JbT2RH3FSyJNLucO5K5H/yxT03w==
|
||||
emojibase-regex@16.0.0:
|
||||
version "16.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emojibase-regex/-/emojibase-regex-16.0.0.tgz#e648d8789dc22c6adc9a10b1af47135559f65a88"
|
||||
integrity sha512-ZMp31BkzBWNW+T73of6NURL6nXQa5GkfKneOkr3cEwBDVllbW/2nuva7NO0J3RjaQ07+SZQNgPTGZ4JlIhmM2Q==
|
||||
|
||||
emojibase@^15.3.1:
|
||||
version "15.3.1"
|
||||
resolved "https://registry.yarnpkg.com/emojibase/-/emojibase-15.3.1.tgz#7f6ff5482486f23e59a457de64e974bd35f3c9a3"
|
||||
integrity sha512-GNsjHnG2J3Ktg684Fs/vZR/6XpOSkZPMAv85EHrr6br2RN2cJNwdS4am/3YSK3y+/gOv2kmoK3GGdahXdMxg2g==
|
||||
emojibase@^16.0.0:
|
||||
version "16.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emojibase/-/emojibase-16.0.0.tgz#9da603b7d740645d0a5d21c6dcfb97c53d6f96c7"
|
||||
integrity sha512-Nw2m7JLIO4Ou2X/yZPRNscHQXVbbr6SErjkJ7EooG7MbR3yDZszCv9KTizsXFc7yZl0n3WF+qUKIC/Lw6H9xaQ==
|
||||
|
||||
emojis-list@^3.0.0:
|
||||
version "3.0.0"
|
||||
|
||||
Reference in New Issue
Block a user