Skip to content

Commit

Permalink
Improve display of edited events
Browse files Browse the repository at this point in the history
  • Loading branch information
richvdh committed Dec 19, 2022
1 parent 0f79d6f commit a4d82f5
Show file tree
Hide file tree
Showing 4 changed files with 330 additions and 8 deletions.
84 changes: 83 additions & 1 deletion cypress/e2e/crypto/crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ limitations under the License.

import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";

import type { ISendEventResponse } from "matrix-js-sdk/src/matrix";
import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS";
import type { CypressBot } from "../../support/bot";
Expand Down Expand Up @@ -198,7 +199,7 @@ describe("Cryptography", function () {
cy.bootstrapCrossSigning();
autoJoin(this.bob);

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

verify.call(this);
});

it("should show the correct shield on edited e2e events", function (this: CryptoTestContext) {
cy.bootstrapCrossSigning();

// bob has a second, not cross-signed, device
cy.loginBot(this.synapse, this.bob.getUserId(), this.bob.__cypress_password, {}).as("bobSecondDevice");

autoJoin(this.bob);

// first create the room, so that we can open the verification panel
cy.createRoom({ name: "TestRoom", invite: [this.bob.getUserId()] })
.as("testRoomId")
.then((roomId) => {
cy.log(`Created test room ${roomId}`);
cy.visit(`/#/room/${roomId}`);

// enable encryption
cy.getClient().then((cli) => {
cli.sendStateEvent(roomId, "m.room.encryption", { algorithm: "m.megolm.v1.aes-sha2" });
});

// wait for Bob to join the room, otherwise our attempt to open his user details may race
// with his join.
cy.contains(".mx_TextualEvent", "Bob joined the room").should("exist");
});

verify.call(this);

cy.get<string>("@testRoomId").then((roomId) => {
// bob sends a valid event
cy.wrap(this.bob.sendTextMessage(roomId, "Hoo!")).as("testEvent");

// the message should appear, decrypted, with no warning
cy.contains(".mx_EventTile_body", "Hoo!")
.closest(".mx_EventTile")
.should("have.class", "mx_EventTile_verified")
.should("not.have.descendants", ".mx_EventTile_e2eIcon_warning");

// bob sends an edit to the first message with his unverified device
cy.get<MatrixClient>("@bobSecondDevice").then((bobSecondDevice) => {
cy.get<ISendEventResponse>("@testEvent").then((testEvent) => {
bobSecondDevice.sendMessage(roomId, {
"m.new_content": {
msgtype: "m.text",
body: "Haa!",
},
"m.relates_to": {
rel_type: "m.replace",
event_id: testEvent.event_id,
},
});
});
});

// the edit should have a warning
cy.contains(".mx_EventTile_body", "Haa!")
.closest(".mx_EventTile")
.within(() => {
cy.get(".mx_EventTile_e2eIcon_warning").should("exist");
});

// a second edit from the verified device should be ok
cy.get<ISendEventResponse>("@testEvent").then((testEvent) => {
this.bob.sendMessage(roomId, {
"m.new_content": {
msgtype: "m.text",
body: "Hee!",
},
"m.relates_to": {
rel_type: "m.replace",
event_id: testEvent.event_id,
},
});
});

cy.contains(".mx_EventTile_body", "Hee!")
.closest(".mx_EventTile")
.should("have.class", "mx_EventTile_verified")
.should("not.have.descendants", ".mx_EventTile_e2eIcon_warning");
});
});
});
17 changes: 15 additions & 2 deletions src/components/views/rooms/EventTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
client.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged);
client.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged);
this.props.mxEvent.on(MatrixEventEvent.Decrypted, this.onDecrypted);
this.props.mxEvent.on(MatrixEventEvent.Replaced, this.onReplaced);
DecryptionFailureTracker.instance.addVisibleEvent(this.props.mxEvent);
if (this.props.showReactions) {
this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onReactionsCreated);
Expand Down Expand Up @@ -461,6 +462,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
this.isListeningForReceipts = false;
this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted);
this.props.mxEvent.removeListener(MatrixEventEvent.Replaced, this.onReplaced);
if (this.props.showReactions) {
this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onReactionsCreated);
}
Expand Down Expand Up @@ -607,10 +609,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
};

