Work on attachments

This commit is contained in:
N-Pex 2025-06-09 09:44:37 +02:00
parent ec79a33eab
commit 842c87dc96
28 changed files with 2714 additions and 798 deletions

View file

@ -27,10 +27,10 @@
v-on:close="showRecorder = false" v-on:file="onVoiceRecording" :sendTypingIndicators="useVoiceMode" />
<FileDropLayout class="file-drop-root" v-if="useFileModeNonAdmin" :room="room"
v-on:pick-file="showAttachmentPicker()"
v-on:add-file="addAttachment($event)"
v-on:pick-file="showAttachmentPicker(false)"
v-on:add-files="(files) => addAttachments(files)"
v-on:remove-file="currentFileInputs.splice($event, 1)"
v-on:reset="resetAttachments"
v-on:reset="v"
:attachments="currentFileInputs"
v-on:close="closeFileMode"
/>
@ -213,7 +213,7 @@
<v-col v-if="!$config.disableMediaSharing" class="input-area-button text-center flex-grow-0 flex-shrink-1">
<label icon flat ref="attachmentLabel">
<v-btn icon @click="showAttachmentPicker"
<v-btn icon @click="() => showAttachmentPicker(true)"
:disabled="attachButtonDisabled">
<v-icon size="36">add_circle_outline</v-icon>
</v-btn>
@ -229,7 +229,7 @@
<input ref="attachment" type="file" name="attachment" @change="handlePickedAttachment($event)"
accept="image/*,audio/*,video/*,.mp3,.mp4,.wav,.m4a,.pdf,application/pdf,.apk,application/vnd.android.package-archive,.ipa,.zip,application/zip,application/x-zip-compressed,multipart/x-zip" class="d-none" multiple/>
<div v-if="currentFileInputsDialog && !useFileModeNonAdmin">
<!-- <div v-if="currentFileInputsDialog && !useFileModeNonAdmin">
<v-dialog v-model="currentFileInputsDialog" class="ma-0 pa-0" :width="$vuetify.display.smAndUp ? '50%' : '85%'" persistent scrollable>
<v-card class="ma-0 pa-0">
<v-card-text v-if="!currentFileInputs.length">
@ -253,6 +253,7 @@
<v-img v-if="currentImageInput && currentImageInput.image" :aspect-ratio="1" :src="currentImageInput.image"
contain class="current-image-input-path" />
<v-progress-linear :style="{ position: 'absolute', left: '0', right: '0', bottom: '0', opacity: currentImageInput.sendInfo ? '1' : '0' }" :value="currentImageInput.sendInfo ? currentImageInput.sendInfo.progress : 0"></v-progress-linear>
<C2PABadge :file="currentImageInput.actualFile" />
</div>
<div>
<span v-if="currentImageInput && currentImageInput.scaled && currentImageInput.useScaled">
@ -302,7 +303,17 @@
</template>
</v-card>
</v-dialog>
</div>
</div> -->
<SendAttachmentsLayout
v-if="currentFileInputs && currentFileInputs.length > 0 && !useFileModeNonAdmin"
:room="room"
v-on:pick-file="showAttachmentPicker(false)"
v-on:add-files="(files) => addAttachments(files)"
v-on:remove-file="(index) => removeAttachment(index)"
:attachments="currentFileInputs"
v-on:close="resetAttachments"
/>
<MessageOperationsBottomSheet ref="messageOperationsSheet">
<EmojiPicker ref="emojiPicker"
@ -385,9 +396,10 @@ import BottomSheet from "./BottomSheet.vue";
import imageResize from "image-resize";
import CreatePollDialog from "./CreatePollDialog.vue";
import chatMixin, { ROOM_READ_MARKER_EVENT_PLACEHOLDER } from "./chatMixin";
import sendAttachmentsMixin from "./sendAttachmentsMixin";
import sendAttachmentsMixin from "./sendAttachmentsMixin.ts";
import AudioLayout from "./AudioLayout.vue";
import FileDropLayout from "./file_mode/FileDropLayout";
import SendAttachmentsLayout from "./file_mode/SendAttachmentsLayout.vue";
import roomTypeMixin from "./roomTypeMixin";
import roomMembersMixin from "./roomMembersMixin";
import PurgeRoomDialog from "../components/PurgeRoomDialog";
@ -401,6 +413,8 @@ import 'vue3-emoji-picker/css';
import emitter from 'tiny-emitter/instance';
import { markRaw } from "vue";
import timerIcon from '@/assets/icons/ic_timer.svg';
import proofmode from "../plugins/proofmode.js";
import C2PABadge from "./c2pa/C2PABadge.vue";
const READ_RECEIPT_TIMEOUT = 5000; /* How long a message must have been visible before the read marker is updated */
const WINDOW_BUFFER_SIZE = 0.3; /** Relative window height of when we start paginating. Always keep this much loaded before and after our scroll position! */
@ -448,13 +462,15 @@ export default {
CreatePollDialog,
AudioLayout,
FileDropLayout,
SendAttachmentsLayout,
UserProfileDialog,
PurgeRoomDialog,
WelcomeHeaderChannelUser,
MessageErrorHandler,
MessageOperationsChannel,
RoomExport,
EmojiPicker
EmojiPicker,
C2PABadge
},
data() {
@ -473,7 +489,7 @@ export default {
timelineWindowPaginating: false,
scrollPosition: null,
currentFileInputs: null,
currentFileInputs: [],
currentSendShowSendButton: true,
currentSendError: null,
currentSendErrorExceededFile: null,
@ -619,7 +635,7 @@ export default {
return this.isCurrentFileInputsAnArray
},
set() {
this.currentFileInputs = null
this.currentFileInputs = [];
}
},
chatContainer() {
@ -1470,97 +1486,65 @@ export default {
/**
* Show attachment picker to select file
*/
showAttachmentPicker() {
showAttachmentPicker(reset) {
if (reset) {
this.resetAttachments();
}
this.$refs.attachment.click();
},
optimizeImage(evt,file) {
let fileObj = {}
fileObj.image = evt.target.result;
fileObj.dimensions = null;
fileObj.type = file.type;
fileObj.actualSize = file.size;
fileObj.actualFile = file
try {
const buffer = Uint8Array.from(window.atob(evt.target.result.replace(/^data[^,]+,/,'')), v => v.charCodeAt(0));
fileObj.dimensions = imageSize(buffer);
// Need to resize?
const w = fileObj.dimensions.width;
const h = fileObj.dimensions.height;
if (w > 640 || h > 640) {
var aspect = w / h;
var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed());
var newHeight = parseInt((w > h ? 640 / aspect : 640).toFixed());
imageResize(evt.target.result, {
format: "png",
width: newWidth,
height: newHeight,
outputType: "blob",
})
.then((img) => {
fileObj["scaled"] =
new File([img], file.name, {
type: img.type,
lastModified: Date.now(),
});
fileObj["useScaled"] = true;
fileObj["scaledSize"] = img.size;
fileObj["scaledDimensions"] = {
width: newWidth,
height: newHeight,
};
})
.catch((err) => {
console.error("Resize failed:", err);
});
}
} catch (error) {
console.error("Failed to get image dimensions: " + error);
}
return fileObj
},
handleFileReader(file) {
async addAttachment(file) {
if (file) {
let optimizedFileObj;
var reader = new FileReader();
reader.onload = (evt) => {
if (file.type.startsWith("image/")) {
optimizedFileObj = this.optimizeImage(evt, file)
} else {
optimizedFileObj = file
}
this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, optimizedFileObj] : [optimizedFileObj];
};
reader.readAsDataURL(file);
this.currentFileInputs = [... this.currentFileInputs, this.$matrix.attachmentManager.createAttachment(file)];
// let optimizedFileObj;
// if (file.type.startsWith("image/")) {
// const f = await proofmode.proofCheckFile(file);
// var reader = new FileReader();
// optimizedFileObj = await new Promise(resolve => {
// reader.onload = evt => {
// resolve(this.optimizeImage(evt, file));
// }
// reader.readAsDataURL(f);
// })
// } else {
// optimizedFileObj = file;
// }
// console.error("OPTIMIZED", optimizedFileObj);
// this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, optimizedFileObj] : [optimizedFileObj];
}
},
removeAttachment(index) {
this.currentFileInputs = this.currentFileInputs.toSpliced(index, 1);
},
/**
* Handle picked attachment
*/
handlePickedAttachment(event) {
this.currentFileInputs = []
const uploadedFiles = Object.values(event.target.files);
this.addAttachments(Object.values(event.target.files));
},
this.$matrix.matrixClient.getMediaConfig().then((config) => {
const configUploadSize = config["m.upload.size"];
const configFormattedUploadSize = this.formatBytes(configUploadSize);
uploadedFiles.every(file => {
if (configUploadSize && file.size > configUploadSize) {
this.currentSendError = this.$t("message.upload_file_too_large");
this.currentSendErrorExceededFile = this.$t("message.upload_exceeded_file_limit", { configFormattedUploadSize });
this.currentSendShowSendButton = false;
return false;
} else {
this.currentSendShowSendButton = true;
}
return true;
});
uploadedFiles.forEach(file => this.handleFileReader(file));
addAttachments(files) {
// TODO - refactor
this.$matrix.matrixClient.getMediaConfig(this.$matrix.useAuthedMedia).then((config) => {
const configUploadSize = config["m.upload.size"];
const configFormattedUploadSize = this.formatBytes(configUploadSize);
files.every(file => {
if (configUploadSize && file.size > configUploadSize) {
this.currentSendError = this.$t("message.upload_file_too_large");
this.currentSendErrorExceededFile = this.$t("message.upload_exceeded_file_limit", { configFormattedUploadSize });
this.currentSendShowSendButton = false;
return false;
} else {
this.currentSendShowSendButton = true;
}
return true;
});
files.forEach(file => this.addAttachment(file));
});
},
showStickerPicker() {
@ -1574,9 +1558,9 @@ export default {
const promise = this.sendAttachments(text, this.currentFileInputs);
promise.then(() => {
this.sendingAttachments = [];
this.currentFileInputs = null;
this.currentFileInputs = [];
this.attachmentCaption = undefined;
this.sendingStatus = this.sendStatuses.INITIAL;
this.sendingStatus = "initial"
})
.catch((err) => {
if (err.name === "AbortError" || err === "Abort") {
@ -1592,18 +1576,14 @@ export default {
cancelSendAttachment() {
this.$refs.attachment.value = null;
if (this.sendingStatus != this.sendStatuses.INITIAL) {
if (this.sendingStatus != "initial") {
this.cancelSendAttachments();
}
this.currentFileInputs = null;
this.currentFileInputs = [];
this.attachmentCaption = undefined;
this.currentSendError = null;
this.currentSendErrorExceededFile = null;
this.sendingStatus = this.sendStatuses.INITIAL;
},
addAttachment(file) {
this.handleFileReader(null, file);
this.sendingStatus = "initial";
},
resetAttachments() {
@ -2044,6 +2024,7 @@ export default {
onVoiceRecording(event) {
this.currentSendShowSendButton = false;
// TODO - refactor
this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, event.file] : [event.file];
var text = undefined;
if (this.currentInput && this.currentInput.length > 0) {

View file

@ -0,0 +1,44 @@
<template>
<v-img class="image-with-progress" v-bind="{...$props, ...$attrs}">
<LoadProgress class="image-with-progress__progress" v-if="loadingProgress >= 0 && loadingProgress < 100" :percentage="loadingProgress" />
</v-img>
</template>
<script>
import User from "../models/user";
import util from "../plugins/utils";
import rememberMeMixin from "./rememberMeMixin";
import * as sdk from "matrix-js-sdk";
import logoMixin from "./logoMixin";
import LoadProgress
from "./LoadProgress.vue";
export default {
name: "ImageWithProgress",
components: { LoadProgress },
props: {
loadingProgress: {
type: Number,
default: function () {
return -1;
},
},
},
data() {
return {};
},
};
</script>
<style lang="scss">
.image-with-progress {
position: relative;
.image-with-progress__progress {
position: absolute;
top: 4px;
right: 4px;
color: white;
width: 32px;
height: 32px;
}
}
</style>

View file

@ -0,0 +1,28 @@
<template>
<v-progress-circular :rotate="360" :width="3" :model-value="percentage" color="white" class="ma-2">
{{ percentage }}
</v-progress-circular>
</template>
<script>
import User from "../models/user";
import util from "../plugins/utils";
import rememberMeMixin from "./rememberMeMixin";
import * as sdk from "matrix-js-sdk";
import logoMixin from "./logoMixin";
export default {
name: "LoadProgress",
props: {
percentage: {
type: Number,
default: function () {
return 0;
},
},
},
data() {
return {};
},
};
</script>

View file

@ -0,0 +1,48 @@
<template>
<div v-if="show" class="c2pa-badge">
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" @click.stop="">$vuetify.icons.ic_cr</v-icon>
</template>
<span>This image contains C2PA data</span>
</v-tooltip>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
export default defineComponent({
name: "C2PABadge",
emits: [],
props: {
proof: {
type: Object as PropType<{
name?: string;
json?: string;
integrity?: { pgp?: any; c2pa?: any; exif?: any; opentimestamps?: any };
}>,
default: function () {
return undefined;
},
},
},
computed: {
show() {
console.log("PROOFCHEKDATA", this.proof);
if (this.proof) {
const {
name,
json,
integrity: { pgp, c2pa, exif, opentimestamps },
} = this.proof;
return c2pa !== undefined;
}
},
},
});
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
</style>

View file

@ -61,7 +61,7 @@ import roomTypeMixin from "./roomTypeMixin";
export const ROOM_READ_MARKER_EVENT_PLACEHOLDER = { getId: () => "ROOM_READ_MARKER", getTs: () => Date.now() };
export default {
mixins: [ roomDisplayOptionsMixin, roomTypeMixin ],
mixins: [roomDisplayOptionsMixin, roomTypeMixin],
components: {
ChatHeader,
MessageIncomingText,
@ -105,7 +105,7 @@ export default {
StickerPickerBottomSheet,
BottomSheet,
CreatePollDialog,
ReadMarker
ReadMarker,
},
computed: {
debugging() {
@ -132,7 +132,7 @@ export default {
},
dateForEvent(event) {
return util.formatDay(event.getTs());
return util.formatDay(event.getTs());
},
componentForEvent(event, isForExport = false) {
@ -180,18 +180,18 @@ export default {
case "m.room.message":
if (event.getSender() != this.$matrix.currentUserId) {
if (event.isRedacted()) {
// Redacted thread, show as text (and hide all media)!
if (event.getUnsigned().redacted_because.content.reason == "redactedThread") {
return MessageIncomingText;
}
return null;
if (event.isRedacted()) {
// Redacted thread, show as text (and hide all media)!
if (event.getUnsigned().redacted_because.content.reason == "redactedThread") {
return MessageIncomingText;
}
if (event.isMxThread) {
// Incoming thread, e.g. a file drop!
return isForExport ? MessageIncomingThreadExport : MessageIncomingThread;
}
if (event.getContent().msgtype == "m.image") {
return null;
}
if (event.isMxThread) {
// Incoming thread, e.g. a file drop!
return isForExport ? MessageIncomingThreadExport : MessageIncomingThread;
}
if (event.getContent().msgtype == "m.image") {
// For SVG, make downloadable
if (
event.getContent().info &&
@ -340,19 +340,27 @@ export default {
return MessageOutgoingPoll;
}
case STATE_EVENT_ROOM_DELETION_NOTICE: {
// Custom event for notice 30 seconds before a room is deleted/purged.
const deletionNotices = this.room.currentState.getStateEvents(STATE_EVENT_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;
case STATE_EVENT_ROOM_DELETION_NOTICE:
{
// Custom event for notice 30 seconds before a room is deleted/purged.
const deletionNotices = this.room.currentState.getStateEvents(STATE_EVENT_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;
}
}
}
}
break;
case "m.room.encrypted":
return event.getSender() != this.$matrix.currentUserId ? MessageIncomingText : MessageOutgoingText
if (event.isRedacted()) {
// Redacted thread, show as text (and hide all media)!
if (event.getUnsigned().redacted_because.content.reason == "redactedThread") {
return MessageOutgoingText;
}
return null;
}
return event.getSender() != this.$matrix.currentUserId ? MessageIncomingText : MessageOutgoingText;
}
return this.debugging ? DebugEvent : null;
},

View file

@ -38,7 +38,7 @@
</div>
</div>
<div class="file-drop-input-container">
<v-textarea ref="input" full-width variant="solo" flat auto-grow v-model="messageInput" no-resize class="input-area-text"
<v-textarea theme="dark" ref="input" full-width variant="solo" flat auto-grow v-model="messageInput" no-resize class="input-area-text"
rows="1" :placeholder="$t('file_mode.add_a_message')" hide-details background-color="transparent"
v-on:keydown.enter.prevent="() => {
sendCurrentTextMessage();
@ -72,7 +72,7 @@
<div class="file-drop-sending-item" v-for="(info, index) in attachmentsSending" :key="index">
<v-img v-if="info.preview" :src="info.preview" />
<div v-else class="filename">{{ info.attachment.name }}</div>
<v-progress-linear :value="info.progress"></v-progress-linear>
<v-progress-linear :model-value="info.progress"></v-progress-linear>
<div class="file-drop-cancel clickable" @click.stop="cancelSendAttachmentItem(info)">
<v-icon size="14" color="white">close</v-icon>
</div>
@ -105,7 +105,7 @@
<script>
import messageMixin from "../messages/messageMixin";
import sendAttachmentsMixin from "../sendAttachmentsMixin";
import sendAttachmentsMixin from "../sendAttachmentsMixin.ts";
import prettyBytes from "pretty-bytes";
export default {
@ -163,9 +163,7 @@ export default {
this.dropTarget = false;
let droppedFiles = e.dataTransfer.files;
if (!droppedFiles) return;
([...droppedFiles]).forEach(f => {
this.$emit('add-file', f);
});
this.$emit('add-files', [...droppedFiles]);
},
scrollToBottom() {
const el = this.$refs.attachmentWrapper;

View file

@ -0,0 +1,322 @@
<template>
<div v-bind="{ ...$attrs }" class="send-attachments">
<div class="send-attachments__title">{{ $t("message.send_attachements_dialog_title") }}</div>
<!-- ATTACHMENT SELECTION MODE -->
<template v-if="attachments && attachments.length > 0 && status == mainStatuses.SELECTING">
<div class="attachment-wrapper" ref="attachmentWrapper" v-if="currentAttachment">
<div
:class="{ 'file-drop-current-item': true, 'drop-target': dropTarget }"
@drop.prevent="filesDropped"
@dragover.prevent="dropTarget = true"
@dragleave.prevent="dropTarget = false"
@dragenter.prevent="dropTarget = true"
>
<v-img v-if="currentAttachment.src && currentAttachment.status === 'loaded'" :src="currentAttachment.src" />
<div v-else class="filename">
<div>{{ currentAttachment.file.name }}</div>
<div v-if="currentAttachment.status === 'loading'" style="font-size: 0.7em; opacity: 0.7">
{{ $t("message.preparing_to_upload") }}
<v-progress-linear indeterminate class="mb-0"></v-progress-linear>
</div>
</div>
</div>
<div class="send-attachments__current-item__info">
<div class="send-attachments__current-item__info__size">
<span
v-if="currentAttachment.scaledFile && currentAttachment.useScaled && currentAttachment.scaledDimensions"
>
{{ currentAttachment.scaledDimensions.width }} x {{ currentAttachment.scaledDimensions.height }}</span
>
<span v-else-if="currentAttachment.dimensions">
{{ currentAttachment.dimensions.width }} x {{ currentAttachment.dimensions.height }}
</span>
<span v-if="currentAttachment.scaledFile && currentAttachment.useScaled">
({{ formatBytes(currentAttachment.scaledFile.size) }})
</span>
<span v-else> ({{ formatBytes(currentAttachment.file.size) }}) </span>
<span class="send-attachments__current-item__info__size__filename" v-if="currentAttachment.src && currentAttachment.file.name">
- {{ currentAttachment.file.name }}
</span>
</div>
<v-switch
v-if="currentAttachment.scaledFile"
:label="$t('message.scale_image')"
v-model="currentAttachment.useScaled"
:disabled="currentAttachment.sendInfo !== undefined"
/>
<C2PABadge :proof="currentAttachment.proof" />
</div>
<div class="file-drop-thumbnail-container">
<div
:class="{ 'file-drop-thumbnail': true, clickable: true, current: id == currentItemIndex }"
@click="currentItemIndex = id"
v-for="(currentImageInput, id) in attachments"
:key="id"
>
<v-img v-if="currentImageInput && currentImageInput.src" :src="currentImageInput.src" />
<div v-if="currentItemIndex == id" class="remove clickable" @click.stop="$emit('remove-file', id)">
<v-icon>$vuetify.icons.ic_trash</v-icon>
</div>
</div>
<div class="file-drop-thumbnail noborder">
<div class="add clickable" @click.stop="$emit('pick-file')">+</div>
</div>
</div>
<div class="file-drop-input-container">
<v-textarea
ref="input"
full-width
variant="solo"
flat
auto-grow
v-model="messageInput"
no-resize
class="input-area-text"
rows="1"
:placeholder="$t('file_mode.add_a_message')"
hide-details
background-color="transparent"
v-on:keydown.enter.prevent="
() => {
sendAll();
}
"
/>
<div class="input-container__buttons">
<v-btn @click="close">{{ $t("menu.cancel") }}</v-btn>
<v-btn @click="sendAll" :disabled="!attachments || attachments.length == 0">{{ $t("menu.send") }}</v-btn>
</div>
</div>
</div>
</template>
<!-- ATTACHMENT SENDING/SENT MODE -->
<template
v-if="attachments && attachments.length > 0 && (status == mainStatuses.SENDING || status == mainStatuses.SENT)"
>
<div class="attachment-wrapper">
<div class="file-drop-sent-stack" ref="stackContainer">
<div v-if="status == mainStatuses.SENDING && attachmentsSentCount == 0" class="no-items">
<div class="file-drop-stack-item direct" :style="stackItemTransform(null, -1)"></div>
<div>{{ $t("file_mode.sending_progress") }}</div>
</div>
<div
v-else
v-for="(info, index) in attachmentsSent"
:key="info.file.name"
class="file-drop-stack-item animated"
:style="stackItemTransform(info, index)"
>
<v-img v-if="info.src" :src="info.src" />
</div>
<div v-if="status == mainStatuses.SENT" class="items-sent" :style="stackItemTransform(null, -1)">
<v-icon>$vuetify.icons.ic_check_circle</v-icon>
</div>
</div>
<!-- Middle section -->
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-container">
<div class="file-drop-sending-item" v-for="(attachment) in attachmentsSending" :key="attachment.file.name">
<v-img v-if="attachment.src" :src="attachment.src" />
<div v-else class="filename">{{ attachment.file.name }}</div>
<v-progress-linear :model-value="attachment.sendInfo?.progress ?? 0"></v-progress-linear>
<div class="file-drop-cancel clickable" @click.stop="cancelSendAttachmentItem(attachment)">
<v-icon size="14" color="white">close</v-icon>
</div>
</div>
</div>
<div v-else-if="status == mainStatuses.SENT" class="file-drop-sending-container">
<div class="file-drop-files-sent">
{{
$t(
messageInput && messageInput.length > 0
? "file_mode.files_sent_with_note"
: "file_mode.files_sent",
attachmentsSent.length
)
}}
</div>
<div class="file-drop-section">
<v-textarea
disabled
full-width
variant="solo"
flat
auto-grow
v-model="messageInput"
no-resize
class="input-area-text"
rows="1"
hide-details
background-color="transparent"
/>
</div>
</div>
<!-- Bottom section -->
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-input-container">
<v-textarea
disabled
full-width
variant="solo"
flat
auto-grow
v-model="messageInput"
no-resize
class="input-area-text"
rows="1"
:placeholder="$t('file_mode.add_a_message')"
hide-details
background-color="transparent"
/>
<v-btn
>{{ $t("file_mode.sending")
}}<v-progress-circular indeterminate size="18" width="2" color="#4642F1"></v-progress-circular
></v-btn>
</div>
<div v-else-if="status == mainStatuses.SENT" class="file-drop-sent-input-container">
<v-btn class="close" @click.stop="close">{{ $t("file_mode.close") }}</v-btn>
</div>
</div>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import messageMixin from "../messages/messageMixin";
import sendAttachmentsMixin from "../sendAttachmentsMixin";
import prettyBytes from "pretty-bytes";
import type { PropType } from "vue";
import { Attachment } from "../../models/attachment";
import C2PABadge from "../c2pa/C2PABadge.vue";
export default defineComponent({
mixins: [messageMixin, sendAttachmentsMixin],
components: { C2PABadge },
emits: ["add-files", "remove-file", "pick-file", "close"],
props: {
attachments: {
type: Array as PropType<Attachment[]>,
default: function () {
return [] as Attachment[];
},
},
},
data() {
return {
currentItemIndex: 0,
messageInput: "",
mainStatuses: Object.freeze({
SELECTING: 0,
SENDING: 1,
SENT: 2,
}),
status: 0,
dropTarget: false,
};
},
mounted() {
this.$audioPlayer.setAutoplay(false);
},
computed: {
currentAttachment(): Attachment {
return this.attachments[this.currentItemIndex];
},
currentItemHasImagePreview() {
return (
this.currentItemIndex >= 0 &&
this.currentItemIndex < this.attachments.length &&
this.attachments[this.currentItemIndex].src
);
},
},
watch: {
attachments(newValue, oldValue) {
// Added or removed?
if (newValue && oldValue && newValue.length > oldValue.length) {
this.currentItemIndex = oldValue.length;
} else if (newValue) {
this.currentItemIndex = newValue.length - 1;
}
},
messageInput() {
this.scrollToBottom();
},
},
methods: {
filesDropped(e: DragEvent) {
this.dropTarget = false;
let droppedFiles: FileList | undefined = e.dataTransfer?.files;
if (!droppedFiles) return;
this.$emit("add-files", [... droppedFiles]);
},
scrollToBottom() {
const el = this.$refs.attachmentWrapper;
if (el) {
// Ugly - need to wait until input is auto-sized, THEN scroll to bottom.
//
this.$nextTick(() => {
this.$nextTick(() => {
this.$nextTick(() => {
el.scrollTop = el.scrollHeight;
});
});
});
}
},
formatBytes(bytes: number) {
return prettyBytes(bytes);
},
close() {
this.sendingAttachments = [];
this.status = this.mainStatuses.SELECTING;
this.messageInput = "";
this.currentItemIndex = 0;
this.$emit("close");
},
sendAll() {
this.status = this.mainStatuses.SENDING;
this.sendAttachments(
this.messageInput && this.messageInput.length > 0 ? this.messageInput : this.$t("file_mode.files"),
this.attachments
).then(() => {
this.status = this.mainStatuses.SENT;
});
},
stackItemTransform(item, index) {
const size =
0.6 *
(this.$refs.stackContainer
? Math.min(this.$refs.stackContainer.clientWidth, this.$refs.stackContainer.clientHeight)
: 176);
let transform = "";
if (item != null && index != -1) {
transform =
"transform: rotate(" +
item.randomRotation +
"deg) translate(" +
item.randomTranslationX +
"px," +
item.randomTranslationY +
"px); z-index:" +
(index + 2) +
";";
}
return transform + "width:" + size + "px;height:" + size + "px;border-radius:" + size / 8 + "px";
},
},
});
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
@use "@/assets/css/sendattachments.scss" as *;
</style>

View file

@ -1,36 +1,38 @@
<template>
<message-incoming v-bind="{...$props, ...$attrs}">
<message-incoming v-bind="{ ...$props, ...$attrs }">
<div class="bubble image-bubble" ref="imageRef">
<v-img
<ImageWithProgress
:aspect-ratio="16 / 9"
ref="image"
:src="src"
:src="src ? src : thumbnailSrc"
:cover="cover"
:contain="contain"
:loadingProgress="thumbnailProgress"
/>
</div>
<v-dialog
v-model="dialog"
:width="$vuetify.display.smAndUp ? '940px' : '90%'"
>
<v-img :src="src"/>
<v-dialog v-model="dialog" :width="$vuetify.display.smAndUp ? '940px' : '90%'">
<ImageWithProgress :src="src ? src : thumbnailSrc" :loadingProgress="srcProgress" />
</v-dialog>
</message-incoming>
</template>
<script>
import util from "../../plugins/utils";
import MessageIncoming from './MessageIncoming.vue';
import ImageWithProgress from "../ImageWithProgress.vue";
import MessageIncoming from "./MessageIncoming.vue";
export default {
extends: MessageIncoming,
components: { MessageIncoming },
components: { MessageIncoming, ImageWithProgress },
data() {
return {
src: undefined,
thumbnailSrc: undefined,
srcProgress: -1,
thumbnailProgress: -1,
cover: true,
contain: false,
dialog: false
dialog: false,
};
},
methods: {
@ -39,48 +41,57 @@ export default {
const hammerInstance = util.singleOrDoubleTabRecognizer(element);
hammerInstance.on("singletap doubletap", (ev) => {
if(ev.type === 'singletap') {
if (ev.type === "singletap") {
this.$matrix.attachmentManager
.loadEventAttachment(
this.event,
(percent) => {
this.srcProgress = percent;
},
this
)
.catch((err) => {
console.log("Failed to fetch attachment: ", err);
});
this.dialog = true;
}
});
}
},
},
mounted() {
//console.log("Mounted with event:", JSON.stringify(this.event.getContent()));
const width = this.$refs.image.$el.clientWidth;
const height = (width * 9) / 16;
util
.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, this.event, this.$config, width, height)
.then((url) => {
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;
}
this.src = url;
if(this.$refs.imageRef) {
this.initMessageInImageHammerJs(this.$refs.imageRef);
}
})
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;
}
if (this.$refs.imageRef) {
this.initMessageInImageHammerJs(this.$refs.imageRef);
}
this.$matrix.attachmentManager
.loadEventThumbnail(
this.event,
(percent) => {
this.thumbnailProgress = percent;
},
this
)
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
},
beforeUnmount() {
if (this.src) {
const objectUrl = this.src;
this.src = null;
URL.revokeObjectURL(objectUrl);
}
this.$matrix.attachmentManager.releaseEvent(this.event);
},
};
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
</style>
</style>

View file

@ -3,35 +3,48 @@
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
<div class="original-message-text" v-html="linkify($sanitize(inReplyToText))" />
</div>
<div class="message">
<SwipeableThumbnailsView :items="items" v-if="!event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL" v-bind="$attrs" />
<SwipeableThumbnailsView
:items="items"
v-if="!event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL"
v-bind="$attrs"
/>
<v-container v-else-if="!event.isRedacted()" fluid class="imageCollection">
<v-row wrap>
<v-col v-for="({ size, item }) in layoutedItems()" :key="item.event.getId()" :cols="size">
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
<v-col v-for="{ size, item } in layoutedItems()" :key="item.event.getId()" :cols="size">
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
</v-col>
</v-row>
</v-container>
<i v-if="event.isRedacted()" class="deleted-text">
<v-icon :color="this.senderIsAdminOrModerator(this.event) ? 'white' : ''" size="small">block</v-icon>
{{ redactedBySomeoneElse(event) ? $t('message.incoming_message_deleted_text') : $t('message.outgoing_message_deleted_text')}}
{{
redactedBySomeoneElse(event)
? $t("message.incoming_message_deleted_text")
: $t("message.outgoing_message_deleted_text")
}}
</i>
<span v-html="linkify($sanitize(messageText))" v-else-if="messageText" />
<span class="edit-marker" v-if="event.replacingEventId() && !event.isRedacted()">
{{ $t('message.edited') }}
{{ $t("message.edited") }}
</span>
</div>
</div>
<GalleryItemsView :originalEvent="originalEvent" :items="items" :initialItem="showItem" v-if="!!showItem" v-on:close="showItem = null" />
<GalleryItemsView
:originalEvent="originalEvent"
:items="items"
:initialItem="showItem"
v-if="!!showItem"
v-on:close="showItem = null"
/>
</message-incoming>
<component v-else-if="items.length == 1" :is="componentFn(items[0].event)"
v-bind="{...$props, ...$attrs}"
<component
v-else-if="items.length == 1"
:is="componentFn(items[0].event)"
v-bind="{ ...$props, ...$attrs }"
:originalEvent="items[0].event"
/>
</template>
@ -40,24 +53,33 @@
import MessageIncoming from "./MessageIncoming.vue";
import messageMixin from "./messageMixin";
import util, { ROOM_TYPE_CHANNEL, ROOM_TYPE_FILE_MODE } from "../../plugins/utils";
import GalleryItemsView from '../file_mode/GalleryItemsView.vue';
import ThumbnailView from '../file_mode/ThumbnailView.vue';
import GalleryItemsView from "../file_mode/GalleryItemsView.vue";
import ThumbnailView from "../file_mode/ThumbnailView.vue";
import SwipeableThumbnailsView from "./channel/SwipeableThumbnailsView.vue";
import { reactive } from "vue";
export default {
extends: MessageIncoming,
components: { MessageIncoming, GalleryItemsView, ThumbnailView, SwipeableThumbnailsView },
components: {
MessageIncoming,
GalleryItemsView,
ThumbnailView,
SwipeableThumbnailsView,
},
mixins: [messageMixin],
data() {
return {
ROOM_TYPE_CHANNEL: ROOM_TYPE_CHANNEL,
items: [],
showItem: null,
}
};
},
mounted() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), util.threadMessageType(), "m.room.message");
this.thread = this.timelineSet.relations.getChildEventsForEvent(
this.event.getId(),
util.threadMessageType(),
"m.room.message"
);
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
@ -67,42 +89,66 @@ export default {
},
computed: {
forceMultiview() {
return this.room.displayType == ROOM_TYPE_FILE_MODE || (this.room.displayType == ROOM_TYPE_CHANNEL && this.items.length == 1 && util.isFileTypePDF(this.items[0].event));
}
return (
this.room.displayType == ROOM_TYPE_FILE_MODE ||
(this.room.displayType == ROOM_TYPE_CHANNEL &&
this.items.length == 1 &&
util.isFileTypePDF(this.items[0].event))
);
},
},
methods: {
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), util.threadMessageType(), "m.room.message");
this.thread = this.timelineSet.relations.getChildEventsForEvent(
this.event.getId(),
util.threadMessageType(),
"m.room.message"
);
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
onItemClick(event) {
this.showItem = event.item;
},
processThread() {
this.$emit('layout-change', () => {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
.filter(e => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype))
.map(e => {
let ret = reactive({
event: e,
src: null,
});
ret.promise = this.$matrix.matrixClient.decryptEventIfNeeded(e)
.then(() => util.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, e, this.$config, 100, 100))
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
if (!this.event.isRedacted()) {
this.$emit(
"layout-change",
() => {
const items = this.timelineSet.relations
.getAllChildEventsForEvent(this.event.getId())
.filter((e) => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype));
this.items = items.map((e) => {
let ret = reactive({
event: e,
src: null,
});
return ret;
});
}, this.$el);
if (items.length > 1) {
ret.promise = this.$matrix.matrixClient
.decryptEventIfNeeded(e)
.then(() =>
util.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, e, this.$config, 100, 100)
)
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
}
return ret;
});
},
this.$el
);
}
},
layoutedItems() {
if (!this.items || this.items.length == 0) { return [] }
if (!this.items || this.items.length == 0) {
return [];
}
let array = this.items.slice(0);
let rows = []
let rows = [];
while (array.length > 0) {
if (array.length >= 7) {
rows.push({ size: 6, item: array[0] });
@ -127,12 +173,12 @@ export default {
array = array.slice(1);
}
}
return rows
return rows;
},
downloadAll() {
this.items.forEach(item => util.download(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, item.event));
}
}
this.items.forEach((item) => util.download(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, item.event));
},
},
};
</script>
@ -159,4 +205,4 @@ export default {
padding: 2px;
}
}
</style>
</style>

View file

@ -1,36 +1,38 @@
<template>
<message-outgoing v-bind="{ ...$props, ...$attrs }">
<div class="bubble image-bubble" ref="imageRef">
<v-img
<ImageWithProgress
:aspect-ratio="16 / 9"
ref="image"
:src="src"
:src="src ? src : thumbnailSrc"
:cover="cover"
:contain="contain"
:loadingProgress="thumbnailProgress"
/>
</div>
<v-dialog
v-model="dialog"
:width="$vuetify.display.smAndUp ? '940px' : '90%'"
>
<v-img :src="src"/>
<v-dialog v-model="dialog" :width="$vuetify.display.smAndUp ? '940px' : '90%'">
<ImageWithProgress :src="src ? src : thumbnailSrc" :loadingProgress="srcProgress" />
</v-dialog>
</message-outgoing>
</template>
<script>
import util from "../../plugins/utils";
import ImageWithProgress from "../ImageWithProgress.vue";
import MessageOutgoing from "./MessageOutgoing.vue";
export default {
extends: MessageOutgoing,
components: { MessageOutgoing },
components: { MessageOutgoing, ImageWithProgress },
data() {
return {
src: undefined,
thumbnailSrc: undefined,
srcProgress: -1,
thumbnailProgress: -1,
cover: true,
contain: false,
dialog: false
dialog: false,
};
},
methods: {
@ -39,47 +41,56 @@ export default {
const hammerInstance = util.singleOrDoubleTabRecognizer(element);
hammerInstance.on("singletap doubletap", (ev) => {
if(ev.type === 'singletap') {
if (ev.type === "singletap") {
this.$matrix.attachmentManager
.loadEventAttachment(
this.event,
(percent) => {
this.srcProgress = percent;
},
this
)
.catch((err) => {
console.log("Failed to fetch attachment: ", err);
});
this.dialog = true;
}
});
}
},
},
mounted() {
const width = this.$refs.image.$el.clientWidth;
const height = (width * 9) / 16;
util
.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, this.event, this.$config, width, height)
.then((url) => {
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;
}
this.src = url;
if(this.$refs.imageRef) {
this.initMessageOutImageHammerJs(this.$refs.imageRef);
}
})
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;
}
if (this.$refs.imageRef) {
this.initMessageOutImageHammerJs(this.$refs.imageRef);
}
this.$matrix.attachmentManager
.loadEventThumbnail(
this.event,
(percent) => {
this.thumbnailProgress = percent;
},
this
)
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
},
beforeUnmount() {
if (this.src) {
const objectUrl = this.src;
this.src = null;
URL.revokeObjectURL(objectUrl);
}
this.$matrix.attachmentManager.releaseEvent(this.event);
},
};
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
</style>
</style>

View file

@ -3,46 +3,46 @@
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
<div class="original-message-text" v-html="linkify($sanitize(inReplyToText))" />
</div>
<div class="message">
<SwipeableThumbnailsView :items="items" v-if="!event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL" v-bind="$attrs" />
<SwipeableThumbnailsView :items="items" v-if="!event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL"
v-bind="$attrs" />
<v-container v-else-if="!event.isRedacted()" fluid class="imageCollection">
<v-row wrap>
<v-col v-for="({ size, item }) in layoutedItems()" :key="item.event.getId()" :cols="size">
<v-col v-for="{ size, item } in layoutedItems()" :key="item.event.getId()" :cols="size">
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
</v-col>
</v-row>
</v-container>
<i v-if="event.isRedacted()" class="deleted-text">
<v-icon size="small">block</v-icon>
{{ redactedBySomeoneElse(event) ? $t('message.incoming_message_deleted_text') : $t('message.outgoing_message_deleted_text')}}
{{
redactedBySomeoneElse(event)
? $t("message.incoming_message_deleted_text")
: $t("message.outgoing_message_deleted_text")
}}
</i>
<span v-html="linkify($sanitize(messageText))" v-else-if="messageText" />
<span class="edit-marker" v-if="event.replacingEventId() && !event.isRedacted()">
{{ $t('message.edited') }}
{{ $t("message.edited") }}
</span>
</div>
</div>
<GalleryItemsView :originalEvent="originalEvent" :items="items" :initialItem="showItem" v-if="!!showItem" v-on:close="showItem = null" />
<GalleryItemsView :originalEvent="originalEvent" :items="items" :initialItem="showItem" v-if="!!showItem"
v-on:close="showItem = null" />
</message-outgoing>
<component v-else-if="items.length == 1" :is="componentFn(items[0].event)"
v-bind="{...$props, ...$attrs}"
:originalEvent="items[0].event"
/>
<component v-else-if="items.length == 1" :is="componentFn(items[0].event)" v-bind="{ ...$props, ...$attrs }"
:originalEvent="items[0].event" />
</template>
<script>
import MessageOutgoing from "./MessageOutgoing.vue";
import messageMixin from "./messageMixin";
import util, { ROOM_TYPE_CHANNEL } from "../../plugins/utils";
import GalleryItemsView from '../file_mode/GalleryItemsView.vue';
import ThumbnailView from '../file_mode/ThumbnailView.vue';
import GalleryItemsView from "../file_mode/GalleryItemsView.vue";
import ThumbnailView from "../file_mode/ThumbnailView.vue";
import SwipeableThumbnailsView from "./channel/SwipeableThumbnailsView.vue";
import { reactive } from "vue";
@ -55,10 +55,14 @@ export default {
ROOM_TYPE_CHANNEL: ROOM_TYPE_CHANNEL,
items: [],
showItem: null,
}
};
},
mounted() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), util.threadMessageType(), "m.room.message");
this.thread = this.timelineSet.relations.getChildEventsForEvent(
this.event.getId(),
util.threadMessageType(),
"m.room.message"
);
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
@ -68,42 +72,65 @@ export default {
},
computed: {
forceMultiview() {
return this.room.displayType == ROOM_TYPE_CHANNEL && this.items.length == 1 && util.isFileTypePDF(this.items[0].event);
}
return (
this.room.displayType == ROOM_TYPE_CHANNEL && this.items.length == 1 && util.isFileTypePDF(this.items[0].event)
);
},
},
methods: {
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), util.threadMessageType(), "m.room.message");
this.thread = this.timelineSet.relations.getChildEventsForEvent(
this.event.getId(),
util.threadMessageType(),
"m.room.message"
);
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
onItemClick(event) {
this.showItem = event.item;
},
processThread() {
this.$emit('layout-change', () => {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
.filter(e => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype))
.map(e => {
let ret = reactive({
event: e,
src: null,
});
ret.promise = this.$matrix.matrixClient.decryptEventIfNeeded(e)
.then(() => util.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, e, this.$config, 100, 100))
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
if (!this.event.isRedacted()) {
this.$emit(
"layout-change",
() => {
const items = this.timelineSet.relations
.getAllChildEventsForEvent(this.event.getId())
.filter((e) => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype));
this.items = items.map((e) => {
let ret = reactive({
event: e,
src: null,
});
return ret;
});
}, this.$el);
if (items.length > 1) {
// Only do if items more than one. If one, the individual component in <component> above will do the work.
//
ret.promise = this.$matrix.matrixClient
.decryptEventIfNeeded(e)
.then(() =>
util.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, e, this.$config, 100, 100)
)
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
}
return ret;
});
},
this.$el
);
}
},
layoutedItems() {
if (!this.items || this.items.length == 0) { return [] }
if (!this.items || this.items.length == 0) {
return [];
}
let array = this.items.slice(0);
let rows = []
let rows = [];
while (array.length > 0) {
if (array.length >= 7) {
rows.push({ size: 6, item: array[0] });
@ -128,9 +155,9 @@ export default {
array = array.slice(1);
}
}
return rows
return rows;
},
}
},
};
</script>
<style lang="scss">
@ -165,4 +192,4 @@ export default {
padding: 20px;
}
}
</style>
</style>

