/* 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 { const violationList = new Array(); 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([]); const [currentPrompt, setCurrentPrompt] = useState(undefined); const loadViolations = useMemo( () => throttle(async (): Promise => { 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 => { 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, }; }