import React, { MutableRefObject } from "react"; 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 { formatFullDateNoDayNoTime, wantsDateSeparator } from "../../DateUtils"; import { RoomPermalinkCreator } from "../permalinks/Permalinks"; import { _t } from "../../languageHandler"; import { EventType } from "matrix-js-sdk/src/@types/event"; 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"; 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, exportProgressRef: MutableRefObject, ) { super(room, exportType, exportOptions, exportProgressRef); 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"); } protected async getRoomAvatar() { let blob: Blob; 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) { console.log("Failed to fetch room's avatar" + err); } } 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 getAvatarURL(event: MatrixEvent): string { const member = event.sender; return ( member.getMxcAvatarUrl() && mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( 30, 30, "crop", ) ); } protected async saveAvatarIfNeeded(event: MatrixEvent) { 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) { console.log("Failed to fetch user's avatar" + err); } } } protected getDateSeparator(event: MatrixEvent) { const ts = event.getTs(); const dateSeparator =
  • ; return renderToStaticMarkup(dateSeparator); } protected needsDateSeparator(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.getAvatarURL(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) { const mxc = mxEv.getContent().url || mxEv.getContent().file?.url; eventTileMarkup = eventTileMarkup.split(mxc).join(filePath); } if (hasAvatar) { eventTileMarkup = eventTileMarkup.replace( encodeURI(this.getAvatarURL(mxEv)).replace(/&/g, '&'), `users/${mxEv.sender.userId.replace(/:/g, "-")}.png`, ); } 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; 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.getEventTile( this.createModifiedEvent(this.mediaOmitText, mxEv), joined, ); } else { this.totalSize += blob.size; const filePath = this.getFilePath(mxEv); eventTile = await this.getEventTile(mxEv, joined, filePath); if (this.totalSize == this.exportOptions.maxSize) { this.exportOptions.attachmentsIncluded = false; } this.addFile(filePath, blob); } } catch (e) { console.log("Error while fetching file" + e); 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); } catch (e) { // TODO: Handle callEvent errors console.error(e); eventTile = await this.getEventTile(this.createModifiedEvent("Error parsing HTML", 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]; this.updateProgress(`Processing event ${i + 1} out of ${events.length}`, false, true); if (this.cancelled) return this.cleanUp(); if (!haveTileForEvent(event)) continue; content += this.needsDateSeparator(event, prevEvent) ? this.getDateSeparator(event) : ""; const shouldBeJoined = !this.needsDateSeparator(event, prevEvent) && shouldFormContinuation(prevEvent, event, false); const body = await this.createMessageBody(event, shouldBeJoined); this.totalSize += Buffer.byteLength(body); content += body; prevEvent = event; } return await this.wrapHTML(content); } public async export() { this.updateProgress("Starting export process", true, false); this.updateProgress("Fetching events"); const fetchStart = performance.now(); const res = await this.getRequiredEvents(); const fetchEnd = performance.now(); this.updateProgress(`Fetched ${res.length} events in ${(fetchEnd - fetchStart)/1000}s`, true, false); this.updateProgress("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]])); } await this.downloadZIP(); const exportEnd = performance.now(); if (this.cancelled) { console.info("Export cancelled successfully"); } else { this.updateProgress("Export successful!"); this.updateProgress(`Exported ${res.length} events in ${(exportEnd - fetchStart)/1000} seconds`); } this.cleanUp(); } }