Work on attachments
This commit is contained in:
parent
ec79a33eab
commit
842c87dc96
28 changed files with 2714 additions and 798 deletions
402
src/assets/css/sendattachments.scss
Normal file
402
src/assets/css/sendattachments.scss
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
@use "@/assets/css/variables" as *;
|
||||
|
||||
$large-button-height: $min-touch-target;
|
||||
$small-button-height: 36px;
|
||||
|
||||
$background: #ffffff;
|
||||
$backgroundSection: rgba(#ededed,0.8);
|
||||
$backgroundHilite: #383739;
|
||||
$text: #000000;
|
||||
$hiliteColor: #4642f1;
|
||||
|
||||
.send-attachments {
|
||||
font-family: "Inter", sans-serif;
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
background-color: $background;
|
||||
color: $text;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
|
||||
.send-attachments__title {
|
||||
color: $text;
|
||||
text-align: center;
|
||||
font-size: 11.54 * $chat-text-size;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 700;
|
||||
line-height: 140%;
|
||||
letter-spacing: 0.34px;
|
||||
text-transform: uppercase;
|
||||
margin-top: 13px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.background {
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
background-color: $background;
|
||||
&.drop-target {
|
||||
background-color: $backgroundHilite;
|
||||
}
|
||||
border-radius: 19px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-format-info {
|
||||
opacity: 0.6;
|
||||
color: $text;
|
||||
text-align: center;
|
||||
font-size: 11 * $chat-text-size;
|
||||
font-family: "Inter", sans-serif;
|
||||
line-height: 117%;
|
||||
letter-spacing: 0.4px;
|
||||
margin-top: 13px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.v-btn {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 11.54 * $chat-text-size;
|
||||
line-height: 140%;
|
||||
color: white;
|
||||
background-color: $hiliteColor !important;
|
||||
border-radius: $small-button-height * 0.5;
|
||||
min-height: 0;
|
||||
height: $small-button-height !important;
|
||||
margin-top: $chat-standard-padding-xs;
|
||||
margin-bottom: $chat-standard-padding-xs;
|
||||
&.large {
|
||||
padding: 16px 23px;
|
||||
height: $large-button-height;
|
||||
border-radius: $large-button-height * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
color: rgba($text, 80%) !important;
|
||||
}
|
||||
textarea::placeholder {
|
||||
color: rgba($text, 80%) !important;
|
||||
}
|
||||
|
||||
.attachment-wrapper {
|
||||
width: 100%;
|
||||
flex: 0 0 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.file-drop-current-item {
|
||||
width: 100%;
|
||||
height: 60%;
|
||||
background-color: $backgroundSection;
|
||||
display: flex;
|
||||
&.drop-target {
|
||||
background-color: $backgroundHilite;
|
||||
}
|
||||
border-radius: 19px;
|
||||
overflow: hidden;
|
||||
.v-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.filename {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.send-attachments__current-item__info {
|
||||
height: 80px;
|
||||
text-align: start;
|
||||
margin: 18px 20px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
|
||||
.send-attachments__current-item__info__size {
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
margin-right: 36px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.send-attachments__current-item__info__size__filename {
|
||||
opacity: 0.7;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.file-drop-thumbnail-container {
|
||||
width: 100%;
|
||||
padding: 13px 20px 15px 20px;
|
||||
height: 74px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
text-align: start;
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
|
||||
.file-drop-thumbnail {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
background-color: #242424;
|
||||
border: 2px solid white;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
&.current {
|
||||
border: 2px solid #4642f1;
|
||||
}
|
||||
&.noborder {
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.v-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
margin-right: 8px;
|
||||
|
||||
.add,
|
||||
.remove {
|
||||
color: $background;
|
||||
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: $backgroundSection;
|
||||
border-radius: 19px;
|
||||
}
|
||||
|
||||
.file-drop-input-container,
|
||||
.file-drop-sending-input-container,
|
||||
.file-drop-sent-input-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
background-color: $backgroundSection;
|
||||
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;
|
||||
}
|
||||
.input-container__buttons {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 10px;
|
||||
display: flex;
|
||||
& > *:not(:first-child) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInStackItem {
|
||||
from {opacity: 0;}
|
||||
to {opacity: 1;}
|
||||
}
|
||||
|
||||
// Sending
|
||||
//
|
||||
.file-drop-sent-stack {
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.no-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
div {
|
||||
position: absolute;
|
||||
}
|
||||
.file-drop-stack-item {
|
||||
transform: rotate(-4.4deg);
|
||||
}
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 21 * $chat-text-size;
|
||||
font-family: "Poppins", sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.34px;
|
||||
}
|
||||
.items-sent {
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
div, .v-icon {
|
||||
position: absolute;
|
||||
}
|
||||
.v-icon, .v-icon__component {
|
||||
width: 30%;
|
||||
height: 30%;
|
||||
}
|
||||
}
|
||||
.file-drop-stack-item {
|
||||
background: #3a3a3c;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
.v-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
&.direct {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
&.animated {
|
||||
animation-name: fadeInStackItem;
|
||||
animation-fill-mode: both;
|
||||
animation-duration: 1.5s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-drop-sending-container {
|
||||
width: 100%;
|
||||
padding: 13px 0px 15px 0px;
|
||||
height: 50%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
white-space: nowrap;
|
||||
text-align: start;
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
|
||||
.file-drop-sending-item {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
background-color: $backgroundSection;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
.v-img {
|
||||
width: $min-touch-target;
|
||||
height: $min-touch-target;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex: 0 0 $min-touch-target;
|
||||
margin-right: 8px;
|
||||
}
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.filename {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 8px;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
.v-progress-linear {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.file-drop-cancel {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: green !important;
|
||||
background: #2e2e3b;
|
||||
border-radius: 8.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-drop-sending-input-container {
|
||||
.v-btn {
|
||||
.v-progress-circular {
|
||||
margin-left: 8px;
|
||||
}
|
||||
background: linear-gradient(0deg, #000 0%, #000 100%), #4642f1;
|
||||
}
|
||||
}
|
||||
|
||||
.file-drop-files-sent {
|
||||
width: 100%;
|
||||
color: $text;
|
||||
text-align: center;
|
||||
font-size: 21 * $chat-text-size;
|
||||
font-family: "Poppins", sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.34px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-drop-sent-input-container {
|
||||
background-color: transparent;
|
||||
.v-btn {
|
||||
right: unset;
|
||||
left: 8px;
|
||||
background: linear-gradient(0deg, #000 0%, #000 100%), #4642f1;
|
||||
&.close {
|
||||
right: 8px;
|
||||
left: unset;
|
||||
background: $hiliteColor !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/assets/icons/ic_cr.vue
Normal file
45
src/assets/icons/ic_cr.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<svg
|
||||
version="1.0"
|
||||
id="katman_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width="24px"
|
||||
height="24px"
|
||||
viewBox="0 0 841.89 595.28"
|
||||
style="enable-background: new 0 0 841.89 595.28"
|
||||
xml:space="preserve"
|
||||
>
|
||||
<g>
|
||||
<g>
|
||||
<path
|
||||
class="st1"
|
||||
d="M207.73,297.41c0-117.82,95.44-213.26,213.26-213.26c117.82,0,213.26,95.44,213.26,213.26v213.26H420.99
|
||||
C303.17,510.67,207.73,415.23,207.73,297.41z"
|
||||
/>
|
||||
<path
|
||||
class="st2"
|
||||
d="M617.82,297.41v196.84H420.99c-108.77,0-196.84-88.07-196.84-196.84s88.07-196.84,196.84-196.84
|
||||
S617.82,188.64,617.82,297.41z M182.97,297.41c0-131.38,106.63-238.01,238.01-238.01S659,166.03,659,297.41v238.01H420.99
|
||||
C289.6,535.42,182.97,428.79,182.97,297.41z M277.7,306.93c0,49.03,33.08,90.45,85.68,90.45c43.32,0,72.59-28.56,79.73-65.93
|
||||
h-42.84c-5.47,17.14-19.28,27.61-36.89,27.61c-26.66,0-44.03-20.95-44.03-52.12s17.38-52.12,44.03-52.12
|
||||
c17.14,0,30.7,9.76,36.42,25.94h43.08c-7.62-36.42-36.65-64.26-79.5-64.26C310.55,216.49,277.7,257.9,277.7,306.93z
|
||||
M500.48,221.25h-40.46v171.61h42.13v-89.49c0-16.9,4.76-27.85,12.85-34.75c7.14-6.43,16.42-9.76,31.66-9.76h10.71V219.1H546.9
|
||||
c-22.14,0-36.89,8.09-46.41,20.47v-18.57V221.25z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
<style type="text/css" scoped>
|
||||
.st1 {
|
||||
fill: #ffffff;
|
||||
}
|
||||
.st2 {
|
||||
fill-rule: evenodd;
|
||||
clip-rule: evenodd;
|
||||
fill: #141414;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -27,10 +27,10 @@
|
|||
v-on:close="showRecorder = false" v-on:file="onVoiceRecording" :sendTypingIndicators="useVoiceMode" />
|
||||
|
||||
<FileDropLayout class="file-drop-root" v-if="useFileModeNonAdmin" :room="room"
|
||||
v-on:pick-file="showAttachmentPicker()"
|
||||
v-on:add-file="addAttachment($event)"
|
||||
v-on:pick-file="showAttachmentPicker(false)"
|
||||
v-on:add-files="(files) => addAttachments(files)"
|
||||
v-on:remove-file="currentFileInputs.splice($event, 1)"
|
||||
v-on:reset="resetAttachments"
|
||||
v-on:reset="v"
|
||||
:attachments="currentFileInputs"
|
||||
v-on:close="closeFileMode"
|
||||
/>
|
||||
|
|
@ -213,7 +213,7 @@
|
|||
|
||||
<v-col v-if="!$config.disableMediaSharing" class="input-area-button text-center flex-grow-0 flex-shrink-1">
|
||||
<label icon flat ref="attachmentLabel">
|
||||
<v-btn icon @click="showAttachmentPicker"
|
||||
<v-btn icon @click="() => showAttachmentPicker(true)"
|
||||
:disabled="attachButtonDisabled">
|
||||
<v-icon size="36">add_circle_outline</v-icon>
|
||||
</v-btn>
|
||||
|
|
@ -229,7 +229,7 @@
|
|||
<input ref="attachment" type="file" name="attachment" @change="handlePickedAttachment($event)"
|
||||
accept="image/*,audio/*,video/*,.mp3,.mp4,.wav,.m4a,.pdf,application/pdf,.apk,application/vnd.android.package-archive,.ipa,.zip,application/zip,application/x-zip-compressed,multipart/x-zip" class="d-none" multiple/>
|
||||
|
||||
<div v-if="currentFileInputsDialog && !useFileModeNonAdmin">
|
||||
<!-- <div v-if="currentFileInputsDialog && !useFileModeNonAdmin">
|
||||
<v-dialog v-model="currentFileInputsDialog" class="ma-0 pa-0" :width="$vuetify.display.smAndUp ? '50%' : '85%'" persistent scrollable>
|
||||
<v-card class="ma-0 pa-0">
|
||||
<v-card-text v-if="!currentFileInputs.length">
|
||||
|
|
@ -253,6 +253,7 @@
|
|||
<v-img v-if="currentImageInput && currentImageInput.image" :aspect-ratio="1" :src="currentImageInput.image"
|
||||
contain class="current-image-input-path" />
|
||||
<v-progress-linear :style="{ position: 'absolute', left: '0', right: '0', bottom: '0', opacity: currentImageInput.sendInfo ? '1' : '0' }" :value="currentImageInput.sendInfo ? currentImageInput.sendInfo.progress : 0"></v-progress-linear>
|
||||
<C2PABadge :file="currentImageInput.actualFile" />
|
||||
</div>
|
||||
<div>
|
||||
<span v-if="currentImageInput && currentImageInput.scaled && currentImageInput.useScaled">
|
||||
|
|
@ -302,7 +303,17 @@
|
|||
</template>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<SendAttachmentsLayout
|
||||
v-if="currentFileInputs && currentFileInputs.length > 0 && !useFileModeNonAdmin"
|
||||
:room="room"
|
||||
v-on:pick-file="showAttachmentPicker(false)"
|
||||
v-on:add-files="(files) => addAttachments(files)"
|
||||
v-on:remove-file="(index) => removeAttachment(index)"
|
||||
:attachments="currentFileInputs"
|
||||
v-on:close="resetAttachments"
|
||||
/>
|
||||
|
||||
<MessageOperationsBottomSheet ref="messageOperationsSheet">
|
||||
<EmojiPicker ref="emojiPicker"
|
||||
|
|
@ -385,9 +396,10 @@ import BottomSheet from "./BottomSheet.vue";
|
|||
import imageResize from "image-resize";
|
||||
import CreatePollDialog from "./CreatePollDialog.vue";
|
||||
import chatMixin, { ROOM_READ_MARKER_EVENT_PLACEHOLDER } from "./chatMixin";
|
||||
import sendAttachmentsMixin from "./sendAttachmentsMixin";
|
||||
import sendAttachmentsMixin from "./sendAttachmentsMixin.ts";
|
||||
import AudioLayout from "./AudioLayout.vue";
|
||||
import FileDropLayout from "./file_mode/FileDropLayout";
|
||||
import SendAttachmentsLayout from "./file_mode/SendAttachmentsLayout.vue";
|
||||
import roomTypeMixin from "./roomTypeMixin";
|
||||
import roomMembersMixin from "./roomMembersMixin";
|
||||
import PurgeRoomDialog from "../components/PurgeRoomDialog";
|
||||
|
|
@ -401,6 +413,8 @@ import 'vue3-emoji-picker/css';
|
|||
import emitter from 'tiny-emitter/instance';
|
||||
import { markRaw } from "vue";
|
||||
import timerIcon from '@/assets/icons/ic_timer.svg';
|
||||
import proofmode from "../plugins/proofmode.js";
|
||||
import C2PABadge from "./c2pa/C2PABadge.vue";
|
||||
|
||||
const READ_RECEIPT_TIMEOUT = 5000; /* How long a message must have been visible before the read marker is updated */
|
||||
const WINDOW_BUFFER_SIZE = 0.3; /** Relative window height of when we start paginating. Always keep this much loaded before and after our scroll position! */
|
||||
|
|
@ -448,13 +462,15 @@ export default {
|
|||
CreatePollDialog,
|
||||
AudioLayout,
|
||||
FileDropLayout,
|
||||
SendAttachmentsLayout,
|
||||
UserProfileDialog,
|
||||
PurgeRoomDialog,
|
||||
WelcomeHeaderChannelUser,
|
||||
MessageErrorHandler,
|
||||
MessageOperationsChannel,
|
||||
RoomExport,
|
||||
EmojiPicker
|
||||
EmojiPicker,
|
||||
C2PABadge
|
||||
},
|
||||
|
||||
data() {
|
||||
|
|
@ -473,7 +489,7 @@ export default {
|
|||
timelineWindowPaginating: false,
|
||||
|
||||
scrollPosition: null,
|
||||
currentFileInputs: null,
|
||||
currentFileInputs: [],
|
||||
currentSendShowSendButton: true,
|
||||
currentSendError: null,
|
||||
currentSendErrorExceededFile: null,
|
||||
|
|
@ -619,7 +635,7 @@ export default {
|
|||
return this.isCurrentFileInputsAnArray
|
||||
},
|
||||
set() {
|
||||
this.currentFileInputs = null
|
||||
this.currentFileInputs = [];
|
||||
}
|
||||
},
|
||||
chatContainer() {
|
||||
|
|
@ -1470,97 +1486,65 @@ export default {
|
|||
/**
|
||||
* Show attachment picker to select file
|
||||
*/
|
||||
showAttachmentPicker() {
|
||||
showAttachmentPicker(reset) {
|
||||
if (reset) {
|
||||
this.resetAttachments();
|
||||
}
|
||||
this.$refs.attachment.click();
|
||||
},
|
||||
|
||||
optimizeImage(evt,file) {
|
||||
let fileObj = {}
|
||||
fileObj.image = evt.target.result;
|
||||
fileObj.dimensions = null;
|
||||
fileObj.type = file.type;
|
||||
fileObj.actualSize = file.size;
|
||||
fileObj.actualFile = file
|
||||
try {
|
||||
const buffer = Uint8Array.from(window.atob(evt.target.result.replace(/^data[^,]+,/,'')), v => v.charCodeAt(0));
|
||||
fileObj.dimensions = imageSize(buffer);
|
||||
|
||||
// Need to resize?
|
||||
const w = fileObj.dimensions.width;
|
||||
const h = fileObj.dimensions.height;
|
||||
if (w > 640 || h > 640) {
|
||||
var aspect = w / h;
|
||||
var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed());
|
||||
var newHeight = parseInt((w > h ? 640 / aspect : 640).toFixed());
|
||||
imageResize(evt.target.result, {
|
||||
format: "png",
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
outputType: "blob",
|
||||
})
|
||||
.then((img) => {
|
||||
fileObj["scaled"] =
|
||||
new File([img], file.name, {
|
||||
type: img.type,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
fileObj["useScaled"] = true;
|
||||
fileObj["scaledSize"] = img.size;
|
||||
fileObj["scaledDimensions"] = {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
};
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Resize failed:", err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get image dimensions: " + error);
|
||||
}
|
||||
return fileObj
|
||||
},
|
||||
handleFileReader(file) {
|
||||
async addAttachment(file) {
|
||||
if (file) {
|
||||
let optimizedFileObj;
|
||||
var reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
if (file.type.startsWith("image/")) {
|
||||
optimizedFileObj = this.optimizeImage(evt, file)
|
||||
} else {
|
||||
optimizedFileObj = file
|
||||
}
|
||||
this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, optimizedFileObj] : [optimizedFileObj];
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
this.currentFileInputs = [... this.currentFileInputs, this.$matrix.attachmentManager.createAttachment(file)];
|
||||
// let optimizedFileObj;
|
||||
// if (file.type.startsWith("image/")) {
|
||||
// const f = await proofmode.proofCheckFile(file);
|
||||
|
||||
// var reader = new FileReader();
|
||||
// optimizedFileObj = await new Promise(resolve => {
|
||||
// reader.onload = evt => {
|
||||
// resolve(this.optimizeImage(evt, file));
|
||||
// }
|
||||
// reader.readAsDataURL(f);
|
||||
// })
|
||||
// } else {
|
||||
// optimizedFileObj = file;
|
||||
// }
|
||||
// console.error("OPTIMIZED", optimizedFileObj);
|
||||
// this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, optimizedFileObj] : [optimizedFileObj];
|
||||
}
|
||||
},
|
||||
|
||||
removeAttachment(index) {
|
||||
this.currentFileInputs = this.currentFileInputs.toSpliced(index, 1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle picked attachment
|
||||
*/
|
||||
handlePickedAttachment(event) {
|
||||
this.currentFileInputs = []
|
||||
const uploadedFiles = Object.values(event.target.files);
|
||||
this.addAttachments(Object.values(event.target.files));
|
||||
},
|
||||
|
||||
this.$matrix.matrixClient.getMediaConfig().then((config) => {
|
||||
const configUploadSize = config["m.upload.size"];
|
||||
const configFormattedUploadSize = this.formatBytes(configUploadSize);
|
||||
|
||||
uploadedFiles.every(file => {
|
||||
if (configUploadSize && file.size > configUploadSize) {
|
||||
this.currentSendError = this.$t("message.upload_file_too_large");
|
||||
this.currentSendErrorExceededFile = this.$t("message.upload_exceeded_file_limit", { configFormattedUploadSize });
|
||||
this.currentSendShowSendButton = false;
|
||||
return false;
|
||||
} else {
|
||||
this.currentSendShowSendButton = true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
uploadedFiles.forEach(file => this.handleFileReader(file));
|
||||
addAttachments(files) {
|
||||
// TODO - refactor
|
||||
this.$matrix.matrixClient.getMediaConfig(this.$matrix.useAuthedMedia).then((config) => {
|
||||
const configUploadSize = config["m.upload.size"];
|
||||
const configFormattedUploadSize = this.formatBytes(configUploadSize);
|
||||
|
||||
files.every(file => {
|
||||
if (configUploadSize && file.size > configUploadSize) {
|
||||
this.currentSendError = this.$t("message.upload_file_too_large");
|
||||
this.currentSendErrorExceededFile = this.$t("message.upload_exceeded_file_limit", { configFormattedUploadSize });
|
||||
this.currentSendShowSendButton = false;
|
||||
return false;
|
||||
} else {
|
||||
this.currentSendShowSendButton = true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
files.forEach(file => this.addAttachment(file));
|
||||
});
|
||||
},
|
||||
|
||||
showStickerPicker() {
|
||||
|
|
@ -1574,9 +1558,9 @@ export default {
|
|||
const promise = this.sendAttachments(text, this.currentFileInputs);
|
||||
promise.then(() => {
|
||||
this.sendingAttachments = [];
|
||||
this.currentFileInputs = null;
|
||||
this.currentFileInputs = [];
|
||||
this.attachmentCaption = undefined;
|
||||
this.sendingStatus = this.sendStatuses.INITIAL;
|
||||
this.sendingStatus = "initial"
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "AbortError" || err === "Abort") {
|
||||
|
|
@ -1592,18 +1576,14 @@ export default {
|
|||
|
||||
cancelSendAttachment() {
|
||||
this.$refs.attachment.value = null;
|
||||
if (this.sendingStatus != this.sendStatuses.INITIAL) {
|
||||
if (this.sendingStatus != "initial") {
|
||||
this.cancelSendAttachments();
|
||||
}
|
||||
this.currentFileInputs = null;
|
||||
this.currentFileInputs = [];
|
||||
this.attachmentCaption = undefined;
|
||||
this.currentSendError = null;
|
||||
this.currentSendErrorExceededFile = null;
|
||||
this.sendingStatus = this.sendStatuses.INITIAL;
|
||||
},
|
||||
|
||||
addAttachment(file) {
|
||||
this.handleFileReader(null, file);
|
||||
this.sendingStatus = "initial";
|
||||
},
|
||||
|
||||
resetAttachments() {
|
||||
|
|
@ -2044,6 +2024,7 @@ export default {
|
|||
|
||||
onVoiceRecording(event) {
|
||||
this.currentSendShowSendButton = false;
|
||||
// TODO - refactor
|
||||
this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, event.file] : [event.file];
|
||||
var text = undefined;
|
||||
if (this.currentInput && this.currentInput.length > 0) {
|
||||
|
|
|
|||
44
src/components/ImageWithProgress.vue
Normal file
44
src/components/ImageWithProgress.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<v-img class="image-with-progress" v-bind="{...$props, ...$attrs}">
|
||||
<LoadProgress class="image-with-progress__progress" v-if="loadingProgress >= 0 && loadingProgress < 100" :percentage="loadingProgress" />
|
||||
</v-img>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import User from "../models/user";
|
||||
import util from "../plugins/utils";
|
||||
import rememberMeMixin from "./rememberMeMixin";
|
||||
import * as sdk from "matrix-js-sdk";
|
||||
import logoMixin from "./logoMixin";
|
||||
import LoadProgress
|
||||
from "./LoadProgress.vue";
|
||||
export default {
|
||||
name: "ImageWithProgress",
|
||||
components: { LoadProgress },
|
||||
props: {
|
||||
loadingProgress: {
|
||||
type: Number,
|
||||
default: function () {
|
||||
return -1;
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.image-with-progress {
|
||||
position: relative;
|
||||
.image-with-progress__progress {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
color: white;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
28
src/components/LoadProgress.vue
Normal file
28
src/components/LoadProgress.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<v-progress-circular :rotate="360" :width="3" :model-value="percentage" color="white" class="ma-2">
|
||||
{{ percentage }}
|
||||
</v-progress-circular>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import User from "../models/user";
|
||||
import util from "../plugins/utils";
|
||||
import rememberMeMixin from "./rememberMeMixin";
|
||||
import * as sdk from "matrix-js-sdk";
|
||||
import logoMixin from "./logoMixin";
|
||||
|
||||
export default {
|
||||
name: "LoadProgress",
|
||||
props: {
|
||||
percentage: {
|
||||
type: Number,
|
||||
default: function () {
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
48
src/components/c2pa/C2PABadge.vue
Normal file
48
src/components/c2pa/C2PABadge.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div v-if="show" class="c2pa-badge">
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon v-bind="props" @click.stop="">$vuetify.icons.ic_cr</v-icon>
|
||||
</template>
|
||||
<span>This image contains C2PA data</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "C2PABadge",
|
||||
emits: [],
|
||||
props: {
|
||||
proof: {
|
||||
type: Object as PropType<{
|
||||
name?: string;
|
||||
json?: string;
|
||||
integrity?: { pgp?: any; c2pa?: any; exif?: any; opentimestamps?: any };
|
||||
}>,
|
||||
default: function () {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
show() {
|
||||
console.log("PROOFCHEKDATA", this.proof);
|
||||
if (this.proof) {
|
||||
const {
|
||||
name,
|
||||
json,
|
||||
integrity: { pgp, c2pa, exif, opentimestamps },
|
||||
} = this.proof;
|
||||
return c2pa !== undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@/assets/css/chat.scss" as *;
|
||||
</style>
|
||||
|
|
@ -61,7 +61,7 @@ import roomTypeMixin from "./roomTypeMixin";
|
|||
export const ROOM_READ_MARKER_EVENT_PLACEHOLDER = { getId: () => "ROOM_READ_MARKER", getTs: () => Date.now() };
|
||||
|
||||
export default {
|
||||
mixins: [ roomDisplayOptionsMixin, roomTypeMixin ],
|
||||
mixins: [roomDisplayOptionsMixin, roomTypeMixin],
|
||||
components: {
|
||||
ChatHeader,
|
||||
MessageIncomingText,
|
||||
|
|
@ -105,7 +105,7 @@ export default {
|
|||
StickerPickerBottomSheet,
|
||||
BottomSheet,
|
||||
CreatePollDialog,
|
||||
ReadMarker
|
||||
ReadMarker,
|
||||
},
|
||||
computed: {
|
||||
debugging() {
|
||||
|
|
@ -132,7 +132,7 @@ export default {
|
|||
},
|
||||
|
||||
dateForEvent(event) {
|
||||
return util.formatDay(event.getTs());
|
||||
return util.formatDay(event.getTs());
|
||||
},
|
||||
|
||||
componentForEvent(event, isForExport = false) {
|
||||
|
|
@ -180,18 +180,18 @@ export default {
|
|||
|
||||
case "m.room.message":
|
||||
if (event.getSender() != this.$matrix.currentUserId) {
|
||||
if (event.isRedacted()) {
|
||||
// Redacted thread, show as text (and hide all media)!
|
||||
if (event.getUnsigned().redacted_because.content.reason == "redactedThread") {
|
||||
return MessageIncomingText;
|
||||
}
|
||||
return null;
|
||||
if (event.isRedacted()) {
|
||||
// Redacted thread, show as text (and hide all media)!
|
||||
if (event.getUnsigned().redacted_because.content.reason == "redactedThread") {
|
||||
return MessageIncomingText;
|
||||
}
|
||||
if (event.isMxThread) {
|
||||
// Incoming thread, e.g. a file drop!
|
||||
return isForExport ? MessageIncomingThreadExport : MessageIncomingThread;
|
||||
}
|
||||
if (event.getContent().msgtype == "m.image") {
|
||||
return null;
|
||||
}
|
||||
if (event.isMxThread) {
|
||||
// Incoming thread, e.g. a file drop!
|
||||
return isForExport ? MessageIncomingThreadExport : MessageIncomingThread;
|
||||
}
|
||||
if (event.getContent().msgtype == "m.image") {
|
||||
// For SVG, make downloadable
|
||||
if (
|
||||
event.getContent().info &&
|
||||
|
|
@ -340,19 +340,27 @@ export default {
|
|||
return MessageOutgoingPoll;
|
||||
}
|
||||
|
||||
case STATE_EVENT_ROOM_DELETION_NOTICE: {
|
||||
// Custom event for notice 30 seconds before a room is deleted/purged.
|
||||
const deletionNotices = this.room.currentState.getStateEvents(STATE_EVENT_ROOM_DELETION_NOTICE);
|
||||
if (deletionNotices && deletionNotices.length > 0 && deletionNotices[deletionNotices.length - 1] == event) {
|
||||
// This is the latest/last one. Look at the status flag. Show nothing if it is "cancel".
|
||||
if (event.getContent().status != "cancel") {
|
||||
return RoomDeletionNotice;
|
||||
case STATE_EVENT_ROOM_DELETION_NOTICE:
|
||||
{
|
||||
// Custom event for notice 30 seconds before a room is deleted/purged.
|
||||
const deletionNotices = this.room.currentState.getStateEvents(STATE_EVENT_ROOM_DELETION_NOTICE);
|
||||
if (deletionNotices && deletionNotices.length > 0 && deletionNotices[deletionNotices.length - 1] == event) {
|
||||
// This is the latest/last one. Look at the status flag. Show nothing if it is "cancel".
|
||||
if (event.getContent().status != "cancel") {
|
||||
return RoomDeletionNotice;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "m.room.encrypted":
|
||||
return event.getSender() != this.$matrix.currentUserId ? MessageIncomingText : MessageOutgoingText
|
||||
if (event.isRedacted()) {
|
||||
// Redacted thread, show as text (and hide all media)!
|
||||
if (event.getUnsigned().redacted_because.content.reason == "redactedThread") {
|
||||
return MessageOutgoingText;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return event.getSender() != this.$matrix.currentUserId ? MessageIncomingText : MessageOutgoingText;
|
||||
}
|
||||
return this.debugging ? DebugEvent : null;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="file-drop-input-container">
|
||||
<v-textarea ref="input" full-width variant="solo" flat auto-grow v-model="messageInput" no-resize class="input-area-text"
|
||||
<v-textarea theme="dark" ref="input" full-width variant="solo" flat auto-grow v-model="messageInput" no-resize class="input-area-text"
|
||||
rows="1" :placeholder="$t('file_mode.add_a_message')" hide-details background-color="transparent"
|
||||
v-on:keydown.enter.prevent="() => {
|
||||
sendCurrentTextMessage();
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
<div class="file-drop-sending-item" v-for="(info, index) in attachmentsSending" :key="index">
|
||||
<v-img v-if="info.preview" :src="info.preview" />
|
||||
<div v-else class="filename">{{ info.attachment.name }}</div>
|
||||
<v-progress-linear :value="info.progress"></v-progress-linear>
|
||||
<v-progress-linear :model-value="info.progress"></v-progress-linear>
|
||||
<div class="file-drop-cancel clickable" @click.stop="cancelSendAttachmentItem(info)">
|
||||
<v-icon size="14" color="white">close</v-icon>
|
||||
</div>
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
|
||||
<script>
|
||||
import messageMixin from "../messages/messageMixin";
|
||||
import sendAttachmentsMixin from "../sendAttachmentsMixin";
|
||||
import sendAttachmentsMixin from "../sendAttachmentsMixin.ts";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
|
||||
export default {
|
||||
|
|
@ -163,9 +163,7 @@ export default {
|
|||
this.dropTarget = false;
|
||||
let droppedFiles = e.dataTransfer.files;
|
||||
if (!droppedFiles) return;
|
||||
([...droppedFiles]).forEach(f => {
|
||||
this.$emit('add-file', f);
|
||||
});
|
||||
this.$emit('add-files', [...droppedFiles]);
|
||||
},
|
||||
scrollToBottom() {
|
||||
const el = this.$refs.attachmentWrapper;
|
||||
|
|
|
|||
322
src/components/file_mode/SendAttachmentsLayout.vue
Normal file
322
src/components/file_mode/SendAttachmentsLayout.vue
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
<template>
|
||||
<div v-bind="{ ...$attrs }" class="send-attachments">
|
||||
<div class="send-attachments__title">{{ $t("message.send_attachements_dialog_title") }}</div>
|
||||
|
||||
<!-- ATTACHMENT SELECTION MODE -->
|
||||
<template v-if="attachments && attachments.length > 0 && status == mainStatuses.SELECTING">
|
||||
<div class="attachment-wrapper" ref="attachmentWrapper" v-if="currentAttachment">
|
||||
<div
|
||||
:class="{ 'file-drop-current-item': true, 'drop-target': dropTarget }"
|
||||
@drop.prevent="filesDropped"
|
||||
@dragover.prevent="dropTarget = true"
|
||||
@dragleave.prevent="dropTarget = false"
|
||||
@dragenter.prevent="dropTarget = true"
|
||||
>
|
||||
<v-img v-if="currentAttachment.src && currentAttachment.status === 'loaded'" :src="currentAttachment.src" />
|
||||
<div v-else class="filename">
|
||||
<div>{{ currentAttachment.file.name }}</div>
|
||||
<div v-if="currentAttachment.status === 'loading'" style="font-size: 0.7em; opacity: 0.7">
|
||||
{{ $t("message.preparing_to_upload") }}
|
||||
<v-progress-linear indeterminate class="mb-0"></v-progress-linear>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="send-attachments__current-item__info">
|
||||
<div class="send-attachments__current-item__info__size">
|
||||
<span
|
||||
v-if="currentAttachment.scaledFile && currentAttachment.useScaled && currentAttachment.scaledDimensions"
|
||||
>
|
||||
{{ currentAttachment.scaledDimensions.width }} x {{ currentAttachment.scaledDimensions.height }}</span
|
||||
>
|
||||
<span v-else-if="currentAttachment.dimensions">
|
||||
{{ currentAttachment.dimensions.width }} x {{ currentAttachment.dimensions.height }}
|
||||
</span>
|
||||
|
||||
<span v-if="currentAttachment.scaledFile && currentAttachment.useScaled">
|
||||
({{ formatBytes(currentAttachment.scaledFile.size) }})
|
||||
</span>
|
||||
<span v-else> ({{ formatBytes(currentAttachment.file.size) }}) </span>
|
||||
|
||||
<span class="send-attachments__current-item__info__size__filename" v-if="currentAttachment.src && currentAttachment.file.name">
|
||||
- {{ currentAttachment.file.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<v-switch
|
||||
v-if="currentAttachment.scaledFile"
|
||||
:label="$t('message.scale_image')"
|
||||
v-model="currentAttachment.useScaled"
|
||||
:disabled="currentAttachment.sendInfo !== undefined"
|
||||
/>
|
||||
|
||||
<C2PABadge :proof="currentAttachment.proof" />
|
||||
</div>
|
||||
|
||||
<div class="file-drop-thumbnail-container">
|
||||
<div
|
||||
:class="{ 'file-drop-thumbnail': true, clickable: true, current: id == currentItemIndex }"
|
||||
@click="currentItemIndex = id"
|
||||
v-for="(currentImageInput, id) in attachments"
|
||||
:key="id"
|
||||
>
|
||||
<v-img v-if="currentImageInput && currentImageInput.src" :src="currentImageInput.src" />
|
||||
<div v-if="currentItemIndex == id" class="remove clickable" @click.stop="$emit('remove-file', id)">
|
||||
<v-icon>$vuetify.icons.ic_trash</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-drop-thumbnail noborder">
|
||||
<div class="add clickable" @click.stop="$emit('pick-file')">+</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-drop-input-container">
|
||||
<v-textarea
|
||||
ref="input"
|
||||
full-width
|
||||
variant="solo"
|
||||
flat
|
||||
auto-grow
|
||||
v-model="messageInput"
|
||||
no-resize
|
||||
class="input-area-text"
|
||||
rows="1"
|
||||
:placeholder="$t('file_mode.add_a_message')"
|
||||
hide-details
|
||||
background-color="transparent"
|
||||
v-on:keydown.enter.prevent="
|
||||
() => {
|
||||
sendAll();
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="input-container__buttons">
|
||||
<v-btn @click="close">{{ $t("menu.cancel") }}</v-btn>
|
||||
<v-btn @click="sendAll" :disabled="!attachments || attachments.length == 0">{{ $t("menu.send") }}</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ATTACHMENT SENDING/SENT MODE -->
|
||||
<template
|
||||
v-if="attachments && attachments.length > 0 && (status == mainStatuses.SENDING || status == mainStatuses.SENT)"
|
||||
>
|
||||
<div class="attachment-wrapper">
|
||||
<div class="file-drop-sent-stack" ref="stackContainer">
|
||||
<div v-if="status == mainStatuses.SENDING && attachmentsSentCount == 0" class="no-items">
|
||||
<div class="file-drop-stack-item direct" :style="stackItemTransform(null, -1)"></div>
|
||||
<div>{{ $t("file_mode.sending_progress") }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="(info, index) in attachmentsSent"
|
||||
:key="info.file.name"
|
||||
class="file-drop-stack-item animated"
|
||||
:style="stackItemTransform(info, index)"
|
||||
>
|
||||
<v-img v-if="info.src" :src="info.src" />
|
||||
</div>
|
||||
<div v-if="status == mainStatuses.SENT" class="items-sent" :style="stackItemTransform(null, -1)">
|
||||
<v-icon>$vuetify.icons.ic_check_circle</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middle section -->
|
||||
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-container">
|
||||
<div class="file-drop-sending-item" v-for="(attachment) in attachmentsSending" :key="attachment.file.name">
|
||||
<v-img v-if="attachment.src" :src="attachment.src" />
|
||||
<div v-else class="filename">{{ attachment.file.name }}</div>
|
||||
<v-progress-linear :model-value="attachment.sendInfo?.progress ?? 0"></v-progress-linear>
|
||||
<div class="file-drop-cancel clickable" @click.stop="cancelSendAttachmentItem(attachment)">
|
||||
<v-icon size="14" color="white">close</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="status == mainStatuses.SENT" class="file-drop-sending-container">
|
||||
<div class="file-drop-files-sent">
|
||||
{{
|
||||
$t(
|
||||
messageInput && messageInput.length > 0
|
||||
? "file_mode.files_sent_with_note"
|
||||
: "file_mode.files_sent",
|
||||
attachmentsSent.length
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="file-drop-section">
|
||||
<v-textarea
|
||||
disabled
|
||||
full-width
|
||||
variant="solo"
|
||||
flat
|
||||
auto-grow
|
||||
v-model="messageInput"
|
||||
no-resize
|
||||
class="input-area-text"
|
||||
rows="1"
|
||||
hide-details
|
||||
background-color="transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom section -->
|
||||
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-input-container">
|
||||
<v-textarea
|
||||
disabled
|
||||
full-width
|
||||
variant="solo"
|
||||
flat
|
||||
auto-grow
|
||||
v-model="messageInput"
|
||||
no-resize
|
||||
class="input-area-text"
|
||||
rows="1"
|
||||
:placeholder="$t('file_mode.add_a_message')"
|
||||
hide-details
|
||||
background-color="transparent"
|
||||
/>
|
||||
<v-btn
|
||||
>{{ $t("file_mode.sending")
|
||||
}}<v-progress-circular indeterminate size="18" width="2" color="#4642F1"></v-progress-circular
|
||||
></v-btn>
|
||||
</div>
|
||||
<div v-else-if="status == mainStatuses.SENT" class="file-drop-sent-input-container">
|
||||
<v-btn class="close" @click.stop="close">{{ $t("file_mode.close") }}</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import messageMixin from "../messages/messageMixin";
|
||||
import sendAttachmentsMixin from "../sendAttachmentsMixin";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import type { PropType } from "vue";
|
||||
import { Attachment } from "../../models/attachment";
|
||||
import C2PABadge from "../c2pa/C2PABadge.vue";
|
||||
|
||||
export default defineComponent({
|
||||
mixins: [messageMixin, sendAttachmentsMixin],
|
||||
components: { C2PABadge },
|
||||
emits: ["add-files", "remove-file", "pick-file", "close"],
|
||||
props: {
|
||||
attachments: {
|
||||
type: Array as PropType<Attachment[]>,
|
||||
default: function () {
|
||||
return [] as Attachment[];
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentItemIndex: 0,
|
||||
messageInput: "",
|
||||
mainStatuses: Object.freeze({
|
||||
SELECTING: 0,
|
||||
SENDING: 1,
|
||||
SENT: 2,
|
||||
}),
|
||||
status: 0,
|
||||
dropTarget: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$audioPlayer.setAutoplay(false);
|
||||
},
|
||||
computed: {
|
||||
currentAttachment(): Attachment {
|
||||
return this.attachments[this.currentItemIndex];
|
||||
},
|
||||
currentItemHasImagePreview() {
|
||||
return (
|
||||
this.currentItemIndex >= 0 &&
|
||||
this.currentItemIndex < this.attachments.length &&
|
||||
this.attachments[this.currentItemIndex].src
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
attachments(newValue, oldValue) {
|
||||
// Added or removed?
|
||||
if (newValue && oldValue && newValue.length > oldValue.length) {
|
||||
this.currentItemIndex = oldValue.length;
|
||||
} else if (newValue) {
|
||||
this.currentItemIndex = newValue.length - 1;
|
||||
}
|
||||
},
|
||||
messageInput() {
|
||||
this.scrollToBottom();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
filesDropped(e: DragEvent) {
|
||||
this.dropTarget = false;
|
||||
let droppedFiles: FileList | undefined = e.dataTransfer?.files;
|
||||
if (!droppedFiles) return;
|
||||
this.$emit("add-files", [... droppedFiles]);
|
||||
},
|
||||
scrollToBottom() {
|
||||
const el = this.$refs.attachmentWrapper;
|
||||
if (el) {
|
||||
// Ugly - need to wait until input is auto-sized, THEN scroll to bottom.
|
||||
//
|
||||
this.$nextTick(() => {
|
||||
this.$nextTick(() => {
|
||||
this.$nextTick(() => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
formatBytes(bytes: number) {
|
||||
return prettyBytes(bytes);
|
||||
},
|
||||
close() {
|
||||
this.sendingAttachments = [];
|
||||
this.status = this.mainStatuses.SELECTING;
|
||||
this.messageInput = "";
|
||||
this.currentItemIndex = 0;
|
||||
this.$emit("close");
|
||||
},
|
||||
sendAll() {
|
||||
this.status = this.mainStatuses.SENDING;
|
||||
this.sendAttachments(
|
||||
this.messageInput && this.messageInput.length > 0 ? this.messageInput : this.$t("file_mode.files"),
|
||||
this.attachments
|
||||
).then(() => {
|
||||
this.status = this.mainStatuses.SENT;
|
||||
});
|
||||
},
|
||||
stackItemTransform(item, index) {
|
||||
const size =
|
||||
0.6 *
|
||||
(this.$refs.stackContainer
|
||||
? Math.min(this.$refs.stackContainer.clientWidth, this.$refs.stackContainer.clientHeight)
|
||||
: 176);
|
||||
let transform = "";
|
||||
if (item != null && index != -1) {
|
||||
transform =
|
||||
"transform: rotate(" +
|
||||
item.randomRotation +
|
||||
"deg) translate(" +
|
||||
item.randomTranslationX +
|
||||
"px," +
|
||||
item.randomTranslationY +
|
||||
"px); z-index:" +
|
||||
(index + 2) +
|
||||
";";
|
||||
}
|
||||
return transform + "width:" + size + "px;height:" + size + "px;border-radius:" + size / 8 + "px";
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@/assets/css/chat.scss" as *;
|
||||
@use "@/assets/css/sendattachments.scss" as *;
|
||||
</style>
|
||||
|
|
@ -1,36 +1,38 @@
|
|||
<template>
|
||||
<message-incoming v-bind="{...$props, ...$attrs}">
|
||||
<message-incoming v-bind="{ ...$props, ...$attrs }">
|
||||
<div class="bubble image-bubble" ref="imageRef">
|
||||
<v-img
|
||||
<ImageWithProgress
|
||||
:aspect-ratio="16 / 9"
|
||||
ref="image"
|
||||
:src="src"
|
||||
:src="src ? src : thumbnailSrc"
|
||||
:cover="cover"
|
||||
:contain="contain"
|
||||
:loadingProgress="thumbnailProgress"
|
||||
/>
|
||||
</div>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
:width="$vuetify.display.smAndUp ? '940px' : '90%'"
|
||||
>
|
||||
<v-img :src="src"/>
|
||||
<v-dialog v-model="dialog" :width="$vuetify.display.smAndUp ? '940px' : '90%'">
|
||||
<ImageWithProgress :src="src ? src : thumbnailSrc" :loadingProgress="srcProgress" />
|
||||
</v-dialog>
|
||||
</message-incoming>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import util from "../../plugins/utils";
|
||||
import MessageIncoming from './MessageIncoming.vue';
|
||||
import ImageWithProgress from "../ImageWithProgress.vue";
|
||||
import MessageIncoming from "./MessageIncoming.vue";
|
||||
|
||||
export default {
|
||||
extends: MessageIncoming,
|
||||
components: { MessageIncoming },
|
||||
components: { MessageIncoming, ImageWithProgress },
|
||||
data() {
|
||||
return {
|
||||
src: undefined,
|
||||
thumbnailSrc: undefined,
|
||||
srcProgress: -1,
|
||||
thumbnailProgress: -1,
|
||||
cover: true,
|
||||
contain: false,
|
||||
dialog: false
|
||||
dialog: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -39,48 +41,57 @@ export default {
|
|||
const hammerInstance = util.singleOrDoubleTabRecognizer(element);
|
||||
|
||||
hammerInstance.on("singletap doubletap", (ev) => {
|
||||
if(ev.type === 'singletap') {
|
||||
if (ev.type === "singletap") {
|
||||
this.$matrix.attachmentManager
|
||||
.loadEventAttachment(
|
||||
this.event,
|
||||
(percent) => {
|
||||
this.srcProgress = percent;
|
||||
},
|
||||
this
|
||||
)
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch attachment: ", err);
|
||||
});
|
||||
this.dialog = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
//console.log("Mounted with event:", JSON.stringify(this.event.getContent()));
|
||||
const width = this.$refs.image.$el.clientWidth;
|
||||
const height = (width * 9) / 16;
|
||||
util
|
||||
.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, this.event, this.$config, width, height)
|
||||
.then((url) => {
|
||||
const info = this.event.getContent().info;
|
||||
// JPEGs use cover, PNG and GIF ect contain. This is because PNG and GIF are expected to
|
||||
// be stickers and small emoji type things.
|
||||
if (info && info.mimetype && info.mimetype.startsWith("image/jp")) {
|
||||
this.cover = true;
|
||||
this.contain = false;
|
||||
} else {
|
||||
this.cover = false;
|
||||
this.contain = true;
|
||||
}
|
||||
this.src = url;
|
||||
if(this.$refs.imageRef) {
|
||||
this.initMessageInImageHammerJs(this.$refs.imageRef);
|
||||
}
|
||||
})
|
||||
const info = this.event.getContent().info;
|
||||
// JPEGs use cover, PNG and GIF ect contain. This is because PNG and GIF are expected to
|
||||
// be stickers and small emoji type things.
|
||||
if (info && info.mimetype && info.mimetype.startsWith("image/jp")) {
|
||||
this.cover = true;
|
||||
this.contain = false;
|
||||
} else {
|
||||
this.cover = false;
|
||||
this.contain = true;
|
||||
}
|
||||
if (this.$refs.imageRef) {
|
||||
this.initMessageInImageHammerJs(this.$refs.imageRef);
|
||||
}
|
||||
|
||||
this.$matrix.attachmentManager
|
||||
.loadEventThumbnail(
|
||||
this.event,
|
||||
(percent) => {
|
||||
this.thumbnailProgress = percent;
|
||||
},
|
||||
this
|
||||
)
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch thumbnail: ", err);
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.src) {
|
||||
const objectUrl = this.src;
|
||||
this.src = null;
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
this.$matrix.attachmentManager.releaseEvent(this.event);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@/assets/css/chat.scss" as *;
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,35 +3,48 @@
|
|||
<div class="bubble">
|
||||
<div class="original-message" v-if="inReplyToText">
|
||||
<div class="original-message-sender">{{ inReplyToSender }}</div>
|
||||
<div
|
||||
class="original-message-text"
|
||||
v-html="linkify($sanitize(inReplyToText))"
|
||||
/>
|
||||
<div class="original-message-text" v-html="linkify($sanitize(inReplyToText))" />
|
||||
</div>
|
||||
|
||||
<div class="message">
|
||||
<SwipeableThumbnailsView :items="items" v-if="!event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL" v-bind="$attrs" />
|
||||
<SwipeableThumbnailsView
|
||||
:items="items"
|
||||
v-if="!event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<v-container v-else-if="!event.isRedacted()" fluid class="imageCollection">
|
||||
<v-row wrap>
|
||||
<v-col v-for="({ size, item }) in layoutedItems()" :key="item.event.getId()" :cols="size">
|
||||
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
|
||||
<v-col v-for="{ size, item } in layoutedItems()" :key="item.event.getId()" :cols="size">
|
||||
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
|
||||
</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>
|
||||
{{ redactedBySomeoneElse(event) ? $t('message.incoming_message_deleted_text') : $t('message.outgoing_message_deleted_text')}}
|
||||
{{
|
||||
redactedBySomeoneElse(event)
|
||||
? $t("message.incoming_message_deleted_text")
|
||||
: $t("message.outgoing_message_deleted_text")
|
||||
}}
|
||||
</i>
|
||||
<span v-html="linkify($sanitize(messageText))" v-else-if="messageText" />
|
||||
<span class="edit-marker" v-if="event.replacingEventId() && !event.isRedacted()">
|
||||
{{ $t('message.edited') }}
|
||||
{{ $t("message.edited") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<GalleryItemsView :originalEvent="originalEvent" :items="items" :initialItem="showItem" v-if="!!showItem" v-on:close="showItem = null" />
|
||||
<GalleryItemsView
|
||||
:originalEvent="originalEvent"
|
||||
:items="items"
|
||||
:initialItem="showItem"
|
||||
v-if="!!showItem"
|
||||
v-on:close="showItem = null"
|
||||
/>
|
||||
</message-incoming>
|
||||
<component v-else-if="items.length == 1" :is="componentFn(items[0].event)"
|
||||
v-bind="{...$props, ...$attrs}"
|
||||
<component
|
||||
v-else-if="items.length == 1"
|
||||
:is="componentFn(items[0].event)"
|
||||
v-bind="{ ...$props, ...$attrs }"
|
||||
:originalEvent="items[0].event"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -40,24 +53,33 @@
|
|||
import MessageIncoming from "./MessageIncoming.vue";
|
||||
import messageMixin from "./messageMixin";
|
||||
import util, { ROOM_TYPE_CHANNEL, ROOM_TYPE_FILE_MODE } from "../../plugins/utils";
|
||||
import GalleryItemsView from '../file_mode/GalleryItemsView.vue';
|
||||
import ThumbnailView from '../file_mode/ThumbnailView.vue';
|
||||
import GalleryItemsView from "../file_mode/GalleryItemsView.vue";
|
||||
import ThumbnailView from "../file_mode/ThumbnailView.vue";
|
||||
import SwipeableThumbnailsView from "./channel/SwipeableThumbnailsView.vue";
|
||||
import { reactive } from "vue";
|
||||
|
||||
export default {
|
||||
extends: MessageIncoming,
|
||||
components: { MessageIncoming, GalleryItemsView, ThumbnailView, SwipeableThumbnailsView },
|
||||
components: {
|
||||
MessageIncoming,
|
||||
GalleryItemsView,
|
||||
ThumbnailView,
|
||||
SwipeableThumbnailsView,
|
||||
},
|
||||
mixins: [messageMixin],
|
||||
data() {
|
||||
return {
|
||||
ROOM_TYPE_CHANNEL: ROOM_TYPE_CHANNEL,
|
||||
items: [],
|
||||
showItem: null,
|
||||
}
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), util.threadMessageType(), "m.room.message");
|
||||
this.thread = this.timelineSet.relations.getChildEventsForEvent(
|
||||
this.event.getId(),
|
||||
util.threadMessageType(),
|
||||
"m.room.message"
|
||||
);
|
||||
if (!this.thread) {
|
||||
this.event.on("Event.relationsCreated", this.onRelationsCreated);
|
||||
}
|
||||
|
|
@ -67,42 +89,66 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
forceMultiview() {
|
||||
return this.room.displayType == ROOM_TYPE_FILE_MODE || (this.room.displayType == ROOM_TYPE_CHANNEL && this.items.length == 1 && util.isFileTypePDF(this.items[0].event));
|
||||
}
|
||||
return (
|
||||
this.room.displayType == ROOM_TYPE_FILE_MODE ||
|
||||
(this.room.displayType == ROOM_TYPE_CHANNEL &&
|
||||
this.items.length == 1 &&
|
||||
util.isFileTypePDF(this.items[0].event))
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onRelationsCreated() {
|
||||
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), util.threadMessageType(), "m.room.message");
|
||||
this.thread = this.timelineSet.relations.getChildEventsForEvent(
|
||||
this.event.getId(),
|
||||
util.threadMessageType(),
|
||||
"m.room.message"
|
||||
);
|
||||
this.event.off("Event.relationsCreated", this.onRelationsCreated);
|
||||
},
|
||||
onItemClick(event) {
|
||||
this.showItem = event.item;
|
||||
},
|
||||
processThread() {
|
||||
this.$emit('layout-change', () => {
|
||||
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
|
||||
.filter(e => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype))
|
||||
.map(e => {
|
||||
let ret = reactive({
|
||||
event: e,
|
||||
src: null,
|
||||
});
|
||||
ret.promise = this.$matrix.matrixClient.decryptEventIfNeeded(e)
|
||||
.then(() => util.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, e, this.$config, 100, 100))
|
||||
.then((url) => {
|
||||
ret.src = url;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch thumbnail: ", err);
|
||||
if (!this.event.isRedacted()) {
|
||||
this.$emit(
|
||||
"layout-change",
|
||||
() => {
|
||||
const items = this.timelineSet.relations
|
||||
.getAllChildEventsForEvent(this.event.getId())
|
||||
.filter((e) => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype));
|
||||
|
||||
this.items = items.map((e) => {
|
||||
let ret = reactive({
|
||||
event: e,
|
||||
src: null,
|
||||
});
|
||||
return ret;
|
||||
});
|
||||
}, this.$el);
|
||||
if (items.length > 1) {
|
||||
ret.promise = this.$matrix.matrixClient
|
||||
.decryptEventIfNeeded(e)
|
||||
.then(() =>
|
||||
util.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, e, this.$config, 100, 100)
|
||||
)
|
||||
.then((url) => {
|
||||
ret.src = url;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch thumbnail: ", err);
|
||||
});
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
},
|
||||
this.$el
|
||||
);
|
||||
}
|
||||
},
|
||||
layoutedItems() {
|
||||
if (!this.items || this.items.length == 0) { return [] }
|
||||
if (!this.items || this.items.length == 0) {
|
||||
return [];
|
||||
}
|
||||
let array = this.items.slice(0);
|
||||
let rows = []
|
||||
let rows = [];
|
||||
while (array.length > 0) {
|
||||
if (array.length >= 7) {
|
||||
rows.push({ size: 6, item: array[0] });
|
||||
|
|
@ -127,12 +173,12 @@ export default {
|
|||
array = array.slice(1);
|
||||
}
|
||||
}
|
||||
return rows
|
||||
return rows;
|
||||
},
|
||||
downloadAll() {
|
||||
this.items.forEach(item => util.download(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, item.event));
|
||||
}
|
||||
}
|
||||
this.items.forEach((item) => util.download(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, item.event));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -159,4 +205,4 @@ export default {
|
|||
padding: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,38 @@
|
|||
<template>
|
||||
<message-outgoing v-bind="{ ...$props, ...$attrs }">
|
||||
<div class="bubble image-bubble" ref="imageRef">
|
||||
<v-img
|
||||
<ImageWithProgress
|
||||
:aspect-ratio="16 / 9"
|
||||
ref="image"
|
||||
:src="src"
|
||||
:src="src ? src : thumbnailSrc"
|
||||
:cover="cover"
|
||||
:contain="contain"
|
||||
:loadingProgress="thumbnailProgress"
|
||||
/>
|
||||
</div>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
:width="$vuetify.display.smAndUp ? '940px' : '90%'"
|
||||
>
|
||||
<v-img :src="src"/>
|
||||
<v-dialog v-model="dialog" :width="$vuetify.display.smAndUp ? '940px' : '90%'">
|
||||
<ImageWithProgress :src="src ? src : thumbnailSrc" :loadingProgress="srcProgress" />
|
||||
</v-dialog>
|
||||
</message-outgoing>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import util from "../../plugins/utils";
|
||||
import ImageWithProgress from "../ImageWithProgress.vue";
|
||||
import MessageOutgoing from "./MessageOutgoing.vue";
|
||||
|
||||
export default {
|
||||
extends: MessageOutgoing,
|
||||
components: { MessageOutgoing },
|
||||
components: { MessageOutgoing, ImageWithProgress },
|
||||
data() {
|
||||
return {
|
||||
src: undefined,
|
||||
thumbnailSrc: undefined,
|
||||
srcProgress: -1,
|
||||
thumbnailProgress: -1,
|
||||
cover: true,
|
||||
contain: false,
|
||||
dialog: false
|
||||
dialog: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -39,47 +41,56 @@ export default {
|
|||
const hammerInstance = util.singleOrDoubleTabRecognizer(element);
|
||||
|
||||
hammerInstance.on("singletap doubletap", (ev) => {
|
||||
if(ev.type === 'singletap') {
|
||||
if (ev.type === "singletap") {
|
||||
this.$matrix.attachmentManager
|
||||
.loadEventAttachment(
|
||||
this.event,
|
||||
(percent) => {
|
||||
this.srcProgress = percent;
|
||||
},
|
||||
this
|
||||
)
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch attachment: ", err);
|
||||
});
|
||||
this.dialog = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const width = this.$refs.image.$el.clientWidth;
|
||||
const height = (width * 9) / 16;
|
||||
util
|
||||
.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, this.event, this.$config, width, height)
|
||||
.then((url) => {
|
||||
const info = this.event.getContent().info;
|
||||
// JPEGs use cover, PNG and GIF ect contain. This is because PNG and GIF are expected to
|
||||
// be stickers and small emoji type things.
|
||||
if (info && info.mimetype && info.mimetype.startsWith("image/jp")) {
|
||||
this.cover = true;
|
||||
this.contain = false;
|
||||
} else {
|
||||
this.cover = false;
|
||||
this.contain = true;
|
||||
}
|
||||
this.src = url;
|
||||
if(this.$refs.imageRef) {
|
||||
this.initMessageOutImageHammerJs(this.$refs.imageRef);
|
||||
}
|
||||
})
|
||||
const info = this.event.getContent().info;
|
||||
// JPEGs use cover, PNG and GIF ect contain. This is because PNG and GIF are expected to
|
||||
// be stickers and small emoji type things.
|
||||
if (info && info.mimetype && info.mimetype.startsWith("image/jp")) {
|
||||
this.cover = true;
|
||||
this.contain = false;
|
||||
} else {
|
||||
this.cover = false;
|
||||
this.contain = true;
|
||||
}
|
||||
if (this.$refs.imageRef) {
|
||||
this.initMessageOutImageHammerJs(this.$refs.imageRef);
|
||||
}
|
||||
|
||||
this.$matrix.attachmentManager
|
||||
.loadEventThumbnail(
|
||||
this.event,
|
||||
(percent) => {
|
||||
this.thumbnailProgress = percent;
|
||||
},
|
||||
this
|
||||
)
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch thumbnail: ", err);
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.src) {
|
||||
const objectUrl = this.src;
|
||||
this.src = null;
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
this.$matrix.attachmentManager.releaseEvent(this.event);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@/assets/css/chat.scss" as *;
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,46 +3,46 @@
|
|||
<div class="bubble">
|
||||
<div class="original-message" v-if="inReplyToText">
|
||||
<div class="original-message-sender">{{ inReplyToSender }}</div>
|
||||
<div
|
||||
class="original-message-text"
|
||||
v-html="linkify($sanitize(inReplyToText))"
|
||||
/>
|
||||
<div class="original-message-text" v-html="linkify($sanitize(inReplyToText))" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="message">
|
||||
<SwipeableThumbnailsView :items="items" v-if="!event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL" v-bind="$attrs" />
|
||||
<SwipeableThumbnailsView :items="items" v-if="!event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL"
|
||||
v-bind="$attrs" />
|
||||
<v-container v-else-if="!event.isRedacted()" fluid class="imageCollection">
|
||||
<v-row wrap>
|
||||
<v-col v-for="({ size, item }) in layoutedItems()" :key="item.event.getId()" :cols="size">
|
||||
<v-col v-for="{ size, item } in layoutedItems()" :key="item.event.getId()" :cols="size">
|
||||
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<i v-if="event.isRedacted()" class="deleted-text">
|
||||
<v-icon size="small">block</v-icon>
|
||||
{{ redactedBySomeoneElse(event) ? $t('message.incoming_message_deleted_text') : $t('message.outgoing_message_deleted_text')}}
|
||||
{{
|
||||
redactedBySomeoneElse(event)
|
||||
? $t("message.incoming_message_deleted_text")
|
||||
: $t("message.outgoing_message_deleted_text")
|
||||
}}
|
||||
</i>
|
||||
<span v-html="linkify($sanitize(messageText))" v-else-if="messageText" />
|
||||
<span class="edit-marker" v-if="event.replacingEventId() && !event.isRedacted()">
|
||||
{{ $t('message.edited') }}
|
||||
{{ $t("message.edited") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<GalleryItemsView :originalEvent="originalEvent" :items="items" :initialItem="showItem" v-if="!!showItem" v-on:close="showItem = null" />
|
||||
<GalleryItemsView :originalEvent="originalEvent" :items="items" :initialItem="showItem" v-if="!!showItem"
|
||||
v-on:close="showItem = null" />
|
||||
</message-outgoing>
|
||||
<component v-else-if="items.length == 1" :is="componentFn(items[0].event)"
|
||||
v-bind="{...$props, ...$attrs}"
|
||||
:originalEvent="items[0].event"
|
||||
/>
|
||||
<component v-else-if="items.length == 1" :is="componentFn(items[0].event)" v-bind="{ ...$props, ...$attrs }"
|
||||
:originalEvent="items[0].event" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MessageOutgoing from "./MessageOutgoing.vue";
|
||||
import messageMixin from "./messageMixin";
|
||||
import util, { ROOM_TYPE_CHANNEL } from "../../plugins/utils";
|
||||
import GalleryItemsView from '../file_mode/GalleryItemsView.vue';
|
||||
import ThumbnailView from '../file_mode/ThumbnailView.vue';
|
||||
import GalleryItemsView from "../file_mode/GalleryItemsView.vue";
|
||||
import ThumbnailView from "../file_mode/ThumbnailView.vue";
|
||||
import SwipeableThumbnailsView from "./channel/SwipeableThumbnailsView.vue";
|
||||
import { reactive } from "vue";
|
||||
|
||||
|
|
@ -55,10 +55,14 @@ export default {
|
|||
ROOM_TYPE_CHANNEL: ROOM_TYPE_CHANNEL,
|
||||
items: [],
|
||||
showItem: null,
|
||||
}
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), util.threadMessageType(), "m.room.message");
|
||||
this.thread = this.timelineSet.relations.getChildEventsForEvent(
|
||||
this.event.getId(),
|
||||
util.threadMessageType(),
|
||||
"m.room.message"
|
||||
);
|
||||
if (!this.thread) {
|
||||
this.event.on("Event.relationsCreated", this.onRelationsCreated);
|
||||
}
|
||||
|
|
@ -68,42 +72,65 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
forceMultiview() {
|
||||
return this.room.displayType == ROOM_TYPE_CHANNEL && this.items.length == 1 && util.isFileTypePDF(this.items[0].event);
|
||||
}
|
||||
return (
|
||||
this.room.displayType == ROOM_TYPE_CHANNEL && this.items.length == 1 && util.isFileTypePDF(this.items[0].event)
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onRelationsCreated() {
|
||||
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), util.threadMessageType(), "m.room.message");
|
||||
this.thread = this.timelineSet.relations.getChildEventsForEvent(
|
||||
this.event.getId(),
|
||||
util.threadMessageType(),
|
||||
"m.room.message"
|
||||
);
|
||||
this.event.off("Event.relationsCreated", this.onRelationsCreated);
|
||||
},
|
||||
onItemClick(event) {
|
||||
this.showItem = event.item;
|
||||
},
|
||||
processThread() {
|
||||
this.$emit('layout-change', () => {
|
||||
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
|
||||
.filter(e => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype))
|
||||
.map(e => {
|
||||
let ret = reactive({
|
||||
event: e,
|
||||
src: null,
|
||||
});
|
||||
ret.promise = this.$matrix.matrixClient.decryptEventIfNeeded(e)
|
||||
.then(() => util.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, e, this.$config, 100, 100))
|
||||
.then((url) => {
|
||||
ret.src = url;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch thumbnail: ", err);
|
||||
if (!this.event.isRedacted()) {
|
||||
this.$emit(
|
||||
"layout-change",
|
||||
() => {
|
||||
const items = this.timelineSet.relations
|
||||
.getAllChildEventsForEvent(this.event.getId())
|
||||
.filter((e) => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype));
|
||||
|
||||
this.items = items.map((e) => {
|
||||
let ret = reactive({
|
||||
event: e,
|
||||
src: null,
|
||||
});
|
||||
return ret;
|
||||
});
|
||||
}, this.$el);
|
||||
if (items.length > 1) {
|
||||
// Only do if items more than one. If one, the individual component in <component> above will do the work.
|
||||
//
|
||||
ret.promise = this.$matrix.matrixClient
|
||||
.decryptEventIfNeeded(e)
|
||||
.then(() =>
|
||||
util.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, e, this.$config, 100, 100)
|
||||
)
|
||||
.then((url) => {
|
||||
ret.src = url;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch thumbnail: ", err);
|
||||
});
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
},
|
||||
this.$el
|
||||
);
|
||||
}
|
||||
},
|
||||
layoutedItems() {
|
||||
if (!this.items || this.items.length == 0) { return [] }
|
||||
if (!this.items || this.items.length == 0) {
|
||||
return [];
|
||||
}
|
||||
let array = this.items.slice(0);
|
||||
let rows = []
|
||||
let rows = [];
|
||||
while (array.length > 0) {
|
||||
if (array.length >= 7) {
|
||||
rows.push({ size: 6, item: array[0] });
|
||||
|
|
@ -128,9 +155,9 @@ export default {
|
|||
array = array.slice(1);
|
||||
}
|
||||
}
|
||||
return rows
|
||||
return rows;
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss">
|
||||
|
|
@ -165,4 +192,4 @@ export default {
|
|||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@ export default {
|
|||
}
|
||||
if (newValue) {
|
||||
newValue.on("Relations.add", this.onAddRelation);
|
||||
this.processThread();
|
||||
}
|
||||
this.processThread();
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
|
|
|
|||
1
src/components/sendAttachmentsMixin.d.ts
vendored
Normal file
1
src/components/sendAttachmentsMixin.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
declare module 'sendAttachmentsMixin';
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
import util from "../plugins/utils";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
sendStatuses: Object.freeze({
|
||||
INITIAL: 0,
|
||||
SENDING: 1,
|
||||
SENT: 2,
|
||||
CANCELED: 3,
|
||||
FAILED: 4,
|
||||
}),
|
||||
sendingStatus: 0,
|
||||
sendingPromise: null,
|
||||
sendingRootEventId: null,
|
||||
sendingAttachments: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
attachmentsSentCount() {
|
||||
return this.sendingAttachments ? this.sendingAttachments.reduce((a, elem, ignoredidx, ignoredarray) => elem.status == this.sendStatuses.SENT ? a + 1 : a, 0) : 0
|
||||
},
|
||||
attachmentsSending() {
|
||||
return this.sendingAttachments ? this.sendingAttachments.filter(elem => elem.status == this.sendStatuses.INITIAL || elem.status == this.sendStatuses.SENDING) : []
|
||||
},
|
||||
attachmentsSent() {
|
||||
this.sortSendingAttachments();
|
||||
return this.sendingAttachments ? this.sendingAttachments.filter(elem => elem.status == this.sendStatuses.SENT) : []
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
sendAttachments(text, attachments) {
|
||||
this.sendingStatus = this.sendStatuses.SENDING;
|
||||
|
||||
this.sendingAttachments = attachments.map((attachment) => {
|
||||
let file = (() => {
|
||||
// other than file type image
|
||||
if(attachment instanceof File) {
|
||||
return attachment;
|
||||
} else {
|
||||
if (attachment.scaled && attachment.useScaled) {
|
||||
// Send scaled version of image instead!
|
||||
return attachment.scaled;
|
||||
} else {
|
||||
// Send actual file image when not scaled!
|
||||
return attachment.actualFile;
|
||||
}
|
||||
}
|
||||
})();
|
||||
let sendInfo = {
|
||||
id: attachment.name,
|
||||
status: this.sendStatuses.INITIAL,
|
||||
statusDate: Date.now,
|
||||
mediaEventId: undefined,
|
||||
attachment: file,
|
||||
preview: attachment.image,
|
||||
progress: 0,
|
||||
randomRotation: 0,
|
||||
randomTranslationX: 0,
|
||||
randomTranslationY: 0
|
||||
};
|
||||
attachment.sendInfo = sendInfo;
|
||||
return sendInfo;
|
||||
});
|
||||
|
||||
this.sendingPromise = util.sendTextMessage(this.$matrix.matrixClient, this.room.roomId, text)
|
||||
.then((eventId) => {
|
||||
this.sendingRootEventId = eventId;
|
||||
|
||||
// Use the eventId as a thread root for all the media
|
||||
let promiseChain = Promise.resolve();
|
||||
const getItemPromise = (index) => {
|
||||
if (index < this.sendingAttachments.length) {
|
||||
const item = this.sendingAttachments[index];
|
||||
if (item.status !== this.sendStatuses.INITIAL) {
|
||||
return getItemPromise(++index);
|
||||
}
|
||||
item.status = this.sendStatuses.SENDING;
|
||||
const itemPromise = util.sendFile(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((mediaEventId) => {
|
||||
// Look at last item rotation, flipping the sign on this, so looks more like a true stack
|
||||
let signR = 1;
|
||||
let signX = 1;
|
||||
let signY = 1;
|
||||
if (this.attachmentsSent.length > 0) {
|
||||
if (this.attachmentsSent[0].randomRotation >= 0) {
|
||||
signR = -1;
|
||||
}
|
||||
if (this.attachmentsSent[0].randomTranslationX >= 0) {
|
||||
signX = -1;
|
||||
}
|
||||
if (this.attachmentsSent[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.mediaEventId = mediaEventId;
|
||||
item.status = this.sendStatuses.SENT;
|
||||
item.statusDate = Date.now;
|
||||
}).catch(ignorederr => {
|
||||
if (item.promise.aborted) {
|
||||
item.status = this.sendStatuses.CANCELED;
|
||||
} else {
|
||||
console.error("ERROR", ignorederr);
|
||||
item.status = this.sendStatuses.FAILED;
|
||||
}
|
||||
});
|
||||
item.promise = itemPromise;
|
||||
return itemPromise.then(() => getItemPromise(++index));
|
||||
}
|
||||
else return Promise.resolve();
|
||||
};
|
||||
|
||||
return promiseChain.then(() => getItemPromise(0));
|
||||
})
|
||||
.then(() => {
|
||||
this.sendingStatus = this.sendStatuses.SENT;
|
||||
this.sendingRootEventId = null;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("ERROR", err);
|
||||
});
|
||||
return this.sendingPromise;
|
||||
},
|
||||
|
||||
cancelSendAttachments() {
|
||||
this.sendingAttachments.toReversed().forEach(item => {
|
||||
this.cancelSendAttachmentItem(item);
|
||||
});
|
||||
this.sendingStatus = this.sendStatuses.CANCELED;
|
||||
if (this.sendingRootEventId && this.room) {
|
||||
|
||||
// Redact all media we already sent, plus the root event
|
||||
let promises = this.sendingAttachments.filter((item) => item.mediaEventId !== undefined).map((item) => this.$matrix.matrixClient.redactEvent(this.room.roomId, item.mediaEventId, undefined, { reason: "cancel" }));
|
||||
promises.push(this.$matrix.matrixClient.redactEvent(this.room.roomId, this.sendingRootEventId, undefined, { reason: "cancel" }));
|
||||
Promise.allSettled(promises)
|
||||
.then(() => {
|
||||
console.log("Message redacted");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Redaction failed: ", err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
cancelSendAttachmentItem(item) {
|
||||
if (item.promise && item.status != this.sendStatuses.INITIAL) {
|
||||
item.promise.abort();
|
||||
}
|
||||
item.status = this.sendStatuses.CANCELED;
|
||||
},
|
||||
|
||||
sortSendingAttachments() {
|
||||
this.sendingAttachments.sort((a, b) => b.statusDate - a.statusDate);
|
||||
},
|
||||
}
|
||||
}
|
||||
195
src/components/sendAttachmentsMixin.ts
Normal file
195
src/components/sendAttachmentsMixin.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { defineComponent, reactive } from "vue";
|
||||
import util from "../plugins/utils";
|
||||
import { Attachment, AttachmentSendInfo } from "../models/attachment";
|
||||
|
||||
export default defineComponent({
|
||||
data(): {
|
||||
sendingStatus: "initial" | "sending" | "sent" | "canceled" | "failed";
|
||||
sendingRootEventId: string | null;
|
||||
sendingPromise: Promise<any> | null;
|
||||
sendingAttachments: Attachment[];
|
||||
} {
|
||||
return {
|
||||
// sendStatuses: Object.freeze({
|
||||
// INITIAL: 0,
|
||||
// SENDING: 1,
|
||||
// SENT: 2,
|
||||
// CANCELED: 3,
|
||||
// FAILED: 4,
|
||||
// }),
|
||||
sendingStatus: "initial",
|
||||
sendingPromise: null,
|
||||
sendingRootEventId: null,
|
||||
sendingAttachments: [] as Attachment[],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
attachmentsSentCount(): number {
|
||||
return this.sendingAttachments
|
||||
? this.sendingAttachments.reduce((a, elem) => (elem.sendInfo?.status == "sent" ? a + 1 : a), 0)
|
||||
: 0;
|
||||
},
|
||||
attachmentsSending(): Attachment[] {
|
||||
return this.sendingAttachments
|
||||
? this.sendingAttachments.filter(
|
||||
(elem) => elem.sendInfo?.status == "initial" || elem.sendInfo?.status == "sending"
|
||||
)
|
||||
: [];
|
||||
},
|
||||
attachmentsSent(): Attachment[] {
|
||||
this.sortSendingAttachments();
|
||||
return this.sendingAttachments ? this.sendingAttachments.filter((elem) => elem.sendInfo?.status == "sent") : [];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
sendAttachments(text: string, attachments: Attachment[]) {
|
||||
this.sendingStatus = "sending";
|
||||
|
||||
this.sendingAttachments = attachments.map((attachment) => {
|
||||
let sendInfo: AttachmentSendInfo = {
|
||||
status: "initial",
|
||||
statusDate: Date.now(),
|
||||
mediaEventId: undefined,
|
||||
progress: 0,
|
||||
randomRotation: 0,
|
||||
randomTranslationX: 0,
|
||||
randomTranslationY: 0,
|
||||
promise: undefined,
|
||||
};
|
||||
attachment.sendInfo = reactive(sendInfo);
|
||||
return attachment;
|
||||
});
|
||||
|
||||
this.sendingPromise = util
|
||||
.sendTextMessage(this.$matrix.matrixClient, this.room.roomId, text)
|
||||
.then((eventId: string) => {
|
||||
this.sendingRootEventId = eventId;
|
||||
|
||||
// Use the eventId as a thread root for all the media
|
||||
let promiseChain = Promise.resolve();
|
||||
const getItemPromise = (index: number) => {
|
||||
if (index < this.sendingAttachments.length) {
|
||||
const attachment = this.sendingAttachments[index];
|
||||
const item = attachment.sendInfo!;
|
||||
if (item.status !== "initial") {
|
||||
return getItemPromise(++index);
|
||||
}
|
||||
item.status = "sending";
|
||||
|
||||
let file = (() => {
|
||||
if (attachment.scaledFile && attachment.useScaled) {
|
||||
// Send scaled version of image instead!
|
||||
return attachment.scaledFile;
|
||||
} else {
|
||||
// Send actual file image when not scaled!
|
||||
return attachment.file;
|
||||
}
|
||||
})();
|
||||
|
||||
const itemPromise = util
|
||||
.sendFile(
|
||||
this.$matrix.matrixClient,
|
||||
this.room.roomId,
|
||||
file,
|
||||
({ loaded, total }: { loaded: number; total: number }) => {
|
||||
if (loaded == total) {
|
||||
item.progress = 100;
|
||||
} else if (total > 0) {
|
||||
item.progress = (100 * loaded) / total;
|
||||
}
|
||||
},
|
||||
eventId,
|
||||
attachment.dimensions
|
||||
)
|
||||
.then((mediaEventId: string) => {
|
||||
// Look at last item rotation, flipping the sign on this, so looks more like a true stack
|
||||
let signR = 1;
|
||||
let signX = 1;
|
||||
let signY = 1;
|
||||
if (this.attachmentsSent.length > 0) {
|
||||
if (this.attachmentsSent[0].sendInfo!.randomRotation >= 0) {
|
||||
signR = -1;
|
||||
}
|
||||
if (this.attachmentsSent[0].sendInfo!.randomTranslationX >= 0) {
|
||||
signX = -1;
|
||||
}
|
||||
if (this.attachmentsSent[0].sendInfo!.randomTranslationY >= 0) {
|
||||
signY = -1;
|
||||
}
|
||||
}
|
||||
item.randomRotation = signR * (2 + Math.random() * 10);
|
||||
item.randomTranslationX = signX * Math.random() * 20;
|
||||
item.randomTranslationY = signY * Math.random() * 20;
|
||||
item.mediaEventId = mediaEventId;
|
||||
item.status = "sent";
|
||||
item.statusDate = Date.now();
|
||||
})
|
||||
.catch((ignorederr: any) => {
|
||||
if (item.promise?.aborted) {
|
||||
item.status = "canceled";
|
||||
} else {
|
||||
console.error("ERROR", ignorederr);
|
||||
item.status = "failed";
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
item.promise = itemPromise;
|
||||
return itemPromise.then(() => getItemPromise(++index));
|
||||
} else return Promise.resolve();
|
||||
};
|
||||
|
||||
return promiseChain.then(() => getItemPromise(0));
|
||||
})
|
||||
.then(() => {
|
||||
this.sendingStatus = "sent";
|
||||
this.sendingRootEventId = null;
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error("ERROR", err);
|
||||
});
|
||||
return this.sendingPromise;
|
||||
},
|
||||
|
||||
cancelSendAttachments() {
|
||||
this.sendingAttachments.toReversed().forEach((item) => {
|
||||
this.cancelSendAttachmentItem(item);
|
||||
});
|
||||
this.sendingStatus = "canceled";
|
||||
if (this.sendingRootEventId && this.room) {
|
||||
// Redact all media we already sent, plus the root event
|
||||
let promises = this.sendingAttachments
|
||||
.filter((item) => item.sendInfo?.mediaEventId !== undefined)
|
||||
.map((item) =>
|
||||
this.$matrix.matrixClient.redactEvent(this.room.roomId, item.sendInfo!.mediaEventId, undefined, {
|
||||
reason: "cancel",
|
||||
})
|
||||
);
|
||||
promises.push(
|
||||
this.$matrix.matrixClient.redactEvent(this.room.roomId, this.sendingRootEventId, undefined, {
|
||||
reason: "cancel",
|
||||
})
|
||||
);
|
||||
Promise.allSettled(promises)
|
||||
.then(() => {
|
||||
console.log("Message redacted");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Redaction failed: ", err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
cancelSendAttachmentItem(item: Attachment) {
|
||||
if (item.sendInfo) {
|
||||
if (item.sendInfo.promise && item.sendInfo.status != "initial") {
|
||||
item.sendInfo.promise.abort();
|
||||
}
|
||||
item.sendInfo.status = "canceled";
|
||||
}
|
||||
},
|
||||
|
||||
sortSendingAttachments() {
|
||||
this.sendingAttachments.sort((a, b) => (b.sendInfo?.statusDate ?? 0) - (a.sendInfo?.statusDate ?? 0));
|
||||
},
|
||||
},
|
||||
});
|
||||
51
src/models/attachment.ts
Normal file
51
src/models/attachment.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export class UploadPromise<Type> {
|
||||
wrappedPromise: Promise<Type>;
|
||||
aborted: boolean = false;
|
||||
onAbort: (() => void) | undefined = undefined;
|
||||
|
||||
constructor(wrappedPromise: Promise<Type>) {
|
||||
this.wrappedPromise = wrappedPromise;
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
if (this.onAbort) {
|
||||
this.onAbort();
|
||||
}
|
||||
}
|
||||
|
||||
then(resolve: any, reject: any) {
|
||||
this.wrappedPromise = this.wrappedPromise.then(resolve, reject);
|
||||
return this;
|
||||
}
|
||||
|
||||
catch(handler: any) {
|
||||
this.wrappedPromise = this.wrappedPromise.catch(handler);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export type AttachmentSendStatus = "initial" | "sending" | "sent" | "canceled" | "failed";
|
||||
|
||||
export type AttachmentSendInfo = {
|
||||
status: AttachmentSendStatus;
|
||||
statusDate: number; //ms
|
||||
mediaEventId: string | undefined;
|
||||
progress: number;
|
||||
promise: UploadPromise<string> | undefined;
|
||||
randomRotation: number; // For UI effects
|
||||
randomTranslationX: number; // For UI effects
|
||||
randomTranslationY: number; // For UI effects
|
||||
};
|
||||
|
||||
export type Attachment = {
|
||||
status: "loading" | "loaded";
|
||||
file: File;
|
||||
dimensions?: { width: number; height: number };
|
||||
scaledFile?: File;
|
||||
scaledDimensions?: { width: number; height: number };
|
||||
useScaled: boolean;
|
||||
src?: string;
|
||||
proof?: any;
|
||||
sendInfo?: AttachmentSendInfo;
|
||||
};
|
||||
325
src/models/attachmentManager.ts
Normal file
325
src/models/attachmentManager.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
||||
import { KeanuEventExtension } from "./eventAttachment";
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import { Counter, ModeOfOperation } from "aes-js";
|
||||
import { Attachment } from "./attachment";
|
||||
import proofmode from "../plugins/proofmode";
|
||||
import imageSize from "image-size";
|
||||
import imageResize from "image-resize";
|
||||
import { reactive } from "vue";
|
||||
|
||||
type CacheEntry = {
|
||||
attachment?: string;
|
||||
thumbnail?: string;
|
||||
attachmentPromise?: Promise<string>;
|
||||
thumbnailPromise?: Promise<string>;
|
||||
attachmentProgress?: ((progress: number) => void)[];
|
||||
thumbnailProgress?: ((progress: number) => void)[];
|
||||
};
|
||||
|
||||
export class AttachmentManager {
|
||||
matrixClient: MatrixClient;
|
||||
useAuthedMedia: boolean;
|
||||
maxSizeUploads: number;
|
||||
maxSizeAutoDownloads: number;
|
||||
|
||||
cache: Map<string | undefined, CacheEntry>;
|
||||
|
||||
constructor(matrixClient: MatrixClient, useAuthedMedia: boolean, maxSizeAutoDownloads: number) {
|
||||
this.matrixClient = matrixClient;
|
||||
this.useAuthedMedia = useAuthedMedia;
|
||||
this.maxSizeUploads = 0;
|
||||
this.maxSizeAutoDownloads = maxSizeAutoDownloads;
|
||||
|
||||
this.cache = new Map();
|
||||
|
||||
// Get max upload size
|
||||
this.matrixClient.getMediaConfig(useAuthedMedia).then((config) => {
|
||||
this.maxSizeUploads = config["m.upload.size"] ?? 0;
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
public createAttachment(file: File): Attachment {
|
||||
let a: Attachment = {
|
||||
status: "loading",
|
||||
file: file,
|
||||
useScaled: false,
|
||||
};
|
||||
const ra = reactive(a);
|
||||
this.prepareUpload(ra);
|
||||
return ra;
|
||||
}
|
||||
|
||||
private async prepareUpload(attachment: Attachment): Promise<Attachment> {
|
||||
const file = attachment.file;
|
||||
if (file.type.startsWith("image/")) {
|
||||
attachment.proof = await proofmode.proofCheckFile(file);
|
||||
|
||||
var reader = new FileReader();
|
||||
await new Promise((resolve) => {
|
||||
reader.onload = (evt) => {
|
||||
attachment.src = (evt.target?.result as string) ?? undefined;
|
||||
if (attachment.src) {
|
||||
try {
|
||||
const buffer = Uint8Array.from(window.atob(attachment.src.replace(/^data[^,]+,/, "")), (v) =>
|
||||
v.charCodeAt(0)
|
||||
);
|
||||
attachment.dimensions = imageSize(buffer);
|
||||
|
||||
// Need to resize?
|
||||
const w = attachment.dimensions.width;
|
||||
const h = attachment.dimensions.height;
|
||||
if (w > 640 || h > 640) {
|
||||
var aspect = w / h;
|
||||
var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed());
|
||||
var newHeight = parseInt((w > h ? 640 / aspect : 640).toFixed());
|
||||
imageResize(attachment.src, {
|
||||
format: "webp",
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
outputType: "blob",
|
||||
})
|
||||
.then((img) => {
|
||||
attachment.scaledFile = new File([img as BlobPart], file.name, {
|
||||
type: "image/webp",
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
attachment.scaledDimensions = {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
};
|
||||
|
||||
// Use scaled version if the image does not contain C2PA
|
||||
attachment.useScaled = attachment.scaledFile !== undefined && (attachment.proof === undefined || attachment.proof.integrity === undefined || attachment.proof.integrity.c2pa === undefined)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Resize failed:", err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get image dimensions: " + error);
|
||||
}
|
||||
}
|
||||
resolve(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
attachment.status = "loaded";
|
||||
return attachment;
|
||||
}
|
||||
|
||||
public async loadEventAttachment(
|
||||
event: MatrixEvent & KeanuEventExtension,
|
||||
progress?: (percent: number) => void,
|
||||
outputObject?: { src: string; thumbnailSrc: string }
|
||||
): Promise<string> {
|
||||
console.error("GET ATTACHMENT FOR EVENT", event.getId());
|
||||
|
||||
const entry = this.cache.get(event.getId()) ?? {};
|
||||
if (entry.attachment) {
|
||||
if (outputObject) {
|
||||
outputObject.src = entry.attachment;
|
||||
}
|
||||
return entry.attachment;
|
||||
}
|
||||
if (!entry.attachmentPromise) {
|
||||
entry.attachmentPromise = this._loadEventAttachmentOrThumbnail(event, false, progress)
|
||||
.then((attachment) => {
|
||||
entry.attachment = attachment;
|
||||
return attachment;
|
||||
})
|
||||
.catch((err) => {
|
||||
entry.attachmentPromise = undefined;
|
||||
throw err;
|
||||
});
|
||||
this.cache.set(event.getId(), entry);
|
||||
}
|
||||
entry.attachmentProgress = (entry.attachmentProgress ?? []).concat();
|
||||
return entry.attachmentPromise.then((attachment) => {
|
||||
console.error("GOT ATTACHMENT", attachment);
|
||||
if (outputObject) {
|
||||
outputObject.src = attachment;
|
||||
}
|
||||
return attachment;
|
||||
});
|
||||
}
|
||||
|
||||
public async loadEventThumbnail(
|
||||
event: MatrixEvent & KeanuEventExtension,
|
||||
progress?: (percent: number) => void,
|
||||
outputObject?: { src: string; thumbnailSrc: string }
|
||||
): Promise<string> {
|
||||
console.error("GET THUMB FOR EVENT", event.getId());
|
||||
|
||||
const entry = this.cache.get(event.getId()) ?? {};
|
||||
if (entry.thumbnail) {
|
||||
if (outputObject) {
|
||||
outputObject.thumbnailSrc = entry.thumbnail;
|
||||
}
|
||||
return entry.thumbnail;
|
||||
}
|
||||
|
||||
if (!entry.thumbnailPromise) {
|
||||
entry.thumbnailPromise = this._loadEventAttachmentOrThumbnail(event, true, progress)
|
||||
.then((thummbnail) => {
|
||||
entry.thumbnail = thummbnail;
|
||||
return thummbnail;
|
||||
})
|
||||
.catch((err) => {
|
||||
entry.thumbnailPromise = undefined;
|
||||
throw err;
|
||||
});
|
||||
this.cache.set(event.getId(), entry);
|
||||
}
|
||||
return entry.thumbnailPromise.then((thumbnail) => {
|
||||
console.error("GOT THUMB", thumbnail);
|
||||
if (outputObject) {
|
||||
outputObject.thumbnailSrc = thumbnail;
|
||||
}
|
||||
return thumbnail;
|
||||
});
|
||||
}
|
||||
|
||||
private async _loadEventAttachmentOrThumbnail(
|
||||
event: MatrixEvent & KeanuEventExtension,
|
||||
thumbnail: boolean,
|
||||
progress?: (percent: number) => void
|
||||
): Promise<string> {
|
||||
await this.matrixClient.decryptEventIfNeeded(event);
|
||||
|
||||
const content = event.getContent();
|
||||
var url = null;
|
||||
var mime = "image/png";
|
||||
var file = null;
|
||||
let decrypt = true;
|
||||
if (thumbnail && !!content.info && !!content.info.thumbnail_url) {
|
||||
url = this.matrixClient.mxcUrlToHttp(
|
||||
content.info.thumbnail_url,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
this.useAuthedMedia
|
||||
);
|
||||
decrypt = false;
|
||||
if (content.info.thumbnail_info) {
|
||||
mime = content.info.thumbnail_info.mimetype;
|
||||
}
|
||||
} else if (content.url != null) {
|
||||
url = this.matrixClient.mxcUrlToHttp(
|
||||
content.url,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
this.useAuthedMedia
|
||||
);
|
||||
decrypt = false;
|
||||
if (content.info) {
|
||||
mime = content.info.mimetype;
|
||||
}
|
||||
} else if (thumbnail && content && content.info && content.info.thumbnail_file && content.info.thumbnail_file.url) {
|
||||
file = content.info.thumbnail_file;
|
||||
url = this.matrixClient.mxcUrlToHttp(
|
||||
file.url,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
this.useAuthedMedia
|
||||
);
|
||||
mime = file.mimetype;
|
||||
} else if (
|
||||
content.file &&
|
||||
content.file.url &&
|
||||
event.getContent()?.info?.size > 0 &&
|
||||
event.getContent()?.info?.size < this.maxSizeAutoDownloads
|
||||
) {
|
||||
// No thumb, use real url
|
||||
file = content.file;
|
||||
url = this.matrixClient.mxcUrlToHttp(
|
||||
file.url,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
this.useAuthedMedia
|
||||
);
|
||||
mime = file.mimetype;
|
||||
}
|
||||
|
||||
if (url == null) {
|
||||
throw new Error("No url found!");
|
||||
}
|
||||
|
||||
let options: AxiosRequestConfig = {
|
||||
responseType: "arraybuffer",
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
if (progress) {
|
||||
progress(Math.floor((progressEvent.progress ?? 0) * 100));
|
||||
}
|
||||
},
|
||||
};
|
||||
if (this.useAuthedMedia) {
|
||||
options.headers = {
|
||||
Authorization: `Bearer ${this.matrixClient.getAccessToken()}`,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await axios.get(url, options);
|
||||
const bytes = decrypt ? await this.decryptData(file, response) : { buffer: response.data };
|
||||
return URL.createObjectURL(new Blob([bytes.buffer], { type: mime }));
|
||||
}
|
||||
|
||||
releaseEvent(event: MatrixEvent & KeanuEventExtension): void {
|
||||
console.error("Release event", event.getId());
|
||||
const entry = this.cache.get(event.getId());
|
||||
if (entry) {
|
||||
// TODO - abortable promises
|
||||
this.cache.delete(event.getId());
|
||||
if (entry.attachment) {
|
||||
URL.revokeObjectURL(entry.attachment);
|
||||
}
|
||||
if (entry.thumbnail) {
|
||||
URL.revokeObjectURL(entry.thumbnail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private b64toBuffer(val: any) {
|
||||
const baseValue = val.replaceAll("-", "+").replaceAll("_", "/");
|
||||
return Buffer.from(baseValue, "base64");
|
||||
}
|
||||
|
||||
private decryptData(file: any, response: AxiosResponse): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const key = this.b64toBuffer(file.key.k);
|
||||
const iv = this.b64toBuffer(file.iv);
|
||||
const originalHash = this.b64toBuffer(file.hashes.sha256);
|
||||
|
||||
var aesCtr = new ModeOfOperation.ctr(key, new Counter(iv));
|
||||
const data = new Uint8Array(response.data);
|
||||
|
||||
crypto.subtle
|
||||
.digest("SHA-256", data)
|
||||
.then((hash) => {
|
||||
// Calculate sha256 and compare hashes
|
||||
if (Buffer.compare(Buffer.from(hash), originalHash) != 0) {
|
||||
reject("Hashes don't match!");
|
||||
return;
|
||||
}
|
||||
var decryptedBytes = aesCtr.decrypt(data);
|
||||
resolve(decryptedBytes);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject("Failed to calculate hash value");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
15
src/models/eventAttachment.ts
Normal file
15
src/models/eventAttachment.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { MatrixEvent } from "matrix-js-sdk";
|
||||
|
||||
export type KeanuEventExtension = {
|
||||
isMxThread?: boolean;
|
||||
isChannelMessage?: boolean;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
export type EventAttachment = {
|
||||
event: MatrixEvent & KeanuEventExtension;
|
||||
src?: string;
|
||||
thumbnail?: string;
|
||||
srcPromise?: Promise<string>;
|
||||
thumbnailPromise?: Promise<string>;
|
||||
};
|
||||
38
src/plugins/proofmode.ts
Normal file
38
src/plugins/proofmode.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { spawn } from "threads";
|
||||
import ProofmodeWorker from './proofmodeWorker?worker'
|
||||
|
||||
export type ProofCheckResult = {
|
||||
name?: string;
|
||||
json?: string;
|
||||
integrity?: { pgp?: any; c2pa?: any; exif?: any; opentimestamps?: any };
|
||||
};
|
||||
|
||||
class ProofMode {
|
||||
worker: any | undefined = undefined;
|
||||
|
||||
async getProofcheckWorker() {
|
||||
if (this.worker) {
|
||||
return this.worker;
|
||||
}
|
||||
try {
|
||||
this.worker = await spawn(new ProofmodeWorker(), { timeout: 20000 });
|
||||
this.worker.values().subscribe(({ type, message }: { type: string, message: string}) => {
|
||||
console.log("ProofCheck:", type, message);
|
||||
});
|
||||
} catch (error) {}
|
||||
return this.worker;
|
||||
}
|
||||
|
||||
async proofCheckFile(file: File): Promise<ProofCheckResult | undefined> {
|
||||
try {
|
||||
const worker = await this.getProofcheckWorker();
|
||||
const res = await worker.checkFiles([file]);
|
||||
if (res && res.files && res.files.length == 1) {
|
||||
return res.files[0];
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
export default new ProofMode();
|
||||
20
src/plugins/proofmodeWorker.js
Normal file
20
src/plugins/proofmodeWorker.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Observable, Subject } from "threads/observable";
|
||||
import { expose } from "threads/worker";
|
||||
import { checkFiles } from "@guardianproject/proofmode";
|
||||
|
||||
let subject = new Subject();
|
||||
|
||||
const sendMessage = (type, message) => {
|
||||
subject.next({ type, message });
|
||||
};
|
||||
|
||||
const check = {
|
||||
checkFiles: (files) => {
|
||||
return checkFiles(files, sendMessage);
|
||||
},
|
||||
values: () => {
|
||||
return Observable.from(subject);
|
||||
},
|
||||
};
|
||||
|
||||
expose(check);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import axios from "axios";
|
||||
import * as ContentHelpers from "matrix-js-sdk/lib/content-helpers";
|
||||
import imageResize from "image-resize";
|
||||
import { AutoDiscovery } from "matrix-js-sdk";
|
||||
import { AutoDiscovery, Method } from "matrix-js-sdk";
|
||||
import User from "../models/user";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import Hammer from "hammerjs";
|
||||
|
|
@ -12,12 +12,8 @@ import aesjs from "aes-js";
|
|||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import i18n from "./lang";
|
||||
import {
|
||||
toRaw,
|
||||
isRef,
|
||||
isReactive,
|
||||
isProxy,
|
||||
} from 'vue';
|
||||
import { toRaw, isRef, isReactive, isProxy } from "vue";
|
||||
import { UploadPromise } from "../models/attachment";
|
||||
|
||||
export const STATE_EVENT_ROOM_DELETION_NOTICE = "im.keanu.room_deletion_notice";
|
||||
export const STATE_EVENT_ROOM_DELETED = "im.keanu.room_deleted";
|
||||
|
|
@ -29,6 +25,9 @@ export const ROOM_TYPE_CHANNEL = "im.keanu.room_type_channel";
|
|||
|
||||
export const STATE_EVENT_ROOM_TYPE = "im.keanu.room_type";
|
||||
|
||||
const THUMBNAIL_MAX_WIDTH = 160;
|
||||
const THUMBNAIL_MAX_HEIGHT = 160;
|
||||
|
||||
// Install extended localized format
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(duration);
|
||||
|
|
@ -43,32 +42,6 @@ var _browserCanRecordAudioF = function () {
|
|||
};
|
||||
var _browserCanRecordAudio = _browserCanRecordAudioF();
|
||||
|
||||
class UploadPromise {
|
||||
aborted = false;
|
||||
onAbort = undefined;
|
||||
|
||||
constructor(wrappedPromise) {
|
||||
this.wrappedPromise = wrappedPromise;
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
if (this.onAbort) {
|
||||
this.onAbort();
|
||||
}
|
||||
}
|
||||
|
||||
then(resolve, reject) {
|
||||
this.wrappedPromise = this.wrappedPromise.then(resolve, reject);
|
||||
return this;
|
||||
}
|
||||
|
||||
catch(handler) {
|
||||
this.wrappedPromise = this.wrappedPromise.catch(handler);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class Util {
|
||||
threadMessageType() {
|
||||
return Thread.hasServerSideSupport ? "m.thread" : "io.element.thread";
|
||||
|
|
@ -90,6 +63,7 @@ class Util {
|
|||
}
|
||||
|
||||
getAttachment(matrixClient, useAuthedMedia, event, progressCallback, asBlob = false, abortController = undefined) {
|
||||
console.error("GET ATTACHMENT FOR EVENT", event.getId());
|
||||
return new Promise((resolve, reject) => {
|
||||
const content = event.getContent();
|
||||
var url = null;
|
||||
|
|
@ -164,94 +138,54 @@ class Util {
|
|||
});
|
||||
}
|
||||
|
||||
getThumbnail(matrixClient, useAuthedMedia, event, config, ignoredw, ignoredh) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const content = event.getContent();
|
||||
var url = null;
|
||||
var mime = "image/png";
|
||||
var file = null;
|
||||
let decrypt = true;
|
||||
if (content.url != null) {
|
||||
url = matrixClient.mxcUrlToHttp(
|
||||
content.url,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
useAuthedMedia
|
||||
);
|
||||
decrypt = false;
|
||||
if (content.info) {
|
||||
mime = content.info.mimetype;
|
||||
}
|
||||
} else if (content && content.info && content.info.thumbnail_file && content.info.thumbnail_file.url) {
|
||||
file = content.info.thumbnail_file;
|
||||
// var width = w;
|
||||
// var height = h;
|
||||
// if (content.info.w < w || content.info.h < h) {
|
||||
// width = content.info.w;
|
||||
// height = content.info.h;
|
||||
// }
|
||||
// url = matrixClient.mxcUrlToHttp(
|
||||
// file.url,
|
||||
// width, height,
|
||||
// "scale",
|
||||
// true
|
||||
// );
|
||||
url = matrixClient.mxcUrlToHttp(
|
||||
file.url,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
useAuthedMedia
|
||||
);
|
||||
mime = file.mimetype;
|
||||
} else if (
|
||||
content.file &&
|
||||
content.file.url &&
|
||||
this.getFileSize(event) > 0 &&
|
||||
this.getFileSize(event) < config.maxSizeAutoDownloads
|
||||
) {
|
||||
// No thumb, use real url
|
||||
file = content.file;
|
||||
url = matrixClient.mxcUrlToHttp(
|
||||
file.url,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
useAuthedMedia
|
||||
);
|
||||
mime = file.mimetype;
|
||||
async getThumbnail(matrixClient, useAuthedMedia, event, config, ignoredw, ignoredh) {
|
||||
console.error("GET THUMB FOR EVENT", event.getId());
|
||||
const content = event.getContent();
|
||||
var url = null;
|
||||
var mime = "image/png";
|
||||
var file = null;
|
||||
let decrypt = true;
|
||||
if (!!content.info && !!content.info.thumbnail_url) {
|
||||
url = matrixClient.mxcUrlToHttp(content.info.thumbnail_url, undefined, undefined, undefined, undefined, undefined, useAuthedMedia);
|
||||
decrypt = false;
|
||||
if (content.info.thumbnail_info) {
|
||||
mime = content.info.thumbnail_info.mimetype;
|
||||
}
|
||||
|
||||
if (url == null) {
|
||||
reject("No url found!");
|
||||
return;
|
||||
} else if (content.url != null) {
|
||||
url = matrixClient.mxcUrlToHttp(content.url, undefined, undefined, undefined, undefined, undefined, useAuthedMedia);
|
||||
decrypt = false;
|
||||
if (content.info) {
|
||||
mime = content.info.mimetype;
|
||||
}
|
||||
} else if (content && content.info && content.info.thumbnail_file && content.info.thumbnail_file.url) {
|
||||
file = content.info.thumbnail_file;
|
||||
url = matrixClient.mxcUrlToHttp(file.url, undefined, undefined, undefined, undefined, undefined, useAuthedMedia);
|
||||
mime = file.mimetype;
|
||||
} else if (
|
||||
content.file &&
|
||||
content.file.url &&
|
||||
this.getFileSize(event) > 0 &&
|
||||
this.getFileSize(event) < config.maxSizeAutoDownloads
|
||||
) {
|
||||
// No thumb, use real url
|
||||
file = content.file;
|
||||
url = matrixClient.mxcUrlToHttp(file.url, undefined, undefined, undefined, undefined, undefined, useAuthedMedia);
|
||||
mime = file.mimetype;
|
||||
}
|
||||
|
||||
axios
|
||||
if (url == null) {
|
||||
throw new Error("No url found!");
|
||||
}
|
||||
|
||||
const response = await axios
|
||||
.get(url, useAuthedMedia ? {
|
||||
responseType: "arraybuffer",
|
||||
headers: {
|
||||
Authorization: `Bearer ${matrixClient.getAccessToken()}`,
|
||||
},
|
||||
} : { responseType: "arraybuffer" })
|
||||
.then((response) => {
|
||||
return decrypt ? this.decryptIfNeeded(file, response) : Promise.resolve({ buffer: response.data });
|
||||
})
|
||||
.then((bytes) => {
|
||||
resolve(URL.createObjectURL(new Blob([bytes.buffer], { type: mime })));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Download error: ", err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
} : { responseType: "arraybuffer" });
|
||||
const bytes = decrypt ? await this.decryptIfNeeded(file, response) : { buffer: response.data };
|
||||
return URL.createObjectURL(new Blob([bytes.buffer], { type: mime }));
|
||||
}
|
||||
|
||||
b64toBuffer(val) {
|
||||
|
|
@ -430,7 +364,37 @@ class Util {
|
|||
});
|
||||
}
|
||||
|
||||
sendFile(matrixClient, roomId, file, onUploadProgress, threadRoot) {
|
||||
async encryptFileAndGenerateInfo(data, mime) {
|
||||
let key = Buffer.from(crypto.getRandomValues(new Uint8Array(256 / 8)));
|
||||
let iv = Buffer.concat([Buffer.from(crypto.getRandomValues(new Uint8Array(8))), Buffer.alloc(8)]); // Initialization vector.
|
||||
|
||||
// Encrypt
|
||||
let aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(iv));
|
||||
let encryptedBytes = aesCtr.encrypt(data);
|
||||
|
||||
// Calculate sha256
|
||||
let hash = await crypto.subtle.digest("SHA-256", encryptedBytes);
|
||||
console.error("HASH GENERATED", Buffer.from(hash));
|
||||
|
||||
const jwk = {
|
||||
kty: "oct",
|
||||
key_ops: ["encrypt", "decrypt"],
|
||||
alg: "A256CTR",
|
||||
k: key.toString("base64").replaceAll(/\//g, "_").replaceAll(/\+/g, "-"),
|
||||
ext: true,
|
||||
};
|
||||
|
||||
const encryptedFile = {
|
||||
mimetype: mime,
|
||||
key: jwk,
|
||||
iv: Buffer.from(iv).toString("base64").replace(/=/g, ""),
|
||||
hashes: { sha256: Buffer.from(hash).toString("base64").replace(/=/g, "") },
|
||||
v: "v2",
|
||||
};
|
||||
return [encryptedBytes, encryptedFile];
|
||||
}
|
||||
|
||||
sendFile(matrixClient, roomId, file, onUploadProgress, threadRoot, dimensions) {
|
||||
const uploadPromise = new UploadPromise(undefined);
|
||||
uploadPromise.wrappedPromise = new Promise((resolve, reject) => {
|
||||
var reader = new FileReader();
|
||||
|
|
@ -439,131 +403,140 @@ class Util {
|
|||
reject("Aborted");
|
||||
return;
|
||||
}
|
||||
const fileContents = e.target.result;
|
||||
var data = new Uint8Array(fileContents);
|
||||
try {
|
||||
const fileContents = e.target.result;
|
||||
|
||||
const info = {
|
||||
mimetype: file.type,
|
||||
size: file.size,
|
||||
};
|
||||
var data = new Uint8Array(fileContents);
|
||||
let thumbnailData = undefined;
|
||||
let thumbnailInfo = undefined;
|
||||
|
||||
// If audio, send duration in ms as well
|
||||
if (file.duration) {
|
||||
info.duration = file.duration;
|
||||
}
|
||||
|
||||
var description = file.name;
|
||||
var msgtype = "m.file";
|
||||
if (file.type.startsWith("image/")) {
|
||||
msgtype = "m.image";
|
||||
} else if (file.type.startsWith("audio/")) {
|
||||
msgtype = "m.audio";
|
||||
} else if (file.type.startsWith("video/")) {
|
||||
msgtype = "m.video";
|
||||
}
|
||||
|
||||
const opts = {
|
||||
type: file.type,
|
||||
name: description,
|
||||
progressHandler: onUploadProgress,
|
||||
onlyContentUri: false,
|
||||
};
|
||||
|
||||
var messageContent = {
|
||||
body: description,
|
||||
info: info,
|
||||
msgtype: msgtype,
|
||||
};
|
||||
|
||||
// If thread root (an eventId) is set, add that here
|
||||
if (threadRoot) {
|
||||
messageContent["m.relates_to"] = {
|
||||
rel_type: this.threadMessageType(),
|
||||
event_id: threadRoot,
|
||||
const info = {
|
||||
mimetype: file.type,
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Set filename for files
|
||||
if (msgtype == "m.file") {
|
||||
messageContent.filename = file.name;
|
||||
}
|
||||
// If audio, send duration in ms as well
|
||||
if (file.duration) {
|
||||
info.duration = file.duration;
|
||||
}
|
||||
|
||||
if (!matrixClient.isRoomEncrypted(roomId)) {
|
||||
// Not encrypted.
|
||||
const promise = matrixClient.uploadContent(data, opts);
|
||||
uploadPromise.onAbort = () => {
|
||||
matrixClient.cancelUpload(promise);
|
||||
};
|
||||
promise
|
||||
.then((response) => {
|
||||
messageContent.url = response.content_uri;
|
||||
return msgtype == "m.audio" ? this.generateWaveform(fileContents, messageContent) : true;
|
||||
})
|
||||
.then(() => {
|
||||
return this.sendMessage(matrixClient, roomId, "m.room.message", messageContent);
|
||||
})
|
||||
.then((result) => {
|
||||
resolve(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
return; // Don't fall through
|
||||
}
|
||||
var description = file.name;
|
||||
var msgtype = "m.file";
|
||||
if (file.type.startsWith("image/")) {
|
||||
msgtype = "m.image";
|
||||
|
||||
let key = Buffer.from(crypto.getRandomValues(new Uint8Array(256 / 8)));
|
||||
let iv = Buffer.concat([Buffer.from(crypto.getRandomValues(new Uint8Array(8))), Buffer.alloc(8)]); // Initialization vector.
|
||||
|
||||
// Encrypt
|
||||
var aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(iv));
|
||||
var encryptedBytes = aesCtr.encrypt(data);
|
||||
data = encryptedBytes;
|
||||
|
||||
// Calculate sha256
|
||||
let hash = await crypto.subtle.digest("SHA-256", data);
|
||||
|
||||
const jwk = {
|
||||
kty: "oct",
|
||||
key_ops: ["encrypt", "decrypt"],
|
||||
alg: "A256CTR",
|
||||
k: key.toString("base64").replaceAll(/\//g, "_").replaceAll(/\+/g, "-"),
|
||||
ext: true,
|
||||
};
|
||||
|
||||
const encryptedFile = {
|
||||
mimetype: file.type,
|
||||
key: jwk,
|
||||
iv: Buffer.from(iv).toString("base64").replace(/=/g, ""),
|
||||
hashes: { sha256: Buffer.from(hash).toString("base64").replace(/=/g, "") },
|
||||
v: "v2",
|
||||
};
|
||||
|
||||
messageContent.file = encryptedFile;
|
||||
|
||||
// Encrypted data sent as octet-stream!
|
||||
opts.type = "application/octet-stream";
|
||||
|
||||
const promise = matrixClient.uploadContent(data, opts);
|
||||
uploadPromise.onAbort = () => {
|
||||
matrixClient.cancelUpload(promise);
|
||||
};
|
||||
promise
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
return reject(response.error);
|
||||
// Generate thumbnail?
|
||||
if (dimensions) {
|
||||
const w = dimensions.width;
|
||||
const h = dimensions.height;
|
||||
if (w > THUMBNAIL_MAX_WIDTH || h > THUMBNAIL_MAX_HEIGHT) {
|
||||
var aspect = w / h;
|
||||
var newWidth = parseInt((w > h ? THUMBNAIL_MAX_WIDTH : THUMBNAIL_MAX_HEIGHT * aspect).toFixed());
|
||||
var newHeight = parseInt((w > h ? THUMBNAIL_MAX_WIDTH / aspect : THUMBNAIL_MAX_HEIGHT).toFixed());
|
||||
const scaled = await imageResize(file, {
|
||||
format: "webp",
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
outputType: "blob",
|
||||
}).catch(() => {return Promise.resolve(undefined)});
|
||||
if (scaled && file.size > scaled.size) {
|
||||
thumbnailData = new Uint8Array(await scaled.arrayBuffer());
|
||||
thumbnailInfo = {
|
||||
mimetype: scaled.type,
|
||||
size: scaled.size,
|
||||
h: newHeight,
|
||||
w: newWidth,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (file.type.startsWith("audio/")) {
|
||||
msgtype = "m.audio";
|
||||
} else if (file.type.startsWith("video/")) {
|
||||
msgtype = "m.video";
|
||||
}
|
||||
|
||||
var messageContent = {
|
||||
body: description,
|
||||
info: info,
|
||||
msgtype: msgtype,
|
||||
};
|
||||
|
||||
// If thread root (an eventId) is set, add that here
|
||||
if (threadRoot) {
|
||||
messageContent["m.relates_to"] = {
|
||||
rel_type: this.threadMessageType(),
|
||||
event_id: threadRoot,
|
||||
};
|
||||
}
|
||||
|
||||
// Set filename for files
|
||||
if (msgtype == "m.file") {
|
||||
messageContent.filename = file.name;
|
||||
}
|
||||
|
||||
const useEncryption = matrixClient.isRoomEncrypted(roomId);
|
||||
|
||||
const dataUploadOpts = {
|
||||
type: useEncryption ? "application/octet-stream" : file.type,
|
||||
name: description,
|
||||
progressHandler: onUploadProgress,
|
||||
onlyContentUri: false,
|
||||
};
|
||||
|
||||
if (useEncryption) {
|
||||
const [encryptedBytes, encryptedFile] = await this.encryptFileAndGenerateInfo(data, file.type);
|
||||
messageContent.file = encryptedFile;
|
||||
data = encryptedBytes;
|
||||
}
|
||||
|
||||
if (thumbnailData) {
|
||||
messageContent.thumbnail_info = thumbnailInfo;
|
||||
if (useEncryption) {
|
||||
console.error("Encrypt thumb thumb");
|
||||
const [encryptedBytes, encryptedFile] = await this.encryptFileAndGenerateInfo(thumbnailData, file.type);
|
||||
messageContent.info.thumbnail_file = encryptedFile;
|
||||
thumbnailData = encryptedBytes;
|
||||
}
|
||||
const thumbnailUploadOpts = {
|
||||
type: useEncryption ? "application/octet-stream" : file.type,
|
||||
name: "thumb:" + description,
|
||||
progressHandler: onUploadProgress,
|
||||
onlyContentUri: false,
|
||||
};
|
||||
const thumbUploadPromise = matrixClient.uploadContent(thumbnailData, thumbnailUploadOpts);
|
||||
uploadPromise.onAbort = () => {
|
||||
matrixClient.cancelUpload(thumbUploadPromise);
|
||||
};
|
||||
const thumbnailResponse = await thumbUploadPromise;
|
||||
if (useEncryption) {
|
||||
messageContent.info.thumbnail_file.url = thumbnailResponse.content_uri;
|
||||
} else {
|
||||
messageContent.info.thumbnail_url = thumbnailResponse.content_uri;
|
||||
}
|
||||
}
|
||||
|
||||
const dataUploadPromise = matrixClient.uploadContent(data, dataUploadOpts);
|
||||
uploadPromise.onAbort = () => {
|
||||
matrixClient.cancelUpload(dataUploadPromise);
|
||||
};
|
||||
const response = await dataUploadPromise;
|
||||
if (useEncryption) {
|
||||
messageContent.file.url = response.content_uri;
|
||||
return msgtype == "m.audio" ? this.generateWaveform(fileContents, messageContent) : true;
|
||||
})
|
||||
.then(() => {
|
||||
return this.sendMessage(matrixClient, roomId, "m.room.message", messageContent);
|
||||
})
|
||||
.then((result) => {
|
||||
resolve(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
} else {
|
||||
messageContent.url = response.content_uri;
|
||||
}
|
||||
|
||||
// Generate audio waveforms
|
||||
if (msgtype == "m.audio") {
|
||||
this.generateWaveform(fileContents, messageContent);
|
||||
}
|
||||
|
||||
const result = await this.sendMessage(matrixClient, roomId, "m.room.message", messageContent);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = (err) => {
|
||||
reject(err);
|
||||
|
|
@ -1248,5 +1221,5 @@ class Util {
|
|||
};
|
||||
return objectIterator(sourceObj);
|
||||
}
|
||||
};
|
||||
}
|
||||
export default new Util();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import util, { STATE_EVENT_ROOM_DELETED, STATE_EVENT_ROOM_TYPE, ROOM_TYPE_CHANNE
|
|||
import User from "../models/user";
|
||||
import * as LocalStorageCryptoStoreClass from "matrix-js-sdk/lib/crypto/store/localStorage-crypto-store";
|
||||
import rememberMeMixin from "../components/rememberMeMixin";
|
||||
import { AttachmentManager } from "../models/attachmentManager";
|
||||
|
||||
const LocalStorageCryptoStore = LocalStorageCryptoStoreClass.LocalStorageCryptoStore;
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ export default {
|
|||
notificationCount: 0,
|
||||
legacyCryptoStore: undefined,
|
||||
tokenRefreshPromise: undefined,
|
||||
attachmentManager: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -352,6 +354,9 @@ export default {
|
|||
}
|
||||
this.useAuthedMedia = await this.matrixClient.isVersionSupported("v1.11");
|
||||
|
||||
// Create the attachment manager
|
||||
this.attachmentManager = new AttachmentManager(this.matrixClient, this.useAuthedMedia, this.$config.maxSizeAutoDownloads);
|
||||
|
||||
// Ready to use! Start by loading rooms.
|
||||
this.initClient();
|
||||
return user;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue