Export chat (from room info page)
This commit is contained in:
parent
dd48bedfb5
commit
95555a23e4
16 changed files with 18905 additions and 657 deletions
428
src/components/RoomExport.vue
Normal file
428
src/components/RoomExport.vue
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
<template>
|
||||
<div class="chat-root">
|
||||
<div class="chat-root 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 cols="auto" class="chat-header-members text-start ma-0 pa-0" @click.stop="onHeaderClicked">
|
||||
<v-avatar size="40" class="me-2">
|
||||
<v-img v-if="room.avatar || memberAvatar" :src="room.avatar || memberAvatar" />
|
||||
</v-avatar>
|
||||
</v-col>
|
||||
|
||||
<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" :title="dateForEvent(event)" />
|
||||
|
||||
<div v-if="!event.isRelation() && !event.isRedacted() && !event.isRedaction()" :ref="event.getId()">
|
||||
<div class="message-wrapper">
|
||||
<component
|
||||
:is="componentForEvent(event, true)"
|
||||
:room="room"
|
||||
:event="event"
|
||||
:nextEvent="events[index + 1]"
|
||||
:reactions="timelineSet.getRelationsForEvent(event.getId(), 'm.annotation', 'm.reaction')"
|
||||
:timelineSet="timelineSet"
|
||||
ref="exportedEvent"
|
||||
/>
|
||||
<!-- <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 AvatarOperations from "./messages/AvatarOperations.vue";
|
||||
import ChatHeader from "./ChatHeader.vue";
|
||||
import VoiceRecorder from "./VoiceRecorder.vue";
|
||||
import RoomInfoBottomSheet from "./RoomInfoBottomSheet.vue";
|
||||
import CreatedRoomWelcomeHeader from "./CreatedRoomWelcomeHeader.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 JSZip from "jszip";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
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,
|
||||
CreatedRoomWelcomeHeader,
|
||||
MessageOperationsBottomSheet,
|
||||
StickerPickerBottomSheet,
|
||||
BottomSheet,
|
||||
AvatarOperations,
|
||||
CreatePollDialog,
|
||||
},
|
||||
props: {
|
||||
room: {
|
||||
type: Object,
|
||||
default: function() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
events: [],
|
||||
fetchedEvents: 0,
|
||||
totalEvents: 0,
|
||||
processedEvents: 0,
|
||||
statusText: "",
|
||||
cancelled: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.doExport();
|
||||
});
|
||||
},
|
||||
watch: {
|
||||
processedEvents() {
|
||||
this.statusText = this.$t("export.processed_n_of_total_events", {
|
||||
count: this.processedEvents,
|
||||
total: this.totalEvents,
|
||||
});
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
exportDate() {
|
||||
return this.$t("export.exported_date", {date: util.formatDay(Date.now().valueOf())});
|
||||
},
|
||||
timelineSet() {
|
||||
return this.room.getUnfilteredTimelineSet();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
cancelExport() {
|
||||
this.cancelled = true;
|
||||
},
|
||||
async getEvents() {
|
||||
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("export.fetched_n_of_total_events", {
|
||||
count: this.totalEvents - nToFetch,
|
||||
total: this.totalEvents,
|
||||
});
|
||||
} else {
|
||||
this.totalEvents += result.chunk.length;
|
||||
this.statusText = this.$t("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) => {
|
||||
this.events = events.reverse();
|
||||
return new Promise((resolve, ignoredReject) => {
|
||||
this.$nextTick(() => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
// UI updated, start processing events
|
||||
zip = new JSZip();
|
||||
var imageFolder = zip.folder("images");
|
||||
var audioFolder = zip.folder("audio");
|
||||
var videoFolder = zip.folder("video");
|
||||
|
||||
var downloadPromises = [];
|
||||
let components = this.$refs.exportedEvent;
|
||||
for (const comp of components) {
|
||||
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, 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, 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
|
||||
let elements = comp.$el.getElementsByTagName("audio");
|
||||
let element = elements && elements[0];
|
||||
if (element) {
|
||||
element.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, 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
|
||||
let elements = comp.$el.getElementsByTagName("video");
|
||||
let element = elements && elements[0];
|
||||
if (element) {
|
||||
element.src = "./video/" + 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 = "<html><head>\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(() => {
|
||||
doc += this.$refs.exportRoot.outerHTML;
|
||||
doc += "</div></body></html>";
|
||||
|
||||
zip.file("chat.html", doc);
|
||||
zip.generateAsync({ type: "blob" }).then((content) => {
|
||||
saveAs(content, "example.zip");
|
||||
this.status = "";
|
||||
this.$emit("close");
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to export:", err);
|
||||
this.$emit("close");
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue