diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 7252c27b5f..5a11ad5bbd 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -7,6 +7,8 @@ on: branches: - develop +permissions: {} # We use ELEMENT_BOT_TOKEN instead + jobs: backport: name: Backport diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 55f5c1f4a3..381755b606 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,7 @@ env: # These must be set for fetchdep.sh to get the right branch REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} +permissions: {} # No permissions required jobs: build: name: "Build on ${{ matrix.image }}" diff --git a/.github/workflows/build_debian.yaml b/.github/workflows/build_debian.yaml index 319dccd9f2..f46678512a 100644 --- a/.github/workflows/build_debian.yaml +++ b/.github/workflows/build_debian.yaml @@ -3,6 +3,7 @@ on: release: types: [published] concurrency: ${{ github.workflow }} +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: build: name: Build package diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index b4c96c4eef..8bbcfe726f 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -9,6 +9,7 @@ on: concurrency: group: ${{ github.repository_owner }}-${{ github.workflow }}-${{ github.ref_name }} cancel-in-progress: true +permissions: {} jobs: build: name: "Build & Deploy develop.element.io" @@ -16,6 +17,10 @@ jobs: if: github.repository == 'element-hq/element-web' runs-on: ubuntu-24.04 environment: develop + 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/dockerhub.yaml b/.github/workflows/dockerhub.yaml index 65457ab8f9..7911cf794a 100644 --- a/.github/workflows/dockerhub.yaml +++ b/.github/workflows/dockerhub.yaml @@ -7,14 +7,14 @@ on: # This job can take a while, and we have usage limits, so just publish develop only twice a day - cron: "0 7/12 * * *" concurrency: ${{ github.workflow }}-${{ github.ref_name }} - -permissions: - id-token: write # needed for signing the images with GitHub OIDC Token +permissions: {} jobs: buildx: name: Docker Buildx runs-on: ubuntu-24.04 environment: dockerhub + permissions: + id-token: write # needed for signing the images with GitHub OIDC Token steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c3f08deb1d..a301b6daf6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,10 +5,7 @@ on: branches: [develop] workflow_dispatch: {} -permissions: - contents: read - pages: write - id-token: write +permissions: {} concurrency: group: "pages" @@ -100,6 +97,9 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write needs: build steps: - name: Deploy to GitHub Pages diff --git a/.github/workflows/end-to-end-tests-netlify.yaml b/.github/workflows/end-to-end-tests-netlify.yaml index a15e02c9ee..e25994ec9d 100644 --- a/.github/workflows/end-to-end-tests-netlify.yaml +++ b/.github/workflows/end-to-end-tests-netlify.yaml @@ -11,6 +11,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }} cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }} +permissions: {} + jobs: report: if: github.event.workflow_run.conclusion != 'cancelled' @@ -20,11 +22,12 @@ jobs: permissions: statuses: write deployments: write + actions: read steps: - name: Download HTML report uses: actions/download-artifact@v4 with: - github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} name: html-report path: playwright-report diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index 1784dafe0e..1a31f75065 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -33,6 +33,8 @@ env: # fetchdep.sh needs to know our PR number PR_NUMBER: ${{ github.event.pull_request.number }} +permissions: {} # No permissions required + jobs: build: name: "Build Element-Web" diff --git a/.github/workflows/issue_closed.yml b/.github/workflows/issue_closed.yml index 191f345cc9..2cffae0011 100644 --- a/.github/workflows/issue_closed.yml +++ b/.github/workflows/issue_closed.yml @@ -4,6 +4,7 @@ on: issues: types: [closed] +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: tidy: name: Tidy closed issues diff --git a/.github/workflows/localazy_download.yaml b/.github/workflows/localazy_download.yaml index a880c3b2e4..435b8154ba 100644 --- a/.github/workflows/localazy_download.yaml +++ b/.github/workflows/localazy_download.yaml @@ -3,6 +3,7 @@ on: workflow_dispatch: {} schedule: - cron: "0 6 * * 1,3,5" # Every Monday, Wednesday and Friday at 6am UTC +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: download: uses: matrix-org/matrix-web-i18n/.github/workflows/localazy_download.yaml@main diff --git a/.github/workflows/localazy_upload.yaml b/.github/workflows/localazy_upload.yaml index 9ba79800db..8cb7743968 100644 --- a/.github/workflows/localazy_upload.yaml +++ b/.github/workflows/localazy_upload.yaml @@ -4,6 +4,7 @@ on: branches: [develop] paths: - "src/i18n/strings/en_EN.json" +permissions: {} # No permissions needed jobs: upload: uses: matrix-org/matrix-web-i18n/.github/workflows/localazy_upload.yaml@main diff --git a/.github/workflows/netlify.yaml b/.github/workflows/netlify.yaml index 174c6579c3..63bac7d33f 100644 --- a/.github/workflows/netlify.yaml +++ b/.github/workflows/netlify.yaml @@ -11,6 +11,9 @@ jobs: if: github.event.workflow_run.conclusion != 'cancelled' && github.event.workflow_run.event == 'pull_request' runs-on: ubuntu-24.04 environment: Netlify + permissions: + actions: read + deployments: write steps: - name: 📝 Create Deployment uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1 @@ -27,7 +30,7 @@ jobs: - name: 📥 Download artifact uses: actions/download-artifact@v4 with: - github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} name: webapp path: webapp diff --git a/.github/workflows/pending-reviews.yaml b/.github/workflows/pending-reviews.yaml index 499da6a9b3..c96ed3f17e 100644 --- a/.github/workflows/pending-reviews.yaml +++ b/.github/workflows/pending-reviews.yaml @@ -6,6 +6,7 @@ on: #schedule: # - cron: "*/10 * * * *" concurrency: ${{ github.workflow }} +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: bot: name: Pending reviews bot diff --git a/.github/workflows/playwright-image-updates.yaml b/.github/workflows/playwright-image-updates.yaml index 1492adc736..1613b42dfb 100644 --- a/.github/workflows/playwright-image-updates.yaml +++ b/.github/workflows/playwright-image-updates.yaml @@ -3,9 +3,12 @@ on: workflow_dispatch: {} schedule: - cron: "0 6 * * *" # Every day at 6am UTC +permissions: {} jobs: update: runs-on: ubuntu-24.04 + permissions: + pull-requests: write steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 1f49adfcc4..2f97ccbbb4 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -4,8 +4,11 @@ on: types: [opened, edited, labeled, unlabeled, synchronize] merge_group: types: [checks_requested] +permissions: {} jobs: action: uses: matrix-org/matrix-js-sdk/.github/workflows/pull_request.yaml@develop + permissions: + pull-requests: read secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/pull_request_base_branch.yaml b/.github/workflows/pull_request_base_branch.yaml index 04ad3f3106..6610ee4879 100644 --- a/.github/workflows/pull_request_base_branch.yaml +++ b/.github/workflows/pull_request_base_branch.yaml @@ -2,6 +2,7 @@ name: Pull Request Base Branch on: pull_request: types: [opened, edited, synchronize] +permissions: {} # No permissions required jobs: check_base_branch: name: Check PR base branch diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index d8afa80a9f..c4bf8e6ab3 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -4,6 +4,9 @@ on: branches: [staging] workflow_dispatch: {} concurrency: ${{ github.workflow }} +permissions: {} jobs: draft: + permissions: + contents: write uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop diff --git a/.github/workflows/release-gitflow.yml b/.github/workflows/release-gitflow.yml index 34232d420d..128c6a1e05 100644 --- a/.github/workflows/release-gitflow.yml +++ b/.github/workflows/release-gitflow.yml @@ -4,6 +4,7 @@ on: push: branches: [master] concurrency: ${{ github.repository }}-${{ github.workflow }} +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: merge: uses: matrix-org/matrix-js-sdk/.github/workflows/release-gitflow.yml@develop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a9c29e197..2ecc4a4662 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,13 @@ on: - rc - final concurrency: ${{ github.workflow }} +permissions: {} jobs: release: uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop + permissions: + contents: write + issues: write secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} @@ -42,6 +46,8 @@ jobs: name: Post release checks needs: release runs-on: ubuntu-24.04 + permissions: + checks: read steps: - name: Wait for dockerhub uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork diff --git a/.github/workflows/release_prepare.yml b/.github/workflows/release_prepare.yml index 5fb969a1c6..b655bb4206 100644 --- a/.github/workflows/release_prepare.yml +++ b/.github/workflows/release_prepare.yml @@ -17,6 +17,7 @@ on: required: true type: boolean default: true +permissions: {} # Uses ELEMENT_BOT_TOKEN instead jobs: prepare: runs-on: ubuntu-24.04 diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index d9b26c78e8..0ee457bac2 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -7,11 +7,16 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} cancel-in-progress: true +permissions: {} jobs: sonarqube: name: 🩻 SonarQube if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group' uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop + permissions: + actions: read + statuses: write + id-token: write # sonar secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 87e5a70730..b7c02c3f2e 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -16,6 +16,8 @@ env: REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} +permissions: {} # No permissions required + jobs: ts_lint: name: "Typescript Syntax Check" @@ -37,6 +39,8 @@ jobs: i18n_lint: name: "i18n Check" uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main + permissions: + pull-requests: read with: hardcoded-words: "Element" allowed-hardcoded-keys: | diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index bb22292a64..fa1be485bb 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -8,6 +8,9 @@ on: - develop paths: - .github/labels.yml + +permissions: {} # We use ELEMENT_BOT_TOKEN instead + jobs: sync-labels: uses: element-hq/element-meta/.github/workflows/sync-labels.yml@develop diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 59fefb2f80..14fd5ffd64 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,6 +26,8 @@ env: # fetchdep.sh needs to know our PR number PR_NUMBER: ${{ github.event.pull_request.number }} +permissions: {} + jobs: jest: name: Jest @@ -94,13 +96,15 @@ jobs: needs: jest if: always() runs-on: ubuntu-24.04 + permissions: + statuses: write steps: - if: needs.jest.result != 'skipped' && needs.jest.result != 'success' run: exit 1 - name: Skip SonarCloud in merge queue if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' - uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1 + uses: guibranco/github-status-action-v2@1f26a0237cd1a57626fbb5a0eb2494c9b8797d07 with: authToken: ${{ secrets.GITHUB_TOKEN }} state: success diff --git a/.github/workflows/triage-assigned.yml b/.github/workflows/triage-assigned.yml index 81d1dff80f..e43eb94618 100644 --- a/.github/workflows/triage-assigned.yml +++ b/.github/workflows/triage-assigned.yml @@ -4,6 +4,8 @@ on: issues: types: [assigned] +permissions: {} # We use ELEMENT_BOT_TOKEN instead + jobs: web-app-team: runs-on: ubuntu-24.04 diff --git a/.github/workflows/triage-incoming.yml b/.github/workflows/triage-incoming.yml index e63017dc3b..b084b4d55e 100644 --- a/.github/workflows/triage-incoming.yml +++ b/.github/workflows/triage-incoming.yml @@ -4,6 +4,8 @@ on: issues: types: [opened] +permissions: {} # We use ELEMENT_BOT_TOKEN instead + jobs: automate-project-columns: runs-on: ubuntu-24.04 diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index 0112f180c1..2cb05a8bcf 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -8,6 +8,8 @@ on: ELEMENT_BOT_TOKEN: required: true +permissions: {} # We use ELEMENT_BOT_TOKEN instead + jobs: apply_Z-Labs_label: name: Add Z-Labs label for features behind labs flags diff --git a/.github/workflows/triage-move-review-requests.yml b/.github/workflows/triage-move-review-requests.yml index 72d9786a4a..d3bcda270b 100644 --- a/.github/workflows/triage-move-review-requests.yml +++ b/.github/workflows/triage-move-review-requests.yml @@ -3,6 +3,7 @@ on: pull_request_target: types: [review_requested] +permissions: {} # Uses ELEMENT_BOT_TOKEN instead jobs: add_design_pr_to_project: name: Move PRs asking for design review to the design board diff --git a/.github/workflows/triage-stale-flaky-tests.yml b/.github/workflows/triage-stale-flaky-tests.yml index d339a136cd..90ba7c40f7 100644 --- a/.github/workflows/triage-stale-flaky-tests.yml +++ b/.github/workflows/triage-stale-flaky-tests.yml @@ -2,6 +2,7 @@ name: Close stale flaky issues on: schedule: - cron: "30 1 * * *" +permissions: {} jobs: close: runs-on: ubuntu-24.04 diff --git a/.github/workflows/triage-unlabelled.yml b/.github/workflows/triage-unlabelled.yml index 1cd1c80afc..efbf80eea9 100644 --- a/.github/workflows/triage-unlabelled.yml +++ b/.github/workflows/triage-unlabelled.yml @@ -3,11 +3,13 @@ name: Move unlabelled from needs info columns to triaged on: issues: types: [unlabeled] - +permissions: {} jobs: Move_Unabeled_Issue_On_Project_Board: name: Move no longer X-Needs-Info issues to Triaged runs-on: ubuntu-24.04 + permissions: + repository-projects: read if: > ${{ !contains(github.event.issue.labels.*.name, 'X-Needs-Info') }} diff --git a/.github/workflows/update-jitsi.yml b/.github/workflows/update-jitsi.yml index 68dbf22e63..bf0414e73a 100644 --- a/.github/workflows/update-jitsi.yml +++ b/.github/workflows/update-jitsi.yml @@ -4,6 +4,7 @@ on: workflow_dispatch: {} schedule: - cron: "0 3 * * 0" # 3am every Sunday +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: update: runs-on: ubuntu-24.04 diff --git a/.github/workflows/update-topics.yaml b/.github/workflows/update-topics.yaml index a984fc4f03..cd6c2fc553 100644 --- a/.github/workflows/update-topics.yaml +++ b/.github/workflows/update-topics.yaml @@ -15,6 +15,7 @@ on: required: true type: string concurrency: ${{ github.workflow }} +permissions: {} # No permissions required jobs: bot: name: Release topic update 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/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/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index d8cac8e28b..7d45e71a4b 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -275,7 +275,7 @@ export default class MImageBody extends React.Component { } const content = this.props.mxEvent.getContent(); - let isAnimated = mayBeAnimated(content.info?.mimetype); + let isAnimated = content.info?.["org.matrix.msc4230.is_animated"] ?? mayBeAnimated(content.info?.mimetype); // If there is no included non-animated thumbnail then we will generate our own, we can't depend on the server // because 1. encryption and 2. we can't ask the server specifically for a non-animated thumbnail. @@ -298,8 +298,15 @@ export default class MImageBody extends React.Component { } try { - const blob = await this.props.mediaEventHelper!.sourceBlob.value; - if (!(await blobIsAnimated(content.info?.mimetype, blob))) { + // If we didn't receive the MSC4230 is_animated flag + // then we need to check if the image is animated by downloading it. + if ( + content.info?.["org.matrix.msc4230.is_animated"] === false || + !(await blobIsAnimated( + content.info?.mimetype, + await this.props.mediaEventHelper!.sourceBlob.value, + )) + ) { isAnimated = false; } diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 62029f46c3..27189000d1 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -30,6 +30,7 @@ import E2EIcon from "./E2EIcon"; import SettingsStore from "../../../settings/SettingsStore"; import { aboveLeftOf, MenuProps } from "../../structures/ContextMenu"; import ReplyPreview from "./ReplyPreview"; +import { UserIdentityWarning } from "./UserIdentityWarning"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore"; @@ -669,6 +670,7 @@ export class MessageComposer extends React.Component {
+ { + const verificationStatus = await crypto.getUserVerificationStatus(userId); + return verificationStatus.needsUserApproval; +} + +/** + * Whether the component is uninitialised, is in the process of initialising, or + * has completed initialising. + */ +enum InitialisationStatus { + Uninitialised, + Initialising, + Completed, +} + +/** + * Displays a banner warning when there is an issue with a user's identity. + * + * Warns when an unverified user's identity has changed, and gives the user a + * button to acknowledge the change. + */ +export const UserIdentityWarning: React.FC = ({ room }) => { + const cli = useMatrixClientContext(); + const crypto = cli.getCrypto(); + + // The current room member that we are prompting the user to approve. + // `undefined` means we are not currently showing a prompt. + const [currentPrompt, setCurrentPrompt] = useState(undefined); + + // Whether or not we've already initialised the component by loading the + // room membership. + const initialisedRef = useRef(InitialisationStatus.Uninitialised); + // Which room members need their identity approved. + const membersNeedingApprovalRef = useRef>(new Map()); + // For each user, we assign a sequence number to each verification status + // that we get, or fetch. + // + // Since fetching a verification status is asynchronous, we could get an + // update in the middle of fetching the verification status, which could + // mean that the status that we fetched is out of date. So if the current + // sequence number is not the same as the sequence number when we started + // the fetch, then we drop our fetched result, under the assumption that the + // update that we received is the most up-to-date version. If it is in fact + // not the most up-to-date version, then we should be receiving a new update + // soon with the newer value, so it will fix itself in the end. + // + // We also assign a sequence number when the user leaves the room, in order + // to prevent prompting about a user who leaves while we are fetching their + // verification status. + const verificationStatusSequencesRef = useRef>(new Map()); + const incrementVerificationStatusSequence = (userId: string): number => { + const verificationStatusSequences = verificationStatusSequencesRef.current; + const value = verificationStatusSequences.get(userId); + const newValue = value === undefined ? 1 : value + 1; + verificationStatusSequences.set(userId, newValue); + return newValue; + }; + + // Update the current prompt. Select a new user if needed, or hide the + // warning if we don't have anyone to warn about. + const updateCurrentPrompt = useCallback((): undefined => { + const membersNeedingApproval = membersNeedingApprovalRef.current; + // We have to do this in a callback to `setCurrentPrompt` + // because this function could have been called after an + // `await`, and the `currentPrompt` that this function would + // have may be outdated. + setCurrentPrompt((currentPrompt) => { + // If we're already displaying a warning, and that user still needs + // approval, continue showing that user. + if (currentPrompt && membersNeedingApproval.has(currentPrompt.userId)) return currentPrompt; + + if (membersNeedingApproval.size === 0) { + return undefined; + } + + // We pick the user with the smallest user ID. + const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b)); + const selection = membersNeedingApproval.get(keys[0]!); + return selection; + }); + }, []); + + // Add a user to the membersNeedingApproval map, and update the current + // prompt if necessary. The user will only be added if they are actually a + // member of the room. If they are not a member, this function will do + // nothing. + const addMemberNeedingApproval = useCallback( + (userId: string, member?: RoomMember): void => { + if (userId === cli.getUserId()) { + // We always skip our own user, because we can't pin our own identity. + return; + } + member = member ?? room.getMember(userId) ?? undefined; + if (!member) return; + + membersNeedingApprovalRef.current.set(userId, member); + // We only select the prompt if we are done initialising, + // because we will select the prompt after we're done + // initialising, and we want to start by displaying a warning + // for the user with the smallest ID. + if (initialisedRef.current === InitialisationStatus.Completed) { + updateCurrentPrompt(); + } + }, + [cli, room, updateCurrentPrompt], + ); + + // For each user in the list check if their identity needs approval, and if + // so, add them to the membersNeedingApproval map and update the prompt if + // needed. + const addMembersWhoNeedApproval = useCallback( + async (members: RoomMember[]): Promise => { + const verificationStatusSequences = verificationStatusSequencesRef.current; + + const promises: Promise[] = []; + + for (const member of members) { + const userId = member.userId; + const sequenceNum = incrementVerificationStatusSequence(userId); + promises.push( + userNeedsApproval(crypto!, userId).then((needsApproval) => { + if (needsApproval) { + // Only actually update the list if we have the most + // recent value. + if (verificationStatusSequences.get(userId) === sequenceNum) { + addMemberNeedingApproval(userId, member); + } + } + }), + ); + } + + await Promise.all(promises); + }, + [crypto, addMemberNeedingApproval], + ); + + // Remove a user from the membersNeedingApproval map, and update the current + // prompt if necessary. + const removeMemberNeedingApproval = useCallback( + (userId: string): void => { + membersNeedingApprovalRef.current.delete(userId); + updateCurrentPrompt(); + }, + [updateCurrentPrompt], + ); + + // Initialise the component. Get the room members, check which ones need + // their identity approved, and pick one to display. + const loadMembers = useCallback(async (): Promise => { + if (!crypto || initialisedRef.current !== InitialisationStatus.Uninitialised) { + return; + } + // If encryption is not enabled in the room, we don't need to do + // anything. If encryption gets enabled later, we will retry, via + // onRoomStateEvent. + if (!(await crypto.isEncryptionEnabledInRoom(room.roomId))) { + return; + } + initialisedRef.current = InitialisationStatus.Initialising; + + const members = await room.getEncryptionTargetMembers(); + await addMembersWhoNeedApproval(members); + + updateCurrentPrompt(); + initialisedRef.current = InitialisationStatus.Completed; + }, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]); + + 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. + const onUserVerificationStatusChanged = useCallback( + (userId: string, verificationStatus: UserVerificationStatus): void => { + // If we haven't started initialising, that means that we're in a + // room where we don't need to display any warnings. + if (initialisedRef.current === InitialisationStatus.Uninitialised) { + return; + } + + incrementVerificationStatusSequence(userId); + + if (verificationStatus.needsUserApproval) { + addMemberNeedingApproval(userId); + } else { + removeMemberNeedingApproval(userId); + } + }, + [addMemberNeedingApproval, removeMemberNeedingApproval], + ); + useTypedEventEmitter(cli, CryptoEvent.UserTrustStatusChanged, onUserVerificationStatusChanged); + + // We watch for encryption events (since we only display warnings in + // encrypted rooms), and for membership changes (since we only display + // warnings for users in the room). + const onRoomStateEvent = useCallback( + async (event: MatrixEvent): Promise => { + if (!crypto || event.getRoomId() !== room.roomId) { + return; + } + + const eventType = event.getType(); + if (eventType === EventType.RoomEncryption && event.getStateKey() === "") { + // Room is now encrypted, so we can initialise the component. + return loadMembers().catch((e) => { + logger.error("Error initialising UserIdentityWarning:", e); + }); + } else if (eventType !== EventType.RoomMember) { + return; + } + + // We're processing an m.room.member event + + if (initialisedRef.current === InitialisationStatus.Uninitialised) { + return; + } + + const userId = event.getStateKey(); + + if (!userId) return; + + if ( + event.getContent().membership === KnownMembership.Join || + (event.getContent().membership === KnownMembership.Invite && room.shouldEncryptForInvitedMembers()) + ) { + // Someone's membership changed and we will now encrypt to them. If + // their identity needs approval, show a warning. + const member = room.getMember(userId); + if (member) { + await addMembersWhoNeedApproval([member]).catch((e) => { + logger.error("Error adding member in UserIdentityWarning:", e); + }); + } + } else { + // Someone's membership changed and we no longer encrypt to them. + // If we're showing a warning about them, we don't need to any more. + removeMemberNeedingApproval(userId); + incrementVerificationStatusSequence(userId); + } + }, + [crypto, room, addMembersWhoNeedApproval, removeMemberNeedingApproval, loadMembers], + ); + useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent); + + if (!crypto || !currentPrompt) return null; + + const confirmIdentity = async (): Promise => { + await crypto.pinCurrentUserIdentity(currentPrompt.userId); + }; + + return ( +
+ +
+ + + {currentPrompt.rawDisplayName === currentPrompt.userId + ? _t( + "encryption|pinned_identity_changed_no_displayname", + { userId: currentPrompt.userId }, + { + a: substituteATag, + b: substituteBTag, + }, + ) + : _t( + "encryption|pinned_identity_changed", + { displayName: currentPrompt.rawDisplayName, userId: currentPrompt.userId }, + { + a: substituteATag, + b: substituteBTag, + }, + )} + + +
+
+ ); +}; + +function substituteATag(sub: string): React.ReactNode { + return ( + + {sub} + + ); +} + +function substituteBTag(sub: string): React.ReactNode { + return {sub}; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3b4765b0ad..66428300a0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -905,6 +905,8 @@ "warning": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings." }, "not_supported": "", + "pinned_identity_changed": "%(displayName)s's (%(userId)s) identity appears to have changed. Learn more", + "pinned_identity_changed_no_displayname": "%(userId)s's identity appears to have changed. Learn more", "recovery_method_removed": { "description_1": "This session has detected that your Security Phrase and key for Secure Messages have been removed.", "description_2": "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.", diff --git a/src/utils/image-media.ts b/src/utils/image-media.ts index 5e0fb07678..5c013c7b1a 100644 --- a/src/utils/image-media.ts +++ b/src/utils/image-media.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { EncryptedFile } from "matrix-js-sdk/src/types"; +import { ImageInfo } from "matrix-js-sdk/src/types"; import { BlurhashEncoder } from "../BlurhashEncoder"; @@ -15,19 +15,7 @@ type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448 interface IThumbnail { - info: { - thumbnail_info?: { - w: number; - h: number; - mimetype: string; - size: number; - }; - w: number; - h: number; - [BLURHASH_FIELD]?: string; - thumbnail_url?: string; - thumbnail_file?: EncryptedFile; - }; + info: ImageInfo; thumbnail: Blob; } diff --git a/src/vector/platform/ElectronPlatform.tsx b/src/vector/platform/ElectronPlatform.tsx index d7ebd94bb2..ac6e7a7feb 100644 --- a/src/vector/platform/ElectronPlatform.tsx +++ b/src/vector/platform/ElectronPlatform.tsx @@ -474,10 +474,8 @@ export default class ElectronPlatform extends BasePlatform { const url = super.getOidcCallbackUrl(); url.protocol = "io.element.desktop"; // Trim the double slash into a single slash to comply with https://datatracker.ietf.org/doc/html/rfc8252#section-7.1 - // Chrome seems to have a strange issue where non-standard protocols prevent URL object mutations on pathname - // field, so we cannot mutate `pathname` reliably and instead have to rewrite the href manually. - if (url.pathname.startsWith("//")) { - url.href = url.href.replace(url.pathname, url.pathname.slice(1)); + if (url.href.startsWith(`${url.protocol}://`)) { + url.href = url.href.replace("://", ":/"); } return url; } diff --git a/test/unit-tests/components/views/rooms/UserIdentityWarning-test.tsx b/test/unit-tests/components/views/rooms/UserIdentityWarning-test.tsx new file mode 100644 index 0000000000..9a70a88768 --- /dev/null +++ b/test/unit-tests/components/views/rooms/UserIdentityWarning-test.tsx @@ -0,0 +1,534 @@ +/* +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. +*/ + +import React from "react"; +import { sleep } from "matrix-js-sdk/src/utils"; +import { + EventType, + MatrixClient, + MatrixEvent, + Room, + RoomState, + RoomStateEvent, + RoomMember, +} from "matrix-js-sdk/src/matrix"; +import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; +import { act, render, screen, waitFor } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { stubClient } from "../../../../test-utils"; +import { UserIdentityWarning } from "../../../../../src/components/views/rooms/UserIdentityWarning"; +import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; + +const ROOM_ID = "!room:id"; + +function mockRoom(): Room { + const room = { + getEncryptionTargetMembers: jest.fn(async () => []), + getMember: jest.fn((userId) => {}), + roomId: ROOM_ID, + shouldEncryptForInvitedMembers: jest.fn(() => true), + } as unknown as Room; + + return room; +} + +function mockRoomMember(userId: string, name?: string): RoomMember { + return { + userId, + name: name ?? userId, + rawDisplayName: name ?? userId, + roomId: ROOM_ID, + getMxcAvatarUrl: jest.fn(), + } as unknown as RoomMember; +} + +function dummyRoomState(): RoomState { + return new RoomState(ROOM_ID); +} + +/** + * Get the warning element, given the warning text (excluding the "Learn more" + * link). This is needed because the warning text contains a `` tag, so the + * normal `getByText` doesn't work. + */ +function getWarningByText(text: string): Element { + return screen.getByText((content?: string, element?: Element | null): boolean => { + return ( + !!element && + element.classList.contains("mx_UserIdentityWarning_main") && + element.textContent === text + " Learn more" + ); + }); +} + +function renderComponent(client: MatrixClient, room: Room) { + return render(, { + wrapper: ({ ...rest }) => , + }); +} + +describe("UserIdentityWarning", () => { + let client: MatrixClient; + let room: Room; + + beforeEach(async () => { + client = stubClient(); + room = mockRoom(); + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // This tests the basic functionality of the component. If we have a room + // member whose identity needs accepting, we should display a warning. When + // the "OK" button gets pressed, it should call `pinCurrentUserIdentity`. + it("displays a warning when a user's identity needs approval", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + crypto.pinCurrentUserIdentity = jest.fn(); + renderComponent(client, room); + + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + await userEvent.click(screen.getByRole("button")!); + await waitFor(() => expect(crypto.pinCurrentUserIdentity).toHaveBeenCalledWith("@alice:example.org")); + }); + + // We don't display warnings in non-encrypted rooms, but if encryption is + // enabled, then we should display a warning if there are any users whose + // identity need accepting. + it("displays pending warnings when encryption is enabled", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + // Start the room off unencrypted. We shouldn't display anything. + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(false); + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(); + + // Encryption gets enabled in the room. We should now warn that Alice's + // identity changed. + jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(true); + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomEncryption, + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + }); + + // When a user's identity needs approval, or has been approved, the display + // should update appropriately. + it("updates the display when identity changes", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, false), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(); + + // The user changes their identity, so we should show the warning. + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, true), + ); + }); + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + + // Simulate the user's new identity having been approved, so we no + // longer show the warning. + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, false), + ); + }); + await waitFor(() => + expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(), + ); + }); + + // We only display warnings about users in the room. When someone + // joins/leaves, we should update the warning appropriately. + describe("updates the display when a member joins/leaves", () => { + it("when invited users can see encrypted messages", async () => { + // Nobody in the room yet + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(true); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + + // Alice joins. Her identity needs approval, so we should show a warning. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "join", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await waitFor(() => + expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + + // Bob is invited. His identity needs approval, so we should show a + // warning for him after Alice's warning is resolved by her leaving. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@bob:example.org", + content: { + membership: "invite", + }, + room_id: ROOM_ID, + sender: "@carol:example.org", + }), + dummyRoomState(), + null, + ); + + // Alice leaves, so we no longer show her warning, but we will show + // a warning for Bob. + act(() => { + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + }); + await waitFor(() => + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(), + ); + await waitFor(() => + expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + }); + + it("when invited users cannot see encrypted messages", async () => { + // Nobody in the room yet + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + + // Alice joins. Her identity needs approval, so we should show a warning. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "join", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await waitFor(() => + expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + + // Bob is invited. His identity needs approval, but we don't encrypt + // to him, so we won't show a warning. (When Alice leaves, the + // display won't be updated to show a warningfor Bob.) + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@bob:example.org", + content: { + membership: "invite", + }, + room_id: ROOM_ID, + sender: "@carol:example.org", + }), + dummyRoomState(), + null, + ); + + // Alice leaves, so we no longer show her warning, and we don't show + // a warning for Bob. + act(() => { + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + }); + await waitFor(() => + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(), + ); + await waitFor(() => + expect(() => getWarningByText("@bob:example.org's identity appears to have changed.")).toThrow(), + ); + }); + + it("when member leaves immediately after component is loaded", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockImplementation(async () => { + setTimeout(() => { + // Alice immediately leaves after we get the room + // membership, so we shouldn't show the warning any more + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + }); + return [mockRoomMember("@alice:example.org")]; + }); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + + await sleep(10); + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(); + }); + + it("when member leaves immediately after joining", async () => { + // Nobody in the room yet + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + + // Alice joins. Her identity needs approval, so we should show a warning. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "join", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + // ... but she immediately leaves, so we shouldn't show the warning any more + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await sleep(10); // give it some time to finish + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(); + }); + }); + + // When we have multiple users whose identity needs approval, one user's + // identity no longer needs approval (e.g. their identity was approved), + // then we show the next one. + it("displays the next user when the current user's identity is approved", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + mockRoomMember("@bob:example.org"), + ]); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + + renderComponent(client, room); + // We should warn about Alice's identity first. + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + + // Simulate Alice's new identity having been approved, so now we warn + // about Bob's identity. + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, false), + ); + }); + await waitFor(() => + expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + }); + + // If we get an update for a user's verification status while we're fetching + // that user's verification status, we should display based on the updated + // value. + describe("handles races between fetching verification status and receiving updates", () => { + // First case: check that if the update says that the user identity + // needs approval, but the fetch says it doesn't, we show the warning. + it("update says identity needs approval", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => { + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, true), + ); + }); + return Promise.resolve(new UserVerificationStatus(false, false, false, false)); + }); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + }); + + // Second case: check that if the update says that the user identity + // doesn't needs approval, but the fetch says it does, we don't show the + // warning. + it("update says identity doesn't need approval", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => { + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, false), + ); + }); + return Promise.resolve(new UserVerificationStatus(false, false, false, true)); + }); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + await waitFor(() => + expect(() => + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toThrow(), + ); + }); + }); +});