Chatwoot/app/javascript/dashboard/components/widgets/conversation/Message.vue

588 lines
15 KiB
Vue

<template>
<li
v-if="hasAttachments || data.content || isEmailContentType"
:class="alignBubble"
>
<div :class="wrapClass">
<div v-tooltip.top-start="messageToolTip" :class="bubbleClass">
<bubble-mail-head
:email-attributes="contentAttributes.email"
:cc="emailHeadAttributes.cc"
:bcc="emailHeadAttributes.bcc"
:is-incoming="isIncoming"
/>
<bubble-text
v-if="data.content"
:message="message"
:is-email="isEmailContentType"
:readable-time="readableTime"
:display-quoted-button="displayQuotedButton"
/>
<span
v-if="isPending && hasAttachments"
class="chat-bubble has-attachment agent"
>
{{ $t('CONVERSATION.UPLOADING_ATTACHMENTS') }}
</span>
<div v-if="!isPending && hasAttachments">
<div v-for="attachment in data.attachments" :key="attachment.id">
<bubble-image
v-if="attachment.file_type === 'image' && !hasImageError"
:url="attachment.data_url"
:readable-time="readableTime"
@error="onImageLoadError"
/>
<audio v-else-if="attachment.file_type === 'audio'" controls>
<source :src="attachment.data_url" />
</audio>
<bubble-video
v-else-if="attachment.file_type === 'video'"
:url="attachment.data_url"
:readable-time="readableTime"
/>
<bubble-file
v-else
:url="attachment.data_url"
:readable-time="readableTime"
/>
</div>
</div>
<bubble-actions
:id="data.id"
:sender="data.sender"
:story-sender="storySender"
:story-id="storyId"
:is-a-tweet="isATweet"
:has-instagram-story="hasInstagramStory"
:is-email="isEmailContentType"
:is-private="data.private"
:message-type="data.message_type"
:readable-time="readableTime"
:source-id="data.source_id"
:inbox-id="data.inbox_id"
:message-read="showReadTicks"
/>
</div>
<spinner v-if="isPending" size="tiny" />
<div
v-if="showAvatar"
v-tooltip.left="tooltipForSender"
class="sender--info"
>
<woot-thumbnail
:src="sender.thumbnail"
:username="senderNameForAvatar"
size="16px"
/>
<a
v-if="isATweet && isIncoming"
class="sender--available-name"
:href="twitterProfileLink"
target="_blank"
rel="noopener noreferrer nofollow"
>
{{ sender.name }}
</a>
</div>
<div v-if="isFailed" class="message-failed--alert">
<woot-button
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
size="tiny"
color-scheme="alert"
variant="clear"
icon="arrow-clockwise"
@click="retrySendMessage"
/>
</div>
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<context-menu
v-if="isBubble && !isMessageDeleted"
:is-open="showContextMenu"
:show-copy="hasText"
:menu-position="contextMenuPosition"
@toggle="handleContextMenuClick"
@delete="handleDelete"
@copy="handleCopy"
/>
</div>
</li>
</template>
<script>
import copy from 'copy-text-to-clipboard';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import timeMixin from '../../../mixins/time';
import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text';
import BubbleImage from './bubble/Image';
import BubbleFile from './bubble/File';
import BubbleVideo from './bubble/Video.vue';
import BubbleActions from './bubble/Actions';
import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
export default {
components: {
BubbleActions,
BubbleText,
BubbleImage,
BubbleFile,
BubbleVideo,
BubbleMailHead,
ContextMenu,
Spinner,
},
mixins: [alertMixin, timeMixin, messageFormatterMixin, contentTypeMixin],
props: {
data: {
type: Object,
required: true,
},
isATweet: {
type: Boolean,
default: false,
},
hasInstagramStory: {
type: Boolean,
default: false,
},
hasUserReadMessage: {
type: Boolean,
default: false,
},
isWebWidgetInbox: {
type: Boolean,
default: false,
},
},
data() {
return {
showContextMenu: false,
hasImageError: false,
};
},
computed: {
contentToBeParsed() {
const {
html_content: { full: fullHTMLContent } = {},
text_content: { full: fullTextContent } = {},
} = this.contentAttributes.email || {};
return fullHTMLContent || fullTextContent || '';
},
displayQuotedButton() {
if (!this.isIncoming) {
return false;
}
if (this.contentToBeParsed.includes('<blockquote')) {
return true;
}
return false;
},
message() {
const botMessageContent = generateBotMessageContent(
this.contentType,
this.contentAttributes,
{
noResponseText: this.$t('CONVERSATION.NO_RESPONSE'),
csat: {
ratingTitle: this.$t('CONVERSATION.RATING_TITLE'),
feedbackTitle: this.$t('CONVERSATION.FEEDBACK_TITLE'),
},
}
);
const {
email: { content_type: contentType = '' } = {},
} = this.contentAttributes;
if (this.contentToBeParsed && this.isIncoming) {
const parsedContent = this.stripStyleCharacters(this.contentToBeParsed);
if (parsedContent) {
// This is a temporary fix for line-breaks in text/plain emails
// Now, It is not rendered properly in the email preview.
// FIXME: Remove this once we have a better solution for rendering text/plain emails
return contentType.includes('text/plain')
? parsedContent.replace(/\n/g, '<br />')
: parsedContent;
}
}
return (
this.formatMessage(
this.data.content,
this.isATweet,
this.data.private
) + botMessageContent
);
},
contentAttributes() {
return this.data.content_attributes || {};
},
sender() {
return this.data.sender || {};
},
storySender() {
return this.contentAttributes.story_sender || null;
},
storyId() {
return this.contentAttributes.story_id || null;
},
contentType() {
const {
data: { content_type: contentType },
} = this;
return contentType;
},
twitterProfileLink() {
const additionalAttributes = this.sender.additional_attributes || {};
const { screen_name: screenName } = additionalAttributes;
return `https://twitter.com/${screenName}`;
},
alignBubble() {
const { message_type: messageType } = this.data;
const isCentered = messageType === MESSAGE_TYPE.ACTIVITY;
const isLeftAligned = messageType === MESSAGE_TYPE.INCOMING;
const isRightAligned =
messageType === MESSAGE_TYPE.OUTGOING ||
messageType === MESSAGE_TYPE.TEMPLATE;
return {
center: isCentered,
left: isLeftAligned,
right: isRightAligned,
'has-context-menu': this.showContextMenu,
'has-tweet-menu': this.isATweet,
};
},
readableTime() {
return this.messageStamp(
this.contentAttributes.external_created_at || this.data.created_at,
'LLL d, h:mm a'
);
},
isBubble() {
return [0, 1, 3].includes(this.data.message_type);
},
isIncoming() {
return this.data.message_type === MESSAGE_TYPE.INCOMING;
},
isOutgoing() {
return this.data.message_type === MESSAGE_TYPE.OUTGOING;
},
showReadTicks() {
return (
(this.isOutgoing || this.isTemplate) &&
this.hasUserReadMessage &&
this.isWebWidgetInbox &&
!this.data.private
);
},
isTemplate() {
return this.data.message_type === MESSAGE_TYPE.TEMPLATE;
},
emailHeadAttributes() {
return {
email: this.contentAttributes.email,
cc: this.contentAttributes.cc_emails,
bcc: this.contentAttributes.bcc_emails,
};
},
hasAttachments() {
return !!(this.data.attachments && this.data.attachments.length > 0);
},
isMessageDeleted() {
return this.contentAttributes.deleted;
},
hasText() {
return !!this.data.content;
},
tooltipForSender() {
const name = this.senderNameForAvatar;
const { message_type: messageType } = this.data;
const showTooltip =
messageType === MESSAGE_TYPE.OUTGOING ||
messageType === MESSAGE_TYPE.TEMPLATE;
return showTooltip
? {
content: `${this.$t('CONVERSATION.SENT_BY')} ${name}`,
}
: false;
},
messageToolTip() {
if (this.isMessageDeleted) {
return false;
}
if (this.isFailed) {
return this.$t(`CONVERSATION.SEND_FAILED`);
}
return false;
},
wrapClass() {
return {
wrap: this.isBubble,
'activity-wrap': !this.isBubble,
'is-pending': this.isPending,
'is-failed': this.isFailed,
};
},
bubbleClass() {
return {
bubble: this.isBubble,
'is-private': this.data.private,
'is-image': this.hasMediaAttachment('image'),
'is-video': this.hasMediaAttachment('video'),
'is-text': this.hasText,
'is-from-bot': this.isSentByBot,
'is-failed': this.isFailed,
};
},
isPending() {
return this.data.status === MESSAGE_STATUS.PROGRESS;
},
isFailed() {
return this.data.status === MESSAGE_STATUS.FAILED;
},
isSentByBot() {
if (this.isPending || this.isFailed) return false;
return !this.sender.type || this.sender.type === 'agent_bot';
},
contextMenuPosition() {
const { message_type: messageType } = this.data;
return messageType ? 'right' : 'left';
},
shouldShowContextMenu() {
return !(this.isFailed || this.isPending);
},
errorMessage() {
const { meta } = this.data;
return meta ? meta.error : '';
},
showAvatar() {
if (this.isOutgoing || this.isTemplate) {
return true;
}
return this.isATweet && this.isIncoming && this.sender;
},
senderNameForAvatar() {
if (this.isOutgoing || this.isTemplate) {
const { name = this.$t('CONVERSATION.BOT') } = this.sender || {};
return name;
}
return '';
},
},
watch: {
data() {
this.hasImageError = false;
},
},
mounted() {
this.hasImageError = false;
},
methods: {
hasMediaAttachment(type) {
if (this.hasAttachments && this.data.attachments.length > 0) {
const { attachments = [{}] } = this.data;
const { file_type: fileType } = attachments[0];
return fileType === type && !this.hasImageError;
}
return false;
},
handleContextMenuClick() {
this.showContextMenu = !this.showContextMenu;
},
async handleDelete() {
const { conversation_id: conversationId, id: messageId } = this.data;
try {
await this.$store.dispatch('deleteMessage', {
conversationId,
messageId,
});
this.showAlert(this.$t('CONVERSATION.SUCCESS_DELETE_MESSAGE'));
this.showContextMenu = false;
} catch (error) {
this.showAlert(this.$t('CONVERSATION.FAIL_DELETE_MESSSAGE'));
}
},
handleCopy() {
copy(this.data.content);
this.showAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
this.showContextMenu = false;
},
async retrySendMessage() {
await this.$store.dispatch('sendMessageWithData', this.data);
},
onImageLoadError() {
this.hasImageError = true;
},
},
};
</script>
<style lang="scss">
.wrap {
> .bubble {
&.is-image,
&.is-video {
padding: 0;
overflow: hidden;
.image,
.video {
max-width: 32rem;
padding: var(--space-micro);
> img,
> video {
border-radius: var(--border-radius-medium);
}
> video {
height: 100%;
object-fit: cover;
width: 100%;
}
}
.video {
height: 18rem;
}
}
&.is-image.is-text > .message-text__wrap {
max-width: 32rem;
padding: var(--space-small) var(--space-normal);
}
&.is-private .file.message-text__wrap {
.file--icon {
color: var(--w-400);
}
.text-block-title {
color: #3c4858;
}
.download.button {
color: var(--w-400);
}
}
&.is-private.is-text > .message-text__wrap .link {
color: var(--w-700);
}
&.is-private.is-text > .message-text__wrap .prosemirror-mention-node {
font-weight: var(--font-weight-black);
background: none;
border-radius: var(--border-radius-small);
padding: 0;
color: var(--color-body);
text-decoration: underline;
}
&.is-from-bot {
background: var(--v-400);
.message-text--metadata .time {
color: var(--v-50);
}
&.is-private .message-text--metadata .time {
color: var(--s-400);
}
}
&.is-failed {
background: var(--r-200);
.message-text--metadata .time {
color: var(--r-50);
}
}
}
&.is-pending {
position: relative;
opacity: 0.8;
.spinner {
position: absolute;
bottom: var(--space-smaller);
right: var(--space-smaller);
}
> .is-image.is-text.bubble > .message-text__wrap {
padding: 0;
}
}
}
.sender--info {
align-items: center;
color: var(--b-700);
display: inline-flex;
padding: var(--space-smaller) 0;
.sender--available-name {
font-size: var(--font-size-mini);
margin-left: var(--space-smaller);
}
}
.message-failed--alert {
color: var(--r-900);
flex-grow: 1;
text-align: right;
margin-top: var(--space-smaller) var(--space-smaller) 0 0;
}
.button--delete-message {
visibility: hidden;
}
li.left,
li.right {
display: flex;
align-items: flex-end;
&:hover .button--delete-message {
visibility: visible;
}
}
li.left.has-tweet-menu .context-menu {
margin-bottom: var(--space-medium);
}
li.right .context-menu-wrap {
margin-left: auto;
}
li.right {
flex-direction: row-reverse;
justify-content: flex-end;
.wrap.is-pending {
margin-left: auto;
}
.wrap.is-failed {
display: flex;
flex-direction: row-reverse;
align-items: flex-end;
margin-left: auto;
}
}
.has-context-menu {
background: var(--color-background);
.button--delete-message {
visibility: visible;
}
}
.context-menu {
position: relative;
}
</style>