View file

@ -75,8 +75,8 @@ export default {
}
if (newValue) {
newValue.on("Relations.add", this.onAddRelation);
this.processThread();
}
this.processThread();
},
immediate: true,
},

View file

@ -0,0 +1 @@
declare module 'sendAttachmentsMixin';

View file

@ -1,165 +0,0 @@
import util from "../plugins/utils";
export default {
data() {
return {
sendStatuses: Object.freeze({
INITIAL: 0,
SENDING: 1,
SENT: 2,
CANCELED: 3,
FAILED: 4,
}),
sendingStatus: 0,
sendingPromise: null,
sendingRootEventId: null,
sendingAttachments: [],
}
},
computed: {
attachmentsSentCount() {
return this.sendingAttachments ? this.sendingAttachments.reduce((a, elem, ignoredidx, ignoredarray) => elem.status == this.sendStatuses.SENT ? a + 1 : a, 0) : 0
},
attachmentsSending() {
return this.sendingAttachments ? this.sendingAttachments.filter(elem => elem.status == this.sendStatuses.INITIAL || elem.status == this.sendStatuses.SENDING) : []
},
attachmentsSent() {
this.sortSendingAttachments();
return this.sendingAttachments ? this.sendingAttachments.filter(elem => elem.status == this.sendStatuses.SENT) : []
},
},
methods: {
sendAttachments(text, attachments) {
this.sendingStatus = this.sendStatuses.SENDING;
this.sendingAttachments = attachments.map((attachment) => {
let file = (() => {
// other than file type image
if(attachment instanceof File) {
return attachment;
} else {
if (attachment.scaled && attachment.useScaled) {
// Send scaled version of image instead!
return attachment.scaled;
} else {
// Send actual file image when not scaled!
return attachment.actualFile;
}
}
})();
let sendInfo = {
id: attachment.name,
status: this.sendStatuses.INITIAL,
statusDate: Date.now,
mediaEventId: undefined,
attachment: file,
preview: attachment.image,
progress: 0,
randomRotation: 0,
randomTranslationX: 0,
randomTranslationY: 0
};
attachment.sendInfo = sendInfo;
return sendInfo;
});
this.sendingPromise = util.sendTextMessage(this.$matrix.matrixClient, this.room.roomId, text)
.then((eventId) => {
this.sendingRootEventId = eventId;
// Use the eventId as a thread root for all the media
let promiseChain = Promise.resolve();
const getItemPromise = (index) => {
if (index < this.sendingAttachments.length) {
const item = this.sendingAttachments[index];
if (item.status !== this.sendStatuses.INITIAL) {
return getItemPromise(++index);
}
item.status = this.sendStatuses.SENDING;
const itemPromise = util.sendFile(this.$matrix.matrixClient, this.room.roomId, item.attachment, ({ loaded, total }) => {
if (loaded == total) {
item.progress = 100;
} else if (total > 0) {
item.progress = 100 * loaded / total;
}
}, eventId)
.then((mediaEventId) => {
// Look at last item rotation, flipping the sign on this, so looks more like a true stack
let signR = 1;
let signX = 1;
let signY = 1;
if (this.attachmentsSent.length > 0) {
if (this.attachmentsSent[0].randomRotation >= 0) {
signR = -1;
}
if (this.attachmentsSent[0].randomTranslationX >= 0) {
signX = -1;
}
if (this.attachmentsSent[0].randomTranslationY >= 0) {
signY = -1;
}
}
item.randomRotation = signR * (2 + Math.random() * 10);
item.randomTranslationX = signX * Math.random() * 20;
item.randomTranslationY = signY * Math.random() * 20;
item.mediaEventId = mediaEventId;
item.status = this.sendStatuses.SENT;
item.statusDate = Date.now;
}).catch(ignorederr => {
if (item.promise.aborted) {
item.status = this.sendStatuses.CANCELED;
} else {
console.error("ERROR", ignorederr);
item.status = this.sendStatuses.FAILED;
}
});
item.promise = itemPromise;
return itemPromise.then(() => getItemPromise(++index));
}
else return Promise.resolve();
};
return promiseChain.then(() => getItemPromise(0));
})
.then(() => {
this.sendingStatus = this.sendStatuses.SENT;
this.sendingRootEventId = null;
})
.catch((err) => {
console.error("ERROR", err);
});
return this.sendingPromise;
},
cancelSendAttachments() {
this.sendingAttachments.toReversed().forEach(item => {
this.cancelSendAttachmentItem(item);
});
this.sendingStatus = this.sendStatuses.CANCELED;
if (this.sendingRootEventId && this.room) {
// Redact all media we already sent, plus the root event
let promises = this.sendingAttachments.filter((item) => item.mediaEventId !== undefined).map((item) => this.$matrix.matrixClient.redactEvent(this.room.roomId, item.mediaEventId, undefined, { reason: "cancel" }));
promises.push(this.$matrix.matrixClient.redactEvent(this.room.roomId, this.sendingRootEventId, undefined, { reason: "cancel" }));
Promise.allSettled(promises)
.then(() => {
console.log("Message redacted");
})
.catch((err) => {
console.log("Redaction failed: ", err);
});
}
},
cancelSendAttachmentItem(item) {
if (item.promise && item.status != this.sendStatuses.INITIAL) {
item.promise.abort();
}
item.status = this.sendStatuses.CANCELED;
},
sortSendingAttachments() {
this.sendingAttachments.sort((a, b) => b.statusDate - a.statusDate);
},
}
}

