Migrate media thread views to composition API
This commit is contained in:
parent
77eebafb79
commit
44578048aa
22 changed files with 1144 additions and 598 deletions
21
package-lock.json
generated
21
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
77
src/components/messages/composition/MessageIncoming.vue
Normal file
77
src/components/messages/composition/MessageIncoming.vue
Normal 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>
|
||||
220
src/components/messages/composition/MessageIncomingThread.vue
Normal file
220
src/components/messages/composition/MessageIncomingThread.vue
Normal 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>
|
||||
95
src/components/messages/composition/MessageOutgoing.vue
Normal file
95
src/components/messages/composition/MessageOutgoing.vue
Normal 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>
|
||||
218
src/components/messages/composition/MessageOutgoingThread.vue
Normal file
218
src/components/messages/composition/MessageOutgoingThread.vue
Normal 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>
|
||||
384
src/components/messages/composition/messageMixin.ts
Normal file
384
src/components/messages/composition/messageMixin.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
24
src/main.js
24
src/main.js
|
|
@ -41,27 +41,6 @@ app.use(analytics);
|
|||
app.use(VueClipboard);
|
||||
app.use(audioPlayer);
|
||||
|
||||
// Add bubble functionality to custom events.
|
||||
// From here: https://stackoverflow.com/questions/41993508/vuejs-bubbling-custom-events
|
||||
app.use((instance) => {
|
||||
instance.$bubble = function $bubble(eventName, ...args) {
|
||||
// Emit the event on all parent components
|
||||
let component = this;
|
||||
let arg = args.at(0);
|
||||
let stop = false;
|
||||
if (arg) {
|
||||
// Add a "stopPropagation" function so that we can do v-on:<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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue