diff --git a/src/components/messages/MessageIncomingImage.vue b/src/components/messages/MessageIncomingImage.vue
index d554dce..16e4e40 100644
--- a/src/components/messages/MessageIncomingImage.vue
+++ b/src/components/messages/MessageIncomingImage.vue
@@ -1,36 +1,35 @@
-
+
-
-
-
+
+
\ No newline at end of file
+
diff --git a/src/components/messages/MessageIncomingThread.vue b/src/components/messages/MessageIncomingThread.vue
deleted file mode 100644
index f30734c..0000000
--- a/src/components/messages/MessageIncomingThread.vue
+++ /dev/null
@@ -1,162 +0,0 @@
-
-
-
-
-
{{ inReplyToSender }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- block
- {{ redactedBySomeoneElse(event) ? $t('message.incoming_message_deleted_text') : $t('message.outgoing_message_deleted_text')}}
-
-
-
- {{ $t('message.edited') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/components/messages/MessageOutgoingImage.vue b/src/components/messages/MessageOutgoingImage.vue
index 5225196..6b8fe4e 100644
--- a/src/components/messages/MessageOutgoingImage.vue
+++ b/src/components/messages/MessageOutgoingImage.vue
@@ -1,36 +1,35 @@
-
-
-
+
+
\ No newline at end of file
+
diff --git a/src/components/messages/MessageOutgoingThread.vue b/src/components/messages/MessageOutgoingThread.vue
deleted file mode 100644
index d00f514..0000000
--- a/src/components/messages/MessageOutgoingThread.vue
+++ /dev/null
@@ -1,168 +0,0 @@
-
-
-
-
-
{{ inReplyToSender }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- block
- {{ redactedBySomeoneElse(event) ? $t('message.incoming_message_deleted_text') : $t('message.outgoing_message_deleted_text')}}
-
-
-
- {{ $t('message.edited') }}
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
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 @@
+
+
+
+
+
{{ eventSenderDisplayName(event) }}
+
+ {{ room.displayType == ROOM_TYPE_CHANNEL ? formatTimeAgo(event?.event.origin_server_ts) : formatTime(event?.event.origin_server_ts) }}
+
+
+
+
+ {{
+ eventSenderDisplayName(event).substring(0, 1).toUpperCase()
+ }}
+
+
+
+
+
+
$vuetify.icons.ic_pin_filled
+
+
+ more_vert
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
{{ inReplyToSender }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ block
+ {{
+ redactedBySomeoneElse(event)
+ ? $t("message.incoming_message_deleted_text")
+ : $t("message.outgoing_message_deleted_text")
+ }}
+
+
+
+ {{ t("message.edited") }}
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
{{ eventSenderDisplayName(event) }}
+
+ {{ room.displayType == ROOM_TYPE_CHANNEL ? formatTimeAgo(event?.event.origin_server_ts) : formatTime(event?.event.origin_server_ts) }}
+
+
{{ event?.status }}
+
+
+
+
+
+
$vuetify.icons.ic_pin_filled
+
+
+
+
+
+
+
+ {{ userAvatarLetter }}
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
{{ inReplyToSender }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ block
+ {{
+ redactedBySomeoneElse(event)
+ ? t("message.incoming_message_deleted_text")
+ : t("message.outgoing_message_deleted_text")
+ }}
+
+
+
+ {{ t("message.edited") }}
+
+
+
+
+
+
+
+
+
+
+
+
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/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.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/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/attachment.ts b/src/models/attachment.ts
new file mode 100644
index 0000000..66f57a2
--- /dev/null
+++ b/src/models/attachment.ts
@@ -0,0 +1,71 @@
+import { ComputedRef, Ref } from "vue";
+
+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;
+};
+
+export type AttachmentBatch = {
+ sendingStatus: Ref<"initial" | "sending" | "sent" | "canceled" | "failed">;
+ sendingRootEventId: Ref;
+ sendingPromise: Ref | undefined>;
+ attachments: Ref;
+ attachmentsSentCount: ComputedRef;
+ attachmentsSending: ComputedRef;
+ attachmentsSent: ComputedRef;
+ addAttachment: (attachment: Attachment) => void;
+ removeAttachment: (attachment: Attachment) => void;
+ isTooLarge: (attachment: Attachment) => boolean;
+ canSend: ComputedRef;
+ send: (message: string) => Promise;
+ cancel: () => void;
+ cancelSendAttachment: (attachment: Attachment) => void;
+};
+
diff --git a/src/models/attachmentManager.ts b/src/models/attachmentManager.ts
new file mode 100644
index 0000000..8a25c51
--- /dev/null
+++ b/src/models/attachmentManager.ts
@@ -0,0 +1,517 @@
+import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk";
+import { EventAttachment, KeanuEventExtension } from "./eventAttachment";
+import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
+import { Counter, ModeOfOperation } from "aes-js";
+import { Attachment, AttachmentBatch, AttachmentSendInfo } from "./attachment";
+import proofmode from "../plugins/proofmode";
+import imageSize from "image-size";
+import imageResize from "image-resize";
+import { computed, isRef, Reactive, reactive, ref, Ref } from "vue";
+import utils from "@/plugins/utils";
+
+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 createUpload(room: Room) {
+ return createUploadBatch(this.matrixClient, room, this.maxSizeUploads);
+ }
+
+ 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 getEventAttachment(event: MatrixEvent & KeanuEventExtension): Reactive {
+ let entry = this.cache.get(event.getId());
+ if (entry !== undefined) {
+ return entry;
+ }
+ 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"),
+ });
+ attachment.loadSrc = () => {
+ if (attachment.src) {
+ return Promise.resolve(attachment.src);
+ } else if (attachment.srcPromise) {
+ return attachment.srcPromise;
+ }
+ 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;
+ });
+ 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);
+ }
+ }
+ };
+ this.cache.set(event.getId(), attachment!);
+ return attachment;
+ }
+
+ 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 }));
+ }
+
+ 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");
+ });
+ });
+ }
+}
+
+export const createUploadBatch = (
+ matrixClient: MatrixClient | null,
+ room: Room | null,
+ maxSizeUploads: number
+): AttachmentBatch => {
+ const sendingStatus: Ref<"initial" | "sending" | "sent" | "canceled" | "failed"> = ref("initial");
+ const sendingRootEventId: Ref = ref(undefined);
+ const sendingPromise: Ref | undefined> = ref(undefined);
+ const attachments: Ref = ref([]);
+
+ const attachmentsSentCount = computed(() => {
+ return attachments.value.reduce((a, elem) => (elem.sendInfo?.status == "sent" ? a + 1 : a), 0);
+ });
+
+ const attachmentsSending = computed(() => {
+ return attachments.value.filter((elem) => elem.sendInfo?.status == "initial" || elem.sendInfo?.status == "sending");
+ });
+
+ const attachmentsSent = computed(() => {
+ sortSendingAttachments();
+ return attachments.value.filter((elem) => elem.sendInfo?.status == "sent");
+ });
+
+ const sortSendingAttachments = () => {
+ attachments.value.sort((a, b) => (b.sendInfo?.statusDate ?? 0) - (a.sendInfo?.statusDate ?? 0));
+ };
+
+ const addAttachment = (attachment: Attachment) => {
+ if (sendingStatus.value == "initial") {
+ attachments.value.push(attachment);
+ }
+ };
+
+ const removeAttachment = (attachment: Attachment) => {
+ if (sendingStatus.value == "initial") {
+ attachments.value = attachments.value.filter((a) => a !== attachment);
+ }
+ };
+
+ const isTooLarge = (attachment: Attachment) => {
+ const file = attachment.scaledFile && attachment.useScaled ? attachment.scaledFile : attachment.file;
+ return file.size > maxSizeUploads;
+ };
+
+ const canSend = computed(() => {
+ return attachments.value.length > 0 && !attachments.value.some((a: Attachment) => isTooLarge(a));
+ });
+
+ const cancel = () => {
+ if (sendingStatus.value !== "initial" && matrixClient && room) {
+ attachments.value.toReversed().forEach((attachment) => {
+ cancelSendAttachment(attachment);
+ });
+ sendingStatus.value = "canceled";
+ if (sendingRootEventId.value) {
+ // Redact all media we already sent, plus the root event
+ let promises = attachments.value.reduce((val: Promise[], attachment: Attachment) => {
+ if (attachment.sendInfo?.mediaEventId) {
+ val.push(
+ matrixClient.redactEvent(room.roomId, attachment.sendInfo!.mediaEventId, undefined, {
+ reason: "cancel",
+ })
+ );
+ }
+ return val;
+ }, [] as Promise[]);
+ if (sendingRootEventId.value) {
+ promises.push(
+ matrixClient.redactEvent(room.roomId, sendingRootEventId.value, undefined, {
+ reason: "cancel",
+ })
+ );
+ }
+ Promise.allSettled(promises)
+ .then(() => {
+ console.log("Message redacted");
+ })
+ .catch((err) => {
+ console.log("Redaction failed: ", err);
+ });
+ }
+ }
+ };
+
+ const cancelSendAttachment = (attachment: Attachment) => {
+ if (attachment.sendInfo) {
+ if (attachment.sendInfo.promise && attachment.sendInfo.status != "initial") {
+ attachment.sendInfo.promise.abort();
+ }
+ attachment.sendInfo.status = "canceled";
+ }
+ };
+
+ const send = (message: string): Promise => {
+ if (!matrixClient || !room) return Promise.reject("Not configured");
+ sendingStatus.value = "sending";
+ attachments.value.forEach((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);
+ });
+
+ const sendingPromise = utils
+ .sendTextMessage(matrixClient, room.roomId, message)
+ .then((eventId: string) => {
+ sendingRootEventId.value = eventId;
+
+ // Use the eventId as a thread root for all the media
+ let promiseChain = Promise.resolve();
+ const getItemPromise = (index: number) => {
+ if (index < attachments.value.length) {
+ const attachment = attachments.value[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 = utils
+ .sendFile(
+ matrixClient,
+ 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 (attachmentsSent.value.length > 0) {
+ if (attachmentsSent.value[0].sendInfo!.randomRotation >= 0) {
+ signR = -1;
+ }
+ if (attachmentsSent.value[0].sendInfo!.randomTranslationX >= 0) {
+ signX = -1;
+ }
+ if (attachmentsSent.value[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(() => {
+ sendingStatus.value = "sent";
+ sendingRootEventId.value = undefined;
+ })
+ .catch((err: any) => {
+ console.error("ERROR", err);
+ });
+ return sendingPromise;
+ };
+
+ return {
+ sendingStatus,
+ sendingRootEventId,
+ sendingPromise,
+ attachments,
+ attachmentsSentCount,
+ attachmentsSending,
+ attachmentsSent,
+ addAttachment,
+ removeAttachment,
+ isTooLarge,
+ canSend,
+ send,
+ cancel,
+ cancelSendAttachment,
+ };
+};
diff --git a/src/models/eventAttachment.ts b/src/models/eventAttachment.ts
new file mode 100644
index 0000000..c30b745
--- /dev/null
+++ b/src/models/eventAttachment.ts
@@ -0,0 +1,28 @@
+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 = {
+ event: MatrixEvent & KeanuEventExtension;
+ src?: string;
+ 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/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..1c4acb5 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;
@@ -1369,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));
},
};
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..c91cae9 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: [
{
@@ -59,8 +38,14 @@ export default defineConfig(({mode}) => ({
}
]
}),
- //nodePolyfills(),
],
+ worker: {
+ format: "es",
+ plugins: () => [
+ wasm(),
+ topLevelAwait()
+ ]
+ },
resolve: {
extensions: ['.vue','.mjs', '.js', '.ts', '.jsx', '.tsx', '.json','.wasm'],
alias: [
@@ -69,53 +54,8 @@ export default defineConfig(({mode}) => ({
{ 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
+ build: {
+ assetsDir: "assets",
+ 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",
- // },
- // },
}));