feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
@@ -0,0 +1,100 @@
/*
* 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 { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api";
import { DecryptionFailureReason } from "@element-hq/web-shared-components";
import { DecryptionFailureBodyViewModel } from "../../../src/viewmodels/room/timeline/event-tile/body/DecryptionFailureBodyViewModel";
describe("DecryptionFailureBodyViewModel", () => {
it("should return the snapshot", () => {
const vm = new DecryptionFailureBodyViewModel({
decryptionFailureCode: null,
verificationState: true,
});
expect(vm.getSnapshot()).toMatchObject({
decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
isLocalDeviceVerified: true,
});
});
it.each([
{
code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
reason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
},
{
code: DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
reason: DecryptionFailureReason.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
},
{
code: DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED,
reason: DecryptionFailureReason.HISTORICAL_MESSAGE_USER_NOT_JOINED,
},
{
code: DecryptionFailureCode.MEGOLM_KEY_WITHHELD,
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
},
{
code: DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
reason: DecryptionFailureReason.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
},
{
code: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
},
{
code: DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
},
{
code: DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED,
reason: DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED,
},
{
code: DecryptionFailureCode.UNKNOWN_ERROR,
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
},
{
code: DecryptionFailureCode.UNKNOWN_SENDER_DEVICE,
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
},
{
code: DecryptionFailureCode.UNSIGNED_SENDER_DEVICE,
reason: DecryptionFailureReason.UNSIGNED_SENDER_DEVICE,
},
])("should return the snapshot with code converted to reason (%s)", ({ code, reason }) => {
const vm = new DecryptionFailureBodyViewModel({
decryptionFailureCode: code,
});
expect(vm.getSnapshot().decryptionFailureReason).toBe(reason);
});
it("should update snapshot when setProps is called with new verificationState", () => {
const vm = new DecryptionFailureBodyViewModel({
decryptionFailureCode: DecryptionFailureCode.UNKNOWN_ERROR,
verificationState: false,
});
expect(vm.getSnapshot().isLocalDeviceVerified).toBe(false);
vm.setVerificationState(true);
expect(vm.getSnapshot().isLocalDeviceVerified).toBe(true);
});
it("should update snapshot when decryption failure code changes", () => {
const vm = new DecryptionFailureBodyViewModel({
decryptionFailureCode: DecryptionFailureCode.UNKNOWN_ERROR,
});
expect(vm.getSnapshot().decryptionFailureReason).toBe(DecryptionFailureReason.UNABLE_TO_DECRYPT);
vm.setDecryptionFailureCode(DecryptionFailureCode.UNSIGNED_SENDER_DEVICE);
expect(vm.getSnapshot().decryptionFailureReason).toBe(DecryptionFailureReason.UNSIGNED_SENDER_DEVICE);
});
});
@@ -0,0 +1,82 @@
/*
* 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 { ActionBarAction } from "@element-hq/web-shared-components";
import { EditHistoryActionBarViewModel } from "../../../src/viewmodels/message-body/EditHistoryActionBarViewModel";
describe("EditHistoryActionBarViewModel", () => {
it("builds a label snapshot with remove and view source actions", () => {
const vm = new EditHistoryActionBarViewModel({
canRemove: true,
showViewSource: true,
});
expect(vm.getSnapshot()).toMatchObject({
actions: [ActionBarAction.Remove, ActionBarAction.ViewSource],
presentation: "label",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
});
it("omits actions that are disabled by props", () => {
const vm = new EditHistoryActionBarViewModel({
canRemove: false,
showViewSource: false,
});
expect(vm.getSnapshot().actions).toEqual([]);
});
it("updates the snapshot when props change", () => {
const vm = new EditHistoryActionBarViewModel({
canRemove: false,
showViewSource: true,
});
expect(vm.getSnapshot().actions).toEqual([ActionBarAction.ViewSource]);
vm.setProps({
canRemove: true,
showViewSource: false,
});
expect(vm.getSnapshot().actions).toEqual([ActionBarAction.Remove]);
});
it("forwards remove clicks to props", () => {
const onRemoveClick = jest.fn();
const vm = new EditHistoryActionBarViewModel({
canRemove: true,
showViewSource: false,
onRemoveClick,
});
const anchor = document.createElement("button");
vm.onRemoveClick(anchor);
expect(onRemoveClick).toHaveBeenCalledWith(anchor);
});
it("forwards view source clicks to props", () => {
const onViewSourceClick = jest.fn();
const vm = new EditHistoryActionBarViewModel({
canRemove: false,
showViewSource: true,
onViewSourceClick,
});
const anchor = document.createElement("button");
vm.onViewSourceClick(anchor);
expect(onViewSourceClick).toHaveBeenCalledWith(anchor);
});
});
@@ -0,0 +1,373 @@
/*
* 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 { MsgType, PushRuleKind, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
import { type JSX } from "react";
import {
EventContentBodyViewModel,
type EventContentBodyViewModelProps,
} from "../../../src/viewmodels/message-body/EventContentBodyViewModel";
import { stubClient, mkStubRoom, mkEvent } from "../../test-utils";
import { bodyToNode } from "../../../src/HtmlUtils";
import {
combineRenderers,
mentionPillRenderer,
keywordPillRenderer,
ambiguousLinkTooltipRenderer,
spoilerRenderer,
codeBlockRenderer,
} from "../../../src/renderer";
import PlatformPeg from "../../../src/PlatformPeg";
import type BasePlatform from "../../../src/BasePlatform";
import SettingsStore from "../../../src/settings/SettingsStore";
jest.mock("../../../src/HtmlUtils", () => ({
...jest.requireActual("../../../src/HtmlUtils"),
bodyToNode: jest.fn(),
}));
jest.mock("../../../src/renderer", () => ({
combineRenderers: jest.fn(),
mentionPillRenderer: jest.fn(),
keywordPillRenderer: jest.fn(),
ambiguousLinkTooltipRenderer: jest.fn(),
codeBlockRenderer: jest.fn(),
spoilerRenderer: jest.fn(),
}));
jest.mock("../../../src/PlatformPeg", () => ({
__esModule: true,
default: {
get: jest.fn(),
},
}));
const mockedBodyToNode = jest.mocked(bodyToNode);
const mockedCombineRenderers = jest.mocked(combineRenderers);
const mockedPlatformPeg = jest.mocked(PlatformPeg);
describe("EventContentBodyViewModel", () => {
const defaultContent = {
body: "Hello world",
msgtype: MsgType.Text,
};
const defaultProps = (overrides: Partial<EventContentBodyViewModelProps> = {}): EventContentBodyViewModelProps => ({
client: null,
content: defaultContent,
linkify: false,
as: "span",
...overrides,
});
beforeEach(() => {
mockedBodyToNode.mockReset();
mockedCombineRenderers.mockReset();
mockedPlatformPeg.get.mockReset();
mockedPlatformPeg.get.mockReturnValue(null);
});
it("passes render options to bodyToNode", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(
defaultProps({
linkify: true,
stripReply: true,
enableBigEmoji: true,
mediaIsVisible: false,
}),
);
const snapshot = vm.getSnapshot();
expect(mockedBodyToNode).toHaveBeenCalledWith(defaultContent, undefined, {
disableBigEmoji: false,
stripReplyFallback: true,
mediaIsVisible: false,
linkify: true,
});
expect(snapshot.body).toBe("Hello world");
expect(snapshot.replacer).toBe(replacer);
expect(snapshot.className).toContain("mx_EventTile_body");
});
it("initializes setting-backed options from SettingsStore when omitted", () => {
const replacer = jest.fn();
const createReplacerFromOptions = jest.fn().mockReturnValue(replacer);
mockedCombineRenderers.mockReturnValue(createReplacerFromOptions);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const getValueSpy = jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "TextualBody.enableBigEmoji") return false;
if (settingName === "Pill.shouldShowPillAvatar") return false;
return true;
});
new EventContentBodyViewModel(defaultProps());
expect(getValueSpy).toHaveBeenCalledWith("TextualBody.enableBigEmoji");
expect(getValueSpy).toHaveBeenCalledWith("Pill.shouldShowPillAvatar");
expect(mockedBodyToNode).toHaveBeenCalledWith(
defaultContent,
undefined,
expect.objectContaining({ disableBigEmoji: true }),
);
expect(mockedCombineRenderers).toHaveBeenCalledWith();
expect(createReplacerFromOptions).toHaveBeenCalledWith(
expect.objectContaining({ shouldShowPillAvatar: false }),
);
getValueSpy.mockRestore();
});
it("uses the injected client to resolve the room for renderer context", () => {
const replacer = jest.fn();
const createReplacerFromOptions = jest.fn().mockReturnValue(replacer);
mockedCombineRenderers.mockReturnValue(createReplacerFromOptions);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const client = stubClient();
const mxEvent = mkEvent({
type: "m.room.message",
room: "!room:example.org",
user: "@user:example.org",
content: defaultContent,
event: true,
});
const room = mkStubRoom("!room:example.org", "Room", client) as Room;
const getRoomSpy = jest.spyOn(client, "getRoom").mockReturnValue(room);
new EventContentBodyViewModel(defaultProps({ mxEvent, client }));
expect(getRoomSpy).toHaveBeenCalledWith("!room:example.org");
expect(createReplacerFromOptions).toHaveBeenCalledWith(expect.objectContaining({ room }));
});
it("forces disableBigEmoji for emote events", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Emote",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
new EventContentBodyViewModel(
defaultProps({
content: {
body: "Emote",
msgtype: MsgType.Emote,
},
enableBigEmoji: true,
}),
);
expect(mockedBodyToNode).toHaveBeenCalledWith(
{ body: "Emote", msgtype: MsgType.Emote },
undefined,
expect.objectContaining({ disableBigEmoji: true }),
);
});
it("uses parse when formattedBody is provided", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: "<b>Hello</b>",
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
const snapshot = vm.getSnapshot();
expect(snapshot.formattedBody).toBe("<b>Hello</b>");
expect(snapshot.body).toBe("Hello world");
expect(snapshot.replacer).toBe(replacer);
});
it("uses emojiBodyElements when provided", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
const emojiElements = ["emoji"] as unknown as JSX.Element[];
mockedBodyToNode.mockReturnValue({
strippedBody: "ignored",
formattedBody: undefined,
emojiBodyElements: emojiElements,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
expect(vm.getSnapshot().body).toBe(emojiElements);
expect(vm.getSnapshot().replacer).toBe(replacer);
});
it("sets dir to auto for div elements even when includeDir is false", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps({ as: "div", includeDir: false }));
expect(vm.getSnapshot().dir).toBe("auto");
});
it("omits dir when includeDir is false on span elements", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps({ as: "span", includeDir: false }));
expect(vm.getSnapshot().dir).toBeUndefined();
});
it("updates snapshot when setEventContent changes content", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Initial",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
expect(vm.getSnapshot().body).toBe("Initial");
mockedBodyToNode.mockReturnValue({
strippedBody: "Updated",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
vm.setEventContent(undefined, { body: "Updated", msgtype: MsgType.Text });
expect(vm.getSnapshot().body).toBe("Updated");
});
it("doesn't emit updates when setters are called with unchanged values", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Initial",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
const vm = new EventContentBodyViewModel(defaultProps());
const previousSnapshot = vm.getSnapshot();
const subscriber = jest.fn();
vm.subscribe(subscriber);
vm.setEventContent(undefined, defaultContent);
vm.setAs("span");
expect(subscriber).toHaveBeenCalledTimes(0);
expect(vm.getSnapshot()).toEqual(previousSnapshot);
});
it("includes renderers based on options and platform capabilities", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
mockedPlatformPeg.get.mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform);
const client = stubClient();
const mxEvent = mkEvent({
type: "m.room.message",
room: "!room:example.org",
user: "@user:example.org",
content: defaultContent,
event: true,
});
jest.spyOn(mxEvent, "getPushDetails").mockReturnValue({
rule: {
enabled: true,
kind: PushRuleKind.ContentSpecific,
pattern: "Hello",
},
} as unknown as ReturnType<MatrixEvent["getPushDetails"]>);
jest.spyOn(client, "getRoom").mockReturnValue(mkStubRoom("!room:example.org", "Room", client) as Room);
new EventContentBodyViewModel(
defaultProps({
renderMentionPills: true,
renderKeywordPills: true,
renderTooltipsForAmbiguousLinks: true,
renderSpoilers: true,
renderCodeBlocks: true,
mxEvent,
}),
);
expect(mockedCombineRenderers).toHaveBeenCalledWith(
mentionPillRenderer,
keywordPillRenderer,
ambiguousLinkTooltipRenderer,
spoilerRenderer,
codeBlockRenderer,
);
});
it("skips tooltip renderer when platform does not need URL tooltips", () => {
const replacer = jest.fn();
mockedCombineRenderers.mockReturnValue(() => replacer);
mockedBodyToNode.mockReturnValue({
strippedBody: "Hello world",
formattedBody: undefined,
emojiBodyElements: undefined,
className: "mx_EventTile_body",
});
mockedPlatformPeg.get.mockReturnValue({ needsUrlTooltips: () => false } as unknown as BasePlatform);
new EventContentBodyViewModel(
defaultProps({
renderMentionPills: true,
renderTooltipsForAmbiguousLinks: true,
}),
);
expect(mockedCombineRenderers).toHaveBeenCalledWith(mentionPillRenderer);
});
});
@@ -0,0 +1,306 @@
/*
* 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 { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { createRef, type RefObject } from "react";
import { FileBodyViewInfoIcon, FileBodyViewState } from "@element-hq/web-shared-components";
import Modal from "../../../src/Modal";
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { type MediaEventHelper } from "../../../src/utils/MediaEventHelper";
import { FileBodyViewModel } from "../../../src/viewmodels/message-body/FileBodyViewModel";
import ErrorDialog from "../../../src/components/views/dialogs/ErrorDialog";
const mockDownload = jest.fn();
jest.mock("../../../src/utils/FileDownloader", () => ({
FileDownloader: jest.fn().mockImplementation(() => ({
download: mockDownload,
})),
}));
jest.mock("../../../src/customisations/Media", () => ({
mediaFromContent: jest.fn((content: { file?: unknown; url?: string }) => ({
isEncrypted: !!content.file,
srcHttp: content.url ?? null,
})),
}));
describe("FileBodyViewModel", () => {
const mkMediaEvent = (
content: Partial<{ body: string; msgtype: string; url: string; file: Record<string, unknown> }>,
): MatrixEvent =>
new MatrixEvent({
room_id: "!room:server",
sender: "@user:server",
type: EventType.RoomMessage,
content: {
body: "alt",
msgtype: "m.file",
url: "https://server/file",
...content,
},
});
const mkMediaEventHelper = ({
encrypted,
blob = new Blob(["content"], { type: "text/plain" }),
fileName = "file.txt",
}: {
encrypted: boolean;
blob?: Blob;
fileName?: string;
}): MediaEventHelper =>
({
media: { isEncrypted: encrypted },
sourceBlob: { value: Promise.resolve(blob) },
fileName,
}) as unknown as MediaEventHelper;
const createVm = (overrides: Partial<ConstructorParameters<typeof FileBodyViewModel>[0]> = {}): FileBodyViewModel =>
new FileBodyViewModel({
mxEvent: mkMediaEvent({}),
mediaEventHelper: mkMediaEventHelper({ encrypted: false }),
showFileInfo: false,
forExport: false,
timelineRenderingType: TimelineRenderingType.File,
refIFrame: createRef<HTMLIFrameElement>() as RefObject<HTMLIFrameElement>,
refLink: createRef<HTMLAnchorElement>() as RefObject<HTMLAnchorElement>,
...overrides,
});
beforeEach(() => {
jest.clearAllMocks();
});
it("shows unencrypted download snapshot in file rendering type", () => {
const vm = createVm();
expect(vm.getSnapshot()).toMatchObject({
state: FileBodyViewState.UNENCRYPTED,
showInfo: false,
showDownload: true,
downloadHref: "https://server/file",
});
});
it.each([
{ msgtype: "m.file", expectedIcon: FileBodyViewInfoIcon.ATTACHMENT },
{ msgtype: "m.audio", expectedIcon: FileBodyViewInfoIcon.AUDIO },
{ msgtype: "m.video", expectedIcon: FileBodyViewInfoIcon.VIDEO },
])("shows generic placeholder info for $msgtype", ({ msgtype, expectedIcon }) => {
const vm = createVm({
mxEvent: mkMediaEvent({ msgtype }),
showFileInfo: true,
});
expect(vm.getSnapshot()).toMatchObject({
state: FileBodyViewState.UNENCRYPTED,
showInfo: true,
infoLabel: "alt",
infoIcon: expectedIcon,
showDownload: false,
});
});
it("shows export snapshot with export href", () => {
const vm = createVm({
forExport: true,
showFileInfo: true,
mxEvent: mkMediaEvent({ url: "https://server/export-file" }),
});
expect(vm.getSnapshot()).toMatchObject({
state: FileBodyViewState.EXPORT,
showInfo: true,
infoLabel: "alt",
infoHref: "https://server/export-file",
});
});
it("downloads unencrypted placeholder content on info click", async () => {
const blob = new Blob(["placeholder"], { type: "text/plain" });
const vm = createVm({
showFileInfo: true,
mediaEventHelper: mkMediaEventHelper({ encrypted: false, blob, fileName: "placeholder.txt" }),
});
await vm.onInfoClick();
expect(mockDownload).toHaveBeenCalledWith({
blob,
name: "placeholder.txt",
});
});
it("decrypts encrypted content and downloads on iframe load", async () => {
const blob = new Blob(["encrypted"], { type: "application/octet-stream" });
const vm = createVm({
mediaEventHelper: mkMediaEventHelper({ encrypted: true, blob, fileName: "encrypted.bin" }),
mxEvent: mkMediaEvent({ file: { url: "mxc://server/file" } }),
});
await vm.onDownloadClick();
expect(vm.getSnapshot().state).toBe(FileBodyViewState.ENCRYPTED);
vm.onDownloadIframeLoad();
expect(mockDownload).toHaveBeenCalledWith(
expect.objectContaining({
blob,
name: "encrypted.bin",
autoDownload: true,
opts: expect.objectContaining({
textContent: expect.any(String),
}),
}),
);
});
it("downloads unencrypted source as blob in onDownloadLinkClick", async () => {
const blob = new Blob(["direct-download"], { type: "text/plain" });
const vm = createVm({
mediaEventHelper: mkMediaEventHelper({ encrypted: false, blob, fileName: "direct.txt" }),
mxEvent: mkMediaEvent({ msgtype: "m.file", url: "https://server/direct.txt" }),
});
const click = jest.spyOn(HTMLAnchorElement.prototype, "click");
const event = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
} as any;
vm.onDownloadLinkClick(event);
await Promise.resolve();
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
expect(click).toHaveBeenCalled();
});
it("shows decrypt error dialog when decrypt fails", async () => {
const vm = createVm({
mediaEventHelper: {
media: { isEncrypted: true },
sourceBlob: { value: Promise.reject(new Error("decrypt failed")) },
fileName: "broken.bin",
} as unknown as MediaEventHelper,
mxEvent: mkMediaEvent({ file: { url: "mxc://server/file" } }),
});
const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => {});
const dialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ close: jest.fn() } as any);
await vm.onDownloadClick();
expect(warnSpy).toHaveBeenCalled();
expect(dialogSpy).toHaveBeenCalledWith(
ErrorDialog,
expect.objectContaining({
title: "Error",
description: expect.stringMatching(/decrypt/i),
}),
);
expect(vm.getSnapshot().state).toBe(FileBodyViewState.DECRYPTION_PENDING);
});
it("resets decrypted state when mxEvent changes", async () => {
const vm = createVm({
mediaEventHelper: mkMediaEventHelper({ encrypted: true }),
mxEvent: mkMediaEvent({ file: { url: "mxc://server/file-a" } }),
});
await vm.onDownloadClick();
expect(vm.getSnapshot().state).toBe(FileBodyViewState.ENCRYPTED);
vm.setProps({
mxEvent: mkMediaEvent({ body: "new", file: { url: "mxc://server/file-b" } }),
});
expect(vm.getSnapshot()).toMatchObject({
state: FileBodyViewState.DECRYPTION_PENDING,
});
});
it("keeps decrypted state when non-event props change", async () => {
const vm = createVm({
mediaEventHelper: mkMediaEventHelper({ encrypted: true }),
mxEvent: mkMediaEvent({ file: { url: "mxc://server/file-a" } }),
});
await vm.onDownloadClick();
expect(vm.getSnapshot().state).toBe(FileBodyViewState.ENCRYPTED);
vm.setProps({
timelineRenderingType: TimelineRenderingType.Thread,
});
expect(vm.getSnapshot()).toMatchObject({
state: FileBodyViewState.ENCRYPTED,
showDownload: false,
});
});
it("uses filename-like downloadTitle in encrypted mode when showFileInfo is false", () => {
const vm = createVm({
showFileInfo: false,
mediaEventHelper: mkMediaEventHelper({ encrypted: true }),
mxEvent: mkMediaEvent({ body: "my-file.pdf", file: { url: "mxc://server/file" } }),
});
expect(vm.getSnapshot()).toMatchObject({
state: FileBodyViewState.DECRYPTION_PENDING,
downloadTitle: "my-file.pdf",
});
});
it("hides download in thread rendering even when showFileInfo is false", () => {
const vm = createVm({
showFileInfo: false,
timelineRenderingType: TimelineRenderingType.Thread,
});
expect(vm.getSnapshot()).toMatchObject({
state: FileBodyViewState.UNENCRYPTED,
showDownload: false,
});
});
it("returns INVALID snapshot when content URL is missing", () => {
const vm = createVm({
mxEvent: mkMediaEvent({ url: undefined }),
showFileInfo: true,
});
expect(vm.getSnapshot()).toMatchObject({
state: FileBodyViewState.INVALID,
showInfo: true,
infoLabel: "alt",
infoIcon: FileBodyViewInfoIcon.ATTACHMENT,
});
});
it("does not download on info click when showFileInfo is false", async () => {
const vm = createVm({ showFileInfo: false });
await vm.onInfoClick();
expect(mockDownload).not.toHaveBeenCalled();
});
it("keeps unencrypted snapshot when download is clicked without mediaEventHelper", async () => {
const vm = createVm({
mediaEventHelper: undefined,
mxEvent: mkMediaEvent({ file: { url: "mxc://server/file" } }),
});
await vm.onDownloadClick();
expect(vm.getSnapshot().state).toBe(FileBodyViewState.UNENCRYPTED);
});
});
@@ -0,0 +1,63 @@
/*
* 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 MatrixEvent } from "matrix-js-sdk/src/matrix";
import { HiddenBodyViewModel } from "../../../src/viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel";
describe("HiddenBodyViewModel", () => {
const createEvent = (visible: boolean, reason?: string | null): MatrixEvent =>
({
messageVisibility: jest.fn().mockReturnValue({
visible,
reason,
}),
}) as unknown as MatrixEvent;
it("extracts a moderation reason from a hidden event", () => {
const vm = new HiddenBodyViewModel({ mxEvent: createEvent(false, "spam") });
expect(vm.getSnapshot()).toEqual({
reason: "spam",
});
});
it("omits the reason when the hidden event has no reason", () => {
const vm = new HiddenBodyViewModel({ mxEvent: createEvent(false, null) });
expect(vm.getSnapshot()).toEqual({
reason: undefined,
});
});
it("throws when created for a visible event", () => {
expect(() => new HiddenBodyViewModel({ mxEvent: createEvent(true) })).toThrow(
"HiddenBodyViewModel should only be applied to hidden messages",
);
});
it("updates the snapshot when the event changes", () => {
const vm = new HiddenBodyViewModel({ mxEvent: createEvent(false) });
vm.setEvent(createEvent(false, "abuse"));
expect(vm.getSnapshot()).toEqual({
reason: "abuse",
});
});
it("does not emit when setEvent receives the current event", () => {
const event = createEvent(false, "spam");
const vm = new HiddenBodyViewModel({ mxEvent: event });
const listener = jest.fn();
vm.subscribe(listener);
vm.setEvent(event);
expect(listener).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,122 @@
/*
* 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 * as DateUtils from "../../../src/DateUtils";
import { MessageTimestampViewModel } from "../../../src/viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel";
jest.mock("../../../src/settings/SettingsStore");
describe("MessageTimestampViewModel", () => {
// Friday Dec 17 2021, 9:09am
const nowDate = new Date("2021-12-17T08:09:00.000Z");
const HOUR_MS = 3600000;
const DAY_MS = HOUR_MS * 24;
afterEach(() => {
jest.restoreAllMocks();
});
it("should return the snapshot", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
});
expect(vm.getSnapshot()).toMatchObject({
ts: "08:09",
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
});
});
it("should return the snapshot with tsReceivedAt", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
receivedTs: nowDate.getTime() + DAY_MS,
});
expect(vm.getSnapshot()).toMatchObject({
ts: "08:09",
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
tsReceivedAt: "Sat, Dec 18, 2021, 08:09:00",
});
});
it("should return the snapshot without presentation class names", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
});
expect(vm.getSnapshot()).toMatchObject({
ts: "08:09",
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
});
});
it("should use formatRelativeTime when showRelative is true", () => {
jest.spyOn(DateUtils, "formatFullDate").mockReturnValue("SENT_AT");
const formatRelativeTimeSpy = jest.spyOn(DateUtils, "formatRelativeTime").mockReturnValue("RELATIVE");
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
showRelative: true,
showTwelveHour: true,
});
expect(vm.getSnapshot()).toMatchObject({
ts: "RELATIVE",
tsSentAt: "SENT_AT",
});
expect(formatRelativeTimeSpy).toHaveBeenCalledWith(expect.any(Date), true);
});
it("should use full date when showFullDate is true and respect showSeconds", () => {
const formatFullDateSpy = jest
.spyOn(DateUtils, "formatFullDate")
.mockImplementation((_date, _showTwelveHour, showSeconds) =>
showSeconds === false ? "FULL_NO_SECONDS" : "SENT_AT",
);
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
showFullDate: true,
showSeconds: false,
});
expect(vm.getSnapshot()).toMatchObject({
ts: "FULL_NO_SECONDS",
tsSentAt: "SENT_AT",
});
expect(formatFullDateSpy).toHaveBeenCalled();
});
it("should use full time when showSeconds is true without full date", () => {
jest.spyOn(DateUtils, "formatFullDate").mockReturnValue("SENT_AT");
const formatFullTimeSpy = jest.spyOn(DateUtils, "formatFullTime").mockReturnValue("FULL_TIME");
const formatTimeSpy = jest.spyOn(DateUtils, "formatTime").mockReturnValue("TIME");
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
showSeconds: true,
});
expect(vm.getSnapshot()).toMatchObject({
ts: "FULL_TIME",
tsSentAt: "SENT_AT",
});
expect(formatFullTimeSpy).toHaveBeenCalled();
expect(formatTimeSpy).not.toHaveBeenCalled();
});
it("should include tooltip inhibition and href in the snapshot", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
inhibitTooltip: true,
href: "https://example.test",
});
expect(vm.getSnapshot()).toMatchObject({
inhibitTooltip: true,
href: "https://example.test",
});
});
});
@@ -0,0 +1,84 @@
/*
* 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 MouseEvent } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { MjolnirBodyViewModel } from "../../../src/viewmodels/room/timeline/event-tile/body/MjolnirBodyViewModel";
describe("MjolnirBodyViewModel", () => {
const createEvent = (roomId = "!room:example.com", eventId = "$event:example.com"): MatrixEvent =>
({
getRoomId: jest.fn().mockReturnValue(roomId),
getId: jest.fn().mockReturnValue(eventId),
}) as unknown as MatrixEvent;
const createClickEvent = (): MouseEvent<HTMLButtonElement> =>
({
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
}) as unknown as MouseEvent<HTMLButtonElement>;
afterEach(() => {
localStorage.clear();
jest.restoreAllMocks();
});
it("has an empty snapshot", () => {
const vm = new MjolnirBodyViewModel({ mxEvent: createEvent() });
expect(vm.getSnapshot()).toEqual({});
});
it("allows rendering the hidden event and notifies the parent", () => {
const onMessageAllowed = jest.fn();
const vm = new MjolnirBodyViewModel({
mxEvent: createEvent("!room:example.com", "$hidden:example.com"),
onMessageAllowed,
});
const event = createClickEvent();
vm.onAllowClick(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(localStorage.getItem("mx_mjolnir_render_!room:example.com__$hidden:example.com")).toBe("true");
expect(onMessageAllowed).toHaveBeenCalledTimes(1);
});
it("uses the updated event and callback", () => {
const oldCallback = jest.fn();
const newCallback = jest.fn();
const vm = new MjolnirBodyViewModel({
mxEvent: createEvent("!old:example.com", "$old:example.com"),
onMessageAllowed: oldCallback,
});
vm.setEvent(createEvent("!new:example.com", "$new:example.com"));
vm.setOnMessageAllowed(newCallback);
vm.onAllowClick(createClickEvent());
expect(localStorage.getItem("mx_mjolnir_render_!old:example.com__$old:example.com")).toBeNull();
expect(localStorage.getItem("mx_mjolnir_render_!new:example.com__$new:example.com")).toBe("true");
expect(oldCallback).not.toHaveBeenCalled();
expect(newCallback).toHaveBeenCalledTimes(1);
});
it("does not emit snapshot updates for unchanged action inputs", () => {
const mxEvent = createEvent();
const onMessageAllowed = jest.fn();
const listener = jest.fn();
const vm = new MjolnirBodyViewModel({ mxEvent, onMessageAllowed });
vm.subscribe(listener);
vm.setEvent(mxEvent);
vm.setOnMessageAllowed(onMessageAllowed);
expect(listener).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,172 @@
/*
* 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 MatrixClient, type MatrixEvent, type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
import {
ReactionsRowButtonTooltipViewModel,
type ReactionsRowButtonTooltipViewModelProps,
} from "../../../src/viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonTooltipViewModel";
import { stubClient, mkStubRoom, mkEvent } from "../../test-utils";
import { unicodeToShortcode } from "../../../src/HtmlUtils";
jest.mock("../../../src/HtmlUtils", () => ({
...jest.requireActual("../../../src/HtmlUtils"),
unicodeToShortcode: jest.fn(),
}));
const mockedUnicodeToShortcode = jest.mocked(unicodeToShortcode);
describe("ReactionsRowButtonTooltipViewModel", () => {
let client: MatrixClient;
let room: Room;
let mxEvent: MatrixEvent;
const createReactionEvent = (senderId: string, content?: Record<string, unknown>): MatrixEvent => {
return mkEvent({
event: true,
type: "m.reaction",
room: room.roomId,
user: senderId,
content: {
"m.relates_to": { rel_type: "m.annotation", event_id: mxEvent.getId(), key: "👍" },
...content,
},
});
};
const createProps = (
overrides?: Partial<ReactionsRowButtonTooltipViewModelProps>,
): ReactionsRowButtonTooltipViewModelProps => ({
client,
mxEvent,
content: "👍",
reactionEvents: [],
customReactionImagesEnabled: false,
...overrides,
});
beforeEach(() => {
client = stubClient();
room = mkStubRoom("!room:example.org", "Test Room", client);
jest.spyOn(client, "getRoom").mockReturnValue(room);
mxEvent = mkEvent({
event: true,
type: "m.room.message",
room: room.roomId,
user: "@sender:example.org",
content: { body: "Test message", msgtype: "m.text" },
});
mockedUnicodeToShortcode.mockImplementation((char: string) => {
if (char === "👍") return ":thumbsup:";
return "";
});
});
afterEach(() => {
jest.restoreAllMocks();
mockedUnicodeToShortcode.mockReset();
});
it("should return undefined snapshot when room is not found", () => {
jest.spyOn(client, "getRoom").mockReturnValue(null);
const vm = new ReactionsRowButtonTooltipViewModel(createProps());
const snapshot = vm.getSnapshot();
expect(snapshot.formattedSenders).toBeUndefined();
expect(snapshot.caption).toBeUndefined();
});
it("should return undefined snapshot when MatrixClient is unavailable", () => {
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ client: null }));
const snapshot = vm.getSnapshot();
expect(snapshot.formattedSenders).toBeUndefined();
expect(snapshot.caption).toBeUndefined();
});
it("should compute formattedSenders and caption from reaction events", () => {
const reactionEvent = createReactionEvent("@alice:example.org");
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember);
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [reactionEvent] }));
const snapshot = vm.getSnapshot();
expect(snapshot.formattedSenders).toBe("Alice");
expect(snapshot.caption).toContain(":thumbsup:");
});
it("should fall back to sender ID when member is not found", () => {
const reactionEvent = createReactionEvent("@unknown:example.org");
jest.spyOn(room, "getMember").mockReturnValue(null);
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [reactionEvent] }));
expect(vm.getSnapshot().formattedSenders).toBe("@unknown:example.org");
});
it("should use custom reaction shortcode when customReactionImagesEnabled is true", () => {
mockedUnicodeToShortcode.mockReturnValue("");
const reactionEvent = createReactionEvent("@alice:example.org", {
"com.beeper.reaction.shortcode": "custom_emoji",
});
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember);
const vm = new ReactionsRowButtonTooltipViewModel(
createProps({
content: "mxc://custom/emoji",
reactionEvents: [reactionEvent],
customReactionImagesEnabled: true,
}),
);
expect(vm.getSnapshot().caption).toContain("custom_emoji");
});
it("should not use custom reaction shortcode when customReactionImagesEnabled is false", () => {
mockedUnicodeToShortcode.mockReturnValue("");
const reactionEvent = createReactionEvent("@alice:example.org", {
"com.beeper.reaction.shortcode": "custom_emoji",
});
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember);
const vm = new ReactionsRowButtonTooltipViewModel(
createProps({
content: "mxc://custom/emoji",
reactionEvents: [reactionEvent],
customReactionImagesEnabled: false,
}),
);
expect(vm.getSnapshot().caption).toBeUndefined();
});
it("should update snapshot and notify subscribers when setProps is called", () => {
const aliceReaction = createReactionEvent("@alice:example.org");
const bobReaction = createReactionEvent("@bob:example.org");
jest.spyOn(room, "getMember").mockImplementation((userId) => {
const names: Record<string, string> = { "@alice:example.org": "Alice", "@bob:example.org": "Bob" };
return names[userId!] ? ({ name: names[userId!], userId } as RoomMember) : null;
});
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [aliceReaction] }));
expect(vm.getSnapshot().formattedSenders).toBe("Alice");
const subscriber = jest.fn();
vm.subscribe(subscriber);
vm.setProps({ reactionEvents: [aliceReaction, bobReaction] });
expect(subscriber).toHaveBeenCalled();
expect(vm.getSnapshot().formattedSenders).toContain("Alice");
expect(vm.getSnapshot().formattedSenders).toContain("Bob");
});
});
@@ -0,0 +1,171 @@
/*
* 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 { EventType, type MatrixClient, type MatrixEvent, RelationType, type Room } from "matrix-js-sdk/src/matrix";
import {
ReactionsRowButtonViewModel,
type ReactionsRowButtonViewModelProps,
} from "../../../src/viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel";
import { type ReactionsRowButtonTooltipViewModel } from "../../../src/viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonTooltipViewModel";
import { createTestClient, mkEvent, mkStubRoom } from "../../test-utils";
import dis from "../../../src/dispatcher/dispatcher";
jest.mock("../../../src/dispatcher/dispatcher");
describe("ReactionsRowButtonViewModel", () => {
let client: MatrixClient;
let room: Room;
let mxEvent: MatrixEvent;
const createReactionEvent = (senderId: string, key = "👍"): MatrixEvent => {
return mkEvent({
event: true,
type: "m.reaction",
room: room.roomId,
user: senderId,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: mxEvent.getId(),
key,
},
},
});
};
const createProps = (overrides?: Partial<ReactionsRowButtonViewModelProps>): ReactionsRowButtonViewModelProps => ({
client,
mxEvent,
content: "👍",
count: 2,
reactionEvents: [createReactionEvent("@alice:example.org"), createReactionEvent("@bob:example.org")],
disabled: false,
customReactionImagesEnabled: false,
...overrides,
});
const getTooltipVm = (vm: ReactionsRowButtonViewModel): ReactionsRowButtonTooltipViewModel =>
vm.getSnapshot().tooltipVm as ReactionsRowButtonTooltipViewModel;
const getAriaLabel = (vm: ReactionsRowButtonViewModel): string | undefined =>
(vm.getSnapshot() as { ariaLabel?: string }).ariaLabel;
beforeEach(() => {
jest.clearAllMocks();
client = createTestClient();
room = mkStubRoom("!room:example.org", "Test Room", client);
jest.spyOn(client, "getRoom").mockReturnValue(room);
mxEvent = mkEvent({
event: true,
type: "m.room.message",
room: room.roomId,
user: "@sender:example.org",
content: { body: "Test message", msgtype: "m.text" },
});
});
it("updates count with merge and does not touch tooltip props", () => {
const vm = new ReactionsRowButtonViewModel(createProps());
const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps");
const listener = jest.fn();
vm.subscribe(listener);
vm.setCount(5);
expect(vm.getSnapshot().count).toBe(5);
expect(listener).toHaveBeenCalledTimes(1);
expect(tooltipSetPropsSpy).not.toHaveBeenCalled();
vm.setCount(6);
expect(listener).toHaveBeenCalledTimes(2);
});
it("includes an ariaLabel in the snapshot", () => {
const vm = new ReactionsRowButtonViewModel(createProps());
expect(getAriaLabel(vm)).toContain("reacted with 👍");
});
it("updates selected state with myReactionEvent without touching tooltip props", () => {
const vm = new ReactionsRowButtonViewModel(createProps());
const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps");
const listener = jest.fn();
vm.subscribe(listener);
const myReactionEvent = createReactionEvent("@me:example.org");
vm.setMyReactionEvent(myReactionEvent);
expect(vm.getSnapshot().isSelected).toBe(true);
expect(listener).toHaveBeenCalledTimes(1);
expect(tooltipSetPropsSpy).not.toHaveBeenCalled();
});
it("updates disabled state without touching tooltip props", () => {
const vm = new ReactionsRowButtonViewModel(createProps({ disabled: false }));
const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps");
vm.setDisabled(true);
expect(vm.getSnapshot().isDisabled).toBe(true);
expect(tooltipSetPropsSpy).not.toHaveBeenCalled();
});
it("setReactionData forwards to tooltip via setProps and updates snapshot content", () => {
const vm = new ReactionsRowButtonViewModel(createProps());
const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps");
const reactionEvents = [createReactionEvent("@carol:example.org", "👎")];
vm.setReactionData("👎", reactionEvents, false);
expect(vm.getSnapshot().content).toBe("👎");
expect(tooltipSetPropsSpy).toHaveBeenCalledWith({
content: "👎",
reactionEvents,
customReactionImagesEnabled: false,
});
vm.setReactionData("👎", reactionEvents, false);
expect(tooltipSetPropsSpy).toHaveBeenCalledTimes(2);
});
it("redacts reaction on click when myReactionEvent exists", () => {
const myReactionEvent = createReactionEvent("@me:example.org");
const vm = new ReactionsRowButtonViewModel(createProps({ myReactionEvent }));
vm.onClick();
expect(client.redactEvent).toHaveBeenCalledWith(room.roomId, myReactionEvent.getId());
expect(client.sendEvent).not.toHaveBeenCalled();
});
it("sends reaction and dispatches message_sent when no myReactionEvent exists", () => {
const vm = new ReactionsRowButtonViewModel(createProps());
vm.onClick();
expect(client.sendEvent).toHaveBeenCalledWith(room.roomId, EventType.Reaction, {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: mxEvent.getId(),
key: "👍",
},
});
expect(dis.dispatch).toHaveBeenCalledWith({ action: "message_sent" });
});
it("does nothing on click when disabled", () => {
const vm = new ReactionsRowButtonViewModel(createProps({ disabled: true }));
vm.onClick();
expect(client.redactEvent).not.toHaveBeenCalled();
expect(client.sendEvent).not.toHaveBeenCalled();
expect(dis.dispatch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,105 @@
/*
* 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 MouseEvent } from "react";
import { ReactionsRowViewModel } from "../../../src/viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel";
describe("ReactionsRowViewModel", () => {
const createVm = (
overrides?: Partial<ConstructorParameters<typeof ReactionsRowViewModel>[0]>,
): ReactionsRowViewModel =>
new ReactionsRowViewModel({
isActionable: true,
reactionGroupCount: 10,
canReact: true,
addReactionButtonActive: false,
...overrides,
});
it("computes initial snapshot from props", () => {
const vm = createVm();
const snapshot = vm.getSnapshot();
expect(snapshot.isVisible).toBe(true);
expect(snapshot.showAllButtonVisible).toBe(true);
expect(snapshot.showAddReactionButton).toBe(true);
expect(snapshot.addReactionButtonActive).toBe(false);
});
it("hides show-all after onShowAllClick", () => {
const vm = createVm();
const listener = jest.fn();
vm.subscribe(listener);
vm.onShowAllClick();
expect(vm.getSnapshot().showAllButtonVisible).toBe(false);
expect(listener).toHaveBeenCalledTimes(1);
});
it("updates visibility when reaction group count changes", () => {
const vm = createVm();
vm.setReactionGroupCount(0);
expect(vm.getSnapshot().isVisible).toBe(false);
});
it("updates add-reaction button visibility from canReact", () => {
const vm = createVm();
vm.setCanReact(false);
expect(vm.getSnapshot().showAddReactionButton).toBe(false);
});
it("updates add-reaction active state", () => {
const vm = createVm();
vm.setAddReactionButtonActive(true);
expect(vm.getSnapshot().addReactionButtonActive).toBe(true);
});
it("forwards add-reaction handlers", () => {
const vm = createVm();
const onAddReactionClick = jest.fn();
const onAddReactionContextMenu = jest.fn();
vm.setAddReactionHandlers({
onAddReactionClick,
onAddReactionContextMenu,
});
const clickEvent = {
currentTarget: document.createElement("button"),
} as unknown as MouseEvent<HTMLButtonElement>;
vm.onAddReactionClick(clickEvent);
vm.onAddReactionContextMenu(clickEvent);
expect(onAddReactionClick).toHaveBeenCalledWith(clickEvent);
expect(onAddReactionContextMenu).toHaveBeenCalledWith(clickEvent);
});
it("doesn't emit only setters that always merge when values are unchanged", () => {
const vm = createVm();
const previousSnapshot = vm.getSnapshot();
const listener = jest.fn();
vm.subscribe(listener);
vm.setCanReact(true);
vm.setReactionGroupCount(10);
vm.setActionable(true);
vm.setAddReactionButtonActive(false);
// `setReactionGroupCount` is optimized and skips emit for unchanged derived values.
// The other setters always merge and therefore emit.
expect(listener).toHaveBeenCalledTimes(0);
expect(vm.getSnapshot()).toEqual(previousSnapshot);
});
});
@@ -0,0 +1,436 @@
/*
* 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 {
LINKIFIED_DATA_ATTRIBUTE,
TextualBodyViewBodyWrapperKind,
TextualBodyViewKind,
} from "@element-hq/web-shared-components";
import { MsgType, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { flushPromises, mkEvent } from "../../test-utils";
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { TextualBodyViewModel } from "../../../src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel";
import Modal from "../../../src/Modal";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { IntegrationManagers } from "../../../src/integrations/IntegrationManagers";
import * as permalinkUtils from "../../../src/utils/permalinks/Permalinks";
import QuestionDialog from "../../../src/components/views/dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../../../src/components/views/dialogs/MessageEditHistoryDialog";
describe("TextualBodyViewModel", () => {
const createEvent = (
content: Record<string, unknown>,
overrides?: Partial<{ room: string; user: string }>,
): MatrixEvent =>
mkEvent({
event: true,
type: "m.room.message",
room: overrides?.room ?? "!room:example.com",
user: overrides?.user ?? "@alice:example.com",
content,
});
const createVm = (
overrides?: Partial<ConstructorParameters<typeof TextualBodyViewModel>[0]>,
): TextualBodyViewModel =>
new TextualBodyViewModel({
mxEvent: createEvent({
body: "Hello world",
msgtype: MsgType.Text,
}),
timelineRenderingType: TimelineRenderingType.Room,
...overrides,
});
afterEach(() => {
jest.restoreAllMocks();
window.location.hash = "";
});
it("computes the initial snapshot from props", () => {
const event = createEvent({
body: "Caption",
msgtype: MsgType.Image,
});
jest.spyOn(event, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));
jest.spyOn(event, "messageVisibility").mockReturnValue({
visible: false,
reason: "copyright",
} as ReturnType<MatrixEvent["messageVisibility"]>);
const vm = createVm({
id: "event-id",
mxEvent: event,
highlightLink: "https://example.com",
replacingEventId: "$replacement",
isSeeingThroughMessageHiddenForModeration: true,
});
const snapshot = vm.getSnapshot();
expect(snapshot.id).toBe("event-id");
expect(snapshot.kind).toBe(TextualBodyViewKind.CAPTION);
expect(snapshot.bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.LINK);
expect(snapshot.bodyLinkHref).toBe("https://example.com");
expect(snapshot.showEditedMarker).toBe(true);
expect(snapshot.editedMarkerText).toContain("edited");
expect(snapshot.showPendingModerationMarker).toBe(true);
expect(snapshot.pendingModerationText).toContain("copyright");
});
it("updates message-derived fields when the event changes", () => {
const vm = createVm();
const emoteEvent = createEvent(
{
body: "waves",
msgtype: MsgType.Emote,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
},
{ user: "@bob:example.com" },
);
emoteEvent.sender = { name: "Bob" } as MatrixEvent["sender"];
vm.setEvent(emoteEvent);
expect(vm.getSnapshot().kind).toBe(TextualBodyViewKind.EMOTE);
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.ACTION);
expect(vm.getSnapshot().emoteSenderName).toBe("Bob");
});
it("updates wrapper state when the highlight link changes", () => {
const starterLinkEvent = createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
});
const vm = createVm({ mxEvent: starterLinkEvent });
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.ACTION);
vm.setHighlightLink("https://element.io");
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.LINK);
expect(vm.getSnapshot().bodyLinkHref).toBe("https://element.io");
vm.setHighlightLink(undefined);
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.ACTION);
});
it("uses the notice kind and no action wrapper for non-string starter links", () => {
const noticeEvent = createEvent({
body: "Notice",
msgtype: MsgType.Notice,
data: {
"org.matrix.neb.starter_link": 42,
},
});
const vm = createVm({ mxEvent: noticeEvent });
expect(vm.getSnapshot().kind).toBe(TextualBodyViewKind.NOTICE);
expect(vm.getSnapshot().bodyWrapper).toBe(TextualBodyViewBodyWrapperKind.NONE);
expect(vm.getSnapshot().bodyActionAriaLabel).toBeUndefined();
});
it("updates the moderation marker from the dedicated setter", () => {
const hiddenEvent = createEvent({
body: "hidden",
msgtype: MsgType.Text,
});
jest.spyOn(hiddenEvent, "messageVisibility").mockReturnValue({
visible: false,
reason: "spam",
} as ReturnType<MatrixEvent["messageVisibility"]>);
const vm = createVm({ mxEvent: hiddenEvent });
vm.setIsSeeingThroughMessageHiddenForModeration(true);
expect(vm.getSnapshot().showPendingModerationMarker).toBe(true);
expect(vm.getSnapshot().pendingModerationText).toContain("spam");
});
it("updates id and edited marker from dedicated setters", () => {
const event = createEvent({
body: "edited",
msgtype: MsgType.Text,
});
jest.spyOn(event, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));
const vm = createVm({ mxEvent: event });
vm.setId("updated-id");
vm.setReplacingEventId("$edit");
expect(vm.getSnapshot().id).toBe("updated-id");
expect(vm.getSnapshot().showEditedMarker).toBe(true);
expect(vm.getSnapshot().editedMarkerText).toContain("edited");
vm.setReplacingEventId(undefined);
expect(vm.getSnapshot().showEditedMarker).toBe(false);
expect(vm.getSnapshot().editedMarkerTooltip).toBeUndefined();
});
it("renders the generic pending moderation text when there is no reason", () => {
const hiddenEvent = createEvent({
body: "hidden",
msgtype: MsgType.Text,
});
jest.spyOn(hiddenEvent, "messageVisibility").mockReturnValue({
visible: false,
reason: null,
} as ReturnType<MatrixEvent["messageVisibility"]>);
const vm = createVm({ mxEvent: hiddenEvent });
vm.setIsSeeingThroughMessageHiddenForModeration(true);
expect(vm.getSnapshot().showPendingModerationMarker).toBe(true);
expect(vm.getSnapshot().pendingModerationText).toMatch(/^\(.+\)$/);
expect(vm.getSnapshot().pendingModerationText).not.toContain("undefined");
});
it("throws when pending moderation is requested for a visible message", () => {
const visibleEvent = createEvent({
body: "visible",
msgtype: MsgType.Text,
});
jest.spyOn(visibleEvent, "messageVisibility").mockReturnValue({
visible: true,
} as ReturnType<MatrixEvent["messageVisibility"]>);
expect(() =>
createVm({
mxEvent: visibleEvent,
isSeeingThroughMessageHiddenForModeration: true,
}),
).toThrow("TextualBodyViewModel should only render pending moderation for hidden messages");
});
it("ignores linkified root clicks", () => {
const vm = createVm();
const preventDefault = jest.fn();
const transformSpy = jest.spyOn(permalinkUtils, "tryTransformPermalinkToLocalHref");
vm.onRootClick({
preventDefault,
target: {
dataset: {
[LINKIFIED_DATA_ATTRIBUTE]: "true",
},
href: "https://example.org",
nodeName: "A",
},
} as any);
expect(transformSpy).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
it("rewrites permalink clicks to local hashes", () => {
const vm = createVm();
const preventDefault = jest.fn();
const anchor = { href: "https://element.example/#/room/!room:example.org", nodeName: "A" };
jest.spyOn(permalinkUtils, "tryTransformPermalinkToLocalHref").mockReturnValue("#/room/!room:example.org");
vm.onRootClick({
preventDefault,
target: {
nodeName: "SPAN",
closest: jest.fn().mockReturnValue(anchor),
},
} as any);
expect(preventDefault).toHaveBeenCalled();
expect(window.location.hash).toBe("#/room/!room:example.org");
});
it("leaves external root clicks alone when no local permalink is found", () => {
const vm = createVm();
const preventDefault = jest.fn();
const href = "https://example.org";
jest.spyOn(permalinkUtils, "tryTransformPermalinkToLocalHref").mockReturnValue(href);
vm.onRootClick({
preventDefault,
target: {
href,
nodeName: "A",
},
} as any);
expect(preventDefault).not.toHaveBeenCalled();
expect(window.location.hash).toBe("");
});
it("does nothing for body actions without a starter link", () => {
const vm = createVm();
const preventDefault = jest.fn();
const hasManagerSpy = jest.spyOn(IntegrationManagers.sharedInstance(), "hasManager");
vm.onBodyActionClick({ preventDefault } as any);
expect(preventDefault).toHaveBeenCalled();
expect(hasManagerSpy).not.toHaveBeenCalled();
});
it("opens the no-manager dialog for starter links when integrations are unavailable", () => {
const vm = createVm({
mxEvent: createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
}),
});
const preventDefault = jest.fn();
const managers = IntegrationManagers.sharedInstance();
jest.spyOn(managers, "hasManager").mockReturnValue(false);
const openNoManagerDialogSpy = jest.spyOn(managers, "openNoManagerDialog").mockImplementation(() => {});
vm.onBodyActionClick({ preventDefault } as any);
expect(preventDefault).toHaveBeenCalled();
expect(openNoManagerDialogSpy).toHaveBeenCalled();
});
it("opens the scalar starter link after confirmation", async () => {
const vm = createVm({
mxEvent: createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
}),
});
const preventDefault = jest.fn();
const managers = IntegrationManagers.sharedInstance();
const connect = jest.fn().mockResolvedValue(undefined);
const getStarterLink = jest.fn().mockReturnValue("https://scalar.example/complete");
const scalarClient = { connect, getStarterLink };
const integrationManager = {
getScalarClient: jest.fn().mockReturnValue(scalarClient),
uiUrl: "https://scalar.example/ui",
};
const popup = { opener: "initial" };
jest.spyOn(managers, "hasManager").mockReturnValue(true);
jest.spyOn(managers, "getPrimaryManager").mockReturnValue(integrationManager as any);
jest.spyOn(Modal, "createDialog").mockReturnValue({
close: jest.fn(),
finished: Promise.resolve([true]),
} as any);
const openSpy = jest.spyOn(window, "open").mockImplementation(() => popup as any);
vm.onBodyActionClick({ preventDefault } as any);
await flushPromises();
expect(connect).toHaveBeenCalled();
expect(Modal.createDialog).toHaveBeenCalledWith(
QuestionDialog,
expect.objectContaining({
button: expect.any(String),
title: expect.any(String),
}),
);
expect(openSpy).toHaveBeenCalledWith(
"https://scalar.example/complete",
"_blank",
expect.stringContaining("width="),
);
expect(popup.opener).toBeNull();
});
it("does not open the scalar starter link when the dialog is cancelled", async () => {
const vm = createVm({
mxEvent: createEvent({
body: "Open the integration",
msgtype: MsgType.Text,
data: {
"org.matrix.neb.starter_link": "https://scalar.example/starter",
},
}),
});
const managers = IntegrationManagers.sharedInstance();
const scalarClient = {
connect: jest.fn().mockResolvedValue(undefined),
getStarterLink: jest.fn().mockReturnValue("https://scalar.example/complete"),
};
jest.spyOn(managers, "hasManager").mockReturnValue(true);
jest.spyOn(managers, "getPrimaryManager").mockReturnValue({
getScalarClient: jest.fn().mockReturnValue(scalarClient),
uiUrl: "https://scalar.example/ui",
} as any);
jest.spyOn(Modal, "createDialog").mockReturnValue({
close: jest.fn(),
finished: Promise.resolve([false]),
} as any);
const openSpy = jest.spyOn(window, "open").mockImplementation(() => ({ opener: "initial" }) as any);
vm.onBodyActionClick({ preventDefault: jest.fn() } as any);
await flushPromises();
expect(openSpy).not.toHaveBeenCalled();
});
it("opens the edit history dialog from the edited marker", () => {
const event = createEvent({
body: "edited",
msgtype: MsgType.Text,
});
const vm = createVm({ mxEvent: event });
const createDialogSpy = jest
.spyOn(Modal, "createDialog")
.mockImplementation(() => ({ close: jest.fn() }) as any);
vm.onEditedMarkerClick();
expect(createDialogSpy).toHaveBeenCalledWith(MessageEditHistoryDialog, { mxEvent: event });
});
it("dispatches composer insert for the emote sender using the current rendering type", () => {
const event = createEvent({
body: "waves",
msgtype: MsgType.Emote,
});
const vm = createVm({ mxEvent: event });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation(() => {});
vm.setTimelineRenderingType(TimelineRenderingType.Thread);
vm.onEmoteSenderClick();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ComposerInsert,
timelineRenderingType: TimelineRenderingType.Thread,
userId: event.getSender(),
});
});
it("does not emit for unchanged setter values", () => {
const mxEvent = createEvent({
body: "Hello world",
msgtype: MsgType.Text,
});
const vm = createVm({ mxEvent });
const listener = jest.fn();
vm.subscribe(listener);
vm.setId(undefined);
vm.setEvent(mxEvent);
vm.setHighlightLink(undefined);
vm.setReplacingEventId(undefined);
vm.setIsSeeingThroughMessageHiddenForModeration(undefined);
vm.setTimelineRenderingType(TimelineRenderingType.Room);
expect(listener).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,129 @@
/*
* 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 { mocked } from "jest-mock";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import Modal from "../../../src/Modal";
import SdkConfig from "../../../src/SdkConfig";
import { BugReportEndpointURLLocal } from "../../../src/IConfigOptions";
import ViewSource from "../../../src/components/structures/ViewSource";
import BugReportDialog from "../../../src/components/views/dialogs/BugReportDialog";
import { TileErrorViewModel } from "../../../src/viewmodels/message-body/TileErrorViewModel";
describe("TileErrorViewModel", () => {
const createEvent = (type = "m.room.message"): MatrixEvent =>
new MatrixEvent({
content: {},
event_id: `$${type}`,
origin_server_ts: Date.now(),
room_id: "!room:example.org",
sender: "@alice:example.org",
type,
});
const createVm = (
overrides: Partial<ConstructorParameters<typeof TileErrorViewModel>[0]> = {},
): TileErrorViewModel => {
const error = overrides.error ?? new Error("Boom");
const mxEvent = overrides.mxEvent ?? createEvent();
return new TileErrorViewModel({
developerMode: true,
error,
mxEvent,
...overrides,
});
};
beforeEach(() => {
SdkConfig.reset();
jest.spyOn(Modal, "createDialog").mockImplementation(() => ({ close: jest.fn() }) as any);
});
afterEach(() => {
SdkConfig.reset();
jest.restoreAllMocks();
});
it("computes the initial snapshot from app state", () => {
SdkConfig.add({ bug_report_endpoint_url: "https://example.org" });
const vm = createVm();
expect(vm.getSnapshot()).toEqual({
message: "Can't load this message",
eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs",
viewSourceCtaLabel: "View Source",
});
});
it("uses the download logs label for local bug reports", () => {
SdkConfig.add({ bug_report_endpoint_url: BugReportEndpointURLLocal });
const vm = createVm();
expect(vm.getSnapshot().bugReportCtaLabel).toBe("Download logs");
});
it("hides optional actions when unavailable", () => {
const vm = createVm({ developerMode: false });
expect(vm.getSnapshot().bugReportCtaLabel).toBeUndefined();
expect(vm.getSnapshot().viewSourceCtaLabel).toBeUndefined();
});
it("guards setters against unchanged values", () => {
const error = new Error("Boom");
const mxEvent = createEvent();
const vm = createVm({ developerMode: true, error, mxEvent });
const listener = jest.fn();
vm.subscribe(listener);
vm.setDeveloperMode(true);
vm.setError(error);
expect(listener).not.toHaveBeenCalled();
});
it("opens the bug report dialog with the current error", () => {
SdkConfig.add({ bug_report_endpoint_url: "https://example.org" });
const originalError = new Error("Boom");
const updatedError = new Error("Updated boom");
const vm = createVm({ error: originalError });
vm.setError(updatedError);
vm.onBugReportClick({} as any);
expect(Modal.createDialog).toHaveBeenCalledWith(BugReportDialog, {
label: "react-tile-soft-crash",
error: updatedError,
});
});
it("opens the view source dialog with the current event", () => {
const mxEvent = createEvent("m.room.redaction");
const vm = createVm({ mxEvent });
vm.onViewSourceClick({} as any);
expect(Modal.createDialog).toHaveBeenCalledWith(
ViewSource,
{
mxEvent,
},
"mx_Dialog_viewsource",
);
});
it("does not open view source when developer mode is disabled", () => {
const vm = createVm({ developerMode: false });
vm.onViewSourceClick({} as any);
expect(mocked(Modal.createDialog)).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,336 @@
/*
* 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 { expect } from "@jest/globals";
import type { MockedObject } from "jest-mock";
import type { MatrixClient, IPreviewUrlResponse } from "matrix-js-sdk/src/matrix";
import { UrlPreviewGroupViewModel } from "../../../src/viewmodels/message-body/UrlPreviewGroupViewModel";
import type { UrlPreview } from "@element-hq/web-shared-components";
import { getMockClientWithEventEmitter, mkEvent } from "../../test-utils";
const IMAGE_MXC = "mxc://example.org/abc";
const BASIC_PREVIEW_OGDATA = {
"og:title": "This is an example!",
"og:description": "This is a description",
"og:type": "document",
"og:url": "https://example.org",
"og:site_name": "Example.org",
};
function getViewModel({ mediaVisible, visible } = { mediaVisible: true, visible: true }): {
vm: UrlPreviewGroupViewModel;
client: MockedObject<MatrixClient>;
onImageClicked: jest.Mock<void, [UrlPreview]>;
} {
const client = getMockClientWithEventEmitter({
getUrlPreview: jest.fn(),
mxcUrlToHttp: jest.fn(),
});
const onImageClicked = jest.fn<void, [UrlPreview]>();
const vm = new UrlPreviewGroupViewModel({
client,
mediaVisible,
visible,
onImageClicked,
mxEvent: mkEvent({
event: true,
user: "@foo:bar",
type: "m.room.message",
content: {},
id: "$id",
}),
});
return { vm, client, onImageClicked };
}
describe("UrlPreviewGroupViewModel", () => {
it("should return no previews by default", () => {
expect(getViewModel().vm.getSnapshot()).toMatchSnapshot();
});
it("should preview a single valid URL", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toMatchSnapshot();
});
it("should preview nested URLs but ignore some element types", async () => {
const { vm, client } = getViewModel();
vm.onTogglePreviewLimit();
client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA);
const msg = document.createElement("div");
msg.innerHTML = `
<ul>
<a href="https://example.org/1">Test1</a>
<li><a href="https://example.org/2">Test2</a></li>
<li>
<ol>
<li><a href="https://example.org/3">Test3</a></li>
</ol>
</li>
</ul>
<pre><a href="https://example.org">Test4</a></pre>
<code><a href="https://example.org">Test5</a></code>
<blockquote><a href="https://example.org">Test6</a></blockquote>`;
await vm.updateEventElement(msg);
const { previews } = vm.getSnapshot();
expect(previews).toHaveLength(3);
expect(previews).toMatchObject([
{
link: "https://example.org/1",
},
{
link: "https://example.org/2",
},
{
link: "https://example.org/3",
},
]);
});
it("should hide preview when invisible", async () => {
const { vm, client } = getViewModel({ visible: false, mediaVisible: true });
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toMatchSnapshot();
expect(client.getUrlPreview).not.toHaveBeenCalled();
});
it("should preview a URL with media", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce({
"og:title": "This is an example!",
"og:type": "document",
"og:url": "https://example.org",
"og:image": IMAGE_MXC,
"og:image:height": 128,
"og:image:width": 128,
"matrix:image:size": 10000,
});
// eslint-disable-next-line no-restricted-properties
client.mxcUrlToHttp.mockImplementation((url, width) => {
expect(url).toEqual(IMAGE_MXC);
if (width) {
return "https://example.org/image/thumb";
}
return "https://example.org/image/src";
});
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toMatchSnapshot();
});
it.each<Partial<IPreviewUrlResponse>>([
{ "matrix:image:size": 8191 },
{ "og:image:width": 95 },
{ "og:image:height": 95 },
])("should preview a URL with a site icon", async (extraResp) => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce({
"og:title": "This is an example!",
"og:type": "document",
"og:url": "https://example.org",
"og:image": IMAGE_MXC,
"og:image:height": 128,
"og:image:width": 128,
"matrix:image:size": 8193,
...extraResp,
});
// eslint-disable-next-line no-restricted-properties
client.mxcUrlToHttp.mockImplementation((url) => {
expect(url).toEqual(IMAGE_MXC);
return "https://example.org/image/src";
});
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0].siteIcon).toBeTruthy();
});
it("should ignore media when mediaVisible is false", async () => {
const { vm, client } = getViewModel({ mediaVisible: false, visible: true });
client.getUrlPreview.mockResolvedValueOnce({
"og:title": "This is an example!",
"og:type": "document",
"og:url": "https://example.org",
"og:image": IMAGE_MXC,
"og:image:height": 128,
"og:image:width": 128,
"matrix:image:size": 10000,
});
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toMatchSnapshot();
// eslint-disable-next-line no-restricted-properties
expect(client.mxcUrlToHttp).not.toHaveBeenCalled();
});
it("should deduplicate multiple versions of the same URL", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
const msg = document.createElement("div");
msg.innerHTML =
'<a href="https://example.org">Test</a><a href="https://example.org">Test</a><a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toMatchSnapshot();
expect(client.getUrlPreview).toHaveBeenCalledTimes(1);
});
it("should ignore failed previews", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockRejectedValue(new Error("Forced test failure"));
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toMatchSnapshot();
});
it("should handle image clicks", async () => {
const { vm, client, onImageClicked } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce({
"og:title": "This is an example!",
"og:type": "document",
"og:url": "https://example.org",
"og:image": IMAGE_MXC,
"og:image:height": 128,
"og:image:width": 128,
"matrix:image:size": 10000,
});
// eslint-disable-next-line no-restricted-properties
client.mxcUrlToHttp.mockImplementation((url, width) => {
expect(url).toEqual(IMAGE_MXC);
if (width) {
return "https://example.org/image/thumb";
}
return "https://example.org/image/src";
});
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
const { previews } = vm.getSnapshot();
vm.onImageClick(previews[0]);
expect(onImageClicked).toHaveBeenCalled();
});
it("should handle being hidden and shown by the user", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
await vm.onHideClick();
expect(vm.getSnapshot()).toMatchSnapshot();
await vm.onShowClick();
expect(vm.getSnapshot()).toMatchSnapshot();
});
describe("calculates author", () => {
it("should use the profile:username if provided", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce({ ...BASIC_PREVIEW_OGDATA, "profile:username": "my username" });
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0].author).toEqual("my username");
});
it("should use author if the og:type is an article", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce({
...BASIC_PREVIEW_OGDATA,
"og:type": "article",
"article:author": "my name",
});
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0].author).toEqual("my name");
});
it("should NOT use author if the author is a URL", async () => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce({
...BASIC_PREVIEW_OGDATA,
"og:type": "article",
"article:author": "https://junk.example.org/foo",
});
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0].author).toBeUndefined();
});
});
it.each([
{ text: "", href: "", hasPreview: false },
{ text: "test", href: "noprotocol.example.org", hasPreview: false },
{ text: "matrix link", href: "https://matrix.to", hasPreview: false },
{ text: "email", href: "mailto:example.org", hasPreview: false },
{ text: "", href: "https://example.org", hasPreview: true },
])("handles different kinds of links %s", async (item) => {
const { vm, client } = getViewModel();
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
const msg = document.createElement("div");
msg.innerHTML = `<a href="${item.href}">${item.text}</a>`;
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews).toHaveLength(item.hasPreview ? 1 : 0);
});
// og:url, og:type are ignored.
const baseOg = {
"og:url": "https://example.org",
"og:type": "document",
};
it.each<IPreviewUrlResponse>([
{ ...baseOg, "og:title": "Basic title" },
{ ...baseOg, "og:site_name": "Site name", "og:title": "" },
{ ...baseOg, "og:description": "A description", "og:title": "" },
{ ...baseOg, "og:title": "Cool blog", "og:site_name": "Cool site" },
{
...baseOg,
"og:title": "Media test",
// API *may* return a string, so check we parse correctly.
"og:image:height": "500" as unknown as number,
"og:image:width": 500,
"matrix:image:size": 10000,
"og:image": IMAGE_MXC,
},
])("handles different kinds of opengraph responses %s", async (og) => {
const { vm, client } = getViewModel();
// eslint-disable-next-line no-restricted-properties
client.mxcUrlToHttp.mockImplementation((url, width) => {
expect(url).toEqual(IMAGE_MXC);
if (width) {
return "https://example.org/image/thumb";
}
return "https://example.org/image/src";
});
client.getUrlPreview.mockResolvedValueOnce(og);
const msg = document.createElement("div");
msg.innerHTML = `<a href="https://example.org">test</a>`;
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0]).toMatchSnapshot();
});
it.each<string>(["og:video", "og:video:type", "og:audio"])("detects playable links via %s", async (property) => {
const { vm, client } = getViewModel();
// eslint-disable-next-line no-restricted-properties
client.mxcUrlToHttp.mockImplementation((url, width) => {
expect(url).toEqual(IMAGE_MXC);
if (width) {
return "https://example.org/image/thumb";
}
return "https://example.org/image/src";
});
client.getUrlPreview.mockResolvedValueOnce({
...BASIC_PREVIEW_OGDATA,
"og:image": IMAGE_MXC,
[property]: "anything",
});
const msg = document.createElement("div");
msg.innerHTML = `<a href="https://example.org">test</a>`;
await vm.updateEventElement(msg);
expect(vm.getSnapshot().previews[0].image?.playable).toEqual(true);
});
});
@@ -0,0 +1,464 @@
/*
* 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 { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { VideoBodyViewState } from "@element-hq/web-shared-components";
import { decode } from "blurhash";
import { type Media } from "@element-hq/element-web-module-api";
import SettingsStore from "../../../src/settings/SettingsStore";
import { ImageSize } from "../../../src/settings/enums/ImageSize";
import { mediaFromContent } from "../../../src/customisations/Media";
import { BLURHASH_FIELD } from "../../../src/utils/image-media";
import { type MediaEventHelper } from "../../../src/utils/MediaEventHelper";
import { VideoBodyViewModel } from "../../../src/viewmodels/message-body/VideoBodyViewModel";
jest.mock("../../../src/customisations/Media", () => ({
mediaFromContent: jest.fn(),
}));
jest.mock("blurhash", () => ({
decode: jest.fn(),
}));
describe("VideoBodyViewModel", () => {
const mockedMediaFromContent = jest.mocked(mediaFromContent);
const mockedDecode = jest.mocked(decode);
const videoRef = { current: null };
let imageSizeWatcher: ((...args: [unknown, unknown, unknown, unknown, ImageSize]) => void) | undefined;
const flushPromises = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
};
const createEvent = ({
body = "demo video",
content = {},
}: {
body?: string;
content?: Record<string, unknown>;
} = {}): MatrixEvent => {
const { info: infoOverride, ...restContent } = content;
return new MatrixEvent({
type: EventType.RoomMessage,
room_id: "!room:server",
event_id: "$video:server",
sender: "@alice:server",
content: {
msgtype: "m.video",
body,
url: "https://server/video.mp4",
...restContent,
info: {
w: 320,
h: 180,
mimetype: "video/mp4",
...(infoOverride as Record<string, unknown> | undefined),
},
},
});
};
const createMediaEventHelper = ({
encrypted,
thumbnailUrl = "blob:thumbnail",
sourceUrl = "blob:video",
sourceBlob = new Blob(["video"], { type: "video/mp4" }),
}: {
encrypted: boolean;
thumbnailUrl?: string | null | Promise<string | null>;
sourceUrl?: string | null | Promise<string | null>;
sourceBlob?: Blob | Promise<Blob>;
}): MediaEventHelper =>
({
media: { isEncrypted: encrypted },
thumbnailUrl: { value: Promise.resolve(thumbnailUrl) },
sourceUrl: { value: Promise.resolve(sourceUrl) },
sourceBlob: { value: Promise.resolve(sourceBlob) },
}) as unknown as MediaEventHelper;
const createVm = (overrides?: Partial<ConstructorParameters<typeof VideoBodyViewModel>[0]>): VideoBodyViewModel =>
new VideoBodyViewModel({
mxEvent: createEvent(),
mediaVisible: false,
videoRef,
...overrides,
});
const createMockMedia = (content: Record<string, any>): Media =>
({
isEncrypted: !!content.file,
srcMxc: content.url ?? "mxc://server/video",
thumbnailMxc: content.info?.thumbnail_url ?? undefined,
srcHttp: content.url ?? "https://server/video.mp4",
thumbnailHttp:
content.info?.thumbnail_url === null
? null
: (content.info?.thumbnail_url ?? "https://server/poster.jpg"),
hasThumbnail: content.info?.thumbnail_url !== null,
getThumbnailHttp: jest.fn(),
getThumbnailOfSourceHttp: jest.fn(),
getSquareThumbnailHttp: jest.fn(),
downloadSource: jest.fn(),
}) as unknown as Media;
beforeEach(() => {
const originalGetValue = SettingsStore.getValue;
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
if (setting === "Images.size") {
return ImageSize.Normal;
}
if (setting === "autoplayVideo") {
return false;
}
return originalGetValue(setting, ...args);
});
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_name, _roomId, callback) => {
imageSizeWatcher = callback as (...args: [unknown, unknown, unknown, unknown, ImageSize]) => void;
return "video-body-test-watch";
});
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
mockedMediaFromContent.mockImplementation((content) => createMockMedia(content));
mockedDecode.mockReturnValue(new Uint8ClampedArray(320 * 180 * 4));
});
afterEach(() => {
jest.restoreAllMocks();
imageSizeWatcher = undefined;
});
it("computes the initial hidden snapshot from props", () => {
const vm = createVm();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.HIDDEN);
expect(vm.getSnapshot().hiddenButtonLabel).toBeTruthy();
expect(vm.getSnapshot().maxWidth).toBe(320);
expect(vm.getSnapshot().maxHeight).toBe(180);
});
it("updates to ready when media becomes visible", () => {
const vm = createVm();
vm.setMediaVisible(true);
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.READY);
expect(vm.getSnapshot().src).toBe("https://server/video.mp4");
expect(vm.getSnapshot().poster).toBe("https://server/poster.jpg");
});
it("uses the export urls directly when rendering for export", () => {
const vm = createVm({
mxEvent: createEvent({
content: {
url: "https://server/fallback.mp4",
file: {
url: "mxc://server/export-video",
},
},
}),
mediaVisible: true,
forExport: true,
});
vm.loadInitialMediaIfVisible();
expect(vm.getSnapshot()).toMatchObject({
state: VideoBodyViewState.READY,
src: "mxc://server/export-video",
preload: "metadata",
poster: undefined,
});
});
it("updates controls and autoplay flags when interaction is inhibited", () => {
const vm = createVm({ mediaVisible: true });
vm.setInhibitInteraction(true);
expect(vm.getSnapshot().controls).toBe(false);
expect(vm.getSnapshot().muted).toBe(false);
expect(vm.getSnapshot().autoPlay).toBe(false);
});
it("forwards preview clicks", () => {
const onPreviewClick = jest.fn();
const vm = createVm({ onPreviewClick });
vm.onPreviewClick();
expect(onPreviewClick).toHaveBeenCalledTimes(1);
});
it("preloads encrypted video when autoplay is enabled", async () => {
const originalGetValue = SettingsStore.getValue;
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
if (setting === "Images.size") return ImageSize.Normal;
if (setting === "autoplayVideo") return true;
return originalGetValue(setting, ...args);
});
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: "blob:encrypted-poster",
sourceUrl: "blob:encrypted-video",
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.LOADING);
await flushPromises();
expect(vm.getSnapshot()).toMatchObject({
state: VideoBodyViewState.READY,
src: "blob:encrypted-video",
poster: "blob:encrypted-poster",
muted: true,
autoPlay: true,
});
});
it("keeps encrypted video lazy-loadable when autoplay is disabled", async () => {
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
info: {
mimetype: "video/quicktime",
},
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
expect(vm.getSnapshot()).toMatchObject({
state: VideoBodyViewState.READY,
src: "data:video/mp4,",
poster: "data:video/mp4,",
preload: "none",
autoPlay: false,
});
});
it("switches to the error state when encrypted preload fails", async () => {
jest.spyOn(logger, "warn").mockImplementation(jest.fn());
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: Promise.reject(new Error("decrypt failed")),
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.ERROR);
expect(vm.getSnapshot().errorLabel).toBeTruthy();
});
it("loads the encrypted source on play when only a placeholder url is present", async () => {
const play = jest.fn();
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
sourceUrl: "blob:played-video",
}),
mediaVisible: true,
videoRef: { current: { play } } as any,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
await vm.onPlay();
expect(vm.getSnapshot().src).toBe("blob:played-video");
expect(play).toHaveBeenCalledTimes(1);
});
it("shows an error when play is requested without encrypted media data", async () => {
const vm = createVm({
mxEvent: createEvent({
content: {
file: { url: "mxc://server/encrypted-video" },
},
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await vm.onPlay();
expect(vm.getSnapshot().state).toBe(VideoBodyViewState.ERROR);
});
it("recomputes dimensions when the image-size setting changes", () => {
const vm = createVm({
mxEvent: createEvent({
content: {
info: {
w: 1280,
h: 720,
},
},
}),
mediaVisible: false,
});
expect(vm.getSnapshot().maxWidth).toBe(324);
expect(vm.getSnapshot().maxHeight).toBe(182);
imageSizeWatcher?.(undefined, undefined, undefined, undefined, ImageSize.Large);
expect(vm.getSnapshot().maxWidth).toBe(800);
expect(vm.getSnapshot().maxHeight).toBe(450);
});
it("uses the blurhash poster while the thumbnail image is loading", () => {
const originalCreateElement = document.createElement.bind(document);
const originalImage = global.Image;
let imageOnLoad: (() => void) | undefined;
const context = {
createImageData: jest.fn((width: number, height: number) => ({
data: new Uint8ClampedArray(width * height * 4),
})),
putImageData: jest.fn(),
};
const canvas = {
width: 0,
height: 0,
getContext: jest.fn(() => context),
toDataURL: jest.fn(() => "data:image/png;base64,blurhash"),
};
jest.spyOn(document, "createElement").mockImplementation(((tagName: string) => {
if (tagName === "canvas") {
return canvas as any;
}
return originalCreateElement(tagName);
}) as typeof document.createElement);
class MockImage {
public onload?: () => void;
public set src(_value: string) {
imageOnLoad = this.onload;
}
}
global.Image = MockImage as unknown as typeof Image;
const vm = createVm({
mxEvent: createEvent({
content: {
info: {
[BLURHASH_FIELD]: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
},
},
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
expect(vm.getSnapshot().poster).toBe("data:image/png;base64,blurhash");
imageOnLoad?.();
expect(vm.getSnapshot().poster).toBe("https://server/poster.jpg");
global.Image = originalImage;
});
it("resets encrypted media state when the event changes", async () => {
const vm = createVm({
mxEvent: createEvent({
content: {
body: "first video",
file: { url: "mxc://server/video-a" },
},
}),
mediaEventHelper: createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
sourceUrl: "blob:first-video",
}),
mediaVisible: true,
});
vm.loadInitialMediaIfVisible();
await flushPromises();
expect(vm.getSnapshot().src).toBe("data:video/mp4,");
vm.setEvent(
createEvent({
body: "second video",
content: {
file: { url: "mxc://server/video-b" },
},
}),
createMediaEventHelper({
encrypted: true,
thumbnailUrl: null,
sourceUrl: "blob:second-video",
}),
);
await flushPromises();
expect(vm.getSnapshot().videoLabel).toBe("second video");
expect(vm.getSnapshot().src).toBe("data:video/mp4,");
});
it("does not emit for unchanged targeted setters", () => {
const event = createEvent();
const onPreviewClick = jest.fn();
const vm = createVm({
mxEvent: event,
mediaVisible: false,
onPreviewClick,
});
const listener = jest.fn();
vm.subscribe(listener);
vm.setEvent(event, undefined);
vm.setForExport(undefined);
vm.setInhibitInteraction(undefined);
vm.setMediaVisible(false);
vm.setOnPreviewClick(onPreviewClick);
expect(listener).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,218 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:description': 'A description',\\n 'og:title': ''\\n} 1`] = `
{
"author": undefined,
"description": undefined,
"image": undefined,
"link": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
"title": "A description",
}
`;
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:site_name': 'Site name',\\n 'og:title': ''\\n} 1`] = `
{
"author": undefined,
"description": undefined,
"image": undefined,
"link": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "Site name",
"title": "Site name",
}
`;
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Basic title'\\n} 1`] = `
{
"author": undefined,
"description": undefined,
"image": undefined,
"link": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
"title": "Basic title",
}
`;
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Cool blog',\\n 'og:site_name': 'Cool site'\\n} 1`] = `
{
"author": undefined,
"description": undefined,
"image": undefined,
"link": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "Cool site",
"title": "Cool blog",
}
`;
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Media test',\\n 'og:image:height': '500',\\n 'og:image:width': 500,\\n 'matrix:image:size': 10000,\\n 'og:image': 'mxc://example.org/abc'\\n} 1`] = `
{
"author": undefined,
"description": undefined,
"image": {
"alt": undefined,
"fileSize": 10000,
"height": 478,
"imageFull": "https://example.org/image/src",
"imageThumb": "https://example.org/image/thumb",
"playable": false,
"width": 478,
},
"link": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
"title": "Media test",
}
`;
exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the same URL 1`] = `
{
"overPreviewLimit": false,
"previews": [
{
"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!",
},
],
"previewsLimited": true,
"totalPreviewCount": 1,
}
`;
exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the user 1`] = `
{
"overPreviewLimit": false,
"previews": [],
"previewsLimited": true,
"totalPreviewCount": 1,
}
`;
exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the user 2`] = `
{
"overPreviewLimit": false,
"previews": [
{
"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!",
},
],
"previewsLimited": true,
"totalPreviewCount": 1,
}
`;
exports[`UrlPreviewGroupViewModel should hide preview when invisible 1`] = `
{
"overPreviewLimit": false,
"previews": [],
"previewsLimited": true,
"totalPreviewCount": 1,
}
`;
exports[`UrlPreviewGroupViewModel should ignore failed previews 1`] = `
{
"overPreviewLimit": false,
"previews": [],
"previewsLimited": true,
"totalPreviewCount": 1,
}
`;
exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false 1`] = `
{
"overPreviewLimit": false,
"previews": [
{
"author": undefined,
"description": undefined,
"image": undefined,
"link": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
"title": "This is an example!",
},
],
"previewsLimited": true,
"totalPreviewCount": 1,
}
`;
exports[`UrlPreviewGroupViewModel should preview a URL with media 1`] = `
{
"overPreviewLimit": false,
"previews": [
{
"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,
},
"link": "https://example.org",
"showTooltipOnLink": false,
"siteIcon": undefined,
"siteName": "example.org",
"title": "This is an example!",
},
],
"previewsLimited": true,
"totalPreviewCount": 1,
}
`;
exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = `
{
"overPreviewLimit": false,
"previews": [
{
"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!",
},
],
"previewsLimited": true,
"totalPreviewCount": 1,
}
`;
exports[`UrlPreviewGroupViewModel should return no previews by default 1`] = `
{
"overPreviewLimit": false,
"previews": [],
"previewsLimited": true,
"totalPreviewCount": 0,
}
`;