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/release_prepare.yml b/.github/workflows/release_prepare.yml index b655bb4206..2d04852258 100644 --- a/.github/workflows/release_prepare.yml +++ b/.github/workflows/release_prepare.yml @@ -19,8 +19,23 @@ on: default: true permissions: {} # Uses ELEMENT_BOT_TOKEN instead jobs: + checks: + name: Sanity checks + strategy: + matrix: + repo: + - matrix-org/matrix-js-sdk + - element-hq/element-web + - element-hq/element-desktop + uses: matrix-org/matrix-js-sdk/.github/workflows/release-checks.yml@develop + secrets: + GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + with: + repository: ${{ matrix.repo }} + prepare: runs-on: ubuntu-24.04 + needs: checks env: # The order is specified bottom-up to avoid any races for allchange REPOS: matrix-js-sdk element-web element-desktop 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..8417edc6f6 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "resolutions": { "oidc-client-ts": "3.1.0", "jwt-decode": "4.0.0", - "caniuse-lite": "1.0.30001679", + "caniuse-lite": "1.0.30001684", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0", "wrap-ansi": "npm:wrap-ansi@^7.0.0" }, @@ -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", @@ -273,6 +273,7 @@ "raw-loader": "^4.0.2", "rimraf": "^6.0.0", "semver": "^7.5.2", + "source-map-loader": "^5.0.0", "stylelint": "^16.1.0", "stylelint-config-standard": "^36.0.0", "stylelint-scss": "^6.0.0", 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 7ccbd7def3..941a08d276 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:34da08a44994e0ad2def7ed5f28c3cc7a2f7ead9722f4ae87b23030f59384ea5"; +const DOCKER_TAG = "develop@sha256:b261d81d9a3615a7716fc92423ee5689b0b450ed49f87a4887e49ecab7aefe45"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); 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/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 74a91d8cbc..07a20315f5 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -104,7 +104,11 @@ class FilePanel extends React.Component { } if (!this.state.timelineSet.eventIdToTimeline(ev.getId()!)) { - this.state.timelineSet.addEventToTimeline(ev, timeline, false); + this.state.timelineSet.addEventToTimeline(ev, timeline, { + fromCache: false, + addToState: false, + toStartOfTimeline: false, + }); } } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 0042169f45..019f9cd1a8 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -23,7 +23,6 @@ import classNames from "classnames"; import { isOnlyCtrlOrCmdKeyEvent, Key } from "../../Keyboard"; import PageTypes from "../../PageTypes"; import MediaDeviceHandler from "../../MediaDeviceHandler"; -import { fixupColorFonts } from "../../utils/FontManager"; import dis from "../../dispatcher/dispatcher"; import { IMatrixClientCreds } from "../../MatrixClientPeg"; import SettingsStore from "../../settings/SettingsStore"; @@ -149,8 +148,6 @@ class LoggedInView extends React.Component { MediaDeviceHandler.loadDevices(); - fixupColorFonts(); - this._roomView = React.createRef(); this._resizeContainer = React.createRef(); this.resizeHandler = React.createRef(); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index af37226d7d..aaf1db42ad 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 = ( -