* 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>
482 lines
18 KiB
TypeScript
482 lines
18 KiB
TypeScript
/*
|
|
Copyright 2024 New Vector Ltd.
|
|
Copyright 2022 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 { EventType, type MatrixEvent, RoomMember, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix";
|
|
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { initOnce } from "@vector-im/matrix-wysiwyg";
|
|
|
|
import {
|
|
clearAllModals,
|
|
createTestClient,
|
|
flushPromises,
|
|
mkEvent,
|
|
mkStubRoom,
|
|
mockPlatformPeg,
|
|
stubClient,
|
|
} from "../../../../test-utils";
|
|
import MessageComposer from "../../../../../src/components/views/rooms/MessageComposer";
|
|
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
|
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
|
import ResizeNotifier from "../../../../../src/utils/ResizeNotifier";
|
|
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
|
import { LocalRoom } from "../../../../../src/models/LocalRoom";
|
|
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
|
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
|
|
import dis from "../../../../../src/dispatcher/dispatcher";
|
|
import { E2EStatus } from "../../../../../src/utils/ShieldUtils";
|
|
import { addTextToComposerRTL } from "../../../../test-utils/composer";
|
|
import UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore";
|
|
import { Action } from "../../../../../src/dispatcher/actions";
|
|
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
|
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
|
|
|
|
const openStickerPicker = async (): Promise<void> => {
|
|
await userEvent.click(screen.getByLabelText("More options"));
|
|
await userEvent.click(screen.getByLabelText("Sticker"));
|
|
};
|
|
|
|
const startVoiceMessage = async (): Promise<void> => {
|
|
await userEvent.click(screen.getByLabelText("More options"));
|
|
await userEvent.click(screen.getByLabelText("Voice Message"));
|
|
};
|
|
|
|
const expectVoiceMessageRecordingTriggered = (): void => {
|
|
// Checking for the voice message dialog text, if no mic can be found.
|
|
// By this we know at least that starting a voice message was triggered.
|
|
expect(screen.getByText("No microphone found")).toBeInTheDocument();
|
|
};
|
|
|
|
beforeAll(initOnce, 10000);
|
|
|
|
describe("MessageComposer", () => {
|
|
stubClient();
|
|
const cli = createTestClient();
|
|
|
|
beforeEach(() => {
|
|
mockPlatformPeg();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await clearAllModals();
|
|
jest.useRealTimers();
|
|
|
|
// restore settings
|
|
act(() => {
|
|
(
|
|
[
|
|
"MessageComposerInput.showStickersButton",
|
|
"MessageComposerInput.showPollsButton",
|
|
"feature_wysiwyg_composer",
|
|
] as const
|
|
).forEach((setting): void => {
|
|
SettingsStore.setValue(setting, null, SettingLevel.DEVICE, SettingsStore.getDefaultValue(setting));
|
|
});
|
|
});
|
|
});
|
|
|
|
it("wysiwyg correctly persists state to and from localStorage", async () => {
|
|
const room = mkStubRoom("!roomId:server", "Room 1", cli);
|
|
const messageText = "Test Text";
|
|
await SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, true);
|
|
const { renderResult, rawComponent } = wrapAndRender({ room });
|
|
const { unmount } = renderResult;
|
|
|
|
await flushPromises();
|
|
|
|
const key = `mx_wysiwyg_state_${room.roomId}`;
|
|
|
|
await userEvent.click(screen.getByRole("textbox"));
|
|
fireEvent.input(screen.getByRole("textbox"), {
|
|
data: messageText,
|
|
inputType: "insertText",
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(messageText));
|
|
|
|
// Wait for event dispatch to happen
|
|
await flushPromises();
|
|
|
|
// assert there is state persisted
|
|
expect(localStorage.getItem(key)).toBeNull();
|
|
|
|
// ensure the right state was persisted to localStorage
|
|
unmount();
|
|
|
|
// assert the persisted state
|
|
expect(JSON.parse(localStorage.getItem(key)!)).toStrictEqual({
|
|
content: messageText,
|
|
isRichText: true,
|
|
});
|
|
|
|
// ensure the correct state is re-loaded
|
|
render(rawComponent);
|
|
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(messageText));
|
|
}, 10000);
|
|
|
|
describe("for a Room", () => {
|
|
const room = mkStubRoom("!roomId:server", "Room 1", cli);
|
|
|
|
it("Renders a SendMessageComposer and MessageComposerButtons by default", () => {
|
|
wrapAndRender({ room });
|
|
expect(screen.getByLabelText("Send an unencrypted message…")).toBeInTheDocument();
|
|
});
|
|
|
|
it("Does not render a SendMessageComposer or MessageComposerButtons when user has no permission", () => {
|
|
wrapAndRender({ room }, false);
|
|
expect(screen.queryByLabelText("Send an unencrypted message…")).not.toBeInTheDocument();
|
|
expect(screen.getByText("You do not have permission to post to this room")).toBeInTheDocument();
|
|
});
|
|
|
|
it("Does not render a SendMessageComposer or MessageComposerButtons when room is tombstoned", () => {
|
|
wrapAndRender(
|
|
{ room },
|
|
true,
|
|
false,
|
|
mkEvent({
|
|
event: true,
|
|
type: "m.room.tombstone",
|
|
room: room.roomId,
|
|
user: "@user1:server",
|
|
skey: "",
|
|
content: {},
|
|
ts: Date.now(),
|
|
}),
|
|
);
|
|
|
|
expect(screen.queryByLabelText("Send an unencrypted message…")).not.toBeInTheDocument();
|
|
expect(screen.getByText("This room has been replaced and is no longer active.")).toBeInTheDocument();
|
|
});
|
|
|
|
describe("when receiving a »reply_to_event«", () => {
|
|
let roomContext: RoomContextType;
|
|
let resizeNotifier: ResizeNotifier;
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
resizeNotifier = {
|
|
notifyTimelineHeightChanged: jest.fn(),
|
|
} as unknown as ResizeNotifier;
|
|
roomContext = wrapAndRender({
|
|
room,
|
|
resizeNotifier,
|
|
}).roomContext;
|
|
});
|
|
|
|
it("should call notifyTimelineHeightChanged() for the same context", () => {
|
|
dis.dispatch({
|
|
action: "reply_to_event",
|
|
context: roomContext.timelineRenderingType,
|
|
});
|
|
|
|
jest.advanceTimersByTime(150);
|
|
expect(resizeNotifier.notifyTimelineHeightChanged).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should not call notifyTimelineHeightChanged() for a different context", () => {
|
|
dis.dispatch({
|
|
action: "reply_to_event",
|
|
context: "test",
|
|
});
|
|
|
|
jest.advanceTimersByTime(150);
|
|
expect(resizeNotifier.notifyTimelineHeightChanged).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// test button display depending on settings
|
|
[
|
|
{
|
|
setting: "MessageComposerInput.showStickersButton" as const,
|
|
buttonLabel: "Sticker",
|
|
},
|
|
{
|
|
setting: "MessageComposerInput.showPollsButton" as const,
|
|
buttonLabel: "Poll",
|
|
},
|
|
].forEach(({ setting, buttonLabel }) => {
|
|
[true, false].forEach((value: boolean) => {
|
|
describe(`when ${setting} = ${value}`, () => {
|
|
beforeEach(async () => {
|
|
await act(() => SettingsStore.setValue(setting, null, SettingLevel.DEVICE, value));
|
|
wrapAndRender({ room });
|
|
await userEvent.click(screen.getByLabelText("More options"));
|
|
});
|
|
|
|
it(`should${value ? "" : " not"} display the button`, () => {
|
|
if (value) {
|
|
// eslint-disable-next-line jest/no-conditional-expect
|
|
expect(screen.getByLabelText(buttonLabel)).toBeInTheDocument();
|
|
} else {
|
|
// eslint-disable-next-line jest/no-conditional-expect
|
|
expect(screen.queryByLabelText(buttonLabel)).not.toBeInTheDocument();
|
|
}
|
|
});
|
|
|
|
describe(`and setting ${setting} to ${!value}`, () => {
|
|
beforeEach(async () => {
|
|
// simulate settings update
|
|
await act(async () => {
|
|
await SettingsStore.setValue(setting, null, SettingLevel.DEVICE, !value);
|
|
dis.dispatch(
|
|
{
|
|
action: Action.SettingUpdated,
|
|
settingName: setting,
|
|
newValue: !value,
|
|
},
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
it(`should${!value || "not"} display the button`, () => {
|
|
if (!value) {
|
|
// eslint-disable-next-line jest/no-conditional-expect
|
|
expect(screen.getByLabelText(buttonLabel)).toBeInTheDocument();
|
|
} else {
|
|
// eslint-disable-next-line jest/no-conditional-expect
|
|
expect(screen.queryByLabelText(buttonLabel)).not.toBeInTheDocument();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
it("should not render the send button", () => {
|
|
wrapAndRender({ room });
|
|
expect(screen.queryByLabelText("Send message")).not.toBeInTheDocument();
|
|
});
|
|
|
|
describe("when a message has been entered", () => {
|
|
beforeEach(async () => {
|
|
const renderResult = wrapAndRender({ room }).renderResult;
|
|
await addTextToComposerRTL(renderResult, "Hello");
|
|
});
|
|
|
|
it("should render the send button", () => {
|
|
expect(screen.getByLabelText("Send message")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("UIStore interactions", () => {
|
|
let resizeCallback: (key: string, data: object) => void;
|
|
|
|
beforeEach(() => {
|
|
jest.spyOn(UIStore.instance, "on").mockImplementation(
|
|
(_event: string | symbol, listener: (key: string, data: object) => void): any => {
|
|
resizeCallback = listener;
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("when a non-resize event occurred in UIStore", () => {
|
|
beforeEach(async () => {
|
|
wrapAndRender({ room });
|
|
await openStickerPicker();
|
|
resizeCallback("test", {});
|
|
});
|
|
|
|
it("should still display the sticker picker", () => {
|
|
expect(screen.getByText("You don't currently have any stickerpacks enabled")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("when a resize to narrow event occurred in UIStore", () => {
|
|
beforeEach(async () => {
|
|
wrapAndRender({ room }, true, true);
|
|
await openStickerPicker();
|
|
act(() => resizeCallback(UI_EVENTS.Resize, {}));
|
|
});
|
|
|
|
it("should close the menu", () => {
|
|
expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should not show the attachment button", () => {
|
|
expect(screen.queryByLabelText("Attachment")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should close the sticker picker", () => {
|
|
expect(
|
|
screen.queryByText("You don't currently have any stickerpacks enabled"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("when a resize to non-narrow event occurred in UIStore", () => {
|
|
beforeEach(async () => {
|
|
wrapAndRender({ room }, true, false);
|
|
await openStickerPicker();
|
|
act(() => resizeCallback(UI_EVENTS.Resize, {}));
|
|
});
|
|
|
|
it("should close the menu", () => {
|
|
expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should show the attachment button", () => {
|
|
expect(screen.getByLabelText("Attachment")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should close the sticker picker", () => {
|
|
expect(
|
|
screen.queryByText("You don't currently have any stickerpacks enabled"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("when not replying to an event", () => {
|
|
it("should pass the expected placeholder to SendMessageComposer", () => {
|
|
wrapAndRender({ room });
|
|
expect(screen.getByLabelText("Send an unencrypted message…")).toBeInTheDocument();
|
|
});
|
|
|
|
it("and an e2e status it should pass the expected placeholder to SendMessageComposer", () => {
|
|
wrapAndRender({
|
|
room,
|
|
e2eStatus: E2EStatus.Normal,
|
|
});
|
|
expect(screen.getByLabelText("Send a message…")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("when replying to an event", () => {
|
|
let replyToEvent: MatrixEvent;
|
|
let props: Partial<React.ComponentProps<typeof MessageComposer>>;
|
|
|
|
const checkPlaceholder = (expected: string) => {
|
|
it("should pass the expected placeholder to SendMessageComposer", () => {
|
|
wrapAndRender(props);
|
|
expect(screen.getByLabelText(expected)).toBeInTheDocument();
|
|
});
|
|
};
|
|
|
|
const setEncrypted = () => {
|
|
beforeEach(() => {
|
|
props.e2eStatus = E2EStatus.Normal;
|
|
});
|
|
};
|
|
|
|
beforeEach(() => {
|
|
replyToEvent = mkEvent({
|
|
event: true,
|
|
type: EventType.RoomMessage,
|
|
user: cli.getUserId()!,
|
|
content: {},
|
|
});
|
|
|
|
props = {
|
|
room,
|
|
replyToEvent,
|
|
};
|
|
});
|
|
|
|
describe("without encryption", () => {
|
|
checkPlaceholder("Send an unencrypted reply…");
|
|
});
|
|
|
|
describe("with encryption", () => {
|
|
setEncrypted();
|
|
checkPlaceholder("Send a reply…");
|
|
});
|
|
|
|
describe("with a non-thread relation", () => {
|
|
beforeEach(() => {
|
|
props.relation = { rel_type: "test" };
|
|
});
|
|
|
|
checkPlaceholder("Send an unencrypted reply…");
|
|
});
|
|
|
|
describe("that is a thread", () => {
|
|
beforeEach(() => {
|
|
props.relation = { rel_type: THREAD_RELATION_TYPE.name };
|
|
});
|
|
|
|
checkPlaceholder("Reply to unencrypted thread…");
|
|
|
|
describe("with encryption", () => {
|
|
setEncrypted();
|
|
checkPlaceholder("Reply to thread…");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("when clicking start a voice message", () => {
|
|
beforeEach(async () => {
|
|
wrapAndRender({ room });
|
|
await startVoiceMessage();
|
|
await flushPromises();
|
|
});
|
|
|
|
it("should try to start a voice message", () => {
|
|
expectVoiceMessageRecordingTriggered();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("for a LocalRoom", () => {
|
|
const localRoom = new LocalRoom("!room:example.com", cli, cli.getUserId()!);
|
|
|
|
it("should not show the stickers button", async () => {
|
|
wrapAndRender({ room: localRoom });
|
|
await act(async () => {
|
|
await userEvent.click(screen.getByLabelText("More options"));
|
|
});
|
|
expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
function wrapAndRender(
|
|
props: Partial<React.ComponentProps<typeof MessageComposer>> = {},
|
|
canSendMessages = true,
|
|
narrow = false,
|
|
tombstone?: MatrixEvent,
|
|
) {
|
|
const mockClient = MatrixClientPeg.safeGet();
|
|
const roomId = "myroomid";
|
|
const room: any = props.room || {
|
|
currentState: undefined,
|
|
roomId,
|
|
client: mockClient,
|
|
getMember: function (userId: string): RoomMember {
|
|
return new RoomMember(roomId, userId);
|
|
},
|
|
};
|
|
|
|
const roomContext = {
|
|
room,
|
|
canSendMessages,
|
|
tombstone,
|
|
narrow,
|
|
} as unknown as RoomContextType;
|
|
|
|
const defaultProps = {
|
|
room,
|
|
resizeNotifier: new ResizeNotifier(),
|
|
permalinkCreator: new RoomPermalinkCreator(room),
|
|
};
|
|
|
|
const getRawComponent = (props = {}, context = roomContext, client = mockClient) => (
|
|
<MatrixClientContext.Provider value={client}>
|
|
<ScopedRoomContextProvider {...context}>
|
|
<MessageComposer {...defaultProps} {...props} />
|
|
</ScopedRoomContextProvider>
|
|
</MatrixClientContext.Provider>
|
|
);
|
|
return {
|
|
rawComponent: getRawComponent(props, roomContext, mockClient),
|
|
renderResult: render(getRawComponent(props, roomContext, mockClient)),
|
|
roomContext,
|
|
};
|
|
}
|