Compare commits

..

13 Commits

Author SHA1 Message Date
Florian Duros
e3f832bed3 Replicate otk bug with test should reset the cryptographic identity 2025-01-21 16:43:02 +01:00
Florian Duros
6385015172 Get only the recovery section in recovery tests 2025-01-20 17:28:32 +01:00
Florian Duros
d49ae996fd Add tests to ResetIdentityPanel 2025-01-20 17:28:32 +01:00
Florian Duros
82728a1050 Add tests to EncryptionUserSettingsTab 2025-01-20 17:28:32 +01:00
Florian Duros
cc70dcefd6 Add tests to AdvancedPanel 2025-01-20 17:28:32 +01:00
Florian Duros
b5e0c1147f Use h3 for title 2025-01-16 15:54:13 +01:00
Florian Duros
70f6603b60 Use same uiAuthCallback 2025-01-16 15:54:13 +01:00
Florian Duros
a5ccc9a9d3 Rename EncryptionDetails css classes 2025-01-16 15:54:13 +01:00
Florian Duros
9c13d3df7b - Add commercial license
- Remove generic type
2025-01-16 15:54:13 +01:00
Florian Duros
54077902d4 Add the Never send encrypted messages to unverified devices settings 2025-01-16 15:54:13 +01:00
Florian Duros
e317b09117 Add advanced section 2025-01-15 16:40:27 +01:00
Florian Duros
11db427649 Update compound 2025-01-15 14:41:52 +01:00
Florian Duros
0757faef6d Make the encryption card more configurable:
- Change the icon
- Can set the destructive props
2025-01-15 14:41:51 +01:00
152 changed files with 1837 additions and 773 deletions

View File

