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:
parent
ab872beb1d
commit
d94108bf3f
@ -79,6 +79,9 @@ class Api::V1::Widget::BaseController < ApplicationController
|
|||||||
sender: @contact,
|
sender: @contact,
|
||||||
content: permitted_params[:message][:content],
|
content: permitted_params[:message][:content],
|
||||||
inbox_id: conversation.inbox_id,
|
inbox_id: conversation.inbox_id,
|
||||||
|
content_attributes: {
|
||||||
|
in_reply_to: permitted_params[:message][:reply_to]
|
||||||
|
},
|
||||||
echo_id: permitted_params[:message][:echo_id],
|
echo_id: permitted_params[:message][:echo_id],
|
||||||
message_type: :incoming
|
message_type: :incoming
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,7 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
# timestamp parameter is used in create conversation method
|
# timestamp parameter is used in create conversation method
|
||||||
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
|
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_message
|
def set_message
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<div
|
<div
|
||||||
class="px-2 py-1.5 rounded-sm min-w-[10rem] mb-2"
|
class="px-2 py-1.5 rounded-sm min-w-[10rem] mb-2"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-slate-100 dark:bg-slate-600 dark:text-slate-50':
|
'bg-slate-50 dark:bg-slate-600 dark:text-slate-50':
|
||||||
messageType === MESSAGE_TYPE.INCOMING,
|
messageType === MESSAGE_TYPE.INCOMING,
|
||||||
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
|
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
|
||||||
'-mx-2': !parentHasAttachments,
|
'-mx-2': !parentHasAttachments,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"arrow-clockwise-outline": "M12 4.75a7.25 7.25 0 1 0 7.201 6.406c-.068-.588.358-1.156.95-1.156.515 0 .968.358 1.03.87a9.25 9.25 0 1 1-3.432-6.116V4.25a1 1 0 1 1 2.001 0v2.698l.034.052h-.034v.25a1 1 0 0 1-1 1h-3a1 1 0 1 1 0-2h.666A7.219 7.219 0 0 0 12 4.75Z",
|
"arrow-clockwise-outline": "M12 4.75a7.25 7.25 0 1 0 7.201 6.406c-.068-.588.358-1.156.95-1.156.515 0 .968.358 1.03.87a9.25 9.25 0 1 1-3.432-6.116V4.25a1 1 0 1 1 2.001 0v2.698l.034.052h-.034v.25a1 1 0 0 1-1 1h-3a1 1 0 1 1 0-2h.666A7.219 7.219 0 0 0 12 4.75Z",
|
||||||
"arrow-right-outline": "M13.267 4.209a.75.75 0 0 0-1.034 1.086l6.251 5.955H3.75a.75.75 0 0 0 0 1.5h14.734l-6.251 5.954a.75.75 0 0 0 1.034 1.087l7.42-7.067a.996.996 0 0 0 .3-.58.758.758 0 0 0-.001-.29.995.995 0 0 0-.3-.578l-7.419-7.067Z",
|
"arrow-right-outline": "M13.267 4.209a.75.75 0 0 0-1.034 1.086l6.251 5.955H3.75a.75.75 0 0 0 0 1.5h14.734l-6.251 5.954a.75.75 0 0 0 1.034 1.087l7.42-7.067a.996.996 0 0 0 .3-.58.758.758 0 0 0-.001-.29.995.995 0 0 0-.3-.578l-7.419-7.067Z",
|
||||||
|
"arrow-reply-outline": "M9.277 16.221a.75.75 0 0 1-1.061 1.06l-4.997-5.003a.75.75 0 0 1 0-1.06L8.217 6.22a.75.75 0 0 1 1.061 1.06L5.557 11h7.842c1.595 0 2.81.242 3.889.764l.246.126a6.203 6.203 0 0 1 2.576 2.576c.61 1.14.89 2.418.89 4.135a.75.75 0 0 1-1.5 0c0-1.484-.228-2.52-.713-3.428a4.702 4.702 0 0 0-1.96-1.96c-.838-.448-1.786-.676-3.094-.709L13.4 12.5H5.562l3.715 3.721Z",
|
||||||
"attach-outline": "M11.772 3.743a6 6 0 0 1 8.66 8.302l-.19.197-8.8 8.798-.036.03a3.723 3.723 0 0 1-5.489-4.973.764.764 0 0 1 .085-.13l.054-.06.086-.088.142-.148.002.003 7.436-7.454a.75.75 0 0 1 .977-.074l.084.073a.75.75 0 0 1 .074.976l-.073.084-7.594 7.613a2.23 2.23 0 0 0 3.174 3.106l8.832-8.83A4.502 4.502 0 0 0 13 4.644l-.168.16-.013.014-9.536 9.536a.75.75 0 0 1-1.133-.977l.072-.084 9.549-9.55h.002Z",
|
"attach-outline": "M11.772 3.743a6 6 0 0 1 8.66 8.302l-.19.197-8.8 8.798-.036.03a3.723 3.723 0 0 1-5.489-4.973.764.764 0 0 1 .085-.13l.054-.06.086-.088.142-.148.002.003 7.436-7.454a.75.75 0 0 1 .977-.074l.084.073a.75.75 0 0 1 .074.976l-.073.084-7.594 7.613a2.23 2.23 0 0 0 3.174 3.106l8.832-8.83A4.502 4.502 0 0 0 13 4.644l-.168.16-.013.014-9.536 9.536a.75.75 0 0 1-1.133-.977l.072-.084 9.549-9.55h.002Z",
|
||||||
"chat-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z",
|
"chat-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z",
|
||||||
"checkmark-outline": "M4.53 12.97a.75.75 0 0 0-1.06 1.06l4.5 4.5a.75.75 0 0 0 1.06 0l11-11a.75.75 0 0 0-1.06-1.06L8.5 16.94l-3.97-3.97Z",
|
"checkmark-outline": "M4.53 12.97a.75.75 0 0 0-1.06 1.06l4.5 4.5a.75.75 0 0 0 1.06 0l11-11a.75.75 0 0 0-1.06-1.06L8.5 16.94l-3.97-3.97Z",
|
||||||
|
|||||||
@ -6,13 +6,13 @@ const createConversationAPI = async content => {
|
|||||||
return API.post(urlData.url, urlData.params);
|
return API.post(urlData.url, urlData.params);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessageAPI = async content => {
|
const sendMessageAPI = async (content, replyTo = null) => {
|
||||||
const urlData = endPoints.sendMessage(content);
|
const urlData = endPoints.sendMessage(content, replyTo);
|
||||||
return API.post(urlData.url, urlData.params);
|
return API.post(urlData.url, urlData.params);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendAttachmentAPI = async attachment => {
|
const sendAttachmentAPI = async (attachment, replyTo = null) => {
|
||||||
const urlData = endPoints.sendAttachment(attachment);
|
const urlData = endPoints.sendAttachment(attachment, replyTo);
|
||||||
return API.post(urlData.url, urlData.params);
|
return API.post(urlData.url, urlData.params);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,7 @@ const createConversation = params => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessage = content => {
|
const sendMessage = (content, replyTo) => {
|
||||||
const referrerURL = window.referrerURL || '';
|
const referrerURL = window.referrerURL || '';
|
||||||
const search = buildSearchParamsWithLocale(window.location.search);
|
const search = buildSearchParamsWithLocale(window.location.search);
|
||||||
return {
|
return {
|
||||||
@ -30,6 +30,7 @@ const sendMessage = content => {
|
|||||||
params: {
|
params: {
|
||||||
message: {
|
message: {
|
||||||
content,
|
content,
|
||||||
|
reply_to: replyTo,
|
||||||
timestamp: new Date().toString(),
|
timestamp: new Date().toString(),
|
||||||
referer_url: referrerURL,
|
referer_url: referrerURL,
|
||||||
},
|
},
|
||||||
@ -37,7 +38,7 @@ const sendMessage = content => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendAttachment = ({ attachment }) => {
|
const sendAttachment = ({ attachment, replyTo = null }) => {
|
||||||
const { referrerURL = '' } = window;
|
const { referrerURL = '' } = window;
|
||||||
const timestamp = new Date().toString();
|
const timestamp = new Date().toString();
|
||||||
const { file } = attachment;
|
const { file } = attachment;
|
||||||
@ -51,6 +52,7 @@ const sendAttachment = ({ attachment }) => {
|
|||||||
|
|
||||||
formData.append('message[referer_url]', referrerURL);
|
formData.append('message[referer_url]', referrerURL);
|
||||||
formData.append('message[timestamp]', timestamp);
|
formData.append('message[timestamp]', timestamp);
|
||||||
|
formData.append('message[reply_to]', replyTo);
|
||||||
return {
|
return {
|
||||||
url: `/api/v1/widget/messages${window.location.search}`,
|
url: `/api/v1/widget/messages${window.location.search}`,
|
||||||
params: formData,
|
params: formData,
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="agent-message-wrap"
|
class="agent-message-wrap group"
|
||||||
:class="{ 'has-response': hasRecordedResponse || isASubmittedForm }"
|
:class="{
|
||||||
|
'has-response': hasRecordedResponse || isASubmittedForm,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div v-if="!isASubmittedForm" class="agent-message">
|
<div v-if="!isASubmittedForm" class="agent-message">
|
||||||
<div class="avatar-wrap">
|
<div class="avatar-wrap">
|
||||||
@ -13,6 +15,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-wrap">
|
<div class="message-wrap">
|
||||||
|
<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
|
<AgentMessageBubble
|
||||||
v-if="shouldDisplayAgentMessage"
|
v-if="shouldDisplayAgentMessage"
|
||||||
:content-type="contentType"
|
:content-type="contentType"
|
||||||
@ -26,7 +37,10 @@
|
|||||||
class="chat-bubble has-attachment agent"
|
class="chat-bubble has-attachment agent"
|
||||||
:class="(wrapClass, $dm('bg-white', 'dark:bg-slate-700'))"
|
:class="(wrapClass, $dm('bg-white', 'dark:bg-slate-700'))"
|
||||||
>
|
>
|
||||||
<div v-for="attachment in message.attachments" :key="attachment.id">
|
<div
|
||||||
|
v-for="attachment in message.attachments"
|
||||||
|
:key="attachment.id"
|
||||||
|
>
|
||||||
<image-bubble
|
<image-bubble
|
||||||
v-if="attachment.file_type === 'image' && !hasImageError"
|
v-if="attachment.file_type === 'image' && !hasImageError"
|
||||||
:url="attachment.data_url"
|
:url="attachment.data_url"
|
||||||
@ -40,6 +54,14 @@
|
|||||||
<file-bubble v-else :url="attachment.data_url" />
|
<file-bubble v-else :url="attachment.data_url" />
|
||||||
</div>
|
</div>
|
||||||
</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="message.showAvatar || hasRecordedResponse"
|
v-if="message.showAvatar || hasRecordedResponse"
|
||||||
v-dompurify-html="agentName"
|
v-dompurify-html="agentName"
|
||||||
@ -63,6 +85,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import UserMessage from 'widget/components/UserMessage.vue';
|
import UserMessage from 'widget/components/UserMessage.vue';
|
||||||
import AgentMessageBubble from 'widget/components/AgentMessageBubble.vue';
|
import AgentMessageBubble from 'widget/components/AgentMessageBubble.vue';
|
||||||
|
import MessageReplyButton from 'widget/components/MessageReplyButton.vue';
|
||||||
import timeMixin from 'dashboard/mixins/time';
|
import timeMixin from 'dashboard/mixins/time';
|
||||||
import ImageBubble from 'widget/components/ImageBubble.vue';
|
import ImageBubble from 'widget/components/ImageBubble.vue';
|
||||||
import FileBubble from 'widget/components/FileBubble.vue';
|
import FileBubble from 'widget/components/FileBubble.vue';
|
||||||
@ -72,6 +95,9 @@ import configMixin from '../mixins/configMixin';
|
|||||||
import messageMixin from '../mixins/messageMixin';
|
import messageMixin from '../mixins/messageMixin';
|
||||||
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
|
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
|
||||||
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
|
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 {
|
export default {
|
||||||
name: 'AgentMessage',
|
name: 'AgentMessage',
|
||||||
@ -81,6 +107,9 @@ export default {
|
|||||||
Thumbnail,
|
Thumbnail,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
FileBubble,
|
FileBubble,
|
||||||
|
MessageReplyButton,
|
||||||
|
ReplyToChip,
|
||||||
|
DragWrapper,
|
||||||
},
|
},
|
||||||
mixins: [timeMixin, configMixin, messageMixin, darkModeMixin],
|
mixins: [timeMixin, configMixin, messageMixin, darkModeMixin],
|
||||||
props: {
|
props: {
|
||||||
@ -88,6 +117,10 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
replyTo: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -180,6 +213,9 @@ export default {
|
|||||||
'has-text': this.shouldDisplayAgentMessage,
|
'has-text': this.shouldDisplayAgentMessage,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
hasReplyTo() {
|
||||||
|
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
message() {
|
message() {
|
||||||
@ -193,6 +229,9 @@ export default {
|
|||||||
onImageLoadError() {
|
onImageLoadError() {
|
||||||
this.hasImageError = true;
|
this.hasImageError = true;
|
||||||
},
|
},
|
||||||
|
toggleReply() {
|
||||||
|
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,10 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<footer
|
<footer
|
||||||
v-if="!hideReplyBox"
|
v-if="!hideReplyBox"
|
||||||
class="shadow-sm bg-white mb-1 z-50 relative"
|
class="relative z-50 mb-1"
|
||||||
:class="{ 'rounded-lg': !isWidgetStyleFlat }"
|
: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
|
<chat-input-wrap
|
||||||
|
class="shadow-sm"
|
||||||
:on-send-message="handleSendMessage"
|
:on-send-message="handleSendMessage"
|
||||||
:on-send-attachment="handleSendAttachment"
|
:on-send-attachment="handleSendAttachment"
|
||||||
/>
|
/>
|
||||||
@ -34,16 +44,19 @@
|
|||||||
import { mapActions, mapGetters } from 'vuex';
|
import { mapActions, mapGetters } from 'vuex';
|
||||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||||
import CustomButton from 'shared/components/Button.vue';
|
import CustomButton from 'shared/components/Button.vue';
|
||||||
|
import FooterReplyTo from 'widget/components/FooterReplyTo.vue';
|
||||||
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
|
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { sendEmailTranscript } from 'widget/api/conversation';
|
import { sendEmailTranscript } from 'widget/api/conversation';
|
||||||
import routerMixin from 'widget/mixins/routerMixin';
|
import routerMixin from 'widget/mixins/routerMixin';
|
||||||
import { IFrameHelper } from '../helpers/utils';
|
import { IFrameHelper } from '../helpers/utils';
|
||||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ChatInputWrap,
|
ChatInputWrap,
|
||||||
CustomButton,
|
CustomButton,
|
||||||
|
FooterReplyTo,
|
||||||
},
|
},
|
||||||
mixins: [routerMixin],
|
mixins: [routerMixin],
|
||||||
props: {
|
props: {
|
||||||
@ -52,6 +65,11 @@ export default {
|
|||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
inReplyTo: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
conversationAttributes: 'conversationAttributes/getConversationParams',
|
conversationAttributes: 'conversationAttributes/getConversationParams',
|
||||||
@ -71,6 +89,14 @@ export default {
|
|||||||
showEmailTranscriptButton() {
|
showEmailTranscriptButton() {
|
||||||
return this.currentUser && this.currentUser.email;
|
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: {
|
methods: {
|
||||||
...mapActions('conversation', [
|
...mapActions('conversation', [
|
||||||
@ -85,14 +111,21 @@ export default {
|
|||||||
async handleSendMessage(content) {
|
async handleSendMessage(content) {
|
||||||
await this.sendMessage({
|
await this.sendMessage({
|
||||||
content,
|
content,
|
||||||
|
replyTo: this.inReplyTo ? this.inReplyTo.id : null,
|
||||||
});
|
});
|
||||||
|
// reset replyTo message after sending
|
||||||
|
this.inReplyTo = null;
|
||||||
// Update conversation attributes on new conversation
|
// Update conversation attributes on new conversation
|
||||||
if (this.conversationSize === 0) {
|
if (this.conversationSize === 0) {
|
||||||
this.getAttributes();
|
this.getAttributes();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleSendAttachment(attachment) {
|
async handleSendAttachment(attachment) {
|
||||||
this.sendAttachment({ attachment });
|
await this.sendAttachment({
|
||||||
|
attachment,
|
||||||
|
replyTo: this.inReplyTo ? this.inReplyTo.id : null,
|
||||||
|
});
|
||||||
|
this.inReplyTo = null;
|
||||||
},
|
},
|
||||||
startNewConversation() {
|
startNewConversation() {
|
||||||
this.clearConversations();
|
this.clearConversations();
|
||||||
@ -104,6 +137,9 @@ export default {
|
|||||||
data: { hasConversation: true },
|
data: { hasConversation: true },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
toggleReplyTo(message) {
|
||||||
|
this.inReplyTo = message;
|
||||||
|
},
|
||||||
async sendTranscript() {
|
async sendTranscript() {
|
||||||
const { email } = this.currentUser;
|
const { email } = this.currentUser;
|
||||||
if (email) {
|
if (email) {
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="hasEmojiPickerEnabled"
|
v-if="hasEmojiPickerEnabled"
|
||||||
class="icon-button flex justify-center items-center"
|
class="flex items-center justify-center icon-button"
|
||||||
aria-label="Emoji picker"
|
aria-label="Emoji picker"
|
||||||
@click="toggleEmojiPicker"
|
@click="toggleEmojiPicker"
|
||||||
>
|
>
|
||||||
@ -192,7 +192,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.emoji-dialog {
|
.emoji-dialog {
|
||||||
right: 0;
|
right: 20px;
|
||||||
top: -302px;
|
top: -302px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<UserMessage v-if="isUserMessage" :message="message" />
|
<UserMessage
|
||||||
<AgentMessage v-else :message="message" />
|
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>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import AgentMessage from 'widget/components/AgentMessage.vue';
|
import AgentMessage from 'widget/components/AgentMessage.vue';
|
||||||
import UserMessage from 'widget/components/UserMessage.vue';
|
import UserMessage from 'widget/components/UserMessage.vue';
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -20,9 +31,16 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
allMessages: 'conversation/getConversation',
|
||||||
|
}),
|
||||||
isUserMessage() {
|
isUserMessage() {
|
||||||
return this.message.message_type === MESSAGE_TYPE.INCOMING;
|
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>
|
</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,10 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-message-wrap">
|
<div class="user-message-wrap group">
|
||||||
<div class="user-message">
|
<div class="flex gap-1 user-message">
|
||||||
<div
|
<div
|
||||||
class="message-wrap"
|
class="message-wrap"
|
||||||
:class="{ 'in-progress': isInProgress, 'is-failed': isFailed }"
|
:class="{ 'in-progress': isInProgress, 'is-failed': isFailed }"
|
||||||
>
|
>
|
||||||
|
<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
|
<user-message-bubble
|
||||||
v-if="showTextBubble"
|
v-if="showTextBubble"
|
||||||
:message="message.content"
|
:message="message.content"
|
||||||
@ -16,7 +28,10 @@
|
|||||||
class="chat-bubble has-attachment user"
|
class="chat-bubble has-attachment user"
|
||||||
:style="{ backgroundColor: widgetColor }"
|
:style="{ backgroundColor: widgetColor }"
|
||||||
>
|
>
|
||||||
<div v-for="attachment in message.attachments" :key="attachment.id">
|
<div
|
||||||
|
v-for="attachment in message.attachments"
|
||||||
|
:key="attachment.id"
|
||||||
|
>
|
||||||
<image-bubble
|
<image-bubble
|
||||||
v-if="attachment.file_type === 'image' && !hasImageError"
|
v-if="attachment.file_type === 'image' && !hasImageError"
|
||||||
:url="attachment.data_url"
|
:url="attachment.data_url"
|
||||||
@ -33,14 +48,16 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</drag-wrapper>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="isFailed"
|
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
|
<button
|
||||||
v-if="!hasAttachments"
|
v-if="!hasAttachments"
|
||||||
:title="$t('COMPONENTS.MESSAGE_BUBBLE.RETRY')"
|
: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"
|
@click="retrySendMessage"
|
||||||
>
|
>
|
||||||
<fluent-icon icon="arrow-clockwise" size="14" />
|
<fluent-icon icon="arrow-clockwise" size="14" />
|
||||||
@ -53,20 +70,28 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import UserMessageBubble from 'widget/components/UserMessageBubble.vue';
|
import UserMessageBubble from 'widget/components/UserMessageBubble.vue';
|
||||||
|
import MessageReplyButton from 'widget/components/MessageReplyButton.vue';
|
||||||
import ImageBubble from 'widget/components/ImageBubble.vue';
|
import ImageBubble from 'widget/components/ImageBubble.vue';
|
||||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||||
import FileBubble from 'widget/components/FileBubble.vue';
|
import FileBubble from 'widget/components/FileBubble.vue';
|
||||||
import timeMixin from 'dashboard/mixins/time';
|
import timeMixin from 'dashboard/mixins/time';
|
||||||
import messageMixin from '../mixins/messageMixin';
|
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';
|
import { mapGetters } from 'vuex';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'UserMessage',
|
name: 'UserMessage',
|
||||||
components: {
|
components: {
|
||||||
UserMessageBubble,
|
UserMessageBubble,
|
||||||
|
MessageReplyButton,
|
||||||
ImageBubble,
|
ImageBubble,
|
||||||
FileBubble,
|
FileBubble,
|
||||||
FluentIcon,
|
FluentIcon,
|
||||||
|
ReplyToChip,
|
||||||
|
DragWrapper,
|
||||||
},
|
},
|
||||||
mixins: [timeMixin, messageMixin],
|
mixins: [timeMixin, messageMixin],
|
||||||
props: {
|
props: {
|
||||||
@ -74,6 +99,10 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
replyTo: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -107,6 +136,9 @@ export default {
|
|||||||
? meta.error
|
? meta.error
|
||||||
: this.$t('COMPONENTS.MESSAGE_BUBBLE.ERROR_MESSAGE');
|
: this.$t('COMPONENTS.MESSAGE_BUBBLE.ERROR_MESSAGE');
|
||||||
},
|
},
|
||||||
|
hasReplyTo() {
|
||||||
|
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
message() {
|
message() {
|
||||||
@ -126,6 +158,9 @@ export default {
|
|||||||
onImageLoadError() {
|
onImageLoadError() {
|
||||||
this.hasImageError = true;
|
this.hasImageError = true;
|
||||||
},
|
},
|
||||||
|
toggleReply() {
|
||||||
|
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -28,18 +28,17 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
sendMessage: async ({ dispatch }, params) => {
|
sendMessage: async ({ dispatch }, params) => {
|
||||||
const { content } = params;
|
const { content, replyTo } = params;
|
||||||
const message = createTemporaryMessage({ content });
|
const message = createTemporaryMessage({ content, replyTo });
|
||||||
|
|
||||||
dispatch('sendMessageWithData', message);
|
dispatch('sendMessageWithData', message);
|
||||||
},
|
},
|
||||||
sendMessageWithData: async ({ commit }, message) => {
|
sendMessageWithData: async ({ commit }, message) => {
|
||||||
const { id, content, meta = {} } = message;
|
const { id, content, replyTo, meta = {} } = message;
|
||||||
|
|
||||||
commit('pushMessageToConversation', message);
|
commit('pushMessageToConversation', message);
|
||||||
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
|
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
|
||||||
try {
|
try {
|
||||||
const { data } = await sendMessageAPI(content);
|
const { data } = await sendMessageAPI(content, replyTo);
|
||||||
|
|
||||||
commit('deleteMessage', message.id);
|
commit('deleteMessage', message.id);
|
||||||
commit('pushMessageToConversation', { ...data, status: 'sent' });
|
commit('pushMessageToConversation', { ...data, status: 'sent' });
|
||||||
@ -69,6 +68,7 @@ export const actions = {
|
|||||||
};
|
};
|
||||||
const tempMessage = createTemporaryMessage({
|
const tempMessage = createTemporaryMessage({
|
||||||
attachments: [attachment],
|
attachments: [attachment],
|
||||||
|
replyTo: params.replyTo,
|
||||||
});
|
});
|
||||||
commit('pushMessageToConversation', tempMessage);
|
commit('pushMessageToConversation', tempMessage);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -2,13 +2,14 @@ import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
|||||||
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
|
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
|
||||||
|
|
||||||
import getUuid from '../../../helpers/uuid';
|
import getUuid from '../../../helpers/uuid';
|
||||||
export const createTemporaryMessage = ({ attachments, content }) => {
|
export const createTemporaryMessage = ({ attachments, content, replyTo }) => {
|
||||||
const timestamp = new Date().getTime() / 1000;
|
const timestamp = new Date().getTime() / 1000;
|
||||||
return {
|
return {
|
||||||
id: getUuid(),
|
id: getUuid(),
|
||||||
content,
|
content,
|
||||||
attachments,
|
attachments,
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
|
replyTo,
|
||||||
created_at: timestamp,
|
created_at: timestamp,
|
||||||
message_type: MESSAGE_TYPE.INCOMING,
|
message_type: MESSAGE_TYPE.INCOMING,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -110,7 +110,10 @@ describe('#actions', () => {
|
|||||||
search: '?param=1',
|
search: '?param=1',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
await actions.sendMessage({ commit, dispatch }, { content: 'hello' });
|
await actions.sendMessage(
|
||||||
|
{ commit, dispatch },
|
||||||
|
{ content: 'hello', replyTo: 124 }
|
||||||
|
);
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
windowSpy.mockRestore();
|
windowSpy.mockRestore();
|
||||||
expect(dispatch).toBeCalledWith('sendMessageWithData', {
|
expect(dispatch).toBeCalledWith('sendMessageWithData', {
|
||||||
@ -119,6 +122,7 @@ describe('#actions', () => {
|
|||||||
created_at: 1466424490,
|
created_at: 1466424490,
|
||||||
id: '1111',
|
id: '1111',
|
||||||
message_type: 0,
|
message_type: 0,
|
||||||
|
replyTo: 124,
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -132,7 +136,10 @@ describe('#actions', () => {
|
|||||||
const thumbUrl = '';
|
const thumbUrl = '';
|
||||||
const attachment = { thumbUrl, fileType: 'file' };
|
const attachment = { thumbUrl, fileType: 'file' };
|
||||||
|
|
||||||
actions.sendAttachment({ commit, dispatch }, { attachment });
|
actions.sendAttachment(
|
||||||
|
{ commit, dispatch },
|
||||||
|
{ attachment, replyTo: 135 }
|
||||||
|
);
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
expect(commit).toBeCalledWith('pushMessageToConversation', {
|
expect(commit).toBeCalledWith('pushMessageToConversation', {
|
||||||
id: '1111',
|
id: '1111',
|
||||||
@ -140,6 +147,7 @@ describe('#actions', () => {
|
|||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
created_at: 1466424490,
|
created_at: 1466424490,
|
||||||
message_type: 0,
|
message_type: 0,
|
||||||
|
replyTo: 135,
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
thumb_url: '',
|
thumb_url: '',
|
||||||
|
|||||||
@ -35,6 +35,15 @@ describe('#createTemporaryMessage', () => {
|
|||||||
expect(message.content).toBe('hello');
|
expect(message.content).toBe('hello');
|
||||||
expect(message.status).toBe('in_progress');
|
expect(message.status).toBe('in_progress');
|
||||||
});
|
});
|
||||||
|
it('returns message object with reply to', () => {
|
||||||
|
const message = createTemporaryMessage({
|
||||||
|
content: 'hello',
|
||||||
|
replyTo: 124,
|
||||||
|
});
|
||||||
|
expect(message.content).toBe('hello');
|
||||||
|
expect(message.status).toBe('in_progress');
|
||||||
|
expect(message.replyTo).toBe(124);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#getNonDeletedMessages', () => {
|
describe('#getNonDeletedMessages', () => {
|
||||||
|
|||||||
@ -5,9 +5,7 @@
|
|||||||
<div class="flex flex-1 overflow-auto">
|
<div class="flex flex-1 overflow-auto">
|
||||||
<conversation-wrap :grouped-messages="groupedMessages" />
|
<conversation-wrap :grouped-messages="groupedMessages" />
|
||||||
</div>
|
</div>
|
||||||
<div class="px-5">
|
<chat-footer class="px-5" />
|
||||||
<chat-footer />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -6,5 +6,6 @@ json.message_type @message.message_type_before_type_cast
|
|||||||
json.created_at @message.created_at.to_i
|
json.created_at @message.created_at.to_i
|
||||||
json.private @message.private
|
json.private @message.private
|
||||||
json.source_id @message.source_id
|
json.source_id @message.source_id
|
||||||
|
json.content_attributes @message.content_attributes
|
||||||
json.attachments @message.attachments.map(&:push_event_data) if @message.attachments.present?
|
json.attachments @message.attachments.map(&:push_event_data) if @message.attachments.present?
|
||||||
json.sender @message.sender.push_event_data if @message.sender
|
json.sender @message.sender.push_event_data if @message.sender
|
||||||
|
|||||||
@ -71,6 +71,35 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
|
|||||||
expect(json_response['message']).to eq('Content is too long (maximum is 150000 characters)')
|
expect(json_response['message']).to eq('Content is too long (maximum is 150000 characters)')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates message in conversation with a valid reply to' do
|
||||||
|
message_params = { content: 'hello world reply', timestamp: Time.current, reply_to: conversation.messages.first.id }
|
||||||
|
post api_v1_widget_messages_url,
|
||||||
|
params: { website_token: web_widget.website_token, message: message_params },
|
||||||
|
headers: { 'X-Auth-Token' => token },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
json_response = response.parsed_body
|
||||||
|
expect(json_response['content']).to eq(message_params[:content])
|
||||||
|
expect(json_response['content_attributes']['in_reply_to']).to eq(conversation.messages.first.id)
|
||||||
|
# check nil for external id since this is a web widget conversation
|
||||||
|
expect(json_response['content_attributes']['in_reply_to_external_id']).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates message in conversation with an in-valid reply to' do
|
||||||
|
message_params = { content: 'hello world reply', timestamp: Time.current, reply_to: conversation.messages.first.id + 300 }
|
||||||
|
post api_v1_widget_messages_url,
|
||||||
|
params: { website_token: web_widget.website_token, message: message_params },
|
||||||
|
headers: { 'X-Auth-Token' => token },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
json_response = response.parsed_body
|
||||||
|
expect(json_response['content']).to eq(message_params[:content])
|
||||||
|
expect(json_response['content_attributes']['in_reply_to']).to be_nil
|
||||||
|
expect(json_response['content_attributes']['in_reply_to_external_id']).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
it 'creates attachment message in conversation' do
|
it 'creates attachment message in conversation' do
|
||||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||||
message_params = { content: 'hello world', timestamp: Time.current, attachments: [file] }
|
message_params = { content: 'hello world', timestamp: Time.current, attachments: [file] }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user