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:
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* 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 EncryptedFile,
|
||||
type RoomMessageEventContent as SdkRoomMessageEventContent,
|
||||
} from "matrix-js-sdk/src/types";
|
||||
|
||||
/**
|
||||
* Bundled URL previews in MSC-4095
|
||||
*
|
||||
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4095
|
||||
*/
|
||||
interface UnstableBundledUrlPreviews {
|
||||
"com.beeper.linkpreviews"?: UnstableBundledUrlPreviewSingle[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single item in bundled URL previews in MSC4095
|
||||
*
|
||||
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4095
|
||||
*/
|
||||
export interface UnstableBundledUrlPreviewSingle {
|
||||
"matched_url": string;
|
||||
"beeper:image:encryption"?: EncryptedFile;
|
||||
"matrix:image:size"?: number;
|
||||
"og:image"?: string;
|
||||
"og:url"?: string;
|
||||
"og:image:width"?: number;
|
||||
"og:image:height"?: number;
|
||||
"og:image:type"?: string;
|
||||
"og:title"?: string;
|
||||
"og:description"?: string;
|
||||
}
|
||||
|
||||
export type RoomMessageEventContent = SdkRoomMessageEventContent & UnstableBundledUrlPreviews;
|
||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, createRef, type ReactNode } from "react";
|
||||
import React, { type JSX, createRef, type ReactNode, useEffect } from "react";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
type IEventRelation,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { LockOffIcon, SendSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
@@ -50,12 +51,19 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
|
||||
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
|
||||
import { type VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
|
||||
import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysiwyg_composer/";
|
||||
import { type MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext";
|
||||
import {
|
||||
type MatrixClientProps,
|
||||
withMatrixClientHOC,
|
||||
useMatrixClientContext,
|
||||
} from "../../../contexts/MatrixClientContext";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { formatTimeLeft } from "../../../DateUtils";
|
||||
import RoomReplacedSvg from "../../../../res/img/room_replaced.svg";
|
||||
import { MessageComposerUrlPreviewWrapper } from "./MessageComposerUrlPreview";
|
||||
import { Type } from "../../../editor/parts";
|
||||
import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
|
||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext";
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
|
||||
// The prefix used when persisting editor drafts to localstorage.
|
||||
export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_";
|
||||
@@ -88,6 +96,7 @@ interface IProps extends MatrixClientProps {
|
||||
relation?: IEventRelation;
|
||||
e2eStatus?: E2EStatus;
|
||||
compact?: boolean;
|
||||
urlPreviewVm: MessageComposerUrlPreviewViewModel;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -103,8 +112,6 @@ interface IState {
|
||||
isWysiwygLabEnabled: boolean;
|
||||
isRichTextEnabled: boolean;
|
||||
initialComposerContent: string;
|
||||
// Specifically for generating previews only.
|
||||
urlPreviewComposerContent: string;
|
||||
}
|
||||
|
||||
type WysiwygComposerState = {
|
||||
@@ -146,7 +153,6 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
this.state = {
|
||||
isComposerEmpty: initialComposerContent?.length === 0,
|
||||
composerContent: initialComposerContent,
|
||||
urlPreviewComposerContent: initialComposerContent,
|
||||
haveRecording: false,
|
||||
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
|
||||
isMenuOpen: false,
|
||||
@@ -395,7 +401,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
private sendMessage = async (): Promise<void> => {
|
||||
this.setState({ urlPreviewComposerContent: "" });
|
||||
this.props.urlPreviewVm.updateWithText({ content: "", debounced: false });
|
||||
if (this.state.haveRecording && this.voiceRecordingButton.current) {
|
||||
// There shouldn't be any text message to send when a voice recording is active, so
|
||||
// just send out the voice recording.
|
||||
@@ -418,25 +424,25 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
roomContext: this.context,
|
||||
relation,
|
||||
replyToEvent,
|
||||
urlPreviewSnapshot: this.props.urlPreviewVm.getSnapshot(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onChange = (model: EditorModel): void => {
|
||||
this.props.urlPreviewVm.updateWithText({
|
||||
content: model.contentPlainText,
|
||||
debounced: true,
|
||||
});
|
||||
this.setState({
|
||||
urlPreviewComposerContent: model
|
||||
.serializeParts()
|
||||
.filter((part) => part.type === Type.Plain)
|
||||
.map((part) => part.text)
|
||||
.join(" "),
|
||||
isComposerEmpty: model.isEmpty,
|
||||
});
|
||||
};
|
||||
|
||||
private onWysiwygChange = (content: string): void => {
|
||||
this.props.urlPreviewVm.updateWithText({ content, debounced: true });
|
||||
this.setState({
|
||||
composerContent: content,
|
||||
urlPreviewComposerContent: content,
|
||||
isComposerEmpty: content?.length === 0,
|
||||
});
|
||||
};
|
||||
@@ -601,6 +607,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
onChange={this.onChange}
|
||||
disabled={this.state.haveRecording}
|
||||
toggleStickerPickerOpen={this.toggleStickerPickerOpen}
|
||||
urlPreviewVm={this.props.urlPreviewVm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -686,7 +693,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
return (
|
||||
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
|
||||
<div className="mx_MessageComposer_wrapper">
|
||||
<MessageComposerUrlPreviewWrapper content={this.state.urlPreviewComposerContent} />
|
||||
<MessageComposerUrlPreviewWrapper urlPreviewVm={this.props.urlPreviewVm} />
|
||||
<UserIdentityWarning room={this.props.room} key={this.props.room.roomId} />
|
||||
<ReplyPreview
|
||||
replyToEvent={this.props.replyToEvent}
|
||||
@@ -735,4 +742,24 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer);
|
||||
export default MessageComposerWithMatrixClient;
|
||||
|
||||
export default function MessageComposerWrapper(props: Omit<IProps, "mxClient" | "urlPreviewVm">): JSX.Element {
|
||||
const { showUrlPreview } = useScopedRoomContext("showUrlPreview");
|
||||
const client = useMatrixClientContext();
|
||||
const urlPreviewBundle = useSettingValue("feature_msc4095_url_preview_bundle");
|
||||
const urlPreviewVm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new MessageComposerUrlPreviewViewModel({
|
||||
client,
|
||||
visible: showUrlPreview,
|
||||
showTooltips: PlatformPeg.get()?.needsUrlTooltips() ?? true,
|
||||
urlPreviewBundle,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void urlPreviewVm.updateUrlPreviewVisible(showUrlPreview);
|
||||
}, [urlPreviewVm, showUrlPreview]);
|
||||
|
||||
return <MessageComposerWithMatrixClient {...props} urlPreviewVm={urlPreviewVm} />;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import React from "react";
|
||||
import { render, waitFor } from "test-utils-rtl";
|
||||
import { test, describe, beforeEach, expect, vi, afterEach } from "vitest";
|
||||
|
||||
import { MessageComposerUrlPreviewWrapper, DEBOUNCE_REQUEST_TIMEOUT_MS } from "./MessageComposerUrlPreview";
|
||||
import { MessageComposerUrlPreviewWrapper } from "./MessageComposerUrlPreview";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
getRoomContext,
|
||||
@@ -22,6 +22,11 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext";
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { CustomComponentsApi } from "../../../modules/customComponentApi";
|
||||
import {
|
||||
DEBOUNCE_REQUEST_TIMEOUT_MS,
|
||||
MessageComposerUrlPreviewViewModel,
|
||||
type MessageComposerUrlPreviewViewModelProps,
|
||||
} from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
@@ -33,6 +38,26 @@ const BASIC_PREVIEW_OGDATA = {
|
||||
"og:site_name": "Example.org",
|
||||
};
|
||||
|
||||
function getUrlPreviewVm(client: MatrixClient, content?: string): MessageComposerUrlPreviewViewModel {
|
||||
const props: MessageComposerUrlPreviewViewModelProps = {
|
||||
client,
|
||||
visible: true,
|
||||
showTooltips: false,
|
||||
urlPreviewBundle: false,
|
||||
};
|
||||
|
||||
if (content !== undefined) {
|
||||
props.content = content;
|
||||
}
|
||||
|
||||
const vm = new MessageComposerUrlPreviewViewModel(props);
|
||||
if (content !== undefined) {
|
||||
// Mirror how MessageComposer drives the view model so previews are actually computed.
|
||||
void vm.updateWithText({ content, debounced: false });
|
||||
}
|
||||
return vm;
|
||||
}
|
||||
|
||||
describe("MessageComposerUrlPreview", () => {
|
||||
let client: MatrixClient;
|
||||
let originalMxModuleApi: ModuleApi;
|
||||
@@ -66,11 +91,15 @@ describe("MessageComposerUrlPreview", () => {
|
||||
}
|
||||
|
||||
test("to be empty without a link to preview", () => {
|
||||
const { container } = wrapComponent(<MessageComposerUrlPreviewWrapper content="Test a string" />);
|
||||
const { container } = wrapComponent(
|
||||
<MessageComposerUrlPreviewWrapper urlPreviewVm={getUrlPreviewVm(client, "Test a string")} />,
|
||||
);
|
||||
expect(container).toMatchInlineSnapshot(`<div />`);
|
||||
});
|
||||
test("to contain a link when there is a URL", async () => {
|
||||
const { getByText } = wrapComponent(<MessageComposerUrlPreviewWrapper content="https://example.org" />);
|
||||
const { getByText } = wrapComponent(
|
||||
<MessageComposerUrlPreviewWrapper urlPreviewVm={getUrlPreviewVm(client, "https://example.org")} />,
|
||||
);
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(getByText("Example.org")).toBeDefined();
|
||||
@@ -87,7 +116,10 @@ describe("MessageComposerUrlPreview", () => {
|
||||
() => <strong>Fake preview</strong>,
|
||||
);
|
||||
const { getByText } = wrapComponent(
|
||||
<MessageComposerUrlPreviewWrapper content="https://example.org" moduleApi={modApi} />,
|
||||
<MessageComposerUrlPreviewWrapper
|
||||
moduleApi={modApi}
|
||||
urlPreviewVm={getUrlPreviewVm(client, "https://example.org")}
|
||||
/>,
|
||||
);
|
||||
await waitFor(
|
||||
() => {
|
||||
@@ -105,12 +137,20 @@ describe("MessageComposerUrlPreview", () => {
|
||||
() => <strong>Fake preview</strong>,
|
||||
);
|
||||
const { container, getByText, queryByText, rerender } = wrapComponent(
|
||||
<MessageComposerUrlPreviewWrapper content="show-fake-preview" moduleApi={modApi} />,
|
||||
<MessageComposerUrlPreviewWrapper
|
||||
urlPreviewVm={getUrlPreviewVm(client, "show-fake-preview")}
|
||||
moduleApi={modApi}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText("Fake preview")).toBeDefined();
|
||||
});
|
||||
rerender(<MessageComposerUrlPreviewWrapper content="other-text" moduleApi={modApi} />);
|
||||
rerender(
|
||||
<MessageComposerUrlPreviewWrapper
|
||||
urlPreviewVm={getUrlPreviewVm(client, "no-longer-matching")}
|
||||
moduleApi={modApi}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(queryByText("Fake preview")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -5,62 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, type ReactNode } from "react";
|
||||
import { MessageComposerUrlPreviewView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
|
||||
import React, { type ReactNode } from "react";
|
||||
import { MessageComposerUrlPreviewView, useViewModel } from "@element-hq/web-shared-components";
|
||||
|
||||
import { MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
|
||||
import { type MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
|
||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext";
|
||||
import { useDebouncedCallback } from "../../../hooks/spotlight/useDebouncedCallback";
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
import { ModuleApi } from "../../../modules/Api";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
|
||||
export const DEBOUNCE_REQUEST_TIMEOUT_MS = 500;
|
||||
|
||||
export function MessageComposerUrlPreviewWrapper({
|
||||
content,
|
||||
urlPreviewVm: vm,
|
||||
moduleApi = ModuleApi.instance,
|
||||
}: {
|
||||
content: string;
|
||||
urlPreviewVm: MessageComposerUrlPreviewViewModel;
|
||||
moduleApi?: ModuleApi;
|
||||
}): ReactNode | null {
|
||||
const { showUrlPreview, roomId } = useScopedRoomContext("showUrlPreview", "roomId");
|
||||
const [customComponent, setCustomComponent] = useState<React.JSX.Element | null>(null);
|
||||
|
||||
const client = useMatrixClientContext();
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new MessageComposerUrlPreviewViewModel({
|
||||
client,
|
||||
visible: showUrlPreview,
|
||||
showTooltips: PlatformPeg.get()?.needsUrlTooltips() ?? true,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void vm.updateUrlPreviewVisible(showUrlPreview);
|
||||
}, [vm, showUrlPreview]);
|
||||
|
||||
// Rather than checking each time the text changes, we only do a URL check every 500ms to avoid
|
||||
// hitting the server too frequently. We also only check the module API for a custom component
|
||||
// at this frequency to avoid expensive calculations downstream.
|
||||
useDebouncedCallback<[MessageComposerUrlPreviewViewModel, string]>(
|
||||
true,
|
||||
(vm, content) => {
|
||||
const customComponent = moduleApi.customComponents.renderComposerPreview(
|
||||
{ text: content, roomId: roomId! },
|
||||
() => <MessageComposerUrlPreviewView vm={vm} />,
|
||||
);
|
||||
|
||||
setCustomComponent(customComponent);
|
||||
// We still update the VM even if the custom component is used since
|
||||
// the component may choose to render the original component.
|
||||
void vm.updateWithText(content);
|
||||
},
|
||||
[vm, content],
|
||||
// Update instantly if content is empty (e.g. sent message or cleared input)
|
||||
content ? DEBOUNCE_REQUEST_TIMEOUT_MS : 0,
|
||||
);
|
||||
const { roomId } = useScopedRoomContext("showUrlPreview", "roomId");
|
||||
const { content } = useViewModel(vm);
|
||||
const customComponent = moduleApi.customComponents.renderComposerPreview({ text: content, roomId: roomId! }, () => (
|
||||
<MessageComposerUrlPreviewView vm={vm} />
|
||||
));
|
||||
|
||||
return customComponent ?? <MessageComposerUrlPreviewView vm={vm} />;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
import { type DebouncedFunc, throttle } from "lodash";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { type Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
|
||||
import { type RoomMessageEventContent } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { type RoomMessageEventContent } from "../../../../@types/url-preview";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import EditorModel from "../../../editor/model";
|
||||
import {
|
||||
@@ -64,8 +64,9 @@ import { type Caret } from "../../../editor/caret";
|
||||
import { type IDiff } from "../../../editor/diff";
|
||||
import { getBlobSafeMimeType } from "../../../utils/blobs";
|
||||
import { EMOJI_REGEX } from "../../../HtmlUtils";
|
||||
import { attachMentions, attachRelation } from "../../../utils/messages";
|
||||
import { attachMentions, attachRelation, attachUrlPreviews } from "../../../utils/messages";
|
||||
import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel";
|
||||
import { type MessageComposerUrlPreviewViewModel } from "../../../viewmodels/composer/MessageComposerUrlPreviewViewModel";
|
||||
|
||||
// The prefix used when persisting editor drafts to localstorage.
|
||||
export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_";
|
||||
@@ -137,6 +138,7 @@ interface ISendMessageComposerProps extends MatrixClientProps {
|
||||
disabled?: boolean;
|
||||
onChange?(model: EditorModel): void;
|
||||
toggleStickerPickerOpen: () => void;
|
||||
urlPreviewVm: MessageComposerUrlPreviewViewModel;
|
||||
}
|
||||
|
||||
export class SendMessageComposer extends React.Component<ISendMessageComposerProps> {
|
||||
@@ -410,6 +412,21 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
this.sendQuickReaction();
|
||||
}
|
||||
|
||||
const clearComposerAndPushHistory = (): void => {
|
||||
this.sendHistoryManager.save(model, replyToEvent);
|
||||
// clear composer
|
||||
model.reset([]);
|
||||
this.editorRef.current?.clearUndoHistory();
|
||||
this.editorRef.current?.focus();
|
||||
this.clearStoredEditorState();
|
||||
if (shouldSend && SettingsStore.getValue("scrollToBottomOnMessageSent")) {
|
||||
dis.dispatch({
|
||||
action: "scroll_to_bottom",
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (shouldSend) {
|
||||
const { roomId } = this.props.room;
|
||||
if (!content) {
|
||||
@@ -420,9 +437,14 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
this.props.relation,
|
||||
);
|
||||
}
|
||||
|
||||
// don't bother sending an empty message
|
||||
if (!content.body.trim()) return;
|
||||
|
||||
// clear composer first so the user doesn't actually see the delay of attach URL preview image files
|
||||
clearComposerAndPushHistory();
|
||||
attachUrlPreviews(this.props.urlPreviewVm.getSnapshot(), content);
|
||||
|
||||
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
|
||||
decorateStartSendingTime(content);
|
||||
}
|
||||
@@ -460,19 +482,8 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
sendRoundTripMetric(this.props.mxClient, roomId, resp.event_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.sendHistoryManager.save(model, replyToEvent);
|
||||
// clear composer
|
||||
model.reset([]);
|
||||
this.editorRef.current?.clearUndoHistory();
|
||||
this.editorRef.current?.focus();
|
||||
this.clearStoredEditorState();
|
||||
if (shouldSend && SettingsStore.getValue("scrollToBottomOnMessageSent")) {
|
||||
dis.dispatch({
|
||||
action: "scroll_to_bottom",
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
});
|
||||
} else {
|
||||
clearComposerAndPushHistory();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
THREAD_RELATION_TYPE,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { type RoomMessageEventContent } from "matrix-js-sdk/src/types";
|
||||
import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components";
|
||||
|
||||
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
@@ -33,19 +34,20 @@ import { CommandCategories, getCommand } from "../../../../../slash-commands/Sla
|
||||
import { runSlashCommand, shouldSendAnyway } from "../../../../../editor/commands";
|
||||
import { Action } from "../../../../../dispatcher/actions";
|
||||
import { addReplyToMessageContent } from "../../../../../utils/Reply";
|
||||
import { attachRelation } from "../../../../../utils/messages";
|
||||
import { attachRelation, attachUrlPreviews } from "../../../../../utils/messages";
|
||||
|
||||
export interface SendMessageParams {
|
||||
mxClient: MatrixClient;
|
||||
relation?: IEventRelation;
|
||||
replyToEvent?: MatrixEvent;
|
||||
roomContext: Pick<IRoomState, "timelineRenderingType" | "room">;
|
||||
urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot;
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
message: string,
|
||||
isHTML: boolean,
|
||||
{ roomContext, mxClient, ...params }: SendMessageParams,
|
||||
{ roomContext, mxClient, urlPreviewSnapshot, ...params }: SendMessageParams,
|
||||
): Promise<ISendEventResponse | undefined> {
|
||||
const { relation, replyToEvent } = params;
|
||||
const { room } = roomContext;
|
||||
@@ -112,6 +114,7 @@ export async function sendMessage(
|
||||
|
||||
// if content is null, we haven't done any slash command processing, so generate some content
|
||||
content ??= await createMessageContent(message, isHTML, params);
|
||||
attachUrlPreviews(urlPreviewSnapshot, content);
|
||||
|
||||
// TODO replace emotion end of message ?
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { diffAtCaret, diffDeletion, type IDiff } from "./diff";
|
||||
import DocumentPosition, { type IPosition } from "./position";
|
||||
import Range from "./range";
|
||||
import { type SerializedPart, type Part, type PartCreator } from "./parts";
|
||||
import { type SerializedPart, type Part, type PartCreator, Type } from "./parts";
|
||||
import { type ICallback } from "./autocomplete";
|
||||
import type AutocompleteWrapperModel from "./autocomplete";
|
||||
import type DocumentOffset from "./offset";
|
||||
@@ -49,6 +49,16 @@ export default class EditorModel {
|
||||
private autoCompletePartCount = 0;
|
||||
private transformCallback: TransformCallback | null = null;
|
||||
|
||||
/**
|
||||
* Returns the plain text parts of the editor content only (skipping mentions)
|
||||
*/
|
||||
public get contentPlainText(): string {
|
||||
return this.serializeParts()
|
||||
.filter((part) => part.type === Type.Plain)
|
||||
.map((part) => part.text)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
public constructor(
|
||||
parts: Part[],
|
||||
partCreator: PartCreator,
|
||||
|
||||
@@ -1572,6 +1572,8 @@
|
||||
"sliding_sync_disabled_notice": "Sign in again to disable",
|
||||
"sliding_sync_server_no_support": "Your server lacks support",
|
||||
"unrealiable_e2e": "Unreliable in encrypted rooms",
|
||||
"url_preview_bundle": "URL Preview Bundle (MSC4095)",
|
||||
"url_preview_bundle_description": "Send URL previews in message alongside message content",
|
||||
"video_rooms": "Video rooms",
|
||||
"video_rooms_a_new_way_to_chat": "A new way to chat over voice and video in %(brand)s.",
|
||||
"video_rooms_always_on_voip_channels": "Video rooms are always-on VoIP channels embedded within a room in %(brand)s.",
|
||||
|
||||
@@ -229,6 +229,7 @@ export interface Settings {
|
||||
"feature_msc4362_encrypted_state_events": IFeature;
|
||||
"feature_user_status": IFeature;
|
||||
"feature_login_with_qr": IFeature;
|
||||
"feature_msc4095_url_preview_bundle": IFeature;
|
||||
// These are in the feature namespace but aren't actually features
|
||||
"feature_hidebold": IBaseSetting<boolean>;
|
||||
|
||||
@@ -641,6 +642,15 @@ export const SETTINGS: Settings = {
|
||||
isFeature: true,
|
||||
default: false,
|
||||
},
|
||||
"feature_msc4095_url_preview_bundle": {
|
||||
labsGroup: LabGroup.Messaging,
|
||||
displayName: _td("labs|url_preview_bundle"),
|
||||
description: _td("labs|url_preview_bundle_description"),
|
||||
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
|
||||
supportedLevelsAreOrdered: true,
|
||||
isFeature: true,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* With the transition to Compound we are moving to a base font size
|
||||
* of 16px. We're taking the opportunity to move away from the `baseFontSize`
|
||||
|
||||
@@ -160,11 +160,13 @@ export class UrlPreviewFetcher {
|
||||
let siteIcon: string | undefined;
|
||||
|
||||
if (typeof response["og:image"] === "string" && loadMedia) {
|
||||
const mxcImageFull = response["og:image"];
|
||||
const media = mediaFromMxc(response["og:image"], this.client);
|
||||
const declaredHeight = UrlPreviewFetcher.getNumberFromOpenGraph(response["og:image:height"]);
|
||||
const declaredWidth = UrlPreviewFetcher.getNumberFromOpenGraph(response["og:image:width"]);
|
||||
const imageSize = UrlPreviewFetcher.getNumberFromOpenGraph(response["matrix:image:size"]);
|
||||
const alt = typeof response["og:image:alt"] === "string" ? response["og:image:alt"] : undefined;
|
||||
const imageType = typeof response["og:image:type"] === "string" ? response["og:image:type"] : undefined;
|
||||
|
||||
if (UrlPreviewFetcher.isImagePreview(declaredWidth, declaredHeight, imageSize)) {
|
||||
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX);
|
||||
@@ -176,6 +178,8 @@ export class UrlPreviewFetcher {
|
||||
image = {
|
||||
imageThumb: thumb,
|
||||
imageFull: media.srcHttp ?? thumb,
|
||||
mxcImageFull,
|
||||
imageType,
|
||||
width,
|
||||
height,
|
||||
fileSize: UrlPreviewFetcher.getNumberFromOpenGraph(response["matrix:image:size"]),
|
||||
@@ -195,6 +199,7 @@ export class UrlPreviewFetcher {
|
||||
description,
|
||||
siteName,
|
||||
siteIcon,
|
||||
ogUrl: response["og:url"],
|
||||
showTooltipOnLink: !!(link !== title && this.showTooltips),
|
||||
image,
|
||||
} satisfies UrlPreview;
|
||||
|
||||
@@ -6,6 +6,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
@@ -19,6 +20,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Site name",
|
||||
@@ -32,6 +34,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
@@ -45,6 +48,7 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Cool site",
|
||||
@@ -62,10 +66,13 @@ exports[`UrlPreviewFetcher > handles different kinds of opengraph responses { 'o
|
||||
"height": 478,
|
||||
"imageFull": "https://example.org/image/src",
|
||||
"imageThumb": "https://example.org/image/thumb",
|
||||
"imageType": undefined,
|
||||
"mxcImageFull": "mxc://example.org/abc",
|
||||
"playable": false,
|
||||
"width": 478,
|
||||
},
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
@@ -83,10 +90,13 @@ exports[`UrlPreviewFetcher > should preview a URL with media 1`] = `
|
||||
"height": 128,
|
||||
"imageFull": "https://example.org/image/src",
|
||||
"imageThumb": "https://example.org/image/thumb",
|
||||
"imageType": undefined,
|
||||
"mxcImageFull": "mxc://example.org/abc",
|
||||
"playable": false,
|
||||
"width": 128,
|
||||
},
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
|
||||
@@ -6,9 +6,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixEvent, type IContent, type IMentions, type IEventRelation } from "matrix-js-sdk/src/matrix";
|
||||
import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components";
|
||||
|
||||
import type EditorModel from "../editor/model";
|
||||
import { Type } from "../editor/parts";
|
||||
import { type RoomMessageEventContent } from "../../@types/url-preview";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
|
||||
/**
|
||||
* Build the mentions information based on the editor model (and any related events):
|
||||
@@ -107,3 +110,25 @@ export function attachRelation(content: IContent, relation?: IEventRelation): vo
|
||||
};
|
||||
}
|
||||
}
|
||||
export function attachUrlPreviews(
|
||||
urlPreviewSnapshot: MessageComposerUrlPreviewSnapshot,
|
||||
content: RoomMessageEventContent,
|
||||
): void {
|
||||
if (!SettingsStore.getValue("feature_msc4095_url_preview_bundle")) return;
|
||||
|
||||
if (urlPreviewSnapshot.previews.length) {
|
||||
content["com.beeper.linkpreviews"] = urlPreviewSnapshot.previews.map((preview) => {
|
||||
return {
|
||||
"matched_url": preview.link,
|
||||
"og:url": preview.ogUrl,
|
||||
"og:title": preview.title,
|
||||
"og:description": preview.description,
|
||||
"og:image": preview.image?.mxcImageFull,
|
||||
"og:image:width": preview.image?.width,
|
||||
"og:image:height": preview.image?.height,
|
||||
"og:image:type": preview.image?.imageType,
|
||||
"matrix:image:size": preview.image?.fileSize,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,15 @@ function getViewModel({ visible } = { visible: true }): {
|
||||
getUrlPreview: vi.fn(),
|
||||
mxcUrlToHttp: vi.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
const vm = new MessageComposerUrlPreviewViewModel({ client, visible, showTooltips: false });
|
||||
const vm = new MessageComposerUrlPreviewViewModel({
|
||||
client,
|
||||
visible,
|
||||
showTooltips: false,
|
||||
urlPreviewBundle: false,
|
||||
});
|
||||
return { vm, client: client as unknown as { getUrlPreview: Mock; mxcUrlToHttp: Mock } };
|
||||
}
|
||||
|
||||
describe("MessageComposerUrlPreviewViewModel", () => {
|
||||
let originalDevicePixelRatio: Window["devicePixelRatio"];
|
||||
beforeAll(() => {
|
||||
@@ -42,73 +48,78 @@ describe("MessageComposerUrlPreviewViewModel", () => {
|
||||
|
||||
it("should return no preview by default", () => {
|
||||
expect(getViewModel().vm.getSnapshot()).toMatchInlineSnapshot(`
|
||||
{
|
||||
"preview": null,
|
||||
}
|
||||
`);
|
||||
{
|
||||
"content": "",
|
||||
"previews": [],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should preview a valid URL in text", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
|
||||
await vm.updateWithText("Check out https://example.org today");
|
||||
await vm.updateWithText({ content: "Check out https://example.org today", debounced: false });
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return null when preview is not visible", async () => {
|
||||
it("should return empty list when preview is not visible", async () => {
|
||||
const { vm, client } = getViewModel({ visible: false });
|
||||
await vm.updateWithText("https://example.org");
|
||||
expect(vm.getSnapshot().preview).toBeNull();
|
||||
await vm.updateWithText({ content: "https://example.org", debounced: false });
|
||||
expect(vm.getSnapshot().previews).toHaveLength(0);
|
||||
expect(client.getUrlPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return null when all URL fetches fail", async () => {
|
||||
it("should return empty list when all URL fetches fail", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockRejectedValue(new Error("Forced test failure"));
|
||||
await vm.updateWithText("https://example.org");
|
||||
expect(vm.getSnapshot().preview).toBeNull();
|
||||
await vm.updateWithText({ content: "https://example.org", debounced: false });
|
||||
expect(vm.getSnapshot().previews).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should use the first URL with a valid preview when multiple are given", async () => {
|
||||
it("should use all URLs with a valid preview when multiple are given", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview
|
||||
.mockRejectedValueOnce(new Error("First URL failed"))
|
||||
.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
|
||||
await vm.updateWithText("https://example.org/one https://example.org/two");
|
||||
expect(vm.getSnapshot().preview?.link).toEqual("https://example.org/two");
|
||||
await vm.updateWithText({ content: "https://example.org/one https://example.org/two", debounced: false });
|
||||
expect(vm.getSnapshot().previews[0]?.link).toEqual("https://example.org/two");
|
||||
expect(vm.getSnapshot().previews).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should not re-fetch when text changes but the URL set does not", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA);
|
||||
await vm.updateWithText("https://example.org");
|
||||
await vm.updateWithText("https://example.org some extra words");
|
||||
await vm.updateWithText({ content: "https://example.org", debounced: false });
|
||||
await vm.updateWithText({ content: "https://example.org some extra words", debounced: false });
|
||||
expect(client.getUrlPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should deduplicate repeated URLs", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA);
|
||||
await vm.updateWithText("https://example.org https://example.org https://example.org");
|
||||
await vm.updateWithText({
|
||||
content: "https://example.org https://example.org https://example.org",
|
||||
debounced: false,
|
||||
});
|
||||
expect(client.getUrlPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should hide preview when made invisible", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA);
|
||||
await vm.updateWithText("https://example.org");
|
||||
expect(vm.getSnapshot().preview).not.toBeNull();
|
||||
await vm.updateWithText({ content: "https://example.org", debounced: false });
|
||||
expect(vm.getSnapshot().previews).not.toHaveLength(0);
|
||||
await vm.updateUrlPreviewVisible(false);
|
||||
expect(vm.getSnapshot().preview).toBeNull();
|
||||
expect(vm.getSnapshot().previews).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should restore preview when made visible again", async () => {
|
||||
const { vm, client } = getViewModel({ visible: false });
|
||||
client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA);
|
||||
await vm.updateWithText("https://example.org");
|
||||
expect(vm.getSnapshot().preview).toBeNull();
|
||||
await vm.updateWithText({ content: "https://example.org", debounced: false });
|
||||
expect(vm.getSnapshot().previews).toHaveLength(0);
|
||||
await vm.updateUrlPreviewVisible(true);
|
||||
expect(vm.getSnapshot().preview).not.toBeNull();
|
||||
expect(vm.getSnapshot().previews).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should preview a URL with media", async () => {
|
||||
@@ -128,7 +139,7 @@ describe("MessageComposerUrlPreviewViewModel", () => {
|
||||
if (width) return "https://example.org/image/thumb";
|
||||
return "https://example.org/image/src";
|
||||
});
|
||||
await vm.updateWithText("https://example.org");
|
||||
await vm.updateWithText({ content: "https://example.org", debounced: false });
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,15 +8,20 @@
|
||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { BaseViewModel, type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
import { UrlPreviewFetcher } from "../../utils/UrlPreviewFetcher";
|
||||
|
||||
const logger = rootLogger.getChild("MessageComposerUrlPreviewViewModel");
|
||||
|
||||
export const DEBOUNCE_REQUEST_TIMEOUT_MS = 500;
|
||||
|
||||
export interface MessageComposerUrlPreviewViewModelProps {
|
||||
client: MatrixClient;
|
||||
visible: boolean;
|
||||
showTooltips: boolean;
|
||||
urlPreviewBundle: boolean;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export class MessageComposerUrlPreviewViewModel extends BaseViewModel<
|
||||
@@ -27,6 +32,9 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel<
|
||||
|
||||
/**
|
||||
* Calculated set of links from the message text.
|
||||
*
|
||||
* Links are inserted in the order they appear in the message text,
|
||||
* which guarantees Array.from(this.links) to be in the same order.
|
||||
*/
|
||||
private links: Set<string> = new Set();
|
||||
|
||||
@@ -35,52 +43,91 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel<
|
||||
*/
|
||||
private urlPreviewVisible: boolean;
|
||||
|
||||
private content: string;
|
||||
|
||||
public constructor(props: MessageComposerUrlPreviewViewModelProps) {
|
||||
super(props, { preview: null });
|
||||
super(props, { previews: [], content: props.content ?? "" });
|
||||
this.urlPreviewVisible = props.visible;
|
||||
this.fetcher = new UrlPreviewFetcher(props.client, Date.now(), props.showTooltips);
|
||||
this.content = this.snapshot.current.content;
|
||||
}
|
||||
|
||||
private async computeSnapshot(): Promise<void> {
|
||||
private async computeSnapshot(content: string): Promise<void> {
|
||||
const newLinksOrdered = content
|
||||
.split(" ")
|
||||
.map((w) => w.trim())
|
||||
.filter((word) => URL.canParse(word));
|
||||
const newLinks = new Set(newLinksOrdered);
|
||||
|
||||
if (!this.urlPreviewVisible) {
|
||||
this.snapshot.set({ preview: null });
|
||||
// Clear any existing previews whenever previews are hidden, regardless of
|
||||
// whether the URL set has changed (e.g. when toggled invisible).
|
||||
this.snapshot.set({ previews: [], content });
|
||||
return;
|
||||
}
|
||||
// We always select the *first* viable preview out of the message.
|
||||
// Subsequent links are ignored.
|
||||
for (const link of this.links) {
|
||||
try {
|
||||
const preview = await this.fetcher.fetchPreview(link, true);
|
||||
if (preview) {
|
||||
this.snapshot.set({ preview });
|
||||
return;
|
||||
|
||||
if (this.links.symmetricDifference(newLinks).size === 0) {
|
||||
// Skip if the URL set hasn't changed
|
||||
return;
|
||||
}
|
||||
|
||||
this.links = newLinks;
|
||||
|
||||
let previews;
|
||||
if (this.props.urlPreviewBundle) {
|
||||
const previewRequests = Array.from(this.links).map(async (link) => {
|
||||
try {
|
||||
return await this.fetcher.fetchPreview(link, true);
|
||||
} catch (ex) {
|
||||
logger.warn("Fetching preview failed", ex);
|
||||
return null;
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.warn("Fetching preview failed", ex);
|
||||
});
|
||||
|
||||
// Fetch previews for all links in the message text,
|
||||
// And remove the ones with erroneous responses
|
||||
const previewResponses = await Promise.all(previewRequests);
|
||||
previews = previewResponses.filter((res) => res !== null);
|
||||
|
||||
this.snapshot.set({ previews, content });
|
||||
} else {
|
||||
for (const link of this.links) {
|
||||
try {
|
||||
const preview = await this.fetcher.fetchPreview(link, true);
|
||||
if (preview) {
|
||||
this.snapshot.set({ previews: [preview], content });
|
||||
return;
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.warn("Fetching preview failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
this.snapshot.set({ previews: [], content });
|
||||
}
|
||||
this.snapshot.set({ preview: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a recalculation of the links in the provided text.
|
||||
* @param content Plaintext from the message composer.
|
||||
*/
|
||||
public async updateWithText(content: string): Promise<void> {
|
||||
const newLinks = new Set(
|
||||
content
|
||||
.split(" ")
|
||||
.map((w) => w.trim())
|
||||
.filter((word) => URL.canParse(word)),
|
||||
);
|
||||
if (this.links.symmetricDifference(newLinks).size === 0) {
|
||||
// Skip if the URL set hasn't changed
|
||||
return;
|
||||
public async updateWithText({ content, debounced }: { content?: string; debounced: boolean }): Promise<void> {
|
||||
if (content !== undefined) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
if (debounced) {
|
||||
return this.computeSnapshotDebounced(this.content);
|
||||
} else {
|
||||
return this.computeSnapshot(this.content);
|
||||
}
|
||||
this.links = newLinks;
|
||||
return this.computeSnapshot();
|
||||
}
|
||||
|
||||
private computeSnapshotDebounced = debounce(
|
||||
(content) => this.computeSnapshot(content),
|
||||
DEBOUNCE_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
/**
|
||||
* Update the view model about visible state of previews.
|
||||
* @param urlPreviewVisible Whether URL previews are hidden for this room.
|
||||
@@ -90,6 +137,6 @@ export class MessageComposerUrlPreviewViewModel extends BaseViewModel<
|
||||
public readonly updateUrlPreviewVisible = (urlPreviewVisible: boolean): Promise<void> => {
|
||||
this.urlPreviewVisible = urlPreviewVisible;
|
||||
this.fetcher.clearCache();
|
||||
return this.computeSnapshot();
|
||||
return this.computeSnapshot(this.content);
|
||||
};
|
||||
}
|
||||
|
||||
+37
-27
@@ -2,38 +2,48 @@
|
||||
|
||||
exports[`MessageComposerUrlPreviewViewModel > should preview a URL with media 1`] = `
|
||||
{
|
||||
"preview": {
|
||||
"author": undefined,
|
||||
"description": undefined,
|
||||
"image": {
|
||||
"alt": undefined,
|
||||
"fileSize": 10000,
|
||||
"height": 128,
|
||||
"imageFull": "https://example.org/image/src",
|
||||
"imageThumb": "https://example.org/image/thumb",
|
||||
"playable": false,
|
||||
"width": 128,
|
||||
"content": "https://example.org",
|
||||
"previews": [
|
||||
{
|
||||
"author": undefined,
|
||||
"description": undefined,
|
||||
"image": {
|
||||
"alt": undefined,
|
||||
"fileSize": 10000,
|
||||
"height": 128,
|
||||
"imageFull": "https://example.org/image/src",
|
||||
"imageThumb": "https://example.org/image/thumb",
|
||||
"imageType": undefined,
|
||||
"mxcImageFull": "mxc://example.org/abc",
|
||||
"playable": false,
|
||||
"width": 128,
|
||||
},
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
"title": "Media example",
|
||||
},
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
"title": "Media example",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`MessageComposerUrlPreviewViewModel > should preview a valid URL in text 1`] = `
|
||||
{
|
||||
"preview": {
|
||||
"author": undefined,
|
||||
"description": "This is a description",
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Example.org",
|
||||
"title": "This is an example!",
|
||||
},
|
||||
"content": "Check out https://example.org today",
|
||||
"previews": [
|
||||
{
|
||||
"author": undefined,
|
||||
"description": "This is a description",
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Example.org",
|
||||
"title": "This is an example!",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
+27
-2
@@ -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();
|
||||
|
||||
@@ -7,11 +7,84 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type IContent } from "matrix-js-sdk/src/matrix";
|
||||
import { type MessageComposerUrlPreviewSnapshot } from "@element-hq/web-shared-components";
|
||||
|
||||
import { attachMentions } from "../../../src/utils/messages";
|
||||
import { attachMentions, attachUrlPreviews } from "../../../src/utils/messages";
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import { mkEvent } from "../../test-utils";
|
||||
import { createPartCreator } from "../editor/mock";
|
||||
import { type RoomMessageEventContent } from "../../../@types/url-preview";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
|
||||
describe("attachUrlPreviews", () => {
|
||||
beforeEach(() => {
|
||||
const original = SettingsStore.getValue;
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
||||
(setting) => setting === "feature_msc4095_url_preview_bundle" || original(setting),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const makeContent = (): RoomMessageEventContent =>
|
||||
({ msgtype: "m.text", body: "hi https://example.com" }) as RoomMessageEventContent;
|
||||
|
||||
const snapshot = (image?: object): MessageComposerUrlPreviewSnapshot => ({
|
||||
previews: [
|
||||
{
|
||||
link: "https://example.com",
|
||||
showTooltipOnLink: false,
|
||||
title: "Example",
|
||||
siteName: "example.com",
|
||||
description: "desc",
|
||||
...(image ? { image } : {}),
|
||||
} as any,
|
||||
],
|
||||
content: "https://example.com",
|
||||
});
|
||||
|
||||
it("does nothing when there are no previews", () => {
|
||||
const content = makeContent();
|
||||
attachUrlPreviews({ previews: [], content: "" }, content);
|
||||
expect(content["com.beeper.linkpreviews"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("attaches a preview with no image", () => {
|
||||
const content = makeContent();
|
||||
attachUrlPreviews(snapshot(), content);
|
||||
expect(content["com.beeper.linkpreviews"]).toEqual([
|
||||
expect.objectContaining({ "og:title": "Example", "og:image": undefined }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("embeds the mxc url from the preview image", () => {
|
||||
const content = makeContent();
|
||||
attachUrlPreviews(
|
||||
snapshot({
|
||||
imageThumb: "",
|
||||
imageFull: "https://example.com/full.png",
|
||||
mxcImageFull: "mxc://server/img",
|
||||
imageType: "image/png",
|
||||
fileSize: 1234,
|
||||
width: 100,
|
||||
height: 50,
|
||||
playable: false,
|
||||
}),
|
||||
content,
|
||||
);
|
||||
|
||||
expect(content["com.beeper.linkpreviews"]![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
"og:image": "mxc://server/img",
|
||||
"og:image:width": 100,
|
||||
"og:image:type": "image/png",
|
||||
"matrix:image:size": 1234,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachMentions", () => {
|
||||
const partsCreator = createPartCreator();
|
||||
|
||||
+4
@@ -9,6 +9,7 @@ exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the sa
|
||||
"description": "This is a description",
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Example.org",
|
||||
@@ -38,6 +39,7 @@ exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the us
|
||||
"description": "This is a description",
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Example.org",
|
||||
@@ -76,6 +78,7 @@ exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
@@ -96,6 +99,7 @@ exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = `
|
||||
"description": "This is a description",
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"ogUrl": "https://example.org",
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Example.org",
|
||||
|
||||
Reference in New Issue
Block a user