8b7f49e74e
* Rewrite doesRoomOrThreadHaveUnreadMessages to use the receipt rewrite from js-sdk * Remove unit tests that rely on receipt timestamps Previously, if we found a receipt for an unknown event, we would use the receipt timestamp and declare all events before that time to be read. Now, we ignore such "dangling" receipts until we find the event they refer to. This new behaviour is more correct, but does lead to more messages being considered unread. This commit deletes tests that checked for the old behaviour. * Check for a missing thread in determineUnreadState * Fix incorrect way to find room timeline * More realistic test setup to support new receipt code * Update snapshot to expect a room to be unread when there are no receipts * Formatting fixes * Update snapshot to show menu and notif button * Disable some flaky tests * Disable some flaky tests * Fix test to make a threaded receipt for an event that is actually in the thread --------- Co-authored-by: Florian Duros <florianduros@element.io> Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
527 lines
21 KiB
TypeScript
527 lines
21 KiB
TypeScript
/*
|
|
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
import * as React from "react";
|
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { mocked } from "jest-mock";
|
|
import {
|
|
CryptoApi,
|
|
EventType,
|
|
IEventDecryptionResult,
|
|
MatrixClient,
|
|
MatrixEvent,
|
|
NotificationCountType,
|
|
PendingEventOrdering,
|
|
Room,
|
|
TweakName,
|
|
} from "matrix-js-sdk/src/matrix";
|
|
import { EventEncryptionInfo, EventShieldColour, EventShieldReason } from "matrix-js-sdk/src/crypto-api";
|
|
import { CryptoBackend } from "matrix-js-sdk/src/common-crypto/CryptoBackend";
|
|
|
|
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 {
|
|
filterConsole,
|
|
flushPromises,
|
|
getRoomContext,
|
|
mkEncryptedEvent,
|
|
mkEvent,
|
|
mkMessage,
|
|
stubClient,
|
|
} from "../../../test-utils";
|
|
import { mkThread } from "../../../test-utils/threads";
|
|
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
|
import dis from "../../../../src/dispatcher/dispatcher";
|
|
import { Action } from "../../../../src/dispatcher/actions";
|
|
import { IRoomState } from "../../../../src/components/structures/RoomView";
|
|
|
|
describe("EventTile", () => {
|
|
const ROOM_ID = "!roomId:example.org";
|
|
let mxEvent: MatrixEvent;
|
|
let room: Room;
|
|
let client: MatrixClient;
|
|
|
|
// let changeEvent: (event: MatrixEvent) => void;
|
|
|
|
/** wrap the EventTile up in context providers, and with basic properties, as it would be by MessagePanel normally. */
|
|
function WrappedEventTile(props: {
|
|
roomContext: IRoomState;
|
|
eventTilePropertyOverrides?: Partial<EventTileProps>;
|
|
}) {
|
|
return (
|
|
<MatrixClientContext.Provider value={client}>
|
|
<RoomContext.Provider value={props.roomContext}>
|
|
<EventTile
|
|
mxEvent={mxEvent}
|
|
replacingEventId={mxEvent.replacingEventId()}
|
|
{...(props.eventTilePropertyOverrides ?? {})}
|
|
/>
|
|
</RoomContext.Provider>
|
|
</MatrixClientContext.Provider>
|
|
);
|
|
}
|
|
|
|
function getComponent(
|
|
overrides: Partial<EventTileProps> = {},
|
|
renderingType: TimelineRenderingType = TimelineRenderingType.Room,
|
|
) {
|
|
const context = getRoomContext(room, {
|
|
timelineRenderingType: renderingType,
|
|
});
|
|
return render(<WrappedEventTile roomContext={context} eventTilePropertyOverrides={overrides} />);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
stubClient();
|
|
client = MatrixClientPeg.safeGet();
|
|
|
|
room = new Room(ROOM_ID, client, client.getSafeUserId(), {
|
|
pendingEventOrdering: PendingEventOrdering.Detached,
|
|
timelineSupport: true,
|
|
});
|
|
|
|
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
|
jest.spyOn(client, "decryptEventIfNeeded").mockResolvedValue();
|
|
|
|
mxEvent = mkMessage({
|
|
room: room.roomId,
|
|
user: "@alice:example.org",
|
|
msg: "Hello world!",
|
|
event: true,
|
|
});
|
|
});
|
|
|
|
describe("EventTile thread summary", () => {
|
|
beforeEach(() => {
|
|
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
|
|
});
|
|
|
|
it("removes the thread summary when thread is deleted", async () => {
|
|
const {
|
|
rootEvent,
|
|
events: [, reply],
|
|
} = mkThread({
|
|
room,
|
|
client,
|
|
authorId: "@alice:example.org",
|
|
participantUserIds: ["@alice:example.org"],
|
|
length: 2, // root + 1 answer
|
|
});
|
|
getComponent(
|
|
{
|
|
mxEvent: rootEvent,
|
|
},
|
|
TimelineRenderingType.Room,
|
|
);
|
|
|
|
await waitFor(() => expect(screen.queryByTestId("thread-summary")).not.toBeNull());
|
|
|
|
const redaction = mkEvent({
|
|
event: true,
|
|
type: EventType.RoomRedaction,
|
|
user: "@alice:example.org",
|
|
room: room.roomId,
|
|
redacts: reply.getId(),
|
|
content: {},
|
|
});
|
|
|
|
act(() => room.processThreadedEvents([redaction], false));
|
|
|
|
await waitFor(() => expect(screen.queryByTestId("thread-summary")).toBeNull());
|
|
});
|
|
});
|
|
|
|
describe("EventTile renderingType: ThreadsList", () => {
|
|
it("shows an unread notification badge", () => {
|
|
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
|
|
|
|
// By default, the thread will assume it is read.
|
|
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0);
|
|
|
|
act(() => {
|
|
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);
|
|
});
|
|
|
|
expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
|
|
expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("EventTile in the right panel", () => {
|
|
beforeAll(() => {
|
|
const dmRoomMap: DMRoomMap = {
|
|
getUserIdForRoomId: jest.fn(),
|
|
} as unknown as DMRoomMap;
|
|
DMRoomMap.setShared(dmRoomMap);
|
|
});
|
|
|
|
it("renders the room name for notifications", () => {
|
|
const { container } = getComponent({}, TimelineRenderingType.Notification);
|
|
expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent(
|
|
"@alice:example.org in !roomId:example.org",
|
|
);
|
|
});
|
|
|
|
it("renders the sender for the thread list", () => {
|
|
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
|
|
expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent("@alice:example.org");
|
|
});
|
|
|
|
it.each([
|
|
[TimelineRenderingType.Notification, Action.ViewRoom],
|
|
[TimelineRenderingType.ThreadsList, Action.ShowThread],
|
|
])("type %s dispatches %s", (renderingType, action) => {
|
|
jest.spyOn(dis, "dispatch");
|
|
|
|
const { container } = getComponent({}, renderingType);
|
|
|
|
fireEvent.click(container.querySelector("li")!);
|
|
|
|
expect(dis.dispatch).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
describe("Event verification", () => {
|
|
// data for our stubbed getEncryptionInfoForEvent: a map from event id to result
|
|
const eventToEncryptionInfoMap = new Map<string, EventEncryptionInfo>();
|
|
|
|
beforeEach(() => {
|
|
eventToEncryptionInfoMap.clear();
|
|
|
|
const mockCrypto = {
|
|
// a mocked version of getEncryptionInfoForEvent which will pick its result from `eventToEncryptionInfoMap`
|
|
getEncryptionInfoForEvent: async (event: MatrixEvent) => eventToEncryptionInfoMap.get(event.getId()!)!,
|
|
} as unknown as CryptoApi;
|
|
client.getCrypto = () => mockCrypto;
|
|
});
|
|
|
|
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()!, {
|
|
shieldColour: EventShieldColour.RED,
|
|
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
|
} as EventEncryptionInfo);
|
|
|
|
const { container } = getComponent();
|
|
await act(flushPromises);
|
|
|
|
const eventTiles = container.getElementsByClassName("mx_EventTile");
|
|
expect(eventTiles).toHaveLength(1);
|
|
|
|
// 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()!, {
|
|
shieldColour: EventShieldColour.NONE,
|
|
shieldReason: null,
|
|
} as EventEncryptionInfo);
|
|
|
|
const { container } = getComponent();
|
|
await act(flushPromises);
|
|
|
|
const eventTiles = container.getElementsByClassName("mx_EventTile");
|
|
expect(eventTiles).toHaveLength(1);
|
|
|
|
// there should be no warning
|
|
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
|
});
|
|
|
|
it.each([
|
|
[EventShieldReason.UNKNOWN, "Unknown error"],
|
|
[EventShieldReason.UNVERIFIED_IDENTITY, "unverified user"],
|
|
[EventShieldReason.UNSIGNED_DEVICE, "device not verified by its owner"],
|
|
[EventShieldReason.UNKNOWN_DEVICE, "unknown or deleted device"],
|
|
[EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, "can't be guaranteed"],
|
|
[EventShieldReason.MISMATCHED_SENDER_KEY, "Encrypted by an unverified session"],
|
|
])("shows the correct reason code for %i (%s)", async (reasonCode: EventShieldReason, expectedText: string) => {
|
|
mxEvent = await mkEncryptedEvent({
|
|
plainContent: { msgtype: "m.text", body: "msg1" },
|
|
plainType: "m.room.message",
|
|
user: "@alice:example.org",
|
|
room: room.roomId,
|
|
});
|
|
eventToEncryptionInfoMap.set(mxEvent.getId()!, {
|
|
shieldColour: EventShieldColour.GREY,
|
|
shieldReason: reasonCode,
|
|
} as EventEncryptionInfo);
|
|
|
|
const { container } = getComponent();
|
|
await act(flushPromises);
|
|
|
|
const e2eIcons = container.getElementsByClassName("mx_EventTile_e2eIcon");
|
|
expect(e2eIcons).toHaveLength(1);
|
|
expect(e2eIcons[0].classList).toContain("mx_EventTile_e2eIcon_normal");
|
|
expect(e2eIcons[0].getAttribute("aria-label")).toContain(expectedText);
|
|
});
|
|
|
|
describe("undecryptable event", () => {
|
|
filterConsole("Error decrypting event");
|
|
|
|
it("shows an undecryptable warning", async () => {
|
|
mxEvent = mkEvent({
|
|
type: "m.room.encrypted",
|
|
room: room.roomId,
|
|
user: "@alice:example.org",
|
|
event: true,
|
|
content: {},
|
|
});
|
|
|
|
const mockCrypto = {
|
|
decryptEvent: async (_ev): Promise<IEventDecryptionResult> => {
|
|
throw new Error("can't decrypt");
|
|
},
|
|
} as CryptoBackend;
|
|
|
|
await mxEvent.attemptDecryption(mockCrypto);
|
|
|
|
const { container } = getComponent();
|
|
await act(flushPromises);
|
|
|
|
const eventTiles = container.getElementsByClassName("mx_EventTile");
|
|
expect(eventTiles).toHaveLength(1);
|
|
|
|
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
|
|
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain(
|
|
"mx_EventTile_e2eIcon_decryption_failure",
|
|
);
|
|
});
|
|
});
|
|
|
|
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()!, {
|
|
shieldColour: EventShieldColour.NONE,
|
|
shieldReason: null,
|
|
} as EventEncryptionInfo);
|
|
|
|
const roomContext = getRoomContext(room, {});
|
|
const { container, rerender } = render(<WrappedEventTile roomContext={roomContext} />);
|
|
|
|
await act(flushPromises);
|
|
|
|
const eventTiles = container.getElementsByClassName("mx_EventTile");
|
|
expect(eventTiles).toHaveLength(1);
|
|
|
|
// 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()!, {
|
|
shieldColour: EventShieldColour.RED,
|
|
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
|
} as EventEncryptionInfo);
|
|
|
|
await act(async () => {
|
|
mxEvent.makeReplaced(replacementEvent);
|
|
rerender(<WrappedEventTile roomContext={roomContext} />);
|
|
await flushPromises;
|
|
});
|
|
|
|
// check it was updated
|
|
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()!, {
|
|
shieldColour: EventShieldColour.NONE,
|
|
shieldReason: null,
|
|
} as EventEncryptionInfo);
|
|
|
|
const roomContext = getRoomContext(room, {});
|
|
const { container, rerender } = render(<WrappedEventTile roomContext={roomContext} />);
|
|
await act(flushPromises);
|
|
|
|
const eventTiles = container.getElementsByClassName("mx_EventTile");
|
|
expect(eventTiles).toHaveLength(1);
|
|
|
|
// 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,
|
|
});
|
|
|
|
await act(async () => {
|
|
mxEvent.makeReplaced(replacementEvent);
|
|
rerender(<WrappedEventTile roomContext={roomContext} />);
|
|
await flushPromises;
|
|
});
|
|
|
|
// check it was updated
|
|
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
|
|
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain(
|
|
"mx_EventTile_e2eIcon_warning",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("event highlighting", () => {
|
|
const isHighlighted = (container: HTMLElement): boolean =>
|
|
!!container.getElementsByClassName("mx_EventTile_highlight").length;
|
|
|
|
beforeEach(() => {
|
|
mocked(client.getPushActionsForEvent).mockReturnValue(null);
|
|
});
|
|
|
|
it("does not highlight message where message matches no push actions", () => {
|
|
const { container } = getComponent();
|
|
|
|
expect(client.getPushActionsForEvent).toHaveBeenCalledWith(mxEvent);
|
|
expect(isHighlighted(container)).toBeFalsy();
|
|
});
|
|
|
|
it(`does not highlight when message's push actions does not have a highlight tweak`, () => {
|
|
mocked(client.getPushActionsForEvent).mockReturnValue({ notify: true, tweaks: {} });
|
|
const { container } = getComponent();
|
|
|
|
expect(isHighlighted(container)).toBeFalsy();
|
|
});
|
|
|
|
it(`highlights when message's push actions have a highlight tweak`, () => {
|
|
mocked(client.getPushActionsForEvent).mockReturnValue({
|
|
notify: true,
|
|
tweaks: { [TweakName.Highlight]: true },
|
|
});
|
|
const { container } = getComponent();
|
|
|
|
expect(isHighlighted(container)).toBeTruthy();
|
|
});
|
|
|
|
describe("when a message has been edited", () => {
|
|
let editingEvent: MatrixEvent;
|
|
|
|
beforeEach(() => {
|
|
editingEvent = new MatrixEvent({
|
|
type: "m.room.message",
|
|
room_id: ROOM_ID,
|
|
sender: "@alice:example.org",
|
|
content: {
|
|
"msgtype": "m.text",
|
|
"body": "* edited body",
|
|
"m.new_content": {
|
|
msgtype: "m.text",
|
|
body: "edited body",
|
|
},
|
|
"m.relates_to": {
|
|
rel_type: "m.replace",
|
|
event_id: mxEvent.getId(),
|
|
},
|
|
},
|
|
});
|
|
mxEvent.makeReplaced(editingEvent);
|
|
});
|
|
|
|
it("does not highlight message where no version of message matches any push actions", () => {
|
|
const { container } = getComponent();
|
|
|
|
// get push actions for both events
|
|
expect(client.getPushActionsForEvent).toHaveBeenCalledWith(mxEvent);
|
|
expect(client.getPushActionsForEvent).toHaveBeenCalledWith(editingEvent);
|
|
expect(isHighlighted(container)).toBeFalsy();
|
|
});
|
|
|
|
it(`does not highlight when no version of message's push actions have a highlight tweak`, () => {
|
|
mocked(client.getPushActionsForEvent).mockReturnValue({ notify: true, tweaks: {} });
|
|
const { container } = getComponent();
|
|
|
|
expect(isHighlighted(container)).toBeFalsy();
|
|
});
|
|
|
|
it(`highlights when previous version of message's push actions have a highlight tweak`, () => {
|
|
mocked(client.getPushActionsForEvent).mockImplementation((event: MatrixEvent) => {
|
|
if (event === mxEvent) {
|
|
return { notify: true, tweaks: { [TweakName.Highlight]: true } };
|
|
}
|
|
return { notify: false, tweaks: {} };
|
|
});
|
|
const { container } = getComponent();
|
|
|
|
expect(isHighlighted(container)).toBeTruthy();
|
|
});
|
|
|
|
it(`highlights when new version of message's push actions have a highlight tweak`, () => {
|
|
mocked(client.getPushActionsForEvent).mockImplementation((event: MatrixEvent) => {
|
|
if (event === editingEvent) {
|
|
return { notify: true, tweaks: { [TweakName.Highlight]: true } };
|
|
}
|
|
return { notify: false, tweaks: {} };
|
|
});
|
|
const { container } = getComponent();
|
|
|
|
expect(isHighlighted(container)).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
});
|