Experimental "file drop" mode

This commit is contained in:
N Pex 2023-06-28 12:14:44 +00:00
parent 791fa5936a
commit ebadd509e9
19 changed files with 1038 additions and 85 deletions

View file

@ -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"));

View file

@ -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
}
}

View file

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

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

View file

@ -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 &&

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

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

View file

@ -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;

View 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;
},
},
};