feat: show ReplyTo in widget UI (#8094)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
class="agent-message-wrap"
|
||||
:class="{ 'has-response': hasRecordedResponse || isASubmittedForm }"
|
||||
class="agent-message-wrap group"
|
||||
:class="{
|
||||
'has-response': hasRecordedResponse || isASubmittedForm,
|
||||
}"
|
||||
>
|
||||
<div v-if="!isASubmittedForm" class="agent-message">
|
||||
<div class="avatar-wrap">
|
||||
@@ -13,31 +15,51 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="message-wrap">
|
||||
<AgentMessageBubble
|
||||
v-if="shouldDisplayAgentMessage"
|
||||
:content-type="contentType"
|
||||
:message-content-attributes="messageContentAttributes"
|
||||
:message-id="message.id"
|
||||
:message-type="messageType"
|
||||
:message="message.content"
|
||||
/>
|
||||
<div
|
||||
v-if="hasAttachments"
|
||||
class="chat-bubble has-attachment agent"
|
||||
:class="(wrapClass, $dm('bg-white', 'dark:bg-slate-700'))"
|
||||
>
|
||||
<div v-for="attachment in message.attachments" :key="attachment.id">
|
||||
<image-bubble
|
||||
v-if="attachment.file_type === 'image' && !hasImageError"
|
||||
:url="attachment.data_url"
|
||||
:thumb="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
@error="onImageLoadError"
|
||||
<div v-if="hasReplyTo" class="flex mt-2 mb-1 text-xs">
|
||||
<reply-to-chip :reply-to="replyTo" />
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<drag-wrapper
|
||||
class="space-y-2"
|
||||
direction="right"
|
||||
@dragged="toggleReply"
|
||||
>
|
||||
<AgentMessageBubble
|
||||
v-if="shouldDisplayAgentMessage"
|
||||
:content-type="contentType"
|
||||
:message-content-attributes="messageContentAttributes"
|
||||
:message-id="message.id"
|
||||
:message-type="messageType"
|
||||
:message="message.content"
|
||||
/>
|
||||
<div
|
||||
v-if="hasAttachments"
|
||||
class="chat-bubble has-attachment agent"
|
||||
:class="(wrapClass, $dm('bg-white', 'dark:bg-slate-700'))"
|
||||
>
|
||||
<div
|
||||
v-for="attachment in message.attachments"
|
||||
:key="attachment.id"
|
||||
>
|
||||
<image-bubble
|
||||
v-if="attachment.file_type === 'image' && !hasImageError"
|
||||
:url="attachment.data_url"
|
||||
:thumb="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
<audio v-else-if="attachment.file_type === 'audio'" controls>
|
||||
<source :src="attachment.data_url" />
|
||||
</audio>
|
||||
<file-bubble v-else :url="attachment.data_url" />
|
||||
</div>
|
||||
</div>
|
||||
</drag-wrapper>
|
||||
<div class="flex flex-col justify-end">
|
||||
<message-reply-button
|
||||
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
|
||||
@click="toggleReply"
|
||||
/>
|
||||
<audio v-else-if="attachment.file_type === 'audio'" controls>
|
||||
<source :src="attachment.data_url" />
|
||||
</audio>
|
||||
<file-bubble v-else :url="attachment.data_url" />
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
@@ -63,6 +85,7 @@
|
||||
<script>
|
||||
import UserMessage from 'widget/components/UserMessage.vue';
|
||||
import AgentMessageBubble from 'widget/components/AgentMessageBubble.vue';
|
||||
import MessageReplyButton from 'widget/components/MessageReplyButton.vue';
|
||||
import timeMixin from 'dashboard/mixins/time';
|
||||
import ImageBubble from 'widget/components/ImageBubble.vue';
|
||||
import FileBubble from 'widget/components/FileBubble.vue';
|
||||
@@ -72,6 +95,9 @@ import configMixin from '../mixins/configMixin';
|
||||
import messageMixin from '../mixins/messageMixin';
|
||||
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
|
||||
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
|
||||
import ReplyToChip from 'widget/components/ReplyToChip.vue';
|
||||
import DragWrapper from 'widget/components/DragWrapper.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
export default {
|
||||
name: 'AgentMessage',
|
||||
@@ -81,6 +107,9 @@ export default {
|
||||
Thumbnail,
|
||||
UserMessage,
|
||||
FileBubble,
|
||||
MessageReplyButton,
|
||||
ReplyToChip,
|
||||
DragWrapper,
|
||||
},
|
||||
mixins: [timeMixin, configMixin, messageMixin, darkModeMixin],
|
||||
props: {
|
||||
@@ -88,6 +117,10 @@ export default {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
replyTo: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -180,6 +213,9 @@ export default {
|
||||
'has-text': this.shouldDisplayAgentMessage,
|
||||
};
|
||||
},
|
||||
hasReplyTo() {
|
||||
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
message() {
|
||||
@@ -193,6 +229,9 @@ export default {
|
||||
onImageLoadError() {
|
||||
this.hasImageError = true;
|
||||
},
|
||||
toggleReply() {
|
||||
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<template>
|
||||
<footer
|
||||
v-if="!hideReplyBox"
|
||||
class="shadow-sm bg-white mb-1 z-50 relative"
|
||||
:class="{ 'rounded-lg': !isWidgetStyleFlat }"
|
||||
class="relative z-50 mb-1"
|
||||
:class="{
|
||||
'rounded-lg': !isWidgetStyleFlat,
|
||||
'pt-2.5 shadow-[0px_-20px_20px_1px_rgba(0,_0,_0,_0.05)] dark:shadow-[0px_-20px_20px_1px_rgba(0,_0,_0,_0.15)] rounded-t-none':
|
||||
hasReplyTo,
|
||||
}"
|
||||
>
|
||||
<footer-reply-to
|
||||
v-if="hasReplyTo"
|
||||
:in-reply-to="inReplyTo"
|
||||
@dismiss="inReplyTo = null"
|
||||
/>
|
||||
<chat-input-wrap
|
||||
class="shadow-sm"
|
||||
:on-send-message="handleSendMessage"
|
||||
:on-send-attachment="handleSendAttachment"
|
||||
/>
|
||||
@@ -34,16 +44,19 @@
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import CustomButton from 'shared/components/Button.vue';
|
||||
import FooterReplyTo from 'widget/components/FooterReplyTo.vue';
|
||||
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { sendEmailTranscript } from 'widget/api/conversation';
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
import { IFrameHelper } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatInputWrap,
|
||||
CustomButton,
|
||||
FooterReplyTo,
|
||||
},
|
||||
mixins: [routerMixin],
|
||||
props: {
|
||||
@@ -52,6 +65,11 @@ export default {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inReplyTo: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
conversationAttributes: 'conversationAttributes/getConversationParams',
|
||||
@@ -71,6 +89,14 @@ export default {
|
||||
showEmailTranscriptButton() {
|
||||
return this.currentUser && this.currentUser.email;
|
||||
},
|
||||
hasReplyTo() {
|
||||
return (
|
||||
this.inReplyTo && (this.inReplyTo.content || this.inReplyTo.attachments)
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
bus.$on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', [
|
||||
@@ -85,14 +111,21 @@ export default {
|
||||
async handleSendMessage(content) {
|
||||
await this.sendMessage({
|
||||
content,
|
||||
replyTo: this.inReplyTo ? this.inReplyTo.id : null,
|
||||
});
|
||||
// reset replyTo message after sending
|
||||
this.inReplyTo = null;
|
||||
// Update conversation attributes on new conversation
|
||||
if (this.conversationSize === 0) {
|
||||
this.getAttributes();
|
||||
}
|
||||
},
|
||||
handleSendAttachment(attachment) {
|
||||
this.sendAttachment({ attachment });
|
||||
async handleSendAttachment(attachment) {
|
||||
await this.sendAttachment({
|
||||
attachment,
|
||||
replyTo: this.inReplyTo ? this.inReplyTo.id : null,
|
||||
});
|
||||
this.inReplyTo = null;
|
||||
},
|
||||
startNewConversation() {
|
||||
this.clearConversations();
|
||||
@@ -104,6 +137,9 @@ export default {
|
||||
data: { hasConversation: true },
|
||||
});
|
||||
},
|
||||
toggleReplyTo(message) {
|
||||
this.inReplyTo = message;
|
||||
},
|
||||
async sendTranscript() {
|
||||
const { email } = this.currentUser;
|
||||
if (email) {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
/>
|
||||
<button
|
||||
v-if="hasEmojiPickerEnabled"
|
||||
class="icon-button flex justify-center items-center"
|
||||
class="flex items-center justify-center icon-button"
|
||||
aria-label="Emoji picker"
|
||||
@click="toggleEmojiPicker"
|
||||
>
|
||||
@@ -192,7 +192,7 @@ export default {
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
right: 0;
|
||||
right: 20px;
|
||||
top: -302px;
|
||||
max-width: 100%;
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
<template>
|
||||
<UserMessage v-if="isUserMessage" :message="message" />
|
||||
<AgentMessage v-else :message="message" />
|
||||
<UserMessage
|
||||
v-if="isUserMessage"
|
||||
:id="`cwmsg-${message.id}`"
|
||||
:message="message"
|
||||
:reply-to="replyTo"
|
||||
/>
|
||||
<AgentMessage
|
||||
v-else
|
||||
:id="`cwmsg-${message.id}`"
|
||||
:message="message"
|
||||
:reply-to="replyTo"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AgentMessage from 'widget/components/AgentMessage.vue';
|
||||
import UserMessage from 'widget/components/UserMessage.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
|
||||
export default {
|
||||
@@ -20,9 +31,16 @@ export default {
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
allMessages: 'conversation/getConversation',
|
||||
}),
|
||||
isUserMessage() {
|
||||
return this.message.message_type === MESSAGE_TYPE.INCOMING;
|
||||
},
|
||||
replyTo() {
|
||||
const replyTo = this.message?.content_attributes?.in_reply_to;
|
||||
return replyTo ? this.allMessages[replyTo] : null;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
55
app/javascript/widget/components/DragWrapper.vue
Normal file
55
app/javascript/widget/components/DragWrapper.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div
|
||||
:style="{ transform: `translateX(${dragDistance}px)` }"
|
||||
class="will-change-transform"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="resetPosition"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DragWrapper',
|
||||
props: {
|
||||
direction: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['left', 'right'].includes(value),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
startX: null,
|
||||
dragDistance: 0,
|
||||
threshold: 50, // Threshold value in pixels
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleTouchStart(event) {
|
||||
this.startX = event.touches[0].clientX;
|
||||
},
|
||||
handleTouchMove(event) {
|
||||
const touchX = event.touches[0].clientX;
|
||||
let deltaX = touchX - this.startX;
|
||||
|
||||
if (this.direction === 'right') {
|
||||
this.dragDistance = Math.min(this.threshold, deltaX);
|
||||
} else if (this.direction === 'left') {
|
||||
this.dragDistance = Math.max(-this.threshold, deltaX);
|
||||
}
|
||||
},
|
||||
resetPosition() {
|
||||
if (
|
||||
(this.dragDistance >= this.threshold && this.direction === 'right') ||
|
||||
(this.dragDistance <= -this.threshold && this.direction === 'left')
|
||||
) {
|
||||
this.$emit('dragged', this.direction);
|
||||
}
|
||||
this.dragDistance = 0; // Reset the position after releasing the touch
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
41
app/javascript/widget/components/FooterReplyTo.vue
Normal file
41
app/javascript/widget/components/FooterReplyTo.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div
|
||||
class="mb-2.5 rounded-[7px] dark:bg-slate-900 dark:text-slate-100 bg-slate-100 px-2 py-1.5 text-sm text-slate-700 flex items-center gap-2"
|
||||
>
|
||||
<div class="items-center flex-grow truncate">
|
||||
<strong>Replying to:</strong>
|
||||
{{ inReplyTo.content || replyToAttachment }}
|
||||
</div>
|
||||
<button
|
||||
class="items-end flex-shrink-0 p-1 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800"
|
||||
@click="$emit('dismiss')"
|
||||
>
|
||||
<fluent-icon icon="dismiss" size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
export default {
|
||||
name: 'FooterReplyTo',
|
||||
components: { FluentIcon },
|
||||
props: {
|
||||
inReplyTo: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
replyToAttachment() {
|
||||
if (!this.inReplyTo?.attachments.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const [{ file_type: fileType } = {}] = this.inReplyTo.attachments;
|
||||
return this.$t(`ATTACHMENTS.${fileType}.CONTENT`);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
17
app/javascript/widget/components/MessageReplyButton.vue
Normal file
17
app/javascript/widget/components/MessageReplyButton.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<button
|
||||
class="p-1 mb-1 rounded-full dark:text-slate-500 dark:bg-slate-900 text-slate-600 bg-slate-100 hover:text-slate-800"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<FluentIcon icon="arrow-reply" size="11" class="flex-shrink-0" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
export default {
|
||||
name: 'MessageReplyButton',
|
||||
components: { FluentIcon },
|
||||
};
|
||||
</script>
|
||||
51
app/javascript/widget/components/ReplyToChip.vue
Normal file
51
app/javascript/widget/components/ReplyToChip.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<button
|
||||
class="px-1.5 py-0.5 rounded-md text-slate-500 bg-slate-50 dark:bg-slate-900 opacity-60 hover:opacity-100 cursor-pointer flex items-center gap-1.5"
|
||||
@click="navigateTo(replyTo.id)"
|
||||
>
|
||||
<fluent-icon icon="arrow-reply" size="12" class="flex-shrink-0" />
|
||||
<div class="truncate max-w-[8rem]">
|
||||
{{ replyTo.content || replyToAttachment }}
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
export default {
|
||||
name: 'AgentMessage',
|
||||
components: {
|
||||
FluentIcon,
|
||||
},
|
||||
props: {
|
||||
replyTo: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
replyToAttachment() {
|
||||
if (!this.replyTo?.attachments.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const [{ file_type: fileType } = {}] = this.replyTo.attachments;
|
||||
return this.$t(`ATTACHMENTS.${fileType}.CONTENT`);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
navigateTo(id) {
|
||||
const elementId = `cwmsg-${id}`;
|
||||
this.$nextTick(() => {
|
||||
const el = document.getElementById(elementId);
|
||||
el.scrollIntoView();
|
||||
el.classList.add('bg-slate-100', 'dark:bg-slate-900');
|
||||
setTimeout(() => {
|
||||
el.classList.remove('bg-slate-100', 'dark:bg-slate-900');
|
||||
}, 500);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,46 +1,63 @@
|
||||
<template>
|
||||
<div class="user-message-wrap">
|
||||
<div class="user-message">
|
||||
<div class="user-message-wrap group">
|
||||
<div class="flex gap-1 user-message">
|
||||
<div
|
||||
class="message-wrap"
|
||||
:class="{ 'in-progress': isInProgress, 'is-failed': isFailed }"
|
||||
>
|
||||
<user-message-bubble
|
||||
v-if="showTextBubble"
|
||||
:message="message.content"
|
||||
:status="message.status"
|
||||
:widget-color="widgetColor"
|
||||
/>
|
||||
<div
|
||||
v-if="hasAttachments"
|
||||
class="chat-bubble has-attachment user"
|
||||
:style="{ backgroundColor: widgetColor }"
|
||||
>
|
||||
<div v-for="attachment in message.attachments" :key="attachment.id">
|
||||
<image-bubble
|
||||
v-if="attachment.file_type === 'image' && !hasImageError"
|
||||
:url="attachment.data_url"
|
||||
:thumb="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
<file-bubble
|
||||
v-else
|
||||
:url="attachment.data_url"
|
||||
:is-in-progress="isInProgress"
|
||||
:widget-color="widgetColor"
|
||||
is-user-bubble
|
||||
<div v-if="hasReplyTo" class="flex justify-end mt-2 mb-1 text-xs">
|
||||
<reply-to-chip :reply-to="replyTo" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-1">
|
||||
<div class="flex flex-col justify-end">
|
||||
<message-reply-button
|
||||
v-if="!isInProgress && !isFailed"
|
||||
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
|
||||
@click="toggleReply"
|
||||
/>
|
||||
</div>
|
||||
<drag-wrapper direction="left" @dragged="toggleReply">
|
||||
<user-message-bubble
|
||||
v-if="showTextBubble"
|
||||
:message="message.content"
|
||||
:status="message.status"
|
||||
:widget-color="widgetColor"
|
||||
/>
|
||||
<div
|
||||
v-if="hasAttachments"
|
||||
class="chat-bubble has-attachment user"
|
||||
:style="{ backgroundColor: widgetColor }"
|
||||
>
|
||||
<div
|
||||
v-for="attachment in message.attachments"
|
||||
:key="attachment.id"
|
||||
>
|
||||
<image-bubble
|
||||
v-if="attachment.file_type === 'image' && !hasImageError"
|
||||
:url="attachment.data_url"
|
||||
:thumb="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
<file-bubble
|
||||
v-else
|
||||
:url="attachment.data_url"
|
||||
:is-in-progress="isInProgress"
|
||||
:widget-color="widgetColor"
|
||||
is-user-bubble
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</drag-wrapper>
|
||||
</div>
|
||||
<div
|
||||
v-if="isFailed"
|
||||
class="flex justify-end align-middle px-4 py-2 text-red-700"
|
||||
class="flex justify-end px-4 py-2 text-red-700 align-middle"
|
||||
>
|
||||
<button
|
||||
v-if="!hasAttachments"
|
||||
:title="$t('COMPONENTS.MESSAGE_BUBBLE.RETRY')"
|
||||
class="inline-flex justify-center items-center ml-2"
|
||||
class="inline-flex items-center justify-center ml-2"
|
||||
@click="retrySendMessage"
|
||||
>
|
||||
<fluent-icon icon="arrow-clockwise" size="14" />
|
||||
@@ -53,20 +70,28 @@
|
||||
|
||||
<script>
|
||||
import UserMessageBubble from 'widget/components/UserMessageBubble.vue';
|
||||
import MessageReplyButton from 'widget/components/MessageReplyButton.vue';
|
||||
import ImageBubble from 'widget/components/ImageBubble.vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import FileBubble from 'widget/components/FileBubble.vue';
|
||||
import timeMixin from 'dashboard/mixins/time';
|
||||
import messageMixin from '../mixins/messageMixin';
|
||||
import ReplyToChip from 'widget/components/ReplyToChip.vue';
|
||||
import DragWrapper from 'widget/components/DragWrapper.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'UserMessage',
|
||||
components: {
|
||||
UserMessageBubble,
|
||||
MessageReplyButton,
|
||||
ImageBubble,
|
||||
FileBubble,
|
||||
FluentIcon,
|
||||
ReplyToChip,
|
||||
DragWrapper,
|
||||
},
|
||||
mixins: [timeMixin, messageMixin],
|
||||
props: {
|
||||
@@ -74,6 +99,10 @@ export default {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
replyTo: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -107,6 +136,9 @@ export default {
|
||||
? meta.error
|
||||
: this.$t('COMPONENTS.MESSAGE_BUBBLE.ERROR_MESSAGE');
|
||||
},
|
||||
hasReplyTo() {
|
||||
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
message() {
|
||||
@@ -126,6 +158,9 @@ export default {
|
||||
onImageLoadError() {
|
||||
this.hasImageError = true;
|
||||
},
|
||||
toggleReply() {
|
||||
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user