mirror of https://github.com/vector-im/riot-web
Rewrite export tool to use existing components to render output, use existing source URLs for media
parent
60ef6f9332
commit
573a3ca983
|
@ -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
|
||||
|
|
|
@ -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} />;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -1244,11 +1251,11 @@ class E2ePadlock extends React.Component<IE2ePadlockProps, IE2ePadlockState> {
|
|||
}
|
||||
|
||||
onHoverStart = () => {
|
||||
this.setState({hover: true});
|
||||
this.setState({ hover: true });
|
||||
};
|
||||
|
||||
onHoverEnd = () => {
|
||||
this.setState({hover: false});
|
||||
this.setState({ hover: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -1286,11 +1293,11 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
|
|||
}
|
||||
|
||||
onHoverStart = () => {
|
||||
this.setState({hover: true});
|
||||
this.setState({ hover: true });
|
||||
};
|
||||
|
||||
onHoverEnd = () => {
|
||||
this.setState({hover: false});
|
||||
this.setState({ hover: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
@ -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"));
|
||||
})
|
||||
})
|
||||
}
|
||||
`
|
Loading…
Reference in New Issue