Rework Upload internals to use MVVM (#33307)

* Initial reword of upload to MVVM.

* Update tests

* More incremental improvements

* Refactor tests to use helper method for composer uploads.

* Add drag and drop tests

* lint

* Add commentary

* fixup test

* More precise selector

* Retarget uploads

* lint

* fixup

* one more type

* update snap

* Fixup composerUploadFiles

* fix import

* lint

* Copy and paste fixes too

* Add tests for pasting

* Add tests for pasting files.

* Remove redundant fn

* rm comment

* tidy up

* Test cleanup

* More clean up

* another fix

* Use condensed version

* Cleanup tests

* more cleaning

* last bity

* s/throw Error/throw new Error/
This commit is contained in:
Will Hunt
2026-05-12 11:30:30 +00:00
committed by GitHub
parent 39607799de
commit aeaeb55cda
26 changed files with 768 additions and 490 deletions
@@ -36,6 +36,10 @@ 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";
import {
RoomUploadContext,
type RoomUploadViewModel,
} from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
const openStickerPicker = async (): Promise<void> => {
await userEvent.click(screen.getByLabelText("More options"));
@@ -469,7 +473,9 @@ function wrapAndRender(
const getRawComponent = (props = {}, context = roomContext, client = mockClient) => (
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...context}>
<MessageComposer {...defaultProps} {...props} />
<RoomUploadContext.Provider value={{} as RoomUploadViewModel}>
<MessageComposer {...defaultProps} {...props} />
</RoomUploadContext.Provider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
);
@@ -14,7 +14,8 @@ import { createTestClient, getRoomContext, mkStubRoom } from "../../../../test-u
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
import { type RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
describe("MessageComposerButtons", () => {
// @ts-ignore - we're deliberately not implementing the whole interface here, but
@@ -54,7 +55,9 @@ describe("MessageComposerButtons", () => {
return render(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}>{component}</ScopedRoomContextProvider>
<ScopedRoomContextProvider {...defaultRoomContext}>
<RoomUploadContextProvider>{component}</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
}
@@ -31,6 +31,7 @@ import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room";
import { addTextToComposer } from "../../../../test-utils/composer";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import { SdkContextClass } from "../../../../../src/contexts/SDKContext.ts";
import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
jest.mock("../../../../../src/utils/local-room", () => ({
doMaybeLocalRoomAction: jest.fn(),
@@ -187,8 +188,10 @@ describe("<SendMessageComposer/>", () => {
};
const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => (
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...roomContext}>
<SendMessageComposer {...defaultProps} {...props} />
<ScopedRoomContextProvider room={mockRoom} {...roomContext}>
<RoomUploadContextProvider>
<SendMessageComposer {...defaultProps} {...props} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
);
@@ -435,7 +438,11 @@ describe("<SendMessageComposer/>", () => {
const { container } = render(
<MatrixClientContext.Provider value={cli}>
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} />
<ScopedRoomContextProvider {...({ room } as unknown as RoomContextType)}>
<RoomUploadContextProvider>
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
@@ -27,6 +27,7 @@ import { type ActionPayload } from "../../../../../../src/dispatcher/payloads";
import * as EmojiButton from "../../../../../../src/components/views/rooms/EmojiButton";
import { createMocks } from "./utils";
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx";
import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
beforeAll(initOnce, 10000);
@@ -46,7 +47,9 @@ describe("EditWysiwygComposer", () => {
return render(
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...roomContext}>
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
<RoomUploadContextProvider>
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
@@ -62,7 +65,9 @@ describe("EditWysiwygComposer", () => {
rerender(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext} room={undefined}>
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} />
<RoomUploadContextProvider>
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
@@ -273,7 +278,9 @@ describe("EditWysiwygComposer", () => {
render(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}>
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
<RoomUploadContextProvider>
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
</RoomUploadContextProvider>
<Emoji menuPosition={{ chevronFace: ChevronFace.Top }} />
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
@@ -25,6 +25,7 @@ import { setSelection } from "../../../../../../src/components/views/rooms/wysiw
import { createMocks } from "./utils";
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx";
import { E2EStatus } from "../../../../../../src/utils/ShieldUtils.ts";
import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
jest.mock("../../../../../../src/components/views/rooms/EmojiButton", () => ({
EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => {
@@ -77,15 +78,17 @@ describe("SendWysiwygComposer", () => {
return render(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}>
<SendWysiwygComposer
onChange={onChange}
onSend={onSend}
disabled={disabled}
isRichTextEnabled={isRichTextEnabled}
menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })}
placeholder={placeholder}
e2eStatus={e2eStatus}
/>
<RoomUploadContextProvider>
<SendWysiwygComposer
onChange={onChange}
onSend={onSend}
disabled={disabled}
isRichTextEnabled={isRichTextEnabled}
menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })}
placeholder={placeholder}
e2eStatus={e2eStatus}
/>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
@@ -33,6 +33,7 @@ import type AutocompleteProvider from "../../../../../../../src/autocomplete/Aut
import * as Permalinks from "../../../../../../../src/utils/permalinks/Permalinks";
import { type PermalinkParts } from "../../../../../../../src/utils/permalinks/PermalinkConstructor";
import { ScopedRoomContextProvider } from "../../../../../../../src/contexts/ScopedRoomContext.tsx";
import { RoomUploadContextProvider } from "../../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
beforeAll(initOnce, 10000);
@@ -42,12 +43,14 @@ describe("WysiwygComposer", () => {
return render(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}>
<WysiwygComposer
onChange={onChange}
onSend={onSend}
disabled={disabled}
initialContent={initialContent}
/>
<RoomUploadContextProvider>
<WysiwygComposer
onChange={onChange}
onSend={onSend}
disabled={disabled}
initialContent={initialContent}
/>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
@@ -561,19 +564,21 @@ describe("WysiwygComposer", () => {
return render(
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...roomContext}>
<ComposerContext.Provider
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
>
<WysiwygComposer
onChange={jest.fn()}
onSend={jest.fn()}
initialContent={
roomContext.room && _editorStateTransfer
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
: undefined
}
/>
</ComposerContext.Provider>
<RoomUploadContextProvider>
<ComposerContext.Provider
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
>
<WysiwygComposer
onChange={jest.fn()}
onSend={jest.fn()}
initialContent={
roomContext.room && _editorStateTransfer
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
: undefined
}
/>
</ComposerContext.Provider>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
@@ -5,29 +5,21 @@ Copyright 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 { type IEventRelation, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { waitFor } from "jest-matrix-react";
import fetchMock from "@fetch-mock/jest";
import { TimelineRenderingType } from "../../../../../../../src/contexts/RoomContext";
import { mkStubRoom, stubClient } from "../../../../../../test-utils";
import ContentMessages from "../../../../../../../src/ContentMessages";
import { type IRoomState } from "../../../../../../../src/components/structures/RoomView";
import {
handleClipboardEvent,
isEventToHandleAsClipboardEvent,
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/utils";
import type { RoomUploadViewModel } from "../../../../../../../src/viewmodels/room/RoomUploadViewModel";
import type { MockedObject } from "jest-mock";
const mockClient = stubClient();
const mockRoom = mkStubRoom("mock room", "mock room", mockClient);
const mockRoomState = {
room: mockRoom,
timelineRenderingType: TimelineRenderingType.Room,
replyToEvent: {} as unknown as MatrixEvent,
} as unknown as IRoomState;
const mockUploadVM = {
initiateViaDataTransfer: jest.fn().mockResolvedValue(undefined),
initiateViaInputFiles: jest.fn().mockResolvedValue(undefined),
} as Partial<RoomUploadViewModel> as MockedObject<RoomUploadViewModel>;
const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom");
const sendContentToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentToRoom");
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
describe("handleClipboardEvent", () => {
@@ -45,29 +37,16 @@ describe("handleClipboardEvent", () => {
it("returns false if it is not a paste event", () => {
const originalEvent = createMockClipboardEvent({ type: "copy" });
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
expect(output).toBe(false);
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
});
it("returns false if clipboard data is null", () => {
const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: null });
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
expect(output).toBe(false);
});
it("returns false if room is undefined", () => {
const originalEvent = createMockClipboardEvent({ type: "paste" });
const { room, ...roomStateWithoutRoom } = mockRoomState;
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
roomStateWithoutRoom,
mockClient,
);
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
expect(output).toBe(false);
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
});
it("returns false if room clipboardData files and types are empty", () => {
@@ -75,8 +54,9 @@ describe("handleClipboardEvent", () => {
type: "paste",
clipboardData: { files: [], types: [] },
});
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
expect(output).toBe(false);
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
});
it("handles event and calls sendContentListToRoom when data files are present", () => {
@@ -84,65 +64,23 @@ describe("handleClipboardEvent", () => {
type: "paste",
clipboardData: { files: ["something here"], types: [] },
});
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
const mockReplyToEvent = {} as unknown as MatrixEvent;
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
expect(sendContentListToRoomSpy).toHaveBeenCalledWith(
originalEvent.clipboardData?.files,
mockRoom.roomId,
undefined, // this is the event relation, an optional arg
mockReplyToEvent,
mockClient,
mockRoomState.timelineRenderingType,
);
expect(output).toBe(true);
});
it("calls sendContentListToRoom with eventRelation when present", () => {
const originalEvent = createMockClipboardEvent({
type: "paste",
clipboardData: { files: ["something here"], types: [] },
});
const mockEventRelation = {} as unknown as IEventRelation;
const mockReplyToEvent = {} as unknown as MatrixEvent;
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
mockRoomState,
mockClient,
mockEventRelation,
);
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
expect(sendContentListToRoomSpy).toHaveBeenCalledWith(
originalEvent.clipboardData?.files,
mockRoom.roomId,
mockEventRelation, // this is the event relation, an optional arg
mockReplyToEvent,
mockClient,
mockRoomState.timelineRenderingType,
);
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1);
expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledWith(originalEvent.clipboardData);
expect(output).toBe(true);
});
it("calls the error handler when sentContentListToRoom errors", async () => {
const mockErrorMessage = "something went wrong";
sendContentListToRoomSpy.mockRejectedValueOnce(new Error(mockErrorMessage));
mockUploadVM.initiateViaDataTransfer.mockRejectedValueOnce(new Error(mockErrorMessage));
const originalEvent = createMockClipboardEvent({
type: "paste",
clipboardData: { files: ["something here"], types: [] },
});
const mockEventRelation = {} as unknown as IEventRelation;
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
mockRoomState,
mockClient,
mockEventRelation,
);
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
});
@@ -158,15 +96,7 @@ describe("handleClipboardEvent", () => {
getData: jest.fn().mockReturnValue("<div>invalid html"),
},
});
const mockEventRelation = {} as unknown as IEventRelation;
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
mockRoomState,
mockClient,
mockEventRelation,
);
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
expect(logSpy).toHaveBeenCalledWith("Failed to handle pasted content as Safari inserted content");
expect(output).toBe(false);
});
@@ -180,10 +110,10 @@ describe("handleClipboardEvent", () => {
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
},
});
const mockEventRelation = {} as unknown as IEventRelation;
handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient, mockEventRelation);
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
expect(fetchMock).toHaveFetchedTimes(1, "blob:");
expect(output).toBe(true);
});
it("calls error handler when fetch fails", async () => {
@@ -197,14 +127,7 @@ describe("handleClipboardEvent", () => {
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
},
});
const mockEventRelation = {} as unknown as IEventRelation;
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
mockRoomState,
mockClient,
mockEventRelation,
);
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
await waitFor(() => {
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
@@ -212,7 +135,7 @@ describe("handleClipboardEvent", () => {
expect(output).toBe(true);
});
it("calls sendContentToRoom when parsing is successful", async () => {
it("calls initiateViaInputFiles when parsing is successful", async () => {
fetchMock.get("test/file", {
blob: () => {
return Promise.resolve({ type: "image/jpeg" } as Blob);
@@ -227,23 +150,11 @@ describe("handleClipboardEvent", () => {
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
},
});
const mockEventRelation = {} as unknown as IEventRelation;
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
mockRoomState,
mockClient,
mockEventRelation,
);
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
await waitFor(() => {
expect(sendContentToRoomSpy).toHaveBeenCalledWith(
expect.any(File),
mockRoom.roomId,
mockEventRelation,
mockClient,
mockRoomState.replyToEvent,
);
expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledTimes(1);
expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledWith([expect.any(File)]);
});
expect(output).toBe(true);
});
@@ -254,8 +165,8 @@ describe("handleClipboardEvent", () => {
return Promise.resolve({ type: "image/jpeg" } as Blob);
},
});
const mockErrorMessage = "sendContentToRoom failed";
sendContentToRoomSpy.mockRejectedValueOnce(mockErrorMessage);
const mockErrorMessage = "initiateViaInputFiles failed";
mockUploadVM.initiateViaInputFiles.mockRejectedValueOnce(mockErrorMessage);
const originalEvent = createMockClipboardEvent({
type: "paste",
@@ -265,14 +176,7 @@ describe("handleClipboardEvent", () => {
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
},
});
const mockEventRelation = {} as unknown as IEventRelation;
const output = handleClipboardEvent(
originalEvent,
originalEvent.clipboardData,
mockRoomState,
mockClient,
mockEventRelation,
);
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
await waitFor(() => {
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);