Sending URL Preview Bundles (MSC 4095) (#34150)

* Fetches link previews for all links in message (instead of just the first one)

* message url component to display preview for multiple urls

* Revert visible behaviour back to only showing one URL preview

While fetching all link previews in the text message.

* Moved URL preview VM to MessageComposer

* Added com.beeper.linkpreviews to messages sent with the markdown compositor

* MSC 4452 implemented for the older (markdown) composer

* claude told me to use logger instead of console

* Previews generated with snapshot instead of vm

* moved attachPreviews to a separate file

* added attach URL previews to rich text editor

* don't let attachUrlPreviews block clearning the composer

* fixed linter errors

* moved url preview behind labs feature gate

* passed linters

* moved lab feature checking to where attach preview is used

* claude wrote some unit tests for url previewing

* fixed linter errors

* added feature to labs.md

* fixed linter errors

* fixed oxfmt error

* set previews to none if all previews failed

* resolved PR reviews besides the ones that requires a larger code change

* moved url preview VM creation to message composer

* removed delay when clearing composer url preview on message send

* minor changes to stop using globals everywhere

* moved debouncing responsibility to urlpreviewVM

* minor lint fixes

* added comment

* remove composer content from the state of urlpreview, moved it to the vm

* urlpreviewwrapper depends on the vm only

* added comments

* edited the comment

* updated snapshots

* claude fixed tests

* enable feature for tests that requires MSC4095

* updated snapshot
This commit is contained in:
Sirius
2026-07-13 15:31:18 +00:00
committed by GitHub
parent 6aeb5f552b
commit 84653bb416
25 changed files with 588 additions and 210 deletions
@@ -35,6 +35,7 @@ import { addTextToComposer } from "../../../../test-utils/composer";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass";
import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
import { MessageComposerUrlPreviewViewModel } from "../../../../../src/viewmodels/composer/MessageComposerUrlPreviewViewModel.ts";
import { SDKContext } from "../../../../../src/contexts/SDKContext.ts";
jest.mock("../../../../../src/utils/local-room", () => ({
@@ -186,6 +187,12 @@ describe("<SendMessageComposer/>", () => {
spyDispatcher.mockReset();
});
const urlPreviewVm = new MessageComposerUrlPreviewViewModel({
client: mockClient,
visible: false,
showTooltips: false,
urlPreviewBundle: false,
});
const defaultProps = {
room: mockRoom,
toggleStickerPickerOpen: jest.fn(),
@@ -194,7 +201,7 @@ describe("<SendMessageComposer/>", () => {
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider room={mockRoom} {...roomContext}>
<RoomUploadContextProvider>
<SendMessageComposer {...defaultProps} {...props} />
<SendMessageComposer {...defaultProps} {...props} urlPreviewVm={urlPreviewVm} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
@@ -439,6 +446,13 @@ describe("<SendMessageComposer/>", () => {
it("should call prepareToEncrypt when the user is typing", async () => {
const cli = stubClient();
const urlPreviewVm = new MessageComposerUrlPreviewViewModel({
client: cli,
visible: false,
showTooltips: false,
urlPreviewBundle: false,
});
cli.isRoomEncrypted = jest.fn().mockReturnValue(true);
const room = mkStubRoom("!roomId:server", "Room", cli);
@@ -448,7 +462,11 @@ describe("<SendMessageComposer/>", () => {
<MatrixClientContext.Provider value={cli}>
<ScopedRoomContextProvider {...({ room } as unknown as RoomContextType)}>
<RoomUploadContextProvider>
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} />
<SendMessageComposer
room={room}
toggleStickerPickerOpen={jest.fn()}
urlPreviewVm={urlPreviewVm}
/>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
@@ -65,7 +65,11 @@ describe("message", () => {
describe("sendMessage", () => {
it("Should not send empty html message", async () => {
// When
await sendMessage("", true, { roomContext: defaultRoomContext, mxClient: mockClient });
await sendMessage("", true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
expect(mockClient.sendMessage).toHaveBeenCalledTimes(0);
@@ -80,6 +84,7 @@ describe("message", () => {
await sendMessage(message, true, {
roomContext: mockRoomContextWithoutId,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -93,6 +98,7 @@ describe("message", () => {
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -104,6 +110,7 @@ describe("message", () => {
roomContext: defaultRoomContext,
mxClient: mockClient,
relation: {},
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -118,6 +125,7 @@ describe("message", () => {
event_id: "valid_id",
rel_type: "m.does_not_match",
},
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -133,6 +141,7 @@ describe("message", () => {
event_id: "valid_id",
rel_type: "m.thread",
},
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -145,6 +154,7 @@ describe("message", () => {
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -172,6 +182,7 @@ describe("message", () => {
roomContext: defaultRoomContext,
mxClient: mockClient,
replyToEvent: mockReplyEvent,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -201,6 +212,7 @@ describe("message", () => {
await sendMessage(message, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -212,7 +224,11 @@ describe("message", () => {
it("Should handle emojis", async () => {
// When
await sendMessage("🎉", false, { roomContext: defaultRoomContext, mxClient: mockClient });
await sendMessage("🎉", false, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
expect(spyDispatcher).toHaveBeenCalledWith({ action: "effects.confetti" });
@@ -227,6 +243,7 @@ describe("message", () => {
await sendMessage(validCommand, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -239,6 +256,7 @@ describe("message", () => {
await sendMessage(invalidPrefixCommand, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -256,6 +274,7 @@ describe("message", () => {
const result = await sendMessage(validCommand, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// Then
@@ -270,6 +289,7 @@ describe("message", () => {
await sendMessage(inputText, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
"myfakeroom",
@@ -289,6 +309,7 @@ describe("message", () => {
roomContext: defaultRoomContext,
mxClient: mockClient,
relation: mockRelation,
urlPreviewSnapshot: { previews: [], content: "" },
});
expect(mockClient.sendMessage).toHaveBeenCalledWith(
@@ -305,6 +326,7 @@ describe("message", () => {
roomContext: defaultRoomContext,
mxClient: mockClient,
replyToEvent: mockEvent,
urlPreviewSnapshot: { previews: [], content: "" },
});
expect(addReplySpy).toHaveBeenCalledTimes(1);
@@ -319,6 +341,7 @@ describe("message", () => {
roomContext: defaultRoomContext,
mxClient: mockClient,
replyToEvent: mockEvent,
urlPreviewSnapshot: { previews: [], content: "" },
});
expect(result).toBeUndefined();
@@ -333,6 +356,7 @@ describe("message", () => {
await sendMessage(invalidCommandInput, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
// we expect the message to have been sent
@@ -353,6 +377,7 @@ describe("message", () => {
const result = await sendMessage(invalidCommandInput, true, {
roomContext: defaultRoomContext,
mxClient: mockClient,
urlPreviewSnapshot: { previews: [], content: "" },
});
expect(result).toBeUndefined();