Export chat (from room info page)

This commit is contained in:
N Pex 2022-05-23 15:19:55 +00:00 committed by n8fr8
parent d694d0139a
commit 0f28002470
16 changed files with 18905 additions and 657 deletions

18444
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,12 +16,14 @@
"core-js": "^3.6.5",
"data-uri-to-buffer": "^3.0.1",
"dayjs": "^1.10.3",
"file-saver": "^2.0.5",
"fix-webm-duration": "^1.0.0",
"image-resize": "^1.1.5",
"image-size": "^1.0.0",
"intersection-observer": "^0.12",
"js-sha256": "^0.9.0",
"json-web-key": "^0.4.0",
"jszip": "^3.9.1",
"linkifyjs": "3.0.0-beta.3",
"material-design-icons-iconfont": "^6.1",
"matrix-js-sdk": "^15.2.0",

View file

@ -1137,4 +1137,9 @@ $admin-fg: white;
.loading-indicator {
position: absolute;
background-color: rgba(0, 0, 0, 0.2);
}
.exporting-indicator {
position: absolute;
background-color: white;
}

View file

@ -210,7 +210,8 @@
"show_all": "Show all >",
"leave_room": "Leave",
"version_info": "Powered by Guardian Project. Version: {version}",
"scan_code": "Scan to join the room"
"scan_code": "Scan to join the room",
"export_room": "Export chat"
},
"room_info_sheet": {
"this_room": "This room",
@ -257,5 +258,11 @@
"close_poll": "Close poll",
"poll_submit": "Submit",
"num_answered": "{count} have answered"
},
"export": {
"exported_date": "Exported on {date}",
"fetched_n_events": "Fetched {count} events",
"fetched_n_of_total_events": "Fetched {count} of {total} events",
"processed_n_of_total_events": "Processed media for {count} of {total} events"
}
}

View file

