/* Copyright 2021 - 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 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(); } }