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",
|
"rollup-plugin-polyfill-node": "^0.13.0",
|
||||||
"sass": "^1.86.0",
|
"sass": "^1.86.0",
|
||||||
"sass-loader": "^10",
|
"sass-loader": "^10",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
"unplugin-vue-components": "^28.4.1",
|
"unplugin-vue-components": "^28.4.1",
|
||||||
"vite": "^6.2.2",
|
"vite": "^6.2.2",
|
||||||
"vite-plugin-static-copy": "^2.3.0",
|
"vite-plugin-static-copy": "^2.3.0",
|
||||||
|
|
@ -7032,6 +7033,20 @@
|
||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/ufo": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
|
||||||
|
|
@ -12600,6 +12615,12 @@
|
||||||
"prelude-ls": "^1.2.1"
|
"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": {
|
"ufo": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@
|
||||||
"rollup-plugin-polyfill-node": "^0.13.0",
|
"rollup-plugin-polyfill-node": "^0.13.0",
|
||||||
"sass": "^1.86.0",
|
"sass": "^1.86.0",
|
||||||
"sass-loader": "^10",
|
"sass-loader": "^10",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
"unplugin-vue-components": "^28.4.1",
|
"unplugin-vue-components": "^28.4.1",
|
||||||
"vite": "^6.2.2",
|
"vite": "^6.2.2",
|
||||||
"vite-plugin-static-copy": "^2.3.0",
|
"vite-plugin-static-copy": "^2.3.0",
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
see below. Otherwise things like context menus won't work as designed.
|
see below. Otherwise things like context menus won't work as designed.
|
||||||
-->
|
-->
|
||||||
<component :is="event.component" :room="room" :originalEvent="event" :nextEvent="event.nextDisplayedEvent"
|
<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"
|
:componentFn="componentForEvent"
|
||||||
v-on:context-menu="showContextMenuForEvent({event: event, anchor: $event.anchor})"
|
v-on:context-menu="showContextMenuForEvent({event: event, anchor: $event.anchor})"
|
||||||
v-on:own-avatar-clicked="viewProfile"
|
v-on:own-avatar-clicked="viewProfile"
|
||||||
|
|
@ -415,6 +415,7 @@ import { markRaw } from "vue";
|
||||||
import timerIcon from '@/assets/icons/ic_timer.svg';
|
import timerIcon from '@/assets/icons/ic_timer.svg';
|
||||||
import proofmode from "../plugins/proofmode.js";
|
import proofmode from "../plugins/proofmode.js";
|
||||||
import C2PABadge from "./c2pa/C2PABadge.vue";
|
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 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! */
|
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, "") : "";
|
return (contentArr && contentArr.length > 0) ? contentArr[0].replace(/^> (<.*> )?/g, "") : "";
|
||||||
},
|
},
|
||||||
heartEmoji() {
|
heartEmoji() {
|
||||||
return this.$refs.emojiPicker.mapEmojis["Symbols"].find(({ aliases }) => aliases.includes('heart')).data;
|
return "❤️";
|
||||||
},
|
},
|
||||||
compActiveMember() {
|
compActiveMember() {
|
||||||
const currentUserId= this.selectedEvent?.sender.userId || this.$matrix.currentUserId
|
const currentUserId= this.selectedEvent?.sender.userId || this.$matrix.currentUserId
|
||||||
|
|
@ -1347,7 +1348,7 @@ export default {
|
||||||
const sel = "[eventId=\"" + parentEvent.getId() + "\"]";
|
const sel = "[eventId=\"" + parentEvent.getId() + "\"]";
|
||||||
const element = document.querySelector(sel);
|
const element = document.querySelector(sel);
|
||||||
if (element) {
|
if (element) {
|
||||||
this.onLayoutChange(fn, element);
|
this.onLayoutChange({action: fn, element: element});
|
||||||
} else {
|
} else {
|
||||||
fn();
|
fn();
|
||||||
}
|
}
|
||||||
|
|
@ -1377,7 +1378,7 @@ export default {
|
||||||
const sel = "[eventId=\"" + parentEvent.getId() + "\"]";
|
const sel = "[eventId=\"" + parentEvent.getId() + "\"]";
|
||||||
const element = document.querySelector(sel);
|
const element = document.querySelector(sel);
|
||||||
if (element) {
|
if (element) {
|
||||||
this.onLayoutChange(fn, element);
|
this.onLayoutChange({action: fn, element: element});
|
||||||
} else {
|
} else {
|
||||||
fn();
|
fn();
|
||||||
}
|
}
|
||||||
|
|
@ -1598,7 +1599,8 @@ export default {
|
||||||
* @param {} action A function that performs desired layout changes.
|
* @param {} action A function that performs desired layout changes.
|
||||||
* @param {*} element Root element for the chat message.
|
* @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) {
|
if (!element || !element.parentElement || this.useVoiceMode || this.useFileModeNonAdmin) {
|
||||||
action();
|
action();
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<v-img class="image-with-progress" v-bind="{...$props, ...$attrs}">
|
<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>
|
</v-img>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -10,10 +10,12 @@ import util from "../plugins/utils";
|
||||||
import rememberMeMixin from "./rememberMeMixin";
|
import rememberMeMixin from "./rememberMeMixin";
|
||||||
import * as sdk from "matrix-js-sdk";
|
import * as sdk from "matrix-js-sdk";
|
||||||
import logoMixin from "./logoMixin";
|
import logoMixin from "./logoMixin";
|
||||||
import LoadProgress
|
import LoadProgress from "./LoadProgress.vue";
|
||||||
from "./LoadProgress.vue";
|
import { VImg } from "vuetify/components/VImg";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ImageWithProgress",
|
name: "ImageWithProgress",
|
||||||
|
extends: VImg,
|
||||||
components: { LoadProgress },
|
components: { LoadProgress },
|
||||||
props: {
|
props: {
|
||||||
loadingProgress: {
|
loadingProgress: {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import MessageIncomingAudio from "./messages/MessageIncomingAudio.vue";
|
||||||
import MessageIncomingVideo from "./messages/MessageIncomingVideo.vue";
|
import MessageIncomingVideo from "./messages/MessageIncomingVideo.vue";
|
||||||
import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue";
|
import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue";
|
||||||
import MessageIncomingPoll from "./messages/MessageIncomingPoll.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 MessageOutgoingText from "./messages/MessageOutgoingText";
|
||||||
import MessageOutgoingFile from "./messages/MessageOutgoingFile";
|
import MessageOutgoingFile from "./messages/MessageOutgoingFile";
|
||||||
import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue";
|
import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue";
|
||||||
|
|
@ -15,7 +15,7 @@ import MessageOutgoingAudio from "./messages/MessageOutgoingAudio.vue";
|
||||||
import MessageOutgoingVideo from "./messages/MessageOutgoingVideo.vue";
|
import MessageOutgoingVideo from "./messages/MessageOutgoingVideo.vue";
|
||||||
import MessageOutgoingSticker from "./messages/MessageOutgoingSticker.vue";
|
import MessageOutgoingSticker from "./messages/MessageOutgoingSticker.vue";
|
||||||
import MessageOutgoingPoll from "./messages/MessageOutgoingPoll.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 MessageIncomingImageExport from "./messages/export/MessageIncomingImageExport";
|
||||||
import MessageIncomingAudioExport from "./messages/export/MessageIncomingAudioExport";
|
import MessageIncomingAudioExport from "./messages/export/MessageIncomingAudioExport";
|
||||||
import MessageIncomingVideoExport from "./messages/export/MessageIncomingVideoExport";
|
import MessageIncomingVideoExport from "./messages/export/MessageIncomingVideoExport";
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
<div class="file-drop-thumbnail-container">
|
<div class="file-drop-thumbnail-container">
|
||||||
<div :class="{ 'file-drop-thumbnail': true, 'clickable': true, 'current': id == currentItemIndex }"
|
<div :class="{ 'file-drop-thumbnail': true, 'clickable': true, 'current': id == currentItemIndex }"
|
||||||
@click="currentItemIndex = id" v-for="(currentImageInput, id) in items" :key="id">
|
@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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="thumbnailRef">
|
<div ref="thumbnailRef" style="width: 100%;height: 100%;">
|
||||||
<v-responsive
|
<v-responsive
|
||||||
v-if="item.event.getContent().msgtype == 'm.video' && item.src"
|
v-if="item.event.getContent().msgtype == 'm.video' && item.src"
|
||||||
:class="{ 'thumbnail-item': true, preview: previewOnly }"
|
:class="{ 'thumbnail-item': true, preview: previewOnly }"
|
||||||
|
|
@ -8,13 +8,14 @@
|
||||||
{{ $t("fallbacks.video_file") }}
|
{{ $t("fallbacks.video_file") }}
|
||||||
</video>
|
</video>
|
||||||
</v-responsive>
|
</v-responsive>
|
||||||
<v-img
|
<ImageWithProgress
|
||||||
v-else-if="item.event.getContent().msgtype == 'm.image' && item.src"
|
v-else-if="item.event.getContent().msgtype == 'm.image'"
|
||||||
:aspect-ratio="previewOnly ? 16 / 9 : undefined"
|
:aspect-ratio="previewOnly ? 16 / 9 : undefined"
|
||||||
:class="{ 'thumbnail-item': true, preview: previewOnly }"
|
:class="{ 'thumbnail-item': true, preview: previewOnly }"
|
||||||
:src="item.src"
|
:src="item.src ? item.src : item.thumbnail"
|
||||||
:contain="!previewOnly"
|
:contain="!previewOnly"
|
||||||
:cover="previewOnly"
|
:cover="previewOnly"
|
||||||
|
:loadingProgress="previewOnly ? item.thumbnailProgress : item.srcProgress"
|
||||||
/>
|
/>
|
||||||
<div v-else :class="{ 'thumbnail-item': true, preview: previewOnly, 'file-item': true }">
|
<div v-else :class="{ 'thumbnail-item': true, preview: previewOnly, 'file-item': true }">
|
||||||
<v-icon :class="fileTypeIconClass">{{ fileTypeIcon }}</v-icon>
|
<v-icon :class="fileTypeIconClass">{{ fileTypeIcon }}</v-icon>
|
||||||
|
|
@ -23,13 +24,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import util from "../../plugins/utils";
|
import util from "../../plugins/utils";
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
import type { PropType } from 'vue'
|
import type { PropType } from 'vue'
|
||||||
import { EventAttachment } from "../../models/eventAttachment";
|
import { EventAttachment } from "../../models/eventAttachment";
|
||||||
|
import ImageWithProgress from "../ImageWithProgress";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
components: { ImageWithProgress },
|
||||||
props: {
|
props: {
|
||||||
/**
|
/**
|
||||||
* Item is an object of { event: MXEvent, src: URL }
|
* Item is an object of { event: MXEvent, src: URL }
|
||||||
|
|
@ -95,6 +99,9 @@ export default defineComponent({
|
||||||
if (this.$refs.thumbnailRef) {
|
if (this.$refs.thumbnailRef) {
|
||||||
this.initThumbnailHammerJs(this.$refs.thumbnailRef);
|
this.initThumbnailHammerJs(this.$refs.thumbnailRef);
|
||||||
}
|
}
|
||||||
|
if (!this.previewOnly && this.item) {
|
||||||
|
this.item.loadSrc();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@
|
||||||
<ImageWithProgress
|
<ImageWithProgress
|
||||||
:aspect-ratio="16 / 9"
|
:aspect-ratio="16 / 9"
|
||||||
ref="image"
|
ref="image"
|
||||||
:src="src ? src : thumbnailSrc"
|
:src="eventAttachment.src ? eventAttachment.src : eventAttachment.thumbnail"
|
||||||
:cover="cover"
|
:cover="cover"
|
||||||
:contain="contain"
|
:contain="contain"
|
||||||
:loadingProgress="thumbnailProgress"
|
:loadingProgress="eventAttachment.thumbnailProgress"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<v-dialog v-model="dialog" :width="$vuetify.display.smAndUp ? '940px' : '90%'">
|
<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>
|
</v-dialog>
|
||||||
</message-incoming>
|
</message-incoming>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -26,10 +26,7 @@ export default {
|
||||||
components: { MessageIncoming, ImageWithProgress },
|
components: { MessageIncoming, ImageWithProgress },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
src: undefined,
|
eventAttachment: {},
|
||||||
thumbnailSrc: undefined,
|
|
||||||
srcProgress: -1,
|
|
||||||
thumbnailProgress: -1,
|
|
||||||
cover: true,
|
cover: true,
|
||||||
contain: false,
|
contain: false,
|
||||||
dialog: false,
|
dialog: false,
|
||||||
|
|
@ -42,17 +39,7 @@ export default {
|
||||||
|
|
||||||
hammerInstance.on("singletap doubletap", (ev) => {
|
hammerInstance.on("singletap doubletap", (ev) => {
|
||||||
if (ev.type === "singletap") {
|
if (ev.type === "singletap") {
|
||||||
this.$matrix.attachmentManager
|
this.eventAttachment?.loadSrc();
|
||||||
.loadEventAttachment(
|
|
||||||
this.event,
|
|
||||||
(percent) => {
|
|
||||||
this.srcProgress = percent;
|
|
||||||
},
|
|
||||||
this
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
|
||||||
console.log("Failed to fetch attachment: ", err);
|
|
||||||
});
|
|
||||||
this.dialog = true;
|
this.dialog = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -74,20 +61,11 @@ export default {
|
||||||
this.initMessageInImageHammerJs(this.$refs.imageRef);
|
this.initMessageInImageHammerJs(this.$refs.imageRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$matrix.attachmentManager
|
this.eventAttachment = this.$matrix.attachmentManager.getEventAttachment(this.event);
|
||||||
.loadEventThumbnail(
|
this.eventAttachment?.loadThumbnail();
|
||||||
this.event,
|
|
||||||
(percent) => {
|
|
||||||
this.thumbnailProgress = percent;
|
|
||||||
},
|
|
||||||
this
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
|
||||||
console.log("Failed to fetch thumbnail: ", err);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
this.$matrix.attachmentManager.releaseEvent(this.event);
|
this.eventAttachment?.release();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</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
|
<ImageWithProgress
|
||||||
:aspect-ratio="16 / 9"
|
:aspect-ratio="16 / 9"
|
||||||
ref="image"
|
ref="image"
|
||||||
:src="src ? src : thumbnailSrc"
|
:src="eventAttachment.src ? eventAttachment.src : eventAttachment.thumbnail"
|
||||||
:cover="cover"
|
:cover="cover"
|
||||||
:contain="contain"
|
:contain="contain"
|
||||||
:loadingProgress="thumbnailProgress"
|
:loadingProgress="eventAttachment.thumbnailProgress"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<v-dialog v-model="dialog" :width="$vuetify.display.smAndUp ? '940px' : '90%'">
|
<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>
|
</v-dialog>
|
||||||
</message-outgoing>
|
</message-outgoing>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -26,10 +26,7 @@ export default {
|
||||||
components: { MessageOutgoing, ImageWithProgress },
|
components: { MessageOutgoing, ImageWithProgress },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
src: undefined,
|
eventAttachment: {},
|
||||||
thumbnailSrc: undefined,
|
|
||||||
srcProgress: -1,
|
|
||||||
thumbnailProgress: -1,
|
|
||||||
cover: true,
|
cover: true,
|
||||||
contain: false,
|
contain: false,
|
||||||
dialog: false,
|
dialog: false,
|
||||||
|
|
@ -42,23 +39,15 @@ export default {
|
||||||
|
|
||||||
hammerInstance.on("singletap doubletap", (ev) => {
|
hammerInstance.on("singletap doubletap", (ev) => {
|
||||||
if (ev.type === "singletap") {
|
if (ev.type === "singletap") {
|
||||||
this.$matrix.attachmentManager
|
this.eventAttachment?.loadSrc();
|
||||||
.loadEventAttachment(
|
|
||||||
this.event,
|
|
||||||
(percent) => {
|
|
||||||
this.srcProgress = percent;
|
|
||||||
},
|
|
||||||
this
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
|
||||||
console.log("Failed to fetch attachment: ", err);
|
|
||||||
});
|
|
||||||
this.dialog = true;
|
this.dialog = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
console.error("Mounted outgoing image, load thumbnail!");
|
||||||
|
|
||||||
const info = this.event.getContent().info;
|
const info = this.event.getContent().info;
|
||||||
// JPEGs use cover, PNG and GIF ect contain. This is because PNG and GIF are expected to
|
// JPEGs use cover, PNG and GIF ect contain. This is because PNG and GIF are expected to
|
||||||
// be stickers and small emoji type things.
|
// be stickers and small emoji type things.
|
||||||
|
|
@ -72,21 +61,11 @@ export default {
|
||||||
if (this.$refs.imageRef) {
|
if (this.$refs.imageRef) {
|
||||||
this.initMessageOutImageHammerJs(this.$refs.imageRef);
|
this.initMessageOutImageHammerJs(this.$refs.imageRef);
|
||||||
}
|
}
|
||||||
|
this.eventAttachment = this.$matrix.attachmentManager.getEventAttachment(this.event);
|
||||||
this.$matrix.attachmentManager
|
this.eventAttachment?.loadThumbnail();
|
||||||
.loadEventThumbnail(
|
|
||||||
this.event,
|
|
||||||
(percent) => {
|
|
||||||
this.thumbnailProgress = percent;
|
|
||||||
},
|
|
||||||
this
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
|
||||||
console.log("Failed to fetch thumbnail: ", err);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
this.$matrix.attachmentManager.releaseEvent(this.event);
|
this.eventAttachment?.release();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</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');
|
this.reactions = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), 'm.annotation', 'm.reaction');
|
||||||
},
|
},
|
||||||
onClickEmoji(emoji) {
|
onClickEmoji(emoji) {
|
||||||
this.$bubble('send-quick-reaction', {reaction:emoji, event:this.event});
|
this.$emit('send-quick-reaction', {reaction:emoji, event:this.event});
|
||||||
},
|
},
|
||||||
onAddRelation(ignoredevent) {
|
onAddRelation(ignoredevent) {
|
||||||
this.processReactions();
|
this.processReactions();
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export default {
|
||||||
this.reactions = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), 'm.annotation', 'm.reaction');
|
this.reactions = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), 'm.annotation', 'm.reaction');
|
||||||
},
|
},
|
||||||
onClickEmoji(emoji) {
|
onClickEmoji(emoji) {
|
||||||
this.$bubble('send-quick-reaction', {reaction:emoji, event:this.event});
|
this.$emit('send-quick-reaction', {reaction:emoji, event:this.event});
|
||||||
},
|
},
|
||||||
onAddRelation(ignoredevent) {
|
onAddRelation(ignoredevent) {
|
||||||
this.processReactions();
|
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(VueClipboard);
|
||||||
app.use(audioPlayer);
|
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
|
// Register a global custom directive called `v-blur` that prevents focus
|
||||||
app.directive('blur', {
|
app.directive('blur', {
|
||||||
mounted: function (el) {
|
mounted: function (el) {
|
||||||
|
|
@ -182,6 +161,9 @@ app.use(i18n);
|
||||||
app.$i18n = i18n;
|
app.$i18n = i18n;
|
||||||
app.config.globalProperties.$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 });
|
app.use(matrix, { store: store, i18n: i18n });
|
||||||
|
|
||||||
// Set $matrix inside data store
|
// Set $matrix inside data store
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,12 @@
|
||||||
import { MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
import { MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
||||||
import { KeanuEventExtension } from "./eventAttachment";
|
import { EventAttachment, KeanuEventExtension } from "./eventAttachment";
|
||||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
import { Counter, ModeOfOperation } from "aes-js";
|
import { Counter, ModeOfOperation } from "aes-js";
|
||||||
import { Attachment } from "./attachment";
|
import { Attachment } from "./attachment";
|
||||||
import proofmode from "../plugins/proofmode";
|
import proofmode from "../plugins/proofmode";
|
||||||
import imageSize from "image-size";
|
import imageSize from "image-size";
|
||||||
import imageResize from "image-resize";
|
import imageResize from "image-resize";
|
||||||
import { reactive } from "vue";
|
import { Reactive, reactive } from "vue";
|
||||||
|
|
||||||
type CacheEntry = {
|
|
||||||
attachment?: string;
|
|
||||||
thumbnail?: string;
|
|
||||||
attachmentPromise?: Promise<string>;
|
|
||||||
thumbnailPromise?: Promise<string>;
|
|
||||||
attachmentProgress?: ((progress: number) => void)[];
|
|
||||||
thumbnailProgress?: ((progress: number) => void)[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export class AttachmentManager {
|
export class AttachmentManager {
|
||||||
matrixClient: MatrixClient;
|
matrixClient: MatrixClient;
|
||||||
|
|
@ -23,7 +14,7 @@ export class AttachmentManager {
|
||||||
maxSizeUploads: number;
|
maxSizeUploads: number;
|
||||||
maxSizeAutoDownloads: number;
|
maxSizeAutoDownloads: number;
|
||||||
|
|
||||||
cache: Map<string | undefined, CacheEntry>;
|
cache: Map<string | undefined, Reactive<EventAttachment>>;
|
||||||
|
|
||||||
constructor(matrixClient: MatrixClient, useAuthedMedia: boolean, maxSizeAutoDownloads: number) {
|
constructor(matrixClient: MatrixClient, useAuthedMedia: boolean, maxSizeAutoDownloads: number) {
|
||||||
this.matrixClient = matrixClient;
|
this.matrixClient = matrixClient;
|
||||||
|
|
@ -34,9 +25,12 @@ export class AttachmentManager {
|
||||||
this.cache = new Map();
|
this.cache = new Map();
|
||||||
|
|
||||||
// Get max upload size
|
// Get max upload size
|
||||||
this.matrixClient.getMediaConfig(useAuthedMedia).then((config) => {
|
this.matrixClient
|
||||||
|
.getMediaConfig(useAuthedMedia)
|
||||||
|
.then((config) => {
|
||||||
this.maxSizeUploads = config["m.upload.size"] ?? 0;
|
this.maxSizeUploads = config["m.upload.size"] ?? 0;
|
||||||
}).catch(() => {});
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public createAttachment(file: File): Attachment {
|
public createAttachment(file: File): Attachment {
|
||||||
|
|
@ -90,7 +84,11 @@ export class AttachmentManager {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use scaled version if the image does not contain C2PA
|
// 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) => {
|
.catch((err) => {
|
||||||
console.error("Resize failed:", err);
|
console.error("Resize failed:", err);
|
||||||
|
|
@ -110,76 +108,62 @@ export class AttachmentManager {
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async loadEventAttachment(
|
public getEventAttachment(event: MatrixEvent & KeanuEventExtension): Reactive<EventAttachment> {
|
||||||
event: MatrixEvent & KeanuEventExtension,
|
let entry = this.cache.get(event.getId());
|
||||||
progress?: (percent: number) => void,
|
if (entry !== undefined) {
|
||||||
outputObject?: { src: string; thumbnailSrc: string }
|
return entry;
|
||||||
): 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;
|
const attachment: Reactive<EventAttachment> = reactive({
|
||||||
}
|
event: event,
|
||||||
if (!entry.attachmentPromise) {
|
srcProgress: -1,
|
||||||
entry.attachmentPromise = this._loadEventAttachmentOrThumbnail(event, false, progress)
|
thumbnailProgress: -1,
|
||||||
.then((attachment) => {
|
loadSrc: () => Promise.reject("Not implemented"),
|
||||||
entry.attachment = attachment;
|
loadThumbnail: () => Promise.reject("Not implemented"),
|
||||||
return attachment;
|
release: () => Promise.reject("Not implemented"),
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
entry.attachmentPromise = undefined;
|
|
||||||
throw err;
|
|
||||||
});
|
});
|
||||||
this.cache.set(event.getId(), entry);
|
attachment.loadSrc = () => {
|
||||||
|
if (attachment.src) {
|
||||||
|
return Promise.resolve(attachment.src);
|
||||||
|
} else if (attachment.srcPromise) {
|
||||||
|
return attachment.srcPromise;
|
||||||
}
|
}
|
||||||
entry.attachmentProgress = (entry.attachmentProgress ?? []).concat();
|
attachment.srcPromise = this._loadEventAttachmentOrThumbnail(event, false, (percent) => {
|
||||||
return entry.attachmentPromise.then((attachment) => {
|
attachment.srcProgress = percent;
|
||||||
console.error("GOT ATTACHMENT", attachment);
|
}).then((src) => {
|
||||||
if (outputObject) {
|
attachment.src = src;
|
||||||
outputObject.src = attachment;
|
return src;
|
||||||
}
|
|
||||||
return attachment;
|
|
||||||
});
|
});
|
||||||
|
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) => {
|
||||||
public async loadEventThumbnail(
|
attachment.thumbnailProgress = percent;
|
||||||
event: MatrixEvent & KeanuEventExtension,
|
}).then((thummbnail) => {
|
||||||
progress?: (percent: number) => void,
|
attachment.thumbnail = thummbnail;
|
||||||
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;
|
|
||||||
}
|
|
||||||
return entry.thumbnail;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!entry.thumbnailPromise) {
|
|
||||||
entry.thumbnailPromise = this._loadEventAttachmentOrThumbnail(event, true, progress)
|
|
||||||
.then((thummbnail) => {
|
|
||||||
entry.thumbnail = thummbnail;
|
|
||||||
return thummbnail;
|
return thummbnail;
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
entry.thumbnailPromise = undefined;
|
|
||||||
throw err;
|
|
||||||
});
|
});
|
||||||
this.cache.set(event.getId(), entry);
|
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);
|
||||||
}
|
}
|
||||||
return entry.thumbnailPromise.then((thumbnail) => {
|
if (attachment.thumbnail) {
|
||||||
console.error("GOT THUMB", thumbnail);
|
URL.revokeObjectURL(attachment.thumbnail);
|
||||||
if (outputObject) {
|
|
||||||
outputObject.thumbnailSrc = thumbnail;
|
|
||||||
}
|
}
|
||||||
return thumbnail;
|
}
|
||||||
});
|
}
|
||||||
|
this.cache.set(event.getId(), attachment!);
|
||||||
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _loadEventAttachmentOrThumbnail(
|
private async _loadEventAttachmentOrThumbnail(
|
||||||
|
|
@ -277,21 +261,6 @@ export class AttachmentManager {
|
||||||
return URL.createObjectURL(new Blob([bytes.buffer], { type: mime }));
|
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) {
|
private b64toBuffer(val: any) {
|
||||||
const baseValue = val.replaceAll("-", "+").replaceAll("_", "/");
|
const baseValue = val.replaceAll("-", "+").replaceAll("_", "/");
|
||||||
return Buffer.from(baseValue, "base64");
|
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 = {
|
export type KeanuEventExtension = {
|
||||||
isMxThread?: boolean;
|
isMxThread?: boolean;
|
||||||
isChannelMessage?: boolean;
|
isChannelMessage?: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
|
parentThread?: MatrixEvent & KeanuEventExtension;
|
||||||
|
replyEvent?: MatrixEvent & KeanuEventExtension;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventAttachment = {
|
export type EventAttachment = {
|
||||||
|
|
@ -12,4 +14,15 @@ export type EventAttachment = {
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
srcPromise?: Promise<string>;
|
srcPromise?: Promise<string>;
|
||||||
thumbnailPromise?: 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");
|
const instance = matrixService.mount("#app2");
|
||||||
app.config.globalProperties.$matrix = instance;
|
app.config.globalProperties.$matrix = instance;
|
||||||
app.$matrix = instance;
|
app.$matrix = instance;
|
||||||
|
app.provide("globalMatrix", instance);
|
||||||
sdk.setCryptoStoreFactory(instance.createCryptoStore.bind(instance));
|
sdk.setCryptoStoreFactory(instance.createCryptoStore.bind(instance));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue