Merge branch 'develop' into florianduros/rip-out-legacy-crypto/3-remove-isRoomEncrypted

This commit is contained in:
Florian Duros 2024-11-22 17:18:26 +01:00 committed by GitHub
commit cf4d2fd593
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1001 additions and 33 deletions

View File

@ -7,6 +7,8 @@ on:
branches:
- develop
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
backport:
name: Backport

View File

@ -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 }}"

View File

@ -3,6 +3,7 @@ on:
release:
types: [published]
concurrency: ${{ github.workflow }}
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
build:
name: Build package

View File

@ -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 }}

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -4,6 +4,7 @@
on:
issues:
types: [closed]
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
tidy:
name: Tidy closed issues

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -6,6 +6,7 @@ on:
#schedule:
# - cron: "*/10 * * * *"
concurrency: ${{ github.workflow }}
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
bot:
name: Pending reviews bot

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -17,6 +17,7 @@ on:
required: true
type: boolean
default: true
permissions: {} # Uses ELEMENT_BOT_TOKEN instead
jobs:
prepare:
runs-on: ubuntu-24.04

View File

@ -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 }}

View File

@ -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: |

View File

@ -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

View File

@ -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

View File

@ -4,6 +4,8 @@ on:
issues:
types: [assigned]
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
web-app-team:
runs-on: ubuntu-24.04

View File

@ -4,6 +4,8 @@ on:
issues:
types: [opened]
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
automate-project-columns:
runs-on: ubuntu-24.04

View File

@ -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

View File

@ -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

View File

@ -2,6 +2,7 @@ name: Close stale flaky issues
on:
schedule:
- cron: "30 1 * * *"
permissions: {}
jobs:
close:
runs-on: ubuntu-24.04

View File

@ -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') }}

View File

@ -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

View File

@ -15,6 +15,7 @@ on:
required: true
type: string
concurrency: ${{ github.workflow }}
permissions: {} # No permissions required
jobs:
bot:
name: Release topic update

View File

@ -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";

View File

@ -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));
}

View File

@ -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;

View File

@ -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 ||

View File

@ -275,7 +275,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
}
const content = this.props.mxEvent.getContent<ImageContent>();
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<IBodyProps, IState> {
}
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;
}

View File

@ -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<IProps, IState> {
<Tooltip open={isTooltipOpen} description={formatTimeLeft(secondsLeft)} placement="bottom">
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
<div className="mx_MessageComposer_wrapper">
<UserIdentityWarning room={this.props.room} key={this.props.room.roomId} />
<ReplyPreview
replyToEvent={this.props.replyToEvent}
permalinkCreator={this.props.permalinkCreator}

View File

@ -0,0 +1,328 @@
/*
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, { useCallback, useRef, useState } from "react";
import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix";
import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { Button, Separator } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
interface UserIdentityWarningProps {
/**
* The current room being viewed.
*/
room: Room;
/**
* The ID of the room being viewed. This is used to ensure that the
* component's state and references are cleared when the room changes.
*/
key: string;
}
/**
* Does the given user's identity need to be approved?
*/
async function userNeedsApproval(crypto: CryptoApi, userId: string): Promise<boolean> {
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<UserIdentityWarningProps> = ({ 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<RoomMember | undefined>(undefined);
// Whether or not we've already initialised the component by loading the
// room membership.
const initialisedRef = useRef<InitialisationStatus>(InitialisationStatus.Uninitialised);
// Which room members need their identity approved.
const membersNeedingApprovalRef = useRef<Map<string, RoomMember>>(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<Map<string, number>>(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<void> => {
const verificationStatusSequences = verificationStatusSequencesRef.current;
const promises: Promise<void>[] = [];
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<void> => {
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<void> => {
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<void> => {
await crypto.pinCurrentUserIdentity(currentPrompt.userId);
};
return (
<div className="mx_UserIdentityWarning">
<Separator />
<div className="mx_UserIdentityWarning_row">
<MemberAvatar member={currentPrompt} title={currentPrompt.userId} size="30px" />
<span className="mx_UserIdentityWarning_main">
{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,
},
)}
</span>
<Button kind="primary" size="sm" onClick={confirmIdentity}>
{_t("action|ok")}
</Button>
</div>
</div>
);
};
function substituteATag(sub: string): React.ReactNode {
return (
<a href="https://element.io/help#encryption18" target="_blank" rel="noreferrer noopener">
{sub}
</a>
);
}
function substituteBTag(sub: string): React.ReactNode {
return <b>{sub}</b>;
}

View File

@ -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": "<not supported>",
"pinned_identity_changed": "%(displayName)s's (<b>%(userId)s</b>) identity appears to have changed. <a>Learn more</a>",
"pinned_identity_changed_no_displayname": "<b>%(userId)s</b>'s identity appears to have changed. <a>Learn more</a>",
"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.",

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 `<b>` 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(<UserIdentityWarning room={room} key={ROOM_ID} />, {
wrapper: ({ ...rest }) => <MatrixClientContext.Provider value={client} {...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(),
);
});
});
});