|
| 1 | +/* |
| 2 | +Copyright 2025 New Vector Ltd. |
| 3 | +
|
| 4 | +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial |
| 5 | +Please see LICENSE files in the repository root for full details. |
| 6 | +*/ |
| 7 | + |
| 8 | +import { useCallback, useEffect, useMemo, useState } from "react"; |
| 9 | +import { EventType, MatrixEvent, Room, RoomMember, RoomStateEvent } from "matrix-js-sdk/src/matrix"; |
| 10 | +import { CryptoApi, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; |
| 11 | +import { throttle } from "lodash"; |
| 12 | +import { logger } from "matrix-js-sdk/src/logger"; |
| 13 | + |
| 14 | +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx"; |
| 15 | +import { useTypedEventEmitter } from "../../../hooks/useEventEmitter.ts"; |
| 16 | + |
| 17 | +export type ViolationType = "PinViolation" | "VerificationViolation"; |
| 18 | + |
| 19 | +/** |
| 20 | + * Represents a prompt to the user about a violation in the room. |
| 21 | + * The type of violation and the member it relates to are included. |
| 22 | + * If the type is "VerificationViolation", the warning is critical and should be reported with more urgency. |
| 23 | + */ |
| 24 | +export type ViolationPrompt = { |
| 25 | + member: RoomMember; |
| 26 | + type: ViolationType; |
| 27 | +}; |
| 28 | + |
| 29 | +/** |
| 30 | + * The state of the UserIdentityWarningViewModel. |
| 31 | + * This includes the current prompt to show to the user and a callback to handle button clicks. |
| 32 | + * If currentPrompt is undefined, there are no violations to show. |
| 33 | + */ |
| 34 | +export interface UserIdentityWarningState { |
| 35 | + currentPrompt?: ViolationPrompt; |
| 36 | + dispatchAction: (action: UserIdentityWarningViewModelAction) => void; |
| 37 | +} |
| 38 | + |
| 39 | +/** |
| 40 | + * List of actions that can be dispatched to the UserIdentityWarningViewModel. |
| 41 | + */ |
| 42 | +export type UserIdentityWarningViewModelAction = |
| 43 | + | { type: "PinUserIdentity"; userId: string } |
| 44 | + | { type: "WithdrawVerification"; userId: string }; |
| 45 | + |
| 46 | +/** |
| 47 | + * Maps a list of room members to a list of violations. |
| 48 | + * Checks for all members in the room to see if they have any violations. |
| 49 | + * If no violations are found, an empty list is returned. |
| 50 | + * |
| 51 | + * @param cryptoApi |
| 52 | + * @param members - The list of room members to check for violations. |
| 53 | + */ |
| 54 | +async function mapToViolations(cryptoApi: CryptoApi, members: RoomMember[]): Promise<ViolationPrompt[]> { |
| 55 | + const violationList = new Array<ViolationPrompt>(); |
| 56 | + for (const member of members) { |
| 57 | + const verificationStatus = await cryptoApi.getUserVerificationStatus(member.userId); |
| 58 | + if (verificationStatus.wasCrossSigningVerified() && !verificationStatus.isCrossSigningVerified()) { |
| 59 | + violationList.push({ member, type: "VerificationViolation" }); |
| 60 | + } else if (verificationStatus.needsUserApproval) { |
| 61 | + violationList.push({ member, type: "PinViolation" }); |
| 62 | + } |
| 63 | + } |
| 64 | + return violationList; |
| 65 | +} |
| 66 | + |
| 67 | +export function useUserIdentityWarningViewModel(room: Room, key: string): UserIdentityWarningState { |
| 68 | + const cli = useMatrixClientContext(); |
| 69 | + const crypto = cli.getCrypto(); |
| 70 | + |
| 71 | + const [members, setMembers] = useState<RoomMember[]>([]); |
| 72 | + const [currentPrompt, setCurrentPrompt] = useState<ViolationPrompt | undefined>(undefined); |
| 73 | + |
| 74 | + const loadViolations = useMemo( |
| 75 | + () => |
| 76 | + throttle(async (): Promise<void> => { |
| 77 | + const isEncrypted = crypto && (await crypto.isEncryptionEnabledInRoom(room.roomId)); |
| 78 | + if (!isEncrypted) { |
| 79 | + setMembers([]); |
| 80 | + setCurrentPrompt(undefined); |
| 81 | + return; |
| 82 | + } |
| 83 | + |
| 84 | + const targetMembers = await room.getEncryptionTargetMembers(); |
| 85 | + setMembers(targetMembers); |
| 86 | + const violations = await mapToViolations(crypto, targetMembers); |
| 87 | + |
| 88 | + let candidatePrompt: ViolationPrompt | undefined; |
| 89 | + if (violations.length > 0) { |
| 90 | + // sort by user ID to ensure consistent ordering |
| 91 | + const sortedViolations = violations.sort((a, b) => a.member.userId.localeCompare(b.member.userId)); |
| 92 | + candidatePrompt = sortedViolations[0]; |
| 93 | + } else { |
| 94 | + candidatePrompt = undefined; |
| 95 | + } |
| 96 | + |
| 97 | + // is the current prompt still valid? |
| 98 | + setCurrentPrompt((existingPrompt): ViolationPrompt | undefined => { |
| 99 | + if (existingPrompt && violations.includes(existingPrompt)) { |
| 100 | + return existingPrompt; |
| 101 | + } else if (candidatePrompt) { |
| 102 | + return candidatePrompt; |
| 103 | + } else { |
| 104 | + return undefined; |
| 105 | + } |
| 106 | + }); |
| 107 | + }), |
| 108 | + [crypto, room], |
| 109 | + ); |
| 110 | + |
| 111 | + // We need to listen for changes to the members list |
| 112 | + useTypedEventEmitter( |
| 113 | + cli, |
| 114 | + RoomStateEvent.Events, |
| 115 | + useCallback( |
| 116 | + async (event: MatrixEvent): Promise<void> => { |
| 117 | + if (!crypto || event.getRoomId() !== room.roomId) { |
| 118 | + return; |
| 119 | + } |
| 120 | + let shouldRefresh = false; |
| 121 | + |
| 122 | + const eventType = event.getType(); |
| 123 | + |
| 124 | + if (eventType === EventType.RoomEncryption && event.getStateKey() === "") { |
| 125 | + // Room is now encrypted, so we can initialise the component. |
| 126 | + shouldRefresh = true; |
| 127 | + } else if (eventType == EventType.RoomMember) { |
| 128 | + // We're processing an m.room.member event |
| 129 | + // Something has changed in membership, someone joined or someone left or |
| 130 | + // someone changed their display name. Anyhow let's refresh. |
| 131 | + const userId = event.getStateKey(); |
| 132 | + shouldRefresh = !!userId; |
| 133 | + } |
| 134 | + |
| 135 | + if (shouldRefresh) { |
| 136 | + loadViolations().catch((e) => { |
| 137 | + logger.error("Error refreshing UserIdentityWarningViewModel:", e); |
| 138 | + }); |
| 139 | + } |
| 140 | + }, |
| 141 | + [crypto, room, loadViolations], |
| 142 | + ), |
| 143 | + ); |
| 144 | + |
| 145 | + // We need to listen for changes to the verification status of the members to refresh violations |
| 146 | + useTypedEventEmitter( |
| 147 | + cli, |
| 148 | + CryptoEvent.UserTrustStatusChanged, |
| 149 | + useCallback( |
| 150 | + (userId: string): void => { |
| 151 | + if (members.find((m) => m.userId == userId)) { |
| 152 | + // This member is tracked, we need to refresh. |
| 153 | + // refresh all for now? |
| 154 | + // As a later optimisation we could store the current violations and only update the relevant one. |
| 155 | + loadViolations().catch((e) => { |
| 156 | + logger.error("Error refreshing UserIdentityWarning:", e); |
| 157 | + }); |
| 158 | + } |
| 159 | + }, |
| 160 | + [loadViolations, members], |
| 161 | + ), |
| 162 | + ); |
| 163 | + |
| 164 | + useEffect(() => { |
| 165 | + loadViolations().catch((e) => { |
| 166 | + logger.error("Error initialising UserIdentityWarning:", e); |
| 167 | + }); |
| 168 | + }, [loadViolations]); |
| 169 | + |
| 170 | + const dispatchAction = useCallback( |
| 171 | + (action: UserIdentityWarningViewModelAction): void => { |
| 172 | + if (!crypto) { |
| 173 | + return; |
| 174 | + } |
| 175 | + if (action.type === "PinUserIdentity") { |
| 176 | + crypto.pinCurrentUserIdentity(action.userId).catch((e) => { |
| 177 | + logger.error("Error pinning user identity:", e); |
| 178 | + }); |
| 179 | + } else if (action.type === "WithdrawVerification") { |
| 180 | + crypto.withdrawVerificationRequirement(action.userId).catch((e) => { |
| 181 | + logger.error("Error withdrawing verification requirement:", e); |
| 182 | + }); |
| 183 | + } |
| 184 | + }, |
| 185 | + [crypto], |
| 186 | + ); |
| 187 | + |
| 188 | + return { |
| 189 | + currentPrompt, |
| 190 | + dispatchAction, |
| 191 | + }; |
| 192 | +} |
0 commit comments