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

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
@@ -0,0 +1,754 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
EventTimeline,
type EventTimelineSet,
EventType,
type IRoomEvent,
type MatrixClient,
MatrixEvent,
MsgType,
Relations,
RelationType,
Room,
RoomMember,
RoomState,
} from "matrix-js-sdk/src/matrix";
import fetchMock from "@fetch-mock/jest";
import escapeHtml from "escape-html";
import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { mocked } from "jest-mock";
import { filterConsole, mkReaction, mkStubRoom, REPEATABLE_DATE, stubClient } from "../../../test-utils";
import { ExportType, type IExportOptions } from "../../../../src/utils/exportUtils/exportUtils";
import SdkConfig from "../../../../src/SdkConfig";
import HTMLExporter from "../../../../src/utils/exportUtils/HtmlExport";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { mediaFromMxc } from "../../../../src/customisations/Media";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { SdkContextClass } from "../../../../src/contexts/SDKContext.ts";
jest.mock("jszip");
jest.mock("../../../../src/settings/SettingsStore");
const EVENT_MESSAGE: IRoomEvent = {
event_id: "$1",
type: EventType.RoomMessage,
sender: "@bob:example.com",
origin_server_ts: 0,
content: {
msgtype: "m.text",
body: "Message",
avatar_url: "mxc://example.org/avatar.bmp",
},
};
const EVENT_ATTACHMENT: IRoomEvent = {
event_id: "$2",
type: EventType.RoomMessage,
sender: "@alice:example.com",
origin_server_ts: 1,
content: {
msgtype: MsgType.File,
body: "hello.txt",
filename: "hello.txt",
url: "mxc://example.org/test-id",
},
};
const EVENT_ATTACHMENT_MALFORMED: IRoomEvent = {
event_id: "$2",
type: EventType.RoomMessage,
sender: "@alice:example.com",
origin_server_ts: 1,
content: {
msgtype: MsgType.File,
body: "hello.txt",
file: {
url: undefined,
},
},
};
const EVENT_MENTION: IRoomEvent = {
event_id: "$4",
type: EventType.RoomMessage,
sender: "@bob:example.com",
origin_server_ts: 0,
content: {
"msgtype": "m.text",
"body": "Message Alex",
"format": "org.matrix.custom.html",
"formatted_body": 'Message <a href="https://matrix.to/#/@alex:example.org">@alex:example.org</a>',
"m.mentions": { user_ids: ["@alex:example.org"] },
},
};
describe("HTMLExport", () => {
let client: jest.Mocked<MatrixClient>;
let room: Room;
filterConsole(
"Starting export",
"events in", // Fetched # events in # seconds
"events so far",
"Export successful!",
"does not have an m.room.create event",
"Creating HTML",
"Generating a ZIP",
"Cleaning up",
);
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
jest.setSystemTime(REPEATABLE_DATE);
client = stubClient() as jest.Mocked<MatrixClient>;
SdkContextClass.instance.client = client;
DMRoomMap.makeShared(client);
room = new Room("!myroom:example.org", client, "@me:example.org");
client.getRoom.mockReturnValue(room);
// Set up a default mock that uses the actual SettingsStore implementation
const actualSettingsStore = jest.requireActual("../../../../src/settings/SettingsStore").default;
mocked(SettingsStore).getValue.mockImplementation((name: any, roomId?: any, excludeDefault?: any): any => {
// Default to the real implementation
return actualSettingsStore.getValue(name, roomId, excludeDefault);
});
});
function mockMessages(...events: IRoomEvent[]): void {
client.createMessagesRequest.mockImplementation((_roomId, fromStr, limit = 30) => {
const from = fromStr === null ? 0 : parseInt(fromStr);
const chunk = events.slice(from, limit);
return Promise.resolve({
chunk,
start: from.toString(),
end: (from + limit).toString(),
});
});
}
/** Retrieve a map of files within the zip. */
function getFiles(exporter: HTMLExporter): { [filename: string]: Blob } {
//@ts-ignore private access
const files = exporter.files;
return files.reduce((d, f) => ({ ...d, [f.name]: f.blob }), {});
}
function getMessageFile(exporter: HTMLExporter): Blob {
const files = getFiles(exporter);
return files["messages.html"]!;
}
/** set a mock fetch response for an MXC */
function mockMxc(mxc: string, body: string) {
const media = mediaFromMxc(mxc, client);
fetchMock.get(media.srcHttp!, body);
}
function mockReactionForMessage(message: IRoomEvent): MatrixEvent {
const firstMessage = new MatrixEvent(message);
const reaction = mkReaction(firstMessage);
const relationsContainer = {
getRelations: jest.fn(),
getChildEventsForEvent: jest.fn(),
} as unknown as RelationsContainer;
const relations = new Relations(RelationType.Annotation, EventType.Reaction, client);
relations.addEvent(reaction);
relationsContainer.getChildEventsForEvent = jest
.fn()
.mockImplementation(
(eventId: string, relationType: RelationType | string, eventType: EventType | string) => {
if (eventId === firstMessage.getId()) {
return relations;
}
},
);
const timelineSet = {
relations: relationsContainer,
getLiveTimeline: () => timeline,
} as unknown as EventTimelineSet;
const timeline = new EventTimeline(timelineSet);
room.getUnfilteredTimelineSet = jest.fn().mockReturnValue(timelineSet);
return reaction;
}
it("should throw when created with invalid config for LastNMessages", async () => {
expect(
() =>
new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: undefined,
},
() => {},
),
).toThrow("Invalid export options");
});
it("should have an SDK-branded destination file name", () => {
const roomName = "My / Test / Room: Welcome";
const stubOptions: IExportOptions = {
attachmentsIncluded: false,
maxSize: 50000000,
numberOfMessages: 40,
};
const stubRoom = mkStubRoom("!myroom:example.org", roomName, client);
const exporter = new HTMLExporter(stubRoom, ExportType.LastNMessages, stubOptions, () => {});
expect(exporter.destinationFileName).toMatchSnapshot();
SdkConfig.put({ brand: "BrandedChat/WithSlashes/ForFun" });
expect(exporter.destinationFileName).toMatchSnapshot();
});
it("should export", async () => {
const events = [...Array(50)].map<IRoomEvent>((_, i) => ({
event_id: `${i}`,
type: EventType.RoomMessage,
sender: `@user${i}:example.com`,
origin_server_ts: 5_000 + i * 1000,
content: {
msgtype: "m.text",
body: `Message #${i}`,
},
}));
mockReactionForMessage(events[0]);
mockMessages(...events);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: events.length,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toMatchSnapshot();
}, 10000);
it("should include the room's avatar", async () => {
mockMessages(EVENT_MESSAGE);
const mxc = "mxc://www.example.com/avatars/nice-room.jpeg";
const avatar = "011011000110111101101100";
jest.spyOn(room, "getMxcAvatarUrl").mockReturnValue(mxc);
mockMxc(mxc, avatar);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const files = getFiles(exporter);
expect(await files["room.png"]!.text()).toBe(avatar);
});
it("should include the creation event", async () => {
const creator = "@bob:example.com";
mockMessages(EVENT_MESSAGE);
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomCreate,
event_id: "$00001",
room_id: room.roomId,
sender: creator,
origin_server_ts: 0,
content: {},
state_key: "",
}),
]);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
expect(await getMessageFile(exporter).text()).toContain(`${creator} created this room.`);
});
it("should include the topic", async () => {
const topic = ":^-) (-^:";
mockMessages(EVENT_MESSAGE);
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomTopic,
event_id: "$00001",
room_id: room.roomId,
sender: "@alice:example.com",
origin_server_ts: 0,
content: { topic },
state_key: "",
}),
]);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
expect(await getMessageFile(exporter).text()).toContain(`Topic: ${topic}`);
});
it("should include avatars", async () => {
mockMessages(EVENT_MESSAGE);
jest.spyOn(RoomMember.prototype, "getMxcAvatarUrl").mockReturnValue("mxc://example.org/avatar.bmp");
const avatarContent = "this is a bitmap all the pixels are red :^-)";
mockMxc("mxc://example.org/avatar.bmp", avatarContent);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
// Ensure that the avatar is present
const files = getFiles(exporter);
const file = files["users/@bob-example.com.png"];
expect(file).not.toBeUndefined();
// Ensure it has the expected content
expect(await file.text()).toBe(avatarContent);
});
it("should handle when an event has no sender", async () => {
const EVENT_MESSAGE_NO_SENDER: IRoomEvent = {
event_id: "$1",
type: EventType.RoomMessage,
sender: "",
origin_server_ts: 0,
content: {
msgtype: "m.text",
body: "Message with no sender",
},
};
mockMessages(EVENT_MESSAGE_NO_SENDER);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toContain(EVENT_MESSAGE_NO_SENDER.content.body);
});
it("should handle when events sender cannot be found in room state", async () => {
mockMessages(EVENT_MESSAGE);
jest.spyOn(RoomState.prototype, "getSentinelMember").mockReturnValue(null);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toContain(EVENT_MESSAGE.content.body);
});
it("should include attachments", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
const attachmentBody = "Lorem ipsum dolor sit amet";
mockMxc("mxc://example.org/test-id", attachmentBody);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: true,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
// Ensure that the attachment is present
const files = getFiles(exporter);
const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))!];
expect(file).not.toBeUndefined();
// Ensure that the attachment has the expected content
const text = await file.text();
expect(text).toBe(attachmentBody);
});
it("should handle attachments with identical names and dates", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT, EVENT_ATTACHMENT);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: true,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const files = getFiles(exporter);
// There should be 5 files: 2 attachments, 1 html file, 1 css file and 1 js file
expect(Object.keys(files)).toHaveLength(5);
// Ensure that the attachment is present
const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))!];
expect(file).not.toBeUndefined();
// Ensure that the duplicate attachment has been uniquely named
const duplicateFile = files[Object.keys(files).find((k) => k.endsWith("(1).txt"))!];
expect(duplicateFile).not.toBeUndefined();
});
it("should handle when attachment cannot be fetched", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT_MALFORMED, EVENT_ATTACHMENT);
const attachmentBody = "Lorem ipsum dolor sit amet";
mockMxc("mxc://example.org/test-id", attachmentBody);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: true,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
// good attachment present
const files = getFiles(exporter);
const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))!];
expect(file).not.toBeUndefined();
// Ensure that the attachment has the expected content
const text = await file.text();
expect(text).toBe(attachmentBody);
// messages export still successful
const messagesFile = getMessageFile(exporter);
expect(await messagesFile.text()).toBeTruthy();
});
it("should handle when attachment srcHttp is falsy", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
const attachmentBody = "Lorem ipsum dolor sit amet";
mockMxc("mxc://example.org/test-id", attachmentBody);
jest.spyOn(client, "mxcUrlToHttp").mockReturnValue(null);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: true,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
// attachment not present
const files = getFiles(exporter);
const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))!];
expect(file).toBeUndefined();
// messages export still successful
const messagesFile = getMessageFile(exporter);
expect(await messagesFile.text()).toBeTruthy();
});
it("should omit attachments", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
// Ensure that the attachment is present
const files = getFiles(exporter);
for (const fileName of Object.keys(files)) {
expect(fileName).not.toMatch(/^files\/hello/);
}
});
it("should add link to next and previous file", async () => {
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 5000,
},
() => {},
);
// test link to the first page
//@ts-ignore private access
let result = await exporter.wrapHTML("", 0, 3);
expect(result).not.toContain("Previous group of messages");
expect(result).toContain(
'<div style="text-align:center;margin:10px"><a href="./messages2.html" style="font-weight:bold">Next group of messages</a></div>',
);
// test link for a middle page
//@ts-ignore private access
result = await exporter.wrapHTML("", 1, 3);
expect(result).toContain(
'<div style="text-align:center"><a href="./messages.html" style="font-weight:bold">Previous group of messages</a></div>',
);
expect(result).toContain(
'<div style="text-align:center;margin:10px"><a href="./messages3.html" style="font-weight:bold">Next group of messages</a></div>',
);
// test link for last page
//@ts-ignore private access
result = await exporter.wrapHTML("", 2, 3);
expect(result).toContain(
'<div style="text-align:center"><a href="./messages2.html" style="font-weight:bold">Previous group of messages</a></div>',
);
expect(result).not.toContain("Next group of messages");
});
it("should not leak javascript from room names or topics", async () => {
const name = "<svg onload=alert(3)>";
const topic = "<svg onload=alert(5)>";
mockMessages(EVENT_MESSAGE);
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomName,
event_id: "$00001",
room_id: room.roomId,
sender: "@alice:example.com",
origin_server_ts: 0,
content: { name },
state_key: "",
}),
new MatrixEvent({
type: EventType.RoomTopic,
event_id: "$00002",
room_id: room.roomId,
sender: "@alice:example.com",
origin_server_ts: 1,
content: { topic },
state_key: "",
}),
]);
room.recalculate();
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const html = await getMessageFile(exporter).text();
expect(html).not.toContain(`${name}`);
expect(html).toContain(`${escapeHtml(name)}`);
expect(html).not.toContain(`${topic}`);
expect(html).toContain(`Topic: ${escapeHtml(topic)}`);
});
it("should not make /messages requests when exporting 'Current Timeline'", async () => {
client.createMessagesRequest.mockRejectedValue(new Error("Should never be called"));
room.addLiveEvents(
[
new MatrixEvent({
event_id: `$eventId`,
type: EventType.RoomMessage,
sender: client.getSafeUserId(),
origin_server_ts: 123456789,
content: {
msgtype: "m.text",
body: `testing testing`,
},
}),
],
{ addToState: true },
);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toContain("testing testing");
expect(client.createMessagesRequest).not.toHaveBeenCalled();
});
it("should include reactions", async () => {
const reaction = mockReactionForMessage(EVENT_MESSAGE);
mockMessages(EVENT_MESSAGE);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toContain(reaction.getContent()["m.relates_to"]?.key);
});
it("should not crash when jump to date flag is enabled", async () => {
// Override just the feature flag for this specific test
const originalMock = mocked(SettingsStore).getValue.getMockImplementation();
mocked(SettingsStore).getValue.mockImplementation((name: any, roomId?: any, excludeDefault?: any): any => {
if (name === "feature_jump_to_date") {
return true;
}
// Fallback to the default mock implementation set in beforeEach
return originalMock!(name, roomId, excludeDefault);
});
mockMessages(EVENT_MESSAGE);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(file).not.toBeUndefined();
});
it("should not crash when exporting mentions", async () => {
mockMessages(EVENT_MESSAGE, EVENT_MENTION);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(file).not.toBeUndefined();
});
});
@@ -0,0 +1,31 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import JSONExporter from "../../../../src/utils/exportUtils/JSONExport";
import { createTestClient, mkStubRoom, REPEATABLE_DATE } from "../../../test-utils";
import { ExportType, type IExportOptions } from "../../../../src/utils/exportUtils/exportUtils";
describe("JSONExport", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(REPEATABLE_DATE);
});
it("should have a Matrix-branded destination file name", () => {
const roomName = "My / Test / Room: Welcome";
const client = createTestClient();
const stubOptions: IExportOptions = {
attachmentsIncluded: false,
maxSize: 50000000,
};
const stubRoom = mkStubRoom("!myroom:example.org", roomName, client);
const exporter = new JSONExporter(stubRoom, ExportType.Timeline, stubOptions, () => {});
expect(exporter.destinationFileName).toMatchSnapshot();
});
});
@@ -0,0 +1,64 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
import { createTestClient, mkStubRoom, REPEATABLE_DATE } from "../../../test-utils";
import { ExportType, type IExportOptions } from "../../../../src/utils/exportUtils/exportUtils";
import PlainTextExporter from "../../../../src/utils/exportUtils/PlainTextExport";
import SettingsStore from "../../../../src/settings/SettingsStore";
class TestablePlainTextExporter extends PlainTextExporter {
public async testCreateOutput(events: MatrixEvent[]): Promise<string> {
return this.createOutput(events);
}
}
describe("PlainTextExport", () => {
let stubOptions: IExportOptions;
let stubRoom: Room;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(REPEATABLE_DATE);
const roomName = "My / Test / Room: Welcome";
const client = createTestClient();
stubOptions = {
attachmentsIncluded: false,
maxSize: 50000000,
};
stubRoom = mkStubRoom("!myroom:example.org", roomName, client);
});
it("should have a Matrix-branded destination file name", () => {
const exporter = new PlainTextExporter(stubRoom, ExportType.Timeline, stubOptions, () => {});
expect(exporter.destinationFileName).toMatchSnapshot();
});
it.each([
[24, false, "Fri, Apr 16, 2021, 17:20:00 - @alice:example.com: Hello, world!\n"],
[12, true, "Fri, Apr 16, 2021, 5:20:00 PM - @alice:example.com: Hello, world!\n"],
])("should return text with %i hr time format", async (hour: number, setting: boolean, expectedMessage: string) => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string): any =>
settingName === "showTwelveHourTimestamps" ? setting : undefined,
);
const events: MatrixEvent[] = [
new MatrixEvent({
type: "m.room.message",
content: {
body: "Hello, world!",
},
sender: "@alice:example.com",
origin_server_ts: 1618593600000,
}),
];
const exporter = new TestablePlainTextExporter(stubRoom, ExportType.Timeline, stubOptions, () => {});
const output = await exporter.testCreateOutput(events);
expect(output).toBe(expectedMessage);
});
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`JSONExport should have a Matrix-branded destination file name 1`] = `"matrix - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.json"`;
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`PlainTextExport should have a Matrix-branded destination file name 1`] = `"matrix - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.txt"`;
@@ -0,0 +1,108 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "@fetch-mock/jest";
import getExportCSS from "../../../../src/utils/exportUtils/exportCSS";
describe("exportCSS", () => {
describe("getExportCSS", () => {
beforeEach(() => {
document.head.replaceChildren();
});
it("supports documents missing stylesheets", async () => {
const css = await getExportCSS(new Set());
expect(css).not.toContain("color-scheme: light");
});
it("fetches export stylesheets and filters unused css", async () => {
document.head.innerHTML = `
<link rel="stylesheet" href="/bundle.css" />
<link rel="stylesheet" href="/theme-light.css" />
<link rel="stylesheet" href="/theme-dark.css" />
`;
fetchMock.get(
"end:/bundle.css",
`
@font-face { font-family: Inter; src: url(inter.woff2); }
body { margin: 0; }
.mx_Used { font-family: Inter; color: #111111; }
.mx_Code { font-family: Fira Code; }
.mx_Unused { color: #123456; }
.mx_Empty {}
.mx_Used, .mx_UnusedComma { color: #abcdef; }
@media screen {
.mx_Used { --cpd-font-family-sans: "Inter"; }
.mx_UnusedNested { color: #654321; }
}
@supports (display: grid) {
.mx_UnusedSupported { color: #fedcba; }
}
`,
);
fetchMock.get("end:/theme-light.css", ".mx_Theme { color: #222222; }");
const css = await getExportCSS(new Set(["mx_Used", "mx_Code", "mx_Theme"]));
expect(fetchMock).toHaveFetchedTimes(1, "end:/bundle.css");
expect(fetchMock).toHaveFetchedTimes(1, "end:/theme-light.css");
expect(fetchMock).not.toHaveFetched("end:/theme-dark.css");
expect(css).toContain("margin:0");
expect(css).toContain("#111");
expect(css).toContain("#222");
expect(css).toContain("#abcdef");
expect(css).not.toContain("@font-face");
expect(css).not.toContain("#123456");
expect(css).not.toContain("#654321");
expect(css).not.toContain("#fedcba");
expect(css).not.toContain("font-family:Inter");
expect(css).not.toContain("font-family:Fira Code");
expect(css).toContain("BlinkMacSystemFont");
expect(css).toContain("Menlo, Consolas");
});
it("keeps export-only css in the app cascade layer after layered font rules", async () => {
document.head.innerHTML = `
<link rel="stylesheet" href="/bundle.css" />
`;
fetchMock.get(
"end:/bundle.css",
`
@layer compound-tokens, compound-web, shared-components, app-web;
@layer compound-web {
.mx_Typography {
font: var(--cpd-font-heading-lg-regular);
}
}
@layer app-web {
body {
font: var(--cpd-font-body-md-regular) !important;
}
}
`,
);
const css = await getExportCSS(new Set(["mx_Typography"]));
expect(css).toContain("@layer compound-web{.mx_Typography{font:var(--cpd-font-heading-lg-regular)}}");
expect(css).toContain("@layer app-web{body{font:var(--cpd-font-body-md-regular)!important}}");
const exportCssLayerIndex = css.indexOf("@layer app-web {");
expect(exportCssLayerIndex).toBeGreaterThan(
css.indexOf("@layer app-web{body{font:var(--cpd-font-body-md-regular)!important}}"),
);
expect(css.slice(exportCssLayerIndex)).toBe("@layer app-web {css-file-stub}");
});
});
});