From ec79a33eab74c7995a1cddd29587628291c51729 Mon Sep 17 00:00:00 2001 From: N-Pex Date: Mon, 9 Jun 2025 09:34:43 +0200 Subject: [PATCH 01/11] Fixes after refactoring --- src/assets/css/channel.scss | 4 ++-- src/assets/css/chat.scss | 16 +++++++++++++++- src/assets/css/create.scss | 2 +- src/assets/css/filedrop.scss | 8 ++++---- src/components/AudioLayout.vue | 2 +- src/components/CreateChannel.vue | 2 +- src/components/CreateFileDrop.vue | 2 +- src/components/CreateRoom.vue | 2 +- src/components/Profile.vue | 2 +- src/components/RoomAvatarPicker.vue | 2 +- src/components/messages/AudioPlayer.vue | 2 +- 11 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/assets/css/channel.scss b/src/assets/css/channel.scss index 343da1d..f6f862e 100644 --- a/src/assets/css/channel.scss +++ b/src/assets/css/channel.scss @@ -104,7 +104,7 @@ /* full bleed */ padding: 0 0 0 0; border-radius: 0 !important; - .v-image, video { + .v-img, video { border-radius: 0 !important; } } @@ -126,7 +126,7 @@ } /* Make all images 'cover' */ - .v-image__image { + .v-img__image { background-size: cover; } } diff --git a/src/assets/css/chat.scss b/src/assets/css/chat.scss index c69b606..ae5a5ce 100644 --- a/src/assets/css/chat.scss +++ b/src/assets/css/chat.scss @@ -575,12 +575,13 @@ body { } .bubble.image-bubble { padding: 0px; + overflow: hidden; display: inline-block; width: 70%; max-width: 70%; cursor: pointer; - .v-image, + .v-img, video { border-radius: 10px 10px 0 10px; [dir="rtl"] & { @@ -1618,3 +1619,16 @@ body { color: #1d1d1d; } } + +.c2pa-badge { + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + overflow: hidden; + .v-icon { + width: 100%; + height: 100%; + } +} \ No newline at end of file diff --git a/src/assets/css/create.scss b/src/assets/css/create.scss index 514fd5d..40466d2 100644 --- a/src/assets/css/create.scss +++ b/src/assets/css/create.scss @@ -173,7 +173,7 @@ display: flex; align-items: center; justify-content: center; - .v-image { + .v-img { flex: 0 0 14px; width: 14px; height: 10px; diff --git a/src/assets/css/filedrop.scss b/src/assets/css/filedrop.scss index 76f7b17..f3e10b0 100644 --- a/src/assets/css/filedrop.scss +++ b/src/assets/css/filedrop.scss @@ -100,7 +100,7 @@ $small-button-height: 36px; } border-radius: 19px; overflow: hidden; - .v-image { + .v-img { width: 100%; height: 100%; object-fit: cover; @@ -145,7 +145,7 @@ $small-button-height: 36px; &.noborder { border: 2px solid transparent; } - .v-image { + .v-img { width: 100%; height: 100%; object-fit: cover; @@ -254,7 +254,7 @@ $small-button-height: 36px; position: absolute; overflow: hidden; opacity: 0; - .v-image { + .v-img { width: 100%; height: 100%; object-fit: cover; @@ -296,7 +296,7 @@ $small-button-height: 36px; background: linear-gradient(0deg, #26242b 0%, #26242b 100%), #fff; position: relative; padding: 8px; - .v-image { + .v-img { width: $min-touch-target; height: $min-touch-target; border-radius: 8px; diff --git a/src/components/AudioLayout.vue b/src/components/AudioLayout.vue index ed66778..5b58c18 100644 --- a/src/components/AudioLayout.vue +++ b/src/components/AudioLayout.vue @@ -55,7 +55,7 @@ $vuetify.icons.rewind - + $vuetify.icons.pause_circle diff --git a/src/components/CreateChannel.vue b/src/components/CreateChannel.vue index 8cc00ab..e000d55 100644 --- a/src/components/CreateChannel.vue +++ b/src/components/CreateChannel.vue @@ -29,7 +29,7 @@
{{ creatingRoomStatus }} + :model-value="creatingRoomProgress" color="primary" width="2" size="20">
{{ $t("getlink.next") }} diff --git a/src/components/CreateFileDrop.vue b/src/components/CreateFileDrop.vue index aac86f1..ad38869 100644 --- a/src/components/CreateFileDrop.vue +++ b/src/components/CreateFileDrop.vue @@ -27,7 +27,7 @@
{{ creatingRoomStatus }} + :model-value="creatingRoomProgress" color="primary" width="2" size="20">
{{ $t("getlink.next") }} diff --git a/src/components/CreateRoom.vue b/src/components/CreateRoom.vue index 54276a8..1e09ddc 100644 --- a/src/components/CreateRoom.vue +++ b/src/components/CreateRoom.vue @@ -56,7 +56,7 @@
{{ creatingRoomStatus }} + :model-value="creatingRoomProgress" color="primary" width="2" size="20">
{{ $t("getlink.next") }} diff --git a/src/components/Profile.vue b/src/components/Profile.vue index 06cebcd..59b9252 100644 --- a/src/components/Profile.vue +++ b/src/components/Profile.vue @@ -41,7 +41,7 @@ :rotate="360" v-else :width="3" - :value="loadValue" + :model-value="loadValue" color="primary" > {{ loadValue }} diff --git a/src/components/RoomAvatarPicker.vue b/src/components/RoomAvatarPicker.vue index 1c2b3e8..a2e7990 100644 --- a/src/components/RoomAvatarPicker.vue +++ b/src/components/RoomAvatarPicker.vue @@ -18,7 +18,7 @@ :rotate="360" v-else :width="3" - :value="loadValue" + :model-value="loadValue" color="primary" > {{ loadValue }} diff --git a/src/components/messages/AudioPlayer.vue b/src/components/messages/AudioPlayer.vue index 3c80a65..58972e8 100644 --- a/src/components/messages/AudioPlayer.vue +++ b/src/components/messages/AudioPlayer.vue @@ -1,6 +1,6 @@ @@ -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)); + }, + }, }; @@ -159,4 +205,4 @@ export default { padding: 2px; } } - \ No newline at end of file + diff --git a/src/components/messages/MessageOutgoingImage.vue b/src/components/messages/MessageOutgoingImage.vue index 5225196..c0a6ba8 100644 --- a/src/components/messages/MessageOutgoingImage.vue +++ b/src/components/messages/MessageOutgoingImage.vue @@ -1,36 +1,38 @@ \ No newline at end of file + diff --git a/src/components/messages/MessageOutgoingThread.vue b/src/components/messages/MessageOutgoingThread.vue index d00f514..12048f7 100644 --- a/src/components/messages/MessageOutgoingThread.vue +++ b/src/components/messages/MessageOutgoingThread.vue @@ -3,46 +3,46 @@
{{ inReplyToSender }}
-
+
-
- + - + block - {{ 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") + }} - {{ $t('message.edited') }} + {{ $t("message.edited") }}
- + - + \ No newline at end of file + diff --git a/src/components/messages/messageMixin.js b/src/components/messages/messageMixin.js index bcce0e7..df7beed 100644 --- a/src/components/messages/messageMixin.js +++ b/src/components/messages/messageMixin.js @@ -75,8 +75,8 @@ export default { } if (newValue) { newValue.on("Relations.add", this.onAddRelation); + this.processThread(); } - this.processThread(); }, immediate: true, }, diff --git a/src/components/sendAttachmentsMixin.d.ts b/src/components/sendAttachmentsMixin.d.ts new file mode 100644 index 0000000..4f143c5 --- /dev/null +++ b/src/components/sendAttachmentsMixin.d.ts @@ -0,0 +1 @@ +declare module 'sendAttachmentsMixin'; \ No newline at end of file diff --git a/src/components/sendAttachmentsMixin.js b/src/components/sendAttachmentsMixin.js deleted file mode 100644 index 7f4d03d..0000000 --- a/src/components/sendAttachmentsMixin.js +++ /dev/null @@ -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); - }, - } -} \ No newline at end of file diff --git a/src/components/sendAttachmentsMixin.ts b/src/components/sendAttachmentsMixin.ts new file mode 100644 index 0000000..d496ff6 --- /dev/null +++ b/src/components/sendAttachmentsMixin.ts @@ -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 | 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)); + }, + }, +}); diff --git a/src/models/attachment.ts b/src/models/attachment.ts new file mode 100644 index 0000000..20efb69 --- /dev/null +++ b/src/models/attachment.ts @@ -0,0 +1,51 @@ +export class UploadPromise { + wrappedPromise: Promise; + aborted: boolean = false; + onAbort: (() => void) | undefined = undefined; + + constructor(wrappedPromise: Promise) { + 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 | 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; +}; diff --git a/src/models/attachmentManager.ts b/src/models/attachmentManager.ts new file mode 100644 index 0000000..74171bd --- /dev/null +++ b/src/models/attachmentManager.ts @@ -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; + thumbnailPromise?: Promise; + attachmentProgress?: ((progress: number) => void)[]; + thumbnailProgress?: ((progress: number) => void)[]; +}; + +export class AttachmentManager { + matrixClient: MatrixClient; + useAuthedMedia: boolean; + maxSizeUploads: number; + maxSizeAutoDownloads: number; + + cache: Map; + + 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 { + 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 { + 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 { + 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 { + 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 { + 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"); + }); + }); + } +} diff --git a/src/models/eventAttachment.ts b/src/models/eventAttachment.ts new file mode 100644 index 0000000..2faf41a --- /dev/null +++ b/src/models/eventAttachment.ts @@ -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; + thumbnailPromise?: Promise; +}; diff --git a/src/plugins/proofmode.ts b/src/plugins/proofmode.ts new file mode 100644 index 0000000..0905a0e --- /dev/null +++ b/src/plugins/proofmode.ts @@ -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 { + 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(); \ No newline at end of file diff --git a/src/plugins/proofmodeWorker.js b/src/plugins/proofmodeWorker.js new file mode 100644 index 0000000..fa51de9 --- /dev/null +++ b/src/plugins/proofmodeWorker.js @@ -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); diff --git a/src/plugins/utils.js b/src/plugins/utils.js index 9a0784b..7cbe462 100644 --- a/src/plugins/utils.js +++ b/src/plugins/utils.js @@ -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(); diff --git a/src/services/matrix.service.js b/src/services/matrix.service.js index b963eee..1d8c2b8 100644 --- a/src/services/matrix.service.js +++ b/src/services/matrix.service.js @@ -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; diff --git a/tsconfig.json b/tsconfig.json index 37f3149..c0d70c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,18 @@ { - "compilerOptions": { - "strict": true - }, -} \ No newline at end of file + "compilerOptions": { + "target": "es2024", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ES2024" + ], + }, + //"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/components/file_mode/ThumbnailView.vue"] +} diff --git a/vite.config.mjs b/vite.config.mjs index d6e10f9..c5b84e8 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -3,34 +3,16 @@ import vue from "@vitejs/plugin-vue"; import vueJsx from "@vitejs/plugin-vue-jsx"; import vitePluginVuetify from "vite-plugin-vuetify"; import { fileURLToPath, URL } from "node:url"; -import Components from "unplugin-vue-components/vite"; import { viteStaticCopy } from 'vite-plugin-static-copy'; -import nodePolyfills from 'rollup-plugin-polyfill-node'; -import { resolve } from "path"; -import commonjs from '@rollup/plugin-commonjs'; - -function VuetifyResolver() { - return { - type: 'component', - resolve: (name) => { - console.log("rESOLVE", name); - if (name.match(/^V[A-Z]/) && !name.includes("VEmojiPicker")) - return { name, from: './node_modules/vuetify/components' } - }, - } -} +import wasm from "vite-plugin-wasm"; +import topLevelAwait from "vite-plugin-top-level-await"; // https://vitejs.dev/config/ export default defineConfig(({mode}) => ({ base: "./", plugins: [ - // commonjs({ - // include: /node_modules/, - // requireReturnsDefault: 'auto', // <---- this solves default issue - // }), - // commonjs({ - // exclude: ["*vuex-persist*", "*deepmerge*"] - // }), + wasm(), + topLevelAwait(), vue({ template: { compilerOptions: { @@ -40,9 +22,6 @@ export default defineConfig(({mode}) => ({ }), vueJsx(), vitePluginVuetify(), - // Components({ - // resolvers: [VuetifyResolver()], - // }), viteStaticCopy({ targets: [ { @@ -68,54 +47,8 @@ export default defineConfig(({mode}) => ({ { find: "~@", replacement: fileURLToPath(new URL("./src", import.meta.url)) }, { find: "vue", replacement: fileURLToPath(new URL("./node_modules/vue/dist/vue.esm-bundler.js", import.meta.url)) }, ], - }, - define: { - //global: "window", - //module: {}, - Lame: "window.Lame", - Presets: "window.Presets", - GainAnalysis: "window.GainAnalysis", - QuantizePVT: "window.QuantizePVT", - Quantize: "window.Quantize", - Takehiro: "window.Takehiro", - Reservoir: "window.Reservoir", - MPEGMode: "window.MPEGMode", - BitStream: "window.BitStream", }, build: { - commonjsOptions: { transformMixedEsModules: true } // Change + commonjsOptions: { transformMixedEsModules: true } } - // optimizeDeps: { - // include: ["deepmerge", "vuex-persist"], - // }, - // optimizeDeps: { - // include: [ - // "vuex-persist", "vue-sanitize" - // ], - // esbuildOptions:{ - // plugins:[ - // commonjs() - // ] - // } - // }, - // build: { - // commonjsOptions: { - // include: [/node_modules/], - // requireReturnsDefault: true, - // exclude: ["vuex-persist"] - // } - // }, - // rollupOptions: { - // //Here, we are externalizing Vue to prevent it to be bundled - // //with our library - // external: ["vue"], - // //Add this so the UMD build will recognize the global variables - // //of externalized dependencies - // output: { - // globals: { - // vue: "Vue", - // }, - // exports: "named", - // }, - // }, })); From 77eebafb7911ceebc08338e4c3fd47604c5ff6ba Mon Sep 17 00:00:00 2001 From: N-Pex Date: Mon, 9 Jun 2025 09:44:49 +0200 Subject: [PATCH 03/11] Update ThumbnailView.vue --- src/components/file_mode/ThumbnailView.vue | 195 +++++++++++---------- 1 file changed, 103 insertions(+), 92 deletions(-) diff --git a/src/components/file_mode/ThumbnailView.vue b/src/components/file_mode/ThumbnailView.vue index d8fd0fd..0ca4add 100644 --- a/src/components/file_mode/ThumbnailView.vue +++ b/src/components/file_mode/ThumbnailView.vue @@ -1,113 +1,124 @@ - - \ No newline at end of file + From 44578048aa46584c427e6d756917848afcccbedf Mon Sep 17 00:00:00 2001 From: N-Pex Date: Tue, 10 Jun 2025 13:35:51 +0200 Subject: [PATCH 04/11] Migrate media thread views to composition API --- package-lock.json | 21 + package.json | 1 + src/components/Chat.vue | 12 +- src/components/ImageWithProgress.vue | 8 +- src/components/chatMixin.js | 4 +- src/components/file_mode/GalleryItemsView.vue | 2 +- src/components/file_mode/ThumbnailView.vue | 15 +- .../messages/MessageIncomingImage.vue | 38 +- .../messages/MessageIncomingThread.vue | 208 ---------- .../messages/MessageOutgoingImage.vue | 41 +- .../messages/MessageOutgoingThread.vue | 195 --------- src/components/messages/QuickReactions.vue | 2 +- .../channel/QuickReactionsChannel.vue | 2 +- .../messages/composition/MessageIncoming.vue | 77 ++++ .../composition/MessageIncomingThread.vue | 220 ++++++++++ .../messages/composition/MessageOutgoing.vue | 95 +++++ .../composition/MessageOutgoingThread.vue | 218 ++++++++++ .../messages/composition/messageMixin.ts | 384 ++++++++++++++++++ src/main.js | 24 +- src/models/attachmentManager.ts | 159 +++----- src/models/eventAttachment.ts | 15 +- src/services/matrix.service.js | 1 + 22 files changed, 1144 insertions(+), 598 deletions(-) delete mode 100644 src/components/messages/MessageIncomingThread.vue delete mode 100644 src/components/messages/MessageOutgoingThread.vue create mode 100644 src/components/messages/composition/MessageIncoming.vue create mode 100644 src/components/messages/composition/MessageIncomingThread.vue create mode 100644 src/components/messages/composition/MessageOutgoing.vue create mode 100644 src/components/messages/composition/MessageOutgoingThread.vue create mode 100644 src/components/messages/composition/messageMixin.ts diff --git a/package-lock.json b/package-lock.json index c1d9c8b..14088e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "rollup-plugin-polyfill-node": "^0.13.0", "sass": "^1.86.0", "sass-loader": "^10", + "typescript": "^5.8.3", "unplugin-vue-components": "^28.4.1", "vite": "^6.2.2", "vite-plugin-static-copy": "^2.3.0", @@ -7032,6 +7033,20 @@ "node": ">= 0.8.0" } }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/ufo": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", @@ -12600,6 +12615,12 @@ "prelude-ls": "^1.2.1" } }, + "typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true + }, "ufo": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", diff --git a/package.json b/package.json index 873186b..dadc434 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "rollup-plugin-polyfill-node": "^0.13.0", "sass": "^1.86.0", "sass-loader": "^10", + "typescript": "^5.8.3", "unplugin-vue-components": "^28.4.1", "vite": "^6.2.2", "vite-plugin-static-copy": "^2.3.0", diff --git a/src/components/Chat.vue b/src/components/Chat.vue index 713672a..b6cace4 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -89,7 +89,7 @@ see below. Otherwise things like context menus won't work as designed. --> 0) ? contentArr[0].replace(/^> (<.*> )?/g, "") : ""; }, heartEmoji() { - return this.$refs.emojiPicker.mapEmojis["Symbols"].find(({ aliases }) => aliases.includes('heart')).data; + return "❤️"; }, compActiveMember() { const currentUserId= this.selectedEvent?.sender.userId || this.$matrix.currentUserId @@ -1347,7 +1348,7 @@ export default { const sel = "[eventId=\"" + parentEvent.getId() + "\"]"; const element = document.querySelector(sel); if (element) { - this.onLayoutChange(fn, element); + this.onLayoutChange({action: fn, element: element}); } else { fn(); } @@ -1377,7 +1378,7 @@ export default { const sel = "[eventId=\"" + parentEvent.getId() + "\"]"; const element = document.querySelector(sel); if (element) { - this.onLayoutChange(fn, element); + this.onLayoutChange({action: fn, element: element}); } else { fn(); } @@ -1598,7 +1599,8 @@ export default { * @param {} action A function that performs desired layout changes. * @param {*} element Root element for the chat message. */ - onLayoutChange(action, element) { + onLayoutChange(event) { + const { action, element } = event; if (!element || !element.parentElement || this.useVoiceMode || this.useFileModeNonAdmin) { action(); return diff --git a/src/components/ImageWithProgress.vue b/src/components/ImageWithProgress.vue index c153d98..8cf9c81 100644 --- a/src/components/ImageWithProgress.vue +++ b/src/components/ImageWithProgress.vue @@ -1,6 +1,6 @@ @@ -10,10 +10,12 @@ 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"; +import LoadProgress from "./LoadProgress.vue"; +import { VImg } from "vuetify/components/VImg"; + export default { name: "ImageWithProgress", + extends: VImg, components: { LoadProgress }, props: { loadingProgress: { diff --git a/src/components/chatMixin.js b/src/components/chatMixin.js index 2e9bffc..8453bad 100644 --- a/src/components/chatMixin.js +++ b/src/components/chatMixin.js @@ -7,7 +7,7 @@ import MessageIncomingAudio from "./messages/MessageIncomingAudio.vue"; import MessageIncomingVideo from "./messages/MessageIncomingVideo.vue"; import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue"; import MessageIncomingPoll from "./messages/MessageIncomingPoll.vue"; -import MessageIncomingThread from "./messages/MessageIncomingThread.vue"; +import MessageIncomingThread from "./messages/composition/MessageIncomingThread.vue"; import MessageOutgoingText from "./messages/MessageOutgoingText"; import MessageOutgoingFile from "./messages/MessageOutgoingFile"; import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue"; @@ -15,7 +15,7 @@ import MessageOutgoingAudio from "./messages/MessageOutgoingAudio.vue"; import MessageOutgoingVideo from "./messages/MessageOutgoingVideo.vue"; import MessageOutgoingSticker from "./messages/MessageOutgoingSticker.vue"; import MessageOutgoingPoll from "./messages/MessageOutgoingPoll.vue"; -import MessageOutgoingThread from "./messages/MessageOutgoingThread.vue"; +import MessageOutgoingThread from "./messages/composition/MessageOutgoingThread.vue"; import MessageIncomingImageExport from "./messages/export/MessageIncomingImageExport"; import MessageIncomingAudioExport from "./messages/export/MessageIncomingAudioExport"; import MessageIncomingVideoExport from "./messages/export/MessageIncomingVideoExport"; diff --git a/src/components/file_mode/GalleryItemsView.vue b/src/components/file_mode/GalleryItemsView.vue index fec79a2..4414f85 100644 --- a/src/components/file_mode/GalleryItemsView.vue +++ b/src/components/file_mode/GalleryItemsView.vue @@ -18,7 +18,7 @@
- +
diff --git a/src/components/file_mode/ThumbnailView.vue b/src/components/file_mode/ThumbnailView.vue index 0ca4add..e73fdee 100644 --- a/src/components/file_mode/ThumbnailView.vue +++ b/src/components/file_mode/ThumbnailView.vue @@ -1,5 +1,5 @@ + diff --git a/src/components/messages/MessageIncomingImage.vue b/src/components/messages/MessageIncomingImage.vue index 5f7a579..16e4e40 100644 --- a/src/components/messages/MessageIncomingImage.vue +++ b/src/components/messages/MessageIncomingImage.vue @@ -4,14 +4,14 @@
- + @@ -26,10 +26,7 @@ export default { components: { MessageIncoming, ImageWithProgress }, data() { return { - src: undefined, - thumbnailSrc: undefined, - srcProgress: -1, - thumbnailProgress: -1, + eventAttachment: {}, cover: true, contain: false, dialog: false, @@ -42,17 +39,7 @@ export default { hammerInstance.on("singletap doubletap", (ev) => { 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.eventAttachment?.loadSrc(); this.dialog = true; } }); @@ -74,20 +61,11 @@ export default { 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); - }); + this.eventAttachment = this.$matrix.attachmentManager.getEventAttachment(this.event); + this.eventAttachment?.loadThumbnail(); }, beforeUnmount() { - this.$matrix.attachmentManager.releaseEvent(this.event); + this.eventAttachment?.release(); }, }; diff --git a/src/components/messages/MessageIncomingThread.vue b/src/components/messages/MessageIncomingThread.vue deleted file mode 100644 index 5e09ac6..0000000 --- a/src/components/messages/MessageIncomingThread.vue +++ /dev/null @@ -1,208 +0,0 @@ - - - - - - - diff --git a/src/components/messages/MessageOutgoingImage.vue b/src/components/messages/MessageOutgoingImage.vue index c0a6ba8..6b8fe4e 100644 --- a/src/components/messages/MessageOutgoingImage.vue +++ b/src/components/messages/MessageOutgoingImage.vue @@ -4,14 +4,14 @@
- + @@ -26,10 +26,7 @@ export default { components: { MessageOutgoing, ImageWithProgress }, data() { return { - src: undefined, - thumbnailSrc: undefined, - srcProgress: -1, - thumbnailProgress: -1, + eventAttachment: {}, cover: true, contain: false, dialog: false, @@ -42,23 +39,15 @@ export default { hammerInstance.on("singletap doubletap", (ev) => { 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.eventAttachment?.loadSrc(); this.dialog = true; } }); }, }, mounted() { + console.error("Mounted outgoing image, load thumbnail!"); + 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. @@ -72,21 +61,11 @@ export default { 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); - }); + this.eventAttachment = this.$matrix.attachmentManager.getEventAttachment(this.event); + this.eventAttachment?.loadThumbnail(); }, beforeUnmount() { - this.$matrix.attachmentManager.releaseEvent(this.event); + this.eventAttachment?.release(); }, }; diff --git a/src/components/messages/MessageOutgoingThread.vue b/src/components/messages/MessageOutgoingThread.vue deleted file mode 100644 index 12048f7..0000000 --- a/src/components/messages/MessageOutgoingThread.vue +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - diff --git a/src/components/messages/QuickReactions.vue b/src/components/messages/QuickReactions.vue index 6a405c4..6f3774f 100644 --- a/src/components/messages/QuickReactions.vue +++ b/src/components/messages/QuickReactions.vue @@ -105,7 +105,7 @@ export default { this.reactions = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), 'm.annotation', 'm.reaction'); }, onClickEmoji(emoji) { - this.$bubble('send-quick-reaction', {reaction:emoji, event:this.event}); + this.$emit('send-quick-reaction', {reaction:emoji, event:this.event}); }, onAddRelation(ignoredevent) { this.processReactions(); diff --git a/src/components/messages/channel/QuickReactionsChannel.vue b/src/components/messages/channel/QuickReactionsChannel.vue index 255e724..83b2f05 100644 --- a/src/components/messages/channel/QuickReactionsChannel.vue +++ b/src/components/messages/channel/QuickReactionsChannel.vue @@ -69,7 +69,7 @@ export default { this.reactions = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), 'm.annotation', 'm.reaction'); }, onClickEmoji(emoji) { - this.$bubble('send-quick-reaction', {reaction:emoji, event:this.event}); + this.$emit('send-quick-reaction', {reaction:emoji, event:this.event}); }, onAddRelation(ignoredevent) { this.processReactions(); diff --git a/src/components/messages/composition/MessageIncoming.vue b/src/components/messages/composition/MessageIncoming.vue new file mode 100644 index 0000000..5201b6f --- /dev/null +++ b/src/components/messages/composition/MessageIncoming.vue @@ -0,0 +1,77 @@ + + + + \ No newline at end of file diff --git a/src/components/messages/composition/MessageIncomingThread.vue b/src/components/messages/composition/MessageIncomingThread.vue new file mode 100644 index 0000000..c242981 --- /dev/null +++ b/src/components/messages/composition/MessageIncomingThread.vue @@ -0,0 +1,220 @@ + + + + + + diff --git a/src/components/messages/composition/MessageOutgoing.vue b/src/components/messages/composition/MessageOutgoing.vue new file mode 100644 index 0000000..e2b11d8 --- /dev/null +++ b/src/components/messages/composition/MessageOutgoing.vue @@ -0,0 +1,95 @@ + + + + \ No newline at end of file diff --git a/src/components/messages/composition/MessageOutgoingThread.vue b/src/components/messages/composition/MessageOutgoingThread.vue new file mode 100644 index 0000000..55eefb9 --- /dev/null +++ b/src/components/messages/composition/MessageOutgoingThread.vue @@ -0,0 +1,218 @@ + + + + + + diff --git a/src/components/messages/composition/messageMixin.ts b/src/components/messages/composition/messageMixin.ts new file mode 100644 index 0000000..1b9906b --- /dev/null +++ b/src/components/messages/composition/messageMixin.ts @@ -0,0 +1,384 @@ +import * as linkify from "linkifyjs"; +import linkifyHtml from "linkify-html"; +import utils from "@/plugins/utils"; +import Hammer from "hammerjs"; + +linkify.options.defaults.className = "link"; +linkify.options.defaults.target = { url: "_blank" }; + +import { computed, onBeforeUnmount, Ref, ref, watch } from "vue"; +import { EventTimelineSet, Relations, RelationsEvent } from "matrix-js-sdk"; +import { KeanuEvent, KeanuRoom } from "../../../models/eventAttachment"; + +export interface MessageProps { + room: KeanuRoom; + originalEvent: KeanuEvent; + nextEvent: KeanuEvent | null | undefined; + timelineSet: EventTimelineSet; + componentFn: (event: KeanuEvent) => any; +} + +export type MessageEmits = { + (event: "ownAvatarClicked", value: { event: KeanuEvent }): void; + (event: "otherAvatarClicked", value: { event: KeanuEvent; anchor: any }): void; + (event: "contextMenu", value: { event: KeanuEvent; anchor: any }): void; + (event: "addQuickHeartReaction", value: { position: { top: string; left: string } }): void; +}; + +export const useMessage = ( + $matrix: any, + $t: any, + props: MessageProps, + emits: MessageEmits, + processThread?: () => void +) => { + const event: Ref = ref(undefined); + const thread: Ref = ref(undefined); + + onBeforeUnmount(() => { + thread.value = undefined; + }); + + watch( + props.originalEvent, + (originalEvent) => { + event.value = originalEvent; + + // Check not null and not {} + if (originalEvent && originalEvent.isBeingDecrypted && originalEvent.isBeingDecrypted()) { + originalEvent.getDecryptionPromise()?.then(() => { + event.value = originalEvent; + }); + } + }, + { immediate: true } + ); + + watch( + thread, + (newValue, oldValue) => { + if (oldValue) { + oldValue.off(RelationsEvent.Add, onAddRelation); + } + if (newValue) { + newValue.on(RelationsEvent.Add, onAddRelation); + if (processThread) { + processThread(); + } + } + }, + { immediate: true } + ); + + /** + * + * @returns true if event is non-null and contains data + */ + const validEvent = computed(() => { + return event.value !== undefined; + }); + + /** + * If this is a thread event, we return the root here, so all reactions will land on the root event. + */ + const eventForReactions = computed(() => { + if (event.value && event.value.parentThread) { + return event.value.parentThread; + } + return event.value; + }); + + const incoming = computed(() => { + return event.value && event.value.getSender() != $matrix.currentUserId; + }); + + /** + * Don't show sender and time if the next event is within 2 minutes and also from us (= back to back messages) + */ + const showSenderAndTime = computed(() => { + if (!isPinned.value && props.nextEvent && props.nextEvent.getSender() == event.value?.getSender()) { + const ts1 = props.nextEvent.event.origin_server_ts ?? 0; + const ts2 = event.value!.event.origin_server_ts ?? 0; + return ts1 - ts2 < 2 * 60 * 1000; // less than 2 minutes + } + return true; + }); + + const inReplyToSender = computed(() => { + const originalEvent = validEvent.value && event.value?.replyEvent; + if (originalEvent) { + const sender = eventSenderDisplayName(originalEvent); + if (originalEvent.isThreadRoot || originalEvent.isMxThread) { + return sender || $t("message.someone"); + } else { + return $t("message.user_said", { user: sender || $t("message.someone") }); + } + } + return null; + }); + + const inReplyToEvent = computed(() => { + return event.value?.replyEvent; + }); + + const inReplyToText = computed(() => { + const relatesTo = event.value?.getWireContent()["m.relates_to"]; + if (relatesTo && relatesTo["m.in_reply_to"]) { + if (inReplyToEvent.value && (inReplyToEvent.value.isThreadRoot || inReplyToEvent.value.isMxThread)) { + const children = props.timelineSet.relations + .getAllChildEventsForEvent(inReplyToEvent.value.getId()!) + .filter((e) => utils.downloadableTypes().includes(e.getContent().msgtype)); + return $t("message.sent_media", { count: children.length }); + } + const content = event.value?.getContent(); + if (content && content.body) { + const lines = content.body.split("\n").reverse() || []; + while (lines.length && !lines[0].startsWith("> ")) lines.shift(); + // Reply fallback has a blank line after it, so remove it to prevent leading newline + if (lines[0] === "") lines.shift(); + const text = lines[0] && lines[0].replace(/^> (<.*> )?/g, ""); + if (text) { + return text; + } + } + if (inReplyToEvent.value) { + var c = inReplyToEvent.value.getContent(); + return c.body; + } + // We don't have the original text (at the moment at least) + return $t("fallbacks.original_text"); + } + return null; + }); + + const messageText = computed(() => { + const relatesTo = event.value?.getWireContent()["m.relates_to"]; + if (event.value && relatesTo && relatesTo["m.in_reply_to"]) { + const content = event.value.getContent(); + if ("body" in content) { + // Remove the new text and strip "> " from the old original text + const lines = content.body.split("\n"); + while (lines.length && lines[0].startsWith("> ")) lines.shift(); + // Reply fallback has a blank line after it, so remove it to prevent leading newline + if (lines[0] === "") lines.shift(); + return lines.join("\n"); + } + } + return event.value?.getContent().body; + }); + + const isPinned = computed(() => { + return event.value && event.value.parentThread ? event.value.parentThread.isPinned : event.value?.isPinned || false; + }); + + /** + * Classes to set for the message. Currently only for "messageIn" + */ + + const messageClasses = computed(() => { + if (incoming.value) { + return { messageIn: true, "from-admin": senderIsAdminOrModerator(event.value), pinned: isPinned.value }; + } else { + return { messageOut: true, pinned: isPinned.value }; + } + }); + + const userAvatar = computed(() => { + if (!$matrix.userAvatar) { + return null; + } + return $matrix.matrixClient.mxcUrlToHttp( + $matrix.userAvatar, + 80, + 80, + "scale", + true, + undefined, + $matrix.useAuthedMedia + ); + }); + + const userAvatarLetter = computed(() => { + if (!$matrix.currentUser) { + return null; + } + return ($matrix.currentUserDisplayName || $matrix.currentUserId.substring(1)).substring(0, 1).toUpperCase(); + }); + + const onAddRelation = () => { + if (processThread) { + processThread(); + } + }; + + const ownAvatarClicked = () => { + if (event.value) { + emits("ownAvatarClicked", { event: event.value }); + } + }; + + const otherAvatarClicked = (avatarRef: any) => { + if (event.value) { + emits("otherAvatarClicked", { event: event.value, anchor: avatarRef }); + } + }; + + const showContextMenu = (buttonRef: any) => { + if (event.value) { + emits("contextMenu", { event: event.value, anchor: buttonRef }); + } + }; + + /** + * Get a display name given an event. + */ + const eventSenderDisplayName = (e: KeanuEvent | undefined) => { + if (e?.getSender() === $matrix.currentUserId) { + return $t("message.you"); + } + if (e && props.room) { + const sender = e.getSender(); + const member = sender ? props.room.getMember(sender) : undefined; + if (member) { + return member.name; + } + } + return e?.getContent().displayname || e?.getSender(); + }; + + /** + * In the case where the state_key points out a userId for an operation (e.g. membership events) + * return the display name of the affected user. + * @param event + * @returns + */ + const eventStateKeyDisplayName = (e: KeanuEvent | undefined) => { + if (e?.getStateKey() === $matrix.currentUserId) { + return $t("message.you"); + } + if (e && props.room) { + const key = e.getStateKey(); + const member = key ? props.room.getMember(key) : undefined; + if (member) { + return member.name; + } + } + return e?.getStateKey(); + }; + + const messageEventAvatar = (e: KeanuEvent | undefined) => { + if (e && props.room) { + const sender = e.getSender(); + const member = sender ? props.room.getMember(sender) : undefined; + if (member) { + return member.getAvatarUrl( + $matrix.matrixClient.getHomeserverUrl(), + 40, + 40, + "scale", + true, + false, + $matrix.useAuthedMedia + ); + } + } + return null; + }; + + /** + * Return true if the event sender has a powel level > 0, e.g. is moderator or admin of some sort. + */ + const senderIsAdminOrModerator = (e: KeanuEvent | undefined) => { + if (e && props.room) { + const sender = e.getSender(); + const member = sender ? props.room.getMember(sender) : undefined; + if (member) { + return member.powerLevel > 0; + } + } + return false; + }; + + const redactedBySomeoneElse = (e: KeanuEvent | undefined) => { + if (!e || !e.isRedacted()) return false; + const redactionEvent = e.getUnsigned().redacted_because; + if (redactionEvent) { + return redactionEvent.sender !== $matrix.currentUserId; + } + return false; + }; + + const formatTimeAgo = (time: number | undefined) => { + if (!time) return ""; + const date = new Date(); + date.setTime(time); + var ti = Math.abs(new Date().getTime() - date.getTime()); + ti = ti / 1000; // Convert to seconds + let s = ""; + if (ti < 60) { + s = $t("global.time.recently"); + } else if (ti < 3600 && Math.round(ti / 60) < 60) { + s = $t("global.time.minutes", Math.round(ti / 60)); + } else if (ti < 86400 && Math.round(ti / 60 / 60) < 24) { + s = $t("global.time.hours", Math.round(ti / 60 / 60)); + } else { + s = $t("global.time.days", Math.round(ti / 60 / 60 / 24)); + } + return utils.toLocalNumbers(s); + }; + + const formatTime = (time: number | undefined) => { + if (!time) return ""; + return utils.formatTime(time); + }; + + const linkify = (text: string) => { + return linkifyHtml(text); + }; + + const mc: Ref = ref(undefined); + const mcCustom: Ref = ref(undefined); + + const initMsgHammerJs = (element: Element, opbutton: Element) => { + mc.value = new Hammer(element); + mcCustom.value = new Hammer.Manager(element); + mcCustom.value.add(new Hammer.Tap({ event: "doubletap", taps: 2 })); + mcCustom.value.on("doubletap", (evt: Hammer.HammerInput) => { + var { top, left } = evt.target.getBoundingClientRect(); + var position = { top: `${top}px`, left: `${left}px` }; + emits("addQuickHeartReaction", { position }); + }); + mc.value.on("press", () => { + showContextMenu(opbutton); + }); + }; + + return { + event, + thread, + + validEvent, + eventForReactions, + showSenderAndTime, + inReplyToSender, + inReplyToEvent, + inReplyToText, + messageText, + isPinned, + messageClasses, + userAvatar, + userAvatarLetter, + ownAvatarClicked, + otherAvatarClicked, + showContextMenu, + eventSenderDisplayName, + eventStateKeyDisplayName, + messageEventAvatar, + senderIsAdminOrModerator, + redactedBySomeoneElse, + formatTimeAgo, + formatTime, + linkify, + initMsgHammerJs, + }; +}; diff --git a/src/main.js b/src/main.js index 35a0775..0a97090 100644 --- a/src/main.js +++ b/src/main.js @@ -41,27 +41,6 @@ app.use(analytics); app.use(VueClipboard); app.use(audioPlayer); -// Add bubble functionality to custom events. -// From here: https://stackoverflow.com/questions/41993508/vuejs-bubbling-custom-events -app.use((instance) => { - instance.$bubble = function $bubble(eventName, ...args) { - // Emit the event on all parent components - let component = this; - let arg = args.at(0); - let stop = false; - if (arg) { - // Add a "stopPropagation" function so that we can do v-on:.stop="..." - arg.stopPropagation = () => { - stop = true; - } - } - do { - component.$emit(eventName, ... args); - component = component.$parent; - } while (!stop && component); - }; -}); - // Register a global custom directive called `v-blur` that prevents focus app.directive('blur', { mounted: function (el) { @@ -182,6 +161,9 @@ app.use(i18n); app.$i18n = i18n; app.config.globalProperties.$i18n = i18n; +app.provide("globalT", i18n.global.t); +app.provide("globalSanitize", app.config.globalProperties.$sanitize); + app.use(matrix, { store: store, i18n: i18n }); // Set $matrix inside data store diff --git a/src/models/attachmentManager.ts b/src/models/attachmentManager.ts index 74171bd..c45d741 100644 --- a/src/models/attachmentManager.ts +++ b/src/models/attachmentManager.ts @@ -1,21 +1,12 @@ import { MatrixClient, MatrixEvent } from "matrix-js-sdk"; -import { KeanuEventExtension } from "./eventAttachment"; +import { EventAttachment, 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; - thumbnailPromise?: Promise; - attachmentProgress?: ((progress: number) => void)[]; - thumbnailProgress?: ((progress: number) => void)[]; -}; +import { Reactive, reactive } from "vue"; export class AttachmentManager { matrixClient: MatrixClient; @@ -23,7 +14,7 @@ export class AttachmentManager { maxSizeUploads: number; maxSizeAutoDownloads: number; - cache: Map; + cache: Map>; constructor(matrixClient: MatrixClient, useAuthedMedia: boolean, maxSizeAutoDownloads: number) { this.matrixClient = matrixClient; @@ -34,9 +25,12 @@ export class AttachmentManager { this.cache = new Map(); // Get max upload size - this.matrixClient.getMediaConfig(useAuthedMedia).then((config) => { + this.matrixClient + .getMediaConfig(useAuthedMedia) + .then((config) => { this.maxSizeUploads = config["m.upload.size"] ?? 0; - }).catch(() => {}); + }) + .catch(() => {}); } public createAttachment(file: File): Attachment { @@ -88,9 +82,13 @@ export class AttachmentManager { 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) + attachment.useScaled = + attachment.scaledFile !== undefined && + (attachment.proof === undefined || + attachment.proof.integrity === undefined || + attachment.proof.integrity.c2pa === undefined); }) .catch((err) => { console.error("Resize failed:", err); @@ -110,76 +108,62 @@ export class AttachmentManager { return attachment; } - public async loadEventAttachment( - event: MatrixEvent & KeanuEventExtension, - progress?: (percent: number) => void, - outputObject?: { src: string; thumbnailSrc: string } - ): Promise { - 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; + public getEventAttachment(event: MatrixEvent & KeanuEventExtension): Reactive { + let entry = this.cache.get(event.getId()); + if (entry !== undefined) { + return entry; } - 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; + const attachment: Reactive = reactive({ + event: event, + srcProgress: -1, + thumbnailProgress: -1, + loadSrc: () => Promise.reject("Not implemented"), + loadThumbnail: () => Promise.reject("Not implemented"), + release: () => Promise.reject("Not implemented"), }); - } - - public async loadEventThumbnail( - event: MatrixEvent & KeanuEventExtension, - progress?: (percent: number) => void, - outputObject?: { src: string; thumbnailSrc: string } - ): Promise { - console.error("GET THUMB FOR EVENT", event.getId()); - - const entry = this.cache.get(event.getId()) ?? {}; - if (entry.thumbnail) { - if (outputObject) { - outputObject.thumbnailSrc = entry.thumbnail; + attachment.loadSrc = () => { + if (attachment.src) { + return Promise.resolve(attachment.src); + } else if (attachment.srcPromise) { + return attachment.srcPromise; } - return entry.thumbnail; - } - - if (!entry.thumbnailPromise) { - entry.thumbnailPromise = this._loadEventAttachmentOrThumbnail(event, true, progress) - .then((thummbnail) => { - entry.thumbnail = thummbnail; + attachment.srcPromise = this._loadEventAttachmentOrThumbnail(event, false, (percent) => { + attachment.srcProgress = percent; + }).then((src) => { + attachment.src = src; + return src; + }); + return attachment.srcPromise; + }; + attachment.loadThumbnail = () => { + if (attachment.thumbnail) { + return Promise.resolve(attachment.thumbnail); + } else if (attachment.thumbnailPromise) { + return attachment.thumbnailPromise; + } + attachment.thumbnailPromise = this._loadEventAttachmentOrThumbnail(event, true, (percent) => { + attachment.thumbnailProgress = percent; + }).then((thummbnail) => { + attachment.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 attachment.thumbnailPromise; + }; + attachment.release = (src: boolean, thumbnail: boolean) => { + // TODO - figure out logic + if (entry) { + // TODO - abortable promises + this.cache.delete(event.getId()); + if (attachment.src) { + URL.revokeObjectURL(attachment.src); + } + if (attachment.thumbnail) { + URL.revokeObjectURL(attachment.thumbnail); + } } - return thumbnail; - }); + } + this.cache.set(event.getId(), attachment!); + return attachment; } private async _loadEventAttachmentOrThumbnail( @@ -277,21 +261,6 @@ export class AttachmentManager { 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"); diff --git a/src/models/eventAttachment.ts b/src/models/eventAttachment.ts index 2faf41a..c30b745 100644 --- a/src/models/eventAttachment.ts +++ b/src/models/eventAttachment.ts @@ -1,9 +1,11 @@ -import { MatrixEvent } from "matrix-js-sdk"; +import { MatrixEvent, Room } from "matrix-js-sdk"; export type KeanuEventExtension = { isMxThread?: boolean; isChannelMessage?: boolean; isPinned?: boolean; + parentThread?: MatrixEvent & KeanuEventExtension; + replyEvent?: MatrixEvent & KeanuEventExtension; } export type EventAttachment = { @@ -12,4 +14,15 @@ export type EventAttachment = { thumbnail?: string; srcPromise?: Promise; thumbnailPromise?: Promise; + srcProgress: number; + thumbnailProgress: number; + loadSrc: () => void; + loadThumbnail: () => Promise; + release: (src: boolean, thumbnail: boolean) => void; }; + +export type KeanuEvent = MatrixEvent & KeanuEventExtension; + +export type KeanuRoom = Room & { + displayType: "im.keanu.room_type_default" | "im.keanu.room_type_voice" | "im.keanu.room_type_file" | "im.keanu.room_type_channel" | undefined; +} \ No newline at end of file diff --git a/src/services/matrix.service.js b/src/services/matrix.service.js index 1d8c2b8..1c4acb5 100644 --- a/src/services/matrix.service.js +++ b/src/services/matrix.service.js @@ -1374,6 +1374,7 @@ export default { const instance = matrixService.mount("#app2"); app.config.globalProperties.$matrix = instance; app.$matrix = instance; + app.provide("globalMatrix", instance); sdk.setCryptoStoreFactory(instance.createCryptoStore.bind(instance)); }, }; From c00fcd61731f5020c9150570072fdadb784c7e25 Mon Sep 17 00:00:00 2001 From: N-Pex Date: Tue, 10 Jun 2025 13:47:07 +0200 Subject: [PATCH 05/11] Fix scroll to end --- src/assets/css/chat.scss | 2 +- src/components/Chat.vue | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/assets/css/chat.scss b/src/assets/css/chat.scss index ae5a5ce..7ccc21c 100644 --- a/src/assets/css/chat.scss +++ b/src/assets/css/chat.scss @@ -368,7 +368,7 @@ body { .scroll-to-end { position: absolute; - bottom: 20px; + bottom: 102px; right: 16px; &.reversed { top: 120px; diff --git a/src/components/Chat.vue b/src/components/Chat.vue index b6cace4..541c906 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -116,14 +116,13 @@ - - - - - + + + +
From 1d30d0633d7cf0a8c22e8b8ca92e156f69547bec Mon Sep 17 00:00:00 2001 From: N-Pex Date: Tue, 10 Jun 2025 16:50:19 +0200 Subject: [PATCH 06/11] Style of media send should be dark --- src/assets/css/sendattachments.scss | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/assets/css/sendattachments.scss b/src/assets/css/sendattachments.scss index 0197ae1..6219764 100644 --- a/src/assets/css/sendattachments.scss +++ b/src/assets/css/sendattachments.scss @@ -3,10 +3,10 @@ $large-button-height: $min-touch-target; $small-button-height: 36px; -$background: #ffffff; -$backgroundSection: rgba(#ededed,0.8); +$background: #000000; +$backgroundSection: #181719; $backgroundHilite: #383739; -$text: #000000; +$text: #ffffff; $hiliteColor: #4642f1; .send-attachments { @@ -97,10 +97,11 @@ $hiliteColor: #4642f1; width: 100%; flex: 0 0 100%; overflow-y: auto; + display: flex; } .file-drop-current-item { width: 100%; - height: 60%; + flex: 1 1 100%; background-color: $backgroundSection; display: flex; &.drop-target { @@ -124,7 +125,7 @@ $hiliteColor: #4642f1; } .send-attachments__current-item__info { - height: 80px; + flex: 0 0 80px; text-align: start; margin: 18px 20px; padding: 0; @@ -147,7 +148,7 @@ $hiliteColor: #4642f1; .file-drop-thumbnail-container { width: 100%; padding: 13px 20px 15px 20px; - height: 74px; + flex: 0 0 74px; overflow-x: auto; overflow-y: hidden; white-space: nowrap; @@ -185,7 +186,7 @@ $hiliteColor: #4642f1; .add, .remove { - color: $background; + color: $text; position: absolute; top: 0; left: 0; @@ -213,6 +214,10 @@ $hiliteColor: #4642f1; border-radius: 19px; } + .file-drop-input-container { + flex: 1 1 auto; + } + .file-drop-input-container, .file-drop-sending-input-container, .file-drop-sent-input-container { @@ -230,6 +235,9 @@ $hiliteColor: #4642f1; padding: 16px 18px; font-family: "Inter", sans-serif; font-weight: 300; + .v-field { + background-color: transparent !important; + } } .input-container__buttons { position: absolute; From fd82fd8840407382916a9ebf698b1ddb327fe67e Mon Sep 17 00:00:00 2001 From: N-Pex Date: Wed, 11 Jun 2025 14:59:34 +0200 Subject: [PATCH 07/11] Add AttachmentBatch for future removal of sendAttachmentsMixin --- src/assets/css/sendattachments.scss | 100 ++++++-- src/components/Chat.vue | 159 +----------- .../file_mode/SendAttachmentsLayout.vue | 100 ++++---- src/models/attachment.ts | 18 ++ src/models/attachmentManager.ts | 231 +++++++++++++++++- 5 files changed, 377 insertions(+), 231 deletions(-) diff --git a/src/assets/css/sendattachments.scss b/src/assets/css/sendattachments.scss index 6219764..a3094bf 100644 --- a/src/assets/css/sendattachments.scss +++ b/src/assets/css/sendattachments.scss @@ -45,9 +45,11 @@ $hiliteColor: #4642f1; width: 100%; height: 50%; background-color: $background; + &.drop-target { background-color: $backgroundHilite; } + border-radius: 19px; display: flex; flex-direction: column; @@ -79,6 +81,7 @@ $hiliteColor: #4642f1; 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; @@ -89,6 +92,7 @@ $hiliteColor: #4642f1; textarea { color: rgba($text, 80%) !important; } + textarea::placeholder { color: rgba($text, 80%) !important; } @@ -98,22 +102,28 @@ $hiliteColor: #4642f1; flex: 0 0 100%; overflow-y: auto; display: flex; + flex-direction: column; } + .file-drop-current-item { width: 100%; flex: 1 1 100%; 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%; @@ -158,9 +168,12 @@ $hiliteColor: #4642f1; &::-webkit-scrollbar { display: none; } + /* Hide scrollbar for IE, Edge and Firefox */ - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; + /* IE and Edge */ + scrollbar-width: none; + /* Firefox */ .file-drop-thumbnail { width: 46px; @@ -171,17 +184,21 @@ $hiliteColor: #4642f1; 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, @@ -195,11 +212,13 @@ $hiliteColor: #4642f1; 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); @@ -228,31 +247,40 @@ $hiliteColor: #4642f1; border-radius: 19px; display: flex; flex-direction: column; - .input-area-text { - flex: 0 0 auto; - width: 100%; - margin-bottom: 50px; - padding: 16px 18px; - font-family: "Inter", sans-serif; - font-weight: 300; - .v-field { - background-color: transparent !important; - } - } + .input-container__buttons { position: absolute; right: 8px; bottom: 10px; display: flex; - & > *:not(:first-child) { + + &>*:not(:first-child) { margin-left: 8px; } } } + .input-area-text { + flex: 0 0 auto; + width: 100%; + margin-bottom: 50px; + padding: 16px 18px; + font-family: "Inter", sans-serif; + font-weight: 300; + + .v-field { + background-color: transparent !important; + } + } + @keyframes fadeInStackItem { - from {opacity: 0;} - to {opacity: 1;} + from { + opacity: 0; + } + + to { + opacity: 1; + } } // Sending @@ -263,49 +291,63 @@ $hiliteColor: #4642f1; 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-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 { + + div, + .v-icon { position: absolute; } - .v-icon, .v-icon__component { + + .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; @@ -327,9 +369,12 @@ $hiliteColor: #4642f1; &::-webkit-scrollbar { display: none; } + /* Hide scrollbar for IE, Edge and Firefox */ - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; + /* IE and Edge */ + scrollbar-width: none; + /* Firefox */ .file-drop-sending-item { width: 100%; @@ -339,6 +384,7 @@ $hiliteColor: #4642f1; border-radius: 12px; position: relative; padding: 8px; + .v-img { width: $min-touch-target; height: $min-touch-target; @@ -347,18 +393,22 @@ $hiliteColor: #4642f1; 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; @@ -379,7 +429,9 @@ $hiliteColor: #4642f1; .v-progress-circular { margin-left: 8px; } - background: linear-gradient(0deg, #000 0%, #000 100%), #4642f1; + + background: linear-gradient(0deg, #000 0%, #000 100%), + #4642f1; } } @@ -396,10 +448,12 @@ $hiliteColor: #4642f1; .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; @@ -407,4 +461,4 @@ $hiliteColor: #4642f1; } } } -} +} \ No newline at end of file diff --git a/src/components/Chat.vue b/src/components/Chat.vue index 541c906..b6e5b3e 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -228,90 +228,13 @@ - - @@ -490,10 +413,7 @@ export default { scrollPosition: null, currentFileInputs: [], - currentSendShowSendButton: true, - currentSendError: null, - currentSendErrorExceededFile: null, - attachmentCaption: undefined, + uploadBatch: undefined, showEmojiPicker: false, selectedEvent: null, editedEvent: null, @@ -615,29 +535,6 @@ export default { const currentUserId= this.selectedEvent?.sender.userId || this.$matrix.currentUserId return this.joinedAndInvitedMembers.find(({userId}) => userId === currentUserId) }, - nonImageFiles() { - return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => !file?.type.includes("image/")) - }, - imageFiles() { - return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => file?.type.includes("image/")) - }, - isCurrentFileInputsAnArray() { - return Array.isArray(this.currentFileInputs) - }, - showAttachmentCaptionInput() { - // IFF we are sending one PDF, add option to set caption. - const imageFiles = this.imageFiles || []; - const otherFiles = this.nonImageFiles || []; - return imageFiles.length == 0 && otherFiles.length == 1 && (otherFiles[0].type === "application/pdf" || (otherFiles[0].name || "").endsWith(".pdf")); - }, - currentFileInputsDialog: { - get() { - return this.isCurrentFileInputsAnArray - }, - set() { - this.currentFileInputs = []; - } - }, chatContainer() { const container = this.$refs.chatContainer; if (this.useVoiceMode) { @@ -1493,32 +1390,15 @@ export default { this.$refs.attachment.click(); }, - async addAttachment(file) { + addAttachment(file) { if (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]; + if (!this.uploadBatch) { + this.uploadBatch = this.$matrix.attachmentManager.createUpload(this.room); + } + this.uploadBatch?.addAttachment(this.$matrix.attachmentManager.createAttachment(file)); } }, - removeAttachment(index) { - this.currentFileInputs = this.currentFileInputs.toSpliced(index, 1); - }, - /** * Handle picked attachment */ @@ -1527,24 +1407,7 @@ export default { }, 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)); - }); + files.forEach(file => this.addAttachment(file)); }, showStickerPicker() { @@ -1559,7 +1422,6 @@ export default { promise.then(() => { this.sendingAttachments = []; this.currentFileInputs = []; - this.attachmentCaption = undefined; this.sendingStatus = "initial" }) .catch((err) => { @@ -1580,7 +1442,6 @@ export default { this.cancelSendAttachments(); } this.currentFileInputs = []; - this.attachmentCaption = undefined; this.currentSendError = null; this.currentSendErrorExceededFile = null; this.sendingStatus = "initial"; diff --git a/src/components/file_mode/SendAttachmentsLayout.vue b/src/components/file_mode/SendAttachmentsLayout.vue index 1114bcd..d65ff2a 100644 --- a/src/components/file_mode/SendAttachmentsLayout.vue +++ b/src/components/file_mode/SendAttachmentsLayout.vue @@ -3,7 +3,7 @@
{{ $t("message.send_attachements_dialog_title") }}
-