578 lines
No EOL
23 KiB
Vue
578 lines
No EOL
23 KiB
Vue
<template>
|
|
<div class="chat-root">
|
|
<div class="chat-root export d-flex flex-column" ref="exportRoot">
|
|
<!-- Header-->
|
|
<v-container fluid class="chat-header flex-grow-0 flex-shrink-0">
|
|
<v-row class="chat-header-row flex-nowrap">
|
|
<v-col class="chat-header-name ma-0 pa-0 flex-shrink-1 flex-nowrap">
|
|
<div class="room-name-inline text-truncate" :title="room.name">
|
|
{{ room.name }}
|
|
</div>
|
|
<div class="num-members">{{ $tc("room.members", room.getJoinedMemberCount()) }}</div>
|
|
</v-col>
|
|
|
|
<v-col cols="auto" class="text-end ma-0 pa-0">{{ exportDate }}</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
|
|
<div class="chat-content flex-grow-1 flex-shrink-1" ref="chatContainer">
|
|
<div v-for="(event, index) in events" :key="event.getId()" :eventId="event.getId()">
|
|
<!-- DAY Marker, shown for every new day in the timeline -->
|
|
<div v-if="showDayMarkerBeforeEvent(event)" class="day-marker">
|
|
<div class="line"></div>
|
|
<div class="text">{{ dayForEvent(event) }}</div>
|
|
<div class="line"></div>
|
|
</div>
|
|
|
|
<div v-if="!event.isRelation() && !event.isRedacted() && !event.isRedaction()" :ref="event.getId()">
|
|
<div class="message-wrapper">
|
|
<component :is="componentForEvent(event, true)" :room="room" :originalEvent="event"
|
|
:nextEvent="events[index + 1]" :timelineSet="timelineSet" :componentFn="componentForEventForExport"
|
|
ref="exportedEvent" v-on:layout-change="onLayoutChange" />
|
|
<!-- <div v-if="debugging" style="user-select:text">EventID: {{ event.getId() }}</div> -->
|
|
<!-- <div v-if="debugging" style="user-select:text">Event: {{ JSON.stringify(event) }}</div> -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading indicator -->
|
|
<v-container fluid fill-height class="exporting-indicator">
|
|
<v-row align="center" justify="center">
|
|
<v-col class="text-center">
|
|
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
|
<div>{{ statusText }}</div>
|
|
<v-btn color="black" depressed class="filled-button mt-5" @click.stop="cancelExport">{{
|
|
$t("menu.cancel")
|
|
}}</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import MessageIncomingText from "./messages/MessageIncomingText.vue";
|
|
import MessageIncomingFile from "./messages/MessageIncomingFile.vue";
|
|
import MessageIncomingImage from "./messages/MessageIncomingImage.vue";
|
|
import MessageIncomingAudio from "./messages/MessageIncomingAudio.vue";
|
|
import MessageIncomingVideo from "./messages/MessageIncomingVideo.vue";
|
|
import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue";
|
|
import MessageOutgoingText from "./messages/MessageOutgoingText.vue";
|
|
import MessageOutgoingFile from "./messages/MessageOutgoingFile.vue";
|
|
import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue";
|
|
import MessageOutgoingAudio from "./messages/MessageOutgoingAudio.vue";
|
|
import MessageOutgoingVideo from "./messages/MessageOutgoingVideo.vue";
|
|
import MessageOutgoingSticker from "./messages/MessageOutgoingSticker.vue";
|
|
import MessageOutgoingPoll from "./messages/MessageOutgoingPoll.vue";
|
|
import ContactJoin from "./messages/ContactJoin.vue";
|
|
import ContactLeave from "./messages/ContactLeave.vue";
|
|
import ContactInvited from "./messages/ContactInvited.vue";
|
|
import ContactChanged from "./messages/ContactChanged.vue";
|
|
import RoomCreated from "./messages/RoomCreated.vue";
|
|
import RoomAliased from "./messages/RoomAliased.vue";
|
|
import RoomNameChanged from "./messages/RoomNameChanged.vue";
|
|
import RoomTopicChanged from "./messages/RoomTopicChanged.vue";
|
|
import RoomAvatarChanged from "./messages/RoomAvatarChanged.vue";
|
|
import RoomHistoryVisibility from "./messages/RoomHistoryVisibility.vue";
|
|
import RoomJoinRules from "./messages/RoomJoinRules.vue";
|
|
import RoomPowerLevelsChanged from "./messages/RoomPowerLevelsChanged.vue";
|
|
import RoomGuestAccessChanged from "./messages/RoomGuestAccessChanged.vue";
|
|
import RoomEncrypted from "./messages/RoomEncrypted.vue";
|
|
import RoomDeletionNotice from "./messages/RoomDeletionNotice.vue";
|
|
import DebugEvent from "./messages/DebugEvent.vue";
|
|
import MessageOperations from "./messages/MessageOperations.vue";
|
|
import ChatHeader from "./ChatHeader.vue";
|
|
import VoiceRecorder from "./VoiceRecorder.vue";
|
|
import RoomInfoBottomSheet from "./RoomInfoBottomSheet.vue";
|
|
import WelcomeHeaderRoom from "./welcome_headers/WelcomeHeaderRoom.vue";
|
|
import MessageOperationsBottomSheet from "./MessageOperationsBottomSheet.vue";
|
|
import StickerPickerBottomSheet from "./StickerPickerBottomSheet.vue";
|
|
import BottomSheet from "./BottomSheet.vue";
|
|
import CreatePollDialog from "./CreatePollDialog.vue";
|
|
import chatMixin from "./chatMixin";
|
|
import util from "../plugins/utils";
|
|
import { EventTimelineSet } from "matrix-js-sdk";
|
|
import axios from 'axios';
|
|
import "../services/jszip.min";
|
|
import "../services/filesaver.cjs";
|
|
|
|
export default {
|
|
name: "RoomExport",
|
|
mixins: [chatMixin],
|
|
components: {
|
|
ChatHeader,
|
|
MessageIncomingText,
|
|
MessageIncomingFile,
|
|
MessageIncomingImage,
|
|
MessageIncomingAudio,
|
|
MessageIncomingVideo,
|
|
MessageIncomingSticker,
|
|
MessageOutgoingText,
|
|
MessageOutgoingFile,
|
|
MessageOutgoingImage,
|
|
MessageOutgoingAudio,
|
|
MessageOutgoingVideo,
|
|
MessageOutgoingSticker,
|
|
MessageOutgoingPoll,
|
|
ContactJoin,
|
|
ContactLeave,
|
|
ContactInvited,
|
|
ContactChanged,
|
|
RoomCreated,
|
|
RoomAliased,
|
|
RoomNameChanged,
|
|
RoomTopicChanged,
|
|
RoomAvatarChanged,
|
|
RoomHistoryVisibility,
|
|
RoomJoinRules,
|
|
RoomPowerLevelsChanged,
|
|
RoomGuestAccessChanged,
|
|
RoomEncrypted,
|
|
RoomDeletionNotice,
|
|
DebugEvent,
|
|
MessageOperations,
|
|
VoiceRecorder,
|
|
RoomInfoBottomSheet,
|
|
WelcomeHeaderRoom,
|
|
MessageOperationsBottomSheet,
|
|
StickerPickerBottomSheet,
|
|
BottomSheet,
|
|
CreatePollDialog,
|
|
},
|
|
props: {
|
|
room: {
|
|
type: Object,
|
|
default: function () {
|
|
return null;
|
|
},
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
timelineSet: null,
|
|
events: [],
|
|
fetchedEvents: 0,
|
|
totalEvents: 0,
|
|
processedEvents: 0,
|
|
statusText: "",
|
|
cancelled: false,
|
|
};
|
|
},
|
|
mounted() {
|
|
this.$nextTick(() => {
|
|
this.doExport();
|
|
});
|
|
},
|
|
watch: {
|
|
processedEvents() {
|
|
this.statusText = this.$t("room_export.processed_n_of_total_events", {
|
|
count: this.processedEvents,
|
|
total: this.totalEvents,
|
|
});
|
|
},
|
|
},
|
|
computed: {
|
|
exportDate() {
|
|
return this.$t("room_export.exported_date", { date: util.formatDay(Date.now().valueOf()) });
|
|
},
|
|
},
|
|
methods: {
|
|
componentForEventForExport(event) {
|
|
return this.componentForEvent(event, true);
|
|
},
|
|
cancelExport() {
|
|
this.cancelled = true;
|
|
},
|
|
async getEvents() {
|
|
// TODO - Handle pinned messages?
|
|
const eventsPerBatch = 100;
|
|
let batchToken = null;
|
|
var nToFetch = null;
|
|
this.totalEvents = nToFetch == null ? 0 : nToFetch;
|
|
var fetchedEvents = [];
|
|
const eventMapper = this.$matrix.matrixClient.getEventMapper();
|
|
|
|
while (nToFetch == null || nToFetch > 0) {
|
|
const result = await this.$matrix.matrixClient.createMessagesRequest(
|
|
this.room.roomId,
|
|
batchToken,
|
|
nToFetch == null ? eventsPerBatch : Math.min(nToFetch, eventsPerBatch),
|
|
"b"
|
|
);
|
|
// For testing, uncomment to give a chance to cancel...
|
|
// await new Promise((resolve, ignoredReject) => {
|
|
// setTimeout(() => {
|
|
// resolve(true);
|
|
// }, 1000);
|
|
// });
|
|
if (this.cancelled) {
|
|
return Promise.reject("cancelled");
|
|
}
|
|
if (result.chunk.length === 0) break;
|
|
if (nToFetch != null) {
|
|
nToFetch -= result.chunk.length;
|
|
this.statusText = this.$t("room_export.fetched_n_of_total_events", {
|
|
count: this.totalEvents - nToFetch,
|
|
total: this.totalEvents,
|
|
});
|
|
} else {
|
|
this.totalEvents += result.chunk.length;
|
|
this.statusText = this.$t("room_export.fetched_n_events", { count: this.totalEvents });
|
|
}
|
|
fetchedEvents.push(...result.chunk.map(eventMapper));
|
|
|
|
if (!result.end) break;
|
|
batchToken = result.end;
|
|
}
|
|
return fetchedEvents;
|
|
},
|
|
doExport() {
|
|
var zip = null;
|
|
var currentMediaSize = 0;
|
|
var maxMediaSize = 1024 * 1024 * 1024; // 1GB
|
|
|
|
this.getEvents()
|
|
.then((events) => {
|
|
var decryptionPromises = [];
|
|
for (const event of this.events) {
|
|
if (event.isEncrypted()) {
|
|
decryptionPromises.push(
|
|
this.$matrix.matrixClient.decryptEventIfNeeded(event, {
|
|
isRetry: true,
|
|
emit: false,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
return Promise.all(decryptionPromises).then(() => {
|
|
return events;
|
|
});
|
|
})
|
|
.then((events) => {
|
|
// Create a timeline and add the events to that, so that relations etc are aggregated correctly!
|
|
this.timelineSet = new EventTimelineSet(null, { unstableClientRelationAggregation: true });
|
|
this.timelineSet.addEventsToTimeline(events.reverse(), true, false, this.timelineSet.getLiveTimeline(), "");
|
|
this.events = events;
|
|
|
|
// Need to set thread root events and replyEvents so stuff is rendered correctly.
|
|
this.events.filter(event => (event.threadRootId && !event.parentThread)).forEach(event => {
|
|
const parentEvent = this.timelineSet.findEventById(event.threadRootId) || this.room.findEventById(event.threadRootId);
|
|
if (parentEvent) {
|
|
parentEvent["isMxThread"] = true;
|
|
event["parentThread"] = parentEvent;
|
|
}
|
|
});
|
|
this.events.filter(event => (event.replyEventId && !event.replyEvent)).forEach(event => {
|
|
const parentEvent = this.timelineSet.findEventById(event.replyEventId) || this.room.findEventById(event.replyEventId);
|
|
if (parentEvent) {
|
|
event["replyEvent"] = parentEvent;
|
|
}
|
|
});
|
|
|
|
// Wait a tick so UI is updated.
|
|
return new Promise((resolve, ignoredReject) => {
|
|
this.$nextTick(() => {
|
|
resolve(true);
|
|
});
|
|
});
|
|
})
|
|
.then(() => {
|
|
// UI updated, start processing events
|
|
zip = new JSZip();
|
|
var avatarFolder = zip.folder("avatars");
|
|
var imageFolder = zip.folder("images");
|
|
var audioFolder = zip.folder("audio");
|
|
var videoFolder = zip.folder("video");
|
|
var filesFolder = zip.folder("files");
|
|
|
|
var downloadPromises = [];
|
|
let components = this.$refs.exportedEvent;
|
|
for (const parentComp of components) {
|
|
let childComponents = [parentComp];
|
|
|
|
// Some components, i.e. the media threads, have subcomponents
|
|
// that we want to export. So pickup subcomponents here as well.
|
|
if (parentComp.$refs && parentComp.$refs.exportedEvent) {
|
|
if (Array.isArray(parentComp.$refs.exportedEvent)) {
|
|
for (const child of parentComp.$refs.exportedEvent) {
|
|
childComponents.push(child);
|
|
}
|
|
} else {
|
|
childComponents.push(parentComp.$refs.exportedEvent);
|
|
}
|
|
}
|
|
for (const comp of childComponents) {
|
|
|
|
// Avatars need downloading?
|
|
if (comp.$el) {
|
|
const avatars = comp.$el.getElementsByClassName("v-avatar");
|
|
if (avatars && avatars.length > 0) {
|
|
const member = this.room.getMember(comp.event.getSender());
|
|
if (member) {
|
|
const fileName = comp.event.getSender() + ".png";
|
|
|
|
const setSource = (fileName) => {
|
|
for (let avatarIndex = 0; avatarIndex < avatars.length; avatarIndex++) {
|
|
const avatarElement = avatars[avatarIndex];
|
|
const images = avatarElement.getElementsByTagName("img");
|
|
for (let imageIndex = 0; imageIndex < images.length; imageIndex++) {
|
|
const img = images[imageIndex];
|
|
img.onerror = undefined;
|
|
img.removeAttribute("src");
|
|
img.setAttribute("data-exported-src", './avatars/' + fileName);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!avatarFolder.file(fileName)) {
|
|
const url = member.getAvatarUrl(this.$matrix.matrixClient.getHomeserverUrl(), 40, 40, "scale", true, false, this.$matrix.useAuthedMedia);
|
|
if (url) {
|
|
avatarFolder.file(fileName, "empty");
|
|
downloadPromises.push(
|
|
axios.get(url, {
|
|
responseType: 'blob'
|
|
})
|
|
.then(result => {
|
|
if (result.data) {
|
|
avatarFolder.file(fileName, result.data);
|
|
setSource(fileName);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Download error: ", err);
|
|
avatarFolder.remove(fileName);
|
|
}));
|
|
}
|
|
} else {
|
|
setSource(fileName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let componentClass = comp.$vnode.tag.split("-").reverse()[0];
|
|
switch (componentClass) {
|
|
case "MessageIncomingImageExport":
|
|
case "MessageOutgoingImageExport":
|
|
// TODO - maybe consider what media to download based on the file size we already have?
|
|
// info = comp.event.getContent().info;
|
|
// if (info && info.size && currentMediaSize + info.size > maxMediaSize) {
|
|
// // No need to even download.
|
|
// console.log("Dont download!");
|
|
// continue;
|
|
// }
|
|
|
|
downloadPromises.push(
|
|
util
|
|
.getAttachment(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, comp.event, null, true)
|
|
.then((blob) => {
|
|
return new Promise((resolve, ignoredReject) => {
|
|
let mime = blob.type;
|
|
var extension = ".png";
|
|
switch (mime) {
|
|
case "image/jpeg":
|
|
case "image/jpg":
|
|
extension = ".jpg";
|
|
break;
|
|
case "image/gif":
|
|
extension = ".gif";
|
|
}
|
|
if (currentMediaSize + blob.size <= maxMediaSize) {
|
|
currentMediaSize += blob.size;
|
|
|
|
let fileName = comp.event.getId() + extension;
|
|
imageFolder.file(fileName, blob); // TODO calc bytes
|
|
|
|
let blobUrl = URL.createObjectURL(blob);
|
|
comp.src = blobUrl;
|
|
|
|
this.$nextTick(() => {
|
|
// Update source
|
|
let elements = comp.$el.getElementsByClassName("v-image__image");
|
|
let element = elements && elements[0];
|
|
if (element) {
|
|
element.style.backgroundImage = 'url("./images/' + fileName + '")';
|
|
element.classList.remove("v-image__image--preload");
|
|
}
|
|
URL.revokeObjectURL(blobUrl); // Give the blob back
|
|
this.processedEvents += 1;
|
|
resolve(true);
|
|
});
|
|
}
|
|
});
|
|
})
|
|
.catch((ignoredErr) => {
|
|
this.processedEvents += 1;
|
|
})
|
|
);
|
|
break;
|
|
case "MessageIncomingAudioExport":
|
|
case "MessageOutgoingAudioExport":
|
|
downloadPromises.push(
|
|
util
|
|
.getAttachment(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, comp.event, null, true)
|
|
.then((blob) => {
|
|
if (currentMediaSize + blob.size <= maxMediaSize) {
|
|
currentMediaSize += blob.size;
|
|
return new Promise((resolve, ignoredReject) => {
|
|
//let mime = blob.type;
|
|
var extension = ".mp3";
|
|
let fileName = comp.event.getId() + extension;
|
|
audioFolder.file(fileName, blob); // TODO calc bytes
|
|
//this.$nextTick(() => {
|
|
let elements = comp.$el.getElementsByTagName("audio");
|
|
let element = elements && elements[0];
|
|
if (element) {
|
|
element.setAttribute("data-exported-src", "./audio/" + fileName);
|
|
}
|
|
this.processedEvents += 1;
|
|
resolve(true);
|
|
//});
|
|
});
|
|
}
|
|
})
|
|
.catch((ignoredErr) => {
|
|
this.processedEvents += 1;
|
|
})
|
|
);
|
|
break;
|
|
case "MessageIncomingVideoExport":
|
|
case "MessageOutgoingVideoExport":
|
|
downloadPromises.push(
|
|
util
|
|
.getAttachment(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, comp.event, null, true)
|
|
.then((blob) => {
|
|
if (currentMediaSize + blob.size <= maxMediaSize) {
|
|
currentMediaSize += blob.size;
|
|
return new Promise((resolve, ignoredReject) => {
|
|
//let mime = blob.type;
|
|
var extension = ".mp4";
|
|
let fileName = comp.event.getId() + extension;
|
|
videoFolder.file(fileName, blob); // TODO calc bytes
|
|
// comp.src = "./video/" + fileName;
|
|
let elements = comp.$el.getElementsByTagName("video");
|
|
let element = elements && elements[0];
|
|
if (element) {
|
|
element.setAttribute("data-exported-src", "./video/" + fileName);
|
|
}
|
|
this.processedEvents += 1;
|
|
resolve(true);
|
|
});
|
|
}
|
|
})
|
|
.catch((ignoredErr) => {
|
|
this.processedEvents += 1;
|
|
})
|
|
);
|
|
break;
|
|
case "MessageIncomingFileExport":
|
|
case "MessageOutgoingFileExport":
|
|
downloadPromises.push(
|
|
util
|
|
.getAttachment(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, comp.event, null, true)
|
|
.then((blob) => {
|
|
if (currentMediaSize + blob.size <= maxMediaSize) {
|
|
currentMediaSize += blob.size;
|
|
return new Promise((resolve, ignoredReject) => {
|
|
var extension = util.getFileExtension(comp.event);
|
|
let fileName = comp.event.getId() + extension;
|
|
filesFolder.file(fileName, blob);
|
|
comp.href="./files/" + fileName;
|
|
this.processedEvents += 1;
|
|
resolve(true);
|
|
});
|
|
}
|
|
})
|
|
.catch((ignoredErr) => {
|
|
this.processedEvents += 1;
|
|
})
|
|
);
|
|
break;
|
|
default:
|
|
this.processedEvents += 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return Promise.all(downloadPromises);
|
|
})
|
|
.then(() => {
|
|
console.log("All media added, total size: " + currentMediaSize);
|
|
|
|
let root = this.$refs.exportRoot;
|
|
|
|
var doc = "<!DOCTYPE html>\n<html><head>\n<meta charset=\"utf-8\"/>\n";
|
|
|
|
for (const sheet of document.styleSheets) {
|
|
doc += "<style type='text/css'>\n";
|
|
for (const rule of sheet.cssRules) {
|
|
if (rule.constructor.name != "CSSFontFaceRule") {
|
|
// Strip font face rules for now.
|
|
doc += rule.cssText + "\n";
|
|
}
|
|
}
|
|
doc += "</style>\n";
|
|
}
|
|
doc +=
|
|
"</head><body><div class='v-application v-application--is-ltr theme--light' style='height:100%;overflow-y:auto'>";
|
|
const getCssRules = function (el) {
|
|
if (el.classList.contains("op-button")) {
|
|
el.innerHTML = "";
|
|
} else {
|
|
for (let i = 0; i < el.children.length; i++) {
|
|
getCssRules(el.children[i]);
|
|
}
|
|
}
|
|
};
|
|
getCssRules(root);
|
|
|
|
this.$nextTick(() => {
|
|
const contentHtml = this.$refs.exportRoot.outerHTML;
|
|
doc += contentHtml.replaceAll("data-exported-src=", "src=");
|
|
doc += "</div></body></html>";
|
|
|
|
zip.file("chat.html", doc);
|
|
zip.generateAsync({ type: "blob" }).then((content) => {
|
|
saveAs(
|
|
content,
|
|
this.$t("room_export.export_filename", { date: util.formatDay(Date.now().valueOf()) }) + ".zip"
|
|
);
|
|
this.status = "";
|
|
this.$emit("close");
|
|
});
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
console.error("Failed to export:", err);
|
|
this.$emit("close");
|
|
});
|
|
},
|
|
onLayoutChange(action, ignoredelement) {
|
|
action();
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.chat-root.export {
|
|
.messageIn-thread, .messageOut-thread {
|
|
/** For media threads, hide all duplicated metadata, like
|
|
sender, sender avatar, time, quick reactions etc. They are
|
|
shown for the root thread event */
|
|
.messageIn {
|
|
margin-left: 50px !important;
|
|
}
|
|
.messageOut {
|
|
margin-right: 50px !important;
|
|
}
|
|
.messageIn, .messageOut {
|
|
.quick-reaction-container, .senderAndTime, .avatar {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style> |