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:
Shivam Mishra
2023-10-27 13:35:02 +05:30
committed by GitHub
parent ab872beb1d
commit d94108bf3f
22 changed files with 426 additions and 82 deletions

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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%;

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>