Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 26a01ab

Browse files
authored
Merge pull request #9789 from matrix-org/rav/edited_events
Ensure that events are correctly updated when they are edited.
2 parents 910aa0b + ad7c002 commit 26a01ab

File tree

5 files changed

+365
-81
lines changed

5 files changed

+365
-81
lines changed

cypress/e2e/crypto/crypto.spec.ts

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,17 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
18-
17+
import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix";
1918
import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
2019
import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS";
20+
import type { CypressBot } from "../../support/bot";
2121
import { SynapseInstance } from "../../plugins/synapsedocker";
2222
import Chainable = Cypress.Chainable;
2323

2424
type EmojiMapping = [emoji: string, name: string];
2525
interface CryptoTestContext extends Mocha.Context {
2626
synapse: SynapseInstance;
27-
bob: MatrixClient;
27+
bob: CypressBot;
2828
}
2929

3030
const waitForVerificationRequest = (cli: MatrixClient): Promise<VerificationRequest> => {
@@ -197,7 +197,7 @@ describe("Cryptography", function () {
197197
cy.bootstrapCrossSigning();
198198
autoJoin(this.bob);
199199

200-
/* we need to have a room with the other user present, so we can open the verification panel */
200+
// we need to have a room with the other user present, so we can open the verification panel
201201
let roomId: string;
202202
cy.createRoom({ name: "TestRoom", invite: [this.bob.getUserId()] }).then((_room1Id) => {
203203
roomId = _room1Id;
@@ -210,4 +210,85 @@ describe("Cryptography", function () {
210210

211211
verify.call(this);
212212
});
213+
214+
it("should show the correct shield on edited e2e events", function (this: CryptoTestContext) {
215+
cy.bootstrapCrossSigning();
216+
217+
// bob has a second, not cross-signed, device
218+
cy.loginBot(this.synapse, this.bob.getUserId(), this.bob.__cypress_password, {}).as("bobSecondDevice");
219+
220+
autoJoin(this.bob);
221+
222+
// first create the room, so that we can open the verification panel
223+
cy.createRoom({ name: "TestRoom", invite: [this.bob.getUserId()] })
224+
.as("testRoomId")
225+
.then((roomId) => {
226+
cy.log(`Created test room ${roomId}`);
227+
cy.visit(`/#/room/${roomId}`);
228+
229+
// enable encryption
230+
cy.getClient().then((cli) => {
231+
cli.sendStateEvent(roomId, "m.room.encryption", { algorithm: "m.megolm.v1.aes-sha2" });
232+
});
233+
234+
// wait for Bob to join the room, otherwise our attempt to open his user details may race
235+
// with his join.
236+
cy.contains(".mx_TextualEvent", "Bob joined the room").should("exist");
237+
});
238+
239+
verify.call(this);
240+
241+
cy.get<string>("@testRoomId").then((roomId) => {
242+
// bob sends a valid event
243+
cy.wrap(this.bob.sendTextMessage(roomId, "Hoo!")).as("testEvent");
244+
245+
// the message should appear, decrypted, with no warning
246+
cy.contains(".mx_EventTile_body", "Hoo!")
247+
.closest(".mx_EventTile")
248+
.should("have.class", "mx_EventTile_verified")
249+
.should("not.have.descendants", ".mx_EventTile_e2eIcon_warning");
250+
251+
// bob sends an edit to the first message with his unverified device
252+
cy.get<MatrixClient>("@bobSecondDevice").then((bobSecondDevice) => {
253+
cy.get<ISendEventResponse>("@testEvent").then((testEvent) => {
254+
bobSecondDevice.sendMessage(roomId, {
255+
"m.new_content": {
256+
msgtype: "m.text",
257+
body: "Haa!",
258+
},
259+
"m.relates_to": {
260+
rel_type: "m.replace",
261+
event_id: testEvent.event_id,
262+
},
263+
});
264+
});
265+
});
266+
267+
// the edit should have a warning
268+
cy.contains(".mx_EventTile_body", "Haa!")
269+
.closest(".mx_EventTile")
270+
.within(() => {
271+
cy.get(".mx_EventTile_e2eIcon_warning").should("exist");
272+
});
273+
274+
// a second edit from the verified device should be ok
275+
cy.get<ISendEventResponse>("@testEvent").then((testEvent) => {
276+
this.bob.sendMessage(roomId, {
277+
"m.new_content": {
278+
msgtype: "m.text",
279+
body: "Hee!",
280+
},
281+
"m.relates_to": {
282+
rel_type: "m.replace",
283+
event_id: testEvent.event_id,
284+
},
285+
});
286+
});
287+
288+
cy.contains(".mx_EventTile_body", "Hee!")
289+
.closest(".mx_EventTile")
290+
.should("have.class", "mx_EventTile_verified")
291+
.should("not.have.descendants", ".mx_EventTile_e2eIcon_warning");
292+
});
293+
});
213294
});

cypress/support/bot.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ const defaultCreateBotOptions = {
5151
bootstrapCrossSigning: true,
5252
} as CreateBotOpts;
5353

54+
export interface CypressBot extends MatrixClient {
55+
__cypress_password: string;
56+
}
57+
5458
declare global {
5559
// eslint-disable-next-line @typescript-eslint/no-namespace
5660
namespace Cypress {
@@ -60,7 +64,7 @@ declare global {
6064
* @param synapse the instance on which to register the bot user
6165
* @param opts create bot options
6266
*/
63-
getBot(synapse: SynapseInstance, opts: CreateBotOpts): Chainable<MatrixClient>;
67+
getBot(synapse: SynapseInstance, opts: CreateBotOpts): Chainable<CypressBot>;
6468
/**
6569
* Returns a new Bot instance logged in as an existing user
6670
* @param synapse the instance on which to register the bot user
@@ -156,14 +160,20 @@ function setupBotClient(
156160
});
157161
}
158162

159-
Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable<MatrixClient> => {
163+
Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable<CypressBot> => {
160164
opts = Object.assign({}, defaultCreateBotOptions, opts);
161165
const username = Cypress._.uniqueId(opts.userIdPrefix);
162166
const password = Cypress._.uniqueId("password_");
163-
return cy.registerUser(synapse, username, password, opts.displayName).then((credentials) => {
164-
cy.log(`Registered bot user ${username} with displayname ${opts.displayName}`);
165-
return setupBotClient(synapse, credentials, opts);
166-
});
167+
return cy
168+
.registerUser(synapse, username, password, opts.displayName)
169+
.then((credentials) => {
170+
cy.log(`Registered bot user ${username} with displayname ${opts.displayName}`);
171+
return setupBotClient(synapse, credentials, opts);
172+
})
173+
.then((client): Chainable<CypressBot> => {
174+
Object.assign(client, { __cypress_password: password });
175+
return cy.wrap(client as CypressBot);
176+
});
167177
});
168178

169179
Cypress.Commands.add(

src/components/views/rooms/EventTile.tsx

Lines changed: 38 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ interface IState {
232232
// Whether the action bar is focused.
233233
actionBarFocused: boolean;
234234
// Whether the event's sender has been verified.
235-
verified: string;
235+
verified: string | null;
236236
// The Relations model from the JS SDK for reactions to `mxEvent`
237237
reactions?: Relations | null | undefined;
238238

@@ -278,7 +278,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
278278
this.state = {
279279
// Whether the action bar is focused.
280280
actionBarFocused: false,
281-
// Whether the event's sender has been verified.
281+
// Whether the event's sender has been verified. `null` if no attempt has yet been made to verify
282+
// (including if the event is not encrypted).
282283
verified: null,
283284
// The Relations model from the JS SDK for reactions to `mxEvent`
284285
reactions: this.getReactions(),
@@ -371,6 +372,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
371372
client.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged);
372373
client.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged);
373374
this.props.mxEvent.on(MatrixEventEvent.Decrypted, this.onDecrypted);
375+
this.props.mxEvent.on(MatrixEventEvent.Replaced, this.onReplaced);
374376
DecryptionFailureTracker.instance.addVisibleEvent(this.props.mxEvent);
375377
if (this.props.showReactions) {
376378
this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onReactionsCreated);
@@ -395,7 +397,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
395397
const room = client.getRoom(this.props.mxEvent.getRoomId());
396398
room?.on(ThreadEvent.New, this.onNewThread);
397399

398-
this.verifyEvent(this.props.mxEvent);
400+
this.verifyEvent();
399401
}
400402

401403
private get supportsThreadNotifications(): boolean {
@@ -461,6 +463,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
461463
}
462464
this.isListeningForReceipts = false;
463465
this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted);
466+
this.props.mxEvent.removeListener(MatrixEventEvent.Replaced, this.onReplaced);
464467
if (this.props.showReactions) {
465468
this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onReactionsCreated);
466469
}
@@ -470,15 +473,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
470473
this.threadState?.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
471474
}
472475

473-
public componentDidUpdate(prevProps: Readonly<EventTileProps>) {
476+
public componentDidUpdate(prevProps: Readonly<EventTileProps>, prevState: Readonly<IState>) {
477+
// If the verification state changed, the height might have changed
478+
if (prevState.verified !== this.state.verified && this.props.onHeightChanged) {
479+
this.props.onHeightChanged();
480+
}
474481
// If we're not listening for receipts and expect to be, register a listener.
475482
if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) {
476483
MatrixClientPeg.get().on(RoomEvent.Receipt, this.onRoomReceipt);
477484
this.isListeningForReceipts = true;
478485
}
479486
// re-check the sender verification as outgoing events progress through the send process.
480487
if (prevProps.eventSendStatus !== this.props.eventSendStatus) {
481-
this.verifyEvent(this.props.mxEvent);
488+
this.verifyEvent();
482489
}
483490
}
484491

@@ -586,26 +593,36 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
586593
*/
587594
private onDecrypted = () => {
588595
// we need to re-verify the sending device.
589-
// (we call onHeightChanged in verifyEvent to handle the case where decryption
590-
// has caused a change in size of the event tile)
591-
this.verifyEvent(this.props.mxEvent);
592-
this.forceUpdate();
596+
this.verifyEvent();
597+
// decryption might, of course, trigger a height change, so call onHeightChanged after the re-render
598+
this.forceUpdate(this.props.onHeightChanged);
593599
};
594600

595601
private onDeviceVerificationChanged = (userId: string, device: string): void => {
596602
if (userId === this.props.mxEvent.getSender()) {
597-
this.verifyEvent(this.props.mxEvent);
603+
this.verifyEvent();
598604
}
599605
};
600606

601607
private onUserVerificationChanged = (userId: string, _trustStatus: UserTrustLevel): void => {
602608
if (userId === this.props.mxEvent.getSender()) {
603-
this.verifyEvent(this.props.mxEvent);
609+
this.verifyEvent();
604610
}
605611
};
606612

607-
private async verifyEvent(mxEvent: MatrixEvent): Promise<void> {
613+
/** called when the event is edited after we show it. */
614+
private onReplaced = () => {
615+
// re-verify the event if it is replaced (the edit may not be verified)
616+
this.verifyEvent();
617+
};
618+
619+
private verifyEvent(): void {
620+
// if the event was edited, show the verification info for the edit, not
621+
// the original
622+
const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;
623+
608624
if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) {
625+
this.setState({ verified: null });
609626
return;
610627
}
611628

@@ -615,66 +632,36 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
615632

616633
if (encryptionInfo.mismatchedSender) {
617634
// something definitely wrong is going on here
618-
this.setState(
619-
{
620-
verified: E2EState.Warning,
621-
},
622-
this.props.onHeightChanged,
623-
); // Decryption may have caused a change in size
635+
this.setState({ verified: E2EState.Warning });
624636
return;
625637
}
626638

627639
if (!userTrust.isCrossSigningVerified()) {
628640
// If the message is unauthenticated, then display a grey
629641
// shield, otherwise if the user isn't cross-signed then
630642
// nothing's needed
631-
this.setState(
632-
{
633-
verified: encryptionInfo.authenticated ? E2EState.Normal : E2EState.Unauthenticated,
634-
},
635-
this.props.onHeightChanged,
636-
); // Decryption may have caused a change in size
643+
this.setState({ verified: encryptionInfo.authenticated ? E2EState.Normal : E2EState.Unauthenticated });
637644
return;
638645
}
639646

640647
const eventSenderTrust =
641648
encryptionInfo.sender && MatrixClientPeg.get().checkDeviceTrust(senderId, encryptionInfo.sender.deviceId);
642649
if (!eventSenderTrust) {
643-
this.setState(
644-
{
645-
verified: E2EState.Unknown,
646-
},
647-
this.props.onHeightChanged,
648-
); // Decryption may have caused a change in size
650+
this.setState({ verified: E2EState.Unknown });
649651
return;
650652
}
651653

652654
if (!eventSenderTrust.isVerified()) {
653-
this.setState(
654-
{
655-
verified: E2EState.Warning,
656-
},
657-
this.props.onHeightChanged,
658-
); // Decryption may have caused a change in size
655+
this.setState({ verified: E2EState.Warning });
659656
return;
660657
}
661658

662659
if (!encryptionInfo.authenticated) {
663-
this.setState(
664-
{
665-
verified: E2EState.Unauthenticated,
666-
},
667-
this.props.onHeightChanged,
668-
); // Decryption may have caused a change in size
660+
this.setState({ verified: E2EState.Unauthenticated });
669661
return;
670662
}
671663

672-
this.setState(
673-
{
674-
verified: E2EState.Verified,
675-
},
676-
this.props.onHeightChanged,
677-
); // Decryption may have caused a change in size
664+
this.setState({ verified: E2EState.Verified });
678665
}
679666

680667
private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean {
@@ -768,7 +755,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
768755
};
769756

770757
private renderE2EPadlock() {
771-
const ev = this.props.mxEvent;
758+
// if the event was edited, show the verification info for the edit, not
759+
// the original
760+
const ev = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;
772761

773762
// no icon for local rooms
774763
if (isLocalRoom(ev.getRoomId()!)) return;

0 commit comments

Comments
 (0)