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
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
+218
@@ -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,
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user