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

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