/** called when the event is edited after we show it. */
private onReplaced = () => {
// re-verify the event if it is replaced (the edit may not be verified)
this.verifyEvent();
};

private verifyEvent(): void {
const mxEvent = this.props.mxEvent;
// if the event was edited, show the verification info for the edit, not
// the original
const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;

if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) {
this.setState({ verified: null });
return;
}

Expand Down Expand Up @@ -743,7 +754,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
};

private renderE2EPadlock() {
const ev = this.props.mxEvent;
// if the event was edited, show the verification info for the edit, not
// the original
const ev = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;

// no icon for local rooms
if (isLocalRoom(ev.getRoomId()!)) return;
Expand Down
190 changes: 185 additions & 5 deletions test/components/views/rooms/EventTile-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,30 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React from "react";
import * as React from "react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { DeviceTrustLevel, UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning";
import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
import { IEncryptedEventInfo } from "matrix-js-sdk/src/crypto/api";

import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { getRoomContext, mkEvent, mkMessage, stubClient } from "../../../test-utils";
import { getRoomContext, mkEncryptedEvent, mkEvent, mkMessage, stubClient } from "../../../test-utils";
import { mkThread } from "../../../test-utils/threads";

describe("EventTile", () => {
const ROOM_ID = "!roomId:example.org";
let mxEvent: MatrixEvent;
let room: Room;
let client: MatrixClient;

// let changeEvent: (event: MatrixEvent) => void;

function TestEventTile(props: Partial<EventTileProps>) {
Expand Down Expand Up @@ -67,7 +71,7 @@ describe("EventTile", () => {
stubClient();
client = MatrixClientPeg.get();

room = new Room(ROOM_ID, client, client.getUserId(), {
room = new Room(ROOM_ID, client, client.getUserId()!, {
pendingEventOrdering: PendingEventOrdering.Detached,
});

Expand Down Expand Up @@ -140,18 +144,194 @@ describe("EventTile", () => {
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0);

act(() => {
room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Total, 3);
room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Total, 3);
});

expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(0);

act(() => {
room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Highlight, 1);
room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Highlight, 1);
});

expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(1);
});
});

describe("Event verification", () => {
// data for our stubbed getEventEncryptionInfo: a map from event id to result
const eventToEncryptionInfoMap = new Map<string, IEncryptedEventInfo>();

const TRUSTED_DEVICE = DeviceInfo.fromStorage({}, "TRUSTED_DEVICE");
const UNTRUSTED_DEVICE = DeviceInfo.fromStorage({}, "UNTRUSTED_DEVICE");

beforeEach(() => {
eventToEncryptionInfoMap.clear();

// a mocked version of getEventEncryptionInfo which will pick its result from `eventToEncryptionInfoMap`
client.getEventEncryptionInfo = (event) => eventToEncryptionInfoMap.get(event.getId()!)!;

// a mocked version of checkUserTrust which always says the user is trusted (we do our testing via
// unverified devices).
const trustedUserTrustLevel = new UserTrustLevel(true, true, true);
client.checkUserTrust = (_userId) => trustedUserTrustLevel;

// a version of checkDeviceTrust which says that TRUSTED_DEVICE is trusted, and others are not.
const trustedDeviceTrustLevel = DeviceTrustLevel.fromUserTrustLevel(trustedUserTrustLevel, true, false);
const untrustedDeviceTrustLevel = DeviceTrustLevel.fromUserTrustLevel(trustedUserTrustLevel, false, false);
client.checkDeviceTrust = (userId, deviceId) => {
if (deviceId === TRUSTED_DEVICE.deviceId) {
return trustedDeviceTrustLevel;
} else {
return untrustedDeviceTrustLevel;
}
};
});

it("shows a warning for an event from an unverified device", async () => {
mxEvent = await mkEncryptedEvent({
plainContent: { msgtype: "m.text", body: "msg1" },
plainType: "m.room.message",
user: "@alice:example.org",
room: room.roomId,
});
eventToEncryptionInfoMap.set(mxEvent.getId()!, {
authenticated: true,
sender: UNTRUSTED_DEVICE,
} as IEncryptedEventInfo);

const { container } = getComponent();

const eventTiles = container.getElementsByClassName("mx_EventTile");
expect(eventTiles).toHaveLength(1);
const eventTile = eventTiles[0];

expect(eventTile.classList).toContain("mx_EventTile_unverified");

// there should be a warning shield
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain(
"mx_EventTile_e2eIcon_warning",
);
});

it("shows no shield for a verified event", async () => {
mxEvent = await mkEncryptedEvent({
plainContent: { msgtype: "m.text", body: "msg1" },
plainType: "m.room.message",
user: "@alice:example.org",
room: room.roomId,
});
eventToEncryptionInfoMap.set(mxEvent.getId()!, {
authenticated: true,
sender: TRUSTED_DEVICE,
} as IEncryptedEventInfo);

const { container } = getComponent();

const eventTiles = container.getElementsByClassName("mx_EventTile");
expect(eventTiles).toHaveLength(1);
const eventTile = eventTiles[0];

expect(eventTile.classList).toContain("mx_EventTile_verified");

// there should be no warning
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
});

it("should update the warning when the event is edited", async () => {
// we start out with an event from the trusted device
mxEvent = await mkEncryptedEvent({
plainContent: { msgtype: "m.text", body: "msg1" },
plainType: "m.room.message",
user: "@alice:example.org",
room: room.roomId,
});
eventToEncryptionInfoMap.set(mxEvent.getId()!, {
authenticated: true,
sender: TRUSTED_DEVICE,
} as IEncryptedEventInfo);

const { container } = getComponent();

const eventTiles = container.getElementsByClassName("mx_EventTile");
expect(eventTiles).toHaveLength(1);
const eventTile = eventTiles[0];

expect(eventTile.classList).toContain("mx_EventTile_verified");

// there should be no warning
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);

// then we replace the event with one from the unverified device
const replacementEvent = await mkEncryptedEvent({
plainContent: { msgtype: "m.text", body: "msg1" },
plainType: "m.room.message",
user: "@alice:example.org",
room: room.roomId,
});
eventToEncryptionInfoMap.set(replacementEvent.getId()!, {
authenticated: true,
sender: UNTRUSTED_DEVICE,
} as IEncryptedEventInfo);

act(() => {
mxEvent.makeReplaced(replacementEvent);
});

// check it was updated
expect(eventTile.classList).toContain("mx_EventTile_unverified");
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain(
"mx_EventTile_e2eIcon_warning",
);
});

it("should update the warning when the event is replaced with an unencrypted one", async () => {
jest.spyOn(client, "isRoomEncrypted").mockReturnValue(true);

// we start out with an event from the trusted device
mxEvent = await mkEncryptedEvent({
plainContent: { msgtype: "m.text", body: "msg1" },
plainType: "m.room.message",
user: "@alice:example.org",
room: room.roomId,
});
eventToEncryptionInfoMap.set(mxEvent.getId()!, {
authenticated: true,
sender: TRUSTED_DEVICE,
} as IEncryptedEventInfo);

const { container } = getComponent();

const eventTiles = container.getElementsByClassName("mx_EventTile");
expect(eventTiles).toHaveLength(1);
const eventTile = eventTiles[0];

expect(eventTile.classList).toContain("mx_EventTile_verified");

// there should be no warning
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);

// then we replace the event with an unencrypted one
const replacementEvent = await mkMessage({
msg: "msg2",
user: "@alice:example.org",
room: room.roomId,
event: true,
});

act(() => {
mxEvent.makeReplaced(replacementEvent);
});

// check it was updated
expect(eventTile.classList).not.toContain("mx_EventTile_verified");
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain(
"mx_EventTile_e2eIcon_warning",
);
});
});
});
Loading

0 comments on commit a4d82f5

Please sign in to comment.