element-web-Github/test/components/views/context_menus/MessageContextMenu-test.tsx
Florian Duros 3d80eff65b
Add Pin/Unpin action in quick access of the message action bar (#12897)
* Add Pin/Unpin action in quick access of the message action bar

* Add tests for `MessageActionBar`

* Add tests for `PinningUtils`

* Fix `MessageContextMenu-test`

* Add e2e test to pin/unpin from message action bar
2024-08-21 08:50:00 +00:00

578 lines
24 KiB
TypeScript

/*
Copyright 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 { fireEvent, render, RenderResult, screen, waitFor } from "@testing-library/react";
import {
EventStatus,
MatrixEvent,
Room,
PendingEventOrdering,
BeaconIdentifier,
Beacon,
getBeaconInfoIdentifier,
EventType,
FeatureSupport,
Thread,
M_POLL_KIND_DISCLOSED,
EventTimeline,
} from "matrix-js-sdk/src/matrix";
import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
import { IRoomState } from "../../../../src/components/structures/RoomView";
import { canEditContent } from "../../../../src/utils/EventUtils";
import { copyPlaintext, getSelectedText } from "../../../../src/utils/strings";
import MessageContextMenu from "../../../../src/components/views/context_menus/MessageContextMenu";
import { makeBeaconEvent, makeBeaconInfoEvent, makeLocationEvent, stubClient } from "../../../test-utils";
import dispatcher from "../../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { ReadPinsEventId } from "../../../../src/components/views/right_panel/types";
import { Action } from "../../../../src/dispatcher/actions";
import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils";
import { VoiceBroadcastInfoState } from "../../../../src/voice-broadcast";
import { createMessageEventContent } from "../../../test-utils/events";
jest.mock("../../../../src/utils/strings", () => ({
copyPlaintext: jest.fn(),
getSelectedText: jest.fn(),
}));
jest.mock("../../../../src/utils/EventUtils", () => ({
...(jest.requireActual("../../../../src/utils/EventUtils") as object),
canEditContent: jest.fn(),
}));
jest.mock("../../../../src/dispatcher/dispatcher");
const roomId = "roomid";
describe("MessageContextMenu", () => {
beforeEach(() => {
jest.resetAllMocks();
stubClient();
});
it("does show copy link button when supplied a link", () => {
const eventContent = createMessageEventContent("hello");
const props = {
link: "https://google.com/",
};
createMenuWithContent(eventContent, props);
const copyLinkButton = document.querySelector('a[aria-label="Copy link"]');
expect(copyLinkButton).toHaveAttribute("href", props.link);
});
it("does not show copy link button when not supplied a link", () => {
const eventContent = createMessageEventContent("hello");
createMenuWithContent(eventContent);
const copyLinkButton = document.querySelector('a[aria-label="Copy link"]');
expect(copyLinkButton).toBeFalsy();
});
describe("message pinning", () => {
let room: Room;
beforeEach(() => {
room = makeDefaultRoom();
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"mayClientSendStateEvent",
).mockReturnValue(true);
});
afterAll(() => {
jest.spyOn(SettingsStore, "getValue").mockRestore();
});
it("does not show pin option when user does not have rights to pin", () => {
const eventContent = createMessageEventContent("hello");
const event = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
// mock permission to disallow adding pinned messages to room
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"mayClientSendStateEvent",
).mockReturnValue(false);
createMenu(event, { rightClick: true }, {}, undefined, room);
expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy();
});
it("does not show pin option for beacon_info event", () => {
const deadBeaconEvent = makeBeaconInfoEvent("@alice:server.org", roomId, { isLive: false });
createMenu(deadBeaconEvent, { rightClick: true }, {}, undefined, room);
expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy();
});
it("does not show pin option when pinning feature is disabled", () => {
const eventContent = createMessageEventContent("hello");
const pinnableEvent = new MatrixEvent({
type: EventType.RoomMessage,
content: eventContent,
room_id: roomId,
});
// disable pinning feature
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room);
expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy();
});
it("shows pin option when pinning feature is enabled", () => {
const eventContent = createMessageEventContent("hello");
const pinnableEvent = new MatrixEvent({
type: EventType.RoomMessage,
content: eventContent,
room_id: roomId,
});
createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room);
expect(screen.getByRole("menuitem", { name: "Pin" })).toBeTruthy();
});
it("pins event on pin option click", async () => {
const onFinished = jest.fn();
const eventContent = createMessageEventContent("hello");
const pinnableEvent = new MatrixEvent({
type: EventType.RoomMessage,
content: eventContent,
room_id: roomId,
});
pinnableEvent.event.event_id = "!3";
const client = MatrixClientPeg.safeGet();
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockReturnValue({
// @ts-ignore
getContent: () => ({ pinned: ["!1", "!2"] }),
});
// mock read pins account data
const pinsAccountData = new MatrixEvent({ content: { event_ids: ["!1", "!2"] } });
jest.spyOn(room, "getAccountData").mockReturnValue(pinsAccountData);
createMenu(pinnableEvent, { onFinished, rightClick: true }, {}, undefined, room);
await userEvent.click(screen.getByRole("menuitem", { name: "Pin" }));
// added to account data
await waitFor(() =>
expect(client.setRoomAccountData).toHaveBeenCalledWith(roomId, ReadPinsEventId, {
event_ids: [
// from account data
"!1",
"!2",
pinnableEvent.getId(),
],
}),
);
// add to room's pins
await waitFor(() =>
expect(client.sendStateEvent).toHaveBeenCalledWith(
roomId,
EventType.RoomPinnedEvents,
{
pinned: ["!1", "!2", pinnableEvent.getId()],
},
"",
),
);
expect(onFinished).toHaveBeenCalled();
});
it("unpins event on pin option click when event is pinned", async () => {
const eventContent = createMessageEventContent("hello");
const pinnableEvent = new MatrixEvent({
type: EventType.RoomMessage,
content: eventContent,
room_id: roomId,
});
pinnableEvent.event.event_id = "!3";
const client = MatrixClientPeg.safeGet();
// make the event already pinned in the room
const pinEvent = new MatrixEvent({
type: EventType.RoomPinnedEvents,
room_id: roomId,
state_key: "",
content: { pinned: [pinnableEvent.getId(), "!another-event"] },
});
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!.setStateEvents([pinEvent]);
// mock read pins account data
const pinsAccountData = new MatrixEvent({ content: { event_ids: ["!1", "!2"] } });
jest.spyOn(room, "getAccountData").mockReturnValue(pinsAccountData);
createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room);
await userEvent.click(screen.getByRole("menuitem", { name: "Unpin" }));
expect(client.setRoomAccountData).not.toHaveBeenCalled();
// add to room's pins
expect(client.sendStateEvent).toHaveBeenCalledWith(
roomId,
EventType.RoomPinnedEvents,
// pinnableEvent's id removed, other pins intact
{ pinned: ["!another-event"] },
"",
);
});
});
describe("message forwarding", () => {
it("allows forwarding a room message", () => {
const eventContent = createMessageEventContent("hello");
createMenuWithContent(eventContent);
expect(document.querySelector('li[aria-label="Forward"]')).toBeTruthy();
});
it("does not allow forwarding a poll", () => {
const eventContent = PollStartEvent.from("why?", ["42"], M_POLL_KIND_DISCLOSED);
createMenuWithContent(eventContent);
expect(document.querySelector('li[aria-label="Forward"]')).toBeFalsy();
});
it("should not allow forwarding a voice broadcast", () => {
const broadcastStartEvent = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Started,
"@user:example.com",
"ABC123",
);
createMenu(broadcastStartEvent);
expect(document.querySelector('li[aria-label="Forward"]')).toBeFalsy();
});
describe("forwarding beacons", () => {
const aliceId = "@alice:server.org";
it("does not allow forwarding a beacon that is not live", () => {
const deadBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: false });
const beacon = new Beacon(deadBeaconEvent);
const beacons = new Map<BeaconIdentifier, Beacon>();
beacons.set(getBeaconInfoIdentifier(deadBeaconEvent), beacon);
createMenu(deadBeaconEvent, {}, {}, beacons);
expect(document.querySelector('li[aria-label="Forward"]')).toBeFalsy();
});
it("does not allow forwarding a beacon that is not live but has a latestLocation", () => {
const deadBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: false });
const beaconLocation = makeBeaconEvent(aliceId, {
beaconInfoId: deadBeaconEvent.getId(),
geoUri: "geo:51,41",
});
const beacon = new Beacon(deadBeaconEvent);
// @ts-ignore illegally set private prop
beacon._latestLocationEvent = beaconLocation;
const beacons = new Map<BeaconIdentifier, Beacon>();
beacons.set(getBeaconInfoIdentifier(deadBeaconEvent), beacon);
createMenu(deadBeaconEvent, {}, {}, beacons);
expect(document.querySelector('li[aria-label="Forward"]')).toBeFalsy();
});
it("does not allow forwarding a live beacon that does not have a latestLocation", () => {
const beaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true });
const beacon = new Beacon(beaconEvent);
const beacons = new Map<BeaconIdentifier, Beacon>();
beacons.set(getBeaconInfoIdentifier(beaconEvent), beacon);
createMenu(beaconEvent, {}, {}, beacons);
expect(document.querySelector('li[aria-label="Forward"]')).toBeFalsy();
});
it("allows forwarding a live beacon that has a location", () => {
const liveBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true });
const beaconLocation = makeBeaconEvent(aliceId, {
beaconInfoId: liveBeaconEvent.getId(),
geoUri: "geo:51,41",
});
const beacon = new Beacon(liveBeaconEvent);
// @ts-ignore illegally set private prop
beacon._latestLocationEvent = beaconLocation;
const beacons = new Map<BeaconIdentifier, Beacon>();
beacons.set(getBeaconInfoIdentifier(liveBeaconEvent), beacon);
createMenu(liveBeaconEvent, {}, {}, beacons);
expect(document.querySelector('li[aria-label="Forward"]')).toBeTruthy();
});
it("opens forward dialog with correct event", () => {
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
const liveBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true });
const beaconLocation = makeBeaconEvent(aliceId, {
beaconInfoId: liveBeaconEvent.getId(),
geoUri: "geo:51,41",
});
const beacon = new Beacon(liveBeaconEvent);
// @ts-ignore illegally set private prop
beacon._latestLocationEvent = beaconLocation;
const beacons = new Map<BeaconIdentifier, Beacon>();
beacons.set(getBeaconInfoIdentifier(liveBeaconEvent), beacon);
createMenu(liveBeaconEvent, {}, {}, beacons);
fireEvent.click(document.querySelector('li[aria-label="Forward"]')!);
// called with forwardableEvent, not beaconInfo event
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
event: beaconLocation,
}),
);
});
});
});
describe("open as map link", () => {
it("does not allow opening a plain message in open street maps", () => {
const eventContent = createMessageEventContent("hello");
createMenuWithContent(eventContent);
expect(document.querySelector('a[aria-label="Open in OpenStreetMap"]')).toBeFalsy();
});
it("does not allow opening a beacon that does not have a shareable location event", () => {
const deadBeaconEvent = makeBeaconInfoEvent("@alice", roomId, { isLive: false });
const beacon = new Beacon(deadBeaconEvent);
const beacons = new Map<BeaconIdentifier, Beacon>();
beacons.set(getBeaconInfoIdentifier(deadBeaconEvent), beacon);
createMenu(deadBeaconEvent, {}, {}, beacons);
expect(document.querySelector('a[aria-label="Open in OpenStreetMap"]')).toBeFalsy();
});
it("allows opening a location event in open street map", () => {
const locationEvent = makeLocationEvent("geo:50,50");
createMenu(locationEvent);
// exists with a href with the lat/lon from the location event
expect(document.querySelector('a[aria-label="Open in OpenStreetMap"]')).toHaveAttribute(
"href",
"https://www.openstreetmap.org/?mlat=50&mlon=50#map=16/50/50",
);
});
it("allows opening a beacon that has a shareable location event", () => {
const liveBeaconEvent = makeBeaconInfoEvent("@alice", roomId, { isLive: true });
const beaconLocation = makeBeaconEvent("@alice", {
beaconInfoId: liveBeaconEvent.getId(),
geoUri: "geo:51,41",
});
const beacon = new Beacon(liveBeaconEvent);
// @ts-ignore illegally set private prop
beacon._latestLocationEvent = beaconLocation;
const beacons = new Map<BeaconIdentifier, Beacon>();
beacons.set(getBeaconInfoIdentifier(liveBeaconEvent), beacon);
createMenu(liveBeaconEvent, {}, {}, beacons);
// exists with a href with the lat/lon from the location event
expect(document.querySelector('a[aria-label="Open in OpenStreetMap"]')).toHaveAttribute(
"href",
"https://www.openstreetmap.org/?mlat=51&mlon=41#map=16/51/41",
);
});
});
describe("right click", () => {
it("copy button does work as expected", () => {
const text = "hello";
const eventContent = createMessageEventContent(text);
mocked(getSelectedText).mockReturnValue(text);
createRightClickMenuWithContent(eventContent);
const copyButton = document.querySelector('li[aria-label="Copy"]')!;
fireEvent.mouseDown(copyButton);
expect(copyPlaintext).toHaveBeenCalledWith(text);
});
it("copy button is not shown when there is nothing to copy", () => {
const text = "hello";
const eventContent = createMessageEventContent(text);
mocked(getSelectedText).mockReturnValue("");
createRightClickMenuWithContent(eventContent);
const copyButton = document.querySelector('li[aria-label="Copy"]');
expect(copyButton).toBeFalsy();
});
it("shows edit button when we can edit", () => {
const eventContent = createMessageEventContent("hello");
mocked(canEditContent).mockReturnValue(true);
createRightClickMenuWithContent(eventContent);
const editButton = document.querySelector('li[aria-label="Edit"]');
expect(editButton).toBeTruthy();
});
it("does not show edit button when we cannot edit", () => {
const eventContent = createMessageEventContent("hello");
mocked(canEditContent).mockReturnValue(false);
createRightClickMenuWithContent(eventContent);
const editButton = document.querySelector('li[aria-label="Edit"]');
expect(editButton).toBeFalsy();
});
it("shows reply button when we can reply", () => {
const eventContent = createMessageEventContent("hello");
const context = {
canSendMessages: true,
};
createRightClickMenuWithContent(eventContent, context);
const replyButton = document.querySelector('li[aria-label="Reply"]');
expect(replyButton).toBeTruthy();
});
it("does not show reply button when we cannot reply", () => {
const eventContent = createMessageEventContent("hello");
const context = {
canSendMessages: true,
};
const unsentMessage = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
// queued messages are not actionable
unsentMessage.setStatus(EventStatus.QUEUED);
createMenu(unsentMessage, {}, context);
const replyButton = document.querySelector('li[aria-label="Reply"]');
expect(replyButton).toBeFalsy();
});
it("shows react button when we can react", () => {
const eventContent = createMessageEventContent("hello");
const context = {
canReact: true,
};
createRightClickMenuWithContent(eventContent, context);
const reactButton = document.querySelector('li[aria-label="React"]');
expect(reactButton).toBeTruthy();
});
it("does not show react button when we cannot react", () => {
const eventContent = createMessageEventContent("hello");
const context = {
canReact: false,
};
createRightClickMenuWithContent(eventContent, context);
const reactButton = document.querySelector('li[aria-label="React"]');
expect(reactButton).toBeFalsy();
});
it("shows view in room button when the event is a thread root", () => {
const eventContent = createMessageEventContent("hello");
const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
mxEvent.getThread = () => ({ rootEvent: mxEvent }) as Thread;
const props = {
rightClick: true,
};
const context = {
timelineRenderingType: TimelineRenderingType.Thread,
};
createMenu(mxEvent, props, context);
const reactButton = document.querySelector('li[aria-label="View in room"]');
expect(reactButton).toBeTruthy();
});
it("does not show view in room button when the event is not a thread root", () => {
const eventContent = createMessageEventContent("hello");
createRightClickMenuWithContent(eventContent);
const reactButton = document.querySelector('li[aria-label="View in room"]');
expect(reactButton).toBeFalsy();
});
it("creates a new thread on reply in thread click", () => {
const eventContent = createMessageEventContent("hello");
const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
Thread.hasServerSideSupport = FeatureSupport.Stable;
const context = {
canSendMessages: true,
};
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
createRightClickMenu(mxEvent, context);
const replyInThreadButton = document.querySelector('li[aria-label="Reply in thread"]')!;
fireEvent.click(replyInThreadButton);
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ShowThread,
rootEvent: mxEvent,
push: false,
});
});
});
});
function createRightClickMenuWithContent(eventContent: object, context?: Partial<IRoomState>): RenderResult {
return createMenuWithContent(eventContent, { rightClick: true }, context);
}
function createRightClickMenu(mxEvent: MatrixEvent, context?: Partial<IRoomState>): RenderResult {
return createMenu(mxEvent, { rightClick: true }, context);
}
function createMenuWithContent(
eventContent: object,
props?: Partial<MessageContextMenu["props"]>,
context?: Partial<IRoomState>,
): RenderResult {
// XXX: We probably shouldn't be assuming all events are going to be message events, but considering this
// test is for the Message context menu, it's a fairly safe assumption.
const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
return createMenu(mxEvent, props, context);
}
function makeDefaultRoom(): Room {
return new Room(roomId, MatrixClientPeg.safeGet(), "@user:example.com", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
}
function createMenu(
mxEvent: MatrixEvent,
props?: Partial<MessageContextMenu["props"]>,
context: Partial<IRoomState> = {},
beacons: Map<BeaconIdentifier, Beacon> = new Map(),
room: Room = makeDefaultRoom(),
): RenderResult {
const client = MatrixClientPeg.safeGet();
// @ts-ignore illegally set private prop
room.currentState.beacons = beacons;
mxEvent.setStatus(EventStatus.SENT);
client.getUserId = jest.fn().mockReturnValue("@user:example.com");
client.getRoom = jest.fn().mockReturnValue(room);
return render(
<RoomContext.Provider value={context as IRoomState}>
<MessageContextMenu mxEvent={mxEvent} onFinished={jest.fn()} {...props} />
</RoomContext.Provider>,
);
}