diff --git a/.github/actions/download-verify-element-tarball/action.yml b/.github/actions/download-verify-element-tarball/action.yml new file mode 100644 index 0000000000..978b27bae4 --- /dev/null +++ b/.github/actions/download-verify-element-tarball/action.yml @@ -0,0 +1,33 @@ +name: Upload release assets +description: Uploads assets to an existing release and optionally signs them +inputs: + tag: + description: GitHub release tag to fetch assets from. + required: true + out-file-path: + description: Path to where the webapp should be extracted to. + required: true +runs: + using: composite + steps: + - name: Download current version for its old bundles + id: current_download + uses: robinraju/release-downloader@a96f54c1b5f5e09e47d9504526e96febd949d4c2 # v1 + with: + tag: steps.current_version.outputs.version + fileName: element-*.tar.gz* + out-file-path: ${{ runner.temp }}/download-verify-element-tarball + + - name: Verify tarball + run: gpg --verify element-*.tar.gz.asc element-*.tar.gz + working-directory: ${{ runner.temp }}/download-verify-element-tarball + + - name: Extract tarball + run: tar xvzf element-*.tar.gz -C webapp --strip-components=1 + working-directory: ${{ runner.temp }}/download-verify-element-tarball + + - name: Move webapp to out-file-path + run: mv ${{ runner.temp }}/download-verify-element-tarball/webapp ${{ inputs.out-file-path }} + + - name: Clean up temp directory + run: rm -R ${{ runner.temp }}/download-verify-element-tarball diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index c21ab831e6..8bbcfe726f 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -20,6 +20,7 @@ jobs: permissions: checks: read pages: write + deployments: write env: R2_BUCKET: "element-web-develop" R2_URL: ${{ vars.CF_R2_S3_API }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000..a41a4dcec7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,88 @@ +# Manual deploy workflow for deploying to app.element.io & staging.element.io +# Runs automatically for staging.element.io when an RC or Release is published +# Note: Does *NOT* run automatically for app.element.io so that it gets tested on staging.element.io beforehand +name: Build and Deploy ${{ inputs.site || 'staging.element.io' }} +on: + release: + types: [published] + workflow_dispatch: + inputs: + site: + description: Which site to deploy to + required: true + default: staging.element.io + type: choice + options: + - staging.element.io + - app.element.io +concurrency: ${{ inputs.site || 'staging.element.io' }} +permissions: {} +jobs: + deploy: + name: "Deploy to Cloudflare Pages" + runs-on: ubuntu-24.04 + environment: ${{ inputs.site || 'staging.element.io' }} + permissions: + checks: read + deployments: write + env: + SITE: ${{ inputs.site || 'staging.element.io' }} + steps: + - name: Load GPG key + run: | + curl https://packages.element.io/element-release-key.gpg | gpg --import + gpg -k "$GPG_FINGERPRINT" + env: + GPG_FINGERPRINT: ${{ secrets.GPG_FINGERPRINT }} + + - name: Check current version on deployment + id: current_version + run: | + echo "version=$(curl -s https://$SITE/version)" >> $GITHUB_OUTPUT + + # The current version bundle melding dance is skipped if the version we're deploying is the same + # as then we're just doing a re-deploy of the same version with potentially different configs. + - name: Download current version for its old bundles + id: current_download + if: steps.current_version.outputs.version != github.ref_name + uses: element-hq/element-web/.github/actions/download-verify-element-tarball@${{ github.ref_name }} + with: + tag: steps.current_version.outputs.version + out-file-path: current_version + + - name: Download target version + uses: element-hq/element-web/.github/actions/download-verify-element-tarball@${{ github.ref_name }} + with: + tag: ${{ github.ref_name }} + out-file-path: _deploy + + - name: Merge current bundles into target + if: steps.current_download.outcome == 'success' + run: cp -vnpr current_version/bundles/* _deploy/bundles/ + + - name: Copy config + run: cp element.io/app/config.json _deploy/config.json + + - name: Populate 404.html + run: echo "404 Not Found" > _deploy/404.html + + - name: Populate _headers + run: cp .github/cfp_headers _deploy/_headers + + - name: Wait for other steps to succeed + uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork + with: + ref: ${{ github.sha }} + running-workflow-name: "Build and Deploy ${{ env.SITE }}" + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 10 + check-regexp: ^((?!SonarCloud|SonarQube|issue|board|label|Release|prepare|GitHub Pages).)*$ + + - name: Deploy to Cloudflare Pages + uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1 + with: + apiToken: ${{ secrets.CF_PAGES_TOKEN }} + accountId: ${{ secrets.CF_PAGES_ACCOUNT_ID }} + projectName: ${{ env.SITE == 'staging.element.io' && 'element-web-staging' || 'element-web' }} + directory: _deploy + gitHubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dockerhub.yaml b/.github/workflows/dockerhub.yaml index 7911cf794a..8dae6cf5ab 100644 --- a/.github/workflows/dockerhub.yaml +++ b/.github/workflows/dockerhub.yaml @@ -39,7 +39,7 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5 + uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5 with: images: | vectorim/element-web @@ -51,7 +51,7 @@ jobs: - name: Build and push id: build-and-push - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6 + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6 with: context: . push: true diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 2f97ccbbb4..d48fed1792 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -9,6 +9,6 @@ jobs: action: uses: matrix-org/matrix-js-sdk/.github/workflows/pull_request.yaml@develop permissions: - pull-requests: read + pull-requests: write secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ecc4a4662..019bc1b9ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ jobs: permissions: contents: write issues: write + pull-requests: read secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 14fd5ffd64..0c531f89b4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -104,7 +104,7 @@ jobs: - name: Skip SonarCloud in merge queue if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' - uses: guibranco/github-status-action-v2@1f26a0237cd1a57626fbb5a0eb2494c9b8797d07 + uses: guibranco/github-status-action-v2@66088c44e212a906c32a047529a213d81809ec1c with: authToken: ${{ secrets.GITHUB_TOKEN }} state: success diff --git a/__mocks__/FontManager.js b/__mocks__/FontManager.js deleted file mode 100644 index 41eab4bf94..0000000000 --- a/__mocks__/FontManager.js +++ /dev/null @@ -1,6 +0,0 @@ -// Stub out FontManager for tests as it doesn't validate anything we don't already know given -// our fixed test environment and it requires the installation of node-canvas. - -module.exports = { - fixupColorFonts: () => Promise.resolve(), -}; diff --git a/jest.config.ts b/jest.config.ts index 04f1a91e77..326f2040d9 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -32,7 +32,6 @@ const config: Config = { "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", "waveWorker\\.min\\.js": "/__mocks__/empty.js", "context-filter-polyfill": "/__mocks__/empty.js", - "FontManager.ts": "/__mocks__/FontManager.js", "workers/(.+)Factory": "/__mocks__/workerFactoryMock.js", "^!!raw-loader!.*": "jest-raw-loader", "recorderWorkletFactory": "/__mocks__/empty.js", diff --git a/package.json b/package.json index a48284bb97..2ef4a6f170 100644 --- a/package.json +++ b/package.json @@ -114,10 +114,10 @@ "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.16.0", - "linkify-element": "4.1.3", - "linkify-react": "4.1.3", - "linkify-string": "4.1.3", - "linkifyjs": "4.1.3", + "linkify-element": "4.1.4", + "linkify-react": "4.1.4", + "linkify-string": "4.1.4", + "linkifyjs": "4.1.4", "lodash": "^4.17.21", "maplibre-gl": "^4.0.0", "matrix-encrypt-attachment": "^1.0.3", diff --git a/playwright/Dockerfile b/playwright/Dockerfile index 9d478ff231..2b30c416f7 100644 --- a/playwright/Dockerfile +++ b/playwright/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.48.2-jammy +FROM mcr.microsoft.com/playwright:v1.49.0-jammy WORKDIR /work diff --git a/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/playwright/e2e/crypto/decryption-failure-messages.spec.ts index ce7ca34d8e..b2a1209a70 100644 --- a/playwright/e2e/crypto/decryption-failure-messages.spec.ts +++ b/playwright/e2e/crypto/decryption-failure-messages.spec.ts @@ -67,6 +67,9 @@ test.describe("Cryptography", function () { await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click(); await app.viewRoomByName("Test room"); + // In this case, the call to cryptoApi.isEncryptionEnabledInRoom is taking a long time to resolve + await page.waitForTimeout(1000); + // There should be two historical events in the timeline const tiles = await page.locator(".mx_EventTile").all(); expect(tiles.length).toBeGreaterThanOrEqual(2); diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index b5d3790aaa..c6382f1d72 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -16,6 +16,7 @@ import { logOutOfElement, verify, } from "./utils"; +import { bootstrapCrossSigningForClient } from "../../pages/client.ts"; test.describe("Cryptography", function () { test.use({ @@ -307,5 +308,30 @@ test.describe("Cryptography", function () { const penultimate = page.locator(".mx_EventTile").filter({ hasText: "test encrypted from verified" }); await expect(penultimate.locator(".mx_EventTile_e2eIcon")).not.toBeVisible(); }); + + test("should show correct shields on events sent by users with changed identity", async ({ + page, + app, + bot: bob, + homeserver, + }) => { + // Verify Bob + await verify(app, bob); + + // Bob logs in a new device and resets cross-signing + const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); + await bootstrapCrossSigningForClient(await bobSecondDevice.prepareClient(), bob.credentials, true); + + /* should show an error for a message from a previously verified device */ + await bobSecondDevice.sendMessage(testRoomId, "test encrypted from user that was previously verified"); + const last = page.locator(".mx_EventTile_last"); + await expect(last).toContainText("test encrypted from user that was previously verified"); + const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastE2eIcon.focus(); + await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText( + "Sender's verified identity has changed", + ); + }); }); }); diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index 0a14950b1f..f11d94a703 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand"; // Docker tag to use for synapse docker image. // We target a specific digest as every now and then a Synapse update will break our CI. // This digest is updated by the playwright-image-updates.yaml workflow periodically. -const DOCKER_TAG = "develop@sha256:127c68d4468019ce363c8b2fd7a42a3ef50710eb3aaf288a2295dd4623ce9f54"; +const DOCKER_TAG = "develop@sha256:e163b15bf4905e4067dece856cca00e6ac8d1d655f4f1307978eee256b3ea775"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 12239fac2d..0fcdf6dee6 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -319,6 +319,7 @@ @import "./views/rooms/_ThirdPartyMemberInfo.pcss"; @import "./views/rooms/_ThreadSummary.pcss"; @import "./views/rooms/_TopUnreadMessagesBar.pcss"; +@import "./views/rooms/_UserIdentityWarning.pcss"; @import "./views/rooms/_VoiceRecordComposerTile.pcss"; @import "./views/rooms/_WhoIsTypingTile.pcss"; @import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss"; diff --git a/res/css/views/rooms/_UserIdentityWarning.pcss b/res/css/views/rooms/_UserIdentityWarning.pcss new file mode 100644 index 0000000000..b294b3fc8c --- /dev/null +++ b/res/css/views/rooms/_UserIdentityWarning.pcss @@ -0,0 +1,28 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +.mx_UserIdentityWarning { + /* 42px is the padding-left of .mx_MessageComposer_wrapper in res/css/views/rooms/_MessageComposer.pcss */ + margin-left: calc(-42px + var(--RoomView_MessageList-padding)); + + .mx_UserIdentityWarning_row { + display: flex; + align-items: center; + + .mx_BaseAvatar { + margin-left: var(--cpd-space-2x); + } + .mx_UserIdentityWarning_main { + margin-left: var(--cpd-space-6x); + flex-grow: 1; + } + } +} + +.mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning { + margin-left: calc(-25px + var(--RoomView_MessageList-padding)); +} diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 deleted file mode 100644 index 90f444b1a1..0000000000 Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 and /dev/null differ diff --git a/res/themes/light/css/_fonts.pcss b/res/themes/light/css/_fonts.pcss index 62613fcee5..a9d31f2b2b 100644 --- a/res/themes/light/css/_fonts.pcss +++ b/res/themes/light/css/_fonts.pcss @@ -143,3 +143,21 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } + +/* Twemoji COLR */ +@font-face { + font-family: "Twemoji"; + font-weight: 400; + src: url("$(res)/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2") format("woff2"); +} +/* For at least Chrome on Windows 10, we have to explictly add extra weights for the emoji to appear in bold messages, etc. */ +@font-face { + font-family: "Twemoji"; + font-weight: 600; + src: url("$(res)/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2") format("woff2"); +} +@font-face { + font-family: "Twemoji"; + font-weight: 700; + src: url("$(res)/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2") format("woff2"); +} diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index dcef9c2eb9..73366f2fee 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -22,6 +22,13 @@ declare module "matrix-js-sdk/src/types" { [BLURHASH_FIELD]?: string; } + export interface ImageInfo { + /** + * @see https://github.com/matrix-org/matrix-spec-proposals/pull/4230 + */ + "org.matrix.msc4230.is_animated"?: boolean; + } + export interface StateEvents { // Jitsi-backed video room state events [JitsiCallMemberEventType]: JitsiCallMemberContent; diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 895e168f3b..344a2f112c 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -56,6 +56,7 @@ import { createThumbnail } from "./utils/image-media"; import { attachMentions, attachRelation } from "./components/views/rooms/SendMessageComposer"; import { doMaybeLocalRoomAction } from "./utils/local-room"; import { SdkContextClass } from "./contexts/SDKContext"; +import { blobIsAnimated } from "./utils/Image.ts"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre @@ -150,15 +151,20 @@ async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imag thumbnailType = "image/jpeg"; } + // We don't await this immediately so it can happen in the background + const isAnimatedPromise = blobIsAnimated(imageFile.type, imageFile); + const imageElement = await loadImageElement(imageFile); const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType); const imageInfo = result.info; + imageInfo["org.matrix.msc4230.is_animated"] = await isAnimatedPromise; + // For lesser supported image types, always include the thumbnail even if it is larger if (!ALWAYS_INCLUDE_THUMBNAIL.includes(imageFile.type)) { // we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from. - const sizeDifference = imageFile.size - imageInfo.thumbnail_info!.size; + const sizeDifference = imageFile.size - imageInfo.thumbnail_info!.size!; if ( // image is small enough already imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 4f47cd7eac..84d83827da 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -230,12 +230,15 @@ export default class DeviceListener { private async getKeyBackupInfo(): Promise { if (!this.client) return null; const now = new Date().getTime(); + const crypto = this.client.getCrypto(); + if (!crypto) return null; + if ( !this.keyBackupInfo || !this.keyBackupFetchedAt || this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL ) { - this.keyBackupInfo = await this.client.getKeyBackupVersion(); + this.keyBackupInfo = await crypto.getKeyBackupInfo(); this.keyBackupFetchedAt = now; } return this.keyBackupInfo; diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 1258bde2ca..932f6d7fcf 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -279,7 +279,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { MediaDeviceHandler.loadDevices(); - fixupColorFonts(); - this._roomView = React.createRef(); this._resizeContainer = React.createRef(); this.resizeHandler = React.createRef(); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index e51dd96647..9f9e225352 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1638,7 +1638,7 @@ export default class MatrixChat extends React.PureComponent { } else { // otherwise check the server to see if there's a new one try { - newVersionInfo = await cli.getKeyBackupVersion(); + newVersionInfo = (await cli.getCrypto()?.getKeyBackupInfo()) ?? null; if (newVersionInfo !== null) haveNewVersion = true; } catch (e) { logger.error("Saw key backup error but failed to check backup version!", e); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 470b73de7c..58e37606b2 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -9,7 +9,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { ChangeEvent, ComponentProps, createRef, ReactElement, ReactNode, RefObject, useContext } from "react"; +import React, { + ChangeEvent, + ComponentProps, + createRef, + ReactElement, + ReactNode, + RefObject, + useContext, + JSX, +} from "react"; import classNames from "classnames"; import { IRecommendedVersion, @@ -29,6 +38,7 @@ import { MatrixError, ISearchResults, THREAD_RELATION_TYPE, + MatrixClient, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -233,6 +243,11 @@ export interface IRoomState { liveTimeline?: EventTimeline; narrow: boolean; msc3946ProcessDynamicPredecessor: boolean; + /** + * Whether the room is encrypted or not. + * If null, we are still determining the encryption status. + */ + isRoomEncrypted: boolean | null; canAskToJoin: boolean; promptAskToJoin: boolean; @@ -417,6 +432,7 @@ export class RoomView extends React.Component { canAskToJoin: this.askToJoinEnabled, promptAskToJoin: false, viewRoomOpts: { buttons: [] }, + isRoomEncrypted: null, }; } @@ -847,7 +863,7 @@ export class RoomView extends React.Component { return isManuallyShown && widgets.length > 0; } - public componentDidMount(): void { + public async componentDidMount(): Promise { this.unmounted = false; this.dispatcherRef = defaultDispatcher.register(this.onAction); @@ -1342,13 +1358,12 @@ export class RoomView extends React.Component { this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); this.calculatePeekRules(room); - this.updatePreviewUrlVisibility(room); this.loadMembersIfJoined(room); this.calculateRecommendedVersion(room); - this.updateE2EStatus(room); this.updatePermissions(room); this.checkWidgets(room); this.loadVirtualRoom(room); + this.updateRoomEncrypted(room); if ( this.getMainSplitContentType(room) !== MainSplitContentType.Timeline && @@ -1377,6 +1392,13 @@ export class RoomView extends React.Component { return room?.currentState.getStateEvents(EventType.RoomTombstone, "") ?? undefined; } + private async getIsRoomEncrypted(roomId = this.state.roomId): Promise { + const crypto = this.context.client?.getCrypto(); + if (!crypto || !roomId) return false; + + return await crypto.isEncryptionEnabledInRoom(roomId); + } + private async calculateRecommendedVersion(room: Room): Promise { const upgradeRecommendation = await room.getRecommendedVersion(); if (this.unmounted) return; @@ -1409,12 +1431,15 @@ export class RoomView extends React.Component { }); } - private updatePreviewUrlVisibility({ roomId }: Room): void { - // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit - const key = this.context.client?.isRoomEncrypted(roomId) ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"; - this.setState({ - showUrlPreview: SettingsStore.getValue(key, roomId), - }); + private updatePreviewUrlVisibility(room: Room): void { + this.setState(({ isRoomEncrypted }) => ({ + showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted), + })); + } + + private getPreviewUrlVisibility({ roomId }: Room, isRoomEncrypted: boolean | null): boolean { + const key = isRoomEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"; + return SettingsStore.getValue(key, roomId); } private onRoom = (room: Room): void => { @@ -1456,7 +1481,7 @@ export class RoomView extends React.Component { }; private async updateE2EStatus(room: Room): Promise { - if (!this.context.client?.isRoomEncrypted(room.roomId)) return; + if (!this.context.client || !this.state.isRoomEncrypted) return; // If crypto is not currently enabled, we aren't tracking devices at all, // so we don't know what the answer is. Let's error on the safe side and show @@ -1467,33 +1492,54 @@ export class RoomView extends React.Component { if (this.context.client.getCrypto()) { /* At this point, the user has encryption on and cross-signing on */ - e2eStatus = await shieldStatusForRoom(this.context.client, room); - RoomView.e2eStatusCache.set(room.roomId, e2eStatus); + e2eStatus = await this.cacheAndGetE2EStatus(room, this.context.client); if (this.unmounted) return; this.setState({ e2eStatus }); } } + private async cacheAndGetE2EStatus(room: Room, client: MatrixClient): Promise { + const e2eStatus = await shieldStatusForRoom(client, room); + RoomView.e2eStatusCache.set(room.roomId, e2eStatus); + return e2eStatus; + } + private onUrlPreviewsEnabledChange = (): void => { if (this.state.room) { this.updatePreviewUrlVisibility(this.state.room); } }; - private onRoomStateEvents = (ev: MatrixEvent, state: RoomState): void => { + private onRoomStateEvents = async (ev: MatrixEvent, state: RoomState): Promise => { // ignore if we don't have a room yet - if (!this.state.room || this.state.room.roomId !== state.roomId) return; + if (!this.state.room || this.state.room.roomId !== state.roomId || !this.context.client) return; switch (ev.getType()) { case EventType.RoomTombstone: this.setState({ tombstone: this.getRoomTombstone() }); break; - + case EventType.RoomEncryption: { + await this.updateRoomEncrypted(); + break; + } default: this.updatePermissions(this.state.room); } }; + private async updateRoomEncrypted(room = this.state.room): Promise { + if (!room || !this.context.client) return; + + const isRoomEncrypted = await this.getIsRoomEncrypted(room.roomId); + const newE2EStatus = isRoomEncrypted ? await this.cacheAndGetE2EStatus(room, this.context.client) : null; + + this.setState({ + isRoomEncrypted, + showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted), + ...(newE2EStatus && { e2eStatus: newE2EStatus }), + }); + } + private onRoomStateUpdate = (state: RoomState): void => { // ignore members in other rooms if (state.roomId !== this.state.room?.roomId) { @@ -2027,6 +2073,8 @@ export class RoomView extends React.Component { public render(): ReactNode { if (!this.context.client) return null; + const { isRoomEncrypted } = this.state; + const isRoomEncryptionLoading = isRoomEncrypted === null; if (this.state.room instanceof LocalRoom) { if (this.state.room.state === LocalRoomState.CREATING) { @@ -2242,14 +2290,16 @@ export class RoomView extends React.Component { let aux: JSX.Element | undefined; let previewBar; if (this.state.timelineRenderingType === TimelineRenderingType.Search) { - aux = ( - - ); + if (!isRoomEncryptionLoading) { + aux = ( + + ); + } } else if (showRoomUpgradeBar) { aux = ; } else if (myMembership !== KnownMembership.Join) { @@ -2325,8 +2375,10 @@ export class RoomView extends React.Component { let messageComposer; const showComposer = + !isRoomEncryptionLoading && // joined and not showing search results - myMembership === KnownMembership.Join && !this.state.search; + myMembership === KnownMembership.Join && + !this.state.search; if (showComposer) { messageComposer = ( { highlightedEventId = this.state.initialEventId; } - const messagePanel = ( -