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

@@ -79,6 +79,9 @@ class Api::V1::Widget::BaseController < ApplicationController
sender: @contact,
content: permitted_params[:message][:content],
inbox_id: conversation.inbox_id,
content_attributes: {
in_reply_to: permitted_params[:message][:reply_to]
},
echo_id: permitted_params[:message][:echo_id],
message_type: :incoming
}

View File

@@ -64,7 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params
# 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
def set_message

View File

@@ -2,7 +2,7 @@
<div
class="px-2 py-1.5 rounded-sm min-w-[10rem] mb-2"
: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,
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
'-mx-2': !parentHasAttachments,

View File

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

View File

@@ -6,13 +6,13 @@ const createConversationAPI = async content => {
return API.post(urlData.url, urlData.params);
};
const sendMessageAPI = async content => {
const urlData = endPoints.sendMessage(content);
const sendMessageAPI = async (content, replyTo = null) => {
const urlData = endPoints.sendMessage(content, replyTo);
return API.post(urlData.url, urlData.params);
};
const sendAttachmentAPI = async attachment => {
const urlData = endPoints.sendAttachment(attachment);
const sendAttachmentAPI = async (attachment, replyTo = null) => {
const urlData = endPoints.sendAttachment(attachment, replyTo);
return API.post(urlData.url, urlData.params);
};

View File

@@ -22,7 +22,7 @@ const createConversation = params => {
};
};
const sendMessage = content => {
const sendMessage = (content, replyTo) => {
const referrerURL = window.referrerURL || '';
const search = buildSearchParamsWithLocale(window.location.search);
return {
@@ -30,6 +30,7 @@ const sendMessage = content => {
params: {
message: {
content,
reply_to: replyTo,
timestamp: new Date().toString(),
referer_url: referrerURL,
},
@@ -37,7 +38,7 @@ const sendMessage = content => {
};
};
const sendAttachment = ({ attachment }) => {
const sendAttachment = ({ attachment, replyTo = null }) => {
const { referrerURL = '' } = window;
const timestamp = new Date().toString();
const { file } = attachment;
@@ -51,6 +52,7 @@ const sendAttachment = ({ attachment }) => {
formData.append('message[referer_url]', referrerURL);
formData.append('message[timestamp]', timestamp);
formData.append('message[reply_to]', replyTo);
return {
url: `/api/v1/widget/messages${window.location.search}`,
params: formData,

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>

View File

@@ -28,18 +28,17 @@ export const actions = {
}
},
sendMessage: async ({ dispatch }, params) => {
const { content } = params;
const message = createTemporaryMessage({ content });
const { content, replyTo } = params;
const message = createTemporaryMessage({ content, replyTo });
dispatch('sendMessageWithData', message);
},
sendMessageWithData: async ({ commit }, message) => {
const { id, content, meta = {} } = message;
const { id, content, replyTo, meta = {} } = message;
commit('pushMessageToConversation', message);
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
try {
const { data } = await sendMessageAPI(content);
const { data } = await sendMessageAPI(content, replyTo);
commit('deleteMessage', message.id);
commit('pushMessageToConversation', { ...data, status: 'sent' });
@@ -69,6 +68,7 @@ export const actions = {
};
const tempMessage = createTemporaryMessage({
attachments: [attachment],
replyTo: params.replyTo,
});
commit('pushMessageToConversation', tempMessage);
try {

View File

@@ -2,13 +2,14 @@ import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import getUuid from '../../../helpers/uuid';
export const createTemporaryMessage = ({ attachments, content }) => {
export const createTemporaryMessage = ({ attachments, content, replyTo }) => {
const timestamp = new Date().getTime() / 1000;
return {
id: getUuid(),
content,
attachments,
status: 'in_progress',
replyTo,
created_at: timestamp,
message_type: MESSAGE_TYPE.INCOMING,
};

View File

@@ -110,7 +110,10 @@ describe('#actions', () => {
search: '?param=1',
},
}));
await actions.sendMessage({ commit, dispatch }, { content: 'hello' });
await actions.sendMessage(
{ commit, dispatch },
{ content: 'hello', replyTo: 124 }
);
spy.mockRestore();
windowSpy.mockRestore();
expect(dispatch).toBeCalledWith('sendMessageWithData', {
@@ -119,6 +122,7 @@ describe('#actions', () => {
created_at: 1466424490,
id: '1111',
message_type: 0,
replyTo: 124,
status: 'in_progress',
});
});
@@ -132,7 +136,10 @@ describe('#actions', () => {
const thumbUrl = '';
const attachment = { thumbUrl, fileType: 'file' };
actions.sendAttachment({ commit, dispatch }, { attachment });
actions.sendAttachment(
{ commit, dispatch },
{ attachment, replyTo: 135 }
);
spy.mockRestore();
expect(commit).toBeCalledWith('pushMessageToConversation', {
id: '1111',
@@ -140,6 +147,7 @@ describe('#actions', () => {
status: 'in_progress',
created_at: 1466424490,
message_type: 0,
replyTo: 135,
attachments: [
{
thumb_url: '',

View File

@@ -35,6 +35,15 @@ describe('#createTemporaryMessage', () => {
expect(message.content).toBe('hello');
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', () => {

View File

@@ -5,9 +5,7 @@
<div class="flex flex-1 overflow-auto">
<conversation-wrap :grouped-messages="groupedMessages" />
</div>
<div class="px-5">
<chat-footer />
</div>
<chat-footer class="px-5" />
</div>
</template>
<script>

View File

@@ -6,5 +6,6 @@ json.message_type @message.message_type_before_type_cast
json.created_at @message.created_at.to_i
json.private @message.private
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.sender @message.sender.push_event_data if @message.sender

View File

@@ -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)')
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
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
message_params = { content: 'hello world', timestamp: Time.current, attachments: [file] }