diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 56b91c750e..d0c447f8c6 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -52,26 +52,41 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and load + id: test-build uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6 with: context: . load: true - tags: ${{ env.TEST_TAG }} - name: Test the image + env: + IMAGEID: ${{ steps.test-build.outputs.imageid }} run: | + set -x + # Make a fake module to test the image MODULE_PATH="modules/module_name/index.js" mkdir -p $(dirname $MODULE_PATH) echo 'alert("Testing");' > $MODULE_PATH # Spin up a container of the image - CONTAINER_ID=$(docker run --rm -dp 80:80 -v $(pwd)/modules:/tmp/element-web-modules ${{ env.TEST_TAG }}) + ELEMENT_WEB_PORT=8181 + CONTAINER_ID=$( + docker run \ + --rm \ + -e "ELEMENT_WEB_PORT=$ELEMENT_WEB_PORT" \ + -dp "$ELEMENT_WEB_PORT:$ELEMENT_WEB_PORT" \ + -v $(pwd)/modules:/tmp/element-web-modules \ + "$IMAGEID" \ + ) # Run some smoke tests - wget --retry-connrefused --tries=5 -q --wait=3 --spider http://localhost:80/modules/module_name/index.js - MODULE_1=$(curl http://localhost:80/config.json | jq -r .modules[0]) - test "$MODULE_1" = "/${MODULE_PATH}" + wget --retry-connrefused --tries=5 -q --wait=3 --spider "http://localhost:$ELEMENT_WEB_PORT/modules/module_name/index.js" + MODULE_0=$(curl "http://localhost:$ELEMENT_WEB_PORT/config.json" | jq -r .modules[0]) + test "$MODULE_0" = "/${MODULE_PATH}" + + # Check healthcheck + test "$(docker inspect -f {{.State.Running}} $CONTAINER_ID)" == "true" # Clean up docker stop "$CONTAINER_ID" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22739da21f..78383e8bf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ jobs: contents: write issues: write pull-requests: read + id-token: write secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2de3ce4d9d..324c7f7ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +Changes in [1.11.93](https://github.com/element-hq/element-web/releases/tag/v1.11.93) (2025-02-25) +================================================================================================== +## ✨ Features + +* [backport] Dynamically load Element Web modules in Docker entrypoint ([#29358](https://github.com/element-hq/element-web/pull/29358)). Contributed by @t3chguy. +* ChangeRecoveryKey: error handling ([#29262](https://github.com/element-hq/element-web/pull/29262)). Contributed by @richvdh. +* Dehydration: enable dehydrated device on "Set up recovery" ([#29265](https://github.com/element-hq/element-web/pull/29265)). Contributed by @richvdh. +* Render reason for invite rejection. ([#29257](https://github.com/element-hq/element-web/pull/29257)). Contributed by @Half-Shot. +* New room list: add search section ([#29251](https://github.com/element-hq/element-web/pull/29251)). Contributed by @florianduros. +* New room list: hide favourites and people meta spaces ([#29241](https://github.com/element-hq/element-web/pull/29241)). Contributed by @florianduros. +* New Room List: Create new labs flag ([#29239](https://github.com/element-hq/element-web/pull/29239)). Contributed by @MidhunSureshR. +* Stop URl preview from covering message box ([#29215](https://github.com/element-hq/element-web/pull/29215)). Contributed by @edent. +* Rename "security key" into "recovery key" ([#29217](https://github.com/element-hq/element-web/pull/29217)). Contributed by @florianduros. +* Add new verification section to user profile ([#29200](https://github.com/element-hq/element-web/pull/29200)). Contributed by @MidhunSureshR. +* Initial support for runtime modules ([#29104](https://github.com/element-hq/element-web/pull/29104)). Contributed by @t3chguy. +* Add `Forgot recovery key?` button to encryption tab ([#29202](https://github.com/element-hq/element-web/pull/29202)). Contributed by @florianduros. +* Add KeyIcon to key storage out of sync toast ([#29201](https://github.com/element-hq/element-web/pull/29201)). Contributed by @florianduros. +* Improve rendering of empty topics in the timeline ([#29152](https://github.com/element-hq/element-web/pull/29152)). Contributed by @Half-Shot. + +## 🐛 Bug Fixes + +* Fix font scaling in member list ([#29285](https://github.com/element-hq/element-web/pull/29285)). Contributed by @florianduros. +* Grow member list search field when resizing the right panel ([#29267](https://github.com/element-hq/element-web/pull/29267)). Contributed by @langleyd. +* Don't reload roomview on offline connectivity check ([#29243](https://github.com/element-hq/element-web/pull/29243)). Contributed by @dbkr. +* Respect user's 12/24 hour preference consistently ([#29237](https://github.com/element-hq/element-web/pull/29237)). Contributed by @t3chguy. +* Restore the accessibility role on call views ([#29225](https://github.com/element-hq/element-web/pull/29225)). Contributed by @robintown. +* Revert `GoToHome` keyboard shortcut to `Ctrl`–`Shift`–`H` on macOS ([#28577](https://github.com/element-hq/element-web/pull/28577)). Contributed by @gy-mate. +* Encryption tab: display correct encryption panel when user cancels the reset identity flow ([#29216](https://github.com/element-hq/element-web/pull/29216)). Contributed by @florianduros. + + Changes in [1.11.92](https://github.com/element-hq/element-web/releases/tag/v1.11.92) (2025-02-11) ================================================================================================== ## ✨ Features diff --git a/Dockerfile b/Dockerfile index dd8be21932..1103d7336f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,3 +46,5 @@ USER nginx # HTTP listen port ENV ELEMENT_WEB_PORT=80 + +HEALTHCHECK --start-period=5s CMD wget --retry-connrefused --tries=5 -q --wait=3 --spider http://localhost:$ELEMENT_WEB_PORT/config.json diff --git a/package.json b/package.json index 2c37e3111b..262764ec41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.11.91", + "version": "1.11.93", "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { diff --git a/playwright/e2e/crypto/dehydration.spec.ts b/playwright/e2e/crypto/dehydration.spec.ts index 472ee39493..35b5fae1ad 100644 --- a/playwright/e2e/crypto/dehydration.spec.ts +++ b/playwright/e2e/crypto/dehydration.spec.ts @@ -116,6 +116,40 @@ test.describe("Dehydration", () => { expect(dehydratedDeviceIds.length).toBe(1); expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]); }); + + test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => { + await logIntoElement(page, credentials); + + // Create a dehydrated device by setting up recovery (see "'Set up + // recovery' creates dehydrated device" test above) + const settingsDialogLocator = await app.settings.openUserSettings("Encryption"); + await settingsDialogLocator.getByRole("button", { name: "Set up recovery" }).click(); + + // First it displays an informative panel about the recovery key + await expect(settingsDialogLocator.getByRole("heading", { name: "Set up recovery" })).toBeVisible(); + await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); + + // Next, it displays the new recovery key. We click on the copy button. + await expect(settingsDialogLocator.getByText("Save your recovery key somewhere safe")).toBeVisible(); + await settingsDialogLocator.getByRole("button", { name: "Copy" }).click(); + const recoveryKey = await app.getClipboard(); + await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); + + await expect( + settingsDialogLocator.getByText("Enter your recovery key to confirm", { exact: true }), + ).toBeVisible(); + await settingsDialogLocator.getByRole("textbox").fill(recoveryKey); + await settingsDialogLocator.getByRole("button", { name: "Finish set up" }).click(); + + await expectDehydratedDeviceEnabled(app); + + // After recovery is set up, we reset our cryptographic identity, which + // should drop the dehydrated device. + await settingsDialogLocator.getByRole("button", { name: "Reset cryptographic identity" }).click(); + await settingsDialogLocator.getByRole("button", { name: "Continue" }).click(); + + await expectDehydratedDeviceDisabled(app); + }); }); async function getDehydratedDeviceIds(client: Client): Promise { @@ -144,3 +178,16 @@ async function expectDehydratedDeviceEnabled(app: ElementAppPage): Promise }) .toEqual(1); } + +/** Wait for our user to not have a dehydrated device */ +async function expectDehydratedDeviceDisabled(app: ElementAppPage): Promise { + // It might be nice to do this via the UI, but currently this info is not exposed via the UI. + // + // Note we might have to wait for the device list to be refreshed, so we wrap in `expect.poll`. + await expect + .poll(async () => { + const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client); + return dehydratedDeviceIds.length; + }) + .toEqual(0); +} diff --git a/playwright/e2e/crypto/device-verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts index ee710d1cfb..9be79452c4 100644 --- a/playwright/e2e/crypto/device-verification.spec.ts +++ b/playwright/e2e/crypto/device-verification.spec.ts @@ -21,6 +21,7 @@ import { waitForVerificationRequest, } from "./utils"; import { type Bot } from "../../pages/bot"; +import { Toasts } from "../../pages/toasts.ts"; test.describe("Device verification", { tag: "@no-webkit" }, () => { let aliceBotClient: Bot; @@ -72,6 +73,51 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false); }); + // Regression test for https://github.com/element-hq/element-web/issues/29110 + test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => { + // Before we log in, the bot creates an encrypted room, so that we can test the toast behaviour that only happens + // when we are in an encrypted room. + await aliceBotClient.createRoom({ + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { algorithm: "m.megolm.v1.aes-sha2" }, + }, + ], + }); + + // In order to simulate a real environment more accurately, we need to slow down the arrival of the + // `m.secret.send` to-device messages. That's slightly tricky to do directly, so instead we delay the *outgoing* + // `m.secret.request` messages. + await page.route("**/_matrix/client/v3/sendToDevice/m.secret.request/**", async (route) => { + await route.fulfill({ json: {} }); + await new Promise((f) => setTimeout(f, 1000)); + await route.fetch(); + }); + + await logIntoElement(page, credentials); + + // Launch the verification request between alice and the bot + const verificationRequest = await initiateAliceVerificationRequest(page); + + // Handle emoji SAS verification + const infoDialog = page.locator(".mx_InfoDialog"); + // the bot chooses to do an emoji verification + const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1")); + + // Handle emoji request and check that emojis are matching + await doTwoWaySasVerification(page, verifier); + + await infoDialog.getByRole("button", { name: "They match" }).click(); + await infoDialog.getByRole("button", { name: "Got it" }).click(); + + // There should be no toast (other than the notifications one) + const toasts = new Toasts(page); + await toasts.rejectToast("Notifications"); + await toasts.assertNoToasts(); + }); + test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => { // A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key" await logIntoElement(page, credentials); diff --git a/playwright/e2e/left-panel/room-list-view/room-list-header.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts similarity index 59% rename from playwright/e2e/left-panel/room-list-view/room-list-header.spec.ts rename to playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts index 2f4ef6f001..daa8d3869f 100644 --- a/playwright/e2e/left-panel/room-list-view/room-list-header.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts @@ -47,12 +47,41 @@ test.describe("Header section of the room list", () => { await app.closeDialog(); }); - test("should render the header section for a space", async ({ page, app, user }) => { + test("should render the header section for a space", { tag: "@screenshot" }, async ({ page, app, user }) => { await app.client.createSpace({ name: "MySpace" }); await page.getByRole("button", { name: "MySpace" }).click(); const roomListHeader = getHeaderSection(page); + await expect(roomListHeader).toMatchScreenshot("room-list-space-header.png"); + await expect(roomListHeader.getByRole("heading", { name: "MySpace" })).toBeVisible(); await expect(roomListHeader.getByRole("button", { name: "Add" })).toBeVisible(); + + const spaceMenu = roomListHeader.getByRole("button", { name: "Open space menu" }); + await spaceMenu.click(); + + await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-space-menu.png"); + + // It should open the space home + await page.getByRole("menuitem", { name: "Space home" }).click(); + await expect(page.getByRole("main").getByRole("heading", { name: "MySpace" })).toBeVisible(); + + // It should open the invite dialog + await spaceMenu.click(); + await page.getByRole("menuitem", { name: "Invite" }).click(); + await expect(page.getByRole("heading", { name: "Invite to MySpace" })).toBeVisible(); + await app.closeDialog(); + + // It should open the space preferences + await spaceMenu.click(); + await page.getByRole("menuitem", { name: "Preferences" }).click(); + await expect(page.getByRole("heading", { name: "Preferences" })).toBeVisible(); + await app.closeDialog(); + + // It should open the space settings + await spaceMenu.click(); + await page.getByRole("menuitem", { name: "Space Settings" }).click(); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); + await app.closeDialog(); }); }); diff --git a/playwright/e2e/left-panel/room-list-view/room-list-view.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts similarity index 78% rename from playwright/e2e/left-panel/room-list-view/room-list-view.spec.ts rename to playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts index 7cd5122e8a..53795773ed 100644 --- a/playwright/e2e/left-panel/room-list-view/room-list-view.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts @@ -9,7 +9,7 @@ import { type Page } from "@playwright/test"; import { test, expect } from "../../../element-web-test"; -test.describe("Search section of the room list", () => { +test.describe("Room list panel", () => { test.use({ labsFlags: ["feature_new_room_list"], }); @@ -19,7 +19,7 @@ test.describe("Search section of the room list", () => { * @param page */ function getRoomListView(page: Page) { - return page.getByTestId("room-list-view"); + return page.getByTestId("room-list-panel"); } test.beforeEach(async ({ page, app, user }) => { @@ -27,8 +27,8 @@ test.describe("Search section of the room list", () => { await app.closeNotificationToast(); }); - test("should render the room list view", { tag: "@screenshot" }, async ({ page, app, user }) => { + test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => { const roomListView = getRoomListView(page); - await expect(roomListView).toMatchScreenshot("room-list-view.png"); + await expect(roomListView).toMatchScreenshot("room-list-panel.png"); }); }); diff --git a/playwright/e2e/left-panel/room-list-view/room-list-search.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts similarity index 100% rename from playwright/e2e/left-panel/room-list-view/room-list-search.spec.ts rename to playwright/e2e/left-panel/room-list-panel/room-list-search.spec.ts diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-compose-menu-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-compose-menu-linux.png new file mode 100644 index 0000000000..8382d3b184 Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-compose-menu-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-linux.png new file mode 100644 index 0000000000..6c871b6b9c Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-space-menu-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-space-menu-linux.png new file mode 100644 index 0000000000..d2934c2a76 Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-space-menu-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-space-header-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-space-header-linux.png new file mode 100644 index 0000000000..bdb7f8fa2a Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-space-header-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png new file mode 100644 index 0000000000..d03dbf992b Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png new file mode 100644 index 0000000000..123cf37586 Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-compose-menu-linux.png b/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-compose-menu-linux.png deleted file mode 100644 index 8f53c47d53..0000000000 Binary files a/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-compose-menu-linux.png and /dev/null differ diff --git a/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-linux.png b/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-linux.png deleted file mode 100644 index 82636daa73..0000000000 Binary files a/playwright/snapshots/left-panel/room-list-view/room-list-header.spec.ts/room-list-header-linux.png and /dev/null differ diff --git a/playwright/snapshots/left-panel/room-list-view/room-list-search.spec.ts/search-section-linux.png b/playwright/snapshots/left-panel/room-list-view/room-list-search.spec.ts/search-section-linux.png deleted file mode 100644 index 6c2684e849..0000000000 Binary files a/playwright/snapshots/left-panel/room-list-view/room-list-search.spec.ts/search-section-linux.png and /dev/null differ diff --git a/playwright/snapshots/left-panel/room-list-view/room-list-view.spec.ts/room-list-view-linux.png b/playwright/snapshots/left-panel/room-list-view/room-list-view.spec.ts/room-list-view-linux.png deleted file mode 100644 index 378d5ef926..0000000000 Binary files a/playwright/snapshots/left-panel/room-list-view/room-list-view.spec.ts/room-list-view-linux.png and /dev/null differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 13347e887a..c14bb40db7 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -269,9 +269,9 @@ @import "./views/right_panel/_VerificationPanel.pcss"; @import "./views/right_panel/_WidgetCard.pcss"; @import "./views/room_settings/_AliasSettings.pcss"; -@import "./views/rooms/RoomListView/_RoomListHeaderView.pcss"; -@import "./views/rooms/RoomListView/_RoomListSearch.pcss"; -@import "./views/rooms/RoomListView/_RoomListView.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListHeaderView.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListPanel.pcss"; +@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss"; @import "./views/rooms/_AppsDrawer.pcss"; @import "./views/rooms/_Autocomplete.pcss"; @import "./views/rooms/_AuxPanel.pcss"; diff --git a/res/css/views/rooms/RoomListView/_RoomListHeaderView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss similarity index 66% rename from res/css/views/rooms/RoomListView/_RoomListHeaderView.pcss rename to res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss index b9c2f7f67b..6a88e61332 100644 --- a/res/css/views/rooms/RoomListView/_RoomListHeaderView.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListHeaderView.pcss @@ -17,4 +17,16 @@ button { color: var(--cpd-color-icon-secondary); } + + .mx_SpaceMenu_button { + svg { + transition: transform 0.1s linear; + } + } + + .mx_SpaceMenu_button[aria-expanded="true"] { + svg { + transform: rotate(180deg); + } + } } diff --git a/res/css/views/rooms/RoomListView/_RoomListView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListPanel.pcss similarity index 94% rename from res/css/views/rooms/RoomListView/_RoomListView.pcss rename to res/css/views/rooms/RoomListPanel/_RoomListPanel.pcss index 117810e6b7..eb1f6e5fe5 100644 --- a/res/css/views/rooms/RoomListView/_RoomListView.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListPanel.pcss @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -.mx_RoomListView { +.mx_RoomListPanel { background-color: var(--cpd-color-bg-canvas-default); height: 100%; border-right: 1px solid var(--cpd-color-bg-subtle-primary); diff --git a/res/css/views/rooms/RoomListView/_RoomListSearch.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss similarity index 96% rename from res/css/views/rooms/RoomListView/_RoomListSearch.pcss rename to res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss index e6bd492466..f175ab3976 100644 --- a/res/css/views/rooms/RoomListView/_RoomListSearch.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss @@ -31,7 +31,7 @@ } } - .mx_RoomListSearch_explore:hover { + .mx_RoomListSearch_button:hover { svg { fill: var(--cpd-color-icon-primary); } diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 5445082e25..cf46be41fa 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -15,9 +15,10 @@ import { type SyncState, ClientStoppedError, } from "matrix-js-sdk/src/matrix"; -import { logger as baseLogger } from "matrix-js-sdk/src/logger"; +import { logger as baseLogger, LogSpan } from "matrix-js-sdk/src/logger"; import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange"; +import { secureRandomString } from "matrix-js-sdk/src/randomstring"; import { PosthogAnalytics } from "./PosthogAnalytics"; import dis from "./dispatcher/dispatcher"; @@ -96,6 +97,7 @@ export default class DeviceListener { this.client.on(ClientEvent.AccountData, this.onAccountData); this.client.on(ClientEvent.Sync, this.onSync); this.client.on(RoomStateEvent.Events, this.onRoomStateEvents); + this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn"); // only configurable in config, so we don't need to watch the value this.enableBulkUnverifiedSessionsReminder = SettingsStore.getValue(UIFeature.BulkUnverifiedSessionsReminder); @@ -118,6 +120,7 @@ export default class DeviceListener { this.client.removeListener(ClientEvent.AccountData, this.onAccountData); this.client.removeListener(ClientEvent.Sync, this.onSync); this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); + this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); } SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef); dis.unregister(this.dispatcherRef); @@ -225,6 +228,11 @@ export default class DeviceListener { this.updateClientInformation(); }; + private onToDeviceEvent = (event: MatrixEvent): void => { + // Receiving a 4S secret can mean we are in sync where we were not before. + if (event.getType() === EventType.SecretSend) this.recheck(); + }; + /** * Fetch the key backup information from the server. * @@ -273,18 +281,29 @@ export default class DeviceListener { private async doRecheck(): Promise { if (!this.running || !this.client) return; // we have been stopped + const logSpan = new LogSpan(logger, "check_" + secureRandomString(4)); + const cli = this.client; // cross-signing support was added to Matrix in MSC1756, which landed in spec v1.1 - if (!(await cli.isVersionSupported("v1.1"))) return; + if (!(await cli.isVersionSupported("v1.1"))) { + logSpan.debug("cross-signing not supported"); + return; + } const crypto = cli.getCrypto(); - if (!crypto) return; + if (!crypto) { + logSpan.debug("crypto not enabled"); + return; + } // don't recheck until the initial sync is complete: lots of account data events will fire // while the initial sync is processing and we don't need to recheck on each one of them // (we add a listener on sync to do once check after the initial sync is done) - if (!cli.isInitialSyncComplete()) return; + if (!cli.isInitialSyncComplete()) { + logSpan.debug("initial sync not yet complete"); + return; + } const crossSigningReady = await crypto.isCrossSigningReady(); const secretStorageReady = await crypto.isSecretStorageReady(); @@ -306,6 +325,7 @@ export default class DeviceListener { await this.reportCryptoSessionStateToAnalytics(cli); if (this.dismissedThisDeviceToast || allSystemsReady) { + logSpan.info("No toast needed"); hideSetupEncryptionToast(); this.checkKeyBackupStatus(); @@ -316,27 +336,33 @@ export default class DeviceListener { if (!crossSigningReady) { // This account is legacy and doesn't have cross-signing set up at all. // Prompt the user to set it up. - logger.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast"); + logSpan.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast"); showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); } else if (!isCurrentDeviceTrusted) { // cross signing is ready but the current device is not trusted: prompt the user to verify - logger.info("Current device not verified: showing VERIFY_THIS_SESSION toast"); + logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast"); 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. - logger.info("Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast"); + logSpan.info( + "Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast", + crossSigningStatus.privateKeysCachedLocally, + ); 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 (unless they've explicitly said no to key storage) const disabledEvent = cli.getAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY); if (!disabledEvent?.getContent().disabled) { + logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast"); showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); + } else { + logSpan.info("No default 4S key but backup disabled: no toast needed"); } } 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", { + logSpan.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", { crossSigningReady, secretStorageReady, allCrossSigningSecretsCached, @@ -345,6 +371,8 @@ export default class DeviceListener { }); showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); } + } else { + logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false"); } // This needs to be done after awaiting on getUserDeviceInfo() above, so @@ -377,9 +405,9 @@ export default class DeviceListener { } } - logger.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(",")); - logger.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(",")); - logger.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(",")); + logSpan.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(",")); + logSpan.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(",")); + logSpan.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(",")); const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed(); @@ -404,7 +432,7 @@ export default class DeviceListener { // ...and hide any we don't need any more for (const deviceId of this.displayingToastsForDeviceIds) { if (!newUnverifiedDeviceIds.has(deviceId)) { - logger.debug("Hiding unverified session toast for " + deviceId); + logSpan.debug("Hiding unverified session toast for " + deviceId); hideUnverifiedSessionsToast(deviceId); } } diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index aa4d419196..f168c39070 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -37,7 +37,7 @@ import PosthogTrackers from "../../PosthogTrackers"; import type PageType from "../../PageTypes"; import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation"; import SettingsStore from "../../settings/SettingsStore"; -import { RoomListView } from "../views/rooms/RoomListView"; +import { RoomListPanel } from "../views/rooms/RoomListPanel"; interface IProps { isMinimized: boolean; @@ -390,7 +390,7 @@ export default class LeftPanel extends React.Component { return (
- +
); diff --git a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx index a37384b7fa..fb822b8129 100644 --- a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx @@ -6,7 +6,7 @@ */ import { useCallback } from "react"; -import { type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix"; +import { JoinRule, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; @@ -23,7 +23,15 @@ import { UPDATE_SELECTED_SPACE, } from "../../../stores/spaces"; import SpaceStore from "../../../stores/spaces/SpaceStore"; -import { showCreateNewRoom } from "../../../utils/space"; +import { + shouldShowSpaceSettings, + showCreateNewRoom, + showSpaceInvite, + showSpacePreferences, + showSpaceSettings, +} from "../../../utils/space"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; /** * Hook to get the active space and its title. @@ -59,6 +67,11 @@ export interface RoomListHeaderViewState { * True if the user can create rooms */ displayComposeMenu: boolean; + /** + * Whether to display the space menu + * True if there is an active space + */ + displaySpaceMenu: boolean; /** * Whether the user can create rooms */ @@ -67,6 +80,14 @@ export interface RoomListHeaderViewState { * Whether the user can create video rooms */ canCreateVideoRoom: boolean; + /** + * Whether the user can invite in the active space + */ + canInviteInSpace: boolean; + /** + * Whether the user can access space settings + */ + canAccessSpaceSettings: boolean; /** * Create a chat room * @param e - The click event @@ -81,17 +102,39 @@ export interface RoomListHeaderViewState { * Create a video room */ createVideoRoom: () => void; + /** + * Open the active space home + */ + openSpaceHome: () => void; + /** + * Display the space invite dialog + */ + inviteInSpace: () => void; + /** + * Open the space preferences + */ + openSpacePreferences: () => void; + /** + * Open the space settings + */ + openSpaceSettings: () => void; } /** * View model for the RoomListHeader. */ export function useRoomListHeaderViewModel(): RoomListHeaderViewState { + const matrixClient = useMatrixClientContext(); const { activeSpace, title } = useSpace(); const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms); const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms"); const displayComposeMenu = canCreateRoom; + const displaySpaceMenu = Boolean(activeSpace); + const canInviteInSpace = Boolean( + activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(matrixClient.getSafeUserId()), + ); + const canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace)); /* Actions */ @@ -125,13 +168,48 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState { } }, [activeSpace, elementCallVideoRoomsEnabled]); + const openSpaceHome = useCallback(() => { + // openSpaceHome is only available when there is an active space + if (!activeSpace) return; + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: activeSpace.roomId, + metricsTrigger: undefined, + }); + }, [activeSpace]); + + const inviteInSpace = useCallback(() => { + // inviteInSpace is only available when there is an active space + if (!activeSpace) return; + showSpaceInvite(activeSpace); + }, [activeSpace]); + + const openSpacePreferences = useCallback(() => { + // openSpacePreferences is only available when there is an active space + if (!activeSpace) return; + showSpacePreferences(activeSpace); + }, [activeSpace]); + + const openSpaceSettings = useCallback(() => { + // openSpaceSettings is only available when there is an active space + if (!activeSpace) return; + showSpaceSettings(activeSpace); + }, [activeSpace]); + return { title, displayComposeMenu, + displaySpaceMenu, canCreateRoom, canCreateVideoRoom, + canInviteInSpace, + canAccessSpaceSettings, createChatRoom, createRoom, createVideoRoom, + openSpaceHome, + inviteInSpace, + openSpacePreferences, + openSpaceSettings, }; } diff --git a/src/components/views/rooms/RoomListView/RoomListHeaderView.tsx b/src/components/views/rooms/RoomListPanel/RoomListHeaderView.tsx similarity index 57% rename from src/components/views/rooms/RoomListView/RoomListHeaderView.tsx rename to src/components/views/rooms/RoomListPanel/RoomListHeaderView.tsx index 478b9b7e70..7c82dabc2a 100644 --- a/src/components/views/rooms/RoomListView/RoomListHeaderView.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListHeaderView.tsx @@ -8,7 +8,11 @@ import React, { type JSX, useState } from "react"; import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose"; import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; +import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down"; import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room"; +import HomeIcon from "@vector-im/compound-design-tokens/assets/web/icons/home"; +import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences"; +import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings"; import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call"; import { _t } from "../../../../languageHandler"; @@ -34,12 +38,57 @@ export function RoomListHeaderView(): JSX.Element { align="center" data-testid="room-list-header" > -

{vm.title}

+ +

{vm.title}

+ {vm.displaySpaceMenu && } +
{vm.displayComposeMenu && } ); } +interface SpaceMenuProps { + /** + * The view model for the room list header + */ + vm: RoomListHeaderViewState; +} + +/** + * The space menu for the room list header + */ +function SpaceMenu({ vm }: SpaceMenuProps): JSX.Element { + const [open, setOpen] = useState(false); + + return ( + + + + } + > + + {vm.canInviteInSpace && ( + + )} + + {vm.canAccessSpaceSettings && ( + + )} + + ); +} + interface ComposeMenuProps { /** * The view model for the room list header diff --git a/src/components/views/rooms/RoomListView/RoomListView.tsx b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx similarity index 88% rename from src/components/views/rooms/RoomListView/RoomListView.tsx rename to src/components/views/rooms/RoomListPanel/RoomListPanel.tsx index 3266256d67..a52b619651 100644 --- a/src/components/views/rooms/RoomListView/RoomListView.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx @@ -15,7 +15,7 @@ import { RoomListSearch } from "./RoomListSearch"; import { RoomListHeaderView } from "./RoomListHeaderView"; import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel"; -type RoomListViewProps = { +type RoomListPanelProps = { /** * Current active space * See {@link RoomListSearch} @@ -24,9 +24,9 @@ type RoomListViewProps = { }; /** - * A view component for the room list. + * The panel of the room list */ -export const RoomListView: React.FC = ({ activeSpace }) => { +export const RoomListPanel: React.FC = ({ activeSpace }) => { const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer); const { rooms } = useRoomListViewModel(); @@ -39,7 +39,7 @@ export const RoomListView: React.FC = ({ activeSpace }) => { }; return ( -
+
{displayRoomSearch && } diff --git a/src/components/views/rooms/RoomListView/RoomListSearch.tsx b/src/components/views/rooms/RoomListPanel/RoomListSearch.tsx similarity index 71% rename from src/components/views/rooms/RoomListView/RoomListSearch.tsx rename to src/components/views/rooms/RoomListPanel/RoomListSearch.tsx index 415e817ad9..6809841075 100644 --- a/src/components/views/rooms/RoomListView/RoomListSearch.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListSearch.tsx @@ -9,6 +9,7 @@ import React, { type JSX } from "react"; import { Button } from "@vector-im/compound-web"; import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore"; import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/search"; +import DialPadIcon from "@vector-im/compound-design-tokens/assets/web/icons/dial-pad"; import { IS_MAC, Key } from "../../../../Keyboard"; import { _t } from "../../../../languageHandler"; @@ -20,6 +21,8 @@ import { Action } from "../../../../dispatcher/actions"; import PosthogTrackers from "../../../../PosthogTrackers"; import defaultDispatcher from "../../../../dispatcher/dispatcher"; import { Flex } from "../../../utils/Flex"; +import { useTypedEventEmitterState } from "../../../../hooks/useEventEmitter"; +import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../../LegacyCallHandler"; type RoomListSearchProps = { /** @@ -35,6 +38,12 @@ type RoomListSearchProps = { */ export function RoomListSearch({ activeSpace }: RoomListSearchProps): JSX.Element { const displayExploreButton = activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms); + // We only display the dial button if the user is can make PSTN calls + const displayDialButton = useTypedEventEmitterState( + LegacyCallHandler.instance, + LegacyCallHandlerEvent.ProtocolSupport, + () => LegacyCallHandler.instance.getSupportsPstnProtocol(), + ); return ( @@ -50,9 +59,22 @@ export function RoomListSearch({ activeSpace }: RoomListSearchProps): JSX.Elemen {IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K"} + {displayDialButton && ( + + + + + +`; + +exports[` compose menu should not display the compose menu 1`] = ` + +
+
+

+ title +

+ +
+
+
+`; + +exports[` space menu should display the space menu 1`] = ` + +
+
+

+ title +

+ +
+ +
+
+`; + +exports[` space menu should not display the space menu 1`] = ` + +
+
+

+ title +

+
+ +
+
+`; diff --git a/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListView-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap similarity index 85% rename from test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListView-test.tsx.snap rename to test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap index 10d94d0389..8f47e1686f 100644 --- a/test/unit-tests/components/views/rooms/RoomListView/__snapshots__/RoomListView-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomListPanel-test.tsx.snap @@ -1,10 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` should not render the RoomListSearch component when UIComponent.FilterContainer is at false 1`] = ` +exports[` should not render the RoomListSearch component when UIComponent.FilterContainer is at false 1`] = `
should not render the RoomListSearch component when UI data-testid="room-list-header" style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0;" > -

- Home -

+
+

+ Home +

+
should not render the RoomListSearch component when UI `; -exports[` should render the RoomListSearch component when UIComponent.FilterContainer is at true 1`] = ` +exports[` should render the RoomListSearch component when UIComponent.FilterContainer is at true 1`] = `
should render the RoomListSearch component when UIComp +
+ +`; + +exports[` should display the dial button when the PTSN protocol is not supported 1`] = ` + +