@ -384,36 +384,6 @@
<script>
import Vue from "vue";
import { TimelineWindow, EventTimeline } from "matrix-js-sdk";
import MessageIncomingText from "./messages/MessageIncomingText";
import MessageIncomingFile from "./messages/MessageIncomingFile";
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 MessageIncomingPoll from "./messages/MessageIncomingPoll.vue";
import MessageOutgoingText from "./messages/MessageOutgoingText";
import MessageOutgoingFile from "./messages/MessageOutgoingFile";
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 util from "../plugins/utils";
import MessageOperations from "./messages/MessageOperations.vue";
import AvatarOperations from "./messages/AvatarOperations.vue";
@ -422,11 +392,11 @@ import VoiceRecorder from "./VoiceRecorder";
import RoomInfoBottomSheet from "./RoomInfoBottomSheet";
import CreatedRoomWelcomeHeader from "./CreatedRoomWelcomeHeader";
import MessageOperationsBottomSheet from "./MessageOperationsBottomSheet";
import stickers from "../plugins/stickers";
import StickerPickerBottomSheet from "./StickerPickerBottomSheet";
import BottomSheet from "./BottomSheet.vue";
import ImageResize from "image-resize";
import CreatePollDialog from "./CreatePollDialog.vue";
import chatMixin from "./chatMixin";
const sizeOf = require("image-size");
const dataUriToBuffer = require("data-uri-to-buffer");
@ -462,37 +432,9 @@ ScrollPosition.prototype.prepareFor = function(direction) {
export default {
name: "Chat",
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,
@ -934,120 +876,6 @@ export default {
}
},
componentForEvent(event) {
switch (event.getType()) {
case "m.room.member":
if (event.getContent().membership == "join") {
if (event.getPrevContent() && event.getPrevContent().membership == "join") {
// We we already joined, so this must be a display name and/or avatar update!
return ContactChanged;
} else {
return ContactJoin;
}
} else if (event.getContent().membership == "leave") {
return ContactLeave;
} else if (event.getContent().membership == "invite") {
return ContactInvited;
}
break;
case "m.room.message":
if (event.getSender() != this.$matrix.currentUserId) {
if (event.getContent().msgtype == "m.image") {
// For SVG, make downloadable
if (
event.getContent().info &&
event.getContent().info.mimetype &&
event.getContent().info.mimetype.startsWith("image/svg")
) {
return MessageIncomingFile;
}
return MessageIncomingImage;
} else if (event.getContent().msgtype == "m.audio") {
return MessageIncomingAudio;
} else if (event.getContent().msgtype == "m.video") {
return MessageIncomingVideo;
} else if (event.getContent().msgtype == "m.file") {
return MessageIncomingFile;
} else if (stickers.isStickerShortcode(event.getContent().body)) {
return MessageIncomingSticker;
}
return MessageIncomingText;
} else {
if (event.getContent().msgtype == "m.image") {
// For SVG, make downloadable
if (
event.getContent().info &&
event.getContent().info.mimetype &&
event.getContent().info.mimetype.startsWith("image/svg")
) {
return MessageOutgoingImage;
}
return MessageOutgoingImage;
} else if (event.getContent().msgtype == "m.audio") {
return MessageOutgoingAudio;
} else if (event.getContent().msgtype == "m.video") {
return MessageOutgoingVideo;
} else if (event.getContent().msgtype == "m.file") {
return MessageOutgoingFile;
} else if (stickers.isStickerShortcode(event.getContent().body)) {
return MessageOutgoingSticker;
}
return MessageOutgoingText;
}
case "m.room.create":
return RoomCreated;
case "m.room.canonical_alias":
return RoomAliased;
case "m.room.name":
return RoomNameChanged;
case "m.room.topic":
return RoomTopicChanged;
case "m.room.avatar":
return RoomAvatarChanged;
case "m.room.history_visibility":
return RoomHistoryVisibility;
case "m.room.join_rules":
return RoomJoinRules;
case "m.room.power_levels":
return RoomPowerLevelsChanged;
case "m.room.guest_access":
return RoomGuestAccessChanged;
case "m.room.encryption":
return RoomEncrypted;
case "m.poll.start":
case "org.matrix.msc3381.poll.start":
if (event.getSender() != this.$matrix.currentUserId) {
return MessageIncomingPoll;
} else {
return MessageOutgoingPoll;
}
case "im.keanu.room_deletion_notice": {
// Custom event for notice 30 seconds before a room is deleted/purged.
const deletionNotices = this.room.currentState.getStateEvents("im.keanu.room_deletion_notice");
if (deletionNotices && deletionNotices.length > 0 && deletionNotices[deletionNotices.length - 1] == event) {
// This is the latest/last one. Look at the status flag. Show nothing if it is "cancel".
if (event.getContent().status != "cancel") {
return RoomDeletionNotice;
}
}
}
}
return this.debugging ? DebugEvent : null;
},
paginateBackIfNeeded() {
this.$nextTick(() => {
const container = this.$refs.chatContainer;
@ -1589,24 +1417,6 @@ export default {
this.restartRRTimer();
},
showDayMarkerBeforeEvent(event) {
const idx = this.events.indexOf(event);
if (idx <= 0) {
return true;
}
const previousEvent = this.events[idx - 1];
return util.dayDiff(previousEvent.getTs(), event.getTs()) > 0;
},
dayForEvent(event) {
let dayDiff = util.dayDiffToday(event.getTs());
if (dayDiff < 7) {
return this.$tc("message.time_ago", dayDiff);
} else {
return util.formatDay(event.getTs());
}
},
showRecordingUI() {
this.showRecorderPTT = false;
this.showRecorder = true;

View 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>

View file

@ -167,6 +167,17 @@
</v-card-text>
</v-card>
<!-- EXPORT CHAT -->
<div style="text-align: center">
<v-btn
v-if="userCanExportChat"
color="black"
depressed
class="filled-button"
@click.stop="exportRoom"
>{{ $t("room_info.export_room") }}</v-btn>
</div>
<!-- PURGE ROOM -->
<div class="members ma-3 pa-3 text-center">
<v-btn
@ -179,7 +190,7 @@
>{{ $t("room_info.purge") }}</v-btn
>
</div>
<div class="build-version">
{{ $t("room_info.version_info", { version: buildVersion }) }}
</div>
@ -198,6 +209,7 @@
<QRCodePopup :show="showFullScreenQR" :message="publicRoomLink" @close="showFullScreenQR = false" />
<RoomExport :room="room" v-if="exporting" v-on:close="exporting = false" />
</div>
</template>
@ -205,6 +217,7 @@
import LeaveRoomDialog from "../components/LeaveRoomDialog";
import PurgeRoomDialog from "../components/PurgeRoomDialog";
import DeviceList from "../components/DeviceList";
import RoomExport from "../components/RoomExport";
import QRCode from "qrcode";
import roomInfoMixin from "./roomInfoMixin";
import QRCodePopup from './QRCodePopup.vue';
@ -217,6 +230,7 @@ export default {
LeaveRoomDialog,
PurgeRoomDialog,
DeviceList,
RoomExport,
QRCodePopup,
},
data() {
@ -244,7 +258,8 @@ export default {
icon: "person_add",
},
],
SHOW_MEMBER_LIMIT: 5
SHOW_MEMBER_LIMIT: 5,
exporting: false,
};
},
mounted() {
@ -465,6 +480,11 @@ export default {
console.error(err);
});
},
exportRoom() {
if (this.room) {
this.exporting = true;
}
}
},
};
</script>