@@ -1,5 +1,5 @@
module.exports = {
plugins: ["matrix-org", "eslint-plugin-react-compiler"],
plugins: ["matrix-org"],
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"],
parserOptions: {
project: ["./tsconfig.json"],
@@ -170,8 +170,6 @@ module.exports = {
"jsx-a11y/role-supports-aria-props": "off",
"matrix-org/require-copyright-header": "error",
"react-compiler/react-compiler": "error",
},
overrides: [
{
@@ -264,7 +262,6 @@ module.exports = {
// These are fine in tests
"no-restricted-globals": "off",
"react-compiler/react-compiler": "off",
},
},
{

View File

@@ -96,4 +96,3 @@ jobs:
projectName: ${{ env.SITE == 'staging.element.io' && 'element-web-staging' || 'element-web' }}
directory: _deploy
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
branch: main

View File

@@ -17,7 +17,6 @@ class MockMap extends EventEmitter {
setCenter = jest.fn();
setStyle = jest.fn();
fitBounds = jest.fn();
remove = jest.fn();
}
const MockMapInstance = new MockMap();

View File

@@ -91,7 +91,7 @@
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^2.1.0",
"@vector-im/compound-web": "^7.5.0",
"@vector-im/compound-web": "^7.6.1",
"@vector-im/matrix-wysiwyg": "2.38.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
@@ -237,7 +237,6 @@
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "^2.0.2",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-unicorn": "^56.0.0",
"express": "^4.18.2",

View File

@@ -13,14 +13,6 @@ import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import { ElementAppPage } from "../../pages/ElementAppPage";
// Find and click "Reply" button
const clickButtonReply = async (tile: Locator) => {
await expect(async () => {
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
}).toPass();
};
test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test.use({
displayName: "Hanako",
@@ -230,7 +222,8 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
// Find and click "Reply" button on MessageActionBar
const tile = page.locator(".mx_EventTile_last");
await clickButtonReply(tile);
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
// Reply to the player with another audio file
await uploadFile(page, "playwright/sample-files/1sec.ogg");
@@ -258,12 +251,18 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
const tile = page.locator(".mx_EventTile_last");
// Find and click "Reply" button
const clickButtonReply = async () => {
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
};
await uploadFile(page, "playwright/sample-files/upload-first.ogg");
// Assert that the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
await clickButtonReply(tile);
await clickButtonReply();
// Reply to the player with another audio file
await uploadFile(page, "playwright/sample-files/upload-second.ogg");
@@ -271,7 +270,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
// Assert that the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
await clickButtonReply(tile);
await clickButtonReply();
// Reply to the player with yet another audio file to create a reply chain
await uploadFile(page, "playwright/sample-files/upload-third.ogg");

View File

@@ -66,9 +66,6 @@ test.describe("Cryptography", function () {
// Bob has a second, not cross-signed, device
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
// Dismiss the toast nagging us to set up recovery otherwise it gets in the way of clicking the room list
await page.getByRole("button", { name: "Not now" }).click();
await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
algorithm: "m.megolm.v1.aes-sha2",
ciphertext: "the bird is in the hand",

View File

@@ -413,25 +413,3 @@ export async function createSecondBotDevice(page: Page, homeserver: HomeserverIn
await bobSecondDevice.prepareClient();
return bobSecondDevice;
}
/**
* Remove the cached secrets from the indexedDB
* This is a workaround to simulate the case where the secrets are not cached.
*/
export async function deleteCachedSecrets(page: Page) {
await page.evaluate(async () => {
const removeCachedSecrets = new Promise((resolve) => {
const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto");
request.onsuccess = (event: Event & { target: { result: IDBDatabase } }) => {
const db = event.target.result;
const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity");
request.onsuccess = () => {
db.close();
resolve(undefined);
};
};
});
await removeCachedSecrets;
});
await page.reload();
}

View File

@@ -81,7 +81,6 @@ test.describe("Create Knock Room", () => {
const spotlightDialog = await app.openSpotlight();
await spotlightDialog.filter(Filter.PublicRooms);
await spotlightDialog.search("Cyber");
await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity");
});
});

View File

@@ -284,7 +284,6 @@ test.describe("Knock Into Room", () => {
const spotlightDialog = await app.openSpotlight();
await spotlightDialog.filter(Filter.PublicRooms);
await spotlightDialog.search("Cyber");
await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity");
await spotlightDialog.results.nth(0).click();

View File

@@ -58,16 +58,6 @@ async function editMessage(page: Page, message: Locator, newMsg: string): Promis
await editComposer.press("Enter");
}
const screenshotOptions = (page?: Page) => ({
mask: page ? [page.locator(".mx_MessageTimestamp")] : undefined,
// Hide the jump to bottom button in the timeline to avoid flakiness
css: `
.mx_JumpToBottomButton {
display: none !important;
}
`,
});
test.describe("Message rendering", () => {
[
{ direction: "ltr", displayName: "Quentin" },
@@ -89,10 +79,9 @@ test.describe("Message rendering", () => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "Hello, world!");
await expect(msgTile).toMatchScreenshot(
`basic-message-ltr-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`basic-message-ltr-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
},
);
@@ -100,17 +89,14 @@ test.describe("Message rendering", () => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me lays an egg");
await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`, screenshotOptions());
await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`);
});
test("should render an LTR rich text emote", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me lays a *free range* egg");
await expect(msgTile).toMatchScreenshot(
`emote-rich-ltr-${direction}displayname.png`,
screenshotOptions(),
);
await expect(msgTile).toMatchScreenshot(`emote-rich-ltr-${direction}displayname.png`);
});
test("should render an edited LTR message", async ({ page, user, app, room }) => {
@@ -120,10 +106,9 @@ test.describe("Message rendering", () => {
await editMessage(page, msgTile, "Hello, universe!");
await expect(msgTile).toMatchScreenshot(
`edited-message-ltr-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`edited-message-ltr-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render a reply of a LTR message", async ({ page, user, app, room }) => {
@@ -137,37 +122,32 @@ test.describe("Message rendering", () => {
]);
await replyMessage(page, msgTile, "response to multiline message");
await expect(msgTile).toMatchScreenshot(
`reply-message-ltr-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`reply-message-ltr-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render a basic RTL text message", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "مرحبا بالعالم!");
await expect(msgTile).toMatchScreenshot(
`basic-message-rtl-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`basic-message-rtl-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render an RTL emote", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me يضع بيضة");
await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`, screenshotOptions());
await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`);
});
test("should render a richtext RTL emote", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me أضع بيضة *حرة النطاق*");
await expect(msgTile).toMatchScreenshot(
`emote-rich-rtl-${direction}displayname.png`,
screenshotOptions(),
);
await expect(msgTile).toMatchScreenshot(`emote-rich-rtl-${direction}displayname.png`);
});
test("should render an edited RTL message", async ({ page, user, app, room }) => {
@@ -177,10 +157,9 @@ test.describe("Message rendering", () => {
await editMessage(page, msgTile, "مرحبا بالكون!");
await expect(msgTile).toMatchScreenshot(
`edited-message-rtl-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`edited-message-rtl-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render a reply of a RTL message", async ({ page, user, app, room }) => {
@@ -194,10 +173,9 @@ test.describe("Message rendering", () => {
]);
await replyMessage(page, msgTile, "مرحبا بالعالم!");
await expect(msgTile).toMatchScreenshot(
`reply-message-trl-${direction}displayname.png`,
screenshotOptions(page),
);
await expect(msgTile).toMatchScreenshot(`reply-message-trl-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
});
});

View File

@@ -35,10 +35,10 @@ test.describe("Pinned messages", () => {
mask: [tile.locator(".mx_MessageTimestamp")],
// Hide the jump to bottom button in the timeline to avoid flakiness
css: `
.mx_JumpToBottomButton {
display: none !important;
}
`,
.mx_JumpToBottomButton {
display: none !important;
}
`,
});
},
);

View File

@@ -111,10 +111,6 @@ test.describe("Room Header", () => {
async ({ page, app, user }) => {
await createVideoRoom(page, app);
// Dismiss a toast that is otherwise in the way (it's the other
// side but there's no need to have it in the screenshot)
await page.getByRole("button", { name: "Later" }).click();
const header = page.locator(".mx_RoomHeader");
// There's two room info button - the header itself and the i button

View File

@@ -0,0 +1,79 @@
/*
* 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 { test, expect } from "./index";
import {
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
verifySession,
} from "../../crypto/utils";
test.describe("Advanced section in Encryption tab", () => {
let expectedBackupVersion: string;
test.beforeEach(async ({ page, app, homeserver, credentials }) => {
const res = await createBot(page, homeserver, credentials);
expectedBackupVersion = res.expectedBackupVersion;
});
test("should show the encryption details", { tag: "@screenshot" }, async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
await util.openEncryptionTab();
const section = util.getEncryptionDetailsSection();
const deviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId());
await expect(section.getByText(deviceId)).toBeVisible();
await expect(section).toMatchScreenshot("encryption-details.png", {
mask: [section.getByTestId("deviceId"), section.getByTestId("sessionKey")],
});
});
test("should show the import room keys dialog", async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
await util.openEncryptionTab();
const section = util.getEncryptionDetailsSection();
await section.getByRole("button", { name: "Import keys" }).click();
await expect(page.getByRole("heading", { name: "Import room keys" })).toBeVisible();
});
test("should show the export room keys dialog", async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
await util.openEncryptionTab();
const section = util.getEncryptionDetailsSection();
await section.getByRole("button", { name: "Export keys" }).click();
await expect(page.getByRole("heading", { name: "Export room keys" })).toBeVisible();
});
test("should reset the cryptographic identity", { tag: "@screenshot" }, async ({ page, app, util }) => {
test.slow();
await verifySession(app, "new passphrase");
const tab = await util.openEncryptionTab();
const section = util.getEncryptionDetailsSection();
await section.getByRole("button", { name: "Reset cryptographic identity" }).click();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("reset-cryptographic-identity.png");
await tab.getByRole("button", { name: "Continue" }).click();
await expect(section.getByRole("button", { name: "Reset cryptographic identity" })).toBeVisible();
// After resetting the identity, the user should set up a new recovery key
await expect(
util.getEncryptionRecoverySection().getByRole("button", { name: "Set up recovery" }),
).toBeVisible();
await checkDeviceIsCrossSigned(app);
await app.closeDialog();
// The key backup was enabled before resetting the identity
// We create a new one after the reset
await checkDeviceIsConnectedKeyBackup(page, `${parseInt(expectedBackupVersion) + 1}`, true);
});
});

View File

@@ -18,6 +18,8 @@ export { expect };
export const test = base.extend<{
util: Helpers;
}>({
displayName: "Alice",
util: async ({ page, app, bot }, use) => {
await use(new Helpers(page, app));
},
@@ -67,6 +69,20 @@ class Helpers {
return this.page.getByTestId("encryptionTab");
}
/**
* Get the recovery section
*/
getEncryptionRecoverySection() {
return this.page.getByTestId("recoveryPanel");
}
/**
* Get the encryption details section
*/
getEncryptionDetailsSection() {
return this.page.getByTestId("encryptionDetails");
}
/**
* Set the default key id of the secret storage to `null`
*/
@@ -89,7 +105,8 @@ class Helpers {
await expect(dialog.getByText(title, { exact: true })).toBeVisible();
await expect(dialog).toMatchScreenshot(screenshot);
const clipboardContent = await this.app.getClipboard();
const handle = await this.page.evaluateHandle(() => navigator.clipboard.readText());
const clipboardContent = await handle.jsonValue();
await dialog.getByRole("textbox").fill(clipboardContent);
await dialog.getByRole("button", { name: confirmButtonLabel }).click();
await expect(dialog).toMatchScreenshot("default-recovery.png");

View File

@@ -6,13 +6,13 @@
*/
import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { Page } from "@playwright/test";
import { test, expect } from ".";
import {
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
deleteCachedSecrets,
verifySession,
} from "../../crypto/utils";
@@ -32,15 +32,19 @@ test.describe("Recovery section in Encryption tab", () => {
test("should verify the device", { 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(util.getEncryptionTabContent()).toMatchScreenshot("verify-device-encryption-tab.png");
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
await verifyButton.click();
await util.verifyDevice(recoveryKey);
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
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);
@@ -53,7 +57,7 @@ test.describe("Recovery section in Encryption tab", () => {
test(
"should change the recovery key",
{ tag: ["@screenshot", "@no-webkit"] },
{ tag: "@screenshot" },
async ({ page, app, homeserver, credentials, util, context }) => {
await verifySession(app, "new passphrase");
const dialog = await util.openEncryptionTab();
@@ -61,7 +65,7 @@ test.describe("Recovery section in Encryption tab", () => {
// The user can only change the recovery key
const changeButton = dialog.getByRole("button", { name: "Change recovery key" });
await expect(changeButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png");
await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png");
await changeButton.click();
// Display the new recovery key and click on the copy button
@@ -81,7 +85,7 @@ test.describe("Recovery section in Encryption tab", () => {
},
);
test("should setup the recovery key", { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, util }) => {
test("should setup the recovery key", { tag: "@screenshot" }, async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
await util.removeSecretStorageDefaultKeyId();
@@ -89,7 +93,7 @@ test.describe("Recovery section in Encryption tab", () => {
const dialog = await util.openEncryptionTab();
const setupButton = dialog.getByRole("button", { name: "Set up recovery" });
await expect(setupButton).toBeVisible();
await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-recovery.png");
await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("set-up-recovery.png");
await setupButton.click();
// Display an informative panel about the recovery key
@@ -137,12 +141,12 @@ test.describe("Recovery section in Encryption tab", () => {
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 expect(util.getEncryptionRecoverySection()).toMatchScreenshot("out-of-sync-recovery.png");
await enterKeyButton.click();
// Fill the recovery key
await util.enterRecoveryKey(recoveryKey);
await expect(dialog).toMatchScreenshot("default-recovery.png");
await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png");
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
@@ -154,3 +158,25 @@ test.describe("Recovery section in Encryption tab", () => {
},
);
});
/**
* Remove the cached secrets from the indexedDB
* This is a workaround to simulate the case where the secrets are not cached.
*/
async function deleteCachedSecrets(page: Page) {
await page.evaluate(async () => {
const removeCachedSecrets = new Promise((resolve) => {
const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto");
request.onsuccess = async (event: Event & { target: { result: IDBDatabase } }) => {
const db = event.target.result;
const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity");
request.onsuccess = () => {
db.close();
resolve(undefined);
};
};
});
await removeCachedSecrets;
});
await page.reload();
}

View File

@@ -69,6 +69,11 @@ const test = base.extend<{
});
test.describe("Sliding Sync", () => {
test.skip(
({ homeserverType }) => homeserverType === "pinecone",
"due to a bug in Pinecone https://github.com/element-hq/dendrite/issues/3490",
);
const checkOrder = async (wantOrder: string[], page: Page) => {
await expect(page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomTile_title")).toHaveText(wantOrder);
};

View File

@@ -84,7 +84,7 @@ test.describe("Spaces", () => {
// Copy matrix.to link
await page.getByRole("button", { name: "Share invite link" }).click();
expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#lets-have-a-riot:${user.homeServer}`);
expect(await app.getClipboardText()).toEqual(`https://matrix.to/#/#lets-have-a-riot:${user.homeServer}`);
// Go to space home
await page.getByRole("button", { name: "Go to my first room" }).click();
@@ -177,7 +177,7 @@ test.describe("Spaces", () => {
const shareDialog = page.locator(".mx_SpacePublicShare");
// Copy link first
await shareDialog.getByRole("button", { name: "Share invite link" }).click();
expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#space:${user.homeServer}`);
expect(await app.getClipboardText()).toEqual(`https://matrix.to/#/#space:${user.homeServer}`);
// Start Matrix invite flow
await shareDialog.getByRole("button", { name: "Invite people" }).click();

View File

@@ -38,13 +38,11 @@ export const test = base.extend<{
room1Name: "Room 1",
room1: async ({ room1Name: name, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] });
await bot.awaitRoomMembership(roomId);
await use({ name, roomId });
},
room2Name: "Room 2",
room2: async ({ room2Name: name, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] });
await bot.awaitRoomMembership(roomId);
await use({ name, roomId });
},
msg: async ({ page, app, util }, use) => {

View File

@@ -1195,7 +1195,6 @@ test.describe("Timeline", () => {
});
await sendImage(app.client, room.roomId, NEW_AVATAR);
await app.timeline.scrollToBottom();
await expect(page.locator(".mx_MImageBody").first()).toBeVisible();
// Exclude timestamp and read marker from snapshot

View File

@@ -1,31 +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 { test, expect } from "../../element-web-test";
test.describe("PSTN", () => {
test.beforeEach(async ({ page }) => {
// Mock the third party protocols endpoint to look like the HS has PSTN support
await page.route("**/_matrix/client/v3/thirdparty/protocols", async (route) => {
await route.fulfill({
status: 200,
json: {
"im.vector.protocol.pstn": {},
},
});
});
});
test("should render dialpad as expected", { tag: "@screenshot" }, async ({ page, user, toasts }) => {
await toasts.rejectToast("Notifications");
await toasts.assertNoToasts();
await expect(page.locator(".mx_LeftPanel_filterContainer")).toMatchScreenshot("dialpad-trigger.png");
await page.getByLabel("Open dial pad").click();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("dialpad.png");
});
});

View File

@@ -24,40 +24,18 @@ type PaginationLinks = {
first?: string;
};
// We see quite a few test flakes which are caused by the app exploding
// so we have some magic strings we check the logs for to better track the flake with its cause
const SPECIAL_CASES = {
"ChunkLoadError": "ChunkLoadError",
"Unreachable code should not be executed": "Rust crypto panic",
"Out of bounds memory access": "Rust crypto memory error",
};
class FlakyReporter implements Reporter {
private flakes = new Map<string, TestCase[]>();
public onTestEnd(test: TestCase): void {
// Ignores flakes on Dendrite and Pinecone as they have their own flakes we do not track
if (["Dendrite", "Pinecone"].includes(test.parent.project()?.name)) return;
let failures = [`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`];
const title = `${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`;
if (test.outcome() === "flaky") {
const timedOutRuns = test.results.filter((result) => result.status === "timedOut");
const pageLogs = timedOutRuns.flatMap((result) =>
result.attachments.filter((attachment) => attachment.name.startsWith("page-")),
);
// If a test failed due to a systemic fault then the test is not flaky, the app is, record it as such.
const specialCases = Object.keys(SPECIAL_CASES).filter((log) =>
pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body.includes(log)),
);
if (specialCases.length > 0) {
failures = specialCases.map((specialCase) => SPECIAL_CASES[specialCase]);
}
for (const title of failures) {
if (!this.flakes.has(title)) {
this.flakes.set(title, []);
}
this.flakes.get(title).push(test);
if (!this.flakes.has(title)) {
this.flakes.set(title, []);
}
this.flakes.get(title).push(test);
}
}

View File

@@ -158,6 +158,10 @@ export class ElementAppPage {
return button.click();
}
public async getClipboardText(): Promise<string> {
return this.page.evaluate("navigator.clipboard.readText()");
}
public async openSpotlight(): Promise<Spotlight> {
const spotlight = new Spotlight(this.page);
await spotlight.open();

View File

@@ -15,6 +15,7 @@ import type {
ICreateRoomOpts,
ISendEventResponse,
MatrixClient,
Room,
MatrixEvent,
ReceiptType,
IRoomDirectoryOptions,
@@ -177,12 +178,21 @@ export class Client {
*/
public async createRoom(options: ICreateRoomOpts): Promise<string> {
const client = await this.prepareClient();
const roomId = await client.evaluate(async (cli, options) => {
return await client.evaluate(async (cli, options) => {
const { room_id: roomId } = await cli.createRoom(options);
if (!cli.getRoom(roomId)) {
await new Promise<void>((resolve) => {
const onRoom = (room: Room) => {
if (room.roomId === roomId) {
cli.off(window.matrixcs.ClientEvent.Room, onRoom);
resolve();
}
};
cli.on(window.matrixcs.ClientEvent.Room, onRoom);
});
}
return roomId;
}, options);
await this.awaitRoomMembership(roomId);
return roomId;
}
/**

View File

@@ -155,13 +155,9 @@ export const test = base.extend<TestFixtures, Services & Options>({
{ scope: "worker" },
],
context: async (
{ homeserverType, synapseConfig, logger, context, request, _homeserver, homeserver },
use,
testInfo,
) => {
context: async ({ homeserverType, synapseConfig, logger, context, request, homeserver }, use, testInfo) => {
testInfo.skip(
!(_homeserver instanceof SynapseContainer) && Object.keys(synapseConfig).length > 0,
!(homeserver instanceof SynapseContainer) && Object.keys(synapseConfig).length > 0,
`Test specifies Synapse config options so is unsupported with ${homeserverType}`,
);
homeserver.setRequest(request);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -19,7 +19,7 @@ import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverCon
import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts";
import { Api, ClientServerApi, Verb } from "../plugins/utils/api.ts";
const TAG = "develop@sha256:5d62b61c4373eaca25df6c6bb99fc1be92f8f40b8abebd8897bf5b2af9eb137a";
const TAG = "develop@sha256:436278578c6b396d3a581f6af020edaff37dd7c3d26d20362de9e05e4a70cee8";
const DEFAULT_CONFIG = {
server_name: "localhost",

View File

@@ -283,7 +283,6 @@
@import "./views/rooms/_EventTile.pcss";
@import "./views/rooms/_HistoryTile.pcss";
@import "./views/rooms/_IRCLayout.pcss";
@import "./views/rooms/_InvitedIconView.pcss";
@import "./views/rooms/_JumpToBottomButton.pcss";
@import "./views/rooms/_LinkPreviewGroup.pcss";
@import "./views/rooms/_LinkPreviewWidget.pcss";
@@ -354,8 +353,10 @@
@import "./views/settings/_ThemeChoicePanel.pcss";
@import "./views/settings/_UpdateCheckButton.pcss";
@import "./views/settings/_UserProfileSettings.pcss";
@import "./views/settings/encryption/_AdvancedPanel.pcss";
@import "./views/settings/encryption/_ChangeRecoveryKey.pcss";
@import "./views/settings/encryption/_EncryptionCard.pcss";
@import "./views/settings/encryption/_ResetIdentityPanel.pcss";
@import "./views/settings/tabs/_SettingsBanner.pcss";
@import "./views/settings/tabs/_SettingsIndent.pcss";
@import "./views/settings/tabs/_SettingsSection.pcss";

View File

@@ -1,10 +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.
*/
.mx_InvitedIconView {
color: var(--cpd-color-icon-tertiary);
}

View File

@@ -14,10 +14,4 @@ Please see LICENSE files in the repository root for full details.
.mx_MemberListView_container {
height: 100%;
}
.mx_MemberListView_separator {
margin: 0;
border: none;
border-top: 2px solid var(--cpd-color-bg-subtle-primary);
}
}

View File

@@ -31,10 +31,9 @@ Please see LICENSE files in the repository root for full details.
min-width: 0;
}
.mx_MemberTileView_userLabel {
.mx_MemberTileView_user_label {
font: var(--cpd-font-body-sm-regular);
font-size: 13px;
color: var(--cpd-color-text-secondary);
}
.mx_MemberTileView_avatar {
@@ -42,4 +41,18 @@ Please see LICENSE files in the repository root for full details.
height: 32px;
width: 32px;
}
.mx_E2EIconView {
display: flex;
justify-content: center;
align-items: center;
}
.mx_E2EIconView_warning {
color: var(--cpd-color-icon-critical-primary);
}
.mx_E2EIconView_verified {
color: var(--cpd-color-icon-success-primary);
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2024 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.
*/
.mx_EncryptionDetails,
.mx_OtherSettings {
display: flex;
flex-direction: column;
gap: var(--cpd-space-6x);
width: 100%;
align-items: start;
.mx_EncryptionDetails_session_title,
.mx_OtherSettings_title {
font: var(--cpd-font-body-lg-semibold);
padding-bottom: var(--cpd-space-2x);
border-bottom: 1px solid var(--cpd-color-gray-400);
width: 100%;
margin: 0;
}
}
.mx_EncryptionDetails {
.mx_EncryptionDetails_session {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
width: 100%;
> div {
display: flex;
> span {
width: 50%;
word-wrap: break-word;
}
}
> div:nth-child(odd) {
background-color: var(--cpd-color-gray-200);
}
}
.mx_EncryptionDetails_buttons {
display: flex;
gap: var(--cpd-space-4x);
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2024 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.
*/
.mx_ResetIdentityPanel {
.mx_ResetIdentityPanel_content {
display: flex;
flex-direction: column;
gap: var(--cpd-space-3x);
> ul {
margin: 0;
list-style-type: none;
display: flex;
flex-direction: column;
gap: var(--cpd-space-1x);
> li {
padding: var(--cpd-space-2x) var(--cpd-space-3x);
}
}
> span {
font: var(--cpd-font-body-md-medium);
text-align: center;
}
}
.mx_ResetIdentityPanel_footer {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
justify-content: center;
}
}

View File

@@ -31,49 +31,50 @@ export async function createCrossSigning(cli: MatrixClient): Promise<void> {
throw new Error("No crypto API found!");
}
const doBootstrapUIAuth = async (
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
): Promise<void> => {
try {
await makeRequest({});
} catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
// Not a UIA response
throw error;
}
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("auth|uia|sso_preauth_body"),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("encryption|confirm_encryption_setup_title"),
body: _t("encryption|confirm_encryption_setup_body"),
continueText: _t("action|confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient: cli,
makeRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
}
};
await cryptoApi.bootstrapCrossSigning({
authUploadDeviceSigningKeys: doBootstrapUIAuth,
authUploadDeviceSigningKeys: (makeRequest) => uiAuthCallback(cli, makeRequest),
});
}
export async function uiAuthCallback(
matrixClient: MatrixClient,
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
): Promise<void> {
try {
await makeRequest({});
} catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
// Not a UIA response
throw error;
}
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("auth|uia|sso_preauth_body"),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("encryption|confirm_encryption_setup_title"),
body: _t("encryption|confirm_encryption_setup_body"),
continueText: _t("action|confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient,
makeRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
}
}

View File

@@ -34,9 +34,11 @@ import {
hideToast as hideUnverifiedSessionsToast,
showToast as showUnverifiedSessionsToast,
} from "./toasts/UnverifiedSessionToast";
import { isSecretStorageBeingAccessed } from "./SecurityManager";
import { accessSecretStorage, isSecretStorageBeingAccessed } from "./SecurityManager";
import { isSecureBackupRequired } from "./utils/WellKnownUtils";
import { ActionPayload } from "./dispatcher/payloads";
import { Action } from "./dispatcher/actions";
import { isLoggedIn } from "./utils/login";
import SdkConfig from "./SdkConfig";
import PlatformPeg from "./PlatformPeg";
import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation";
@@ -281,21 +283,7 @@ export default class DeviceListener {
const crossSigningReady = await crypto.isCrossSigningReady();
const secretStorageReady = await crypto.isSecretStorageReady();
const crossSigningStatus = await crypto.getCrossSigningStatus();
const allCrossSigningSecretsCached =
crossSigningStatus.privateKeysCachedLocally.masterKey &&
crossSigningStatus.privateKeysCachedLocally.selfSigningKey &&
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
const defaultKeyId = await cli.secretStorage.getDefaultKeyId();
const isCurrentDeviceTrusted =
crossSigningReady &&
Boolean(
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached;
const allSystemsReady = crossSigningReady && secretStorageReady;
await this.reportCryptoSessionStateToAnalytics(cli);
if (this.dismissedThisDeviceToast || allSystemsReady) {
@@ -306,31 +294,31 @@ export default class DeviceListener {
// make sure our keys are finished downloading
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
if (!crossSigningReady) {
// This account is legacy and doesn't have cross-signing set up at all.
// Prompt the user to set it up.
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
} else if (!isCurrentDeviceTrusted) {
// cross signing is ready but the current device is not trusted: prompt the user to verify
// cross signing isn't enabled - nag to enable it
// There are 3 different toasts for:
if (!(await crypto.getCrossSigningKeyId()) && (await crypto.userHasCrossSigningKeys())) {
// Toast 1. Cross-signing on account but this device doesn't trust the master key (verify this session)
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
} else if (!allCrossSigningSecretsCached) {
// cross signing ready & device trusted, but we are missing secrets from our local cache.
// prompt the user to enter their recovery key.
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
} else if (defaultKeyId === null) {
// the user just hasn't set up 4S yet: prompt them to do so
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
this.checkKeyBackupStatus();
} else {
// some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
// in 'other' situations. Possibly we should consider prompting for a full reset in this case?
logger.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", {
crossSigningReady,
secretStorageReady,
allCrossSigningSecretsCached,
isCurrentDeviceTrusted,
defaultKeyId,
});
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
const backupInfo = await this.getKeyBackupInfo();
if (backupInfo) {
// Toast 2: Key backup is enabled but recovery (4S) is not set up: prompt user to set up recovery.
// Since we now enable key backup at registration time, this will be the common case for
// new users.
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
} else {
// Toast 3: No cross-signing or key backup on account (set up encryption)
await cli.waitForClientWellKnown();
if (isSecureBackupRequired(cli) && isLoggedIn()) {
// If we're meant to set up, and Secure Backup is required,
// trigger the flow directly without a toast once logged in.
hideSetupEncryptionToast();
accessSecretStorage();
} else {
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
}
}
}
}
@@ -346,6 +334,12 @@ export default class DeviceListener {
// Unverified devices that have appeared since then
const newUnverifiedDeviceIds = new Set<string>();
const isCurrentDeviceTrusted =
crossSigningReady &&
Boolean(
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
// as long as cross-signing isn't ready,
// you can't see or dismiss any device toasts
if (crossSigningReady) {

View File

@@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { MatrixError, RuleId, TweakName, SyncState, TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { MatrixError, RuleId, TweakName, SyncState } from "matrix-js-sdk/src/matrix";
import {
CallError,
CallErrorCode,
@@ -22,6 +22,7 @@ import {
MatrixCall,
} from "matrix-js-sdk/src/webrtc/call";
import { logger } from "matrix-js-sdk/src/logger";
import EventEmitter from "events";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler";
@@ -136,23 +137,14 @@ export enum LegacyCallHandlerEvent {
CallChangeRoom = "call_change_room",
SilencedCallsChanged = "silenced_calls_changed",
CallState = "call_state",
ProtocolSupport = "protocol_support",
}
type EventEmitterMap = {
[LegacyCallHandlerEvent.CallsChanged]: (calls: Map<string, MatrixCall>) => void;
[LegacyCallHandlerEvent.CallChangeRoom]: (call: MatrixCall) => void;
[LegacyCallHandlerEvent.SilencedCallsChanged]: (calls: Set<string>) => void;
[LegacyCallHandlerEvent.CallState]: (mappedRoomId: string | null, status: CallState) => void;
[LegacyCallHandlerEvent.ProtocolSupport]: () => void;
};
/**
* LegacyCallHandler manages all currently active calls. It should be used for
* placing, answering, rejecting and hanging up calls. It also handles ringing,
* PSTN support and other things.
*/
export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandlerEvent, EventEmitterMap> {
export default class LegacyCallHandler extends EventEmitter {
private calls = new Map<string, MatrixCall>(); // roomId -> call
// Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one.
@@ -279,13 +271,15 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
this.supportsPstnProtocol = null;
}
dis.dispatch({ action: Action.PstnSupportUpdated });
if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) {
this.supportsSipNativeVirtual = Boolean(
protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL],
);
}
this.emit(LegacyCallHandlerEvent.ProtocolSupport);
dis.dispatch({ action: Action.VirtualRoomSupportUpdated });
} catch (e) {
if (maxTries === 1) {
logger.log("Failed to check for protocol support and no retries remain: assuming no support", e);
@@ -302,8 +296,8 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
return !!SdkConfig.getObject("voip")?.get("obey_asserted_identity");
}
public getSupportsPstnProtocol(): boolean {
return this.supportsPstnProtocol ?? false;
public getSupportsPstnProtocol(): boolean | null {
return this.supportsPstnProtocol;
}
public getSupportsVirtualRooms(): boolean | null {
@@ -574,7 +568,6 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
if (!mappedRoomId || !this.matchesCallForThisRoom(call)) return;
this.setCallState(call, newState);
// XXX: this is used by the IPC into Electron to keep device awake
dis.dispatch({
action: "call_state",
room_id: mappedRoomId,

View File

@@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details.
import React, { StrictMode } from "react";
import { createRoot, Root } from "react-dom/client";
import classNames from "classnames";
import { IDeferred, defer } from "matrix-js-sdk/src/utils";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { Glass, TooltipProvider } from "@vector-im/compound-web";
@@ -44,7 +45,7 @@ export interface IModal<C extends ComponentType> {
onFinished: ComponentProps<C>["onFinished"];
close(...args: Parameters<ComponentProps<C>["onFinished"]>): void;
hidden?: boolean;
deferred?: PromiseWithResolvers<Parameters<ComponentProps<C>["onFinished"]>>;
deferred?: IDeferred<Parameters<ComponentProps<C>["onFinished"]>>;
}
export interface IHandle<C extends ComponentType> {
@@ -213,7 +214,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
modal: IModal<C>,
props?: ComponentProps<C>,
): [IHandle<C>["close"], IHandle<C>["finished"]] {
modal.deferred = Promise.withResolvers<Parameters<ComponentProps<C>["onFinished"]>>();
modal.deferred = defer<Parameters<ComponentProps<C>["onFinished"]>>();
return [
async (...args: Parameters<ComponentProps<C>["onFinished"]>): Promise<void> => {
if (modal.beforeClosePromise) {

View File

@@ -46,7 +46,7 @@ import {
SlidingSync,
} from "matrix-js-sdk/src/sliding-sync";
import { logger } from "matrix-js-sdk/src/logger";
import { sleep } from "matrix-js-sdk/src/utils";
import { defer, sleep } from "matrix-js-sdk/src/utils";
import SettingsStore from "./settings/SettingsStore";
import SlidingSyncController from "./settings/controllers/SlidingSyncController";
@@ -110,7 +110,7 @@ export class SlidingSyncManager {
public slidingSync?: SlidingSync;
private client?: MatrixClient;
private configureDefer = Promise.withResolvers<void>();
private configureDefer = defer<void>();
public static get instance(): SlidingSyncManager {
return SlidingSyncManager.internalInstance;

View File

@@ -6,12 +6,14 @@ 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 { defer, IDeferred } from "matrix-js-sdk/src/utils";
import { WorkerPayload } from "./workers/worker";
export class WorkerManager<Request extends {}, Response> {
private readonly worker: Worker;
private seq = 0;
private pendingDeferredMap = new Map<number, PromiseWithResolvers<Response>>();
private pendingDeferredMap = new Map<number, IDeferred<Response>>();
public constructor(worker: Worker) {
this.worker = worker;
@@ -28,7 +30,7 @@ export class WorkerManager<Request extends {}, Response> {
public call(request: Request): Promise<Response> {
const seq = this.seq++;
const deferred = Promise.withResolvers<Response>();
const deferred = defer<Response>();
this.pendingDeferredMap.set(seq, deferred);
this.worker.postMessage({ seq, ...request });
return deferred.promise;

View File

@@ -392,7 +392,6 @@ export const useRovingTabIndex = <T extends HTMLElement>(
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-compiler/react-compiler
const isActive = context.state.activeNode === nodeRef.current;
return [onFocus, isActive, ref, nodeRef];
};

View File

@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import EventEmitter from "events";
import { SimpleObservable } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import { defer } from "matrix-js-sdk/src/utils";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { arrayFastResample } from "../utils/arrays";
@@ -157,7 +158,7 @@ export class Playback extends EventEmitter implements IDestroyable, PlaybackInte
// 5mb
logger.log("Audio file too large: processing through <audio /> element");
this.element = document.createElement("AUDIO") as HTMLAudioElement;
const deferred = Promise.withResolvers<unknown>();
const deferred = defer<unknown>();
this.element.onloadeddata = deferred.resolve;
this.element.onerror = deferred.reject;
this.element.src = URL.createObjectURL(new Blob([this.buf]));

View File

@@ -142,7 +142,6 @@ export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
{isFocused && suggestions.length ? (
<div
className="mx_AutocompleteInput_matches"
// eslint-disable-next-line react-compiler/react-compiler
style={{ top: editorContainerRef.current?.clientHeight }}
data-testid="autocomplete-matches"
>

View File

@@ -607,7 +607,6 @@ export const useContextMenu = <T extends any = HTMLElement>(inputRef?: RefObject
setIsOpen(false);
};
// eslint-disable-next-line react-compiler/react-compiler
return [button.current ? isOpen : false, button, open, close, setIsOpen];
};

View File

@@ -286,7 +286,9 @@ class FilePanel extends React.Component<IProps, IState> {
ref={this.card}
header={_t("right_panel|files_button")}
>
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
{this.card.current && (
<Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />
)}
<SearchWarning isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
<TimelinePanel
manageReadReceipts={false}

View File

@@ -13,7 +13,7 @@ import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
import LegacyCallHandler from "../../LegacyCallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import RoomSearch from "./RoomSearch";
@@ -51,7 +51,6 @@ enum BreadcrumbsMode {
interface IState {
showBreadcrumbs: BreadcrumbsMode;
activeSpace: SpaceKey;
supportsPstnProtocol: boolean;
}
export default class LeftPanel extends React.Component<IProps, IState> {
@@ -66,7 +65,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.state = {
activeSpace: SpaceStore.instance.activeSpace,
showBreadcrumbs: LeftPanel.breadcrumbsMode,
supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol(),
};
}
@@ -78,7 +76,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
LegacyCallHandler.instance.on(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport);
if (this.listContainerRef.current) {
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
@@ -93,7 +90,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
LegacyCallHandler.instance.off(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport);
UIStore.instance.stopTrackingElementDimensions("ListContainer");
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
this.listContainerRef.current?.removeEventListener("scroll", this.onScroll);
@@ -105,10 +101,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
}
private updateProtocolSupport = (): void => {
this.setState({ supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol() });
};
private updateActiveSpace = (activeSpace: SpaceKey): void => {
this.setState({ activeSpace });
};
@@ -338,8 +330,9 @@ export default class LeftPanel extends React.Component<IProps, IState> {
private renderSearchDialExplore(): React.ReactNode {
let dialPadButton: JSX.Element | undefined;
// If we have dialer support, show a button to bring up the dial pad to start a new call
if (this.state.supportsPstnProtocol) {
// If we have dialer support, show a button to bring up the dial pad
// to start a new call
if (LegacyCallHandler.instance.getSupportsPstnProtocol()) {
dialPadButton = (
<AccessibleButton
className={classNames("mx_LeftPanel_dialPadButton", {})}

View File

@@ -19,7 +19,7 @@ import {
SyncStateData,
TimelineEvents,
} from "matrix-js-sdk/src/matrix";
import { QueryDict } from "matrix-js-sdk/src/utils";
import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
import { logger } from "matrix-js-sdk/src/logger";
import { throttle } from "lodash";
import { CryptoEvent, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
@@ -217,7 +217,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
};
private firstSyncComplete = false;
private firstSyncPromise: PromiseWithResolvers<void>;
private firstSyncPromise: IDeferred<void>;
private screenAfterLogin?: IScreen;
private tokenLogin?: boolean;
@@ -255,7 +255,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// Used by _viewRoom before getting state from sync
this.firstSyncComplete = false;
this.firstSyncPromise = Promise.withResolvers();
this.firstSyncPromise = defer();
if (this.props.config.sync_timeline_limit) {
MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit;
@@ -1503,7 +1503,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// since we're about to start the client and therefore about
// to do the first sync
this.firstSyncComplete = false;
this.firstSyncPromise = Promise.withResolvers();
this.firstSyncPromise = defer();
const cli = MatrixClientPeg.safeGet();
// Allow the JS SDK to reap timeline events. This reduces the amount of

View File

@@ -95,7 +95,7 @@ export default class NotificationPanel extends React.PureComponent<IProps, IStat
onClose={this.props.onClose}
withoutScrollContainer={true}
>
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
{this.card.current && <Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />}
{content}
</BaseCard>
</ScopedRoomContextProvider>

View File

@@ -6,7 +6,7 @@ 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, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import React, { forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react";
import {
ISearchResults,
IThreadBundledRelationship,
@@ -58,7 +58,7 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
const [results, setResults] = useState<ISearchResults | null>(null);
const aborted = useRef(false);
// A map from room ID to permalink creator
const permalinkCreators = useMemo(() => new Map<string, RoomPermalinkCreator>(), []);
const permalinkCreators = useRef(new Map<string, RoomPermalinkCreator>()).current;
const innerRef = useRef<ScrollPanel | null>();
useEffect(() => {

View File

@@ -273,7 +273,6 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
}
const onRetryClicked = (): void => {
// eslint-disable-next-line react-compiler/react-compiler
room.state = LocalRoomState.NEW;
defaultDispatcher.dispatch({
action: "local_room_event",
@@ -1083,7 +1082,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
};
private onCallState = (roomId: string | null): void => {
private onCallState = (roomId: string): void => {
// don't filter out payloads for room IDs other than props.room because
// we may be interested in the conf 1:1 room
@@ -2515,7 +2514,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
mainSplitContentClassName = "mx_MainSplit_timeline";
mainSplitBody = (
<>
<Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} />
{this.roomViewBody.current && (
<Measured sensor={this.roomViewBody.current} onMeasurement={this.onMeasurement} />
)}
{auxPanel}
{pinnedMessageBanner}
<main className={timelineClasses}>

View File

@@ -204,7 +204,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
ref={card}
closeButtonRef={closeButonRef}
>
<Measured sensor={card} onMeasurement={setNarrow} />
{card.current && <Measured sensor={card.current} onMeasurement={setNarrow} />}
{timelineSet ? (
<TimelinePanel
key={filterOption + ":" + (timelineSet.getFilter()?.filterId ?? roomId)}

View File

@@ -443,7 +443,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
}}
>
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
{this.card.current && <Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />}
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { useMemo } from "react";
import React, { useEffect, useRef } from "react";
type FlexProps = {
/**
@@ -40,6 +40,25 @@ type FlexProps = {
grow?: string | null;
};
/**
* Set or remove a CSS property
* @param ref the reference
* @param name the CSS property name
* @param value the CSS property value
*/
function addOrRemoveProperty(
ref: React.MutableRefObject<HTMLElement | undefined>,
name: string,
value?: string | null,
): void {
const style = ref.current!.style;
if (value) {
style.setProperty(name, value);
} else {
style.removeProperty(name);
}
}
/**
* A flex child helper
*/
@@ -52,12 +71,12 @@ export function Box({
children,
...props
}: React.PropsWithChildren<FlexProps>): JSX.Element {
const style = useMemo(() => {
const style: Record<string, any> = {};
if (flex) style["--mx-box-flex"] = flex;
if (shrink) style["--mx-box-shrink"] = shrink;
if (grow) style["--mx-box-grow"] = grow;
return style;
const ref = useRef<HTMLElement>();
useEffect(() => {
addOrRemoveProperty(ref, `--mx-box-flex`, flex);
addOrRemoveProperty(ref, `--mx-box-shrink`, shrink);
addOrRemoveProperty(ref, `--mx-box-grow`, grow);
}, [flex, grow, shrink]);
return React.createElement(
@@ -69,7 +88,7 @@ export function Box({
"mx_Box--shrink": !!shrink,
"mx_Box--grow": !!grow,
}),
style,
ref,
},
children,
);

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { useMemo } from "react";
import React, { useEffect, useRef } from "react";
type FlexProps = {
/**
@@ -64,16 +64,15 @@ export function Flex({
children,
...props
}: React.PropsWithChildren<FlexProps>): JSX.Element {
const style = useMemo(
() => ({
"--mx-flex-display": display,
"--mx-flex-direction": direction,
"--mx-flex-align": align,
"--mx-flex-justify": justify,
"--mx-flex-gap": gap,
}),
[align, direction, display, gap, justify],
);
const ref = useRef<HTMLElement>();
return React.createElement(as, { ...props, className: classNames("mx_Flex", className), style }, children);
useEffect(() => {
ref.current!.style.setProperty(`--mx-flex-display`, display);
ref.current!.style.setProperty(`--mx-flex-direction`, direction);
ref.current!.style.setProperty(`--mx-flex-align`, align);
ref.current!.style.setProperty(`--mx-flex-justify`, justify);
ref.current!.style.setProperty(`--mx-flex-gap`, gap);
}, [align, direction, display, gap, justify]);
return React.createElement(as, { ...props, className: classNames("mx_Flex", className), ref }, children);
}

View File

@@ -19,7 +19,7 @@ import {
UserEvent,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { throttle } from "lodash";
import { RoomMember } from "../../../models/rooms/RoomMember";
@@ -99,12 +99,8 @@ export function sdkRoomMemberToRoomMember(member: SdkRoomMember): Member {
};
}
export const SEPARATOR = "SEPARATOR";
export type MemberWithSeparator = Member | typeof SEPARATOR;
export interface MemberListViewState {
members: MemberWithSeparator[];
memberCount: number;
members: Member[];
search: (searchQuery: string) => void;
isPresenceEnabled: boolean;
shouldShowInvite: boolean;
@@ -122,65 +118,60 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
}
const sdkContext = useContext(SDKContext);
const [memberMap, setMemberMap] = useState<Map<string, MemberWithSeparator>>(new Map());
const [memberMap, setMemberMap] = useState<Map<string, Member>>(new Map());
const [isLoading, setIsLoading] = useState<boolean>(true);
// This is the last known total number of members in this room.
const [totalMemberCount, setTotalMemberCount] = useState(0);
/**
* This is the current number of members in the list.
* This number will be less than the total number of members
* in the room when the search functionality is used.
*/
const [memberCount, setMemberCount] = useState(0);
const totalMemberCount = useRef<number>(0);
const searchQuery = useRef("");
const loadMembers = useMemo(
() =>
throttle(
async (searchQuery?: string): Promise<void> => {
async (): Promise<void> => {
const { joined: joinedSdk, invited: invitedSdk } = await sdkContext.memberListStore.loadMemberList(
roomId,
searchQuery,
searchQuery.current,
);
const threePidInvited = getPending3PidInvites(room, searchQuery);
const newMemberMap = new Map<string, MemberWithSeparator>();
// First add the joined room members
for (const member of joinedSdk) {
const roomMember = sdkRoomMemberToRoomMember(member);
newMemberMap.set(member.userId, roomMember);
}
// Then a separator if needed
if (joinedSdk.length > 0 && (invitedSdk.length > 0 || threePidInvited.length > 0))
newMemberMap.set(SEPARATOR, SEPARATOR);
// Then add the invited room members
const newMemberMap = new Map<string, Member>();
// First add the invited room members
for (const member of invitedSdk) {
const roomMember = sdkRoomMemberToRoomMember(member);
newMemberMap.set(member.userId, roomMember);
}
// Finally add the third party invites
// Then add the third party invites
const threePidInvited = getPending3PidInvites(room, searchQuery.current);
for (const invited of threePidInvited) {
const key = invited.threePidInvite!.event.getContent().display_name;
newMemberMap.set(key, invited);
}
// Finally add the joined room members
for (const member of joinedSdk) {
const roomMember = sdkRoomMemberToRoomMember(member);
newMemberMap.set(member.userId, roomMember);
}
setMemberMap(newMemberMap);
setMemberCount(joinedSdk.length + invitedSdk.length + threePidInvited.length);
if (!searchQuery) {
if (!searchQuery.current) {
/**
* Since searching for members only gives you the relevant
* members matching the query, do not update the totalMemberCount!
**/
setTotalMemberCount(newMemberMap.size);
totalMemberCount.current = newMemberMap.size;
}
},
500,
{ leading: true, trailing: true },
),
[sdkContext.memberListStore, roomId, room],
[roomId, sdkContext.memberListStore, room],
);
const search = useCallback(
(query: string) => {
searchQuery.current = query;
loadMembers();
},
[loadMembers],
);
const isPresenceEnabled = useMemo(
@@ -261,13 +252,12 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
return {
members: Array.from(memberMap.values()),
memberCount,
search: loadMembers,
search,
shouldShowInvite,
isPresenceEnabled,
isLoading,
onInviteButtonClick,
shouldShowSearch: totalMemberCount >= 20,
shouldShowSearch: totalMemberCount.current >= 20,
canInvite,
};
}

View File

@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
import dis from "../../../../dispatcher/dispatcher";
import { Action } from "../../../../dispatcher/actions";
import { ThreePIDInvite } from "../../../../models/rooms/ThreePIDInvite";
import { _t } from "../../../../languageHandler";
interface ThreePidTileViewModelProps {
threePidInvite: ThreePIDInvite;
@@ -17,7 +16,6 @@ interface ThreePidTileViewModelProps {
export interface ThreePidTileViewState {
name: string;
onClick: () => void;
userLabel?: string;
}
export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): ThreePidTileViewState {
@@ -30,11 +28,8 @@ export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): Thr
});
};
const userLabel = `(${_t("member_list|invited_label")})`;
return {
name,
onClick,
userLabel,
};
}

View File

@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { MatrixEvent, EventType, RelationType, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { defer } from "matrix-js-sdk/src/utils";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -57,7 +58,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent<IProps
const eventId = this.props.mxEvent.getId()!;
const client = MatrixClientPeg.safeGet();
const { resolve, reject, promise } = Promise.withResolvers<boolean>();
const { resolve, reject, promise } = defer<boolean>();
let result: Awaited<ReturnType<MatrixClient["relations"]>>;
try {

View File

@@ -58,10 +58,11 @@ const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
if (canvas) canvas.height = UIStore.instance.windowHeight;
UIStore.instance.on(UI_EVENTS.Resize, resize);
const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored
return () => {
dis.unregister(dispatcherRef);
UIStore.instance.off(UI_EVENTS.Resize, resize);
// eslint-disable-next-line react-hooks/exhaustive-deps
const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored
for (const effect in currentEffects) {
const effectModule: ICanvasEffect = currentEffects.get(effect)!;
if (effectModule && effectModule.isRunning) {

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import classNames from "classnames";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { randomString } from "matrix-js-sdk/src/randomstring";
import ToggleSwitch from "./ToggleSwitch";
import { Caption } from "../typography/Caption";
@@ -36,7 +36,7 @@ interface IProps {
}
export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
private readonly id = `mx_LabelledToggleSwitch_${secureRandomString(12)}`;
private readonly id = `mx_LabelledToggleSwitch_${randomString(12)}`;
public render(): React.ReactNode {
// This is a minimal version of a SettingsFlag

View File

@@ -6,12 +6,12 @@ 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, { RefObject } from "react";
import React from "react";
import UIStore, { UI_EVENTS } from "../../../stores/UIStore";
interface IProps {
sensor: RefObject<Element>;
sensor: Element;
breakpoint: number;
onMeasurement(narrow: boolean): void;
}
@@ -35,14 +35,14 @@ export default class Measured extends React.PureComponent<IProps> {
}
public componentDidUpdate(prevProps: Readonly<IProps>): void {
const previous = prevProps.sensor.current;
const current = this.props.sensor.current;
const previous = prevProps.sensor;
const current = this.props.sensor;
if (previous === current) return;
if (previous) {
UIStore.instance.stopTrackingElementDimensions(`Measured${this.instanceId}`);
}
if (current) {
UIStore.instance.trackElementDimensions(`Measured${this.instanceId}`, this.props.sensor.current);
UIStore.instance.trackElementDimensions(`Measured${this.instanceId}`, this.props.sensor);
}
}

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { randomString } from "matrix-js-sdk/src/randomstring";
import SettingsStore from "../../../settings/SettingsStore";
import { _t } from "../../../languageHandler";
@@ -35,7 +35,7 @@ interface IState {
}
export default class SettingsFlag extends React.Component<IProps, IState> {
private readonly id = `mx_SettingsFlag_${secureRandomString(12)}`;
private readonly id = `mx_SettingsFlag_${randomString(12)}`;
public constructor(props: IProps) {
super(props);

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { Ref } from "react";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { randomString } from "matrix-js-sdk/src/randomstring";
import classnames from "classnames";
export enum CheckboxStyle {
@@ -33,7 +33,7 @@ export default class StyledCheckbox extends React.PureComponent<IProps, IState>
public constructor(props: IProps) {
super(props);
// 56^10 so unlikely chance of collision.
this.id = this.props.id || "checkbox_" + secureRandomString(10);
this.id = this.props.id || "checkbox_" + randomString(10);
}
public render(): React.ReactNode {

View File

@@ -18,7 +18,7 @@ import {
ContentHelpers,
M_BEACON,
} from "matrix-js-sdk/src/matrix";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { randomString } from "matrix-js-sdk/src/randomstring";
import classNames from "classnames";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
@@ -81,10 +81,10 @@ const useBeaconState = (
// eg thread and main timeline, reply
// maplibregl needs a unique id to attach the map instance to
const useUniqueId = (eventId: string): string => {
const [id, setId] = useState(`${eventId}_${secureRandomString(8)}`);
const [id, setId] = useState(`${eventId}_${randomString(8)}`);
useEffect(() => {
setId(`${eventId}_${secureRandomString(8)}`);
setId(`${eventId}_${randomString(8)}`);
}, [eventId]);
return id;

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { MatrixEvent, ClientEvent, ClientEventHandlerMap } from "matrix-js-sdk/src/matrix";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { Tooltip } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
@@ -41,7 +41,7 @@ export default class MLocationBody extends React.Component<IBodyProps, IState> {
// multiple instances of same map might be in document
// eg thread and main timeline, reply
const idSuffix = `${props.mxEvent.getId()}_${secureRandomString(8)}`;
const idSuffix = `${props.mxEvent.getId()}_${randomString(8)}`;
this.mapId = `mx_MLocationBody_${idSuffix}`;
this.reconnectedListener = createReconnectedListener(this.clearError);

View File

@@ -213,7 +213,7 @@ export default class TimelineCard extends React.Component<IProps, IState> {
header={_t("right_panel|video_room_chat|title")}
ref={this.card}
>
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
{this.card.current && <Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />}
<div className="mx_TimelineCard_timeline">
{jumpToBottom}
<TimelinePanel

View File

@@ -10,6 +10,7 @@ import React, { createRef, KeyboardEvent, RefObject } from "react";
import classNames from "classnames";
import { flatMap } from "lodash";
import { Room } from "matrix-js-sdk/src/matrix";
import { defer } from "matrix-js-sdk/src/utils";
import Autocompleter, { ICompletion, ISelectionRange, IProviderCompletions } from "../../../autocomplete/Autocompleter";
import SettingsStore from "../../../settings/SettingsStore";
@@ -172,7 +173,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
}
}
const deferred = Promise.withResolvers<void>();
const deferred = defer<void>();
this.setState(
{
completions,

View File

@@ -88,10 +88,12 @@ function getHeaderLabelJSX(vm: MemberListViewState): React.ReactNode {
</Flex>
);
}
if (vm.memberCount === 0) {
const filteredMemberCount = vm.members.length;
if (filteredMemberCount === 0) {
return _t("member_list|no_matches");
}
return _t("member_list|count", { count: vm.memberCount });
return _t("member_list|count", { count: filteredMemberCount });
}
export const MemberListHeaderView: React.FC<Props> = (props: Props) => {

View File

@@ -11,11 +11,7 @@ import { List, ListRowProps } from "react-virtualized/dist/commonjs/List";
import { AutoSizer } from "react-virtualized";
import { Flex } from "../../../utils/Flex";
import {
MemberWithSeparator,
SEPARATOR,
useMemberListViewModel,
} from "../../../viewmodels/memberlist/MemberListViewModel";
import { useMemberListViewModel } from "../../../viewmodels/memberlist/MemberListViewModel";
import { RoomMemberTileView } from "./tiles/RoomMemberTileView";
import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
import { MemberListHeaderView } from "./MemberListHeaderView";
@@ -30,41 +26,10 @@ interface IProps {
const MemberListView: React.FC<IProps> = (props: IProps) => {
const vm = useMemberListViewModel(props.roomId);
const totalRows = vm.members.length;
const getRowComponent = (item: MemberWithSeparator): React.JSX.Element => {
if (item === SEPARATOR) {
return <hr className="mx_MemberListView_separator" />;
} else if (item.member) {
return <RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />;
} else {
return <ThreePidInviteTileView threePidInvite={item.threePidInvite} />;
}
};
const getRowHeight = ({ index }: { index: number }): number => {
if (vm.members[index] === SEPARATOR) {
/**
* This is a separator of 2px height rendered between
* joined and invited members.
*/
return 2;
} else if (totalRows && index === totalRows) {
/**
* The empty spacer div rendered at the bottom should
* have a height of 32px.
*/
return 32;
} else {
/**
* The actual member tiles have a height of 56px.
*/
return 56;
}
};
const memberCount = vm.members.length;
const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => {
if (index === totalRows) {
if (index === memberCount) {
// We've rendered all the members,
// now we render an empty div to add some space to the end of the list.
return <div key={key} style={style} />;
@@ -72,7 +37,11 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
const item = vm.members[index];
return (
<div key={key} style={style}>
{getRowComponent(item)}
{item.member ? (
<RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />
) : (
<ThreePidInviteTileView threePidInvite={item.threePidInvite} />
)}
</div>
);
};
@@ -94,9 +63,11 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
{({ height, width }) => (
<List
rowRenderer={rowRenderer}
rowHeight={getRowHeight}
// All the member tiles will have a height of 56px.
// The additional empty div at the end of the list should have a height of 32px.
rowHeight={({ index }) => (index === memberCount ? 32 : 56)}
// The +1 refers to the additional empty div that we render at the end of the list.
rowCount={totalRows + 1}
rowCount={memberCount + 1}
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
height={height - 113}
width={width}

View File

@@ -14,8 +14,7 @@ import { E2EIconView } from "./common/E2EIconView";
import AvatarPresenceIconView from "./common/PresenceIconView";
import BaseAvatar from "../../../avatars/BaseAvatar";
import { _t } from "../../../../../languageHandler";
import { MemberTileView } from "./common/MemberTileView";
import { InvitedIconView } from "./common/InvitedIconView";
import { MemberTileLayout } from "./common/MemberTileLayout";
interface IProps {
member: RoomMember;
@@ -44,23 +43,25 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
presenceJSX = <AvatarPresenceIconView presenceState={presenceState} />;
}
let iconJsx;
if (vm.e2eStatus) {
iconJsx = <E2EIconView status={vm.e2eStatus} />;
let userLabelJSX;
if (vm.userLabel) {
userLabelJSX = <div className="mx_MemberTileView_user_label">{vm.userLabel}</div>;
}
if (member.isInvite) {
iconJsx = <InvitedIconView isThreePid={false} />;
let e2eIcon;
if (vm.e2eStatus) {
e2eIcon = <E2EIconView status={vm.e2eStatus} />;
}
return (
<MemberTileView
<MemberTileLayout
title={vm.title}
onClick={vm.onClick}
avatarJsx={av}
presenceJsx={presenceJSX}
nameJsx={nameJSX}
userLabel={vm.userLabel}
iconJsx={iconJsx}
userLabelJsx={userLabelJSX}
e2eIconJsx={e2eIcon}
/>
);
}

View File

@@ -10,8 +10,7 @@ import React from "react";
import { useThreePidTileViewModel } from "../../../../viewmodels/memberlist/tiles/ThreePidTileViewModel";
import { ThreePIDInvite } from "../../../../../models/rooms/ThreePIDInvite";
import BaseAvatar from "../../../avatars/BaseAvatar";
import { MemberTileView } from "./common/MemberTileView";
import { InvitedIconView } from "./common/InvitedIconView";
import { MemberTileLayout } from "./common/MemberTileLayout";
interface Props {
threePidInvite: ThreePIDInvite;
@@ -20,15 +19,5 @@ interface Props {
export function ThreePidInviteTileView(props: Props): JSX.Element {
const vm = useThreePidTileViewModel(props);
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
const iconJsx = <InvitedIconView isThreePid={true} />;
return (
<MemberTileView
nameJsx={vm.name}
avatarJsx={av}
onClick={vm.onClick}
userLabel={vm.userLabel}
iconJsx={iconJsx}
/>
);
return <MemberTileLayout nameJsx={vm.name} avatarJsx={av} onClick={vm.onClick} />;
}

View File

@@ -1,25 +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 from "react";
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
import { Flex } from "../../../../../utils/Flex";
interface Props {
isThreePid: boolean;
}
export function InvitedIconView({ isThreePid }: Props): JSX.Element {
const Icon = isThreePid ? EmailIcon : UserAddIcon;
return (
<Flex align="center" className="mx_InvitedIconView">
<Icon height="16px" width="16px" />
</Flex>
);
}

View File

@@ -15,16 +15,11 @@ interface Props {
onClick: () => void;
title?: string;
presenceJsx?: JSX.Element;
userLabel?: React.ReactNode;
iconJsx?: JSX.Element;
userLabelJsx?: JSX.Element;
e2eIconJsx?: JSX.Element;
}
export function MemberTileView(props: Props): JSX.Element {
let userLabelJsx: React.ReactNode;
if (props.userLabel) {
userLabelJsx = <div className="mx_MemberTileView_userLabel">{props.userLabel}</div>;
}
export function MemberTileLayout(props: Props): JSX.Element {
return (
// The wrapping div is required to make the magic mouse listener work, for some reason.
<div>
@@ -36,8 +31,8 @@ export function MemberTileView(props: Props): JSX.Element {
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
</div>
<div className="mx_MemberTileView_right">
{userLabelJsx}
{props.iconJsx}
{props.userLabelJsx}
{props.e2eIconJsx}
</div>
</AccessibleButton>
</div>

View File

@@ -109,7 +109,6 @@ export function ReadReceiptGroup({
readReceiptPosition = readReceiptMap[userId];
if (!readReceiptPosition) {
readReceiptPosition = {};
// eslint-disable-next-line react-compiler/react-compiler
readReceiptMap[userId] = readReceiptPosition;
}
}

View File

@@ -6,7 +6,7 @@ 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 { EventType, Room, RoomType } from "matrix-js-sdk/src/matrix";
import { EventType, RoomType, Room } from "matrix-js-sdk/src/matrix";
import React, { ComponentType, createRef, ReactComponentElement, SyntheticEvent } from "react";
import { IState as IRovingTabIndexState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
@@ -56,7 +56,6 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import AccessibleButton from "../elements/AccessibleButton";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler.tsx";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void;
@@ -441,7 +440,6 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate);
SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
LegacyCallHandler.instance.on(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport);
this.updateLists(); // trigger the first update
}
@@ -450,13 +448,8 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
defaultDispatcher.unregister(this.dispatcherRef);
SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate);
LegacyCallHandler.instance.off(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport);
}
private updateProtocolSupport = (): void => {
this.updateLists();
};
private onRoomViewStoreUpdate = (): void => {
this.setState({
currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined,
@@ -478,6 +471,8 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
metricsViaKeyboard: true,
});
}
} else if (payload.action === Action.PstnSupportUpdated) {
this.updateLists();
}
};

View File

@@ -5,7 +5,7 @@ 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, useEffect, useRef, useState } from "react";
import React, { useCallback, useRef, useState } from "react";
import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix";
import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
@@ -213,11 +213,9 @@ export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }
initialisedRef.current = InitialisationStatus.Completed;
}, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]);
useEffect(() => {
loadMembers().catch((e) => {
logger.error("Error initialising UserIdentityWarning:", e);
});
}, [loadMembers]);
loadMembers().catch((e) => {
logger.error("Error initialising UserIdentityWarning:", e);
});
// When a user's verification status changes, we check if they need to be
// added/removed from the set of members needing approval.

View File

@@ -6,7 +6,7 @@ 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, { ForwardedRef, forwardRef, MutableRefObject, useMemo } from "react";
import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react";
import classNames from "classnames";
import EditorStateTransfer from "../../../../utils/EditorStateTransfer";
@@ -44,7 +44,7 @@ export default function EditWysiwygComposer({
className,
...props
}: EditWysiwygComposerProps): JSX.Element {
const defaultContextValue = useMemo(() => getDefaultContextValue({ editorStateTransfer }), [editorStateTransfer]);
const defaultContextValue = useRef(getDefaultContextValue({ editorStateTransfer }));
const initialContent = useInitialContent(editorStateTransfer);
const isReady = !editorStateTransfer || initialContent !== undefined;
@@ -55,7 +55,7 @@ export default function EditWysiwygComposer({
}
return (
<ComposerContext.Provider value={defaultContextValue}>
<ComposerContext.Provider value={defaultContextValue.current}>
<WysiwygComposer
className={classNames("mx_EditWysiwygComposer", className)}
initialContent={initialContent}

View File

@@ -6,7 +6,7 @@ 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, { ForwardedRef, forwardRef, MutableRefObject, useMemo } from "react";
import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react";
import { IEventRelation } from "matrix-js-sdk/src/matrix";
import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler";
@@ -52,13 +52,10 @@ export default function SendWysiwygComposer({
...props
}: SendWysiwygComposerProps): JSX.Element {
const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer;
const defaultContextValue = useMemo(
() => getDefaultContextValue({ eventRelation: props.eventRelation }),
[props.eventRelation],
);
const defaultContextValue = useRef(getDefaultContextValue({ eventRelation: props.eventRelation }));
return (
<ComposerContext.Provider value={defaultContextValue}>
<ComposerContext.Provider value={defaultContextValue.current}>
<Composer
className="mx_SendWysiwygComposer"
leftComponent={e2eStatus && <E2EIcon status={e2eStatus} />}

View File

@@ -21,7 +21,6 @@ export function useComposerFunctions(
() => ({
clear: () => {
if (ref.current) {
// eslint-disable-next-line react-compiler/react-compiler
ref.current.innerHTML = "";
}
},

View File

@@ -12,7 +12,6 @@ export function usePlainTextInitialization(initialContent = "", ref: RefObject<H
useEffect(() => {
// always read and write the ref.current using .innerHTML for consistency in linebreak and HTML entity handling
if (ref.current) {
// eslint-disable-next-line react-compiler/react-compiler
ref.current.innerHTML = initialContent;
}
}, [ref, initialContent]);

View File

@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent, JSX, useCallback, useMemo, useState } from "react";
import React, { ChangeEvent, JSX, useCallback, useMemo, useRef, useState } from "react";
import {
InlineField,
ToggleControl,
@@ -39,12 +39,12 @@ import { useSettingValue } from "../../../hooks/useSettings";
*/
export function ThemeChoicePanel(): JSX.Element {
const themeState = useTheme();
const themeWatcher = useMemo(() => new ThemeWatcher(), []);
const themeWatcher = useRef(new ThemeWatcher());
const customThemeEnabled = useSettingValue("feature_custom_themes");
return (
<SettingsSubsection heading={_t("common|theme")} legacy={false} data-testid="themePanel">
{themeWatcher.isSystemThemeSupported() && (
{themeWatcher.current.isSystemThemeSupported() && (
<SystemTheme systemThemeActivated={themeState.systemThemeActivated} />
)}
<ThemeSelectors theme={themeState.theme} disabled={themeState.systemThemeActivated} />

View File

@@ -0,0 +1,146 @@
/*
* Copyright 2024 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, { JSX, lazy, MouseEventHandler } from "react";
import { Button, HelpMessage, InlineField, InlineSpinner, Label, Root, ToggleControl } from "@vector-im/compound-web";
import DownloadIcon from "@vector-im/compound-design-tokens/assets/web/icons/download";
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
import { _t } from "../../../../languageHandler";
import { SettingsSection } from "../shared/SettingsSection";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import Modal from "../../../../Modal";
import { SettingLevel } from "../../../../settings/SettingLevel";
import { useSettingValueAt } from "../../../../hooks/useSettings";
import SettingsStore from "../../../../settings/SettingsStore";
interface AdvancedPanelProps {
/**
* Callback for when the user clicks the button to reset their identity.
*/
onResetIdentityClick: MouseEventHandler<HTMLButtonElement>;
}
/**
* The advanced panel of the encryption settings.
*/
export function AdvancedPanel({ onResetIdentityClick }: AdvancedPanelProps): JSX.Element {
return (
<SettingsSection heading={_t("settings|encryption|advanced|title")} legacy={false}>
<EncryptionDetails onResetIdentityClick={onResetIdentityClick} />
<OtherSettings />
</SettingsSection>
);
}
interface EncryptionDetails {
/**
* Callback for when the user clicks the button to reset their identity.
*/
onResetIdentityClick: MouseEventHandler<HTMLButtonElement>;
}
/**
* The encryption details section of the advanced panel.
*/
function EncryptionDetails({ onResetIdentityClick }: EncryptionDetails): JSX.Element {
const matrixClient = useMatrixClientContext();
// Null when the keys are not loaded yet
const keys = useAsyncMemo(
() => {
const crypto = matrixClient.getCrypto();
return crypto ? crypto.getOwnDeviceKeys() : Promise.resolve(null);
},
[matrixClient],
null,
);
return (
<div className="mx_EncryptionDetails" data-testid="encryptionDetails">
<div className="mx_EncryptionDetails_session">
<h3 className="mx_EncryptionDetails_session_title">
{_t("settings|encryption|advanced|details_title")}
</h3>
<div>
<span>{_t("settings|encryption|advanced|session_id")}</span>
<span data-testid="deviceId">{matrixClient.deviceId}</span>
</div>
<div>
<span>{_t("settings|encryption|advanced|session_key")}</span>
<span data-testid="sessionKey">
{keys ? keys.ed25519 : <InlineSpinner aria-label={_t("common|loading")} />}
</span>
</div>
</div>
<div className="mx_EncryptionDetails_buttons">
<Button
size="sm"
kind="secondary"
Icon={ShareIcon}
onClick={() =>
Modal.createDialog(
lazy(
() => import("../../../../async-components/views/dialogs/security/ExportE2eKeysDialog"),
),
{ matrixClient },
)
}
>
{_t("settings|encryption|advanced|export_keys")}
</Button>
<Button
size="sm"
kind="secondary"
Icon={DownloadIcon}
onClick={() =>
Modal.createDialog(
lazy(
() => import("../../../../async-components/views/dialogs/security/ImportE2eKeysDialog"),
),
{ matrixClient },
)
}
>
{_t("settings|encryption|advanced|import_keys")}
</Button>
</div>
<Button size="sm" kind="tertiary" destructive={true} onClick={onResetIdentityClick}>
{_t("settings|encryption|advanced|reset_identity")}
</Button>
</div>
);
}
/**
* Display the never send encrypted message to unverified devices setting.
*/
function OtherSettings(): JSX.Element | null {
const blacklistUnverifiedDevices = useSettingValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices");
const canSetValue = SettingsStore.canSetValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE);
if (!canSetValue) return null;
return (
<Root
data-testid="otherSettings"
className="mx_OtherSettings"
onChange={async (evt) => {
const checked = new FormData(evt.currentTarget).get("neverSendEncrypted") === "on";
await SettingsStore.setValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE, checked);
}}
>
<h3 className="mx_OtherSettings_title">{_t("settings|encryption|advanced|other_people_device_title")}</h3>
<InlineField
name="neverSendEncrypted"
control={<ToggleControl name="neverSendEncrypted" defaultChecked={blacklistUnverifiedDevices} />}
>
<Label>{_t("settings|encryption|advanced|other_people_device_label")}</Label>
<HelpMessage>{_t("settings|encryption|advanced|other_people_device_description")}</HelpMessage>
</InlineField>
</Root>
);
}

View File

@@ -18,6 +18,7 @@ import {
TextControl,
} from "@vector-im/compound-web";
import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../languageHandler";
@@ -157,7 +158,12 @@ export function ChangeRecoveryKey({
pages={pages}
onPageClick={onCancelClick}
/>
<EncryptionCard title={labels.title} description={labels.description} className="mx_ChangeRecoveryKey">
<EncryptionCard
Icon={KeyIcon}
title={labels.title}
description={labels.description}
className="mx_ChangeRecoveryKey"
>
{content}
</EncryptionCard>
</>

View File

@@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { JSX, PropsWithChildren } from "react";
import React, { JSX, PropsWithChildren, ComponentType, SVGAttributes } from "react";
import { BigIcon, Heading } from "@vector-im/compound-web";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
import classNames from "classnames";
interface EncryptionCardProps {
@@ -22,7 +21,15 @@ interface EncryptionCardProps {
/**
* The description of the card.
*/
description: string;
description?: string;
/**
* Whether this icon shows a destructive action.
*/
destructive?: boolean;
/**
* The icon to display.
*/
Icon: ComponentType<SVGAttributes<SVGElement>>;
}
/**
@@ -32,18 +39,20 @@ export function EncryptionCard({
title,
description,
className,
destructive = false,
Icon,
children,
}: PropsWithChildren<EncryptionCardProps>): JSX.Element {
return (
<div className={classNames("mx_EncryptionCard", className)}>
<div className="mx_EncryptionCard_header">
<BigIcon>
<KeyIcon />
<BigIcon destructive={destructive}>
<Icon />
</BigIcon>
<Heading as="h2" size="sm" weight="semibold">
{title}
</Heading>
<span>{description}</span>
{description && <span>{description}</span>}
</div>
{children}
</div>

View File

@@ -106,6 +106,7 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
/>
}
subHeading={<Subheader state={state} />}
data-testid="recoveryPanel"
>
{content}
</SettingsSection>

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2024 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 { Breadcrumb, Button, VisualList, VisualListItem } from "@vector-im/compound-web";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import InfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import React, { MouseEventHandler } from "react";
import { _t } from "../../../../languageHandler";
import { EncryptionCard } from "./EncryptionCard";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { uiAuthCallback } from "../../../../CreateCrossSigning";
interface ResetIdentityPanelProps {
/**
* Called when the identity is reset.
*/
onFinish: MouseEventHandler<HTMLButtonElement>;
/**
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
*/
onCancelClick: () => void;
}
/**
* The panel for resetting the identity of the current user.
*/
export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPanelProps): JSX.Element {
const matrixClient = useMatrixClientContext();
return (
<>
<Breadcrumb
backLabel={_t("action|back")}
onBackClick={onCancelClick}
pages={[_t("settings|encryption|title"), _t("settings|encryption|advanced|breadcrumb_page")]}
onPageClick={onCancelClick}
/>
<EncryptionCard
Icon={ErrorIcon}
destructive={true}
title={_t("settings|encryption|advanced|breadcrumb_title")}
className="mx_ResetIdentityPanel"
>
<div className="mx_ResetIdentityPanel_content">
<VisualList>
<VisualListItem Icon={CheckIcon} success={true}>
{_t("settings|encryption|advanced|breadcrumb_first_description")}
</VisualListItem>
<VisualListItem Icon={InfoIcon}>
{_t("settings|encryption|advanced|breadcrumb_second_description")}
</VisualListItem>
<VisualListItem Icon={InfoIcon}>
{_t("settings|encryption|advanced|breadcrumb_third_description")}
</VisualListItem>
</VisualList>
<span>{_t("settings|encryption|advanced|breadcrumb_warning")}</span>
</div>
<div className="mx_ResetIdentityPanel_footer">
<Button
destructive={true}
onClick={async (evt) => {
await matrixClient
.getCrypto()
?.resetEncryption((makeRequest) => uiAuthCallback(matrixClient, makeRequest));
onFinish(evt);
}}
>
{_t("action|continue")}
</Button>
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</div>
</EncryptionCard>
</>
);
}

View File

@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
import React, { useCallback, useMemo, useState } from "react";
import { JoinRule, EventType, RoomState, Room } from "matrix-js-sdk/src/matrix";
import { RoomPowerLevelsEventContent } from "matrix-js-sdk/src/types";
import { _t } from "../../../../../languageHandler";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
@@ -25,49 +24,48 @@ interface ElementCallSwitchProps {
const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]);
const [content, maySend] = useRoomState(
const [content, events, maySend] = useRoomState(
room,
useCallback(
(state: RoomState) => {
const content = state
?.getStateEvents(EventType.RoomPowerLevels, "")
?.getContent<RoomPowerLevelsEventContent>();
const content = state?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
return [
content ?? {},
content?.["events"] ?? {},
state?.maySendStateEvent(EventType.RoomPowerLevels, room.client.getSafeUserId()),
] as const;
];
},
[room.client],
),
);
const [elementCallEnabled, setElementCallEnabled] = useState<boolean>(() => {
return content.events?.[ElementCall.MEMBER_EVENT_TYPE.name] === 0;
return events[ElementCall.MEMBER_EVENT_TYPE.name] === 0;
});
const onChange = useCallback(
(enabled: boolean): void => {
setElementCallEnabled(enabled);
// Take a copy to avoid mutating the original
const newContent = { events: {}, ...content };
if (enabled) {
const userLevel = newContent.events[EventType.RoomMessage] ?? content.users_default ?? 0;
const userLevel = events[EventType.RoomMessage] ?? content.users_default ?? 0;
const moderatorLevel = content.kick ?? 50;
newContent.events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel;
newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel;
events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel;
events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel;
} else {
const adminLevel = newContent.events[EventType.RoomPowerLevels] ?? content.state_default ?? 100;
const adminLevel = events[EventType.RoomPowerLevels] ?? content.state_default ?? 100;
newContent.events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel;
newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel;
events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel;
events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel;
}
room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, newContent);
room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, {
events: events,
...content,
});
},
[room.client, room.roomId, content, isPublic],
[room.client, room.roomId, content, events, isPublic],
);
const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand;

View File

@@ -6,7 +6,7 @@
*/
import React, { JSX, useCallback, useEffect, useState } from "react";
import { Button, InlineSpinner } from "@vector-im/compound-web";
import { Button, InlineSpinner, Separator } from "@vector-im/compound-web";
import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer";
import SettingsTab from "../SettingsTab";
@@ -18,6 +18,8 @@ import Modal from "../../../../../Modal";
import SetupEncryptionDialog from "../../../dialogs/security/SetupEncryptionDialog";
import { SettingsSection } from "../../shared/SettingsSection";
import { SettingsSubheader } from "../../SettingsSubheader";
import { AdvancedPanel } from "../../encryption/AdvancedPanel";
import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
/**
* The state in the encryption settings tab.
@@ -29,8 +31,9 @@ import { SettingsSubheader } from "../../SettingsSubheader";
* This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel.
* - "set_recovery_key": The panel to show when the user is setting up their recovery key.
* This happens when the user doesn't have a key a recovery key and the user clicks on "Set up recovery key" button of the RecoveryPanel.
* - "reset_identity": The panel to show when the user is resetting their identity.
*/
type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key";
type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key" | "reset_identity";
export function EncryptionUserSettingsTab(): JSX.Element {
const [state, setState] = useState<State>("loading");
@@ -46,11 +49,15 @@ export function EncryptionUserSettingsTab(): JSX.Element {
break;
case "main":
content = (
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<>
<RecoveryPanel
onChangeRecoveryKeyClick={(setupNewKey) =>
setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key")
}
/>
<Separator kind="section" />
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity")} />
</>
);
break;
case "change_recovery_key":
@@ -63,6 +70,9 @@ export function EncryptionUserSettingsTab(): JSX.Element {
/>
);
break;
case "reset_identity":
content = <ResetIdentityPanel onCancelClick={() => setState("main")} onFinish={() => setState("main")} />;
break;
}
return (

View File

@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import React, { lazy, Suspense, useCallback, useContext, useEffect, useRef, useState } from "react";
import { discoverAndValidateOIDCIssuerWellKnown, MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { defer } from "matrix-js-sdk/src/utils";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
@@ -97,7 +98,7 @@ const useSignOut = (
const url = getManageDeviceUrl(delegatedAuthAccountUrl, deviceId);
window.open(url, "_blank");
} else {
const deferredSuccess = Promise.withResolvers<boolean>();
const deferredSuccess = defer<boolean>();
await deleteDevicesWithInteractiveAuth(matrixClient, deviceIds, async (success) => {
deferredSuccess.resolve(success);
});

View File

@@ -27,7 +27,7 @@ type Props = {
const MATCH_SYSTEM_THEME_ID = "MATCH_SYSTEM_THEME_ID";
const QuickThemeSwitcher: React.FC<Props> = ({ requestClose }) => {
const orderedThemes = useMemo(() => getOrderedThemes(), []);
const orderedThemes = useMemo(getOrderedThemes, []);
const themeState = useTheme();
const nonHighContrast = findNonHighContrastTheme(themeState.theme);

View File

@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { ClientEvent, MatrixClient, MatrixEventEvent, Room } from "matrix-js-sdk/src/matrix";
import { throttle } from "lodash";
@@ -42,12 +42,14 @@ export function useUnreadThreadRooms(forceComputation: boolean): Result {
setResult(computeUnreadThreadRooms(mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs));
}, [mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs]);
const scheduleUpdate = useMemo(
() =>
throttle(doUpdate, MIN_UPDATE_INTERVAL_MS, {
leading: false,
trailing: true,
}),
// The exhautive deps lint rule can't compute dependencies here since it's not a plain inline func.
// We make this as simple as possible so its only dep is doUpdate itself.
// eslint-disable-next-line react-hooks/exhaustive-deps
const scheduleUpdate = useCallback(
throttle(doUpdate, MIN_UPDATE_INTERVAL_MS, {
leading: false,
trailing: true,
}),
[doUpdate],
);

View File

@@ -25,9 +25,6 @@ interface IPropsExtended extends IProps {
SecondaryIcon?: ComponentType<React.SVGAttributes<SVGElement>>;
destructive?: "primary" | "secondary";
onSecondaryClick(): void;
// If set, this will override the max-width (of the description) making the toast wider or narrower than standard
overrideWidth?: string;
}
const GenericToast: React.FC<XOR<IPropsExtended, IProps>> = ({
@@ -40,13 +37,12 @@ const GenericToast: React.FC<XOR<IPropsExtended, IProps>> = ({
destructive,
onPrimaryClick,
onSecondaryClick,
overrideWidth,
}) => {
const detailContent = detail ? <div className="mx_Toast_detail">{detail}</div> : null;
return (
<div>
<div className="mx_Toast_description" style={{ maxWidth: overrideWidth }}>
<div className="mx_Toast_description">
{description}
{detailContent}
</div>

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import React, { ContextType, createContext, memo, ReactNode, useContext, useEffect, useMemo, useState } from "react";
import React, { ContextType, createContext, memo, ReactNode, useContext, useEffect, useRef, useState } from "react";
import { objectKeyChanges } from "../utils/objects.ts";
import { useTypedEventEmitter } from "../hooks/useEventEmitter.ts";
@@ -48,16 +48,15 @@ const ScopedRoomContext = createContext<EfficientContext<ContextValue> | undefin
// Uses react memo and leverages splatting the value to ensure that the context is only updated when the state changes (shallow compare)
export const ScopedRoomContextProvider = memo(
({ children, ...state }: { children: ReactNode } & ContextValue): JSX.Element => {
// eslint-disable-next-line react-compiler/react-compiler,react-hooks/exhaustive-deps
const context = useMemo(() => new EfficientContext<ContextValue>(state), []);
const contextRef = useRef(new EfficientContext<ContextValue>(state));
useEffect(() => {
context.setState(state);
}, [context, state]);
contextRef.current.setState(state);
}, [state]);
// Includes the legacy RoomContext provider for backwards compatibility with class components
return (
<RoomContext.Provider value={state}>
<ScopedRoomContext.Provider value={context}>{children}</ScopedRoomContext.Provider>
<ScopedRoomContext.Provider value={contextRef.current}>{children}</ScopedRoomContext.Provider>
</RoomContext.Provider>
);
},

View File

@@ -6,7 +6,7 @@ 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 { ReactNode, createContext, useCallback, useContext, useEffect, useState, useMemo } from "react";
import { ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
/**
* A ToastContext helps components display any kind of toast message and can be provided
@@ -33,19 +33,19 @@ export function useToastContext(): ToastRack {
* the ToastRack object that should be provided to the context
*/
export function useActiveToast(): [ReactNode | undefined, ToastRack] {
const toastRack = useMemo(() => new ToastRack(), []);
const toastRack = useRef(new ToastRack());
const [activeToast, setActiveToast] = useState<ReactNode | undefined>(toastRack.getActiveToast());
const [activeToast, setActiveToast] = useState<ReactNode | undefined>(toastRack.current.getActiveToast());
const updateCallback = useCallback(() => {
setActiveToast(toastRack.getActiveToast());
setActiveToast(toastRack.current.getActiveToast());
}, [setActiveToast, toastRack]);
useEffect(() => {
toastRack.setCallback(updateCallback);
toastRack.current.setCallback(updateCallback);
}, [toastRack, updateCallback]);
return [activeToast, toastRack];
return [activeToast, toastRack.current];
}
interface DisplayedToast {

View File

@@ -135,6 +135,20 @@ export enum Action {
*/
OpenDialPad = "open_dial_pad",
/**
* Fired when CallHandler has checked for PSTN protocol support
* payload: none
* XXX: Is an action the right thing for this?
*/
PstnSupportUpdated = "pstn_support_updated",
/**
* Similar to PstnSupportUpdated, fired when CallHandler has checked for virtual room support
* payload: none
* XXX: Ditto
*/
VirtualRoomSupportUpdated = "virtual_room_support_updated",
/**
* Fired when an upload has started. Should be used with UploadStartedPayload.
*/

View File

@@ -34,7 +34,7 @@ export function useAsyncRefreshMemo<T>(fn: Fn<T>, deps: DependencyList, initialV
return () => {
discard = true;
};
}, deps); // eslint-disable-line react-hooks/exhaustive-deps,react-compiler/react-compiler
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(refresh, [refresh]);
return [value, refresh];
}

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