Fix "[object Promise]" appearing in HTML exports (#9975)

Fixes https://github.com/vector-im/element-web/issues/24272
pull/28788/head^2
Clark Fischer 2023-01-30 14:31:32 +00:00 committed by GitHub
parent 3e2bf5640e
commit 4c1e4f5127
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 895 additions and 84 deletions

View File

@ -175,7 +175,7 @@ function withinCurrentYear(prevDate: Date, nextDate: Date): boolean {
return prevDate.getFullYear() === nextDate.getFullYear(); return prevDate.getFullYear() === nextDate.getFullYear();
} }
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean { export function wantsDateSeparator(prevEventDate: Date | undefined, nextEventDate: Date | undefined): boolean {
if (!nextEventDate || !prevEventDate) { if (!nextEventDate || !prevEventDate) {
return false; return false;
} }

View File

@ -72,7 +72,7 @@ const groupedStateEvents = [
// check if there is a previous event and it has the same sender as this event // check if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
export function shouldFormContinuation( export function shouldFormContinuation(
prevEvent: MatrixEvent, prevEvent: MatrixEvent | null,
mxEvent: MatrixEvent, mxEvent: MatrixEvent,
showHiddenEvents: boolean, showHiddenEvents: boolean,
threadsEnabled: boolean, threadsEnabled: boolean,
@ -821,7 +821,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// here. // here.
return !this.props.canBackPaginate; return !this.props.canBackPaginate;
} }
return wantsDateSeparator(prevEvent.getDate(), nextEventDate); return wantsDateSeparator(prevEvent.getDate() || undefined, nextEventDate);
} }
// Get a list of read receipts that should be shown next to this event // Get a list of read receipts that should be shown next to this event

View File

@ -130,7 +130,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent<IProps
} }
const baseEventId = this.props.mxEvent.getId(); const baseEventId = this.props.mxEvent.getId();
allEvents.forEach((e, i) => { allEvents.forEach((e, i) => {
if (!lastEvent || wantsDateSeparator(lastEvent.getDate(), e.getDate())) { if (!lastEvent || wantsDateSeparator(lastEvent.getDate() || undefined, e.getDate() || undefined)) {
nodes.push( nodes.push(
<li key={e.getTs() + "~"}> <li key={e.getTs() + "~"}>
<DateSeparator roomId={e.getRoomId()} ts={e.getTs()} /> <DateSeparator roomId={e.getRoomId()} ts={e.getTs()} />

View File

@ -84,7 +84,7 @@ export default class SearchResultTile extends React.Component<IProps> {
// is this a continuation of the previous message? // is this a continuation of the previous message?
const continuation = const continuation =
prevEv && prevEv &&
!wantsDateSeparator(prevEv.getDate(), mxEv.getDate()) && !wantsDateSeparator(prevEv.getDate() || undefined, mxEv.getDate() || undefined) &&
shouldFormContinuation( shouldFormContinuation(
prevEv, prevEv,
mxEv, mxEv,
@ -96,7 +96,10 @@ export default class SearchResultTile extends React.Component<IProps> {
let lastInSection = true; let lastInSection = true;
const nextEv = timeline[j + 1]; const nextEv = timeline[j + 1];
if (nextEv) { if (nextEv) {
const willWantDateSeparator = wantsDateSeparator(mxEv.getDate(), nextEv.getDate()); const willWantDateSeparator = wantsDateSeparator(
mxEv.getDate() || undefined,
nextEv.getDate() || undefined,
);
lastInSection = lastInSection =
willWantDateSeparator || willWantDateSeparator ||
mxEv.getSender() !== nextEv.getSender() || mxEv.getSender() !== nextEv.getSender() ||

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021, 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -66,7 +66,7 @@ export default class HTMLExporter extends Exporter {
} }
protected async getRoomAvatar(): Promise<ReactNode> { protected async getRoomAvatar(): Promise<ReactNode> {
let blob: Blob; let blob: Blob | undefined = undefined;
const avatarUrl = Avatar.avatarUrlForRoom(this.room, 32, 32, "crop"); const avatarUrl = Avatar.avatarUrlForRoom(this.room, 32, 32, "crop");
const avatarPath = "room.png"; const avatarPath = "room.png";
if (avatarUrl) { if (avatarUrl) {
@ -85,7 +85,7 @@ export default class HTMLExporter extends Exporter {
height={32} height={32}
name={this.room.name} name={this.room.name}
title={this.room.name} title={this.room.name}
url={blob ? avatarPath : null} url={blob ? avatarPath : ""}
resizeMethod="crop" resizeMethod="crop"
/> />
); );
@ -96,9 +96,9 @@ export default class HTMLExporter extends Exporter {
const roomAvatar = await this.getRoomAvatar(); const roomAvatar = await this.getRoomAvatar();
const exportDate = formatFullDateNoDayNoTime(new Date()); const exportDate = formatFullDateNoDayNoTime(new Date());
const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender(); const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
const creatorName = this.room?.getMember(creator)?.rawDisplayName || creator; const creatorName = (creator ? this.room.getMember(creator)?.rawDisplayName : creator) || creator;
const exporter = this.client.getUserId(); const exporter = this.client.getUserId()!;
const exporterName = this.room?.getMember(exporter)?.rawDisplayName; const exporterName = this.room.getMember(exporter)?.rawDisplayName;
const topic = this.room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || ""; const topic = this.room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || "";
const createdText = _t("%(creatorName)s created this room.", { const createdText = _t("%(creatorName)s created this room.", {
creatorName, creatorName,
@ -217,20 +217,19 @@ export default class HTMLExporter extends Exporter {
</html>`; </html>`;
} }
protected getAvatarURL(event: MatrixEvent): string { protected getAvatarURL(event: MatrixEvent): string | undefined {
const member = event.sender; const member = event.sender;
return ( const avatarUrl = member?.getMxcAvatarUrl();
member.getMxcAvatarUrl() && mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(30, 30, "crop") return avatarUrl ? mediaFromMxc(avatarUrl).getThumbnailOfSourceHttp(30, 30, "crop") : undefined;
);
} }
protected async saveAvatarIfNeeded(event: MatrixEvent): Promise<void> { protected async saveAvatarIfNeeded(event: MatrixEvent): Promise<void> {
const member = event.sender; const member = event.sender!;
if (!this.avatars.has(member.userId)) { if (!this.avatars.has(member.userId)) {
try { try {
const avatarUrl = this.getAvatarURL(event); const avatarUrl = this.getAvatarURL(event);
this.avatars.set(member.userId, true); this.avatars.set(member.userId, true);
const image = await fetch(avatarUrl); const image = await fetch(avatarUrl!);
const blob = await image.blob(); const blob = await image.blob();
this.addFile(`users/${member.userId.replace(/:/g, "-")}.png`, blob); this.addFile(`users/${member.userId.replace(/:/g, "-")}.png`, blob);
} catch (err) { } catch (err) {
@ -239,19 +238,19 @@ export default class HTMLExporter extends Exporter {
} }
} }
protected async getDateSeparator(event: MatrixEvent): Promise<string> { protected getDateSeparator(event: MatrixEvent): string {
const ts = event.getTs(); const ts = event.getTs();
const dateSeparator = ( const dateSeparator = (
<li key={ts}> <li key={ts}>
<DateSeparator forExport={true} key={ts} roomId={event.getRoomId()} ts={ts} /> <DateSeparator forExport={true} key={ts} roomId={event.getRoomId()!} ts={ts} />
</li> </li>
); );
return renderToStaticMarkup(dateSeparator); return renderToStaticMarkup(dateSeparator);
} }
protected async needsDateSeparator(event: MatrixEvent, prevEvent: MatrixEvent): Promise<boolean> { protected needsDateSeparator(event: MatrixEvent, prevEvent: MatrixEvent | null): boolean {
if (prevEvent == null) return true; if (!prevEvent) return true;
return wantsDateSeparator(prevEvent.getDate(), event.getDate()); return wantsDateSeparator(prevEvent.getDate() || undefined, event.getDate() || undefined);
} }
public getEventTile(mxEv: MatrixEvent, continuation: boolean): JSX.Element { public getEventTile(mxEv: MatrixEvent, continuation: boolean): JSX.Element {
@ -264,9 +263,7 @@ export default class HTMLExporter extends Exporter {
isRedacted={mxEv.isRedacted()} isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()} replacingEventId={mxEv.replacingEventId()}
forExport={true} forExport={true}
readReceipts={null}
alwaysShowTimestamps={true} alwaysShowTimestamps={true}
readReceiptMap={null}
showUrlPreview={false} showUrlPreview={false}
checkUnmounting={() => false} checkUnmounting={() => false}
isTwelveHour={false} isTwelveHour={false}
@ -275,7 +272,6 @@ export default class HTMLExporter extends Exporter {
permalinkCreator={this.permalinkCreator} permalinkCreator={this.permalinkCreator}
lastSuccessful={false} lastSuccessful={false}
isSelectedEvent={false} isSelectedEvent={false}
getRelationsForEvent={null}
showReactions={false} showReactions={false}
layout={Layout.Group} layout={Layout.Group}
showReadReceipts={false} showReadReceipts={false}
@ -286,7 +282,8 @@ export default class HTMLExporter extends Exporter {
} }
protected async getEventTileMarkup(mxEv: MatrixEvent, continuation: boolean, filePath?: string): Promise<string> { protected async getEventTileMarkup(mxEv: MatrixEvent, continuation: boolean, filePath?: string): Promise<string> {
const hasAvatar = !!this.getAvatarURL(mxEv); const avatarUrl = this.getAvatarURL(mxEv);
const hasAvatar = !!avatarUrl;
if (hasAvatar) await this.saveAvatarIfNeeded(mxEv); if (hasAvatar) await this.saveAvatarIfNeeded(mxEv);
const EventTile = this.getEventTile(mxEv, continuation); const EventTile = this.getEventTile(mxEv, continuation);
let eventTileMarkup: string; let eventTileMarkup: string;
@ -312,8 +309,8 @@ export default class HTMLExporter extends Exporter {
eventTileMarkup = eventTileMarkup.replace(/<span class="mx_MFileBody_info_icon".*?>.*?<\/span>/, ""); eventTileMarkup = eventTileMarkup.replace(/<span class="mx_MFileBody_info_icon".*?>.*?<\/span>/, "");
if (hasAvatar) { if (hasAvatar) {
eventTileMarkup = eventTileMarkup.replace( eventTileMarkup = eventTileMarkup.replace(
encodeURI(this.getAvatarURL(mxEv)).replace(/&/g, "&amp;"), encodeURI(avatarUrl).replace(/&/g, "&amp;"),
`users/${mxEv.sender.userId.replace(/:/g, "-")}.png`, `users/${mxEv.sender!.userId.replace(/:/g, "-")}.png`,
); );
} }
return eventTileMarkup; return eventTileMarkup;

View File

@ -58,8 +58,8 @@ const getExportCSS = async (usedClasses: Set<string>): Promise<string> => {
// If the light theme isn't loaded we will have to fetch & parse it manually // If the light theme isn't loaded we will have to fetch & parse it manually
if (!stylesheets.some(isLightTheme)) { if (!stylesheets.some(isLightTheme)) {
const href = document.querySelector<HTMLLinkElement>('link[rel="stylesheet"][href$="theme-light.css"]').href; const href = document.querySelector<HTMLLinkElement>('link[rel="stylesheet"][href$="theme-light.css"]')?.href;
stylesheets.push(await getRulesFromCssFile(href)); if (href) stylesheets.push(await getRulesFromCssFile(href));
} }
let css = ""; let css = "";

View File

@ -0,0 +1,83 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { render, RenderResult } from "@testing-library/react";
import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { flushPromises, mkMessage, stubClient } from "../../../test-utils";
import MessageEditHistoryDialog from "../../../../src/components/views/dialogs/MessageEditHistoryDialog";
describe("<MessageEditHistory />", () => {
const roomId = "!aroom:example.com";
let client: jest.Mocked<MatrixClient>;
let event: MatrixEvent;
beforeEach(() => {
client = stubClient() as jest.Mocked<MatrixClient>;
event = mkMessage({
event: true,
user: "@user:example.com",
room: "!room:example.com",
msg: "My Great Message",
});
});
async function renderComponent(): Promise<RenderResult> {
const result = render(<MessageEditHistoryDialog mxEvent={event} onFinished={jest.fn()} />);
await flushPromises();
return result;
}
function mockEdits(...edits: { msg: string; ts: number | undefined }[]) {
client.relations.mockImplementation(() =>
Promise.resolve({
events: edits.map(
(e) =>
new MatrixEvent({
type: EventType.RoomMessage,
room_id: roomId,
origin_server_ts: e.ts,
content: {
body: e.msg,
},
}),
),
}),
);
}
it("should match the snapshot", async () => {
mockEdits({ msg: "My Great Massage", ts: 1234 });
const { container } = await renderComponent();
expect(container).toMatchSnapshot();
});
it("should support events with ", async () => {
mockEdits(
{ msg: "My Great Massage", ts: undefined },
{ msg: "My Great Massage?", ts: undefined },
{ msg: "My Great Missage", ts: undefined },
);
const { container } = await renderComponent();
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,322 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MessageEditHistory /> should match the snapshot 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_MessageEditHistoryDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header mx_Dialog_headerWithCancel"
>
<h2
class="mx_Heading_h2 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Message edits
</h2>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel mx_MessageEditHistoryDialog_scrollPanel"
tabindex="-1"
>
<div
class="mx_RoomView_messageListWrapper"
>
<ol
aria-live="polite"
class="mx_RoomView_MessageList"
>
<ul
class="mx_MessageEditHistoryDialog_edits"
>
<li>
<div
aria-label="Thu, Jan 1 1970"
class="mx_DateSeparator"
role="separator"
tabindex="-1"
>
<hr
role="none"
/>
<h2
aria-hidden="true"
>
Thu, Jan 1 1970
</h2>
<hr
role="none"
/>
</div>
</li>
<li>
<div
class="mx_EventTile"
>
<div
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
>
00:00
</span>
<div
class="mx_EventTile_content"
>
<span
class="mx_EventTile_body"
dir="auto"
>
My Great Massage
</span>
</div>
<div
class="mx_MessageActionBar"
>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
>
Remove
</div>
</div>
</div>
</div>
</li>
</ul>
</ol>
</div>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;
exports[`<MessageEditHistory /> should support events with 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_MessageEditHistoryDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header mx_Dialog_headerWithCancel"
>
<h2
class="mx_Heading_h2 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Message edits
</h2>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel mx_MessageEditHistoryDialog_scrollPanel"
tabindex="-1"
>
<div
class="mx_RoomView_messageListWrapper"
>
<ol
aria-live="polite"
class="mx_RoomView_MessageList"
>
<ul
class="mx_MessageEditHistoryDialog_edits"
>
<li>
<div
aria-label=", NaN NaN"
class="mx_DateSeparator"
role="separator"
tabindex="-1"
>
<hr
role="none"
/>
<h2
aria-hidden="true"
>
, NaN NaN
</h2>
<hr
role="none"
/>
</div>
</li>
<li>
<div
class="mx_EventTile"
>
<div
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
>
NaN:NaN
</span>
<div
class="mx_EventTile_content"
>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
My Great Massage
<span
class="mx_EditHistoryMessage_deletion"
>
?
</span>
</span>
</span>
</div>
<div
class="mx_MessageActionBar"
>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
>
Remove
</div>
</div>
</div>
</div>
</li>
<li>
<div
class="mx_EventTile"
>
<div
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
>
NaN:NaN
</span>
<div
class="mx_EventTile_content"
>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
My Great M
<span
class="mx_EditHistoryMessage_deletion"
>
i
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
a
</span>
ssage
<span
class="mx_EditHistoryMessage_insertion"
>
?
</span>
</span>
</span>
</div>
<div
class="mx_MessageActionBar"
>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
>
Remove
</div>
</div>
</div>
</div>
</li>
<li>
<div
class="mx_EventTile"
>
<div
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
>
NaN:NaN
</span>
<div
class="mx_EventTile_content"
>
<span
class="mx_EventTile_body"
dir="auto"
>
My Great Missage
</span>
</div>
<div
class="mx_MessageActionBar"
>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
>
Remove
</div>
</div>
</div>
</div>
</li>
</ul>
</ol>
</div>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -17,7 +17,7 @@ limitations under the License.
import * as React from "react"; import * as React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventType } from "matrix-js-sdk/src/@types/event";
import { render } from "@testing-library/react"; import { render, type RenderResult } from "@testing-library/react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { stubClient } from "../../../test-utils"; import { stubClient } from "../../../test-utils";
@ -26,6 +26,8 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
const ROOM_ID = "!qPewotXpIctQySfjSy:localhost"; const ROOM_ID = "!qPewotXpIctQySfjSy:localhost";
type Props = React.ComponentPropsWithoutRef<typeof SearchResultTile>;
describe("SearchResultTile", () => { describe("SearchResultTile", () => {
beforeAll(() => { beforeAll(() => {
stubClient(); stubClient();
@ -35,50 +37,72 @@ describe("SearchResultTile", () => {
jest.spyOn(cli, "getRoom").mockReturnValue(room); jest.spyOn(cli, "getRoom").mockReturnValue(room);
}); });
function renderComponent(props: Partial<Props>): RenderResult {
return render(<SearchResultTile timeline={[]} ourEventsIndexes={[1]} {...props} />);
}
it("Sets up appropriate callEventGrouper for m.call. events", () => { it("Sets up appropriate callEventGrouper for m.call. events", () => {
const { container } = render( const { container } = renderComponent({
<SearchResultTile timeline: [
timeline={[ new MatrixEvent({
new MatrixEvent({ type: EventType.CallInvite,
type: EventType.CallInvite, sender: "@user1:server",
sender: "@user1:server", room_id: ROOM_ID,
room_id: ROOM_ID, origin_server_ts: 1432735824652,
origin_server_ts: 1432735824652, content: { call_id: "call.1" },
content: { call_id: "call.1" }, event_id: "$1:server",
event_id: "$1:server", }),
}), new MatrixEvent({
new MatrixEvent({ content: {
content: { body: "This is an example text message",
body: "This is an example text message", format: "org.matrix.custom.html",
format: "org.matrix.custom.html", formatted_body: "<b>This is an example text message</b>",
formatted_body: "<b>This is an example text message</b>", msgtype: "m.text",
msgtype: "m.text", },
}, event_id: "$144429830826TWwbB:localhost",
event_id: "$144429830826TWwbB:localhost", origin_server_ts: 1432735824653,
origin_server_ts: 1432735824653, room_id: ROOM_ID,
room_id: ROOM_ID, sender: "@example:example.org",
sender: "@example:example.org", type: "m.room.message",
type: "m.room.message", unsigned: {
unsigned: { age: 1234,
age: 1234, },
}, }),
}), new MatrixEvent({
new MatrixEvent({ type: EventType.CallAnswer,
type: EventType.CallAnswer, sender: "@user2:server",
sender: "@user2:server", room_id: ROOM_ID,
room_id: ROOM_ID, origin_server_ts: 1432735824654,
origin_server_ts: 1432735824654, content: { call_id: "call.1" },
content: { call_id: "call.1" }, event_id: "$2:server",
event_id: "$2:server", }),
}), ],
]} });
ourEventsIndexes={[1]}
/>,
);
const tiles = container.querySelectorAll<HTMLElement>(".mx_EventTile"); const tiles = container.querySelectorAll<HTMLElement>(".mx_EventTile");
expect(tiles.length).toEqual(2); expect(tiles.length).toEqual(2);
expect(tiles[0].dataset.eventId).toBe("$1:server"); expect(tiles[0]!.dataset.eventId).toBe("$1:server");
expect(tiles[1].dataset.eventId).toBe("$144429830826TWwbB:localhost"); expect(tiles[1]!.dataset.eventId).toBe("$144429830826TWwbB:localhost");
});
it("supports events with missing timestamps", () => {
const { container } = renderComponent({
timeline: [...Array(20)].map(
(_, i) =>
new MatrixEvent({
type: EventType.RoomMessage,
sender: "@user1:server",
room_id: ROOM_ID,
content: { body: `Message #${i}` },
event_id: `$${i}:server`,
origin_server_ts: undefined,
}),
),
});
const separators = container.querySelectorAll(".mx_DateSeparator");
// One separator is always rendered at the top, we don't want any
// between messages.
expect(separators.length).toBe(1);
}); });
}); });

View File

@ -208,6 +208,10 @@ export function createTestClient(): MatrixClient {
setPassword: jest.fn().mockRejectedValue({}), setPassword: jest.fn().mockRejectedValue({}),
groupCallEventHandler: { groupCalls: new Map<string, GroupCall>() }, groupCallEventHandler: { groupCalls: new Map<string, GroupCall>() },
redactEvent: jest.fn(), redactEvent: jest.fn(),
createMessagesRequest: jest.fn().mockResolvedValue({
chunk: [],
}),
} as unknown as MatrixClient; } as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client); client.reEmitter = new ReEmitter(client);

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,26 +14,101 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { mocked } from "jest-mock"; import { EventType, IRoomEvent, MatrixClient, MatrixEvent, MsgType, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest";
import { createTestClient, mkStubRoom, REPEATABLE_DATE } from "../../test-utils"; import { filterConsole, mkStubRoom, REPEATABLE_DATE, stubClient } from "../../test-utils";
import { ExportType, IExportOptions } from "../../../src/utils/exportUtils/exportUtils"; import { ExportType, IExportOptions } from "../../../src/utils/exportUtils/exportUtils";
import SdkConfig from "../../../src/SdkConfig"; import SdkConfig from "../../../src/SdkConfig";
import HTMLExporter from "../../../src/utils/exportUtils/HtmlExport"; import HTMLExporter from "../../../src/utils/exportUtils/HtmlExport";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { mediaFromMxc } from "../../../src/customisations/Media";
jest.mock("jszip");
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",
},
};
describe("HTMLExport", () => { 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(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
jest.setSystemTime(REPEATABLE_DATE); jest.setSystemTime(REPEATABLE_DATE);
client = stubClient() as jest.Mocked<MatrixClient>;
DMRoomMap.makeShared();
room = new Room("!myroom:example.org", client, "@me:example.org");
client.getRoom.mockReturnValue(room);
}); });
afterEach(() => { function mockMessages(...events: IRoomEvent[]): void {
mocked(SdkConfig.get).mockRestore(); client.createMessagesRequest.mockImplementation((_roomId, fromStr, limit = 30) => {
}); const from = fromStr === null ? 0 : parseInt(fromStr);
const chunk = events.slice(from, limit);
return Promise.resolve({
chunk,
from: from.toString(),
to: (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);
}
it("should have an SDK-branded destination file name", () => { it("should have an SDK-branded destination file name", () => {
const roomName = "My / Test / Room: Welcome"; const roomName = "My / Test / Room: Welcome";
const client = createTestClient();
const stubOptions: IExportOptions = { const stubOptions: IExportOptions = {
attachmentsIncluded: false, attachmentsIncluded: false,
maxSize: 50000000, maxSize: 50000000,
@ -43,10 +118,201 @@ describe("HTMLExport", () => {
expect(exporter.destinationFileName).toMatchSnapshot(); expect(exporter.destinationFileName).toMatchSnapshot();
jest.spyOn(SdkConfig, "get").mockImplementation(() => { SdkConfig.put({ brand: "BrandedChat/WithSlashes/ForFun" });
return { brand: "BrandedChat/WithSlashes/ForFun" };
});
expect(exporter.destinationFileName).toMatchSnapshot(); 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}`,
},
}));
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();
});
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.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
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.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
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.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
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.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
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 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.Timeline,
{
attachmentsIncluded: true,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
// Ensure that the attachment is present
const files = getFiles(exporter);
const file = files["files/hello-1-1-1970 at 12-00-00 AM.txt"];
expect(file).not.toBeUndefined();
// Ensure that the attachment has the expected content
const text = await file.text();
expect(text).toBe(attachmentBody);
});
it("should omit attachments", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
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/);
}
});
}); });

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,26 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import getExportCSS from "../../../src/utils/exportUtils/exportCSS";
describe("exportCSS", () => {
describe("getExportCSS", () => {
it("supports documents missing stylesheets", async () => {
const css = await getExportCSS(new Set());
expect(css).not.toContain("color-scheme: light");
});
});
});