Skip to content

Commit d3a6f34

Browse files
authored
feat(crypto): Support verification violation composer banner (#29067)
* 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
1 parent dcce9c7 commit d3a6f34

File tree

6 files changed

+547
-451
lines changed

6 files changed

+547
-451
lines changed

res/css/views/rooms/_UserIdentityWarning.pcss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,14 @@ Please see LICENSE files in the repository root for full details.
2020
margin-left: var(--cpd-space-6x);
2121
flex-grow: 1;
2222
}
23+
.mx_UserIdentityWarning_main.critical {
24+
color: var(--cpd-color-text-critical-primary);
25+
}
2326
}
2427
}
28+
.mx_UserIdentityWarning.critical {
29+
background: linear-gradient(180deg, var(--cpd-color-red-100) 0%, var(--cpd-color-theme-bg) 100%);
30+
}
2531

2632
.mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning {
2733
margin-left: calc(-25px + var(--RoomView_MessageList-padding));
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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

Comments
 (0)