Experimental "file drop" mode
This commit is contained in:
parent
791fa5936a
commit
ebadd509e9
19 changed files with 1038 additions and 85 deletions
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="chat-root fill-height d-flex flex-column">
|
||||
<ChatHeader class="chat-header flex-grow-0 flex-shrink-0" v-on:header-click="onHeaderClick" v-on:view-room-details="viewRoomDetails" />
|
||||
<ChatHeader class="chat-header flex-grow-0 flex-shrink-0" v-on:header-click="onHeaderClick" v-on:view-room-details="viewRoomDetails" v-if="!useFileModeNonAdmin" />
|
||||
<AudioLayout ref="chatContainer" class="auto-audio-player-root" v-if="useVoiceMode" :room="room"
|
||||
:events="events" :autoplay="!showRecorder"
|
||||
:timelineSet="timelineSet"
|
||||
|
|
@ -15,8 +15,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"
|
||||
v-on:add-file="showAttachmentPicker()"
|
||||
v-on:remove-file="currentFileInputs.splice($event, 1)"
|
||||
v-on:reset="resetAttachments"
|
||||
:attachments="currentFileInputs"
|
||||
/>
|
||||
|
||||
<div v-if="!useVoiceMode" class="chat-content flex-grow-1 flex-shrink-1" ref="chatContainer"
|
||||
<div v-if="!useVoiceMode && !useFileModeNonAdmin" class="chat-content flex-grow-1 flex-shrink-1" ref="chatContainer"
|
||||
v-on:scroll="onScroll" @click="closeContextMenusIfOpen">
|
||||
<div ref="messageOperationsStrut" class="message-operations-strut">
|
||||
<message-operations ref="messageOperations" :style="opStyle" :emojis="recentEmojis" v-on:close="
|
||||
|
|
@ -75,7 +81,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Input area -->
|
||||
<v-container v-if="!useVoiceMode && room" fluid :class="['input-area-outer', replyToEvent ? 'reply-to' : '']">
|
||||
<v-container v-if="!useVoiceMode && !useFileModeNonAdmin && room" fluid :class="['input-area-outer', replyToEvent ? 'reply-to' : '']">
|
||||
<div :class="[replyToEvent ? 'iput-area-inner-box' : '']">
|
||||
<!-- "Scroll to end"-button -->
|
||||
<v-btn v-if="!useVoiceMode" class="scroll-to-end" v-show="showScrollToEnd" fab x-small elevation="0" color="black"
|
||||
|
|
@ -191,15 +197,15 @@
|
|||
<input ref="attachment" type="file" name="attachment" @change="handlePickedAttachment($event)"
|
||||
accept="image/*, audio/*, video/*, .pdf" class="d-none" multiple/>
|
||||
|
||||
<div v-if="currentFileInputsDialog">
|
||||
<div v-if="currentFileInputsDialog && !useFileModeNonAdmin">
|
||||
<v-dialog v-model="currentFileInputsDialog" class="ma-0 pa-0" :width="$vuetify.breakpoint.smAndUp ? '50%' : '85%'" persistent scrollable>
|
||||
<v-card class="ma-0 pa-0">
|
||||
<v-card-title>{{ $t('message.send_attachements_dialog_title') }}</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<template v-if="Array.isArray(currentImageInputs) && currentImageInputs.length">
|
||||
<v-card-title v-if="currentImageInputs.length > 1"> {{ $t('message.images') }} </v-card-title>
|
||||
<v-card-text :class="{'ma-0 pa-2' : true, 'd-flex flex-wrap justify-center': currentImageInputs.length > 1}">
|
||||
<div :class="{'col-4': currentImageInputs.length > 1}" v-for="(currentImageInput, id) in currentImageInputs" :key="id">
|
||||
<template v-if="imageFiles && imageFiles.length">
|
||||
<v-card-title v-if="imageFiles.length > 1"> {{ $t('message.images') }} </v-card-title>
|
||||
<v-card-text :class="{'ma-0 pa-2' : true, 'd-flex flex-wrap justify-center': imageFiles.length > 1}">
|
||||
<div :class="{'col-4': imageFiles.length > 1}" v-for="(currentImageInput, id) in imageFiles" :key="id">
|
||||
<v-img v-if="currentImageInput && currentImageInput.image" :aspect-ratio="1" :src="currentImageInput.image"
|
||||
contain class="current-image-input-path" />
|
||||
<div>
|
||||
|
|
@ -281,7 +287,7 @@
|
|||
<script>
|
||||
import Vue from "vue";
|
||||
import { TimelineWindow, EventTimeline } from "matrix-js-sdk";
|
||||
import util from "../plugins/utils";
|
||||
import util, { ROOM_TYPE_VOICE_MODE, ROOM_TYPE_FILE_MODE } from "../plugins/utils";
|
||||
import MessageOperations from "./messages/MessageOperations.vue";
|
||||
import AvatarOperations from "./messages/AvatarOperations.vue";
|
||||
import ChatHeader from "./ChatHeader";
|
||||
|
|
@ -296,6 +302,7 @@ import ImageResize from "image-resize";
|
|||
import CreatePollDialog from "./CreatePollDialog.vue";
|
||||
import chatMixin from "./chatMixin";
|
||||
import AudioLayout from "./AudioLayout.vue";
|
||||
import FileDropLayout from "./file_mode/FileDropLayout";
|
||||
|
||||
const sizeOf = require("image-size");
|
||||
const dataUriToBuffer = require("data-uri-to-buffer");
|
||||
|
|
@ -344,7 +351,8 @@ export default {
|
|||
BottomSheet,
|
||||
AvatarOperations,
|
||||
CreatePollDialog,
|
||||
AudioLayout
|
||||
AudioLayout,
|
||||
FileDropLayout
|
||||
},
|
||||
|
||||
data() {
|
||||
|
|
@ -360,7 +368,6 @@ export default {
|
|||
timelineWindowPaginating: false,
|
||||
|
||||
scrollPosition: null,
|
||||
currentImageInputs: null,
|
||||
currentFileInputs: null,
|
||||
currentSendOperation: null,
|
||||
currentSendProgress: null,
|
||||
|
|
@ -465,6 +472,9 @@ export default {
|
|||
nonImageFiles() {
|
||||
return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => !file.type.includes("image/"))
|
||||
},
|
||||
imageFiles() {
|
||||
return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => file.type.includes("image/"))
|
||||
},
|
||||
isCurrentFileInputsAnArray() {
|
||||
return Array.isArray(this.currentFileInputs)
|
||||
},
|
||||
|
|
@ -584,9 +594,16 @@ export default {
|
|||
useVoiceMode: {
|
||||
get: function () {
|
||||
if (!this.$config.experimental_voice_mode) return false;
|
||||
return util.useVoiceMode(this.room);
|
||||
return util.roomDisplayType(this.room) === ROOM_TYPE_VOICE_MODE;
|
||||
},
|
||||
},
|
||||
useFileModeNonAdmin: {
|
||||
get: function() {
|
||||
if (!this.$config.experimental_file_mode) return false;
|
||||
return util.roomDisplayType(this.room) === ROOM_TYPE_FILE_MODE && !this.canCreatePoll; // TODO - Check user or admin
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* If we have no events and the room is encrypted, show info about this
|
||||
* to the user.
|
||||
|
|
@ -928,8 +945,10 @@ export default {
|
|||
// If we are at bottom, scroll to see new events...
|
||||
var scrollToSeeNew = event.getSender() == this.$matrix.currentUserId; // When we sent, scroll
|
||||
const container = this.chatContainer;
|
||||
if (container && container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) {
|
||||
scrollToSeeNew = true;
|
||||
if (container) {
|
||||
if (container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) {
|
||||
scrollToSeeNew = true;
|
||||
}
|
||||
}
|
||||
this.handleScrolledToBottom(scrollToSeeNew);
|
||||
|
||||
|
|
@ -996,16 +1015,14 @@ export default {
|
|||
},
|
||||
|
||||
optimizeImage(e,event,file) {
|
||||
let currentImageInput = {
|
||||
image: e.target.result,
|
||||
dimensions: null,
|
||||
};
|
||||
file.image = e.target.result;
|
||||
file.dimensions = null;
|
||||
try {
|
||||
currentImageInput.dimensions = sizeOf(dataUriToBuffer(e.target.result));
|
||||
file.dimensions = sizeOf(dataUriToBuffer(e.target.result));
|
||||
|
||||
// Need to resize?
|
||||
const w = currentImageInput.dimensions.width;
|
||||
const h = currentImageInput.dimensions.height;
|
||||
const w = file.dimensions.width;
|
||||
const h = file.dimensions.height;
|
||||
if (w > 640 || h > 640) {
|
||||
var aspect = w / h;
|
||||
var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed());
|
||||
|
|
@ -1020,16 +1037,16 @@ export default {
|
|||
.play(event.target)
|
||||
.then((img) => {
|
||||
Vue.set(
|
||||
currentImageInput,
|
||||
file,
|
||||
"scaled",
|
||||
new File([img], file.name, {
|
||||
type: img.type,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
);
|
||||
Vue.set(currentImageInput, "useScaled", true);
|
||||
Vue.set(currentImageInput, "scaledSize", img.size);
|
||||
Vue.set(currentImageInput, "scaledDimensions", {
|
||||
Vue.set(file, "useScaled", true);
|
||||
Vue.set(file, "scaledSize", img.size);
|
||||
Vue.set(file, "scaledDimensions", {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
});
|
||||
|
|
@ -1041,15 +1058,14 @@ export default {
|
|||
} catch (error) {
|
||||
console.error("Failed to get image dimensions: " + error);
|
||||
}
|
||||
return currentImageInput
|
||||
return file
|
||||
},
|
||||
handleFileReader(event, file) {
|
||||
if (file) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (file.type.startsWith("image/")) {
|
||||
const currentImageInput = this.optimizeImage(e, event, file)
|
||||
this.currentImageInputs = Array.isArray(this.currentImageInputs) ? [...this.currentImageInputs, currentImageInput] : [currentImageInput]
|
||||
this.optimizeImage(e, event, file)
|
||||
}
|
||||
this.$matrix.matrixClient.getMediaConfig().then((config) => {
|
||||
this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, file] : [file];
|
||||
|
|
@ -1090,17 +1106,17 @@ export default {
|
|||
sendAttachment(withText) {
|
||||
this.$refs.attachment.value = null;
|
||||
if (this.isCurrentFileInputsAnArray) {
|
||||
let inputFiles = this.currentFileInputs;
|
||||
if (Array.isArray(this.currentImageInputs) && this.currentImageInputs.scaled && this.currentImageInputs.useScaled) {
|
||||
// Send scaled version of image instead!
|
||||
inputFiles = this.currentImageInputs.map(({scaled}) => scaled)
|
||||
}
|
||||
|
||||
let inputFiles = this.currentFileInputs.map(entry => {
|
||||
if (entry.scaled && entry.useScaled) {
|
||||
// Send scaled version of image instead!
|
||||
return entry.scaled;
|
||||
}
|
||||
return entry;
|
||||
})
|
||||
const promises = inputFiles.map(inputFile => util.sendImage(this.$matrix.matrixClient, this.roomId, inputFile, this.onUploadProgress));
|
||||
|
||||
Promise.all(promises).then(() => {
|
||||
this.currentSendOperation = null;
|
||||
this.currentImageInputs = null;
|
||||
this.currentFileInputs = null;
|
||||
this.currentSendProgress = null;
|
||||
if (withText) {
|
||||
|
|
@ -1125,12 +1141,15 @@ export default {
|
|||
this.currentSendOperation.abort();
|
||||
}
|
||||
this.currentSendOperation = null;
|
||||
this.currentImageInputs = null;
|
||||
this.currentFileInputs = null;
|
||||
this.currentSendProgress = null;
|
||||
this.currentSendError = null;
|
||||
},
|
||||
|
||||
resetAttachments() {
|
||||
this.cancelSendAttachment();
|
||||
},
|
||||
|
||||
handleScrolledToTop() {
|
||||
if (
|
||||
this.timelineWindow &&
|
||||
|
|
@ -1437,7 +1456,7 @@ export default {
|
|||
|
||||
let eventIdFirst = null;
|
||||
let eventIdLast = null;
|
||||
if (!this.useVoiceMode) {
|
||||
if (!this.useVoiceMode && !this.useFileModeNonAdmin) {
|
||||
const container = this.chatContainer;
|
||||
const elFirst = util.getFirstVisibleElement(container, (item) => item.hasAttribute("eventId"));
|
||||
const elLast = util.getLastVisibleElement(container, (item) => item.hasAttribute("eventId"));
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@
|
|||
}
|
||||
" :disabled="step > steps.INITIAL" solo full-width auto-grow rows="1" no-resize hide-details></v-textarea>
|
||||
|
||||
<!-- Our only option right now is voice mode, so if not enabled, hide the 'options' drop down as well -->
|
||||
<template v-if="$config.experimental_voice_mode || $config.experimental_read_only_room || $config.experimental_public_room">
|
||||
<!-- Check if we have any options enabled in config -->
|
||||
<template v-if="$config.experimental_voice_mode || $config.experimental_read_only_room || $config.experimental_public_room || $config.experimental_file_mode">
|
||||
<div @click.stop="showOptions = !showOptions" v-show="roomName.length > 0" class="options clickable">
|
||||
<div>{{ $t("new_room.options") }}</div>
|
||||
<v-icon v-if="!showOptions">expand_more</v-icon>
|
||||
|
|
@ -63,13 +63,13 @@
|
|||
</v-card-text>
|
||||
<div class="option-warning" v-if="unencryptedRoom"><v-icon size="18">$vuetify.icons.ic_warning</v-icon>{{ $t("room_info.make_public_warning")}}</div>
|
||||
</v-card>
|
||||
<v-card v-if="$config.experimental_voice_mode" v-show="showOptions" class="room-option account ma-0" flat>
|
||||
|
||||
<v-card v-if="availableRoomTypes.length > 1" v-show="showOptions" class="room-option account ma-0" flat>
|
||||
<v-card-text class="with-right-label">
|
||||
<div>
|
||||
<div class="option-title">{{ $t('room_info.voice_mode') }}</div>
|
||||
<div class="option-text">{{ $t('room_info.voice_mode_info') }}</div>
|
||||
<div class="option-title">{{ $t('room_info.room_type') }}</div>
|
||||
</div>
|
||||
<v-switch v-model="useVoiceMode"></v-switch>
|
||||
<RoomTypeSelector v-model="roomType" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-card v-if="$config.experimental_read_only_room" v-show="showOptions" class="room-option account ma-0" flat>
|
||||
|
|
@ -144,9 +144,11 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import util, { ROOM_TYPE_VOICE_MODE } from "../plugins/utils";
|
||||
import util, { ROOM_TYPE_DEFAULT } from "../plugins/utils";
|
||||
import InteractiveAuth from './InteractiveAuth.vue';
|
||||
import rememberMeMixin from "./rememberMeMixin";
|
||||
import roomTypeMixin from "./roomTypeMixin";
|
||||
import RoomTypeSelector from './RoomTypeSelector.vue';
|
||||
|
||||
const steps = Object.freeze({
|
||||
INITIAL: 0,
|
||||
|
|
@ -157,8 +159,8 @@ const steps = Object.freeze({
|
|||
|
||||
export default {
|
||||
name: "CreateRoom",
|
||||
components: { InteractiveAuth },
|
||||
mixins: [rememberMeMixin],
|
||||
components: { InteractiveAuth, RoomTypeSelector },
|
||||
mixins: [rememberMeMixin, roomTypeMixin],
|
||||
data() {
|
||||
return {
|
||||
steps,
|
||||
|
|
@ -201,8 +203,8 @@ export default {
|
|||
roomCreationErrorMsg: "",
|
||||
showOptions: false,
|
||||
unencryptedRoom: false,
|
||||
useVoiceMode: false,
|
||||
readOnlyRoom: false,
|
||||
roomType: ROOM_TYPE_DEFAULT,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -393,9 +395,9 @@ export default {
|
|||
// Add topic
|
||||
createRoomOptions.topic = this.roomTopic;
|
||||
}
|
||||
if (this.useVoiceMode) {
|
||||
if (this.roomType != ROOM_TYPE_DEFAULT) {
|
||||
createRoomOptions.creation_content = {
|
||||
type: ROOM_TYPE_VOICE_MODE
|
||||
type: this.roomType
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,16 +127,13 @@
|
|||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card class="account ma-3" flat v-if="$config.experimental_voice_mode || canChangeReadOnly()">
|
||||
<v-card class="account ma-3" flat v-if="availableRoomTypes.length > 1 || canChangeReadOnly()">
|
||||
<v-card-title class="h2 with-right-label"><div>{{ $t("room_info.experimental_features") }}</div><div></div></v-card-title>
|
||||
<v-card-text class="with-right-label" v-if="$config.experimental_voice_mode">
|
||||
<v-card-text class="with-right-label" v-if="availableRoomTypes.length > 1">
|
||||
<div>
|
||||
<div class="option-title">{{ $t('room_info.voice_mode') }}</div>
|
||||
<div class="option-text">{{ $t('room_info.voice_mode_info') }}</div>
|
||||
<div class="option-title">{{ $t('room_info.room_type') }}</div>
|
||||
</div>
|
||||
<v-switch
|
||||
v-model="useVoiceMode"
|
||||
></v-switch>
|
||||
<RoomTypeSelector v-model="roomType" />
|
||||
</v-card-text>
|
||||
<v-card-text class="with-right-label" v-if="canChangeReadOnly()">
|
||||
<div>
|
||||
|
|
@ -260,25 +257,27 @@ import DeviceList from "../components/DeviceList";
|
|||
import RoomExport from "../components/RoomExport";
|
||||
import RoomAvatarPicker from "../components/RoomAvatarPicker";
|
||||
import CopyLink from "../components/CopyLink.vue"
|
||||
import RoomTypeSelector from "./RoomTypeSelector.vue";
|
||||
import roomInfoMixin from "./roomInfoMixin";
|
||||
import util from "../plugins/utils";
|
||||
import roomTypeMixin from "./roomTypeMixin";
|
||||
import util, { ROOM_TYPE_DEFAULT, ROOM_TYPE_FILE_MODE, ROOM_TYPE_VOICE_MODE } from "../plugins/utils";
|
||||
|
||||
export default {
|
||||
name: "RoomInfo",
|
||||
mixins: [roomInfoMixin],
|
||||
mixins: [roomInfoMixin, roomTypeMixin],
|
||||
components: {
|
||||
LeaveRoomDialog,
|
||||
PurgeRoomDialog,
|
||||
DeviceList,
|
||||
RoomExport,
|
||||
RoomAvatarPicker,
|
||||
RoomTypeSelector,
|
||||
CopyLink
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
members: [],
|
||||
user: null,
|
||||
displayName: "",
|
||||
showAllMembers: false,
|
||||
showLeaveConfirmation: false,
|
||||
showPurgeConfirmation: false,
|
||||
|
|
@ -305,7 +304,6 @@ export default {
|
|||
this.$matrix.on("Room.timeline", this.onEvent);
|
||||
this.updateMembers();
|
||||
this.user = this.$matrix.matrixClient.getUser(this.$matrix.currentUserId);
|
||||
this.displayName = this.user.displayName;
|
||||
|
||||
// Display build version
|
||||
const version = require("!!raw-loader!../assets/version.txt").default;
|
||||
|
|
@ -340,15 +338,26 @@ export default {
|
|||
return "";
|
||||
},
|
||||
|
||||
useVoiceMode: {
|
||||
roomType: {
|
||||
get: function () {
|
||||
return util.useVoiceMode(this.room);
|
||||
},
|
||||
set: function (audioLayout) {
|
||||
if (this.room && this.room.tags) {
|
||||
let options = this.room.tags["ui_options"] || {}
|
||||
options["voice_mode"] = (audioLayout ? 1 : 0);
|
||||
this.room.tags["ui_options"] = options;
|
||||
if (options["voice_mode"]) {
|
||||
return ROOM_TYPE_VOICE_MODE;
|
||||
} else if (options["file_mode"]) {
|
||||
return ROOM_TYPE_FILE_MODE;
|
||||
}
|
||||
}
|
||||
return ROOM_TYPE_DEFAULT;
|
||||
},
|
||||
set: function (roomType) {
|
||||
if (this.room) {
|
||||
let tags = this.room.tags || {};
|
||||
let options = tags["ui_options"] || {}
|
||||
options["voice_mode"] = (roomType == ROOM_TYPE_VOICE_MODE ? 1 : 0);
|
||||
options["file_mode"] = (roomType == ROOM_TYPE_FILE_MODE ? 1 : 0);
|
||||
tags["ui_options"] = options;
|
||||
this.room.tags = tags;
|
||||
this.$matrix.matrixClient.setRoomTag(this.room.roomId, "ui_options", options);
|
||||
}
|
||||
},
|
||||
|
|
@ -618,7 +627,7 @@ export default {
|
|||
-1
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
41
src/components/RoomTypeSelector.vue
Normal file
41
src/components/RoomTypeSelector.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<v-select outlined dense :items="availableRoomTypes"
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event)"
|
||||
:reduce="(obj) => obj.value">
|
||||
<template v-slot:selection="{ item }">{{ item.title }}</template>
|
||||
<template v-slot:item="{ item, attrs, on }">
|
||||
<v-list-item v-on="on" v-bind="attrs" #default="{}">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ item.description }}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import roomTypeMixin from "./roomTypeMixin";
|
||||
|
||||
export default {
|
||||
name: "RoomTypeSelector",
|
||||
mixins: [roomTypeMixin],
|
||||
model: {
|
||||
prop: "modelValue",
|
||||
event: "update:modelValue",
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: function () {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "@/assets/css/chat.scss";
|
||||
</style>
|
||||
|
|
@ -6,6 +6,7 @@ import MessageIncomingAudio from "./messages/MessageIncomingAudio.vue";
|
|||
import MessageIncomingVideo from "./messages/MessageIncomingVideo.vue";
|
||||
import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue";
|
||||
import MessageIncomingPoll from "./messages/MessageIncomingPoll.vue";
|
||||
import MessageIncomingThread from "./messages/MessageIncomingThread.vue";
|
||||
import MessageOutgoingText from "./messages/MessageOutgoingText";
|
||||
import MessageOutgoingFile from "./messages/MessageOutgoingFile";
|
||||
import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue";
|
||||
|
|
@ -58,6 +59,7 @@ export default {
|
|||
MessageIncomingAudio,
|
||||
MessageIncomingVideo,
|
||||
MessageIncomingSticker,
|
||||
MessageIncomingThread,
|
||||
MessageOutgoingText,
|
||||
MessageOutgoingFile,
|
||||
MessageOutgoingImage,
|
||||
|
|
@ -152,7 +154,11 @@ export default {
|
|||
|
||||
case "m.room.message":
|
||||
if (event.getSender() != this.$matrix.currentUserId) {
|
||||
if (event.getContent().msgtype == "m.image") {
|
||||
if (event.isThreadRoot) {
|
||||
// Incoming thread, e.g. a file drop!
|
||||
return MessageIncomingThread;
|
||||
}
|
||||
if (event.getContent().msgtype == "m.image") {
|
||||
// For SVG, make downloadable
|
||||
if (
|
||||
event.getContent().info &&
|
||||
|
|
|
|||
285
src/components/file_mode/FileDropLayout.vue
Normal file
285
src/components/file_mode/FileDropLayout.vue
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<template>
|
||||
<div v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
|
||||
<!-- 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">
|
||||
<v-btn @click="$emit('add-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">
|
||||
<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('add-file')">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-drop-input-container">
|
||||
<v-textarea ref="input" full-width 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="send" :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 && countSent == 0" class="no-items">
|
||||
<div class="file-drop-stack-item" :style="stackItemTransform(null, -1)"></div>
|
||||
<div>{{ $t('file_mode.sending_progress') }}</div>
|
||||
</div>
|
||||
<div v-else v-for="(item, index) in sentItems" :key="item.id" class="file-drop-stack-item animated"
|
||||
:style="stackItemTransform(item, index)">
|
||||
<v-img v-if="item.attachment && item.attachment.image" :src="item.attachment.image" />
|
||||
</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 sendingItems" :key="index">
|
||||
<v-img v-if="info.attachment && info.attachment.image" :src="info.attachment.image" />
|
||||
<div v-else class="filename">{{ info.attachment.name }}</div>
|
||||
<v-progress-linear :value="info.progress"></v-progress-linear>
|
||||
<div class="file-drop-cancel clickable" @click.stop="cancelSendingItem(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">{{ $tc((this.messageInput && this.messageInput.length > 0) ?
|
||||
"file_mode.files_sent_with_note" : "file_mode.files_sent", sentItems.length) }}</div>
|
||||
<div class="file-drop-section">
|
||||
<v-textarea disabled full-width 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 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.return_to_home") }}</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import messageMixin from "../messages/messageMixin";
|
||||
import util from "../../plugins/utils";
|
||||
const prettyBytes = require("pretty-bytes");
|
||||
|
||||
export default {
|
||||
mixins: [messageMixin],
|
||||
components: {},
|
||||
props: {
|
||||
attachments: {
|
||||
type: Array,
|
||||
default: function () {
|
||||
return []
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentItemIndex: 0,
|
||||
messageInput: "",
|
||||
mainStatuses: Object.freeze({
|
||||
SELECTING: 0,
|
||||
SENDING: 1,
|
||||
SENT: 2,
|
||||
}),
|
||||
status: 0,
|
||||
statuses: Object.freeze({
|
||||
INITIAL: 0,
|
||||
SENT: 1,
|
||||
CANCELED: 2,
|
||||
FAILED: 3,
|
||||
}),
|
||||
sendInfo: [],
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
document.body.classList.add("dark");
|
||||
this.$audioPlayer.setAutoplay(false);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.body.classList.remove("dark");
|
||||
},
|
||||
computed: {
|
||||
currentItemHasImagePreview() {
|
||||
return this.currentItemIndex >= 0 && this.currentItemIndex < this.attachments.length &&
|
||||
this.attachments[this.currentItemIndex].image
|
||||
},
|
||||
countSent() {
|
||||
return this.sendInfo ? this.sendInfo.reduce((a, elem, ignoredidx, ignoredarray) => elem.status == this.statuses.SENT ? a + 1 : a, 0) : 0
|
||||
},
|
||||
sendingItems() {
|
||||
return this.sendInfo ? this.sendInfo.filter(elem => elem.status == this.statuses.INITIAL) : []
|
||||
},
|
||||
sentItems() {
|
||||
this.sortSendinfo();
|
||||
return this.sendInfo ? this.sendInfo.filter(elem => elem.status == this.statuses.SENT) : []
|
||||
},
|
||||
sentItemsReversed() {
|
||||
const array = this.sentItems;
|
||||
return array.map((ignoreditem, idx) => array[array.length - 1 - idx])
|
||||
}
|
||||
},
|
||||
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: {
|
||||
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.sendInfo = [];
|
||||
this.status = this.mainStatuses.SELECTING;
|
||||
this.messageInput = "";
|
||||
this.currentItemIndex = 0;
|
||||
},
|
||||
send() {
|
||||
this.status = this.mainStatuses.SENDING;
|
||||
this.sendInfo = this.attachments.map((attachment) => {
|
||||
return {
|
||||
id: attachment.name,
|
||||
status: this.statuses.INITIAL,
|
||||
statusDate: Date.now,
|
||||
attachment: attachment,
|
||||
progress: 0,
|
||||
randomRotation: 0,
|
||||
randomTranslationX: 0,
|
||||
randomTranslationY: 0
|
||||
}
|
||||
});
|
||||
|
||||
const text = (this.messageInput && this.messageInput.length > 0) ? this.messageInput : this.$t('file_mode.files');
|
||||
util.sendTextMessage(this.$matrix.matrixClient, this.room.roomId, text)
|
||||
.then((eventId) => {
|
||||
// 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 }) => {
|
||||
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 => {
|
||||
console.error("ERROR", ignorederr);
|
||||
item.status = this.statuses.FAILED;
|
||||
}));
|
||||
return Promise.allSettled(promises)
|
||||
})
|
||||
.then(() => {
|
||||
this.status = this.mainStatuses.SENT;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("ERROR", err);
|
||||
});
|
||||
},
|
||||
cancelSendingItem(item) {
|
||||
// TODO
|
||||
item.status = this.statuses.CANCELED;
|
||||
},
|
||||
checkDone() {
|
||||
if (!this.sendInfo.some(a => a.status == this.statuses.INITIAL)) {
|
||||
this.status = this.mainStatuses.SENT;
|
||||
}
|
||||
},
|
||||
sortSendinfo() {
|
||||
this.sendInfo.sort((a, b) => b.statusDate - a.statusDate);
|
||||
},
|
||||
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">
|
||||
@import "@/assets/css/chat.scss";
|
||||
</style>
|
||||
114
src/components/messages/MessageIncomingThread.vue
Normal file
114
src/components/messages/MessageIncomingThread.vue
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<template>
|
||||
<message-incoming v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
|
||||
<div class="bubble">
|
||||
<div class="message">
|
||||
<v-container fluid class="imageCollection">
|
||||
<v-row wrap>
|
||||
<v-col v-for="({ size, item }) in layoutedItems()" :key="item.event.getId()" :cols="size">
|
||||
<v-img :aspect-ratio="16 / 9" :src="item.src" cover />
|
||||
</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>
|
||||
{{ $t('message.incoming_message_deleted_text') }}
|
||||
</i>
|
||||
<span v-html="linkify($sanitize(messageText))" v-else />
|
||||
<span class="edit-marker" v-if="event.replacingEventId() && !event.isRedacted()">
|
||||
{{ $t('message.edited') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</message-incoming>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MessageIncoming from "./MessageIncoming.vue";
|
||||
import messageMixin from "./messageMixin";
|
||||
import util from "../../plugins/utils";
|
||||
|
||||
export default {
|
||||
extends: MessageIncoming,
|
||||
components: { MessageIncoming },
|
||||
mixins: [messageMixin],
|
||||
data() {
|
||||
return {
|
||||
items: []
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId()).map(e => {
|
||||
let ret = {
|
||||
event: e,
|
||||
src: null,
|
||||
};
|
||||
ret.promise =
|
||||
util
|
||||
.getThumbnail(this.$matrix.matrixClient, e, 100, 100)
|
||||
.then((url) => {
|
||||
ret.src = url;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch thumbnail: ", err);
|
||||
});
|
||||
return ret;
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
layoutedItems() {
|
||||
if (!this.items || this.items.length == 0) { return [] }
|
||||
let array = this.items.slice(0);
|
||||
let rows = []
|
||||
while (array.length > 0) {
|
||||
if (array.length >= 7) {
|
||||
rows.push({ size: 6, item: array[0] });
|
||||
rows.push({ size: 6, item: array[1] });
|
||||
rows.push({ size: 12, item: array[2] });
|
||||
rows.push({ size: 3, item: array[3] });
|
||||
rows.push({ size: 3, item: array[4] });
|
||||
rows.push({ size: 3, item: array[5] });
|
||||
rows.push({ size: 3, item: array[6] });
|
||||
array = array.slice(7);
|
||||
} else if (array.length >= 3) {
|
||||
rows.push({ size: 6, item: array[0] });
|
||||
rows.push({ size: 6, item: array[1] });
|
||||
rows.push({ size: 12, item: array[2] });
|
||||
array = array.slice(3);
|
||||
} else if (array.length >= 2) {
|
||||
rows.push({ size: 6, item: array[0] });
|
||||
rows.push({ size: 6, item: array[1] });
|
||||
array = array.slice(2);
|
||||
} else {
|
||||
rows.push({ size: 12, item: array[0] });
|
||||
array = array.slice(1);
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "@/assets/css/chat.scss";
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.bubble {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.imageCollection {
|
||||
border-radius: 15px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
.row {
|
||||
margin: -4px; // Compensate for column padding, so the border-radius above looks round!
|
||||
padding: 0;
|
||||
}
|
||||
.col {
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import utils from "../plugins/utils";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -57,7 +59,7 @@ export default {
|
|||
publicRoomLink() {
|
||||
if (this.room && this.roomJoinRule == "public") {
|
||||
return this.$router.getRoomLink(
|
||||
this.room.getCanonicalAlias(), this.room.roomId, this.room.name
|
||||
this.room.getCanonicalAlias(), this.room.roomId, this.room.name, utils.roomDisplayTypeToQueryParam(this.room)
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
24
src/components/roomTypeMixin.js
Normal file
24
src/components/roomTypeMixin.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { ROOM_TYPE_VOICE_MODE, ROOM_TYPE_FILE_MODE, ROOM_TYPE_DEFAULT } from "../plugins/utils";
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
availableRoomTypes() {
|
||||
let types = [{ title: this.$t("room_info.room_type_default"), description: "", value: ROOM_TYPE_DEFAULT }];
|
||||
if (this.$config.experimental_voice_mode) {
|
||||
types.push({
|
||||
title: this.$t("room_info.voice_mode"),
|
||||
description: this.$t("room_info.voice_mode_info"),
|
||||
value: ROOM_TYPE_VOICE_MODE,
|
||||
});
|
||||
}
|
||||
if (this.$config.experimental_file_mode) {
|
||||
types.push({
|
||||
title: this.$t("room_info.file_mode"),
|
||||
description: this.$t("room_info.file_mode_info"),
|
||||
value: ROOM_TYPE_FILE_MODE,
|
||||
});
|
||||
}
|
||||
return types;
|
||||
},
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue