mirror of
https://github.com/vector-im/element-web.git
synced 2025-11-24 20:11:31 +01:00
* Module API experiments * Move ResizerNotifier into SDKContext so we don't have to pass it into RoomView * Add the MultiRoomViewStore * Make RoomViewStore able to take a roomId prop * Different interface to add space panel items A bit less flexible but probably simpler and will help keep things actually consistent rather than just allowing modules to stick any JSX into the space panel (which means they also have to worry about styling if they *do* want it to be consistent). * Allow space panel items to be updated and manage which one is selected, allowing module "spaces" to be considered spaces * Remove fetchRoomFn from SpaceNotificationStore which didn't really seem to have any point as it was only called from one place * Switch to using module api via .instance * Fairly awful workaround to actually break the dependency nightmare * Add test for multiroomviewstore * add test * Make room names deterministic So the tests don't fail if you add other tests or run them individually * Add test for builtinsapi * Update module api * RVS is not needed as prop anymore Since it's passed through context * Add roomId to prop * Remove RoomViewStore from state This is now accessed through class field * Fix test * No need to pass RVS from LoggedInView * Add RoomContextType * Implement new builtins api * Add tests * Fix import * Fix circular dependency issue * Fix import * Add more tests * Improve comment * room-id is optional * Update license * Add implementation for AccountDataApi * Add implementation for Room * Add implementation for ClientApi * Create ClientApi in Api.ts * Write tests * Use nullish coalescing assignment * Implement openRoom in NavigationApi * Write tests * Add implementation for StoresApi * Write tests * Fix circular dependency * Add comments in lieu of type and fix else block * Change to class field --------- Co-authored-by: R Midhun Suresh <hi@midhun.dev>
540 lines
21 KiB
TypeScript
540 lines
21 KiB
TypeScript
/*
|
|
Copyright 2024 New Vector Ltd.
|
|
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE files in the repository root for full details.
|
|
*/
|
|
|
|
import React from "react";
|
|
import { act, render, fireEvent, screen, waitFor } from "jest-matrix-react";
|
|
import {
|
|
EventType,
|
|
EventStatus,
|
|
MatrixEvent,
|
|
MatrixEventEvent,
|
|
MsgType,
|
|
Room,
|
|
FeatureSupport,
|
|
Thread,
|
|
EventTimeline,
|
|
RoomStateEvent,
|
|
} 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, { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
|
|
import dispatcher from "../../../../../src/dispatcher/dispatcher";
|
|
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
|
import { Action } from "../../../../../src/dispatcher/actions";
|
|
import PinningUtils from "../../../../../src/utils/PinningUtils";
|
|
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
|
|
|
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,
|
|
room,
|
|
} as unknown as RoomContextType;
|
|
const getComponent = (props = {}, roomContext: Partial<RoomContextType> = {}) =>
|
|
render(
|
|
<ScopedRoomContextProvider {...defaultRoomContext} {...roomContext}>
|
|
<MessageActionBar {...defaultProps} {...props} />
|
|
</ScopedRoomContextProvider>,
|
|
);
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
// The base case is that we have received the remote echo and have an eventId. No sending status.
|
|
alicesMessageEvent.setStatus(null);
|
|
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("only shows retry and delete buttons when event could not be sent", () => {
|
|
// Enable pin and other features
|
|
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
|
|
|
|
alicesMessageEvent.setStatus(EventStatus.NOT_SENT);
|
|
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
|
|
|
|
// Should show retry and cancel buttons
|
|
expect(queryByLabelText("Retry")).toBeTruthy();
|
|
expect(queryByLabelText("Delete")).toBeTruthy();
|
|
|
|
// Should NOT show edit, pin, react, reply buttons
|
|
expect(queryByLabelText("Edit")).toBeFalsy();
|
|
expect(queryByLabelText("Pin")).toBeFalsy();
|
|
expect(queryByLabelText("React")).toBeFalsy();
|
|
expect(queryByLabelText("Reply")).toBeFalsy();
|
|
expect(queryByLabelText("Reply in thread")).toBeFalsy();
|
|
});
|
|
|
|
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);
|
|
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(false);
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
it("should listen to room pinned events", async () => {
|
|
getComponent({ mxEvent: alicesMessageEvent });
|
|
expect(screen.getByLabelText("Pin")).toBeInTheDocument();
|
|
|
|
// Event is considered pinned
|
|
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
|
|
// Emit that the room pinned events have changed
|
|
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
|
roomState.emit(
|
|
RoomStateEvent.Events,
|
|
{
|
|
getType: () => EventType.RoomPinnedEvents,
|
|
} as MatrixEvent,
|
|
roomState,
|
|
null,
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByLabelText("Unpin")).toBeInTheDocument());
|
|
});
|
|
});
|
|
});
|