riot-web/test/components/structures/MessagePanel-test.tsx

727 lines
26 KiB
TypeScript

/*
Copyright 2016 OpenMarket Ltd
Copyright 2019, 2021, 2022 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 React from "react";
import ReactDOM from "react-dom";
import { EventEmitter } from "events";
import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
import FakeTimers from "@sinonjs/fake-timers";
import { render } from "@testing-library/react";
import { Thread } from "matrix-js-sdk/src/models/thread";
import MessagePanel, { shouldFormContinuation } from "../../../src/components/structures/MessagePanel";
import SettingsStore from "../../../src/settings/SettingsStore";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext, { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import * as TestUtilsMatrix from "../../test-utils";
import {
getMockClientWithEventEmitter,
makeBeaconInfoEvent,
mockClientMethodsEvents,
mockClientMethodsUser,
} from "../../test-utils";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { IRoomState } from "../../../src/components/structures/RoomView";
jest.mock("../../../src/utils/beacon", () => ({
useBeacon: jest.fn(),
}));
const roomId = "!roomId:server_name";
describe("MessagePanel", function () {
let clock = null;
const realSetTimeout = window.setTimeout;
const events = mkEvents();
const userId = "@me:here";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsEvents(),
getAccountData: jest.fn(),
isUserIgnored: jest.fn().mockReturnValue(false),
isRoomEncrypted: jest.fn().mockReturnValue(false),
getRoom: jest.fn(),
getClientWellKnown: jest.fn().mockReturnValue({}),
});
const room = new Room(roomId, client, userId);
const bobMember = new RoomMember(roomId, "@bob:id");
bobMember.name = "Bob";
jest.spyOn(bobMember, "getAvatarUrl").mockReturnValue("avatar.jpeg");
jest.spyOn(bobMember, "getMxcAvatarUrl").mockReturnValue("mxc://avatar.url/image.png");
const alice = "@alice:example.org";
const aliceMember = new RoomMember(roomId, alice);
aliceMember.name = "Alice";
jest.spyOn(aliceMember, "getAvatarUrl").mockReturnValue("avatar.jpeg");
jest.spyOn(aliceMember, "getMxcAvatarUrl").mockReturnValue("mxc://avatar.url/image.png");
const defaultProps = {
resizeNotifier: new EventEmitter() as unknown as ResizeNotifier,
callEventGroupers: new Map(),
room,
className: "cls",
events: [],
};
const defaultRoomContext = {
...RoomContext,
timelineRenderingType: TimelineRenderingType.Room,
room,
roomId: room.roomId,
canReact: true,
canSendMessages: true,
showReadReceipts: true,
showRedactions: false,
showJoinLeaves: false,
showAvatarChanges: false,
showDisplaynameChanges: true,
showHiddenEvents: false,
} as unknown as IRoomState;
const getComponent = (props = {}, roomContext: Partial<IRoomState> = {}) => (
<MatrixClientContext.Provider value={client}>
<RoomContext.Provider value={{ ...defaultRoomContext, ...roomContext }}>
<MessagePanel {...defaultProps} {...props} />
</RoomContext.Provider>
);
</MatrixClientContext.Provider>
);
beforeEach(function () {
jest.clearAllMocks();
// HACK: We assume all settings want to be disabled
jest.spyOn(SettingsStore, "getValue").mockImplementation((arg) => {
return arg === "showDisplaynameChanges";
});
DMRoomMap.makeShared();
});
afterEach(function () {
if (clock) {
clock.uninstall();
clock = null;
}
});
function mkEvents() {
const events = [];
const ts0 = Date.now();
for (let i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + i * 1000,
}),
);
}
return events;
}
// Just to avoid breaking Dateseparator tests that might run at 00hrs
function mkOneDayEvents() {
const events = [];
const ts0 = Date.parse("09 May 2004 00:12:00 GMT");
for (let i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + i * 1000,
}),
);
}
return events;
}
// make a collection of events with some member events that should be collapsed with an EventListSummary
function mkMelsEvents() {
const events = [];
const ts0 = Date.now();
let i = 0;
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + ++i * 1000,
}),
);
for (i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: ts0 + i * 1000,
mship: "join",
prevMship: "join",
name: "A user",
}),
);
}
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + ++i * 1000,
}),
);
return events;
}
// A list of membership events only with nothing else
function mkMelsEventsOnly() {
const events = [];
const ts0 = Date.now();
let i = 0;
for (i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: ts0 + i * 1000,
mship: "join",
prevMship: "join",
name: "A user",
}),
);
}
return events;
}
// A list of room creation, encryption, and invite events.
function mkCreationEvents() {
const mkEvent = TestUtilsMatrix.mkEvent;
const mkMembership = TestUtilsMatrix.mkMembership;
const roomId = "!someroom";
const ts0 = Date.now();
return [
mkEvent({
event: true,
type: "m.room.create",
room: roomId,
user: alice,
content: {
creator: alice,
room_version: "5",
predecessor: {
room_id: "!prevroom",
event_id: "$someevent",
},
},
ts: ts0,
}),
mkMembership({
event: true,
room: roomId,
user: alice,
target: aliceMember,
ts: ts0 + 1,
mship: "join",
name: "Alice",
}),
mkEvent({
event: true,
type: "m.room.join_rules",
room: roomId,
user: alice,
content: {
join_rule: "invite",
},
ts: ts0 + 2,
}),
mkEvent({
event: true,
type: "m.room.history_visibility",
room: roomId,
user: alice,
content: {
history_visibility: "invited",
},
ts: ts0 + 3,
}),
mkEvent({
event: true,
type: "m.room.encryption",
room: roomId,
user: alice,
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
ts: ts0 + 4,
}),
mkMembership({
event: true,
room: roomId,
user: alice,
skey: "@bob:example.org",
target: bobMember,
ts: ts0 + 5,
mship: "invite",
name: "Bob",
}),
];
}
function mkMixedHiddenAndShownEvents() {
const roomId = "!room:id";
const userId = "@alice:example.org";
const ts0 = Date.now();
return [
TestUtilsMatrix.mkMessage({
event: true,
room: roomId,
user: userId,
ts: ts0,
}),
TestUtilsMatrix.mkEvent({
event: true,
type: "org.example.a_hidden_event",
room: roomId,
user: userId,
content: {},
ts: ts0 + 1,
}),
];
}
function isReadMarkerVisible(rmContainer) {
return rmContainer && rmContainer.children.length > 0;
}
it("should show the events", function () {
const { container } = render(getComponent({ events }));
// just check we have the right number of tiles for now
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(10);
});
it("should collapse adjacent member events", function () {
const { container } = render(getComponent({ events: mkMelsEvents() }));
// just check we have the right number of tiles for now
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(2);
const summaryTiles = container.getElementsByClassName("mx_GenericEventListSummary");
expect(summaryTiles.length).toEqual(1);
});
it("should insert the read-marker in the right place", function () {
const { container } = render(
getComponent({
events,
readMarkerEventId: events[4].getId(),
readMarkerVisible: true,
}),
);
const tiles = container.getElementsByClassName("mx_EventTile");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_RoomView_myReadMarker_container");
// it should follow the <li> which wraps the event tile for event 4
const eventContainer = ReactDOM.findDOMNode(tiles[4]);
expect(rm.previousSibling).toEqual(eventContainer);
});
it("should show the read-marker that fall in summarised events after the summary", function () {
const melsEvents = mkMelsEvents();
const { container } = render(
getComponent({
events: melsEvents,
readMarkerEventId: melsEvents[4].getId(),
readMarkerVisible: true,
}),
);
const [summary] = container.getElementsByClassName("mx_GenericEventListSummary");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_RoomView_myReadMarker_container");
expect(rm.previousSibling).toEqual(summary);
// read marker should be visible given props and not at the last event
expect(isReadMarkerVisible(rm)).toBeTruthy();
});
it("should hide the read-marker at the end of summarised events", function () {
const melsEvents = mkMelsEventsOnly();
const { container } = render(
getComponent({
events: melsEvents,
readMarkerEventId: melsEvents[9].getId(),
readMarkerVisible: true,
}),
);
const [summary] = container.getElementsByClassName("mx_GenericEventListSummary");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_RoomView_myReadMarker_container");
expect(rm.previousSibling).toEqual(summary);
// read marker should be hidden given props and at the last event
expect(isReadMarkerVisible(rm)).toBeFalsy();
});
it("shows a ghost read-marker when the read-marker moves", function (done) {
// fake the clock so that we can test the velocity animation.
clock = FakeTimers.install();
const { container, rerender } = render(
<div>
{getComponent({
events,
readMarkerEventId: events[4].getId(),
readMarkerVisible: true,
})}
</div>,
);
const tiles = container.getElementsByClassName("mx_EventTile");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_RoomView_myReadMarker_container");
expect(rm.previousSibling).toEqual(tiles[4]);
rerender(
<div>
{getComponent({
events,
readMarkerEventId: events[6].getId(),
readMarkerVisible: true,
})}
</div>,
);
// now there should be two RM containers
const readMarkers = container.getElementsByClassName("mx_RoomView_myReadMarker_container");
expect(readMarkers.length).toEqual(2);
// the first should be the ghost
expect(readMarkers[0].previousSibling).toEqual(tiles[4]);
const hr: HTMLElement = readMarkers[0].children[0] as HTMLElement;
// the second should be the real thing
expect(readMarkers[1].previousSibling).toEqual(tiles[6]);
// advance the clock, and then let the browser run an animation frame,
// to let the animation start
clock.tick(1500);
realSetTimeout(() => {
// then advance it again to let it complete
clock.tick(1000);
realSetTimeout(() => {
// the ghost should now have finished
expect(hr.style.opacity).toEqual("0");
done();
}, 100);
}, 100);
});
it("should collapse creation events", function () {
const events = mkCreationEvents();
TestUtilsMatrix.upsertRoomStateEvents(room, events);
const { container } = render(getComponent({ events }));
const createEvent = events.find((event) => event.getType() === "m.room.create");
const encryptionEvent = events.find((event) => event.getType() === "m.room.encryption");
// we expect that
// - the room creation event, the room encryption event, and Alice inviting Bob,
// should be outside of the room creation summary
// - all other events should be inside the room creation summary
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles[0].getAttribute("data-event-id")).toEqual(createEvent.getId());
expect(tiles[1].getAttribute("data-event-id")).toEqual(encryptionEvent.getId());
const [summaryTile] = container.getElementsByClassName("mx_GenericEventListSummary");
const summaryEventTiles = summaryTile.getElementsByClassName("mx_EventTile");
// every event except for the room creation, room encryption, and Bob's
// invite event should be in the event summary
expect(summaryEventTiles.length).toEqual(tiles.length - 3);
});
it("should not collapse beacons as part of creation events", function () {
const events = mkCreationEvents();
const creationEvent = events.find((event) => event.getType() === "m.room.create");
const beaconInfoEvent = makeBeaconInfoEvent(creationEvent.getSender(), creationEvent.getRoomId(), {
isLive: true,
});
const combinedEvents = [...events, beaconInfoEvent];
TestUtilsMatrix.upsertRoomStateEvents(room, combinedEvents);
const { container } = render(getComponent({ events: combinedEvents }));
const [summaryTile] = container.getElementsByClassName("mx_GenericEventListSummary");
// beacon body is not in the summary
expect(summaryTile.getElementsByClassName("mx_MBeaconBody").length).toBe(0);
// beacon tile is rendered
expect(container.getElementsByClassName("mx_MBeaconBody").length).toBe(1);
});
it("should hide read-marker at the end of creation event summary", function () {
const events = mkCreationEvents();
TestUtilsMatrix.upsertRoomStateEvents(room, events);
const { container } = render(
getComponent({
events,
readMarkerEventId: events[5].getId(),
readMarkerVisible: true,
}),
);
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_RoomView_myReadMarker_container");
const [messageList] = container.getElementsByClassName("mx_RoomView_MessageList");
const rows = messageList.children;
expect(rows.length).toEqual(7); // 6 events + the NewRoomIntro
expect(rm.previousSibling).toEqual(rows[5]);
// read marker should be hidden given props and at the last event
expect(isReadMarkerVisible(rm)).toBeFalsy();
});
it("should render Date separators for the events", function () {
const events = mkOneDayEvents();
const { queryAllByRole } = render(getComponent({ events }));
const dates = queryAllByRole("separator");
expect(dates.length).toEqual(1);
});
it("appends events into summaries during forward pagination without changing key", () => {
const events = mkMelsEvents().slice(1, 11);
const { container, rerender } = render(getComponent({ events }));
let els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens").split(",").length).toEqual(10);
const updatedEvents = [
...events,
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: Date.now(),
mship: "join",
prevMship: "join",
name: "A user",
}),
];
rerender(getComponent({ events: updatedEvents }));
els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens").split(",").length).toEqual(11);
});
it("prepends events into summaries during backward pagination without changing key", () => {
const events = mkMelsEvents().slice(1, 11);
const { container, rerender } = render(getComponent({ events }));
let els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens").split(",").length).toEqual(10);
const updatedEvents = [
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: Date.now(),
mship: "join",
prevMship: "join",
name: "A user",
}),
...events,
];
rerender(getComponent({ events: updatedEvents }));
els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens").split(",").length).toEqual(11);
});
it("assigns different keys to summaries that get split up", () => {
const events = mkMelsEvents().slice(1, 11);
const { container, rerender } = render(getComponent({ events }));
let els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual(`eventlistsummary-${events[0].getId()}`);
expect(els[0].getAttribute("data-scroll-tokens").split(",").length).toEqual(10);
const updatedEvents = [
...events.slice(0, 5),
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "Hello!",
}),
...events.slice(5, 10),
];
rerender(getComponent({ events: updatedEvents }));
// summaries split becuase room messages are not summarised
els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(2);
expect(els[0].getAttribute("data-testid")).toEqual(`eventlistsummary-${events[0].getId()}`);
expect(els[0].getAttribute("data-scroll-tokens").split(",").length).toEqual(5);
expect(els[1].getAttribute("data-testid")).toEqual(`eventlistsummary-${events[5].getId()}`);
expect(els[1].getAttribute("data-scroll-tokens").split(",").length).toEqual(5);
});
// We test this because setting lookups can be *slow*, and we don't want
// them to happen in this code path
it("doesn't lookup showHiddenEventsInTimeline while rendering", () => {
// We're only interested in the setting lookups that happen on every render,
// rather than those happening on first mount, so let's get those out of the way
const { rerender } = render(getComponent({ events: [] }));
// Set up our spy and re-render with new events
const settingsSpy = jest.spyOn(SettingsStore, "getValue").mockClear();
rerender(getComponent({ events: mkMixedHiddenAndShownEvents() }));
expect(settingsSpy).not.toHaveBeenCalledWith("showHiddenEventsInTimeline");
settingsSpy.mockRestore();
});
it("should group hidden event reactions into an event list summary", () => {
const events = [
TestUtilsMatrix.mkEvent({
event: true,
type: "m.reaction",
room: "!room:id",
user: "@user:id",
content: {},
ts: 1,
}),
TestUtilsMatrix.mkEvent({
event: true,
type: "m.reaction",
room: "!room:id",
user: "@user:id",
content: {},
ts: 2,
}),
TestUtilsMatrix.mkEvent({
event: true,
type: "m.reaction",
room: "!room:id",
user: "@user:id",
content: {},
ts: 3,
}),
];
const { container } = render(getComponent({ events }, { showHiddenEvents: true }));
const els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-scroll-tokens").split(",").length).toEqual(3);
});
});
describe("shouldFormContinuation", () => {
it("does not form continuations from thread roots which have summaries", () => {
const message1 = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "Here is a message in the main timeline",
});
const message2 = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "And here's another message in the main timeline",
});
const threadRoot = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "Here is a thread",
});
jest.spyOn(threadRoot, "isThreadRoot", "get").mockReturnValue(true);
const message3 = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "And here's another message in the main timeline after the thread root",
});
expect(shouldFormContinuation(message1, message2, false, true)).toEqual(true);
expect(shouldFormContinuation(message2, threadRoot, false, true)).toEqual(true);
expect(shouldFormContinuation(threadRoot, message3, false, true)).toEqual(true);
const thread = {
length: 1,
replyToEvent: {},
} as unknown as Thread;
jest.spyOn(threadRoot, "getThread").mockReturnValue(thread);
expect(shouldFormContinuation(message2, threadRoot, false, true)).toEqual(false);
expect(shouldFormContinuation(threadRoot, message3, false, true)).toEqual(false);
});
});