Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/18969

 Conflicts:
	src/components/views/right_panel/UserInfo.tsx
This commit is contained in:
Michael Telatynski 2021-10-08 13:00:20 +01:00
commit 111ae75874
133 changed files with 5419 additions and 1655 deletions

View file

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event';
import { MatrixClientPeg } from '../MatrixClientPeg';
@ -73,9 +72,15 @@ export function canEditOwnEvent(mxEvent: MatrixEvent): boolean {
}
const MAX_JUMP_DISTANCE = 100;
export function findEditableEvent(room: Room, isForward: boolean, fromEventId: string = undefined): MatrixEvent {
const liveTimeline = room.getLiveTimeline();
const events = liveTimeline.getEvents().concat(room.getPendingEvents());
export function findEditableEvent({
events,
isForward,
fromEventId,
}: {
events: MatrixEvent[];
isForward: boolean;
fromEventId?: string;
}): MatrixEvent {
const maxIdx = events.length - 1;
const inc = isForward ? 1 : -1;
const beginIdx = isForward ? 0 : maxIdx;

View file

@ -62,8 +62,9 @@ export default class MultiInviter {
/**
* @param {string} targetId The ID of the room or group to invite to
* @param {function} progressCallback optional callback, fired after each invite.
*/
constructor(targetId: string) {
constructor(targetId: string, private readonly progressCallback?: () => void) {
if (targetId[0] === '+') {
this.roomId = null;
this.groupId = targetId;
@ -181,6 +182,7 @@ export default class MultiInviter {
delete this.errors[address];
resolve();
this.progressCallback?.();
}).catch((err) => {
if (this.canceled) {
return;

View file

@ -0,0 +1,267 @@
/*
Copyright 2021 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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { IExportOptions, ExportType } from "./exportUtils";
import { decryptFile } from "../DecryptFile";
import { mediaFromContent } from "../../customisations/Media";
import { formatFullDateNoDay } from "../../DateUtils";
import { isVoiceMessage } from "../EventUtils";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Direction } from "matrix-js-sdk/src/models/event-timeline";
import { IMediaEventContent } from "../../customisations/models/IMediaEventContent";
import { saveAs } from "file-saver";
import { _t } from "../../languageHandler";
import SdkConfig from "../../SdkConfig";
type BlobFile = {
name: string;
blob: Blob;
};
export default abstract class Exporter {
protected files: BlobFile[] = [];
protected client: MatrixClient;
protected cancelled = false;
protected constructor(
protected room: Room,
protected exportType: ExportType,
protected exportOptions: IExportOptions,
protected setProgressText: React.Dispatch<React.SetStateAction<string>>,
) {
if (exportOptions.maxSize < 1 * 1024 * 1024|| // Less than 1 MB
exportOptions.maxSize > 2000 * 1024 * 1024|| // More than ~ 2 GB
exportOptions.numberOfMessages > 10**8
) {
throw new Error("Invalid export options");
}
this.client = MatrixClientPeg.get();
window.addEventListener("beforeunload", this.onBeforeUnload);
}
protected onBeforeUnload(e: BeforeUnloadEvent): string {
e.preventDefault();
return e.returnValue = _t("Are you sure you want to exit during this export?");
}
protected updateProgress(progress: string, log = true, show = true): void {
if (log) console.log(progress);
if (show) this.setProgressText(progress);
}
protected addFile(filePath: string, blob: Blob): void {
const file = {
name: filePath,
blob,
};
this.files.push(file);
}
protected async downloadZIP(): Promise<string | void> {
const brand = SdkConfig.get().brand;
const filename = `${brand} - Chat Export - ${formatFullDateNoDay(new Date())}.zip`;
const { default: JSZip } = await import('jszip');
const zip = new JSZip();
// Create a writable stream to the directory
if (!this.cancelled) this.updateProgress("Generating a ZIP");
else return this.cleanUp();
for (const file of this.files) zip.file(file.name, file.blob);
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, filename);
}
protected cleanUp(): string {
console.log("Cleaning up...");
window.removeEventListener("beforeunload", this.onBeforeUnload);
return "";
}
public async cancelExport(): Promise<void> {
console.log("Cancelling export...");
this.cancelled = true;
}
protected downloadPlainText(fileName: string, text: string) {
const content = new Blob([text], { type: "text" });
saveAs(content, fileName);
}
protected setEventMetadata(event: MatrixEvent): MatrixEvent {
const roomState = this.client.getRoom(this.room.roomId).currentState;
event.sender = roomState.getSentinelMember(
event.getSender(),
);
if (event.getType() === "m.room.member") {
event.target = roomState.getSentinelMember(
event.getStateKey(),
);
}
return event;
}
public getLimit(): number {
let limit: number;
switch (this.exportType) {
case ExportType.LastNMessages:
limit = this.exportOptions.numberOfMessages;
break;
case ExportType.Timeline:
limit = 40;
break;
default:
limit = 10**8;
}
return limit;
}
protected async getRequiredEvents(): Promise<MatrixEvent[]> {
const eventMapper = this.client.getEventMapper();
let prevToken: string|null = null;
let limit = this.getLimit();
const events: MatrixEvent[] = [];
while (limit) {
const eventsPerCrawl = Math.min(limit, 1000);
const res = await this.client.createMessagesRequest(
this.room.roomId,
prevToken,
eventsPerCrawl,
Direction.Backward,
);
if (this.cancelled) {
this.cleanUp();
return [];
}
if (res.chunk.length === 0) break;
limit -= res.chunk.length;
const matrixEvents: MatrixEvent[] = res.chunk.map(eventMapper);
for (const mxEv of matrixEvents) {
// if (this.exportOptions.startDate && mxEv.getTs() < this.exportOptions.startDate) {
// // Once the last message received is older than the start date, we break out of both the loops
// limit = 0;
// break;
// }
events.push(mxEv);
}
this.updateProgress(
("Fetched " + events.length + " events ") + (this.exportType === ExportType.LastNMessages
? `out of ${this.exportOptions.numberOfMessages}`
: "so far"),
);
prevToken = res.end;
}
// Reverse the events so that we preserve the order
for (let i = 0; i < Math.floor(events.length/2); i++) {
[events[i], events[events.length - i - 1]] = [events[events.length - i - 1], events[i]];
}
const decryptionPromises = events
.filter(event => event.isEncrypted())
.map(event => {
return this.client.decryptEventIfNeeded(event, {
isRetry: true,
emit: false,
});
});
// Wait for all the events to get decrypted.
await Promise.all(decryptionPromises);
for (let i = 0; i < events.length; i++) this.setEventMetadata(events[i]);
return events;
}
protected async getMediaBlob(event: MatrixEvent): Promise<Blob> {
let blob: Blob;
try {
const isEncrypted = event.isEncrypted();
const content: IMediaEventContent = event.getContent();
const shouldDecrypt = isEncrypted && content.hasOwnProperty("file") && event.getType() !== "m.sticker";
if (shouldDecrypt) {
blob = await decryptFile(content.file);
} else {
const media = mediaFromContent(content);
const image = await fetch(media.srcHttp);
blob = await image.blob();
}
} catch (err) {
console.log("Error decrypting media");
}
return blob;
}
public splitFileName(file: string): string[] {
const lastDot = file.lastIndexOf('.');
if (lastDot === -1) return [file, ""];
const fileName = file.slice(0, lastDot);
const ext = file.slice(lastDot + 1);
return [fileName, '.' + ext];
}
public getFilePath(event: MatrixEvent): string {
const mediaType = event.getContent().msgtype;
let fileDirectory: string;
switch (mediaType) {
case "m.image":
fileDirectory = "images";
break;
case "m.video":
fileDirectory = "videos";
break;
case "m.audio":
fileDirectory = "audio";
break;
default:
fileDirectory = event.getType() === "m.sticker" ? "stickers" : "files";
}
const fileDate = formatFullDateNoDay(new Date(event.getTs()));
let [fileName, fileExt] = this.splitFileName(event.getContent().body);
if (event.getType() === "m.sticker") fileExt = ".png";
if (isVoiceMessage(event)) fileExt = ".ogg";
return fileDirectory + "/" + fileName + '-' + fileDate + fileExt;
}
protected isReply(event: MatrixEvent): boolean {
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"]);
}
protected isAttachment(mxEv: MatrixEvent): boolean {
const attachmentTypes = ["m.sticker", "m.image", "m.file", "m.video", "m.audio"];
return mxEv.getType() === attachmentTypes[0] || attachmentTypes.includes(mxEv.getContent().msgtype);
}
abstract export(): Promise<void>;
}

View file

@ -0,0 +1,442 @@
/*
Copyright 2021 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 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, MsgType } 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 exportJS from "!!raw-loader!./exportJS";
import { ExportType } from "./exportUtils";
import { IExportOptions } from "./exportUtils";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import getExportCSS from "./exportCSS";
import { textForEvent } from "../../TextForEvent";
export default class HTMLExporter extends Exporter {
protected avatars: Map<string, boolean>;
protected permalinkCreator: RoomPermalinkCreator;
protected totalSize: number;
protected mediaOmitText: string;
constructor(
room: Room,
exportType: ExportType,
exportOptions: IExportOptions,
setProgressText: React.Dispatch<React.SetStateAction<string>>,
) {
super(room, exportType, exportOptions, setProgressText);
this.avatars = new Map<string, boolean>();
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 = (
<BaseAvatar
width={32}
height={32}
name={this.room.name}
title={this.room.name}
url={blob ? avatarPath : null}
resizeMethod="crop"
/>
);
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(
<p>
{ _t(
"This is the start of export of <roomName/>. Exported by <exporterDetails/> at %(exportDate)s.",
{
exportDate,
},
{
roomName: () => <b>{ this.room.name }</b>,
exporterDetails: () => (
<a
href={`https://matrix.to/#/${exporter}`}
target="_blank"
rel="noopener noreferrer"
>
{ exporterName ? (
<>
<b>{ exporterName }</b>
{ " (" + exporter + ")" }
</>
) : (
<b>{ exporter }</b>
) }
</a>
),
},
) }
</p>,
);
const topicText = topic ? _t("Topic: %(topic)s", { topic }) : "";
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">
${roomAvatar}
</div>
</div>
<div class="mx_RoomHeader_name">
<div
dir="auto"
class="mx_RoomHeader_nametext"
title="${this.room.name}"
>
${this.room.name}
</div>
</div>
<div class="mx_RoomHeader_topic" dir="auto"> ${topic} </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"
>
<div class="mx_NewRoomIntro">
${roomAvatar}
<h2> ${this.room.name} </h2>
<p> ${createdText} <br/><br/> ${exportedText} </p>
<br/>
<p> ${topicText} </p>
</div>
${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>`;
}
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 = <li key={ts}><DateSeparator forExport={true} key={ts} ts={ts} /></li>;
return renderToStaticMarkup(dateSeparator);
}
protected needsDateSeparator(event: MatrixEvent, prevEvent: MatrixEvent) {
if (prevEvent == null) return true;
return wantsDateSeparator(prevEvent.getDate(), event.getDate());
}
public getEventTile(mxEv: MatrixEvent, continuation: boolean) {
return <div className="mx_Export_EventWrapper" id={mxEv.getId()}>
<MatrixClientContext.Provider value={this.client}>
<EventTile
mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
forExport={true}
readReceipts={null}
alwaysShowTimestamps={true}
readReceiptMap={null}
showUrlPreview={false}
checkUnmounting={() => 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}
/>
</MatrixClientContext.Provider>
</div>;
}
protected async getEventTileMarkup(mxEv: MatrixEvent, continuation: boolean, filePath?: string) {
const hasAvatar = !!this.getAvatarURL(mxEv);
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 class="mx_MFileBody_info_icon".*?>.*?<\/span>/, '');
if (hasAvatar) {
eventTileMarkup = eventTileMarkup.replace(
encodeURI(this.getAvatarURL(mxEv)).replace(/&/g, '&amp;'),
`users/${mxEv.sender.userId.replace(/:/g, "-")}.png`,
);
}
return eventTileMarkup;
}
protected createModifiedEvent(text: string, mxEv: MatrixEvent, italic=true) {
const modifiedContent = {
msgtype: "m.text",
body: `${text}`,
format: "org.matrix.custom.html",
formatted_body: `${text}`,
};
if (italic) {
modifiedContent.formatted_body = '<em>' + modifiedContent.formatted_body + '</em>';
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) {
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) {
console.log("Error while fetching file" + e);
eventTile = await this.getEventTileMarkup(
this.createModifiedEvent(_t("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
console.error(e);
eventTile = await this.getEventTileMarkup(
this.createModifiedEvent(textForEvent(mxEv), mxEv, false),
joined,
);
}
return eventTile;
}
protected async createHTML(events: MatrixEvent[], start: number) {
let content = "";
let prevEvent = null;
for (let i = start; i < Math.min(start + 1000, 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...");
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...");
for (let page = 0; page < res.length / 1000; page++) {
const html = await this.createHTML(res, page * 1000);
this.addFile(`messages${page ? page + 1 : ""}.html`, new Blob([html]));
}
const exportCSS = await getExportCSS();
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) {
console.info("Export cancelled successfully");
} else {
this.updateProgress("Export successful!");
this.updateProgress(`Exported ${res.length} events in ${(exportEnd - fetchStart)/1000} seconds`);
}
this.cleanUp();
}
}

View file

@ -0,0 +1,122 @@
/*
Copyright 2021 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 Exporter from "./Exporter";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { formatFullDateNoDay, formatFullDateNoDayNoTime } from "../../DateUtils";
import { haveTileForEvent } from "../../components/views/rooms/EventTile";
import { ExportType } from "./exportUtils";
import { IExportOptions } from "./exportUtils";
import { EventType } from "matrix-js-sdk/src/@types/event";
export default class JSONExporter extends Exporter {
protected totalSize = 0;
protected messages: Record<string, any>[] = [];
constructor(
room: Room,
exportType: ExportType,
exportOptions: IExportOptions,
setProgressText: React.Dispatch<React.SetStateAction<string>>,
) {
super(room, exportType, exportOptions, setProgressText);
}
protected createJSONString(): string {
const exportDate = formatFullDateNoDayNoTime(new Date());
const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
const creatorName = this.room?.getMember(creator)?.rawDisplayName || creator;
const topic = this.room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || "";
const exporter = this.client.getUserId();
const exporterName = this.room?.getMember(exporter)?.rawDisplayName || exporter;
const jsonObject = {
room_name: this.room.name,
room_creator: creatorName,
topic,
export_date: exportDate,
exported_by: exporterName,
messages: this.messages,
};
return JSON.stringify(jsonObject, null, 2);
}
protected async getJSONString(mxEv: MatrixEvent) {
if (this.exportOptions.attachmentsIncluded && this.isAttachment(mxEv)) {
try {
const blob = await this.getMediaBlob(mxEv);
if (this.totalSize + blob.size < this.exportOptions.maxSize) {
this.totalSize += blob.size;
const filePath = this.getFilePath(mxEv);
if (this.totalSize == this.exportOptions.maxSize) {
this.exportOptions.attachmentsIncluded = false;
}
this.addFile(filePath, blob);
}
} catch (err) {
console.log("Error fetching file: " + err);
}
}
const jsonEvent: any = mxEv.toJSON();
const clearEvent = mxEv.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
return clearEvent;
}
protected async createOutput(events: MatrixEvent[]) {
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;
this.messages.push(await this.getJSONString(event));
}
return this.createJSONString();
}
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 output...");
const text = await this.createOutput(res);
if (this.files.length) {
this.addFile("export.json", new Blob([text]));
await this.downloadZIP();
} else {
const fileName = `matrix-export-${formatFullDateNoDay(new Date())}.json`;
this.downloadPlainText(fileName, text);
}
const exportEnd = performance.now();
if (this.cancelled) {
console.info("Export cancelled successfully");
} else {
console.info("Export successful!");
console.log(`Exported ${res.length} events in ${(exportEnd - fetchStart)/1000} seconds`);
}
this.cleanUp();
}
}

View file

@ -0,0 +1,151 @@
/*
Copyright 2021 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 Exporter from "./Exporter";
import { Room } from "matrix-js-sdk/src/models/room";
import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { formatFullDateNoDay } from "../../DateUtils";
import { _t } from "../../languageHandler";
import { haveTileForEvent } from "../../components/views/rooms/EventTile";
import { ExportType } from "./exportUtils";
import { IExportOptions } from "./exportUtils";
import { textForEvent } from "../../TextForEvent";
export default class PlainTextExporter extends Exporter {
protected totalSize: number;
protected mediaOmitText: string;
constructor(
room: Room,
exportType: ExportType,
exportOptions: IExportOptions,
setProgressText: React.Dispatch<React.SetStateAction<string>>,
) {
super(room, exportType, exportOptions, setProgressText);
this.totalSize = 0;
this.mediaOmitText = !this.exportOptions.attachmentsIncluded
? _t("Media omitted")
: _t("Media omitted - file size limit exceeded");
}
public textForReplyEvent = (content: IContent) => {
const REPLY_REGEX = /> <(.*?)>(.*?)\n\n(.*)/s;
const REPLY_SOURCE_MAX_LENGTH = 32;
const match = REPLY_REGEX.exec(content.body);
// if the reply format is invalid, then return the body
if (!match) return content.body;
let rplSource: string;
const rplName = match[1];
const rplText = match[3];
rplSource = match[2].substring(1);
// Get the first non-blank line from the source.
const lines = rplSource.split('\n').filter((line) => !/^\s*$/.test(line));
if (lines.length > 0) {
// Cut to a maximum length.
rplSource = lines[0].substring(0, REPLY_SOURCE_MAX_LENGTH);
// Ellipsis if needed.
if (lines[0].length > REPLY_SOURCE_MAX_LENGTH) {
rplSource = rplSource + "...";
}
// Wrap in formatting
rplSource = ` "${rplSource}"`;
} else {
// Don't show a source because we couldn't format one.
rplSource = "";
}
return `<${rplName}${rplSource}> ${rplText}`;
};
protected plainTextForEvent = async (mxEv: MatrixEvent) => {
const senderDisplayName = mxEv.sender && mxEv.sender.name ? mxEv.sender.name : mxEv.getSender();
let mediaText = "";
if (this.isAttachment(mxEv)) {
if (this.exportOptions.attachmentsIncluded) {
try {
const blob = await this.getMediaBlob(mxEv);
if (this.totalSize + blob.size > this.exportOptions.maxSize) {
mediaText = ` (${this.mediaOmitText})`;
} else {
this.totalSize += blob.size;
const filePath = this.getFilePath(mxEv);
mediaText = " (" + _t("File Attached") + ")";
this.addFile(filePath, blob);
if (this.totalSize == this.exportOptions.maxSize) {
this.exportOptions.attachmentsIncluded = false;
}
}
} catch (error) {
mediaText = " (" + _t("Error fetching file") + ")";
console.log("Error fetching file " + error);
}
} else mediaText = ` (${this.mediaOmitText})`;
}
if (this.isReply(mxEv)) return senderDisplayName + ": " + this.textForReplyEvent(mxEv.getContent()) + mediaText;
else return textForEvent(mxEv) + mediaText;
};
protected async createOutput(events: MatrixEvent[]) {
let content = "";
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;
const textForEvent = await this.plainTextForEvent(event);
content += textForEvent && `${new Date(event.getTs()).toLocaleString()} - ${textForEvent}\n`;
}
return content;
}
public async export() {
this.updateProgress("Starting export process...");
this.updateProgress("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`);
this.updateProgress("Creating output...");
const text = await this.createOutput(res);
if (this.files.length) {
this.addFile("export.txt", new Blob([text]));
await this.downloadZIP();
} else {
const fileName = `matrix-export-${formatFullDateNoDay(new Date())}.txt`;
this.downloadPlainText(fileName, text);
}
const exportEnd = performance.now();
if (this.cancelled) {
console.info("Export cancelled successfully");
} else {
console.info("Export successful!");
console.log(`Exported ${res.length} events in ${(exportEnd - fetchStart)/1000} seconds`);
}
this.cleanUp();
}
}

View file

@ -0,0 +1,50 @@
/*
Copyright 2021 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.
*/
/* eslint-disable max-len, camelcase */
import customCSS from "!!raw-loader!./exportCustomCSS.css";
const getExportCSS = async (): Promise<string> => {
const stylesheets: string[] = [];
document.querySelectorAll('link[rel="stylesheet"]').forEach((e: any) => {
if (e.href.endsWith("bundle.css") || e.href.endsWith("theme-light.css")) {
stylesheets.push(e.href);
}
});
let CSS = "";
for (const stylesheet of stylesheets) {
const res = await fetch(stylesheet);
const innerText = await res.text();
CSS += innerText;
}
const fontFaceRegex = /@font-face {.*?}/sg;
CSS = CSS.replace(fontFaceRegex, '');
CSS = CSS.replace(
/font-family: (Inter|'Inter')/g,
`font-family: -apple-system, BlinkMacSystemFont, avenir next,
avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif`,
);
CSS = CSS.replace(
/font-family: Inconsolata/g,
"font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace",
);
return CSS + customCSS;
};
export default getExportCSS;

View file

@ -0,0 +1,138 @@
/*
Copyright 2021 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.
*/
/*
This file is raw-imported (imported as plain text) for the export bundle, which is the reason for the .css format and the colours being hard-coded hard-coded.
*/
#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-family: -apple-system, BlinkMacSystemFont, avenir next, avenir,
segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial,
sans-serif;
font-weight: 400;
line-height: 1.43;
border-radius: 4px;
letter-spacing: 0.01071em;
}
#snackbar.mx_show {
visibility: visible;
-webkit-animation: mx_snackbar_fadein 0.5s, mx_snackbar_fadeout 0.5s 2.5s;
animation: mx_snackbar_fadein 0.5s, mx_snackbar_fadeout 0.5s 2.5s;
}
a.mx_reply_anchor {
cursor: pointer;
color: #238cf5;
}
a.mx_reply_anchor:hover {
text-decoration: underline;
}
@-webkit-keyframes mx_snackbar_fadein {
from {
bottom: 0;
opacity: 0;
}
to {
bottom: 30px;
opacity: 1;
}
}
@keyframes mx_snackbar_fadein {
from {
bottom: 0;
opacity: 0;
}
to {
bottom: 30px;
opacity: 1;
}
}
@-webkit-keyframes mx_snackbar_fadeout {
from {
bottom: 30px;
opacity: 1;
}
to {
bottom: 0;
opacity: 0;
}
}
@keyframes mx_snackbar_fadeout {
from {
bottom: 30px;
opacity: 1;
}
to {
bottom: 0;
opacity: 0;
}
}
* {
scroll-behavior: smooth !important;
}
.mx_Export_EventWrapper:target {
background: white;
animation: mx_event_highlight_animation 2s linear;
}
@keyframes mx_event_highlight_animation {
0%,
100% {
background: white;
}
50% {
background: #e3e2df;
}
}
.mx_ReplyThread_Export {
margin-top: 0px;
margin-bottom: 5px;
}
.mx_RedactedBody {
padding-left: unset;
}
img {
white-space: nowrap;
overflow: hidden;
}
.mx_MatrixChat {
max-width: 100%;
}

View file

@ -0,0 +1,42 @@
/*
Copyright 2021 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.
*/
// This file is raw-imported (imported as plain text) for the export bundle, which is why this is in JS
function showToastIfNeeded(replyId) {
const el = document.getElementById(replyId);
if (!el) {
showToast("The message you're looking for wasn't exported");
return;
}
}
function showToast(text) {
const 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 => {
showToastIfNeeded(event.target.getAttribute("scroll-to"));
});
});
};

View file

@ -0,0 +1,65 @@
/*
Copyright 2021 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 { _t } from "../../languageHandler";
export enum ExportFormat {
Html = "Html",
PlainText = "PlainText",
Json = "Json",
}
export enum ExportType {
Timeline = "Timeline",
Beginning = "Beginning",
LastNMessages = "LastNMessages",
// START_DATE = "START_DATE",
}
export const textForFormat = (format: ExportFormat): string => {
switch (format) {
case ExportFormat.Html:
return _t("HTML");
case ExportFormat.Json:
return _t("JSON");
case ExportFormat.PlainText:
return _t("Plain Text");
default:
throw new Error("Unknown format");
}
};
export const textForType = (type: ExportType): string => {
switch (type) {
case ExportType.Beginning:
return _t("From the beginning");
case ExportType.LastNMessages:
return _t("Specify a number of messages");
case ExportType.Timeline:
return _t("Current Timeline");
default:
throw new Error("Unknown type: " + type);
// case exportTypes.START_DATE:
// return _t("From a specific date");
}
};
export interface IExportOptions {
// startDate?: number;
numberOfMessages?: number;
attachmentsIncluded: boolean;
maxSize: number;
}

View file

@ -16,8 +16,8 @@ limitations under the License.
// Returns a promise which resolves when the input promise resolves with its value
// or when the timeout of ms is reached with the value of given timeoutValue
export async function timeout<T>(promise: Promise<T>, timeoutValue: T, ms: number): Promise<T> {
const timeoutPromise = new Promise<T>((resolve) => {
export async function timeout<T, Y>(promise: Promise<T>, timeoutValue: Y, ms: number): Promise<T | Y> {
const timeoutPromise = new Promise<T | Y>((resolve) => {
const timeoutId = setTimeout(resolve, ms, timeoutValue);
promise.then(() => {
clearTimeout(timeoutId);