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

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