View file

@ -0,0 +1,195 @@
import { defineComponent, reactive } from "vue";
import util from "../plugins/utils";
import { Attachment, AttachmentSendInfo } from "../models/attachment";
export default defineComponent({
data(): {
sendingStatus: "initial" | "sending" | "sent" | "canceled" | "failed";
sendingRootEventId: string | null;
sendingPromise: Promise<any> | null;
sendingAttachments: Attachment[];
} {
return {
// sendStatuses: Object.freeze({
// INITIAL: 0,
// SENDING: 1,
// SENT: 2,
// CANCELED: 3,
// FAILED: 4,
// }),
sendingStatus: "initial",
sendingPromise: null,
sendingRootEventId: null,
sendingAttachments: [] as Attachment[],
};
},
computed: {
attachmentsSentCount(): number {
return this.sendingAttachments
? this.sendingAttachments.reduce((a, elem) => (elem.sendInfo?.status == "sent" ? a + 1 : a), 0)
: 0;
},
attachmentsSending(): Attachment[] {
return this.sendingAttachments
? this.sendingAttachments.filter(
(elem) => elem.sendInfo?.status == "initial" || elem.sendInfo?.status == "sending"
)
: [];
},
attachmentsSent(): Attachment[] {
this.sortSendingAttachments();
return this.sendingAttachments ? this.sendingAttachments.filter((elem) => elem.sendInfo?.status == "sent") : [];
},
},
methods: {
sendAttachments(text: string, attachments: Attachment[]) {
this.sendingStatus = "sending";
this.sendingAttachments = attachments.map((attachment) => {
let sendInfo: AttachmentSendInfo = {
status: "initial",
statusDate: Date.now(),
mediaEventId: undefined,
progress: 0,
randomRotation: 0,
randomTranslationX: 0,
randomTranslationY: 0,
promise: undefined,
};
attachment.sendInfo = reactive(sendInfo);
return attachment;
});
this.sendingPromise = util
.sendTextMessage(this.$matrix.matrixClient, this.room.roomId, text)
.then((eventId: string) => {
this.sendingRootEventId = eventId;
// Use the eventId as a thread root for all the media
let promiseChain = Promise.resolve();
const getItemPromise = (index: number) => {
if (index < this.sendingAttachments.length) {
const attachment = this.sendingAttachments[index];
const item = attachment.sendInfo!;
if (item.status !== "initial") {
return getItemPromise(++index);
}
item.status = "sending";
let file = (() => {
if (attachment.scaledFile && attachment.useScaled) {
// Send scaled version of image instead!
return attachment.scaledFile;
} else {
// Send actual file image when not scaled!
return attachment.file;
}
})();
const itemPromise = util
.sendFile(
this.$matrix.matrixClient,
this.room.roomId,
file,
({ loaded, total }: { loaded: number; total: number }) => {
if (loaded == total) {
item.progress = 100;
} else if (total > 0) {
item.progress = (100 * loaded) / total;
}
},
eventId,
attachment.dimensions
)
.then((mediaEventId: string) => {
// Look at last item rotation, flipping the sign on this, so looks more like a true stack
let signR = 1;
let signX = 1;
let signY = 1;
if (this.attachmentsSent.length > 0) {
if (this.attachmentsSent[0].sendInfo!.randomRotation >= 0) {
signR = -1;
}
if (this.attachmentsSent[0].sendInfo!.randomTranslationX >= 0) {
signX = -1;
}
if (this.attachmentsSent[0].sendInfo!.randomTranslationY >= 0) {
signY = -1;
}
}
item.randomRotation = signR * (2 + Math.random() * 10);
item.randomTranslationX = signX * Math.random() * 20;
item.randomTranslationY = signY * Math.random() * 20;
item.mediaEventId = mediaEventId;
item.status = "sent";
item.statusDate = Date.now();
})
.catch((ignorederr: any) => {
if (item.promise?.aborted) {
item.status = "canceled";
} else {
console.error("ERROR", ignorederr);
item.status = "failed";
}
return Promise.resolve();
});
item.promise = itemPromise;
return itemPromise.then(() => getItemPromise(++index));
} else return Promise.resolve();
};
return promiseChain.then(() => getItemPromise(0));
})
.then(() => {
this.sendingStatus = "sent";
this.sendingRootEventId = null;
})
.catch((err: any) => {
console.error("ERROR", err);
});
return this.sendingPromise;
},
cancelSendAttachments() {
this.sendingAttachments.toReversed().forEach((item) => {
this.cancelSendAttachmentItem(item);
});
this.sendingStatus = "canceled";
if (this.sendingRootEventId && this.room) {
// Redact all media we already sent, plus the root event
let promises = this.sendingAttachments
.filter((item) => item.sendInfo?.mediaEventId !== undefined)
.map((item) =>
this.$matrix.matrixClient.redactEvent(this.room.roomId, item.sendInfo!.mediaEventId, undefined, {
reason: "cancel",
})
);
promises.push(
this.$matrix.matrixClient.redactEvent(this.room.roomId, this.sendingRootEventId, undefined, {
reason: "cancel",
})
);
Promise.allSettled(promises)
.then(() => {
console.log("Message redacted");
})
.catch((err) => {
console.log("Redaction failed: ", err);
});
}
},
cancelSendAttachmentItem(item: Attachment) {
if (item.sendInfo) {
if (item.sendInfo.promise && item.sendInfo.status != "initial") {
item.sendInfo.promise.abort();
}
item.sendInfo.status = "canceled";
}
},
sortSendingAttachments() {
this.sendingAttachments.sort((a, b) => (b.sendInfo?.statusDate ?? 0) - (a.sendInfo?.statusDate ?? 0));
},
},
});