247
src/components/chatMixin.js Normal file
View file

@ -0,0 +1,247 @@
import util from "../plugins/utils";
import MessageIncomingText from "./messages/MessageIncomingText";
import MessageIncomingFile from "./messages/MessageIncomingFile";
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 MessageIncomingPoll from "./messages/MessageIncomingPoll.vue";
import MessageOutgoingText from "./messages/MessageOutgoingText";
import MessageOutgoingFile from "./messages/MessageOutgoingFile";
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 MessageIncomingImageExport from "./messages/export/MessageIncomingImageExport";
import MessageIncomingAudioExport from "./messages/export/MessageIncomingAudioExport";
import MessageIncomingVideoExport from "./messages/export/MessageIncomingVideoExport";
import MessageOutgoingImageExport from "./messages/export/MessageOutgoingImageExport";
import MessageOutgoingAudioExport from "./messages/export/MessageOutgoingAudioExport";
import MessageOutgoingVideoExport from "./messages/export/MessageOutgoingVideoExport";
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 MessageOperations from "./messages/MessageOperations.vue";
import AvatarOperations from "./messages/AvatarOperations.vue";
import ChatHeader from "./ChatHeader";
import VoiceRecorder from "./VoiceRecorder";
import RoomInfoBottomSheet from "./RoomInfoBottomSheet";
import CreatedRoomWelcomeHeader from "./CreatedRoomWelcomeHeader";
import MessageOperationsBottomSheet from "./MessageOperationsBottomSheet";
import stickers from "../plugins/stickers";
import StickerPickerBottomSheet from "./StickerPickerBottomSheet";
import BottomSheet from "./BottomSheet.vue";
import CreatePollDialog from "./CreatePollDialog.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";
export default {
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,
},
methods: {
showDayMarkerBeforeEvent(event) {
const idx = this.events.indexOf(event);
if (idx <= 0) {
return true;
}
const previousEvent = this.events[idx - 1];
return util.dayDiff(previousEvent.getTs(), event.getTs()) > 0;
},
dayForEvent(event) {
let dayDiff = util.dayDiffToday(event.getTs());
if (dayDiff < 7) {
return this.$tc("message.time_ago", dayDiff);
} else {
return util.formatDay(event.getTs());
}
},
dateForEvent(event) {
return util.formatDay(event.getTs());
},
componentForEvent(event, isForExport = false) {
switch (event.getType()) {
case "m.room.member":
if (event.getContent().membership == "join") {
if (event.getPrevContent() && event.getPrevContent().membership == "join") {
// We we already joined, so this must be a display name and/or avatar update!
return ContactChanged;
} else {
return ContactJoin;
}
} else if (event.getContent().membership == "leave") {
return ContactLeave;
} else if (event.getContent().membership == "invite") {
return ContactInvited;
}
break;
case "m.room.message":
if (event.getSender() != this.$matrix.currentUserId) {
if (event.getContent().msgtype == "m.image") {
// For SVG, make downloadable
if (
event.getContent().info &&
event.getContent().info.mimetype &&
event.getContent().info.mimetype.startsWith("image/svg")
) {
return MessageIncomingFile;
}
if (isForExport) {
return MessageIncomingImageExport;
}
return MessageIncomingImage;
} else if (event.getContent().msgtype == "m.audio") {
if (isForExport) {
return MessageIncomingAudioExport;
}
return MessageIncomingAudio;
} else if (event.getContent().msgtype == "m.video") {
if (isForExport) {
return MessageIncomingVideoExport;
}
return MessageIncomingVideo;
} else if (event.getContent().msgtype == "m.file") {
return MessageIncomingFile;
} else if (stickers.isStickerShortcode(event.getContent().body)) {
return MessageIncomingSticker;
}
return MessageIncomingText;
} else {
if (event.getContent().msgtype == "m.image") {
// For SVG, make downloadable
if (
event.getContent().info &&
event.getContent().info.mimetype &&
event.getContent().info.mimetype.startsWith("image/svg")
) {
return MessageOutgoingImage;
}
if (isForExport) {
return MessageOutgoingImageExport;
}
return MessageOutgoingImage;
} else if (event.getContent().msgtype == "m.audio") {
if (isForExport) {
return MessageOutgoingAudioExport;
}
return MessageOutgoingAudio;
} else if (event.getContent().msgtype == "m.video") {
if (isForExport) {
return MessageOutgoingVideoExport;
}
return MessageOutgoingVideo;
} else if (event.getContent().msgtype == "m.file") {
return MessageOutgoingFile;
} else if (stickers.isStickerShortcode(event.getContent().body)) {
return MessageOutgoingSticker;
}
return MessageOutgoingText;
}
case "m.room.create":
return RoomCreated;
case "m.room.canonical_alias":
return RoomAliased;
case "m.room.name":
return RoomNameChanged;
case "m.room.topic":
return RoomTopicChanged;
case "m.room.avatar":
return RoomAvatarChanged;
case "m.room.history_visibility":
return RoomHistoryVisibility;
case "m.room.join_rules":
return RoomJoinRules;
case "m.room.power_levels":
return RoomPowerLevelsChanged;
case "m.room.guest_access":
return RoomGuestAccessChanged;
case "m.room.encryption":
return RoomEncrypted;
case "m.poll.start":
case "org.matrix.msc3381.poll.start":
if (event.getSender() != this.$matrix.currentUserId) {
return MessageIncomingPoll;
} else {
return MessageOutgoingPoll;
}
case "im.keanu.room_deletion_notice": {
// Custom event for notice 30 seconds before a room is deleted/purged.
const deletionNotices = this.room.currentState.getStateEvents("im.keanu.room_deletion_notice");
if (deletionNotices && deletionNotices.length > 0 && deletionNotices[deletionNotices.length - 1] == event) {
// This is the latest/last one. Look at the status flag. Show nothing if it is "cancel".
if (event.getContent().status != "cancel") {
return RoomDeletionNotice;
}
}
}
}
return this.debugging ? DebugEvent : null;
},
},
};

