Rewrite export tool to use existing components to render output, use existing source URLs for media

pull/21833/head
Jaiwanth 2021-05-31 19:01:32 +05:30
parent 60ef6f9332
commit 573a3ca983
8 changed files with 367 additions and 648 deletions

View File

@ -41,7 +41,7 @@ const continuedTypes = ['m.sticker', 'm.room.message'];
// 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
function shouldFormContinuation(prevEvent, mxEvent) {
export function shouldFormContinuation(prevEvent, mxEvent) {
// sanity check inputs
if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
// check if within the max continuation period

View File

@ -46,6 +46,7 @@ export default class ReplyThread extends React.Component {
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
// Specifies which layout to use.
layout: LayoutPropType,
isExporting: PropTypes.bool,
};
static contextType = MatrixClientContext;
@ -67,6 +68,9 @@ export default class ReplyThread extends React.Component {
};
this.unmounted = false;
if (this.props.isExporting) return;
this.context.on("Event.replaced", this.onEventReplaced);
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
this.room.on("Room.redaction", this.onRoomRedaction);
@ -212,12 +216,13 @@ export default class ReplyThread extends React.Component {
};
}
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout) {
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout, isExporting) {
if (!ReplyThread.getParentEventId(parentEv)) {
return <div className="mx_ReplyThread_wrapper_empty" />;
}
return <ReplyThread
parentEv={parentEv}
isExporting={isExporting}
onHeightChanged={onHeightChanged}
ref={ref}
permalinkCreator={permalinkCreator}
@ -366,6 +371,11 @@ export default class ReplyThread extends React.Component {
})
}
</blockquote>;
} else if (this.props.isExporting) {
const eventId = ReplyThread.getParentEventId(this.props.parentEv);
header = <p style={{ marginTop: -5, marginBottom: 5 }}>
In reply to <a className="mx_reply_anchor" scroll-to={`${eventId}`}>this message</a>
</p>;
} else if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
header = <Spinner w={16} h={16} />;

View File

