Finish unification of attachment sending

This commit is contained in:
N-Pex 2025-06-11 18:04:56 +02:00
parent fd82fd8840
commit a92d767fc2
11 changed files with 246 additions and 1100 deletions

View file

@ -23,4 +23,6 @@ $poll-hilite-color: #6360f0;
$poll-hilite-color-bg: #d6d5fc;
$alert-bg-color: #FF3300;
$min-touch-target: 48px;
$min-touch-target: 48px;
$large-button-height: $min-touch-target;
$small-button-height: 36px;

View file

@ -3,7 +3,6 @@
@use "@/assets/css/variables" as *;
@use "@/assets/css/main.scss" as *;
@use "@/assets/css/vendors/v-emoji-picker" as *;
@use "@/assets/css/filedrop.scss" as *;
@use "@/assets/css/channel.scss" as *;
@use "sass:map";

View file

@ -1,367 +0,0 @@
@use "@/assets/css/variables" as *;
$large-button-height: $min-touch-target;
$small-button-height: 36px;
.file-drop-root {
$hiliteColor: #4642f1;
font-family: "Inter", sans-serif;
position: absolute;
top: 0px;
left: 0;
right: 0;
bottom: 0;
margin: 0;
background-color: var(--v-background-color);
color: var(--v-foreground-color);
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 16px;
.file-drop-title {
color: #fff;
text-align: center;
font-size: 11.54 * $chat-text-size;
font-family: "Inter", sans-serif;
font-weight: 700;
line-height: 140%;
letter-spacing: 0.34px;
text-transform: uppercase;
margin-top: 13px;
margin-bottom: 25px;
}
.background {
width: 100%;
height: 50%;
background-color: #181719;
&.drop-target {
background-color: #383739;
}
border-radius: 19px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.file-format-info {
opacity: 0.6;
color: #fff;
text-align: center;
font-size: 11 * $chat-text-size;
font-family: "Inter", sans-serif;
line-height: 117%;
letter-spacing: 0.4px;
margin-top: 13px;
background: transparent;
}
.v-btn {
font-family: "Inter", sans-serif;
font-weight: 700;
font-size: 11.54 * $chat-text-size;
line-height: 140%;
color: white;
background-color: $hiliteColor !important;
border-radius: $small-button-height * 0.5;
min-height: 0;
height: $small-button-height !important;
margin-top: $chat-standard-padding-xs;
margin-bottom: $chat-standard-padding-xs;
&.large {
padding: 16px 23px;
height: $large-button-height;
border-radius: $large-button-height * 0.5;
}
}
textarea {
color: rgba(white, 80%) !important;
}
textarea::placeholder {
color: rgba(white, 80%) !important;
}
.attachment-wrapper {
width: 100%;
flex: 0 0 100%;
overflow-y: auto;
}
.file-drop-current-item {
width: 100%;
height: 70%;
background-color: #181719;
&.drop-target {
background-color: #383739;
}
border-radius: 19px;
overflow: hidden;
.v-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.filename {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
.file-drop-thumbnail-container {
width: 100%;
padding: 13px 20px 15px 20px;
height: 74px;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
text-align: start;
/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
.file-drop-thumbnail {
width: 46px;
height: 46px;
border-radius: 9px;
overflow: hidden;
background-color: #242424;
border: 2px solid white;
display: inline-block;
position: relative;
&.current {
border: 2px solid #4642f1;
}
&.noborder {
border: 2px solid transparent;
}
.v-img {
width: 100%;
height: 100%;
object-fit: cover;
}
margin-right: 8px;
.add,
.remove {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
.v-icon {
width: 14px;
height: 15.75px;
}
}
.remove {
// Slight background to make visible
background-color: rgba(black, 0.2);
}
}
}
.file-drop-section {
margin-top: 20px;
padding: 16px 18px;
background-color: #181719;
border-radius: 19px;
}
.file-drop-input-container,
.file-drop-sending-input-container,
.file-drop-sent-input-container {
position: relative;
width: 100%;
min-height: 20%;
background-color: #181719;
border-radius: 19px;
display: flex;
flex-direction: column;
.input-area-text {
flex: 0 0 auto;
width: 100%;
margin-bottom: 50px;
padding: 16px 18px;
font-family: "Inter", sans-serif;
font-weight: 300;
}
.v-btn {
position: absolute;
right: 8px;
bottom: 10px;
}
}
@keyframes fadeInStackItem {
from {opacity: 0;}
to {opacity: 1;}
}
// Sending
//
.file-drop-sent-stack {
width: 100%;
height: 30%;
display: flex;
align-items: center;
justify-content: center;
.no-items {
display: flex;
align-items: center;
justify-content: center;
div {
position: absolute;
}
.file-drop-stack-item {
transform: rotate(-4.4deg);
}
color: #fff;
text-align: center;
font-size: 21 * $chat-text-size;
font-family: "Poppins", sans-serif;
font-weight: 700;
letter-spacing: 0.34px;
}
.items-sent {
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
div, .v-icon {
position: absolute;
}
.v-icon, .v-icon__component {
width: 30%;
height: 30%;
}
}
.file-drop-stack-item {
background: #3a3a3c;
position: absolute;
overflow: hidden;
opacity: 0;
.v-img {
width: 100%;
height: 100%;
object-fit: cover;
}
&.direct {
opacity: 1 !important;
}
&.animated {
animation-name: fadeInStackItem;
animation-fill-mode: both;
animation-duration: 1.5s;
}
}
}
.file-drop-sending-container {
width: 100%;
padding: 13px 0px 15px 0px;
height: 50%;
overflow-x: hidden;
overflow-y: auto;
white-space: nowrap;
text-align: start;
/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
.file-drop-sending-item {
width: 100%;
height: 64px;
overflow: hidden;
background-color: #242424;
border-radius: 12px;
background: linear-gradient(0deg, #26242b 0%, #26242b 100%), #fff;
position: relative;
padding: 8px;
.v-img {
width: $min-touch-target;
height: $min-touch-target;
border-radius: 8px;
object-fit: cover;
flex: 0 0 $min-touch-target;
margin-right: 8px;
}
margin-bottom: 8px;
display: flex;
align-items: center;
.filename {
position: absolute;
top: 18px;
left: 8px;
font-size: 0.7em;
}
.v-progress-linear {
align-self: flex-end;
}
.file-drop-cancel {
position: absolute;
right: 8px;
width: 17px;
height: 17px;
color: green !important;
background: #2e2e3b;
border-radius: 8.5px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.file-drop-sending-input-container {
.v-btn {
.v-progress-circular {
margin-left: 8px;
}
background: linear-gradient(0deg, #000 0%, #000 100%), #4642f1;
}
}
.file-drop-files-sent {
width: 100%;
color: #fff;
text-align: center;
font-size: 21 * $chat-text-size;
font-family: "Poppins", sans-serif;
font-weight: 700;
letter-spacing: 0.34px;
text-align: center;
}
.file-drop-sent-input-container {
background-color: transparent;
.v-btn {
right: unset;
left: 8px;
background: linear-gradient(0deg, #000 0%, #000 100%), #4642f1;
&.close {
right: 8px;
left: unset;
background: $hiliteColor !important;
}
}
}
}

View file

@ -1,8 +1,5 @@
@use "@/assets/css/variables" as *;
$large-button-height: $min-touch-target;
$small-button-height: 36px;
$background: #000000;
$backgroundSection: #181719;
$backgroundHilite: #383739;
@ -23,52 +20,9 @@ $hiliteColor: #4642f1;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 16px;
.send-attachments__title {
color: $text;
text-align: center;
font-size: 11.54 * $chat-text-size;
font-family: "Inter", sans-serif;
font-weight: 700;
line-height: 140%;
letter-spacing: 0.34px;
text-transform: uppercase;
margin-top: 13px;
margin-bottom: 25px;
}
.background {
width: 100%;
height: 50%;
background-color: $background;
&.drop-target {
background-color: $backgroundHilite;
}
border-radius: 19px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.file-format-info {
opacity: 0.6;
color: $text;
text-align: center;
font-size: 11 * $chat-text-size;
font-family: "Inter", sans-serif;
line-height: 117%;
letter-spacing: 0.4px;
margin-top: 13px;
background: transparent;
}
.v-btn {
font-family: "Inter", sans-serif;
font-weight: 700;
@ -89,6 +43,20 @@ $hiliteColor: #4642f1;
}
}
.back-button {
position: absolute;
top: 16px;
left: 16px;
margin: 0;
z-index: 1;
}
.send-button {
position: absolute;
top: 4px;
right: 4px;
}
textarea {
color: rgba($text, 80%) !important;
}
@ -97,15 +65,7 @@ $hiliteColor: #4642f1;
color: rgba($text, 80%) !important;
}
.attachment-wrapper {
width: 100%;
flex: 0 0 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.file-drop-current-item {
.send-attachments__selecting__current-item {
width: 100%;
flex: 1 1 100%;
background-color: $backgroundSection;
@ -134,27 +94,6 @@ $hiliteColor: #4642f1;
}
}
.send-attachments__current-item__info {
flex: 0 0 80px;
text-align: start;
margin: 18px 20px;
padding: 0;
position: relative;
.send-attachments__current-item__info__size {
white-space: pre;
overflow: hidden;
margin-right: 36px;
text-overflow: ellipsis;
}
.send-attachments__current-item__info__size__filename {
opacity: 0.7;
font-size: 0.8em;
}
}
.file-drop-thumbnail-container {
width: 100%;
padding: 13px 20px 15px 20px;
@ -175,6 +114,23 @@ $hiliteColor: #4642f1;
scrollbar-width: none;
/* Firefox */
.v-badge {
margin-right: 8px;
}
.v-badge__badge {
top: 0;
width: 12px;
height: 12px;
padding: 0;
min-width: 12px;
user-select: none;
span {
width: 100%;
height: 100%;
}
}
.file-drop-thumbnail {
width: 46px;
height: 46px;
@ -264,7 +220,7 @@ $hiliteColor: #4642f1;
flex: 0 0 auto;
width: 100%;
margin-bottom: 50px;
padding: 16px 18px;
padding: 6px 8px;
font-family: "Inter", sans-serif;
font-weight: 300;

View file

@ -26,13 +26,14 @@
<VoiceRecorder class="audio-layout" v-if="useVoiceMode" :micButtonRef="$refs.mic_button" :ptt="showRecorderPTT" :show="showRecorder"
v-on:close="showRecorder = false" v-on:file="onVoiceRecording" :sendTypingIndicators="useVoiceMode" />
<FileDropLayout class="file-drop-root" v-if="useFileModeNonAdmin" :room="room"
<SendAttachmentsLayout
v-if="room && useFileModeNonAdmin"
:room="room"
v-on:pick-file="showAttachmentPicker(false)"
v-on:add-files="(files) => addAttachments(files)"
v-on:remove-file="currentFileInputs.splice($event, 1)"
v-on:reset="v"
:attachments="currentFileInputs"
:batch="uploadBatch"
v-on:close="closeFileMode"
:showBackButton="false"
/>
<div v-if="!useVoiceMode && !useFileModeNonAdmin" :class="{'chat-content': true, 'flex-grow-1': true, 'flex-shrink-1': true, 'invisible': !initialLoadDone}" ref="chatContainer"
@ -318,9 +319,7 @@ 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.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";
@ -369,7 +368,7 @@ ScrollPosition.prototype.prepareFor = function (direction) {
export default {
name: "Chat",
mixins: [chatMixin, roomTypeMixin, sendAttachmentsMixin, roomMembersMixin],
mixins: [chatMixin, roomTypeMixin, roomMembersMixin],
components: {
ChatHeader,
ChatHeaderPrivate,
@ -384,7 +383,6 @@ export default {
BottomSheet,
CreatePollDialog,
AudioLayout,
FileDropLayout,
SendAttachmentsLayout,
UserProfileDialog,
PurgeRoomDialog,
@ -412,7 +410,6 @@ export default {
timelineWindowPaginating: false,
scrollPosition: null,
currentFileInputs: [],
uploadBatch: undefined,
showEmojiPicker: false,
selectedEvent: null,
@ -1385,7 +1382,8 @@ export default {
*/
showAttachmentPicker(reset) {
if (reset) {
this.resetAttachments();
this.uploadBatch?.cancel();
this.uploadBatch = null;
}
this.$refs.attachment.click();
},
@ -1414,43 +1412,6 @@ export default {
this.$refs.stickerPickerSheet.open();
},
sendAttachment(withText) {
this.$refs.attachment.value = null;
if (this.isCurrentFileInputsAnArray) {
const text = withText || "";
const promise = this.sendAttachments(text, this.currentFileInputs);
promise.then(() => {
this.sendingAttachments = [];
this.currentFileInputs = [];
this.sendingStatus = "initial"
})
.catch((err) => {
if (err.name === "AbortError" || err === "Abort") {
this.currentSendError = null;
this.currentSendErrorExceededFile = null;
} else {
this.currentSendError = err.LocaleString();
this.currentSendErrorExceededFile = err.LocaleString();
}
});
}
},
cancelSendAttachment() {
this.$refs.attachment.value = null;
if (this.sendingStatus != "initial") {
this.cancelSendAttachments();
}
this.currentFileInputs = [];
this.currentSendError = null;
this.currentSendErrorExceededFile = null;
this.sendingStatus = "initial";
},
resetAttachments() {
this.cancelSendAttachment();
},
/**
* Called by message components that need to change their layout. This will avoid "jumping" in the UI, because
* we remember scroll position, apply the layout change, then restore the scroll.
@ -1885,19 +1846,22 @@ export default {
},
onVoiceRecording(event) {
this.currentSendShowSendButton = false;
// TODO - refactor
this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, event.file] : [event.file];
const batch = this.$matrix.attachmentManager.createUpload(this.room);
batch.addAttachment(this.$matrix.attachmentManager.createAttachment(event.file));
var text = undefined;
if (this.currentInput && this.currentInput.length > 0) {
text = this.currentInput;
this.currentInput = "";
}
this.sendAttachment(text);
this.showRecorder = false;
// Log event
this.$analytics.event("Audio", "Voice message sent");
batch.send(text)
.then(() => {
this.showRecorder = false;
// Log event
this.$analytics.event("Audio", "Voice message sent");
})
.catch((err) => {
console.error("Failed to send voice message", err);
})
},
closeRoomWelcomeHeader() {
@ -1973,7 +1937,8 @@ export default {
},
closeFileMode() {
this.resetAttachments();
this.uploadBatch?.cancel();
this.uploadBatch = undefined;
this.$matrix.leaveRoomAndNavigate(this.room.roomId)
.catch((err) => {
console.log("Error leaving", err);

View file

@ -1,216 +0,0 @@
<template>
<div v-bind="{ ...$props, ...$attrs }">
<!-- No attachments view -->
<template v-if="!attachments || attachments.length == 0">
<div>
<v-icon>$vuetify.icons.ic_lock</v-icon>
<div class="file-drop-title">{{ $t("file_mode.secure_file_send") }}</div>
</div>
<div :class="{ 'background': true, 'drop-target': dropTarget }" @drop.prevent="filesDropped"
@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>
</template>
<!-- ATTACHMENT SELECTION MODE -->
<template v-if="attachments && attachments.length > 0 && status == mainStatuses.SELECTING">
<div class="attachment-wrapper" ref="attachmentWrapper">
<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" />
<div v-else class="filename">{{ attachments[currentItemIndex].name }}</div>
</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.image" :src="currentImageInput.image" />
<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 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();
}
" />
<v-btn @click="sendAll" :disabled="!attachments || attachments.length == 0">{{ $t("menu.send") }}</v-btn>
</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.id" class="file-drop-stack-item animated"
:style="stackItemTransform(info, index)">
<v-img v-if="info.preview" :src="info.preview" />
</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="(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 :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>
</div>
</div>
<div v-else-if="status == mainStatuses.SENT" class="file-drop-sending-container">
<div class="file-drop-files-sent">{{ $t((this.messageInput && this.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 @click.stop="reset">{{ $t("file_mode.send_more_files") }}</v-btn>
<v-btn class="close" @click.stop="close">{{ $t("file_mode.close") }}</v-btn>
</div>
</div>
</template>
</div>
</template>
<script>
import messageMixin from "../messages/messageMixin";
import sendAttachmentsMixin from "../sendAttachmentsMixin.ts";
import prettyBytes from "pretty-bytes";
export default {
mixins: [messageMixin, sendAttachmentsMixin],
components: {},
props: {
attachments: {
type: Array,
default: function () {
return []
}
},
},
data() {
return {
currentItemIndex: 0,
messageInput: "",
mainStatuses: Object.freeze({
SELECTING: 0,
SENDING: 1,
SENT: 2,
}),
status: 0,
dropTarget: false,
};
},
mounted() {
document.body.classList.add("dark");
this.$audioPlayer.setAutoplay(false);
},
beforeUnmount() {
document.body.classList.remove("dark");
},
computed: {
currentItemHasImagePreview() {
return this.currentItemIndex >= 0 && this.currentItemIndex < this.attachments.length &&
this.attachments[this.currentItemIndex].image
},
},
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) {
this.dropTarget = false;
let droppedFiles = 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) {
return prettyBytes(bytes);
},
reset() {
this.$emit('reset');
this.sendingAttachments = [];
this.status = this.mainStatuses.SELECTING;
this.messageInput = "";
this.currentItemIndex = 0;
},
close() {
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 *;
</style>

View file

@ -1,170 +1,139 @@
<template>
<div v-bind="{ ...$attrs }" class="send-attachments">
<div class="send-attachments__title">{{ $t("message.send_attachements_dialog_title") }}</div>
<v-btn
v-if="showBackButton"
class="back-button clickable"
icon="arrow_back"
size="default"
elevation="0"
color="black"
@click.stop="close"
:disabled="backButtonDisabled"
></v-btn>
<!-- ATTACHMENT SELECTION MODE -->
<template v-if="batch.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>
<template v-if="status == mainStatuses.SELECTING">
<div
:class="{ 'send-attachments__selecting__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 && currentAttachment.src && currentAttachment.status === 'loaded'"
:src="currentAttachment.src"
/>
<div v-else-if="currentAttachment" 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 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 batch.attachments"
:key="id"
>
<v-img v-if="currentImageInput && currentImageInput.src" :src="currentImageInput.src" />
<div
v-if="currentItemIndex == id"
class="remove clickable"
@click.stop="batch.removeAttachment(currentImageInput)"
>
<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="batch.attachments.length == 0">{{ $t("menu.send") }}</v-btn>
</div>
</div>
<div class="file-drop-thumbnail-container">
<v-tooltip location="top" v-for="(attachment, index) in batch.attachments" :key="index">
<template v-slot:activator="{ props }">
<v-badge :model-value="batch.isTooLarge(attachment)" color="error">
<template v-slot:badge
><span v-bind="props">&nbsp;</span></template
>
<div
:class="{ 'file-drop-thumbnail': true, clickable: true, current: index == currentItemIndex }"
@click="currentItemIndex = index"
>
<v-img v-if="attachment && attachment.src" :src="attachment.src" />
<div
v-if="currentItemIndex == index"
class="remove clickable"
@click.stop="batch.removeAttachment(attachment)"
>
<v-icon>$vuetify.icons.ic_trash</v-icon>
</div>
</div>
</v-badge>
</template>
<span>{{ $t("message.upload_file_too_large") }}</span>
</v-tooltip>
<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();
}
"
/>
<v-btn
class="send-button clickable"
icon="arrow_upward"
size="default"
elevation="0"
color="black"
@click.stop="sendAll"
:disabled="sendButtonDisabled"
></v-btn>
</div>
</template>
<!-- ATTACHMENT SENDING/SENT MODE -->
<template v-if="batch.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 && batch.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 batch.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 class="file-drop-sent-stack" ref="stackContainer">
<div v-if="status == mainStatuses.SENDING && batch.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 batch.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 batch.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="batch.cancelSendAttachment(attachment)">
<v-icon size="14" color="white">close</v-icon>
</div>
<!-- Middle section -->
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-container">
<div class="file-drop-sending-item" v-for="attachment in batch.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="batch.cancelSendAttachment(attachment)">
<v-icon size="14" color="white">close</v-icon>
</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",
batch.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>
<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",
batch.attachmentsSent.length
)
}}
</div>
<!-- Bottom section -->
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-input-container">
<div class="file-drop-section">
<v-textarea
disabled
full-width
@ -175,39 +144,63 @@
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>
<!-- 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>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent, reactive } from "vue";
import messageMixin from "../messages/messageMixin";
import prettyBytes from "pretty-bytes";
import { Attachment } from "../../models/attachment";
import C2PABadge from "../c2pa/C2PABadge.vue";
import { createUploadBatch } from "../../models/attachmentManager";
export default defineComponent({
mixins: [messageMixin],
components: { C2PABadge },
emits: ["add-files", "pick-file", "close"],
emits: ["pick-file", "close"],
props: {
showBackButton: {
type: Boolean,
default: function () {
return true;
},
},
batch: {
type: Object,
default: function () {
return {}
return reactive(createUploadBatch(null, null, 0))
},
},
},
@ -228,6 +221,12 @@ export default defineComponent({
this.$audioPlayer.setAutoplay(false);
},
computed: {
backButtonDisabled() {
return this.status == this.mainStatuses.SENDING;
},
sendButtonDisabled() {
return !this.batch.canSend;
},
currentAttachment(): Attachment | undefined {
if (this.currentItemIndex >= 0 && this.currentItemIndex < this.batch.attachments.length) {
return this.batch.attachments[this.currentItemIndex];
@ -254,29 +253,15 @@ export default defineComponent({
},
deep: true,
},
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;
});
});
});
for (let i = 0; i < droppedFiles.length; i++) {
const file = droppedFiles.item(i);
this.batch.addAttachment(this.$matrix.attachmentManager.createAttachment(file));
}
},
formatBytes(bytes: number) {
@ -291,11 +276,11 @@ export default defineComponent({
},
sendAll() {
this.status = this.mainStatuses.SENDING;
this.batch.send(
this.messageInput && this.messageInput.length > 0 ? this.messageInput : this.$t("file_mode.files")
).then(() => {
this.status = this.mainStatuses.SENT;
});
this.batch
.send(this.messageInput && this.messageInput.length > 0 ? this.messageInput : this.$t("file_mode.files"))
.then(() => {
this.status = this.mainStatuses.SENT;
});
},
stackItemTransform(attachment: Attachment, index: number) {
const size =

View file

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

View file

@ -1,195 +0,0 @@
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));
},
},
});

View file

@ -62,6 +62,8 @@ export type AttachmentBatch = {
attachmentsSent: ComputedRef<Attachment[]>;
addAttachment: (attachment: Attachment) => void;
removeAttachment: (attachment: Attachment) => void;
isTooLarge: (attachment: Attachment) => boolean;
canSend: ComputedRef<boolean>;
send: (message: string) => Promise<any>;
cancel: () => void;
cancelSendAttachment: (attachment: Attachment) => void;

View file

@ -35,7 +35,7 @@ export class AttachmentManager {
}
public createUpload(room: Room) {
return createUploadBatch(this.matrixClient, room);
return createUploadBatch(this.matrixClient, room, this.maxSizeUploads);
}
public createAttachment(file: File): Attachment {
@ -298,7 +298,11 @@ export class AttachmentManager {
}
}
const createUploadBatch = (matrixClient: MatrixClient, room: Room): AttachmentBatch => {
export const createUploadBatch = (
matrixClient: MatrixClient | null,
room: Room | null,
maxSizeUploads: number
): AttachmentBatch => {
const sendingStatus: Ref<"initial" | "sending" | "sent" | "canceled" | "failed"> = ref("initial");
const sendingRootEventId: Ref<string | undefined> = ref(undefined);
const sendingPromise: Ref<Promise<any> | undefined> = ref(undefined);
@ -333,8 +337,17 @@ const createUploadBatch = (matrixClient: MatrixClient, room: Room): AttachmentBa
}
};
const isTooLarge = (attachment: Attachment) => {
const file = attachment.scaledFile && attachment.useScaled ? attachment.scaledFile : attachment.file;
return file.size > maxSizeUploads;
};
const canSend = computed(() => {
return attachments.value.length > 0 && !attachments.value.some((a: Attachment) => isTooLarge(a));
});
const cancel = () => {
if (sendingStatus.value !== "initial") {
if (sendingStatus.value !== "initial" && matrixClient && room) {
attachments.value.toReversed().forEach((attachment) => {
cancelSendAttachment(attachment);
});
@ -379,6 +392,7 @@ const createUploadBatch = (matrixClient: MatrixClient, room: Room): AttachmentBa
};
const send = (message: string): Promise<any> => {
if (!matrixClient || !room) return Promise.reject("Not configured");
sendingStatus.value = "sending";
attachments.value.forEach((attachment) => {
let sendInfo: AttachmentSendInfo = {
@ -494,6 +508,8 @@ const createUploadBatch = (matrixClient: MatrixClient, room: Room): AttachmentBa
attachmentsSent,
addAttachment,
removeAttachment,
isTooLarge,
canSend,
send,
cancel,
cancelSendAttachment,