mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 12:08:01 +00:00
feat: Implement message bubble reply to (#8068)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="conversation flex flex-shrink-0 flex-grow-0 w-auto max-w-full cursor-pointer relative py-0 px-4 border-transparent border-l-2 border-t-0 border-b-0 border-r-0 border-solid items-start hover:bg-slate-25 dark:hover:bg-slate-800 group"
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-slate-25 dark:hover:bg-slate-800 group"
|
||||
:class="{
|
||||
'active bg-slate-25 dark:bg-slate-800 border-woot-500': isActiveChat,
|
||||
'unread-chat': hasUnread,
|
||||
@@ -31,7 +31,7 @@
|
||||
size="40px"
|
||||
/>
|
||||
<div
|
||||
class="py-3 px-0 border-b group-last:border-transparent group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
|
||||
class="px-0 py-3 border-b group-last:border-transparent group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<inbox-name v-if="showInboxName" :inbox="inbox" />
|
||||
@@ -55,44 +55,11 @@
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<p
|
||||
<message-preview
|
||||
v-if="lastMessageInChat"
|
||||
class="conversation--message text-slate-700 dark:text-slate-200 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<fluent-icon
|
||||
v-if="isMessagePrivate"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
|
||||
icon="lock-closed"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-else-if="messageByAgent"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
|
||||
icon="arrow-reply"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
|
||||
icon="info"
|
||||
/>
|
||||
<span v-if="lastMessageInChat.content">
|
||||
{{ parsedLastMessage }}
|
||||
</span>
|
||||
<span v-else-if="lastMessageInChat.attachments">
|
||||
<fluent-icon
|
||||
v-if="attachmentIcon"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
|
||||
:icon="attachmentIcon"
|
||||
/>
|
||||
{{ $t(`${attachmentMessageContent}`) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</span>
|
||||
</p>
|
||||
:message="lastMessageInChat"
|
||||
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm text-slate-700 dark:text-slate-200"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="conversation--message text-slate-700 dark:text-slate-200 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
@@ -106,8 +73,8 @@
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
</p>
|
||||
<div class="conversation--meta flex flex-col absolute right-4 top-4">
|
||||
<span class="text-black-600 text-xxs font-normal leading-4 ml-auto">
|
||||
<div class="absolute flex flex-col conversation--meta right-4 top-4">
|
||||
<span class="ml-auto font-normal leading-4 text-black-600 text-xxs">
|
||||
<time-ago
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
@@ -145,9 +112,8 @@
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import Thumbnail from '../Thumbnail.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import conversationMixin from '../../../mixins/conversations';
|
||||
import timeMixin from '../../../mixins/time';
|
||||
import router from '../../../routes';
|
||||
@@ -159,14 +125,6 @@ import alertMixin from 'shared/mixins/alertMixin';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import PriorityMark from './PriorityMark.vue';
|
||||
const ATTACHMENT_ICONS = {
|
||||
image: 'image',
|
||||
audio: 'headphones-sound-wave',
|
||||
video: 'video',
|
||||
file: 'document',
|
||||
location: 'location',
|
||||
fallback: 'link',
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -175,16 +133,11 @@ export default {
|
||||
Thumbnail,
|
||||
ConversationContextMenu,
|
||||
TimeAgo,
|
||||
MessagePreview,
|
||||
PriorityMark,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
inboxMixin,
|
||||
timeMixin,
|
||||
conversationMixin,
|
||||
messageFormatterMixin,
|
||||
alertMixin,
|
||||
],
|
||||
mixins: [inboxMixin, timeMixin, conversationMixin, alertMixin],
|
||||
props: {
|
||||
activeLabel: {
|
||||
type: String,
|
||||
@@ -258,20 +211,6 @@ export default {
|
||||
);
|
||||
},
|
||||
|
||||
lastMessageFileType() {
|
||||
const lastMessage = this.lastMessageInChat;
|
||||
const [{ file_type: fileType } = {}] = lastMessage.attachments;
|
||||
return fileType;
|
||||
},
|
||||
|
||||
attachmentIcon() {
|
||||
return ATTACHMENT_ICONS[this.lastMessageFileType];
|
||||
},
|
||||
|
||||
attachmentMessageContent() {
|
||||
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
|
||||
},
|
||||
|
||||
isActiveChat() {
|
||||
return this.currentChat.id === this.chat.id;
|
||||
},
|
||||
@@ -292,30 +231,6 @@ export default {
|
||||
return this.lastMessage(this.chat);
|
||||
},
|
||||
|
||||
messageByAgent() {
|
||||
const lastMessage = this.lastMessageInChat;
|
||||
const { message_type: messageType } = lastMessage;
|
||||
return messageType === MESSAGE_TYPE.OUTGOING;
|
||||
},
|
||||
|
||||
isMessageAnActivity() {
|
||||
const lastMessage = this.lastMessageInChat;
|
||||
const { message_type: messageType } = lastMessage;
|
||||
return messageType === MESSAGE_TYPE.ACTIVITY;
|
||||
},
|
||||
|
||||
isMessagePrivate() {
|
||||
const lastMessage = this.lastMessageInChat;
|
||||
const { private: isPrivate } = lastMessage;
|
||||
return isPrivate;
|
||||
},
|
||||
|
||||
parsedLastMessage() {
|
||||
const { content_attributes: contentAttributes } = this.lastMessageInChat;
|
||||
const { email: { subject } = {} } = contentAttributes || {};
|
||||
return this.getPlainText(subject || this.lastMessageInChat.content);
|
||||
},
|
||||
|
||||
inbox() {
|
||||
const { inbox_id: inboxId } = this.chat;
|
||||
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
:url="storyUrl"
|
||||
/>
|
||||
</blockquote>
|
||||
<bubble-reply-to
|
||||
v-if="inReplyToMessageId && inboxSupportsReplyTo"
|
||||
:message="inReplyTo"
|
||||
/>
|
||||
<bubble-text
|
||||
v-if="data.content"
|
||||
:message="message"
|
||||
@@ -141,6 +145,7 @@ import BubbleLocation from './bubble/Location.vue';
|
||||
import BubbleMailHead from './bubble/MailHead.vue';
|
||||
import BubbleText from './bubble/Text.vue';
|
||||
import BubbleContact from './bubble/Contact.vue';
|
||||
import BubbleReplyTo from './bubble/ReplyTo.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
import instagramImageErrorPlaceholder from './instagramImageErrorPlaceholder.vue';
|
||||
@@ -165,6 +170,7 @@ export default {
|
||||
BubbleMailHead,
|
||||
BubbleText,
|
||||
BubbleContact,
|
||||
BubbleReplyTo,
|
||||
ContextMenu,
|
||||
Spinner,
|
||||
instagramImageErrorPlaceholder,
|
||||
@@ -175,6 +181,10 @@ export default {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
currentChat: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isATweet: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -195,6 +205,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inReplyTo: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -271,6 +285,13 @@ export default {
|
||||
) + botMessageContent
|
||||
);
|
||||
},
|
||||
inReplyToMessageId() {
|
||||
// Why not use the inReplyTo object directly?
|
||||
// Glad you asked! The inReplyTo object may or may not be available
|
||||
// depending on the current scroll position of the message list
|
||||
// since old messages are only loaded when the user scrolls up
|
||||
return this.data.content_attributes?.in_reply_to;
|
||||
},
|
||||
contextMenuEnabledOptions() {
|
||||
return {
|
||||
copy: this.hasText,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<template v-if="showMessageType">
|
||||
<fluent-icon
|
||||
v-if="isMessagePrivate"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
|
||||
icon="lock-closed"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-else-if="messageByAgent"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
|
||||
icon="arrow-reply"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
|
||||
icon="info"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="message.content">
|
||||
{{ parsedLastMessage }}
|
||||
</span>
|
||||
<span v-else-if="message.attachments">
|
||||
<fluent-icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
|
||||
:icon="attachmentIcon"
|
||||
/>
|
||||
{{ $t(`${attachmentMessageContent}`) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'MessagePreview',
|
||||
mixins: [messageFormatterMixin],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showMessageType: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultEmptyMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
messageByAgent() {
|
||||
const { message_type: messageType } = this.message;
|
||||
return messageType === MESSAGE_TYPE.OUTGOING;
|
||||
},
|
||||
isMessageAnActivity() {
|
||||
const { message_type: messageType } = this.message;
|
||||
return messageType === MESSAGE_TYPE.ACTIVITY;
|
||||
},
|
||||
isMessagePrivate() {
|
||||
const { private: isPrivate } = this.message;
|
||||
return isPrivate;
|
||||
},
|
||||
parsedLastMessage() {
|
||||
const { content_attributes: contentAttributes } = this.message;
|
||||
const { email: { subject } = {} } = contentAttributes || {};
|
||||
return this.getPlainText(subject || this.message.content);
|
||||
},
|
||||
lastMessageFileType() {
|
||||
const [{ file_type: fileType } = {}] = this.message.attachments;
|
||||
return fileType;
|
||||
},
|
||||
attachmentIcon() {
|
||||
return ATTACHMENT_ICONS[this.lastMessageFileType];
|
||||
},
|
||||
attachmentMessageContent() {
|
||||
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -34,6 +34,7 @@
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
/>
|
||||
<li v-show="unreadMessageCount != 0" class="unread--toast">
|
||||
<span>
|
||||
@@ -55,6 +56,8 @@
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
/>
|
||||
<conversation-label-suggestion
|
||||
v-if="shouldShowLabelSuggestions"
|
||||
@@ -505,6 +508,19 @@ export default {
|
||||
makeMessagesRead() {
|
||||
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
||||
},
|
||||
|
||||
getInReplyToMessage(parentMessage) {
|
||||
if (!parentMessage) return {};
|
||||
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
|
||||
if (!inReplyToMessageId) return {};
|
||||
|
||||
return this.currentChat?.messages.find(message => {
|
||||
if (message.id === inReplyToMessageId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -21,10 +21,8 @@
|
||||
<div class="reply-box__top">
|
||||
<reply-to-message
|
||||
v-if="shouldShowReplyToMessage"
|
||||
:message-id="inReplyTo.id"
|
||||
:message-content="inReplyTo.content"
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
@navigate-to-message="navigateToMessage"
|
||||
/>
|
||||
<canned-response
|
||||
v-if="showMentions && hasSlashCommand"
|
||||
@@ -524,6 +522,7 @@ export default {
|
||||
}
|
||||
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
this.fetchAndSetReplyTo();
|
||||
},
|
||||
conversationIdByRoute(conversationId, oldConversationId) {
|
||||
if (conversationId !== oldConversationId) {
|
||||
@@ -1098,11 +1097,6 @@ export default {
|
||||
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
|
||||
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
|
||||
},
|
||||
navigateToMessage(messageId) {
|
||||
this.$nextTick(() => {
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
|
||||
});
|
||||
},
|
||||
onNewConversationModalActive(isActive) {
|
||||
// Issue is if the new conversation modal is open and we drag and drop the file
|
||||
// then the file is not getting attached to the new conversation modal
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
||||
|
||||
const { messageContent } = defineProps({
|
||||
messageId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
messageContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const cleanedContent = computed(() => extractTextFromMarkdown(messageContent));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="reply-editor bg-slate-50 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2 cursor-pointer"
|
||||
@click="$emit('navigate-to-message', messageId)"
|
||||
class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
|
||||
>
|
||||
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" icon-size="14" />
|
||||
<div class="flex-grow overflow-hidden text-ellipsis">
|
||||
{{ $t('CONVERSATION.REPLYBOX.REPLYING_TO') }} {{ cleanedContent }}.
|
||||
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
|
||||
<div class="flex-grow gap-1 mt-px text-xs truncate">
|
||||
{{ $t('CONVERSATION.REPLYBOX.REPLYING_TO') }}
|
||||
<message-preview
|
||||
:message="message"
|
||||
:show-message-type="false"
|
||||
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
|
||||
class="inline"
|
||||
/>
|
||||
</div>
|
||||
<woot-button
|
||||
v-tooltip="$t('CONVERSATION.REPLYBOX.DISMISS_REPLY')"
|
||||
@@ -37,6 +24,20 @@ const cleanedContent = computed(() => extractTextFromMarkdown(messageContent));
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
|
||||
|
||||
export default {
|
||||
components: { MessagePreview },
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// TODO: Remove this
|
||||
// override for dashboard/assets/scss/widgets/_reply-box.scss
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="px-2 py-1.5 -mx-2 rounded-md bg-woot-600 text-woot-50 min-w-[15rem] mb-2"
|
||||
>
|
||||
<message-preview
|
||||
:message="message"
|
||||
:show-message-type="false"
|
||||
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
|
||||
|
||||
export default {
|
||||
name: 'ReplyTo',
|
||||
components: {
|
||||
MessagePreview,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -46,6 +46,7 @@
|
||||
"NO_RESPONSE": "No response",
|
||||
"RATING_TITLE": "Rating",
|
||||
"FEEDBACK_TITLE": "Feedback",
|
||||
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
|
||||
"CARD": {
|
||||
"SHOW_LABELS": "Show labels",
|
||||
"HIDE_LABELS": "Hide labels"
|
||||
|
||||
@@ -146,3 +146,12 @@ export const MESSAGE_VARIABLES = [
|
||||
key: 'agent.email',
|
||||
},
|
||||
];
|
||||
|
||||
export const ATTACHMENT_ICONS = {
|
||||
image: 'image',
|
||||
audio: 'headphones-sound-wave',
|
||||
video: 'video',
|
||||
file: 'document',
|
||||
location: 'location',
|
||||
fallback: 'link',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user