From 44578048aa46584c427e6d756917848afcccbedf Mon Sep 17 00:00:00 2001 From: N-Pex Date: Tue, 10 Jun 2025 13:35:51 +0200 Subject: [PATCH] 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)); }, };