import React from "react" import streamSaver from "streamsaver"; import Exporter from "./Exporter"; import { mediaFromMxc } from "../../customisations/Media"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { renderToStaticMarkup } from 'react-dom/server' import { Layout } from "../../settings/Layout"; import { shouldFormContinuation } from "../../components/structures/MessagePanel"; import { formatFullDateNoDay, formatFullDateNoDayNoTime, wantsDateSeparator } from "../../DateUtils"; import { RoomPermalinkCreator } from "../permalinks/Permalinks"; import { _t } from "../../languageHandler"; import { EventType } from "matrix-js-sdk/src/@types/event"; import * as ponyfill from "web-streams-polyfill/ponyfill" import * as Avatar from "../../Avatar"; import EventTile, { haveTileForEvent } from "../../components/views/rooms/EventTile"; import DateSeparator from "../../components/views/messages/DateSeparator"; import BaseAvatar from "../../components/views/avatars/BaseAvatar"; import exportCSS from "./exportCSS"; import exportJS from "./exportJS"; import exportIcons from "./exportIcons"; import { exportTypes } from "./exportUtils"; import { exportOptions } from "./exportUtils"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import zip from "./StreamToZip"; export default class HTMLExporter extends Exporter { protected avatars: Map; protected permalinkCreator: RoomPermalinkCreator; protected totalSize: number; protected mediaOmitText: string; constructor(room: Room, exportType: exportTypes, exportOptions: exportOptions) { super(room, exportType, exportOptions); this.avatars = new Map(); this.permalinkCreator = new RoomPermalinkCreator(this.room); this.totalSize = 0; this.mediaOmitText = !this.exportOptions.attachmentsIncluded ? _t("Media omitted") : _t("Media omitted - file size limit exceeded"); window.addEventListener("beforeunload", this.onBeforeUnload) } protected onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); return e.returnValue = "Are you sure you want to exit during this export?"; } protected async getRoomAvatar() { let blob: Blob; const avatarUrl = Avatar.avatarUrlForRoom(this.room, 32, 32, "crop"); const avatarPath = "room.png"; if (avatarUrl) { const image = await fetch(avatarUrl); blob = await image.blob(); this.totalSize += blob.size; this.addFile(avatarPath, blob); } const avatar = ( ); return renderToStaticMarkup(avatar); } protected async wrapHTML(content: string) { const roomAvatar = await this.getRoomAvatar(); const exportDate = formatFullDateNoDayNoTime(new Date()); const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender(); const creatorName = this.room?.getMember(creator)?.rawDisplayName || creator; const exporter = this.client.getUserId(); const exporterName = this.room?.getMember(exporter)?.rawDisplayName; const topic = this.room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || ""; const createdText = _t("%(creatorName)s created this room.", { creatorName, }); const exportedText = renderToStaticMarkup(

{_t( "This is the start of export of . Exported by at %(exportDate)s.", { exportDate, }, { roomName: () => {this.room.name}, exporterDetails: () => ( {exporterName ? ( <> {exporterName} {exporter} ) : ( {exporter} )} ), }, )}

, ); const topicText = topic ? _t("Topic: %(topic)s", { topic }) : ""; return ` Exported Data
${roomAvatar}
${this.room.name}
${topic}
    ${roomAvatar}

    ${this.room.name}

    ${createdText}

    ${exportedText}


    ${topicText}

    ${content}
` } protected hasAvatar(event: MatrixEvent): boolean { const member = event.sender; return !!member.getMxcAvatarUrl(); } protected async saveAvatarIfNeeded(event: MatrixEvent) { const member = event.sender; const avatarUrl = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(30, 30, "crop"); if (!this.avatars.has(member.userId)) { this.avatars.set(member.userId, true); const image = await fetch(avatarUrl); const blob = await image.blob(); this.addFile(`users/${member.userId.replace(/:/g, '-')}`, blob); } } protected getDateSeparator(event: MatrixEvent) { const ts = event.getTs(); const dateSeparator =
  • ; return renderToStaticMarkup(dateSeparator); } protected _wantsDateSeparator(event: MatrixEvent, prevEvent: MatrixEvent) { if (prevEvent == null) return true; return wantsDateSeparator(prevEvent.getDate(), event.getDate()); } protected async getEventTile(mxEv: MatrixEvent, continuation: boolean, filePath?: string) { const hasAvatar = this.hasAvatar(mxEv); if (hasAvatar) await this.saveAvatarIfNeeded(mxEv); const eventTile =
    false} isTwelveHour={false} last={false} lastInSection={false} permalinkCreator={this.permalinkCreator} lastSuccessful={false} isSelectedEvent={false} getRelationsForEvent={null} showReactions={false} layout={Layout.Group} enableFlair={false} showReadReceipts={false} />
    let eventTileMarkup = renderToStaticMarkup(eventTile); if (filePath) eventTileMarkup = eventTileMarkup.replace(/(src=|href=)"forExport"/g, `$1"${filePath}"`); if (hasAvatar) { eventTileMarkup = eventTileMarkup.replace( /src="avatarForExport"/g, `src="users/${mxEv.sender.userId.replace(/:/g, "-")}"`, ); } return eventTileMarkup; } protected createModifiedEvent = (text: string, mxEv: MatrixEvent) => { const modifiedContent = { msgtype: "m.text", body: `*${text}*`, format: "org.matrix.custom.html", formatted_body: `${text}`, } 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) { let eventTile: string; if (this.isAttachment(mxEv)) { if (this.exportOptions.attachmentsIncluded) { try { const blob = await this.getMediaBlob(mxEv); this.totalSize += blob.size; const filePath = this.getFilePath(mxEv); eventTile = await this.getEventTile(mxEv, joined, filePath); if (this.totalSize > this.exportOptions.maxSize - 1024 * 512) { this.exportOptions.attachmentsIncluded = false; } this.addFile(filePath, blob); } catch (e) { console.log("Error while fetching file"); eventTile = await this.getEventTile( this.createModifiedEvent(_t("Error fetching file"), mxEv), joined, ); } } else { eventTile = await this.getEventTile(this.createModifiedEvent(this.mediaOmitText, mxEv), joined); } } else eventTile = await this.getEventTile(mxEv, joined); return eventTile; } protected async createHTML(events: MatrixEvent[]) { let content = ""; let prevEvent = null; for (let i = 0; i < events.length; i++) { const event = events[i]; console.log("Processing event " + i + " out of " + events.length); if (!haveTileForEvent(event)) continue; content += this._wantsDateSeparator(event, prevEvent) ? this.getDateSeparator(event) : ""; const shouldBeJoined = !this._wantsDateSeparator(event, prevEvent) && shouldFormContinuation(prevEvent, event); const body = await this.createMessageBody(event, shouldBeJoined); this.totalSize += Buffer.byteLength(body); content += body; prevEvent = event; } return await this.wrapHTML(content); } public async export() { console.info("Starting export process..."); console.info("Fetching events..."); const fetchStart = performance.now(); const res = await this.getRequiredEvents(); const fetchEnd = performance.now(); console.log(`Fetched ${res.length} events in ${(fetchEnd - fetchStart)/1000} s`); console.info("Creating HTML..."); const html = await this.createHTML(res); this.addFile("index.html", new Blob([html])); this.addFile("css/style.css", new Blob([exportCSS])); this.addFile("js/script.js", new Blob([exportJS])); for (const iconName in exportIcons) { this.addFile(`icons/${iconName}`, new Blob([exportIcons[iconName]])); } const filename = `matrix-export-${formatFullDateNoDay(new Date())}.zip`; console.info("HTML creation successful!"); //Support for firefox browser streamSaver.WritableStream = ponyfill.WritableStream //Create a writable stream to the directory const fileStream = streamSaver.createWriteStream(filename); const writer = fileStream.getWriter(); const files = this.files; console.info("Generating a ZIP..."); const readableZipStream = zip({ start(ctrl) { for (const file of files) ctrl.enqueue(file); ctrl.close(); }, }); console.info("Writing to file system...") const reader = readableZipStream.getReader() await this.pumpToFileStream(reader, writer); const exportEnd = performance.now(); console.info(`Export Successful! Exported ${res.length} events in ${(exportEnd - fetchStart)/1000} seconds`); window.removeEventListener("beforeunload", this.onBeforeUnload); } }