Migrate media thread views to composition API

This commit is contained in:
N-Pex 2025-06-10 13:35:51 +02:00
parent 77eebafb79
commit 44578048aa
22 changed files with 1144 additions and 598 deletions

21
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -89,7 +89,7 @@
see below. Otherwise things like context menus won't work as designed.
-->
<component :is="event.component" :room="room" :originalEvent="event" :nextEvent="event.nextDisplayedEvent"
:timelineSet="timelineSet" v-on:send-quick-reaction.stop="sendQuickReaction"
:timelineSet="timelineSet" v-on:send-quick-reaction="sendQuickReaction"
:componentFn="componentForEvent"
v-on:context-menu="showContextMenuForEvent({event: event, anchor: $event.anchor})"
v-on:own-avatar-clicked="viewProfile"
@ -415,6 +415,7 @@ import { markRaw } from "vue";
import timerIcon from '@/assets/icons/ic_timer.svg';
import proofmode from "../plugins/proofmode.js";
import C2PABadge from "./c2pa/C2PABadge.vue";
import { consoleWarn } from "vuetify/lib/util/console.mjs";
const READ_RECEIPT_TIMEOUT = 5000; /* How long a message must have been visible before the read marker is updated */
const WINDOW_BUFFER_SIZE = 0.3; /** Relative window height of when we start paginating. Always keep this much loaded before and after our scroll position! */
@ -609,7 +610,7 @@ export default {
return (contentArr && contentArr.length > 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

View file

@ -1,6 +1,6 @@
<template>
<v-img class="image-with-progress" v-bind="{...$props, ...$attrs}">
<LoadProgress class="image-with-progress__progress" v-if="loadingProgress >= 0 && loadingProgress < 100" :percentage="loadingProgress" />
<LoadProgress class="image-with-progress__progress" v-if="loadingProgress != undefined && loadingProgress >= 0 && loadingProgress < 100" :percentage="loadingProgress" />
</v-img>
</template>
@ -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: {

View file

@ -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";

View file

@ -18,7 +18,7 @@
<div class="file-drop-thumbnail-container">
<div :class="{ 'file-drop-thumbnail': true, 'clickable': true, 'current': id == currentItemIndex }"
@click="currentItemIndex = id" v-for="(currentImageInput, id) in items" :key="id">
<v-img v-if="currentImageInput && currentImageInput.src" :src="currentImageInput.src" />
<v-img v-if="currentImageInput" :src="currentImageInput.thumbnail ? currentImageInput.thumbnail : currentImageInput.src" />
</div>
</div>

View file

@ -1,5 +1,5 @@
<template>
<div ref="thumbnailRef">
<div ref="thumbnailRef" style="width: 100%;height: 100%;">
<v-responsive
v-if="item.event.getContent().msgtype == 'm.video' && item.src"
:class="{ 'thumbnail-item': true, preview: previewOnly }"
@ -8,13 +8,14 @@
{{ $t("fallbacks.video_file") }}
</video>
</v-responsive>
<v-img
v-else-if="item.event.getContent().msgtype == 'm.image' && item.src"
<ImageWithProgress
v-else-if="item.event.getContent().msgtype == 'm.image'"
:aspect-ratio="previewOnly ? 16 / 9 : undefined"
:class="{ 'thumbnail-item': true, preview: previewOnly }"
:src="item.src"
:src="item.src ? item.src : item.thumbnail"
:contain="!previewOnly"
:cover="previewOnly"
:loadingProgress="previewOnly ? item.thumbnailProgress : item.srcProgress"
/>
<div v-else :class="{ 'thumbnail-item': true, preview: previewOnly, 'file-item': true }">
<v-icon :class="fileTypeIconClass">{{ fileTypeIcon }}</v-icon>
@ -23,13 +24,16 @@
</div>
</div>
</template>
<script lang="ts">
import util from "../../plugins/utils";
import { defineComponent } from "vue";
import type { PropType } from 'vue'
import { EventAttachment } from "../../models/eventAttachment";
import ImageWithProgress from "../ImageWithProgress";
export default defineComponent({
components: { ImageWithProgress },
props: {
/**
* Item is an object of { event: MXEvent, src: URL }
@ -95,6 +99,9 @@ export default defineComponent({
if (this.$refs.thumbnailRef) {
this.initThumbnailHammerJs(this.$refs.thumbnailRef);
}
if (!this.previewOnly && this.item) {
this.item.loadSrc();
}
},
});
</script>

View file

@ -4,14 +4,14 @@
<ImageWithProgress
:aspect-ratio="16 / 9"
ref="image"
:src="src ? src : thumbnailSrc"
:src="eventAttachment.src ? eventAttachment.src : eventAttachment.thumbnail"
:cover="cover"
:contain="contain"
:loadingProgress="thumbnailProgress"
:loadingProgress="eventAttachment.thumbnailProgress"
/>
</div>
<v-dialog v-model="dialog" :width="$vuetify.display.smAndUp ? '940px' : '90%'">
<ImageWithProgress :src="src ? src : thumbnailSrc" :loadingProgress="srcProgress" />
<ImageWithProgress :src="eventAttachment.src ? eventAttachment.src : eventAttachment.thumbnail" :loadingProgress="eventAttachment.srcProgress" />
</v-dialog>
</message-incoming>
</template>
@ -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();
},
};
</script>

View file

@ -1,208 +0,0 @@
<template>
<message-incoming v-bind="{ ...$props, ...$attrs }" v-if="items.length > 1 || event.isRedacted() || forceMultiview">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div class="original-message-text" v-html="linkify($sanitize(inReplyToText))" />
</div>
<div class="message">
<SwipeableThumbnailsView
:items="items"
v-if="!event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL"
v-bind="$attrs"
/>
<v-container v-else-if="!event.isRedacted()" fluid class="imageCollection">
<v-row wrap>
<v-col v-for="{ size, item } in layoutedItems()" :key="item.event.getId()" :cols="size">
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
</v-col>
</v-row>
</v-container>
<i v-if="event.isRedacted()" class="deleted-text">
<v-icon :color="this.senderIsAdminOrModerator(this.event) ? 'white' : ''" size="small">block</v-icon>
{{
redactedBySomeoneElse(event)
? $t("message.incoming_message_deleted_text")
: $t("message.outgoing_message_deleted_text")
}}
</i>
<span v-html="linkify($sanitize(messageText))" v-else-if="messageText" />
<span class="edit-marker" v-if="event.replacingEventId() && !event.isRedacted()">
{{ $t("message.edited") }}
</span>
</div>
</div>
<GalleryItemsView
:originalEvent="originalEvent"
:items="items"
:initialItem="showItem"
v-if="!!showItem"
v-on:close="showItem = null"
/>
</message-incoming>
<component
v-else-if="items.length == 1"
:is="componentFn(items[0].event)"
v-bind="{ ...$props, ...$attrs }"
:originalEvent="items[0].event"
/>
</template>
<script>
import MessageIncoming from "./MessageIncoming.vue";
import messageMixin from "./messageMixin";
import util, { ROOM_TYPE_CHANNEL, ROOM_TYPE_FILE_MODE } from "../../plugins/utils";
import GalleryItemsView from "../file_mode/GalleryItemsView.vue";
import ThumbnailView from "../file_mode/ThumbnailView.vue";
import SwipeableThumbnailsView from "./channel/SwipeableThumbnailsView.vue";
import { reactive } from "vue";
export default {
extends: MessageIncoming,
components: {
MessageIncoming,
GalleryItemsView,
ThumbnailView,
SwipeableThumbnailsView,
},
mixins: [messageMixin],
data() {
return {
ROOM_TYPE_CHANNEL: ROOM_TYPE_CHANNEL,
items: [],
showItem: null,
};
},
mounted() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(
this.event.getId(),
util.threadMessageType(),
"m.room.message"
);
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
},
beforeUnmount() {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
computed: {
forceMultiview() {
return (
this.room.displayType == ROOM_TYPE_FILE_MODE ||
(this.room.displayType == ROOM_TYPE_CHANNEL &&
this.items.length == 1 &&
util.isFileTypePDF(this.items[0].event))
);
},
},
methods: {
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(
this.event.getId(),
util.threadMessageType(),
"m.room.message"
);
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
onItemClick(event) {
this.showItem = event.item;
},
processThread() {
if (!this.event.isRedacted()) {
this.$emit(
"layout-change",
() => {
const items = this.timelineSet.relations
.getAllChildEventsForEvent(this.event.getId())
.filter((e) => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype));
this.items = items.map((e) => {
let ret = reactive({
event: e,
src: null,
});
if (items.length > 1) {
ret.promise = this.$matrix.matrixClient
.decryptEventIfNeeded(e)
.then(() =>
util.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, e, this.$config, 100, 100)
)
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
}
return ret;
});
},
this.$el
);
}
},
layoutedItems() {
if (!this.items || this.items.length == 0) {
return [];
}
let array = this.items.slice(0);
let rows = [];
while (array.length > 0) {
if (array.length >= 7) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
rows.push({ size: 12, item: array[2] });
rows.push({ size: 3, item: array[3] });
rows.push({ size: 3, item: array[4] });
rows.push({ size: 3, item: array[5] });
rows.push({ size: 3, item: array[6] });
array = array.slice(7);
} else if (array.length >= 3) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
rows.push({ size: 12, item: array[2] });
array = array.slice(3);
} else if (array.length >= 2) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
array = array.slice(2);
} else {
rows.push({ size: 12, item: array[0] });
array = array.slice(1);
}
}
return rows;
},
downloadAll() {
this.items.forEach((item) => util.download(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, item.event));
},
},
};
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
</style>
<style lang="scss" scoped>
.bubble {
width: 100%;
}
.imageCollection {
border-radius: 15px;
padding: 0;
overflow: hidden;
.row {
margin: -4px; // Compensate for column padding, so the border-radius above looks round!
padding: 0;
}
.col {
padding: 2px;
}
}
</style>

View file

@ -4,14 +4,14 @@
<ImageWithProgress
:aspect-ratio="16 / 9"
ref="image"
:src="src ? src : thumbnailSrc"
:src="eventAttachment.src ? eventAttachment.src : eventAttachment.thumbnail"
:cover="cover"
:contain="contain"
:loadingProgress="thumbnailProgress"
:loadingProgress="eventAttachment.thumbnailProgress"
/>
</div>
<v-dialog v-model="dialog" :width="$vuetify.display.smAndUp ? '940px' : '90%'">
<ImageWithProgress :src="src ? src : thumbnailSrc" :loadingProgress="srcProgress" />
<ImageWithProgress :src="eventAttachment.src ? eventAttachment.src : eventAttachment.thumbnail" :loadingProgress="eventAttachment.srcProgress" />
</v-dialog>
</message-outgoing>
</template>
@ -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();
},
};
</script>

View file

@ -1,195 +0,0 @@
<template>
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-if="items.length > 1 || event.isRedacted() || forceMultiview">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div class="original-message-text" v-html="linkify($sanitize(inReplyToText))" />
</div>
<div class="message">
<SwipeableThumbnailsView :items="items" v-if="!event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL"
v-bind="$attrs" />
<v-container v-else-if="!event.isRedacted()" fluid class="imageCollection">
<v-row wrap>
<v-col v-for="{ size, item } in layoutedItems()" :key="item.event.getId()" :cols="size">
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
</v-col>
</v-row>
</v-container>
<i v-if="event.isRedacted()" class="deleted-text">
<v-icon size="small">block</v-icon>
{{
redactedBySomeoneElse(event)
? $t("message.incoming_message_deleted_text")
: $t("message.outgoing_message_deleted_text")
}}
</i>
<span v-html="linkify($sanitize(messageText))" v-else-if="messageText" />
<span class="edit-marker" v-if="event.replacingEventId() && !event.isRedacted()">
{{ $t("message.edited") }}
</span>
</div>
</div>
<GalleryItemsView :originalEvent="originalEvent" :items="items" :initialItem="showItem" v-if="!!showItem"
v-on:close="showItem = null" />
</message-outgoing>
<component v-else-if="items.length == 1" :is="componentFn(items[0].event)" v-bind="{ ...$props, ...$attrs }"
:originalEvent="items[0].event" />
</template>
<script>
import MessageOutgoing from "./MessageOutgoing.vue";
import messageMixin from "./messageMixin";
import util, { ROOM_TYPE_CHANNEL } from "../../plugins/utils";
import GalleryItemsView from "../file_mode/GalleryItemsView.vue";
import ThumbnailView from "../file_mode/ThumbnailView.vue";
import SwipeableThumbnailsView from "./channel/SwipeableThumbnailsView.vue";
import { reactive } from "vue";
export default {
extends: MessageOutgoing,
components: { MessageOutgoing, GalleryItemsView, ThumbnailView, SwipeableThumbnailsView },
mixins: [messageMixin],
data() {
return {
ROOM_TYPE_CHANNEL: ROOM_TYPE_CHANNEL,
items: [],
showItem: null,
};
},
mounted() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(
this.event.getId(),
util.threadMessageType(),
"m.room.message"
);
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
},
beforeUnmount() {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
computed: {
forceMultiview() {
return (
this.room.displayType == ROOM_TYPE_CHANNEL && this.items.length == 1 && util.isFileTypePDF(this.items[0].event)
);
},
},
methods: {
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(
this.event.getId(),
util.threadMessageType(),
"m.room.message"
);
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
onItemClick(event) {
this.showItem = event.item;
},
processThread() {
if (!this.event.isRedacted()) {
this.$emit(
"layout-change",
() => {
const items = this.timelineSet.relations
.getAllChildEventsForEvent(this.event.getId())
.filter((e) => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype));
this.items = items.map((e) => {
let ret = reactive({
event: e,
src: null,
});
if (items.length > 1) {
// Only do if items more than one. If one, the individual component in <component> above will do the work.
//
ret.promise = this.$matrix.matrixClient
.decryptEventIfNeeded(e)
.then(() =>
util.getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, e, this.$config, 100, 100)
)
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
}
return ret;
});
},
this.$el
);
}
},
layoutedItems() {
if (!this.items || this.items.length == 0) {
return [];
}
let array = this.items.slice(0);
let rows = [];
while (array.length > 0) {
if (array.length >= 7) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
rows.push({ size: 12, item: array[2] });
rows.push({ size: 3, item: array[3] });
rows.push({ size: 3, item: array[4] });
rows.push({ size: 3, item: array[5] });
rows.push({ size: 3, item: array[6] });
array = array.slice(7);
} else if (array.length >= 3) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
rows.push({ size: 12, item: array[2] });
array = array.slice(3);
} else if (array.length >= 2) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
array = array.slice(2);
} else {
rows.push({ size: 12, item: array[0] });
array = array.slice(1);
}
}
return rows;
},
},
};
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
</style>
<style lang="scss" scoped>
.bubble {
width: 100%;
}
.imageCollection {
border-radius: 15px;
padding: 0;
overflow: hidden;
.row {
margin: -4px; // Compensate for column padding, so the border-radius above looks round!
padding: 0;
}
.col {
padding: 2px;
}
.file-item {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
flex-direction: column;
padding: 20px;
}
}
</style>

View file

@ -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();

View file

@ -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();

View file

@ -0,0 +1,77 @@
<template>
<!-- BASE CLASS FOR INCOMING MESSAGE -->
<div :class="messageClasses">
<div v-if="showSenderAndTime || room.displayType == ROOM_TYPE_CHANNEL" class="senderAndTime">
<div class="sender">{{ eventSenderDisplayName(event) }}</div>
<div class="time">
{{ room.displayType == ROOM_TYPE_CHANNEL ? formatTimeAgo(event?.event.origin_server_ts) : formatTime(event?.event.origin_server_ts) }}
</div>
</div>
<v-avatar class="avatar" ref="avatar" size="32" color="#ededed" @click.stop="otherAvatarClicked(avatar)">
<AuthedImage v-if="messageEventAvatar(event)" :src="messageEventAvatar(event)" onerror="this.style.display='none'" />
<span v-else class="text-white headline">{{
eventSenderDisplayName(event).substring(0, 1).toUpperCase()
}}</span>
</v-avatar>
<!-- SLOT FOR CONTENT -->
<span ref="messageInOutRef" class="content">
<slot></slot>
</span>
<div class="pin-icon" v-if="isPinned"><v-icon>$vuetify.icons.ic_pin_filled</v-icon></div>
<div class="op-button" ref="opbutton" v-if="event && !event.isRedacted() && $matrix.userCanSendMessageInCurrentRoom">
<v-btn id="btn-more" icon @click.stop="showContextMenu($refs.opbutton)">
<v-icon>more_vert</v-icon>
</v-btn>
</div>
<QuickReactionsChannel v-if="room.displayType == ROOM_TYPE_CHANNEL" :event="eventForReactions" :timelineSet="timelineSet" v-bind="$attrs"/>
<QuickReactions v-else :event="eventForReactions" :timelineSet="timelineSet" v-bind="$attrs"/>
<SeenBy v-if="room.displayType != ROOM_TYPE_CHANNEL" :room="room" :event="event"/>
</div>
</template>
<script setup lang="ts">
import SeenBy from "../SeenBy.vue";
import { MessageEmits, MessageProps, useMessage } from "./messageMixin";
import util, { ROOM_TYPE_CHANNEL } from "@/plugins/utils";
import QuickReactions from "../QuickReactions.vue";
import QuickReactionsChannel from "../channel/QuickReactionsChannel.vue";
import AuthedImage from "../../AuthedImage.vue";
import { inject, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
const opbutton = ref(null);
const messageInOutRef = ref(null);
const avatar = ref(null);
const { t } = useI18n()
const $matrix: any = inject('globalMatrix');
const props = defineProps<MessageProps>();
const emits = defineEmits<MessageEmits>();
const { room } = props;
const {
event,
eventForReactions,
showSenderAndTime,
isPinned,
messageClasses,
otherAvatarClicked,
showContextMenu,
eventSenderDisplayName,
messageEventAvatar,
formatTimeAgo,
formatTime,
initMsgHammerJs,
} = useMessage($matrix, t, props, emits);
onMounted(() => {
if (util.isMobileOrTabletBrowser() && messageInOutRef.value && opbutton.value) {
initMsgHammerJs(messageInOutRef.value, opbutton.value);
}
})
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
</style>

View file

@ -0,0 +1,220 @@
<template>
<MessageIncoming
ref="root"
v-bind="{ ...$props, ...$attrs }"
v-if="showMultiview"
>
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div class="original-message-text" v-html="linkify($$sanitize(inReplyToText))" />
</div>
<div class="message">
<SwipeableThumbnailsView
:items="items"
v-if="event && !event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL"
v-bind="$attrs"
/>
<v-container v-else-if="event && !event.isRedacted()" fluid class="imageCollection">
<v-row wrap>
<v-col v-for="{ size, item } in layoutedItems" :key="item.event.getId()" :cols="size">
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
</v-col>
</v-row>
</v-container>
<i v-if="event && event.isRedacted()" class="deleted-text">
<v-icon :color="senderIsAdminOrModerator(event) ? 'white' : ''" size="small">block</v-icon>
{{
redactedBySomeoneElse(event)
? $t("message.incoming_message_deleted_text")
: $t("message.outgoing_message_deleted_text")
}}
</i>
<span v-html="linkify($$sanitize(messageText))" v-else-if="messageText" />
<span class="edit-marker" v-if="event && event.replacingEventId() && !event.isRedacted()">
{{ t("message.edited") }}
</span>
</div>
</div>
<GalleryItemsView
:originalEvent="originalEvent"
:items="items"
:initialItem="showItem"
v-if="!!showItem"
v-on:close="showItem = undefined"
/>
</MessageIncoming>
<component
v-else-if="items.length == 1"
:is="componentFn(items[0].event)"
v-bind="{ ...$props, ...$attrs }"
:originalEvent="items[0].event"
/>
</template>
<script setup lang="ts">
import MessageIncoming from "./MessageIncoming.vue";
import { MessageEmits, MessageProps, useMessage } from "./messageMixin";
import util, { ROOM_TYPE_CHANNEL, ROOM_TYPE_FILE_MODE } from "@/plugins/utils";
import GalleryItemsView from "../../file_mode/GalleryItemsView.vue";
import ThumbnailView from "../../file_mode/ThumbnailView.vue";
import SwipeableThumbnailsView from "../channel/SwipeableThumbnailsView.vue";
import { computed, inject, onBeforeUnmount, ref, Ref, watch } from "vue";
import { EventAttachment } from "../../../models/eventAttachment";
import { useI18n } from 'vue-i18n'
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk";
const { t } = useI18n()
const $matrix: any = inject('globalMatrix');
const $$sanitize: any = inject('globalSanitize');
const root = ref(undefined);
const emits = defineEmits<MessageEmits & {(event: "layout-change", value: {element: Element | undefined, action: () => void}): void}>();
const items: Ref<EventAttachment[]> = ref([]);
const showItem: Ref<EventAttachment | undefined> = ref(undefined);
const props = defineProps<MessageProps>();
const { room } = props;
const processThread = () => {
if (!event.value?.isRedacted()) {
emits("layout-change", {element: root.value, action: _processThread});
}
};
const {
event,
thread,
senderIsAdminOrModerator,
inReplyToSender,
inReplyToText,
messageText,
redactedBySomeoneElse,
linkify,
} = useMessage($matrix, t, props, emits, processThread);
watch(event, () => {
if (event.value) {
if (thread.value === undefined) {
thread.value = props.timelineSet.relations.getChildEventsForEvent(
event.value.getId() ?? "",
util.threadMessageType(),
"m.room.message"
);
}
if (!thread.value) {
event.value.on(MatrixEventEvent.RelationsCreated, onRelationsCreated);
}
}
}, { immediate: true});
onBeforeUnmount(() => {
event.value?.off(MatrixEventEvent.RelationsCreated, onRelationsCreated);
});
const showMultiview = computed((): boolean => {
return props.room.displayType == ROOM_TYPE_FILE_MODE ||
items.value?.length > 1 ||
(event.value && event.value.isRedacted()) ||
(props.room.displayType == ROOM_TYPE_CHANNEL && items.value.length == 1 && util.isFileTypePDF(items.value[0].event)) ||
messageText.value?.length > 0
});
const onRelationsCreated = () => {
if (event.value) {
thread.value = props.timelineSet.relations.getChildEventsForEvent(
event.value.getId() ?? "",
util.threadMessageType(),
"m.room.message"
);
event.value.off(MatrixEventEvent.RelationsCreated, onRelationsCreated);
}
};
const onItemClick = (event: any) => {
showItem.value = event.item;
};
const _processThread = () => {
const eventItems = props.timelineSet.relations
.getAllChildEventsForEvent(event.value?.getId() ?? "")
.filter((e: MatrixEvent) => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype));
items.value = eventItems.map((e: MatrixEvent) => {
let ea = $matrix.attachmentManager.getEventAttachment(e);
if (showMultiview.value) {
ea.loadThumbnail();
}
return ea;
});
};
const layoutedItems = computed(() => {
if (!items.value || items.value.length == 0) {
return [];
}
let array = items.value.slice(0);
let rows = [];
while (array.length > 0) {
if (array.length >= 7) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
rows.push({ size: 12, item: array[2] });
rows.push({ size: 3, item: array[3] });
rows.push({ size: 3, item: array[4] });
rows.push({ size: 3, item: array[5] });
rows.push({ size: 3, item: array[6] });
array = array.slice(7);
} else if (array.length >= 3) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
rows.push({ size: 12, item: array[2] });
array = array.slice(3);
} else if (array.length >= 2) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
array = array.slice(2);
} else {
rows.push({ size: 12, item: array[0] });
array = array.slice(1);
}
}
return rows;
});
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
</style>
<style lang="scss" scoped>
.bubble {
width: 100%;
}
.imageCollection {
border-radius: 15px;
padding: 0;
overflow: hidden;
.row {
margin: -4px; // Compensate for column padding, so the border-radius above looks round!
padding: 0;
}
.col {
padding: 2px;
}
.file-item {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
flex-direction: column;
padding: 20px;
}
}
</style>

View file

@ -0,0 +1,95 @@
<template>
<!-- BASE CLASS FOR OUTGOING MESSAGE -->
<div :class="messageClasses">
<div class="senderAndTime">
<div class="sender" v-if="room && room.displayType == ROOM_TYPE_CHANNEL">{{ eventSenderDisplayName(event) }}</div>
<div class="time">
{{ room.displayType == ROOM_TYPE_CHANNEL ? formatTimeAgo(event?.event.origin_server_ts) : formatTime(event?.event.origin_server_ts) }}
</div>
<div class="status">{{ event?.status }}</div>
</div>
<div class="op-button" ref="opbutton" v-if="event && !event.isRedacted() && $matrix.userCanSendMessageInCurrentRoom">
<v-btn id="btn-show-menu" icon @click.stop="showContextMenu(opbutton)">
<v-icon>more_vert</v-icon>
</v-btn>
</div>
<div class="pin-icon" v-if="isPinned"><v-icon>$vuetify.icons.ic_pin_filled</v-icon></div>
<!-- SLOT FOR CONTENT -->
<span ref="messageInOutRef" class="content">
<slot></slot>
</span>
<v-avatar
class="avatar"
size="32"
color="#ededed"
@click.stop="ownAvatarClicked"
>
<AuthedImage v-if="userAvatar" :src="userAvatar" />
<span v-else class="text-white headline">{{ userAvatarLetter }}</span>
</v-avatar>
<QuickReactionsChannel v-if="room.displayType == ROOM_TYPE_CHANNEL" :event="eventForReactions" :timelineSet="timelineSet" v-bind="$attrs"/>
<QuickReactions v-else :event="eventForReactions" :timelineSet="timelineSet" v-bind="$attrs"/>
<SeenBy v-if="room.displayType != ROOM_TYPE_CHANNEL" :room="room" :event="event"/>
</div>
</template>
<script setup lang="ts">
import SeenBy from "../SeenBy.vue";
import { MessageEmits, MessageProps, useMessage } from "./messageMixin";
import util, { ROOM_TYPE_CHANNEL } from "@/plugins/utils";
import QuickReactions from "../QuickReactions.vue";
import QuickReactionsChannel from "../channel/QuickReactionsChannel.vue";
import AuthedImage from "../../AuthedImage.vue";
import { inject, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
const opbutton = ref(null);
const messageInOutRef = ref(null);
const { t } = useI18n()
const $matrix: any = inject('globalMatrix');
const props = defineProps<MessageProps>();
const emits = defineEmits<MessageEmits>();
const { room } = props;
const {
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,
} = useMessage($matrix, t, props, emits);
onMounted(() => {
if (util.isMobileOrTabletBrowser() && messageInOutRef.value && opbutton.value) {
initMsgHammerJs(messageInOutRef.value, opbutton.value);
}
})
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
</style>

View file

@ -0,0 +1,218 @@
<template>
<MessageOutgoing
ref="root"
v-bind="{ ...$props, ...$attrs }"
v-if="showMultiview"
>
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div class="original-message-text" v-html="linkify($$sanitize(inReplyToText))" />
</div>
<div class="message">
<SwipeableThumbnailsView
:items="items"
v-if="event && !event.isRedacted() && room.displayType == ROOM_TYPE_CHANNEL"
v-bind="$attrs"
/>
<v-container v-else-if="event && !event.isRedacted()" fluid class="imageCollection">
<v-row wrap>
<v-col v-for="{ size, item } in layoutedItems" :key="item.event.getId()" :cols="size">
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
</v-col>
</v-row>
</v-container>
<i v-if="event && event.isRedacted()" class="deleted-text">
<v-icon size="small">block</v-icon>
{{
redactedBySomeoneElse(event)
? t("message.incoming_message_deleted_text")
: t("message.outgoing_message_deleted_text")
}}
</i>
<span v-html="linkify($$sanitize(messageText))" v-else-if="messageText" />
<span class="edit-marker" v-if="event && event.replacingEventId() && !event.isRedacted()">
{{ t("message.edited") }}
</span>
</div>
</div>
<GalleryItemsView
:originalEvent="originalEvent"
:items="items"
:initialItem="showItem"
v-if="!!showItem"
v-on:close="showItem = undefined"
/>
</MessageOutgoing>
<component
v-else-if="items.length == 1"
:is="componentFn(items[0].event)"
v-bind="{ ...$props, ...$attrs }"
:originalEvent="items[0].event"
/>
</template>
<script setup lang="ts">
import MessageOutgoing from "./MessageOutgoing.vue";
import { MessageEmits, MessageProps, useMessage } from "./messageMixin";
import util, { ROOM_TYPE_CHANNEL } from "@/plugins/utils";
import GalleryItemsView from "../../file_mode/GalleryItemsView.vue";
import ThumbnailView from "../../file_mode/ThumbnailView.vue";
import SwipeableThumbnailsView from "../channel/SwipeableThumbnailsView.vue";
import { computed, inject, onBeforeUnmount, ref, Ref, watch } from "vue";
import { EventAttachment } from "../../../models/eventAttachment";
import { useI18n } from 'vue-i18n'
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk";
const { t } = useI18n()
const $matrix: any = inject('globalMatrix');
const $$sanitize: any = inject('globalSanitize');
const root = ref(undefined);
const emits = defineEmits<MessageEmits & {(event: "layout-change", value: {element: Element | undefined, action: () => void}): void}>();
const items: Ref<EventAttachment[]> = ref([]);
const showItem: Ref<EventAttachment | undefined> = ref(undefined);
const props = defineProps<MessageProps>();
const { room } = props;
const processThread = () => {
if (!event.value?.isRedacted()) {
emits("layout-change", {element: root.value, action: _processThread});
}
};
const {
event,
thread,
inReplyToSender,
inReplyToText,
messageText,
redactedBySomeoneElse,
linkify,
} = useMessage($matrix, t, props, emits, processThread);
watch(event, () => {
if (event.value) {
if (thread.value === undefined) {
thread.value = props.timelineSet.relations.getChildEventsForEvent(
event.value.getId() ?? "",
util.threadMessageType(),
"m.room.message"
);
}
if (!thread.value) {
event.value.on(MatrixEventEvent.RelationsCreated, onRelationsCreated);
}
}
}, { immediate: true});
onBeforeUnmount(() => {
event.value?.off(MatrixEventEvent.RelationsCreated, onRelationsCreated);
});
const showMultiview = computed((): boolean => {
return items.value?.length > 1 ||
(event.value && event.value.isRedacted()) ||
(props.room.displayType == ROOM_TYPE_CHANNEL && items.value.length == 1 && util.isFileTypePDF(items.value[0].event)) ||
messageText.value?.length > 0
});
const onRelationsCreated = () => {
if (event.value) {
thread.value = props.timelineSet.relations.getChildEventsForEvent(
event.value.getId() ?? "",
util.threadMessageType(),
"m.room.message"
);
event.value.off(MatrixEventEvent.RelationsCreated, onRelationsCreated);
}
};
const onItemClick = (event: any) => {
showItem.value = event.item;
};
const _processThread = () => {
const eventItems = props.timelineSet.relations
.getAllChildEventsForEvent(event.value?.getId() ?? "")
.filter((e: MatrixEvent) => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype));
items.value = eventItems.map((e: MatrixEvent) => {
let ea = $matrix.attachmentManager.getEventAttachment(e);
if (showMultiview.value) {
ea.loadThumbnail();
}
return ea;
});
};
const layoutedItems = computed(() => {
if (!items.value || items.value.length == 0) {
return [];
}
let array = items.value.slice(0);
let rows = [];
while (array.length > 0) {
if (array.length >= 7) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
rows.push({ size: 12, item: array[2] });
rows.push({ size: 3, item: array[3] });
rows.push({ size: 3, item: array[4] });
rows.push({ size: 3, item: array[5] });
rows.push({ size: 3, item: array[6] });
array = array.slice(7);
} else if (array.length >= 3) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
rows.push({ size: 12, item: array[2] });
array = array.slice(3);
} else if (array.length >= 2) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
array = array.slice(2);
} else {
rows.push({ size: 12, item: array[0] });
array = array.slice(1);
}
}
return rows;
});
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
</style>
<style lang="scss" scoped>
.bubble {
width: 100%;
}
.imageCollection {
border-radius: 15px;
padding: 0;
overflow: hidden;
.row {
margin: -4px; // Compensate for column padding, so the border-radius above looks round!
padding: 0;
}
.col {
padding: 2px;
}
.file-item {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
flex-direction: column;
padding: 20px;
}
}
</style>

View file

@ -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<KeanuEvent | undefined> = ref(undefined);
const thread: Ref<Relations | undefined> = 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<Hammer | undefined> = ref(undefined);
const mcCustom: Ref<Hammer.Manager | undefined> = 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,
};
};

View file

@ -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:<eventname>.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

View file

@ -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<string>;
thumbnailPromise?: Promise<string>;
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<string | undefined, CacheEntry>;
cache: Map<string | undefined, Reactive<EventAttachment>>;
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<string> {
console.error("GET ATTACHMENT FOR EVENT", event.getId());
const entry = this.cache.get(event.getId()) ?? {};
if (entry.attachment) {
if (outputObject) {
outputObject.src = entry.attachment;
}
return entry.attachment;
public getEventAttachment(event: MatrixEvent & KeanuEventExtension): Reactive<EventAttachment> {
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<EventAttachment> = 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<string> {
console.error("GET THUMB FOR EVENT", event.getId());
const entry = this.cache.get(event.getId()) ?? {};
if (entry.thumbnail) {
if (outputObject) {
outputObject.thumbnailSrc = entry.thumbnail;
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");

View file

@ -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<string>;
thumbnailPromise?: Promise<string>;
srcProgress: number;
thumbnailProgress: number;
loadSrc: () => void;
loadThumbnail: () => Promise<string>;
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;
}

View file

@ -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));
},
};