@ -37,10 +37,15 @@ function getdaysArray() {
export default class DateSeparator extends React.Component {
static propTypes = {
ts: PropTypes.number.isRequired,
isExporting: PropTypes.bool,
};
getLabel() {
const date = new Date(this.props.ts);
// During the time the archive is being viewed, a specific day might not make sense, so we return the full date
if (this.props.isExporting) return formatFullDateNoTime(date);
const today = new Date();
const yesterday = new Date();
const days = getdaysArray();

View File

@ -249,6 +249,8 @@ interface IProps {
// for now.
tileShape?: 'notif' | 'file_grid' | 'reply' | 'reply_preview';
isExporting?: boolean;
// show twelve hour timestamps
isTwelveHour?: boolean;
@ -405,6 +407,8 @@ export default class EventTile extends React.Component<IProps, IState> {
// TODO: [REACT-WARNING] Move into constructor
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
// Context isn't propagated through renderToStaticMarkup so we'll have to explicitly set it during export
if (this.props.isExporting) this.context = MatrixClientPeg.get();
this.verifyEvent(this.props.mxEvent);
}
@ -607,6 +611,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}
shouldHighlight() {
if (this.props.isExporting) return false;
const actions = this.context.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent);
if (!actions || !actions.tweaks) { return false; }
@ -951,7 +956,8 @@ export default class EventTile extends React.Component<IProps, IState> {
}
const MessageActionBar = sdk.getComponent('messages.MessageActionBar');
const actionBar = !isEditing ? <MessageActionBar
const showMessageActionBar = !isEditing && !this.props.isExporting;
const actionBar = showMessageActionBar ? <MessageActionBar
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator}
@ -1127,6 +1133,7 @@ export default class EventTile extends React.Component<IProps, IState> {
this.props.permalinkCreator,
this.replyThread,
this.props.layout,
this.props.isExporting,
);
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
@ -1169,11 +1176,11 @@ export default class EventTile extends React.Component<IProps, IState> {
// XXX this'll eventually be dynamic based on the fields once we have extensible event types
const messageTypes = ['m.room.message', 'm.sticker'];
function isMessageEvent(ev) {
function isMessageEvent(ev: MatrixEvent): boolean {
return (messageTypes.includes(ev.getType()));
}
export function haveTileForEvent(e) {
export function haveTileForEvent(e: MatrixEvent): boolean {
// Only messages have a tile (black-rectangle) if redacted
if (e.isRedacted() && !isMessageEvent(e)) return false;

View File

@ -1,639 +0,0 @@
import streamSaver from "streamsaver";
import JSZip from "jszip";
import { decryptFile } from "../DecryptFile";
import { mediaFromContent, mediaFromMxc } from "../../customisations/Media";
import { textForEvent } from "../../TextForEvent";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { getUserNameColorClass } from "../FormattingUtils";
import { Exporter } from "./Exporter";
import * as ponyfill from "web-streams-polyfill/ponyfill"
import { sanitizeHtmlParams } from "../../HtmlUtils";
import sanitizeHtml from "sanitize-html";
const css = `
body {
margin: 0;
font: 12px/18px 'Inter', 'Open Sans',"Lucida Grande","Lucida Sans Unicode",Arial,Helvetica,Verdana,sans-serif;
}
.mx_clearfix:after {
content: " ";
visibility: hidden;
display: block;
height: 0;
clear: both;
}
.mx_pull_left {
float: left;
}
.mx_pull_right {
float: right;
}
.mx_page_wrap {
background-color: #ffffff;
color: #000000;
}
.mx_page_wrap a {
color: #238CF5;
text-decoration: none;
cursor: pointer;
}
.mx_page_wrap a:hover {
text-decoration: underline;
}
.mx_page_header {
position: fixed;
z-index: 10;
background-color: #ffffff;
width: 100%;
border-bottom: 1px solid #e3e6e8;
}
.mx_page_header .mx_content {
width: 480px;
margin: 0 auto;
border-radius: 0 !important;
}
.mx_page_header a.mx_content {
background-repeat: no-repeat;
background-position: 24px 21px;
background-size: 24px 24px;
}
.mx_bold {
color: #212121;
font-weight: 700;
}
.mx_details {
color: #70777b;
}
.mx_page_header .mx_content .mx_text {
padding: 24px 24px 22px 24px;
font-size: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.mx_page_header a.mx_content .mx_text {
padding: 24px 24px 22px 82px;
}
.mx_page_body {
padding-top: 64px;
width: 700px;
margin: 0 auto;
}
.mx_userpic {
display: block;
border-radius: 50%;
overflow: hidden;
}
.mx_userpic .mx_initials {
display: block;
color: #fff;
text-align: center;
text-transform: uppercase;
user-select: none;
}
a.mx_block_link {
display: block;
text-decoration: none !important;
border-radius: 4px;
}
a.mx_block_link:hover {
text-decoration: none !important;
background-color: #f5f7f8;
}
.mx_history {
padding: 16px 0;
}
.mx_message {
margin: 0 -10px;
transition: background-color 1.3s ease;
}
div.mx_selected {
background-color: #eeeeee;
transition: background-color 0.5s ease;
}
.mx_service {
padding: 10px 24px;
}
.mx_service .mx_body {
text-align: center;
}
.mx_message .mx_userpic .mx_initials {
font-size: 16px;
}
.mx_default {
padding: 10px;
}
.mx_default.mx_joined {
margin-top: -10px;
}
.mx_default .mx_from_name {
font-weight: 700;
padding-bottom: 5px;
}
.mx_default .mx_body {
margin-left: 60px;
}
.mx_default .mx_text {
word-wrap: break-word;
line-height: 150%;
}
.mx_default .mx_reply_to,
.mx_default .mx_media_wrap {
padding-bottom: 5px;
}
.mx_default .mx_media {
margin: 0 -10px;
padding: 5px 10px;
}
.mx_default .mx_media .mx_fill,
.mx_default .mx_media .mx_thumb {
width: 48px;
height: 48px;
border-radius: 50%;
}
.mx_default .mx_media .mx_fill {
background-repeat: no-repeat;
background-position: 12px 12px;
background-size: 24px 24px;
}
.mx_default .mx_media .mx_title {
padding-top: 4px;
font-size: 14px;
}
.mx_default .mx_media .mx_description {
color: #000000;
padding-top: 4px;
font-size: 13px;
}
.mx_default .mx_media .mx_status {
padding-top: 4px;
font-size: 13px;
}
.mx_default .mx_photo {
display: block;
}
.mx_from_name.mx_Username_color1{
color: #368bd6;
}
.mx_initials_wrap.mx_Username_color1{
background-color: #368bd6;
}
.mx_from_name.mx_Username_color2{
color: #ac3ba8;
}
.mx_initials_wrap.mx_Username_color2{
background-color: #ac3ba8;
}
.mx_from_name.mx_Username_color3{
color: #03b381;
}
.mx_initials_wrap.mx_Username_color3{
background-color: #03b381;
}
.mx_from_name.mx_Username_color4{
color: #e64f7a;
}
.mx_initials_wrap.mx_Username_color4{
background-color: #e64f7a;
}
.mx_from_name.mx_Username_color5{
color: #ff812d;
}
.mx_initials_wrap.mx_Username_color5{
background-color: #ff812d;
}
.mx_from_name.mx_Username_color6{
color: #2dc2c5;
}
.mx_initials_wrap.mx_Username_color6{
background-color: #2dc2c5;
}
.mx_from_name.mx_Username_color7{
color: #5c56f5;
}
.mx_initials_wrap.mx_Username_color7{
background-color: #5c56f5;
}
.mx_from_name.mx_Username_color8{
color: #74d12c;
}
.mx_initials_wrap.mx_Username_color8{
background-color: #74d12c;
}
#snackbar {
display: flex;
visibility: hidden;
min-width: 250px;
margin-left: -125px;
background-color: #333;
color: #fff;
text-align: center;
position: fixed;
z-index: 1;
left: 50%;
bottom: 30px;
font-size: 17px;
padding: 6px 16px;
font-size: 0.875rem;
font-family: "Roboto", "Helvetica", "Arial", sans-serif;
font-weight: 400;
line-height: 1.43;
border-radius: 4px;
letter-spacing: 0.01071em;
}
#snackbar.show {
visibility: visible;
-webkit-animation: fadein 0.5s, fadeout 0.5s 2.5s;
animation: fadein 0.5s, fadeout 0.5s 2.5s;
}
@-webkit-keyframes fadein {
from {bottom: 0; opacity: 0;}
to {bottom: 30px; opacity: 1;}
}
@keyframes fadein {
from {bottom: 0; opacity: 0;}
to {bottom: 30px; opacity: 1;}
}
@-webkit-keyframes fadeout {
from {bottom: 30px; opacity: 1;}
to {bottom: 0; opacity: 0;}
}
@keyframes fadeout {
from {bottom: 30px; opacity: 1;}
to {bottom: 0; opacity: 0;}
}
`;
const js = `
function scrollToElement(replyId){
let el = document.getElementById(replyId);
if(!el) {
showToast("The message you're looking for wasn't exported");
return;
};
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add("mx_selected");
setTimeout(() => {
el.classList.remove("mx_selected");
}, 1000);
}
function showToast(text) {
let el = document.getElementById("snackbar");
el.innerHTML = text;
el.className = "show";
setTimeout(() => {
el.className = el.className.replace("show", "");
}, 2000);
}
`
export default class HTMLExporter extends Exporter {
protected zip: JSZip;
protected avatars: Map<string, boolean>;
constructor(res: MatrixEvent[], room: Room) {
super(res, room);
this.zip = new JSZip();
this.avatars = new Map<string, boolean>();
}
protected wrapHTML(content: string, room: Room) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Exported Data</title>
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<link href="css/style.css" rel="stylesheet" />
<script src="js/script.js"></script>
</head>
<body>
<div class="mx_page_wrap">
<div class="mx_page_header">
<div class="mx_content">
<div class="mx_text mx_bold">${room.name}</div>
</div>
</div>
<div class="mx_page_body mx_chat_page">
<div class="mx_history">
${content}
</div>
</div>
</div>
<div id="snackbar"/>
</body>
</html>
`
}
protected isEdit(event: MatrixEvent) {
if (event.getType() === "m.room.message" && event.getContent().hasOwnProperty("m.new_content")) return true;
return false;
}
protected async getUserAvatar(event: MatrixEvent) {
const member = event.sender;
if (!member.getMxcAvatarUrl()) {
return `
<div class="mx_pull_left mx_userpic_wrap">
<div
class="mx_userpic mx_initials_wrap ${getUserNameColorClass(event.getSender())}"
style="width: 42px;height: 42px;"
>
<div class="mx_initials" style="line-height: 42px;" src="users/${member.userId}">
${event.sender.name[0]}
</div>
</div>
</div>
`;
} else {
const imageUrl = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(42, 42, "crop");
if (!this.avatars.has(member.userId)) {
this.avatars.set(member.userId, true);
const image = await fetch(imageUrl);
const blob = await image.blob();
this.zip.file(`users/${member.userId}`, blob);
}
return `
<div class="mx_pull_left mx_userpic_wrap">
<div class="mx_userpic" style="width: 42px; height: 42px;">
<img
class="mx_initials"
style="width: 42px;height: 42px;line-height:42px;"
src="users/${member.userId}"
/>
</div>
</div>
`;
}
}
protected async getMediaBlob(event: MatrixEvent) {
let blob: Blob;
try {
const isEncrypted = event.isEncrypted();
const content = event.getContent();
if (isEncrypted) {
blob = await decryptFile(content.file);
} else {
const media = mediaFromContent(event.getContent());
const image = await fetch(media.srcHttp);
blob = await image.blob();
}
} catch (err) {
console.log("Error decrypting media");
}
return blob;
}
//Gets the event_id of an event to which an event is replied
protected getBaseEventId = (event: MatrixEvent) => {
const isEncrypted = event.isEncrypted();
// If encrypted, in_reply_to lies in event.event.content
const content = isEncrypted ? event.event.content : event.getContent();
const relatesTo = content["m.relates_to"];
return (relatesTo && relatesTo["m.in_reply_to"]) ? relatesTo["m.in_reply_to"]["event_id"] : null;
};
protected dateSeparator(event: MatrixEvent, prevEvent: MatrixEvent) {
const prevDate = prevEvent ? new Date(prevEvent.getTs()) : null;
const currDate = new Date(event.getTs());
if (!prevDate || currDate.setHours(0, 0, 0, 0) !== prevDate.setHours(0, 0, 0, 0)) {
return `
<div class="mx_message mx_service">
<div class="mx_body mx_details">
${new Date(event.getTs())
.toLocaleString("en-us", {year: "numeric", month: "long", day: "numeric" })}
</div>
</div>
`;
}
return "";
}
protected async createMessageBody(event: MatrixEvent, joined = false, isReply = false, replyId = null) {
const userPic = await this.getUserAvatar(event);
let messageBody = "";
switch (event.getContent().msgtype) {
case "m.text":
messageBody = `
<div class="mx_text">
${sanitizeHtml(event.getContent().body, sanitizeHtmlParams)}
</div>`;
break;
case "m.emote":
messageBody = `
<div class="mx_text">
* ${event.sender ? event.sender.name : event.getSender()}
${sanitizeHtml(event.getContent().body, sanitizeHtmlParams)}
</div>`;
break;
case "m.image": {
const blob = await this.getMediaBlob(event);
const fileName = `${event.getId()}.${blob.type.replace("image/", "")}`;
messageBody = `
<a class="mx_photo_wrap mx_clearfix mx_pull_left" href="images/${event.getId()}.png">
<img
class="mx_photo"
style="max-width: 600px; max-height: 500px;"
src="images/${fileName}"
/>
</a>`;
this.zip.file(`images/${fileName}`, blob);
break;
}
case "m.video": {
const blob = await this.getMediaBlob(event);
const fileName = `${event.getId()}.${blob.type.replace("video/", "")}`;
messageBody = `
<div class="mx_media_wrap mx_clearfix">
<video
class="mx_video_file"
src="videos/${fileName}"
style="max-height: 400px; max-width: 600px;"
controls
/>
</div>`;
this.zip.file(`videos/${fileName}`, blob);
break;
}
case "m.audio": {
const blob = await this.getMediaBlob(event);
const fileName = `${event.getId()}.${blob.type.replace("audio/", "")}`;
messageBody = `
<div class="mx_media_wrap mx_clearfix">
<audio
class="mx_audio_file"
src="audio/${fileName}"
controls
/>
</div>`;
this.zip.file(`audio/${fileName}`, blob);
break;
}
default:
break;
}
if (isReply) {
messageBody = event.getContent().formatted_body.replace(/<mx-reply>.*<\/mx-reply>/, '')
}
return `
<div class="mx_message mx_default mx_clearfix ${joined ? `mx_joined` : ``}" id="${event.getId()}">
${!joined ? userPic : ``}
<div class="mx_body">
<div class="mx_pull_right mx_date mx_details" title="${new Date(event.getTs())}">
${new Date(event.getTs()).toLocaleTimeString().slice(0, -3)}
</div>
${!joined ? `
<div class="mx_from_name ${getUserNameColorClass(event.getSender())}">
${event.sender.name}
</div>`: ``}
${isReply ?
` <div class="mx_reply_to mx_details">
In reply to <a onclick="scrollToElement('${replyId}')">this message</a>
</div>`: ``}
${messageBody}
</div>
</div>
`;
}
protected async createHTML(events: MatrixEvent[], room: Room) {
let content = "";
let prevEvent = null;
for (const event of events) {
// As the getContent of the edited event fetches the latest edit, there is no need to process edit events
if (this.isEdit(event)) continue;
content += this.dateSeparator(event, prevEvent);
if (event.getType() === "m.room.message") {
const replyTo = this.getBaseEventId(event);
const shouldBeJoined = prevEvent && prevEvent.getContent().msgtype === "m.text"
&& event.sender.userId === prevEvent.sender.userId && !this.dateSeparator(event, prevEvent) && !replyTo;
const body = await this.createMessageBody(event, shouldBeJoined, !!replyTo, replyTo);
content += body;
} else {
const eventText = textForEvent(event);
content += eventText ? `
<div class="mx_message mx_service" id="${event.getId()}">
<div class="mx_body mx_details">
${textForEvent(event)}
</div>
</div>
` : "";
}
prevEvent = event;
}
return this.wrapHTML(content, room);
}
public async export() {
const html = await this.createHTML(this.res, this.room);
this.zip.file("index.html", html);
this.zip.file("css/style.css", css);
this.zip.file("js/script.js", js);
const filename = `matrix-export-${new Date().toISOString()}.zip`;
//Generate the zip file asynchronously
const blob = await this.zip.generateAsync({ type: "blob" });
//Support for firefox browser
streamSaver.WritableStream = ponyfill.WritableStream
//Create a writable stream to the directory
const fileStream = streamSaver.createWriteStream(filename, { size: blob.size });
const writer = fileStream.getWriter();
// Here we chunk the blob into pieces of 10 MB, the size might be dynamically generated.
// This can be used to keep track of the progress
const sliceSize = 10 * 1e6;
for (let fPointer = 0; fPointer < blob.size; fPointer += sliceSize) {
const blobPiece = blob.slice(fPointer, fPointer + sliceSize);
const reader = new FileReader();
const waiter = new Promise<void>((resolve, reject) => {
reader.onloadend = evt => {
const arrayBufferNew: any = evt.target.result;
const uint8ArrayNew = new Uint8Array(arrayBufferNew);
writer.write(uint8ArrayNew);
resolve();
};
reader.readAsArrayBuffer(blobPiece);
});
await waiter;
}
writer.close();
return blob;
}
}

View File

@ -0,0 +1,242 @@
import React from "react"
import streamSaver from "streamsaver";
import JSZip from "jszip";
import { decryptFile } from "../DecryptFile";
import { mediaFromContent } from "../../customisations/Media";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Exporter } from "./Exporter";
import * as ponyfill from "web-streams-polyfill/ponyfill"
import { renderToStaticMarkup } from 'react-dom/server'
import { Layout } from "../../settings/Layout";
import { shouldFormContinuation } from "../../components/structures/MessagePanel";
import { wantsDateSeparator } from "../../DateUtils";
import { RoomPermalinkCreator } from "../permalinks/Permalinks";
import EventTile, { haveTileForEvent } from "../../components/views/rooms/EventTile";
import DateSeparator from "../../components/views/messages/DateSeparator";
import exportCSS from "./exportCSS";
import exportJS from "./exportJS";
export default class HTMLExporter extends Exporter {
protected zip: JSZip;
protected avatars: Map<string, boolean>;
constructor(res: MatrixEvent[], room: Room) {
super(res, room);
this.zip = new JSZip();
this.avatars = new Map<string, boolean>();
}
protected wrapHTML(content: string, room: Room) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="css/style.css" rel="stylesheet" />
<script src="js/script.js"></script>
<title>Exported Data</title>
</head>
<body style="height: 100vh;">
<section
id="matrixchat"
style="height: 100%; overflow: auto"
class="notranslate"
>
<div class="mx_MatrixChat_wrapper" aria-hidden="false">
<div class="mx_MatrixChat">
<main class="mx_RoomView">
<div class="mx_RoomHeader light-panel">
<div class="mx_RoomHeader_wrapper" aria-owns="mx_RightPanel">
<div class="mx_RoomHeader_avatar">
<div class="mx_DecoratedRoomAvatar">
<span class="mx_BaseAvatar" role="presentation"
><span
class="mx_BaseAvatar_initial"
aria-hidden="true"
style="
font-size: 20.8px;
width: 32px;
line-height: 32px;
"
>G</span
><img
class="mx_BaseAvatar_image"
alt=""
aria-hidden="true"
style="width: 32px; height: 32px"
/></span>
</div>
</div>
<div class="mx_RoomHeader_name">
<div
dir="auto"
class="mx_RoomHeader_nametext"
title="${room.name}"
>
${room.name}
</div>
</div>
<div class="mx_RoomHeader_topic" dir="auto"></div>
</div>
</div>
<div class="mx_MainSplit">
<div class="mx_RoomView_body">
<div
class="mx_RoomView_timeline mx_RoomView_timeline_rr_enabled"
>
<div
class="
mx_AutoHideScrollbar
mx_ScrollPanel
mx_RoomView_messagePanel
mx_GroupLayout
"
>
<div class="mx_RoomView_messageListWrapper">
<ol
class="mx_RoomView_MessageList"
aria-live="polite"
role="list"
>
${content}
</ol>
</div>
</div>
</div>
<div class="mx_RoomView_statusArea">
<div class="mx_RoomView_statusAreaBox">
<div class="mx_RoomView_statusAreaBox_line"></div>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
</section>
<div id="snackbar"/>
</body>
</html>`
}
// will be used in the future
protected async getMediaBlob(event: MatrixEvent) {
let blob: Blob;
try {
const isEncrypted = event.isEncrypted();
const content = event.getContent();
if (isEncrypted) {
blob = await decryptFile(content.file);
} else {
const media = mediaFromContent(event.getContent());
const image = await fetch(media.srcHttp);
blob = await image.blob();
}
} catch (err) {
console.log("Error decrypting media");
}
return blob;
}
protected getDateSeparator(event: MatrixEvent) {
const ts = event.getTs();
const dateSeparator = <li key={ts}><DateSeparator isExporting={true} key={ts} ts={ts} /></li>;
return renderToStaticMarkup(dateSeparator);
}
protected _wantsDateSeparator(event: MatrixEvent, prevEvent: MatrixEvent) {
if (prevEvent == null) return true;
return wantsDateSeparator(prevEvent.getDate(), event.getDate());
}
protected async createMessageBody(mxEv: MatrixEvent, joined = false) {
const eventTile = <li id={mxEv.getId()}>
<EventTile
mxEvent={mxEv}
continuation={joined}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
isExporting={true}
readReceipts={null}
readReceiptMap={null}
showUrlPreview={false}
checkUnmounting={() => false}
isTwelveHour={false}
last={false}
lastInSection={false}
permalinkCreator={new RoomPermalinkCreator(this.room)}
lastSuccessful={false}
isSelectedEvent={false}
getRelationsForEvent={null}
showReactions={false}
layout={Layout.Group}
enableFlair={false}
showReadReceipts={false}
/>
</li>
return renderToStaticMarkup(eventTile);
}
protected async createHTML(events: MatrixEvent[], room: Room) {
let content = "";
let prevEvent = null;
for (const event of events) {
if (!haveTileForEvent(event)) continue;
content += this._wantsDateSeparator(event, prevEvent) ? this.getDateSeparator(event) : "";
if (event.getType() === "m.room.message") {
const shouldBeJoined = !this._wantsDateSeparator(event, prevEvent)
&& shouldFormContinuation(prevEvent, event);
const body = await this.createMessageBody(event, shouldBeJoined);
content += body;
}
prevEvent = event;
}
return this.wrapHTML(content, room);
}
public async export() {
const html = await this.createHTML(this.res, this.room);
this.zip.file("index.html", html);
this.zip.file("css/style.css", exportCSS);
this.zip.file("js/script.js", exportJS);
const filename = `matrix-export-${new Date().toISOString()}.zip`;
//Generate the zip file asynchronously
const blob = await this.zip.generateAsync({ type: "blob" });
//Support for firefox browser
streamSaver.WritableStream = ponyfill.WritableStream
//Create a writable stream to the directory
const fileStream = streamSaver.createWriteStream(filename, { size: blob.size });
const writer = fileStream.getWriter();
// Here we chunk the blob into pieces of 10 MB, the size might be dynamically generated.
// This can be used to keep track of the progress
const sliceSize = 10 * 1e6;
for (let fPointer = 0; fPointer < blob.size; fPointer += sliceSize) {
const blobPiece = blob.slice(fPointer, fPointer + sliceSize);
const reader = new FileReader();
const waiter = new Promise<void>((resolve, reject) => {
reader.onloadend = evt => {
const arrayBufferNew: any = evt.target.result;
const uint8ArrayNew = new Uint8Array(arrayBufferNew);
writer.write(uint8ArrayNew);
resolve();
};
reader.readAsArrayBuffer(blobPiece);
});
await waiter;
}
writer.close();
return blob;
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,32 @@
export default `
function scrollToElement(replyId){
let el = document.getElementById(replyId);
if(!el) {
showToast("The message you're looking for wasn't exported");
return;
};
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.style.backgroundColor = '#f6f7f8';
el.style.transition = 'background-color 1s ease'
setTimeout(() => {
el.style.backgroundColor = "white"
}, 2000);
}
function showToast(text) {
let el = document.getElementById("snackbar");
el.innerHTML = text;
el.className = "mx_show";
setTimeout(() => {
el.className = el.className.replace("mx_show", "");
}, 2000);
}
window.onload = () => {
document.querySelectorAll('.mx_reply_anchor').forEach(element => {
element.addEventListener('click', event => {
scrollToElement(event.target.getAttribute("scroll-to"));
})
})
}
`