Merge branch 'file-mode-dragdrop' into 'dev'

File mode: support drag drop and fix cancellation of uploads

See merge request keanuapp/keanuapp-weblite!209
This commit is contained in:
N Pex 2023-07-06 09:20:58 +00:00
commit 44dd4e9562
4 changed files with 125 additions and 67 deletions

View file

@ -36,6 +36,9 @@ $small-button-height: 36px;
width: 100%; width: 100%;
height: 50%; height: 50%;
background-color: #181719; background-color: #181719;
&.drop-target {
background-color: #383739;
}
border-radius: 19px; border-radius: 19px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -90,6 +93,9 @@ $small-button-height: 36px;
width: 100%; width: 100%;
height: 70%; height: 70%;
background-color: #181719; background-color: #181719;
&.drop-target {
background-color: #383739;
}
border-radius: 19px; border-radius: 19px;
overflow: hidden; overflow: hidden;
.v-image { .v-image {
@ -242,16 +248,21 @@ $small-button-height: 36px;
} }
} }
.file-drop-stack-item { .file-drop-stack-item {
background: linear-gradient(0deg, #3a3a3c 0%, #3a3a3c 100%), #fff; background: #3a3a3c;
position: absolute; position: absolute;
overflow: hidden; overflow: hidden;
opacity: 0;
.v-image { .v-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
&.direct {
opacity: 1 !important;
}
&.animated { &.animated {
animation-name: fadeInStackItem; animation-name: fadeInStackItem;
animation-fill-mode: both;
animation-duration: 1.5s; animation-duration: 1.5s;
} }
} }

View file

@ -16,7 +16,8 @@
v-on:close="showRecorder = false" v-on:file="onVoiceRecording" :sendTypingIndicators="useVoiceMode" /> v-on:close="showRecorder = false" v-on:file="onVoiceRecording" :sendTypingIndicators="useVoiceMode" />
<FileDropLayout class="file-drop-root" v-if="useFileModeNonAdmin" :room="room" <FileDropLayout class="file-drop-root" v-if="useFileModeNonAdmin" :room="room"
v-on:add-file="showAttachmentPicker()" v-on:pick-file="showAttachmentPicker()"
v-on:add-file="addAttachment($event)"
v-on:remove-file="currentFileInputs.splice($event, 1)" v-on:remove-file="currentFileInputs.splice($event, 1)"
v-on:reset="resetAttachments" v-on:reset="resetAttachments"
:attachments="currentFileInputs" :attachments="currentFileInputs"
@ -1146,6 +1147,10 @@ export default {
this.currentSendError = null; this.currentSendError = null;
}, },
addAttachment(file) {
this.handleFileReader(null, file);
},
resetAttachments() { resetAttachments() {
this.cancelSendAttachment(); this.cancelSendAttachment();
}, },

View file

@ -6,8 +6,10 @@
<v-icon>$vuetify.icons.ic_lock</v-icon> <v-icon>$vuetify.icons.ic_lock</v-icon>
<div class="file-drop-title">{{ $t("file_mode.secure_file_send") }}</div> <div class="file-drop-title">{{ $t("file_mode.secure_file_send") }}</div>
</div> </div>
<div class="background"> <div :class="{ 'background': true, 'drop-target': dropTarget }" @drop.prevent="filesDropped"
<v-btn @click="$emit('add-file')" class="large">{{ $t("file_mode.choose_files") }}</v-btn> @dragover.prevent="dropTarget = true" @dragleave.prevent="dropTarget = false"
@dragenter.prevent="dropTarget = true">
<v-btn @click="$emit('pick-file')" class="large">{{ $t("file_mode.choose_files") }}</v-btn>
<div class="file-format-info">{{ $t("file_mode.any_file_format_accepted") }}</div> <div class="file-format-info">{{ $t("file_mode.any_file_format_accepted") }}</div>
</div> </div>
</template> </template>
@ -15,7 +17,9 @@
<!-- ATTACHMENT SELECTION MODE --> <!-- ATTACHMENT SELECTION MODE -->
<template v-if="attachments && attachments.length > 0 && status == mainStatuses.SELECTING"> <template v-if="attachments && attachments.length > 0 && status == mainStatuses.SELECTING">
<div class="attachment-wrapper" ref="attachmentWrapper"> <div class="attachment-wrapper" ref="attachmentWrapper">
<div class="file-drop-current-item"> <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="currentItemHasImagePreview" :src="attachments[currentItemIndex].image" /> <v-img v-if="currentItemHasImagePreview" :src="attachments[currentItemIndex].image" />
<div v-else class="filename">{{ attachments[currentItemIndex].name }}</div> <div v-else class="filename">{{ attachments[currentItemIndex].name }}</div>
</div> </div>
@ -28,7 +32,7 @@
</div> </div>
</div> </div>
<div class="file-drop-thumbnail noborder"> <div class="file-drop-thumbnail noborder">
<div class="add clickable" @click.stop="$emit('add-file')"> <div class="add clickable" @click.stop="$emit('pick-file')">
+ +
</div> </div>
</div> </div>
@ -46,11 +50,12 @@
</template> </template>
<!-- ATTACHMENT SENDING/SENT MODE --> <!-- ATTACHMENT SENDING/SENT MODE -->
<template v-if="attachments && attachments.length > 0 && (status == mainStatuses.SENDING || status == mainStatuses.SENT)"> <template
v-if="attachments && attachments.length > 0 && (status == mainStatuses.SENDING || status == mainStatuses.SENT)">
<div class="attachment-wrapper"> <div class="attachment-wrapper">
<div class="file-drop-sent-stack" ref="stackContainer"> <div class="file-drop-sent-stack" ref="stackContainer">
<div v-if="status == mainStatuses.SENDING && countSent == 0" class="no-items"> <div v-if="status == mainStatuses.SENDING && countSent == 0" class="no-items">
<div class="file-drop-stack-item" :style="stackItemTransform(null, -1)"></div> <div class="file-drop-stack-item direct" :style="stackItemTransform(null, -1)"></div>
<div>{{ $t('file_mode.sending_progress') }}</div> <div>{{ $t('file_mode.sending_progress') }}</div>
</div> </div>
<div v-else v-for="(item, index) in sentItems" :key="item.id" class="file-drop-stack-item animated" <div v-else v-for="(item, index) in sentItems" :key="item.id" class="file-drop-stack-item animated"
@ -130,6 +135,7 @@ export default {
FAILED: 3, FAILED: 3,
}), }),
sendInfo: [], sendInfo: [],
dropTarget: false,
}; };
}, },
mounted() { mounted() {
@ -173,6 +179,14 @@ export default {
}, },
}, },
methods: { methods: {
filesDropped(e) {
this.dropTarget = false;
let droppedFiles = e.dataTransfer.files;
if (!droppedFiles) return;
([...droppedFiles]).forEach(f => {
this.$emit('add-file', f);
});
},
scrollToBottom() { scrollToBottom() {
const el = this.$refs.attachmentWrapper; const el = this.$refs.attachmentWrapper;
if (el) { if (el) {
@ -216,38 +230,56 @@ export default {
util.sendTextMessage(this.$matrix.matrixClient, this.room.roomId, text) util.sendTextMessage(this.$matrix.matrixClient, this.room.roomId, text)
.then((eventId) => { .then((eventId) => {
// Use the eventId as a thread root for all the media // Use the eventId as a thread root for all the media
const promises = this.sendInfo.map(item => util.sendImage(this.$matrix.matrixClient, this.room.roomId, item.attachment, ({ loaded, total }) => { let promiseChain = Promise.resolve();
if (loaded == total) { const getItemPromise = (index) => {
item.progress = 100; if (index < this.sendInfo.length) {
} else if (total > 0) { const item = this.sendInfo[index];
item.progress = 100 * loaded / total; if (item.status !== this.statuses.INITIAL) {
return getItemPromise(++index);
}
const itemPromise = util.sendImage(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(() => {
// 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.sentItems.length > 0) {
if (this.sentItems[0].randomRotation >= 0) {
signR = -1;
}
if (this.sentItems[0].randomTranslationX >= 0) {
signX = -1;
}
if (this.sentItems[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.status = this.statuses.SENT;
item.statusDate = Date.now;
}).catch(ignorederr => {
if (item.promise.aborted) {
item.status = this.statuses.CANCELED;
} else {
console.error("ERROR", ignorederr);
item.status = this.statuses.FAILED;
}
});
item.promise = itemPromise;
return itemPromise.then(() => getItemPromise(++index));
} }
}, eventId).then(() => { else return Promise.resolve();
// Look at last item rotation, flipping the sign on this, so looks more like a true stack };
let signR = 1;
let signX = 1; return promiseChain.then(() => getItemPromise(0));
let signY = 1;
if (this.sentItems.length > 0) {
if (this.sentItems[0].randomRotation >= 0) {
signR = -1;
}
if (this.sentItems[0].randomTranslationX >= 0) {
signX = -1;
}
if (this.sentItems[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.status = this.statuses.SENT;
item.statusDate = Date.now;
}).catch(ignorederr => {
console.error("ERROR", ignorederr);
item.status = this.statuses.FAILED;
}));
return Promise.allSettled(promises)
}) })
.then(() => { .then(() => {
this.status = this.mainStatuses.SENT; this.status = this.mainStatuses.SENT;
@ -257,7 +289,9 @@ export default {
}); });
}, },
cancelSendingItem(item) { cancelSendingItem(item) {
// TODO if (item.promise && item.status == this.statuses.INITIAL) {
item.promise.abort();
}
item.status = this.statuses.CANCELED; item.status = this.statuses.CANCELED;
}, },
checkDone() { checkDone() {

View file

@ -30,27 +30,29 @@ var _browserCanRecordAudioF = function () {
} }
var _browserCanRecordAudio = _browserCanRecordAudioF(); var _browserCanRecordAudio = _browserCanRecordAudioF();
class UploadPromise extends Promise { class UploadPromise {
constructor(executor) { aborted = false;
const aborter = { onAbort = undefined;
aborted: false,
abortablePromise: undefined, constructor(wrappedPromise) {
matrixClient: undefined, this.wrappedPromise = wrappedPromise;
}
abort() {
this.aborted = true;
if (this.onAbort) {
this.onAbort();
} }
}
const normalExecutor = function (resolve, reject) { then(resolve, reject) {
executor(resolve, reject, aborter); this.wrappedPromise = this.wrappedPromise.then(resolve, reject);
}; return this;
}
super(normalExecutor); catch(handler) {
this.abort = () => { this.wrappedPromise = this.wrappedPromise.catch(handler);
aborter.aborted = true; return this;
if (aborter.abortablePromise && aborter.matrixClient) {
aborter.matrixClient.cancelUpload(aborter.abortablePromise);
aborter.matrixClient = undefined;
aborter.abortablePromise = undefined;
}
};
} }
} }
@ -340,11 +342,11 @@ class Util {
} }
sendImage(matrixClient, roomId, file, onUploadProgress, threadRoot) { sendImage(matrixClient, roomId, file, onUploadProgress, threadRoot) {
return new UploadPromise((resolve, reject, aborter) => { const uploadPromise = new UploadPromise(undefined);
const abortionController = aborter; uploadPromise.wrappedPromise = new Promise((resolve, reject) => {
var reader = new FileReader(); var reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
if (abortionController.aborted) { if (uploadPromise.aborted) {
reject("Aborted"); reject("Aborted");
return; return;
} }
@ -399,9 +401,11 @@ class Util {
if (!matrixClient.isRoomEncrypted(roomId)) { if (!matrixClient.isRoomEncrypted(roomId)) {
// Not encrypted. // Not encrypted.
abortionController.matrixClient = matrixClient; const promise = matrixClient.uploadContent(data, opts);
abortionController.abortablePromise = matrixClient.uploadContent(data, opts); uploadPromise.onAbort = () => {
abortionController.abortablePromise matrixClient.cancelUpload(promise);
};
promise
.then((response) => { .then((response) => {
messageContent.url = response.content_uri; messageContent.url = response.content_uri;
return this.sendMessage(matrixClient, roomId, "m.room.message", messageContent) return this.sendMessage(matrixClient, roomId, "m.room.message", messageContent)
@ -448,9 +452,12 @@ class Util {
// Encrypted data sent as octet-stream! // Encrypted data sent as octet-stream!
opts.type = "application/octet-stream"; opts.type = "application/octet-stream";
abortionController.abortablePromise = matrixClient.uploadContent(data, opts); const promise = matrixClient.uploadContent(data, opts);
abortionController.abortablePromise uploadPromise.onAbort = () => {
.then((response) => { matrixClient.cancelUpload(promise);
};
promise
.then((response) => {
if (response.error) { if (response.error) {
return reject(response.error); return reject(response.error);
} }
@ -469,6 +476,7 @@ class Util {
} }
reader.readAsArrayBuffer(file); reader.readAsArrayBuffer(file);
}); });
return uploadPromise;
} }
/** /**