Skip to content

Commit

Permalink
feat(crypto): Support verification violation composer banner (#29067)
Browse files Browse the repository at this point in the history
* feat(crypto): Support verification violation composer banner

* refactor UserIdentityWarning by using now a ViewModel

fixup: logger import

fixup: test lint type problems

fix test having an unexpected verification violation

fixup sonarcubes warnings

* review: comments on types and inline some const

* review: Quick refactor, better handling of action on button click

* review: Small updates, remove commented code
  • Loading branch information
BillCarsonFr authored Jan 31, 2025
1 parent dcce9c7 commit d3a6f34
Show file tree
Hide file tree
Showing 6 changed files with 547 additions and 451 deletions.
6 changes: 6 additions & 0 deletions res/css/views/rooms/_UserIdentityWarning.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ Please see LICENSE files in the repository root for full details.
margin-left: var(--cpd-space-6x);
flex-grow: 1;
}
.mx_UserIdentityWarning_main.critical {
color: var(--cpd-color-text-critical-primary);
}
}
}
.mx_UserIdentityWarning.critical {
background: linear-gradient(180deg, var(--cpd-color-red-100) 0%, var(--cpd-color-theme-bg) 100%);
}

.mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning {
margin-left: calc(-25px + var(--RoomView_MessageList-padding));
Expand Down
192 changes: 192 additions & 0 deletions src/components/viewmodels/rooms/UserIdentityWarningViewModel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { useCallback, useEffect, useMemo, useState } from "react";
import { EventType, MatrixEvent, Room, RoomMember, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { CryptoApi, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { throttle } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";

import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter.ts";

export type ViolationType = "PinViolation" | "VerificationViolation";

/**
* Represents a prompt to the user about a violation in the room.
* The type of violation and the member it relates to are included.
* If the type is "VerificationViolation", the warning is critical and should be reported with more urgency.
*/
export type ViolationPrompt = {
member: RoomMember;
type: ViolationType;
};

/**
* The state of the UserIdentityWarningViewModel.
* This includes the current prompt to show to the user and a callback to handle button clicks.
* If currentPrompt is undefined, there are no violations to show.
*/
export interface UserIdentityWarningState {
currentPrompt?: ViolationPrompt;
dispatchAction: (action: UserIdentityWarningViewModelAction) => void;
}

/**
* List of actions that can be dispatched to the UserIdentityWarningViewModel.
*/
export type UserIdentityWarningViewModelAction =
| { type: "PinUserIdentity"; userId: string }
| { type: "WithdrawVerification"; userId: string };

/**
* Maps a list of room members to a list of violations.
* Checks for all members in the room to see if they have any violations.
* If no violations are found, an empty list is returned.
*
* @param cryptoApi
* @param members - The list of room members to check for violations.
*/
async function mapToViolations(cryptoApi: CryptoApi, members: RoomMember[]): Promise<ViolationPrompt[]> {
const violationList = new Array<ViolationPrompt>();
for (const member of members) {
const verificationStatus = await cryptoApi.getUserVerificationStatus(member.userId);
if (verificationStatus.wasCrossSigningVerified() && !verificationStatus.isCrossSigningVerified()) {
violationList.push({ member, type: "VerificationViolation" });
} else if (verificationStatus.needsUserApproval) {
violationList.push({ member, type: "PinViolation" });
}
}
return violationList;
}

export function useUserIdentityWarningViewModel(room: Room, key: string): UserIdentityWarningState {
const cli = useMatrixClientContext();
const crypto = cli.getCrypto();

const [members, setMembers] = useState<RoomMember[]>([]);
const [currentPrompt, setCurrentPrompt] = useState<ViolationPrompt | undefined>(undefined);

const loadViolations = useMemo(
() =>
throttle(async (): Promise<void> => {
const isEncrypted = crypto && (await crypto.isEncryptionEnabledInRoom(room.roomId));
if (!isEncrypted) {
setMembers([]);
setCurrentPrompt(undefined);
return;
}

const targetMembers = await room.getEncryptionTargetMembers();
setMembers(targetMembers);
const violations = await mapToViolations(crypto, targetMembers);

let candidatePrompt: ViolationPrompt | undefined;
if (violations.length > 0) {
// sort by user ID to ensure consistent ordering
const sortedViolations = violations.sort((a, b) => a.member.userId.localeCompare(b.member.userId));
candidatePrompt = sortedViolations[0];
} else {
candidatePrompt = undefined;
}

// is the current prompt still valid?
setCurrentPrompt((existingPrompt): ViolationPrompt | undefined => {
if (existingPrompt && violations.includes(existingPrompt)) {
return existingPrompt;
} else if (candidatePrompt) {
return candidatePrompt;
} else {
return undefined;
}
});
}),
[crypto, room],
);

// We need to listen for changes to the members list
useTypedEventEmitter(
cli,
RoomStateEvent.Events,
useCallback(
async (event: MatrixEvent): Promise<void> => {
if (!crypto || event.getRoomId() !== room.roomId) {
return;
}
let shouldRefresh = false;

const eventType = event.getType();

if (eventType === EventType.RoomEncryption && event.getStateKey() === "") {
// Room is now encrypted, so we can initialise the component.
shouldRefresh = true;
} else if (eventType == EventType.RoomMember) {
// We're processing an m.room.member event
// Something has changed in membership, someone joined or someone left or
// someone changed their display name. Anyhow let's refresh.
const userId = event.getStateKey();
shouldRefresh = !!userId;
}

if (shouldRefresh) {
loadViolations().catch((e) => {
logger.error("Error refreshing UserIdentityWarningViewModel:", e);
});
}
},
[crypto, room, loadViolations],
),
);

// We need to listen for changes to the verification status of the members to refresh violations
useTypedEventEmitter(
cli,
CryptoEvent.UserTrustStatusChanged,
useCallback(
(userId: string): void => {
if (members.find((m) => m.userId == userId)) {
// This member is tracked, we need to refresh.
// refresh all for now?
// As a later optimisation we could store the current violations and only update the relevant one.
loadViolations().catch((e) => {
logger.error("Error refreshing UserIdentityWarning:", e);
});
}
},
[loadViolations, members],
),
);

useEffect(() => {
loadViolations().catch((e) => {
logger.error("Error initialising UserIdentityWarning:", e);
});
}, [loadViolations]);

const dispatchAction = useCallback(
(action: UserIdentityWarningViewModelAction): void => {
if (!crypto) {
return;
}
if (action.type === "PinUserIdentity") {
crypto.pinCurrentUserIdentity(action.userId).catch((e) => {
logger.error("Error pinning user identity:", e);
});
} else if (action.type === "WithdrawVerification") {
crypto.withdrawVerificationRequirement(action.userId).catch((e) => {
logger.error("Error withdrawing verification requirement:", e);
});
}
},
[crypto],
);

return {
currentPrompt,
dispatchAction,
};
}
Loading

0 comments on commit d3a6f34

Please sign in to comment.