3d80eff65b
* 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
503 lines
19 KiB
TypeScript
503 lines
19 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 React from "react";
|
|
import { act, render, fireEvent } from "@testing-library/react";
|
|
import {
|
|
EventType,
|
|
EventStatus,
|
|
MatrixEvent,
|
|
MatrixEventEvent,
|
|
MsgType,
|
|
Room,
|
|
FeatureSupport,
|
|
Thread,
|
|
EventTimeline,
|
|
} from "matrix-js-sdk/src/matrix";
|
|
|
|
import MessageActionBar from "../../../../src/components/views/messages/MessageActionBar";
|
|
import {
|
|
getMockClientWithEventEmitter,
|
|
mockClientMethodsUser,
|
|
mockClientMethodsEvents,
|
|
makeBeaconInfoEvent,
|
|
} from "../../../test-utils";
|
|
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
|
|
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
|
|
import { IRoomState } from "../../../../src/components/structures/RoomView";
|
|
import dispatcher from "../../../../src/dispatcher/dispatcher";
|
|
import SettingsStore from "../../../../src/settings/SettingsStore";
|
|
import { Action } from "../../../../src/dispatcher/actions";
|
|
|
|
jest.mock("../../../../src/dispatcher/dispatcher");
|
|
|
|
describe("<MessageActionBar />", () => {
|
|
const userId = "@alice:server.org";
|
|
const roomId = "!room:server.org";
|
|
|
|
const client = getMockClientWithEventEmitter({
|
|
...mockClientMethodsUser(userId),
|
|
...mockClientMethodsEvents(),
|
|
getRoom: jest.fn(),
|
|
setRoomAccountData: jest.fn(),
|
|
sendStateEvent: jest.fn(),
|
|
});
|
|
const room = new Room(roomId, client, userId);
|
|
|
|
const alicesMessageEvent = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
room_id: roomId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: "Hello",
|
|
},
|
|
event_id: "$alices_message",
|
|
});
|
|
|
|
const bobsMessageEvent = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: "@bob:server.org",
|
|
room_id: roomId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: "I am bob",
|
|
},
|
|
event_id: "$bobs_message",
|
|
});
|
|
|
|
const redactedEvent = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
});
|
|
redactedEvent.makeRedacted(redactedEvent, room);
|
|
|
|
const localStorageMock = (() => {
|
|
let store: Record<string, any> = {};
|
|
return {
|
|
getItem: jest.fn().mockImplementation((key) => store[key] ?? null),
|
|
setItem: jest.fn().mockImplementation((key, value) => {
|
|
store[key] = value;
|
|
}),
|
|
clear: jest.fn().mockImplementation(() => {
|
|
store = {};
|
|
}),
|
|
removeItem: jest.fn().mockImplementation((key) => delete store[key]),
|
|
};
|
|
})();
|
|
Object.defineProperty(window, "localStorage", {
|
|
value: localStorageMock,
|
|
writable: true,
|
|
});
|
|
|
|
jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
|
|
|
|
client.getRoom.mockReturnValue(room);
|
|
|
|
const defaultProps = {
|
|
getTile: jest.fn(),
|
|
getReplyChain: jest.fn(),
|
|
toggleThreadExpanded: jest.fn(),
|
|
mxEvent: alicesMessageEvent,
|
|
permalinkCreator: new RoomPermalinkCreator(room),
|
|
};
|
|
const defaultRoomContext = {
|
|
...RoomContext,
|
|
timelineRenderingType: TimelineRenderingType.Room,
|
|
canSendMessages: true,
|
|
canReact: true,
|
|
} as unknown as IRoomState;
|
|
const getComponent = (props = {}, roomContext: Partial<IRoomState> = {}) =>
|
|
render(
|
|
<RoomContext.Provider value={{ ...defaultRoomContext, ...roomContext }}>
|
|
<MessageActionBar {...defaultProps} {...props} />
|
|
</RoomContext.Provider>,
|
|
);
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
alicesMessageEvent.setStatus(EventStatus.SENT);
|
|
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
|
|
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterAll(() => {
|
|
jest.spyOn(SettingsStore, "getValue").mockRestore();
|
|
jest.spyOn(SettingsStore, "setValue").mockRestore();
|
|
});
|
|
|
|
it("kills event listeners on unmount", () => {
|
|
const offSpy = jest.spyOn(alicesMessageEvent, "off").mockClear();
|
|
const wrapper = getComponent({ mxEvent: alicesMessageEvent });
|
|
|
|
act(() => {
|
|
wrapper.unmount();
|
|
});
|
|
|
|
expect(offSpy.mock.calls[0][0]).toEqual(MatrixEventEvent.Status);
|
|
expect(offSpy.mock.calls[1][0]).toEqual(MatrixEventEvent.Decrypted);
|
|
expect(offSpy.mock.calls[2][0]).toEqual(MatrixEventEvent.BeforeRedaction);
|
|
|
|
expect(client.decryptEventIfNeeded).toHaveBeenCalled();
|
|
});
|
|
|
|
describe("decryption", () => {
|
|
it("decrypts event if needed", () => {
|
|
getComponent({ mxEvent: alicesMessageEvent });
|
|
expect(client.decryptEventIfNeeded).toHaveBeenCalled();
|
|
});
|
|
|
|
it("updates component on decrypted event", () => {
|
|
const decryptingEvent = new MatrixEvent({
|
|
type: EventType.RoomMessageEncrypted,
|
|
sender: userId,
|
|
room_id: roomId,
|
|
content: {},
|
|
});
|
|
jest.spyOn(decryptingEvent, "isBeingDecrypted").mockReturnValue(true);
|
|
const { queryByLabelText } = getComponent({ mxEvent: decryptingEvent });
|
|
|
|
// still encrypted event is not actionable => no reply button
|
|
expect(queryByLabelText("Reply")).toBeFalsy();
|
|
|
|
act(() => {
|
|
// ''decrypt'' the event
|
|
decryptingEvent.event.type = alicesMessageEvent.getType();
|
|
decryptingEvent.event.content = alicesMessageEvent.getContent();
|
|
decryptingEvent.emit(MatrixEventEvent.Decrypted, decryptingEvent);
|
|
});
|
|
|
|
// new available actions after decryption
|
|
expect(queryByLabelText("Reply")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("status", () => {
|
|
it("updates component when event status changes", () => {
|
|
alicesMessageEvent.setStatus(EventStatus.QUEUED);
|
|
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
|
|
// pending event status, cancel action available
|
|
expect(queryByLabelText("Delete")).toBeTruthy();
|
|
|
|
act(() => {
|
|
alicesMessageEvent.setStatus(EventStatus.SENT);
|
|
});
|
|
|
|
// event is sent, no longer cancelable
|
|
expect(queryByLabelText("Delete")).toBeFalsy();
|
|
});
|
|
});
|
|
|
|
describe("redaction", () => {
|
|
// this doesn't do what it's supposed to
|
|
// because beforeRedaction event is fired... before redaction
|
|
// event is unchanged at point when this component updates
|
|
// TODO file bug
|
|
it.skip("updates component on before redaction event", () => {
|
|
const event = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
room_id: roomId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: "Hello",
|
|
},
|
|
});
|
|
const { queryByLabelText } = getComponent({ mxEvent: event });
|
|
|
|
// no pending redaction => no delete button
|
|
expect(queryByLabelText("Delete")).toBeFalsy();
|
|
|
|
act(() => {
|
|
const redactionEvent = new MatrixEvent({
|
|
type: EventType.RoomRedaction,
|
|
sender: userId,
|
|
room_id: roomId,
|
|
});
|
|
redactionEvent.setStatus(EventStatus.QUEUED);
|
|
event.markLocallyRedacted(redactionEvent);
|
|
});
|
|
|
|
// updated with local redaction event, delete now available
|
|
expect(queryByLabelText("Delete")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("options button", () => {
|
|
it("renders options menu", () => {
|
|
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
expect(queryByLabelText("Options")).toBeTruthy();
|
|
});
|
|
|
|
it("opens message context menu on click", () => {
|
|
const { getByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
fireEvent.click(queryByLabelText("Options")!);
|
|
expect(getByTestId("mx_MessageContextMenu")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("reply button", () => {
|
|
it("renders reply button on own actionable event", () => {
|
|
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
expect(queryByLabelText("Reply")).toBeTruthy();
|
|
});
|
|
|
|
it("renders reply button on others actionable event", () => {
|
|
const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent }, { canSendMessages: true });
|
|
expect(queryByLabelText("Reply")).toBeTruthy();
|
|
});
|
|
|
|
it("does not render reply button on non-actionable event", () => {
|
|
// redacted event is not actionable
|
|
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent });
|
|
expect(queryByLabelText("Reply")).toBeFalsy();
|
|
});
|
|
|
|
it("does not render reply button when user cannot send messaged", () => {
|
|
// redacted event is not actionable
|
|
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canSendMessages: false });
|
|
expect(queryByLabelText("Reply")).toBeFalsy();
|
|
});
|
|
|
|
it("dispatches reply event on click", () => {
|
|
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
|
|
fireEvent.click(queryByLabelText("Reply")!);
|
|
|
|
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
|
action: "reply_to_event",
|
|
event: alicesMessageEvent,
|
|
context: TimelineRenderingType.Room,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("react button", () => {
|
|
it("renders react button on own actionable event", () => {
|
|
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
expect(queryByLabelText("React")).toBeTruthy();
|
|
});
|
|
|
|
it("renders react button on others actionable event", () => {
|
|
const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent });
|
|
expect(queryByLabelText("React")).toBeTruthy();
|
|
});
|
|
|
|
it("does not render react button on non-actionable event", () => {
|
|
// redacted event is not actionable
|
|
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent });
|
|
expect(queryByLabelText("React")).toBeFalsy();
|
|
});
|
|
|
|
it("does not render react button when user cannot react", () => {
|
|
// redacted event is not actionable
|
|
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canReact: false });
|
|
expect(queryByLabelText("React")).toBeFalsy();
|
|
});
|
|
|
|
it("opens reaction picker on click", () => {
|
|
const { queryByLabelText, getByTestId } = getComponent({ mxEvent: alicesMessageEvent });
|
|
fireEvent.click(queryByLabelText("React")!);
|
|
expect(getByTestId("mx_EmojiPicker")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("cancel button", () => {
|
|
it("renders cancel button for an event with a cancelable status", () => {
|
|
alicesMessageEvent.setStatus(EventStatus.QUEUED);
|
|
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
expect(queryByLabelText("Delete")).toBeTruthy();
|
|
});
|
|
|
|
it("renders cancel button for an event with a pending edit", () => {
|
|
const event = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
room_id: roomId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: "Hello",
|
|
},
|
|
});
|
|
event.setStatus(EventStatus.SENT);
|
|
const replacingEvent = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
room_id: roomId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: "replacing event body",
|
|
},
|
|
});
|
|
replacingEvent.setStatus(EventStatus.QUEUED);
|
|
event.makeReplaced(replacingEvent);
|
|
const { queryByLabelText } = getComponent({ mxEvent: event });
|
|
expect(queryByLabelText("Delete")).toBeTruthy();
|
|
});
|
|
|
|
it("renders cancel button for an event with a pending redaction", () => {
|
|
const event = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
room_id: roomId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: "Hello",
|
|
},
|
|
});
|
|
event.setStatus(EventStatus.SENT);
|
|
|
|
const redactionEvent = new MatrixEvent({
|
|
type: EventType.RoomRedaction,
|
|
sender: userId,
|
|
room_id: roomId,
|
|
});
|
|
redactionEvent.setStatus(EventStatus.QUEUED);
|
|
|
|
event.markLocallyRedacted(redactionEvent);
|
|
const { queryByLabelText } = getComponent({ mxEvent: event });
|
|
expect(queryByLabelText("Delete")).toBeTruthy();
|
|
});
|
|
|
|
it("renders cancel and retry button for an event with NOT_SENT status", () => {
|
|
alicesMessageEvent.setStatus(EventStatus.NOT_SENT);
|
|
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
expect(queryByLabelText("Retry")).toBeTruthy();
|
|
expect(queryByLabelText("Delete")).toBeTruthy();
|
|
});
|
|
|
|
it.todo("unsends event on cancel click");
|
|
it.todo("retrys event on retry click");
|
|
});
|
|
|
|
describe("thread button", () => {
|
|
beforeEach(() => {
|
|
Thread.setServerSideSupport(FeatureSupport.Stable);
|
|
});
|
|
|
|
describe("when threads feature is enabled", () => {
|
|
it("renders thread button on own actionable event", () => {
|
|
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
expect(queryByLabelText("Reply in thread")).toBeTruthy();
|
|
});
|
|
|
|
it("does not render thread button for a beacon_info event", () => {
|
|
const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId);
|
|
const { queryByLabelText } = getComponent({ mxEvent: beaconInfoEvent });
|
|
expect(queryByLabelText("Reply in thread")).toBeFalsy();
|
|
});
|
|
|
|
it("opens thread on click", () => {
|
|
const { getByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
|
|
fireEvent.click(getByLabelText("Reply in thread"));
|
|
|
|
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
|
action: Action.ShowThread,
|
|
rootEvent: alicesMessageEvent,
|
|
push: false,
|
|
});
|
|
});
|
|
|
|
it("opens parent thread for a thread reply message", () => {
|
|
const threadReplyEvent = new MatrixEvent({
|
|
type: EventType.RoomMessage,
|
|
sender: userId,
|
|
room_id: roomId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: "this is a thread reply",
|
|
},
|
|
});
|
|
// mock the thread stuff
|
|
jest.spyOn(threadReplyEvent, "isThreadRoot", "get").mockReturnValue(false);
|
|
// set alicesMessageEvent as the root event
|
|
jest.spyOn(threadReplyEvent, "getThread").mockReturnValue({
|
|
rootEvent: alicesMessageEvent,
|
|
} as unknown as Thread);
|
|
const { getByLabelText } = getComponent({ mxEvent: threadReplyEvent });
|
|
|
|
fireEvent.click(getByLabelText("Reply in thread"));
|
|
|
|
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
|
action: Action.ShowThread,
|
|
rootEvent: alicesMessageEvent,
|
|
initialEvent: threadReplyEvent,
|
|
highlighted: true,
|
|
scroll_into_view: true,
|
|
push: false,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
it.each([["React"], ["Reply"], ["Reply in thread"], ["Edit"], ["Pin"]])(
|
|
"does not show context menu when right-clicking",
|
|
(buttonLabel: string) => {
|
|
// For favourite and pin buttons
|
|
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
|
|
|
|
const event = new MouseEvent("contextmenu", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
event.stopPropagation = jest.fn();
|
|
event.preventDefault = jest.fn();
|
|
|
|
const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
fireEvent(queryByLabelText(buttonLabel)!, event);
|
|
expect(event.stopPropagation).toHaveBeenCalled();
|
|
expect(event.preventDefault).toHaveBeenCalled();
|
|
expect(queryByTestId("mx_MessageContextMenu")).toBeFalsy();
|
|
},
|
|
);
|
|
|
|
it("does shows context menu when right-clicking options", () => {
|
|
const { queryByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
fireEvent.contextMenu(queryByLabelText("Options")!);
|
|
expect(queryByTestId("mx_MessageContextMenu")).toBeTruthy();
|
|
});
|
|
|
|
describe("pin button", () => {
|
|
beforeEach(() => {
|
|
// enable pin button
|
|
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.spyOn(
|
|
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
|
"mayClientSendStateEvent",
|
|
).mockRestore();
|
|
});
|
|
|
|
it("should not render pin button when user can't send state event", () => {
|
|
jest.spyOn(
|
|
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
|
"mayClientSendStateEvent",
|
|
).mockReturnValue(false);
|
|
|
|
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
expect(queryByLabelText("Pin")).toBeFalsy();
|
|
});
|
|
|
|
it("should render pin button", () => {
|
|
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
expect(queryByLabelText("Pin")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|