View file

@ -0,0 +1,21 @@
<template>
<message-incoming v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<audio controls :src="src">{{ $t("fallbacks.audio_file") }}</audio>
</message-incoming>
</template>
<script>
import attachmentMixin from "../attachmentMixin";
import MessageIncoming from "../MessageIncoming.vue";
export default {
name: "MessageIncomingAudioExport",
extends: MessageIncoming,
mixins: [attachmentMixin],
components: { MessageIncoming },
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>

View file

@ -0,0 +1,47 @@
<template>
<message-incoming v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<div class="bubble image-bubble">
<v-img :aspect-ratio="16 / 9" ref="image" :src="src" :cover="cover" :contain="contain" />
</div>
</message-incoming>
</template>
<script>
import MessageIncoming from "../MessageIncoming.vue";
export default {
name: "MessageIncomingImageExport",
extends: MessageIncoming,
components: { MessageIncoming },
data() {
return {
src: null,
cover: true,
contain: false,
};
},
mounted() {
const info = this.event.getContent().info;
// JPEGs use cover, PNG and GIF ect contain. This is because PNG and GIF are expected to
// be stickers and small emoji type things.
if (info && info.mimetype && info.mimetype.startsWith("image/jp")) {
this.cover = true;
this.contain = false;
} else {
this.cover = false;
this.contain = true;
}
},
beforeDestroy() {
if (this.src) {
const objectUrl = this.src;
this.src = null;
URL.revokeObjectURL(objectUrl);
}
},
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>

View file

@ -0,0 +1,27 @@
<template>
<message-incoming v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<div class="bubble image-bubble">
<v-responsive :aspect-ratio="16 / 9" :src="src">
<video :src="src" controls class="w-100 h-100">
{{ $t("fallbacks.video_file") }}
</video>
</v-responsive>
</div>
</message-incoming>
</template>
<script>
import attachmentMixin from "../attachmentMixin";
import MessageIncoming from "../MessageIncoming.vue";
export default {
name: "MessageIncomingVideoExport",
extends: MessageIncoming,
components: { MessageIncoming },
mixins: [attachmentMixin],
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>

View file

@ -0,0 +1,21 @@
<template>
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<audio controls :src="src">{{ $t("fallbacks.audio_file") }}</audio>
</message-outgoing>
</template>
<script>
import attachmentMixin from "../attachmentMixin";
import MessageOutgoing from "../MessageOutgoing.vue";
export default {
name: "MessageOutgoingAudioExport",
extends: MessageOutgoing,
components: { MessageOutgoing },
mixins: [attachmentMixin],
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>

View file

@ -0,0 +1,47 @@
<template>
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<div class="bubble image-bubble">
<v-img :aspect-ratio="16 / 9" ref="image" :src="src" :cover="cover" :contain="contain" />
</div>
</message-outgoing>
</template>
<script>
import MessageOutgoing from "../MessageOutgoing.vue";
export default {
name: "MessageOutgoingImageExport",
extends: MessageOutgoing,
components: { MessageOutgoing },
data() {
return {
src: null,
cover: true,
contain: false,
};
},
mounted() {
const info = this.event.getContent().info;
// JPEGs use cover, PNG and GIF ect contain. This is because PNG and GIF are expected to
// be stickers and small emoji type things.
if (info && info.mimetype && info.mimetype.startsWith("image/jp")) {
this.cover = true;
this.contain = false;
} else {
this.cover = false;
this.contain = true;
}
},
beforeDestroy() {
if (this.src) {
const objectUrl = this.src;
this.src = null;
URL.revokeObjectURL(objectUrl);
}
},
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>

View file

@ -0,0 +1,27 @@
<template>
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<div class="bubble image-bubble">
<v-responsive :aspect-ratio="16 / 9" class="ma-0 pa-0">
<video :src="src" controls class="w-100 h-100">
{{ $t("fallbacks.video_file") }}
</video>
</v-responsive>
</div>
</message-outgoing>
</template>
<script>
import attachmentMixin from "../attachmentMixin";
import MessageOutgoing from "../MessageOutgoing.vue";
export default {
name: "MessageOutgoingVideoExport",
extends: MessageOutgoing,
components: { MessageOutgoing },
mixins: [attachmentMixin],
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>

View file

@ -62,7 +62,16 @@ export default {
return this.room.shouldEncryptForInvitedMembers()
}
return false;
}
},
userCanExportChat() {
// We say that if you can redact events, you are allowed to export chats.
const me = this.room && this.room.getMember(this.$matrix.currentUserId);
let isAdmin =
me && this.room.currentState && this.room.currentState.hasSufficientPowerLevelFor("redact", me.powerLevel);
return isAdmin;
},
},
watch: {
room: {

View file

@ -22,7 +22,7 @@ var _browserCanRecordAudioF = function () {
var _browserCanRecordAudio = _browserCanRecordAudioF();
class Util {
getAttachment(matrixClient, event, progressCallback) {
getAttachment(matrixClient, event, progressCallback, asBlob = false) {
return new Promise((resolve, reject) => {
const content = event.getContent();
if (content.url != null) {
@ -53,7 +53,11 @@ class Util {
return this.decryptIfNeeded(file, response);
})
.then(bytes => {
resolve(URL.createObjectURL(new Blob([bytes.buffer], { type: file.mimetype })));
if (asBlob) {
resolve(new Blob([bytes.buffer], { type: file.mimetype }));
} else {
resolve(URL.createObjectURL(new Blob([bytes.buffer], { type: file.mimetype })));
}
})
.catch(err => {
console.log("Download error: ", err);