/* Copyright 2024 New Vector Ltd. Copyright 2021-2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React from "react"; import ReactDOM from "react-dom"; import { Room, MatrixEvent, EventType, MsgType } from "matrix-js-sdk/src/matrix"; import { renderToStaticMarkup } from "react-dom/server"; import { logger } from "matrix-js-sdk/src/logger"; import escapeHtml from "escape-html"; import Exporter from "./Exporter"; import { mediaFromMxc } from "../../customisations/Media"; import { Layout } from "../../settings/enums/Layout"; import { shouldFormContinuation } from "../../components/structures/MessagePanel"; import { formatFullDateNoDayNoTime, wantsDateSeparator } from "../../DateUtils"; import { RoomPermalinkCreator } from "../permalinks/Permalinks"; import { _t } from "../../languageHandler"; import * as Avatar from "../../Avatar"; import EventTile from "../../components/views/rooms/EventTile"; import DateSeparator from "../../components/views/messages/DateSeparator"; import BaseAvatar from "../../components/views/avatars/BaseAvatar"; import { ExportType, IExportOptions } from "./exportUtils"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import getExportCSS from "./exportCSS"; import { textForEvent } from "../../TextForEvent"; import { haveRendererForEvent } from "../../events/EventTileFactory"; import exportJS from "!!raw-loader!./exportJS"; export default class HTMLExporter extends Exporter { protected avatars: Map; protected permalinkCreator: RoomPermalinkCreator; protected totalSize: number; protected mediaOmitText: string; public constructor( room: Room, exportType: ExportType, exportOptions: IExportOptions, setProgressText: React.Dispatch>, ) { super(room, exportType, exportOptions, setProgressText); this.avatars = new Map(); this.permalinkCreator = new RoomPermalinkCreator(this.room); this.totalSize = 0; this.mediaOmitText = !this.exportOptions.attachmentsIncluded ? _t("export_chat|media_omitted") : _t("export_chat|media_omitted_file_size"); } protected async getRoomAvatar(): Promise { let blob: Blob | undefined = undefined; const avatarUrl = Avatar.avatarUrlForRoom(this.room, 32, 32, "crop"); const avatarPath = "room.png"; if (avatarUrl) { try { const image = await fetch(avatarUrl); blob = await image.blob(); this.totalSize += blob.size; this.addFile(avatarPath, blob); } catch (err) { logger.log("Failed to fetch room's avatar" + err); } } const avatar = ( ); return renderToStaticMarkup(avatar); } protected async wrapHTML(content: string, currentPage: number, nbPages: number): Promise { const roomAvatar = await this.getRoomAvatar(); const exportDate = formatFullDateNoDayNoTime(new Date()); const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender(); const creatorName = (creator ? this.room.getMember(creator)?.rawDisplayName : creator) || creator; const exporter = this.room.client.getSafeUserId(); const exporterName = this.room.getMember(exporter)?.rawDisplayName; const topic = this.room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || ""; const safeCreatedText = escapeHtml( _t("export_chat|creator_summary", { creatorName, }), ); const safeExporter = escapeHtml(exporter); const safeRoomName = escapeHtml(this.room.name); const safeTopic = escapeHtml(topic); const safeExportedText = renderToStaticMarkup(

{_t( "export_chat|export_info", { exportDate, }, { roomName: () => {safeRoomName}, exporterDetails: () => ( {exporterName ? ( <> {escapeHtml(exporterName)}I {" (" + safeExporter + ")"} ) : ( {safeExporter} )} ), }, )}

, ); const safeTopicText = topic ? _t("export_chat|topic", { topic: safeTopic }) : ""; const previousMessagesLink = renderToStaticMarkup( currentPage !== 0 ? ( ) : ( <> ), ); const nextMessagesLink = renderToStaticMarkup( currentPage < nbPages - 1 ? ( ) : ( <> ), ); return ` ${_t("export_chat|html_title")}
${roomAvatar}
${safeRoomName}
${safeTopic}
${previousMessagesLink}
    ${ currentPage == 0 ? `
    ${roomAvatar}

    ${safeRoomName}

    ${safeCreatedText}

    ${safeExportedText}


    ${safeTopicText}

    ` : "" } ${content}
${nextMessagesLink}
`; } protected getAvatarURL(event: MatrixEvent): string | null { const member = event.sender; const avatarUrl = member?.getMxcAvatarUrl(); return avatarUrl ? mediaFromMxc(avatarUrl).getThumbnailOfSourceHttp(30, 30, "crop") : null; } protected async saveAvatarIfNeeded(event: MatrixEvent): Promise { const member = event.sender!; if (!this.avatars.has(member.userId)) { try { const avatarUrl = this.getAvatarURL(event); this.avatars.set(member.userId, true); const image = await fetch(avatarUrl!); const blob = await image.blob(); this.addFile(`users/${member.userId.replace(/:/g, "-")}.png`, blob); } catch (err) { logger.log("Failed to fetch user's avatar" + err); } } } protected getDateSeparator(event: MatrixEvent): string { const ts = event.getTs(); const dateSeparator = (
  • ); return renderToStaticMarkup(dateSeparator); } protected needsDateSeparator(event: MatrixEvent, prevEvent: MatrixEvent | null): boolean { if (!prevEvent) return true; return wantsDateSeparator(prevEvent.getDate() || undefined, event.getDate() || undefined); } public getEventTile(mxEv: MatrixEvent, continuation: boolean): JSX.Element { return (
    false} isTwelveHour={false} last={false} lastInSection={false} permalinkCreator={this.permalinkCreator} lastSuccessful={false} isSelectedEvent={false} showReactions={false} layout={Layout.Group} showReadReceipts={false} />
    ); } protected async getEventTileMarkup(mxEv: MatrixEvent, continuation: boolean, filePath?: string): Promise { const avatarUrl = this.getAvatarURL(mxEv); const hasAvatar = !!avatarUrl; if (hasAvatar) await this.saveAvatarIfNeeded(mxEv); const EventTile = this.getEventTile(mxEv, continuation); let eventTileMarkup: string; if ( mxEv.getContent().msgtype == MsgType.Emote || mxEv.getContent().msgtype == MsgType.Notice || mxEv.getContent().msgtype === MsgType.Text ) { // to linkify textual events, we'll need lifecycle methods which won't be invoked in renderToString // So, we'll have to render the component into a temporary root element const tempRoot = document.createElement("div"); ReactDOM.render(EventTile, tempRoot); eventTileMarkup = tempRoot.innerHTML; } else { eventTileMarkup = renderToStaticMarkup(EventTile); } if (filePath) { const mxc = mxEv.getContent().url ?? mxEv.getContent().file?.url; eventTileMarkup = eventTileMarkup.split(mxc).join(filePath); } eventTileMarkup = eventTileMarkup.replace(/.*?<\/span>/, ""); if (hasAvatar) { eventTileMarkup = eventTileMarkup.replace( encodeURI(avatarUrl).replace(/&/g, "&"), `users/${mxEv.sender!.userId.replace(/:/g, "-")}.png`, ); } return eventTileMarkup; } protected createModifiedEvent(text: string, mxEv: MatrixEvent, italic = true): MatrixEvent { const modifiedContent = { msgtype: MsgType.Text, body: `${text}`, format: "org.matrix.custom.html", formatted_body: `${text}`, }; if (italic) { modifiedContent.formatted_body = "" + modifiedContent.formatted_body + ""; modifiedContent.body = "*" + modifiedContent.body + "*"; } const modifiedEvent = new MatrixEvent(); modifiedEvent.event = mxEv.event; modifiedEvent.sender = mxEv.sender; modifiedEvent.event.type = "m.room.message"; modifiedEvent.event.content = modifiedContent; return modifiedEvent; } protected async createMessageBody(mxEv: MatrixEvent, joined = false): Promise { let eventTile: string; try { if (this.isAttachment(mxEv)) { if (this.exportOptions.attachmentsIncluded) { try { const blob = await this.getMediaBlob(mxEv); if (this.totalSize + blob.size > this.exportOptions.maxSize) { eventTile = await this.getEventTileMarkup( this.createModifiedEvent(this.mediaOmitText, mxEv), joined, ); } else { this.totalSize += blob.size; const filePath = this.getFilePath(mxEv); eventTile = await this.getEventTileMarkup(mxEv, joined, filePath); if (this.totalSize == this.exportOptions.maxSize) { this.exportOptions.attachmentsIncluded = false; } this.addFile(filePath, blob); } } catch (e) { logger.log("Error while fetching file" + e); eventTile = await this.getEventTileMarkup( this.createModifiedEvent(_t("export_chat|error_fetching_file"), mxEv), joined, ); } } else { eventTile = await this.getEventTileMarkup( this.createModifiedEvent(this.mediaOmitText, mxEv), joined, ); } } else { eventTile = await this.getEventTileMarkup(mxEv, joined); } } catch (e) { // TODO: Handle callEvent errors logger.error(e); eventTile = await this.getEventTileMarkup( this.createModifiedEvent(textForEvent(mxEv, this.room.client), mxEv, false), joined, ); } return eventTile; } protected async createHTML( events: MatrixEvent[], start: number, currentPage: number, nbPages: number, ): Promise { let content = ""; let prevEvent: MatrixEvent | null = null; for (let i = start; i < Math.min(start + 1000, events.length); i++) { const event = events[i]; this.updateProgress( _t("export_chat|processing_event_n", { number: i + 1, total: events.length, }), false, true, ); if (this.cancelled) return this.cleanUp(); if (!haveRendererForEvent(event, this.room.client, false)) continue; content += this.needsDateSeparator(event, prevEvent) ? this.getDateSeparator(event) : ""; const shouldBeJoined = !this.needsDateSeparator(event, prevEvent) && shouldFormContinuation(prevEvent, event, this.room.client, false); const body = await this.createMessageBody(event, shouldBeJoined); this.totalSize += Buffer.byteLength(body); content += body; prevEvent = event; } return this.wrapHTML(content, currentPage, nbPages); } public async export(): Promise { this.updateProgress(_t("export_chat|starting_export")); const fetchStart = performance.now(); const res = await this.getRequiredEvents(); const fetchEnd = performance.now(); this.updateProgress( _t("export_chat|fetched_n_events_in_time", { count: res.length, seconds: (fetchEnd - fetchStart) / 1000, }), true, false, ); this.updateProgress(_t("export_chat|creating_html")); const usedClasses = new Set(); for (let page = 0; page < res.length / 1000; page++) { const html = await this.createHTML(res, page * 1000, page, res.length / 1000); const document = new DOMParser().parseFromString(html, "text/html"); document.querySelectorAll("*").forEach((element) => { element.classList.forEach((c) => usedClasses.add(c)); }); this.addFile(`messages${page ? page + 1 : ""}.html`, new Blob([html])); } const exportCSS = await getExportCSS(usedClasses); this.addFile("css/style.css", new Blob([exportCSS])); this.addFile("js/script.js", new Blob([exportJS])); await this.downloadZIP(); const exportEnd = performance.now(); if (this.cancelled) { logger.info("Export cancelled successfully"); } else { this.updateProgress(_t("export_chat|export_successful")); this.updateProgress( _t("export_chat|exported_n_events_in_time", { count: res.length, seconds: (exportEnd - fetchStart) / 1000, }), ); } this.cleanUp(); } }