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

@ -236,7 +236,7 @@ export default {
} }
#app { #app {
background-color: $app-background; background-color: var(--v-app-background);
} }
.main { .main {

View file

@ -45,6 +45,7 @@
} }
], ],
"experimental_voice_mode": true, "experimental_voice_mode": true,
"experimental_file_mode": true,
"experimental_read_only_room": true, "experimental_read_only_room": true,
"experimental_public_room": true, "experimental_public_room": true,
"show_status_messages": "never" "show_status_messages": "never"

View file

@ -1,16 +1,19 @@
@import "~vuetify/src/styles/settings/_variables.scss"; @import "~vuetify/src/styles/settings/_variables.scss";
@import "@/assets/css/main.scss"; @import "@/assets/css/main.scss";
@import "@/assets/css/vendors/v-emoji-picker"; @import "@/assets/css/vendors/v-emoji-picker";
@import "@/assets/css/filedrop.scss";
$admin-bg: black; $admin-bg: black;
$admin-fg: white; $admin-fg: white;
body { body {
--v-app-background: $app-background;
--v-background-color: white; --v-background-color: white;
--v-foreground-color: black; --v-foreground-color: black;
--v-secondary-color: #242424; --v-secondary-color: #242424;
--v-divider-color: #eeeeee; --v-divider-color: #eeeeee;
&.dark { &.dark {
--v-app-background: black;
--v-background-color: black; --v-background-color: black;
--v-foreground-color: white; --v-foreground-color: white;
--v-secondary-color: #c0c0c0; --v-secondary-color: #c0c0c0;
@ -1528,4 +1531,4 @@ body {
right: 20px; right: 20px;
bottom: 20px; bottom: 20px;
position: absolute; position: absolute;
} }

View file

@ -0,0 +1,349 @@
$large-button-height: 48px;
$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;
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 / 2;
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 / 2;
}
}
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;
border-radius: 19px;
overflow: hidden;
.v-image {
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-image {
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: linear-gradient(0deg, #3a3a3c 0%, #3a3a3c 100%), #fff;
position: absolute;
overflow: hidden;
.v-image {
width: 100%;
height: 100%;
object-fit: cover;
}
&.animated {
animation-name: fadeInStackItem;
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-image {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: cover;
flex: 0 0 48px;
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;
}
}
}

View file

@ -0,0 +1,15 @@
<template>
<svg width="59" height="60" viewBox="0 0 59 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 3826">
<g id="Group 3800">
<g id="Group 3799">
<circle id="Ellipse 352" cx="29.5" cy="29.5127" r="28.5" fill="#4642F1" stroke="white"
stroke-width="2" />
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd"
d="M22.8132 29.1473L26.8315 33.8176L38.5723 19.8068C39.4513 18.8601 40.8326 19.8699 40.1419 20.9428L28.5896 38.677C27.7106 39.813 26.5177 39.9392 25.5131 38.8032L19.674 31.7978C18.5439 30.1569 21.432 27.8217 22.8132 29.1471V29.1473Z"
fill="white" />
</g>
</g>
</g>
</svg>
</template>

View file

@ -0,0 +1,7 @@
<template>
<svg width="18" height="22" viewBox="0 0 18 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16.0247 8.80006H15.4908V6.42878C15.4908 2.88379 12.5789 0 9.00064 0C5.42053 0 2.50904 2.88379 2.50904 6.42878V8.80047H1.97615C0.88518 8.80047 0 9.67678 0 10.7572V20.0423C0 21.1231 0.884952 22 1.97615 22H16.0248C17.1153 22 18 21.1232 18 20.0423L17.9999 10.7568C17.9999 9.67638 17.1151 8.80006 16.025 8.80006H16.0247ZM9.82754 15.5262V16.7406C9.82754 16.9215 9.6795 17.0687 9.4959 17.0687H8.50277C8.32063 17.0687 8.17185 16.9216 8.17185 16.7406V15.5262C7.7193 15.2498 7.41503 14.7589 7.41503 14.193C7.41503 13.3265 8.12456 12.6242 9.0001 12.6242C9.87461 12.6242 10.5841 13.3265 10.5841 14.193C10.584 14.7589 10.2795 15.2498 9.82754 15.5262ZM12.6451 8.80006H5.35551V6.42878C5.35551 4.43786 6.99007 2.81942 9.00073 2.81942C11.0097 2.81942 12.6451 4.43763 12.6451 6.42878V8.80006Z"
fill="white" />
</svg>
</template>

View file

@ -0,0 +1,7 @@
<template>
<svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M1.22395 5.24998L2.19384 14.7971C2.24872 15.3374 2.7326 15.75 3.31161 15.75H10.688C11.267 15.75 11.7509 15.3374 11.8058 14.7971L12.7757 5.24998H1.22395ZM4.75998 2.09999H1.11163C0.816497 2.09999 0.534245 2.20968 0.325379 2.40503C0.117003 2.60085 0 2.86547 0 3.14213V3.68282C0 3.9595 0.116999 4.2241 0.325379 4.41991C0.534255 4.61526 0.81652 4.72495 1.11163 4.72495H12.8884C13.1835 4.72495 13.4658 4.61527 13.6746 4.41991C13.883 4.2241 14 3.95948 14 3.68282V3.14213C14 2.86545 13.883 2.60084 13.6746 2.40503C13.4657 2.20968 13.1835 2.09999 12.8884 2.09999H9.24002V1.3766C9.24002 0.616408 8.58251 0 7.77162 0H6.22825C5.41736 0 4.75985 0.616408 4.75985 1.3766L4.75998 2.09999Z"
fill="white" />
</svg>
</template>

View file

@ -280,8 +280,12 @@
"user_admin": "Administrator", "user_admin": "Administrator",
"user_moderator": "Moderator", "user_moderator": "Moderator",
"experimental_features": "Experimental Features", "experimental_features": "Experimental Features",
"room_type": "Room type",
"room_type_default": "Default",
"voice_mode": "Voice mode", "voice_mode": "Voice mode",
"voice_mode_info": "Switches the chat interface to a 'listen and record' mode", "voice_mode_info": "Switches the chat interface to a 'listen and record' mode",
"file_mode": "File mode",
"file_mode_info": "Switches the chat interface to a 'file drop' mode",
"download_chat": "Download chat", "download_chat": "Download chat",
"read_only_room": "Read only room", "read_only_room": "Read only room",
"read_only_room_info": "Only admins and moderators are allowed to send to the room", "read_only_room_info": "Only admins and moderators are allowed to send to the room",
@ -364,5 +368,17 @@
"symbols": "Symbols", "symbols": "Symbols",
"places": "Places" "places": "Places"
} }
},
"file_mode": {
"choose_files": "Choose files",
"any_file_format_accepted": "Any file format is accepted",
"secure_file_send": "secure file send",
"add_a_message": "Add a message",
"sending_progress": "Sending...",
"sending": "Sending",
"files_sent":"1 file sent! | {count} files sent!",
"files_sent_with_note":"1 file sent with a note! | {count} files sent with a note!",
"return_to_home": "Return to home",
"files": "Files"
} }
} }

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="chat-root fill-height d-flex flex-column"> <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" <AudioLayout ref="chatContainer" class="auto-audio-player-root" v-if="useVoiceMode" :room="room"
:events="events" :autoplay="!showRecorder" :events="events" :autoplay="!showRecorder"
:timelineSet="timelineSet" :timelineSet="timelineSet"
@ -15,8 +15,14 @@
<VoiceRecorder class="audio-layout" v-if="useVoiceMode" :micButtonRef="$refs.mic_button" :ptt="showRecorderPTT" :show="showRecorder" <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" /> 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"> v-on:scroll="onScroll" @click="closeContextMenusIfOpen">
<div ref="messageOperationsStrut" class="message-operations-strut"> <div ref="messageOperationsStrut" class="message-operations-strut">
<message-operations ref="messageOperations" :style="opStyle" :emojis="recentEmojis" v-on:close=" <message-operations ref="messageOperations" :style="opStyle" :emojis="recentEmojis" v-on:close="
@ -75,7 +81,7 @@
</div> </div>
<!-- Input area --> <!-- 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' : '']"> <div :class="[replyToEvent ? 'iput-area-inner-box' : '']">
<!-- "Scroll to end"-button --> <!-- "Scroll to end"-button -->
<v-btn v-if="!useVoiceMode" class="scroll-to-end" v-show="showScrollToEnd" fab x-small elevation="0" color="black" <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)" <input ref="attachment" type="file" name="attachment" @change="handlePickedAttachment($event)"
accept="image/*, audio/*, video/*, .pdf" class="d-none" multiple/> 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-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 class="ma-0 pa-0">
<v-card-title>{{ $t('message.send_attachements_dialog_title') }}</v-card-title> <v-card-title>{{ $t('message.send_attachements_dialog_title') }}</v-card-title>
<v-divider></v-divider> <v-divider></v-divider>
<template v-if="Array.isArray(currentImageInputs) && currentImageInputs.length"> <template v-if="imageFiles && imageFiles.length">
<v-card-title v-if="currentImageInputs.length > 1"> {{ $t('message.images') }} </v-card-title> <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': currentImageInputs.length > 1}"> <v-card-text :class="{'ma-0 pa-2' : true, 'd-flex flex-wrap justify-center': imageFiles.length > 1}">
<div :class="{'col-4': currentImageInputs.length > 1}" v-for="(currentImageInput, id) in currentImageInputs" :key="id"> <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" <v-img v-if="currentImageInput && currentImageInput.image" :aspect-ratio="1" :src="currentImageInput.image"
contain class="current-image-input-path" /> contain class="current-image-input-path" />
<div> <div>
@ -281,7 +287,7 @@
<script> <script>
import Vue from "vue"; import Vue from "vue";
import { TimelineWindow, EventTimeline } from "matrix-js-sdk"; 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 MessageOperations from "./messages/MessageOperations.vue";
import AvatarOperations from "./messages/AvatarOperations.vue"; import AvatarOperations from "./messages/AvatarOperations.vue";
import ChatHeader from "./ChatHeader"; import ChatHeader from "./ChatHeader";
@ -296,6 +302,7 @@ import ImageResize from "image-resize";
import CreatePollDialog from "./CreatePollDialog.vue"; import CreatePollDialog from "./CreatePollDialog.vue";
import chatMixin from "./chatMixin"; import chatMixin from "./chatMixin";
import AudioLayout from "./AudioLayout.vue"; import AudioLayout from "./AudioLayout.vue";
import FileDropLayout from "./file_mode/FileDropLayout";
const sizeOf = require("image-size"); const sizeOf = require("image-size");
const dataUriToBuffer = require("data-uri-to-buffer"); const dataUriToBuffer = require("data-uri-to-buffer");
@ -344,7 +351,8 @@ export default {
BottomSheet, BottomSheet,
AvatarOperations, AvatarOperations,
CreatePollDialog, CreatePollDialog,
AudioLayout AudioLayout,
FileDropLayout
}, },
data() { data() {
@ -360,7 +368,6 @@ export default {
timelineWindowPaginating: false, timelineWindowPaginating: false,
scrollPosition: null, scrollPosition: null,
currentImageInputs: null,
currentFileInputs: null, currentFileInputs: null,
currentSendOperation: null, currentSendOperation: null,
currentSendProgress: null, currentSendProgress: null,
@ -465,6 +472,9 @@ export default {
nonImageFiles() { nonImageFiles() {
return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => !file.type.includes("image/")) return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => !file.type.includes("image/"))
}, },
imageFiles() {
return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => file.type.includes("image/"))
},
isCurrentFileInputsAnArray() { isCurrentFileInputsAnArray() {
return Array.isArray(this.currentFileInputs) return Array.isArray(this.currentFileInputs)
}, },
@ -584,9 +594,16 @@ export default {
useVoiceMode: { useVoiceMode: {
get: function () { get: function () {
if (!this.$config.experimental_voice_mode) return false; 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 * If we have no events and the room is encrypted, show info about this
* to the user. * to the user.
@ -928,8 +945,10 @@ export default {
// If we are at bottom, scroll to see new events... // If we are at bottom, scroll to see new events...
var scrollToSeeNew = event.getSender() == this.$matrix.currentUserId; // When we sent, scroll var scrollToSeeNew = event.getSender() == this.$matrix.currentUserId; // When we sent, scroll
const container = this.chatContainer; const container = this.chatContainer;
if (container && container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) { if (container) {
scrollToSeeNew = true; if (container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) {
scrollToSeeNew = true;
}
} }
this.handleScrolledToBottom(scrollToSeeNew); this.handleScrolledToBottom(scrollToSeeNew);
@ -996,16 +1015,14 @@ export default {
}, },
optimizeImage(e,event,file) { optimizeImage(e,event,file) {
let currentImageInput = { file.image = e.target.result;
image: e.target.result, file.dimensions = null;
dimensions: null,
};
try { try {
currentImageInput.dimensions = sizeOf(dataUriToBuffer(e.target.result)); file.dimensions = sizeOf(dataUriToBuffer(e.target.result));
// Need to resize? // Need to resize?
const w = currentImageInput.dimensions.width; const w = file.dimensions.width;
const h = currentImageInput.dimensions.height; const h = file.dimensions.height;
if (w > 640 || h > 640) { if (w > 640 || h > 640) {
var aspect = w / h; var aspect = w / h;
var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed()); var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed());
@ -1020,16 +1037,16 @@ export default {
.play(event.target) .play(event.target)
.then((img) => { .then((img) => {
Vue.set( Vue.set(
currentImageInput, file,
"scaled", "scaled",
new File([img], file.name, { new File([img], file.name, {
type: img.type, type: img.type,
lastModified: Date.now(), lastModified: Date.now(),
}) })
); );
Vue.set(currentImageInput, "useScaled", true); Vue.set(file, "useScaled", true);
Vue.set(currentImageInput, "scaledSize", img.size); Vue.set(file, "scaledSize", img.size);
Vue.set(currentImageInput, "scaledDimensions", { Vue.set(file, "scaledDimensions", {
width: newWidth, width: newWidth,
height: newHeight, height: newHeight,
}); });
@ -1041,15 +1058,14 @@ export default {
} catch (error) { } catch (error) {
console.error("Failed to get image dimensions: " + error); console.error("Failed to get image dimensions: " + error);
} }
return currentImageInput return file
}, },
handleFileReader(event, file) { handleFileReader(event, file) {
if (file) { if (file) {
var reader = new FileReader(); var reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
if (file.type.startsWith("image/")) { if (file.type.startsWith("image/")) {
const currentImageInput = this.optimizeImage(e, event, file) this.optimizeImage(e, event, file)
this.currentImageInputs = Array.isArray(this.currentImageInputs) ? [...this.currentImageInputs, currentImageInput] : [currentImageInput]
} }
this.$matrix.matrixClient.getMediaConfig().then((config) => { this.$matrix.matrixClient.getMediaConfig().then((config) => {
this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, file] : [file]; this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, file] : [file];
@ -1090,17 +1106,17 @@ export default {
sendAttachment(withText) { sendAttachment(withText) {
this.$refs.attachment.value = null; this.$refs.attachment.value = null;
if (this.isCurrentFileInputsAnArray) { if (this.isCurrentFileInputsAnArray) {
let inputFiles = this.currentFileInputs; let inputFiles = this.currentFileInputs.map(entry => {
if (Array.isArray(this.currentImageInputs) && this.currentImageInputs.scaled && this.currentImageInputs.useScaled) { if (entry.scaled && entry.useScaled) {
// Send scaled version of image instead! // Send scaled version of image instead!
inputFiles = this.currentImageInputs.map(({scaled}) => scaled) return entry.scaled;
} }
return entry;
})
const promises = inputFiles.map(inputFile => util.sendImage(this.$matrix.matrixClient, this.roomId, inputFile, this.onUploadProgress)); const promises = inputFiles.map(inputFile => util.sendImage(this.$matrix.matrixClient, this.roomId, inputFile, this.onUploadProgress));
Promise.all(promises).then(() => { Promise.all(promises).then(() => {
this.currentSendOperation = null; this.currentSendOperation = null;
this.currentImageInputs = null;
this.currentFileInputs = null; this.currentFileInputs = null;
this.currentSendProgress = null; this.currentSendProgress = null;
if (withText) { if (withText) {
@ -1125,12 +1141,15 @@ export default {
this.currentSendOperation.abort(); this.currentSendOperation.abort();
} }
this.currentSendOperation = null; this.currentSendOperation = null;
this.currentImageInputs = null;
this.currentFileInputs = null; this.currentFileInputs = null;
this.currentSendProgress = null; this.currentSendProgress = null;
this.currentSendError = null; this.currentSendError = null;
}, },
resetAttachments() {
this.cancelSendAttachment();
},
handleScrolledToTop() { handleScrolledToTop() {
if ( if (
this.timelineWindow && this.timelineWindow &&
@ -1437,7 +1456,7 @@ export default {
let eventIdFirst = null; let eventIdFirst = null;
let eventIdLast = null; let eventIdLast = null;
if (!this.useVoiceMode) { if (!this.useVoiceMode && !this.useFileModeNonAdmin) {
const container = this.chatContainer; const container = this.chatContainer;
const elFirst = util.getFirstVisibleElement(container, (item) => item.hasAttribute("eventId")); const elFirst = util.getFirstVisibleElement(container, (item) => item.hasAttribute("eventId"));
const elLast = util.getLastVisibleElement(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> " :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 --> <!-- 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"> <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 @click.stop="showOptions = !showOptions" v-show="roomName.length > 0" class="options clickable">
<div>{{ $t("new_room.options") }}</div> <div>{{ $t("new_room.options") }}</div>
<v-icon v-if="!showOptions">expand_more</v-icon> <v-icon v-if="!showOptions">expand_more</v-icon>
@ -63,13 +63,13 @@
</v-card-text> </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> <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-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"> <v-card-text class="with-right-label">
<div> <div>
<div class="option-title">{{ $t('room_info.voice_mode') }}</div> <div class="option-title">{{ $t('room_info.room_type') }}</div>
<div class="option-text">{{ $t('room_info.voice_mode_info') }}</div>
</div> </div>
<v-switch v-model="useVoiceMode"></v-switch> <RoomTypeSelector v-model="roomType" />
</v-card-text> </v-card-text>
</v-card> </v-card>
<v-card v-if="$config.experimental_read_only_room" v-show="showOptions" class="room-option account ma-0" flat> <v-card v-if="$config.experimental_read_only_room" v-show="showOptions" class="room-option account ma-0" flat>
@ -144,9 +144,11 @@
</template> </template>
<script> <script>
import util, { ROOM_TYPE_VOICE_MODE } from "../plugins/utils"; import util, { ROOM_TYPE_DEFAULT } from "../plugins/utils";
import InteractiveAuth from './InteractiveAuth.vue'; import InteractiveAuth from './InteractiveAuth.vue';
import rememberMeMixin from "./rememberMeMixin"; import rememberMeMixin from "./rememberMeMixin";
import roomTypeMixin from "./roomTypeMixin";
import RoomTypeSelector from './RoomTypeSelector.vue';
const steps = Object.freeze({ const steps = Object.freeze({
INITIAL: 0, INITIAL: 0,
@ -157,8 +159,8 @@ const steps = Object.freeze({
export default { export default {
name: "CreateRoom", name: "CreateRoom",
components: { InteractiveAuth }, components: { InteractiveAuth, RoomTypeSelector },
mixins: [rememberMeMixin], mixins: [rememberMeMixin, roomTypeMixin],
data() { data() {
return { return {
steps, steps,
@ -201,8 +203,8 @@ export default {
roomCreationErrorMsg: "", roomCreationErrorMsg: "",
showOptions: false, showOptions: false,
unencryptedRoom: false, unencryptedRoom: false,
useVoiceMode: false,
readOnlyRoom: false, readOnlyRoom: false,
roomType: ROOM_TYPE_DEFAULT,
}; };
}, },
@ -393,9 +395,9 @@ export default {
// Add topic // Add topic
createRoomOptions.topic = this.roomTopic; createRoomOptions.topic = this.roomTopic;
} }
if (this.useVoiceMode) { if (this.roomType != ROOM_TYPE_DEFAULT) {
createRoomOptions.creation_content = { createRoomOptions.creation_content = {
type: ROOM_TYPE_VOICE_MODE type: this.roomType
} }
} }

View file

@ -127,16 +127,13 @@
</v-card-text> </v-card-text>
</v-card> </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-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>
<div class="option-title">{{ $t('room_info.voice_mode') }}</div> <div class="option-title">{{ $t('room_info.room_type') }}</div>
<div class="option-text">{{ $t('room_info.voice_mode_info') }}</div>
</div> </div>
<v-switch <RoomTypeSelector v-model="roomType" />
v-model="useVoiceMode"
></v-switch>
</v-card-text> </v-card-text>
<v-card-text class="with-right-label" v-if="canChangeReadOnly()"> <v-card-text class="with-right-label" v-if="canChangeReadOnly()">
<div> <div>
@ -260,25 +257,27 @@ import DeviceList from "../components/DeviceList";
import RoomExport from "../components/RoomExport"; import RoomExport from "../components/RoomExport";
import RoomAvatarPicker from "../components/RoomAvatarPicker"; import RoomAvatarPicker from "../components/RoomAvatarPicker";
import CopyLink from "../components/CopyLink.vue" import CopyLink from "../components/CopyLink.vue"
import RoomTypeSelector from "./RoomTypeSelector.vue";
import roomInfoMixin from "./roomInfoMixin"; 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 { export default {
name: "RoomInfo", name: "RoomInfo",
mixins: [roomInfoMixin], mixins: [roomInfoMixin, roomTypeMixin],
components: { components: {
LeaveRoomDialog, LeaveRoomDialog,
PurgeRoomDialog, PurgeRoomDialog,
DeviceList, DeviceList,
RoomExport, RoomExport,
RoomAvatarPicker, RoomAvatarPicker,
RoomTypeSelector,
CopyLink CopyLink
}, },
data() { data() {
return { return {
members: [], members: [],
user: null, user: null,
displayName: "",
showAllMembers: false, showAllMembers: false,
showLeaveConfirmation: false, showLeaveConfirmation: false,
showPurgeConfirmation: false, showPurgeConfirmation: false,
@ -305,7 +304,6 @@ export default {
this.$matrix.on("Room.timeline", this.onEvent); this.$matrix.on("Room.timeline", this.onEvent);
this.updateMembers(); this.updateMembers();
this.user = this.$matrix.matrixClient.getUser(this.$matrix.currentUserId); this.user = this.$matrix.matrixClient.getUser(this.$matrix.currentUserId);
this.displayName = this.user.displayName;
// Display build version // Display build version
const version = require("!!raw-loader!../assets/version.txt").default; const version = require("!!raw-loader!../assets/version.txt").default;
@ -340,15 +338,26 @@ export default {
return ""; return "";
}, },
useVoiceMode: { roomType: {
get: function () { get: function () {
return util.useVoiceMode(this.room);
},
set: function (audioLayout) {
if (this.room && this.room.tags) { if (this.room && this.room.tags) {
let options = this.room.tags["ui_options"] || {} let options = this.room.tags["ui_options"] || {}
options["voice_mode"] = (audioLayout ? 1 : 0); if (options["voice_mode"]) {
this.room.tags["ui_options"] = options; 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); this.$matrix.matrixClient.setRoomTag(this.room.roomId, "ui_options", options);
} }
}, },
@ -618,7 +627,7 @@ export default {
-1 -1
); );
} }
} },
}, },
}; };
</script> </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 MessageIncomingVideo from "./messages/MessageIncomingVideo.vue";
import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue"; import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue";
import MessageIncomingPoll from "./messages/MessageIncomingPoll.vue"; import MessageIncomingPoll from "./messages/MessageIncomingPoll.vue";
import MessageIncomingThread from "./messages/MessageIncomingThread.vue";
import MessageOutgoingText from "./messages/MessageOutgoingText"; import MessageOutgoingText from "./messages/MessageOutgoingText";
import MessageOutgoingFile from "./messages/MessageOutgoingFile"; import MessageOutgoingFile from "./messages/MessageOutgoingFile";
import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue"; import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue";
@ -58,6 +59,7 @@ export default {
MessageIncomingAudio, MessageIncomingAudio,
MessageIncomingVideo, MessageIncomingVideo,
MessageIncomingSticker, MessageIncomingSticker,
MessageIncomingThread,
MessageOutgoingText, MessageOutgoingText,
MessageOutgoingFile, MessageOutgoingFile,
MessageOutgoingImage, MessageOutgoingImage,
@ -152,7 +154,11 @@ export default {
case "m.room.message": case "m.room.message":
if (event.getSender() != this.$matrix.currentUserId) { 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 // For SVG, make downloadable
if ( if (
event.getContent().info && 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 { export default {
data() { data() {
return { return {
@ -57,7 +59,7 @@ export default {
publicRoomLink() { publicRoomLink() {
if (this.room && this.roomJoinRule == "public") { if (this.room && this.roomJoinRule == "public") {
return this.$router.getRoomLink( 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; 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;
},
},
};

View file

@ -3,7 +3,9 @@ import * as ContentHelpers from "matrix-js-sdk/lib/content-helpers";
import dataUriToBuffer from "data-uri-to-buffer"; import dataUriToBuffer from "data-uri-to-buffer";
import ImageResize from "image-resize"; import ImageResize from "image-resize";
export const ROOM_TYPE_DEFAULT = "im.keanu.room_type_default";
export const ROOM_TYPE_VOICE_MODE = "im.keanu.room_type_voice"; export const ROOM_TYPE_VOICE_MODE = "im.keanu.room_type_voice";
export const ROOM_TYPE_FILE_MODE = "im.keanu.room_type_file";
const sizeOf = require("image-size"); const sizeOf = require("image-size");
@ -283,7 +285,7 @@ class Util {
matrixClient.sendEvent(roomId, eventType, content, undefined, undefined) matrixClient.sendEvent(roomId, eventType, content, undefined, undefined)
.then((result) => { .then((result) => {
console.log("Message sent: ", result); console.log("Message sent: ", result);
resolve(true); resolve(result.event_id);
}) })
.catch(err => { .catch(err => {
console.log("Send error: ", err); console.log("Send error: ", err);
@ -322,7 +324,7 @@ class Util {
matrixClient.resendEvent(event, matrixClient.getRoom(event.getRoomId())) matrixClient.resendEvent(event, matrixClient.getRoom(event.getRoomId()))
.then((result) => { .then((result) => {
console.log("Message sent: ", result); console.log("Message sent: ", result);
resolve(true); resolve(result.event_id);
}) })
.catch((err) => { .catch((err) => {
// Still error, abort // Still error, abort
@ -337,7 +339,7 @@ class Util {
}); });
} }
sendImage(matrixClient, roomId, file, onUploadProgress) { sendImage(matrixClient, roomId, file, onUploadProgress, threadRoot) {
return new UploadPromise((resolve, reject, aborter) => { return new UploadPromise((resolve, reject, aborter) => {
const abortionController = aborter; const abortionController = aborter;
var reader = new FileReader(); var reader = new FileReader();
@ -382,6 +384,14 @@ class Util {
msgtype: msgtype msgtype: msgtype
} }
// If thread root (an eventId) is set, add that here
if (threadRoot) {
messageContent["m.relates_to"] = {
"rel_type": "m.thread",
"event_id": threadRoot
};
}
// Set filename for files // Set filename for files
if (msgtype == 'm.file') { if (msgtype == 'm.file') {
messageContent.filename = file.name; messageContent.filename = file.name;
@ -462,21 +472,29 @@ class Util {
} }
/** /**
* Return 'true' if we should use voice mode for the given room. * Return what "mode" to use for the given room.
* *
* The default value is given by the room itself. If the "type" of the * The default value is given by the room itself. If the "type" of the
* room is set to 'im.keanu.room_type_voice' then we default to voice mode, * room is set to 'im.keanu.room_type_voice' then we default to voice mode,
* else not. The user can then override this default by flipping the "voice mode" * else if set to 'im.keanu.room_type_file' we default to file mode.
* swicth on room settings (it will be persisted as a user specific tag on the room) * The user can then override this default by changing the "room type"
* in room settings (it will be persisted as a user specific tag on the room)
*/ */
useVoiceMode(roomOrNull) { roomDisplayType(roomOrNull) {
if (roomOrNull) { if (roomOrNull) {
const room = roomOrNull; const room = roomOrNull;
// Have we changed our local view mode of this room? // Have we changed our local view mode of this room?
const tags = room.tags; const tags = room.tags;
if (tags && tags["ui_options"]) { if (tags && tags["ui_options"]) {
return tags["ui_options"]["voice_mode"] === 1; if (tags["ui_options"]["voice_mode"] === 1) {
return ROOM_TYPE_VOICE_MODE;
} else if (tags["ui_options"]["file_mode"] === 1) {
return ROOM_TYPE_FILE_MODE;
} else if (tags["ui_options"]["file_mode"] === 0 && tags["ui_options"]["file_mode"] === 0) {
// Explicitly set to "default"
return ROOM_TYPE_DEFAULT;
}
} }
// Was the room created with a voice mode type? // Was the room created with a voice mode type?
@ -485,10 +503,34 @@ class Util {
"" ""
); );
if (createEvent) { if (createEvent) {
return createEvent.getContent().type === ROOM_TYPE_VOICE_MODE; const roomType = createEvent.getContent().type;
// Validate value, or return default
if ([ROOM_TYPE_FILE_MODE, ROOM_TYPE_VOICE_MODE].includes(roomType)) {
return roomType;
}
} }
} }
return false; return ROOM_TYPE_DEFAULT;
}
/**
* Return the room type for the current room
* @param {*} roomOrNull
*/
roomDisplayTypeToQueryParam(roomOrNull) {
const roomType = this.roomDisplayType(roomOrNull);
if (roomType === ROOM_TYPE_FILE_MODE) {
// Send "file" here, so the receiver of the invite link knows to display the "file drop" join page
// instead of the standard one.
return "file";
} else if (roomType === ROOM_TYPE_VOICE_MODE) {
// No need to return "voice" here. The invite page looks the same for default and voice mode,
// so currently no point in cluttering the invite link with it. The corrent mode will be picked up from
// room creation flags once the user joins.
return undefined;
}
return undefined; // Default, just return undefined
} }
/** Generate a random user name */ /** Generate a random user name */
@ -592,7 +634,7 @@ class Util {
findOneVisibleElement(parentNode) { findOneVisibleElement(parentNode) {
let start = 0; let start = 0;
let end = parentNode.children.length - 1; let end = (parentNode && parentNode.children) ? parentNode.children.length - 1 : -1;
while (start <= end) { while (start <= end) {
let middle = Math.floor((start + end) / 2); let middle = Math.floor((start + end) / 2);
let childNode = parentNode.children[middle]; let childNode = parentNode.children[middle];

View file

@ -140,11 +140,22 @@ router.beforeEach((to, from, next) => {
} }
}); });
router.getRoomLink = function (alias, roomId, roomName) { router.getRoomLink = function (alias, roomId, roomName, mode) {
let params = {};
if ((!alias || roomName.replace(/\s/g, "").toLowerCase() !== util.getRoomNameFromAlias(alias)) && roomName) { if ((!alias || roomName.replace(/\s/g, "").toLowerCase() !== util.getRoomNameFromAlias(alias)) && roomName) {
// There is no longer a correlation between alias and room name, probably because room name has // There is no longer a correlation between alias and room name, probably because room name has
// changed. Include the "?roomName" part // changed. Include the "?roomName" part
return window.location.origin + window.location.pathname + "?roomName=" + encodeURIComponent(roomName) + "#/room/" + encodeURIComponent(util.sanitizeRoomId(alias || roomId)); params["roomName"] = roomName;
}
if (mode) {
// Optional mode given, append as "m" query param
params["m"] = mode;
}
if (Object.entries(params).length > 0) {
const queryString = Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
return window.location.origin + window.location.pathname + "?" + queryString + "#/room/" + encodeURIComponent(util.sanitizeRoomId(alias || roomId));
} }
return window.location.origin + window.location.pathname + "#/room/" + encodeURIComponent(util.sanitizeRoomId(alias || roomId)); return window.location.origin + window.location.pathname + "#/room/" + encodeURIComponent(util.sanitizeRoomId(alias || roomId));
} }