keanu-weblite/src/components/RoomExport.vue
2025-09-10 18:06:41 +02:00

542 lines
20 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">{{ $t("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="componentForEvent"
ref="exportedEvent"
v-on:layout-change="onLayoutChange"
@vue:mounted="addComponent"
v-on:componentMounted="addComponent"
/>
<!-- <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 class="exporting-indicator fill-height">
<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" variant="flat" 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 MessageFile from "./messages/composition/MessageFile.vue";
import MessageImage from "./messages/composition/MessageImage.vue";
import MessageIncomingAudio from "./messages/MessageIncomingAudio.vue";
import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue";
import MessageOutgoingText from "./messages/MessageOutgoingText.vue";
import MessageOutgoingAudio from "./messages/MessageOutgoingAudio.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/composition/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";
import { toRaw } from "vue";
export default {
name: "RoomExport",
mixins: [chatMixin],
emits: ["close"],
components: {
ChatHeader,
MessageIncomingText,
MessageFile,
MessageImage,
MessageIncomingAudio,
MessageIncomingSticker,
MessageOutgoingText,
MessageOutgoingAudio,
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: [],
exportComponents: [],
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: {
addComponent(item) {
//console.log('Add component of type', item.type.__file);
this.exportComponents.push(item);
},
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;
},
async doExport() {
var zip = null;
var currentMediaSize = 0;
var maxMediaSize = 1024 * 1024 * 1024; // 1GB
try {
this.exportComponents = [];
const events = await this.getEvents();
let decryptionPromises = [];
for (const event of events) {
if (event.isEncrypted()) {
decryptionPromises.push(
this.$matrix.matrixClient.decryptEventIfNeeded(event, {
isRetry: true,
emit: false,
})
);
}
}
await Promise.all(decryptionPromises);
// 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.
await new Promise((resolve, ignoredReject) => {
this.$nextTick(() => {
resolve(true);
});
});
this.totalEvents = this.exportComponents.length;
let beforeExportPromises = this.exportComponents
.filter((c) => c.component.exposed?.beforeExport !== undefined)
.map((c) => c.component.exposed.beforeExport());
await Promise.all(beforeExportPromises);
// 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 = [];
for (const comp of this.exportComponents.filter((c) => c.props.originalEvent != undefined)) {
const event = comp.props.originalEvent;
// Avatars need downloading?
if (comp.el && comp.el.nodeType == 1) {
const avatars = comp.el.getElementsByClassName("v-avatar");
if (avatars && avatars.length > 0) {
const member = this.room.getMember(event.getSender());
if (member) {
const fileName = 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",
headers: this.$matrix.useAuthedMedia
? {
Authorization: `Bearer ${this.$matrix.matrixClient.getAccessToken()}`,
}
: undefined,
})
.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 attachment = event && event.getId ? this.$matrix.attachmentManager.getEventAttachment(event) : undefined;
if (attachment && (attachment.srcSize = 0 || currentMediaSize + attachment.srcSize <= maxMediaSize)) {
downloadPromises.push(
attachment
.loadBlob()
.then((res) => {
const blob = res.data;
if (currentMediaSize + blob.size <= maxMediaSize) {
currentMediaSize += blob.size;
const mime = blob.type;
if (mime.startsWith("image/")) {
var extension = ".png";
switch (mime) {
case "image/jpeg":
case "image/jpg":
extension = ".jpg";
break;
case "image/gif":
extension = ".gif";
break;
}
let fileName = event.getId() + extension;
imageFolder.file(fileName, blob); // TODO calc bytes
// Update source
const images = comp.el.getElementsByTagName("img");
for (let imageIndex = 0; imageIndex < images.length; imageIndex++) {
const img = images[imageIndex];
img.removeAttribute("src");
img.setAttribute("data-exported-src", "./images/" + fileName);
}
} else if (mime.startsWith("audio/")) {
var extension = ".webm";
switch (mime) {
case "audio/mpeg":
extension = ".mp3";
break;
case "audio/x-m4a":
extension = ".m4a";
break;
}
let fileName = event.getId() + extension;
audioFolder.file(fileName, blob); // TODO calc bytes
let elements = comp.el.getElementsByTagName("audio");
let element = elements && elements[0];
if (element) {
element.setAttribute("data-exported-src", "./audio/" + fileName);
}
} else if (mime.startsWith("video/")) {
var extension = ".mp4";
let fileName = 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);
}
} else {
var extension = util.getFileExtension(event);
let fileName = event.getId() + extension;
filesFolder.file(fileName, blob);
comp.component.data.href = "./files/" + fileName;
}
this.processedEvents += 1;
return true;
} else {
this.processedEvents += 1;
return false;
}
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
} else {
this.processedEvents += 1;
}
}
await Promise.all(downloadPromises);
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");
});
this.events = [];
});
} catch (error) {
console.error("Failed to export:", error);
this.$emit("close");
this.events = [];
}
},
onLayoutChange(event) {
const { action, element } = event;
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>