Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62c85ff454 | ||
|
|
8251dd8750 | ||
|
|
4ce23096b7 | ||
|
|
671186afb8 | ||
|
|
a39e5f0ee4 | ||
|
|
1801d3fd20 | ||
|
|
593914d9ad | ||
|
|
3c700261ea | ||
|
|
dcd7277ce9 | ||
|
|
eb4dbf537c | ||
|
|
daf29567d5 | ||
|
|
f2711f9a6f | ||
|
|
85caf39395 | ||
|
|
896a5ad23f | ||
|
|
4b9f2675e1 | ||
|
|
cbe0e7485f | ||
|
|
319f9d31e3 |
@@ -1,6 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, defineAsyncComponent } from 'vue';
|
import { computed, ref, defineAsyncComponent } from 'vue';
|
||||||
import { provideMessageContext } from './provider.js';
|
import { provideMessageContext } from './provider.js';
|
||||||
|
import { useTrack } from 'dashboard/composables';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
|
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import {
|
import {
|
||||||
MESSAGE_TYPES,
|
MESSAGE_TYPES,
|
||||||
ATTACHMENT_TYPES,
|
ATTACHMENT_TYPES,
|
||||||
@@ -8,6 +14,7 @@ import {
|
|||||||
SENDER_TYPES,
|
SENDER_TYPES,
|
||||||
ORIENTATION,
|
ORIENTATION,
|
||||||
MESSAGE_STATUS,
|
MESSAGE_STATUS,
|
||||||
|
CONTENT_TYPES,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
@@ -30,6 +37,7 @@ const LocationBubble = defineAsyncComponent(
|
|||||||
|
|
||||||
import MessageError from './MessageError.vue';
|
import MessageError from './MessageError.vue';
|
||||||
import MessageMeta from './MessageMeta.vue';
|
import MessageMeta from './MessageMeta.vue';
|
||||||
|
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Attachment
|
* @typedef {Object} Attachment
|
||||||
@@ -65,7 +73,7 @@ import MessageMeta from './MessageMeta.vue';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Props
|
* @typedef {Object} Props
|
||||||
* @property {('sent'|'delivered'|'read'|'failed')} status - The delivery status of the message
|
* @property {('sent'|'delivered'|'read'|'failed'|'progress')} status - The delivery status of the message
|
||||||
* @property {ContentAttributes} [contentAttributes={}] - Additional attributes of the message content
|
* @property {ContentAttributes} [contentAttributes={}] - Additional attributes of the message content
|
||||||
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
|
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
|
||||||
* @property {Sender|null} [sender=null] - The sender information
|
* @property {Sender|null} [sender=null] - The sender information
|
||||||
@@ -78,6 +86,11 @@ import MessageMeta from './MessageMeta.vue';
|
|||||||
* @property {string|null} [error=null] - Error message if the message failed to send
|
* @property {string|null} [error=null] - Error message if the message failed to send
|
||||||
* @property {string|null} [senderType=null] - The type of the sender
|
* @property {string|null} [senderType=null] - The type of the sender
|
||||||
* @property {string} content - The message content
|
* @property {string} content - The message content
|
||||||
|
* @property {boolean} [groupWithNext=false] - Whether the message should be grouped with the next message
|
||||||
|
* @property {Object|null} [inReplyTo=null] - The message to which this message is a reply
|
||||||
|
* @property {boolean} [isEmailInbox=false] - Whether the message is from an email inbox
|
||||||
|
* @property {number} conversationId - The ID of the conversation to which the message belongs
|
||||||
|
* @property {number} inboxId - The ID of the inbox to which the message belongs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// eslint-disable-next-line vue/define-macros-order
|
// eslint-disable-next-line vue/define-macros-order
|
||||||
@@ -93,70 +106,51 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
||||||
},
|
},
|
||||||
attachments: {
|
attachments: { type: Array, default: () => [] },
|
||||||
type: Array,
|
content: { type: String, default: null },
|
||||||
default: () => [],
|
contentAttributes: { type: Object, default: () => ({}) },
|
||||||
},
|
contentType: {
|
||||||
private: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
createdAt: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
sender: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
senderId: {
|
|
||||||
type: Number,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
senderType: {
|
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: 'text',
|
||||||
},
|
validator: value => Object.values(CONTENT_TYPES).includes(value),
|
||||||
content: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
contentAttributes: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {},
|
|
||||||
},
|
|
||||||
currentUserId: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
groupWithNext: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
inReplyTo: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
isEmailInbox: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
},
|
||||||
|
conversationId: { type: Number, required: true },
|
||||||
|
createdAt: { type: Number, required: true },
|
||||||
|
currentUserId: { type: Number, required: true },
|
||||||
|
groupWithNext: { type: Boolean, default: false },
|
||||||
|
inboxId: { type: Number, required: true },
|
||||||
|
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
|
||||||
|
inReplyTo: { type: Object, default: null },
|
||||||
|
isEmailInbox: { type: Boolean, default: false },
|
||||||
|
private: { type: Boolean, default: false },
|
||||||
|
sender: { type: Object, default: null },
|
||||||
|
senderId: { type: Number, default: null },
|
||||||
|
senderType: { type: String, default: null },
|
||||||
|
sourceId: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const contextMenuPosition = ref({});
|
||||||
|
const showContextMenu = ref(false);
|
||||||
/**
|
/**
|
||||||
* Computes the message variant based on props
|
* Computes the message variant based on props
|
||||||
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
||||||
*/
|
*/
|
||||||
const variant = computed(() => {
|
const variant = computed(() => {
|
||||||
if (props.private) return MESSAGE_VARIANTS.PRIVATE;
|
if (props.private) return MESSAGE_VARIANTS.PRIVATE;
|
||||||
|
|
||||||
if (props.isEmailInbox) {
|
if (props.isEmailInbox) {
|
||||||
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
|
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
|
||||||
if (emailInboxTypes.includes(props.messageType)) {
|
if (emailInboxTypes.includes(props.messageType)) {
|
||||||
return MESSAGE_VARIANTS.EMAIL;
|
return MESSAGE_VARIANTS.EMAIL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
|
||||||
|
return MESSAGE_VARIANTS.EMAIL;
|
||||||
|
}
|
||||||
|
|
||||||
if (props.status === MESSAGE_STATUS.FAILED) return MESSAGE_VARIANTS.ERROR;
|
if (props.status === MESSAGE_STATUS.FAILED) return MESSAGE_VARIANTS.ERROR;
|
||||||
if (props.contentAttributes.isUnsupported)
|
if (props.contentAttributes?.isUnsupported)
|
||||||
return MESSAGE_VARIANTS.UNSUPPORTED;
|
return MESSAGE_VARIANTS.UNSUPPORTED;
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
@@ -170,10 +164,20 @@ const variant = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isMyMessage = computed(() => {
|
const isMyMessage = computed(() => {
|
||||||
|
// if an outgoing message is still processing, then it's definitely a
|
||||||
|
// message sent by the current user
|
||||||
|
if (
|
||||||
|
props.status === MESSAGE_STATUS.PROGRESS &&
|
||||||
|
props.messageType === MESSAGE_TYPES.OUTGOING
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const senderId = props.senderId ?? props.sender?.id;
|
const senderId = props.senderId ?? props.sender?.id;
|
||||||
const senderType = props.senderType ?? props.sender?.type;
|
const senderType = props.senderType ?? props.sender?.type;
|
||||||
|
|
||||||
if (!senderType || !senderId) return false;
|
if (!senderType || !senderId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase() &&
|
senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase() &&
|
||||||
@@ -248,7 +252,11 @@ const componentToRender = computed(() => {
|
|||||||
if (emailInboxTypes.includes(props.messageType)) return EmailBubble;
|
if (emailInboxTypes.includes(props.messageType)) return EmailBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.contentAttributes.isUnsupported) {
|
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
|
||||||
|
return EmailBubble;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.contentAttributes?.isUnsupported) {
|
||||||
return UnsupportedBubble;
|
return UnsupportedBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +268,7 @@ const componentToRender = computed(() => {
|
|||||||
return InstagramStoryBubble;
|
return InstagramStoryBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.attachments.length === 1) {
|
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
||||||
const fileType = props.attachments[0].fileType;
|
const fileType = props.attachments[0].fileType;
|
||||||
|
|
||||||
if (!props.content) {
|
if (!props.content) {
|
||||||
@@ -275,13 +283,88 @@ const componentToRender = computed(() => {
|
|||||||
if (fileType === ATTACHMENT_TYPES.CONTACT) return ContactBubble;
|
if (fileType === ATTACHMENT_TYPES.CONTACT) return ContactBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.attachments.length > 1 && !props.content) {
|
if (
|
||||||
|
Array.isArray(props.attachments) &&
|
||||||
|
props.attachments.length > 1 &&
|
||||||
|
!props.content
|
||||||
|
) {
|
||||||
return AttachmentsBubble;
|
return AttachmentsBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TextBubble;
|
return TextBubble;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldShowContextMenu = computed(() => {
|
||||||
|
return !(
|
||||||
|
props.status === MESSAGE_STATUS.FAILED ||
|
||||||
|
props.status === MESSAGE_STATUS.PROGRESS ||
|
||||||
|
props.contentAttributes?.isUnsupported
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBubble = computed(() => {
|
||||||
|
return props.messageType !== MESSAGE_TYPES.ACTIVITY;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMessageDeleted = computed(() => {
|
||||||
|
return props.contentAttributes?.deleted;
|
||||||
|
});
|
||||||
|
|
||||||
|
const payloadForContextMenu = computed(() => {
|
||||||
|
return {
|
||||||
|
id: props.id,
|
||||||
|
content_attributes: props.contentAttributes,
|
||||||
|
content: props.content,
|
||||||
|
conversation_id: props.conversationId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextMenuEnabledOptions = computed(() => {
|
||||||
|
const hasText = !!props.content;
|
||||||
|
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
|
||||||
|
|
||||||
|
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
|
||||||
|
|
||||||
|
return {
|
||||||
|
copy: hasText,
|
||||||
|
delete: hasText || hasAttachments,
|
||||||
|
cannedResponse: isOutgoing && hasText,
|
||||||
|
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function openContextMenu(e) {
|
||||||
|
const shouldSkipContextMenu =
|
||||||
|
e.target?.classList.contains('skip-context-menu') ||
|
||||||
|
e.target?.tagName.toLowerCase() === 'a';
|
||||||
|
if (shouldSkipContextMenu || getSelection().toString()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.type === 'contextmenu') {
|
||||||
|
useTrack(ACCOUNT_EVENTS.OPEN_MESSAGE_CONTEXT_MENU);
|
||||||
|
}
|
||||||
|
contextMenuPosition.value = {
|
||||||
|
x: e.pageX || e.clientX,
|
||||||
|
y: e.pageY || e.clientY,
|
||||||
|
};
|
||||||
|
showContextMenu.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeContextMenu() {
|
||||||
|
showContextMenu.value = false;
|
||||||
|
contextMenuPosition.value = { x: null, y: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReplyTo() {
|
||||||
|
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||||
|
const { conversationId, id: replyTo } = props;
|
||||||
|
|
||||||
|
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
|
||||||
|
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, props);
|
||||||
|
}
|
||||||
|
|
||||||
provideMessageContext({
|
provideMessageContext({
|
||||||
variant,
|
variant,
|
||||||
inReplyTo: props.inReplyTo,
|
inReplyTo: props.inReplyTo,
|
||||||
@@ -292,6 +375,7 @@ provideMessageContext({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
:id="`message${props.id}`"
|
||||||
class="flex w-full"
|
class="flex w-full"
|
||||||
:data-message-id="props.id"
|
:data-message-id="props.id"
|
||||||
:class="[flexOrientationClass, shouldGroupWithNext ? 'mb-2' : 'mb-4']"
|
:class="[flexOrientationClass, shouldGroupWithNext ? 'mb-2' : 'mb-4']"
|
||||||
@@ -324,10 +408,11 @@ provideMessageContext({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="[grid-area:bubble]"
|
class="[grid-area:bubble] flex"
|
||||||
:class="{
|
:class="{
|
||||||
'pl-9': ORIENTATION.RIGHT === orientation,
|
'pl-9': ORIENTATION.RIGHT === orientation,
|
||||||
}"
|
}"
|
||||||
|
@contextmenu="openContextMenu($event)"
|
||||||
>
|
>
|
||||||
<Component :is="componentToRender" v-bind="props" />
|
<Component :is="componentToRender" v-bind="props" />
|
||||||
</div>
|
</div>
|
||||||
@@ -344,8 +429,22 @@ provideMessageContext({
|
|||||||
:sender="props.sender"
|
:sender="props.sender"
|
||||||
:status="props.status"
|
:status="props.status"
|
||||||
:private="props.private"
|
:private="props.private"
|
||||||
:is-my-message="isMyMessage"
|
:message-type="props.messageType"
|
||||||
:created-at="props.createdAt"
|
:created-at="props.createdAt"
|
||||||
|
:source-id="props.sourceId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
|
||||||
|
<ContextMenu
|
||||||
|
v-if="isBubble && !isMessageDeleted"
|
||||||
|
:context-menu-position="contextMenuPosition"
|
||||||
|
:is-open="showContextMenu"
|
||||||
|
:enabled-options="contextMenuEnabledOptions"
|
||||||
|
:message="payloadForContextMenu"
|
||||||
|
hide-button
|
||||||
|
@open="openContextMenu"
|
||||||
|
@close="closeContextMenu"
|
||||||
|
@reply-to="handleReplyTo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script setup>
|
||||||
|
import { defineProps, computed } from 'vue';
|
||||||
|
import Message from './Message.vue';
|
||||||
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props definition for the component
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {Array} readMessages - Array of read messages
|
||||||
|
* @property {Array} unReadMessages - Array of unread messages
|
||||||
|
* @property {Number} currentUserId - ID of the current user
|
||||||
|
* @property {Boolean} isAnEmailChannel - Whether this is an email channel
|
||||||
|
* @property {Object} inboxSupportsReplyTo - Inbox reply support configuration
|
||||||
|
* @property {Array} messages - Array of all messages
|
||||||
|
*/
|
||||||
|
const props = defineProps({
|
||||||
|
readMessages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
unReadMessages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
currentUserId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isAnEmailChannel: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
inboxSupportsReplyTo: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ incoming: false, outgoing: false }),
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
shouldShowSpinner: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
unreadMessageCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unread = computed(() => {
|
||||||
|
return useCamelCase(props.unReadMessages, { deep: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const read = computed(() => {
|
||||||
|
return useCamelCase(props.readMessages, { deep: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a message should be grouped with the next message
|
||||||
|
* @param {Number} index - Index of the current message
|
||||||
|
* @param {Array} messages - Array of messages to check
|
||||||
|
* @returns {Boolean} - Whether the message should be grouped with next
|
||||||
|
*/
|
||||||
|
const shouldGroupWithNext = (index, messages) => {
|
||||||
|
if (index === messages.length - 1) return false;
|
||||||
|
|
||||||
|
const current = messages[index];
|
||||||
|
const next = messages[index + 1];
|
||||||
|
|
||||||
|
if (next.status === 'failed') return false;
|
||||||
|
|
||||||
|
const nextSenderId = next.senderId ?? next.sender?.id;
|
||||||
|
const currentSenderId = current.senderId ?? current.sender?.id;
|
||||||
|
if (currentSenderId !== nextSenderId) return false;
|
||||||
|
|
||||||
|
// Check if messages are in the same minute by rounding down to nearest minute
|
||||||
|
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the message that was replied to
|
||||||
|
* @param {Object} parentMessage - The message containing the reply reference
|
||||||
|
* @returns {Object|null} - The message being replied to, or null if not found
|
||||||
|
*/
|
||||||
|
const getInReplyToMessage = parentMessage => {
|
||||||
|
if (!parentMessage) return null;
|
||||||
|
|
||||||
|
const inReplyToMessageId =
|
||||||
|
parentMessage.contentAttributes?.inReplyTo ??
|
||||||
|
parentMessage.content_attributes?.in_reply_to;
|
||||||
|
|
||||||
|
if (!inReplyToMessageId) return null;
|
||||||
|
|
||||||
|
// Find in-reply-to message in the messages prop
|
||||||
|
const replyMessage = props.messages?.find(
|
||||||
|
message => message.id === inReplyToMessageId
|
||||||
|
);
|
||||||
|
|
||||||
|
return replyMessage ? useCamelCase(replyMessage) : null;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul class="px-4 bg-n-background">
|
||||||
|
<transition name="slide-up">
|
||||||
|
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
||||||
|
<li class="min-h-[4rem]">
|
||||||
|
<span v-if="shouldShowSpinner" class="spinner message" />
|
||||||
|
</li>
|
||||||
|
</transition>
|
||||||
|
<Message
|
||||||
|
v-for="(message, index) in read"
|
||||||
|
:key="message.id"
|
||||||
|
v-bind="message"
|
||||||
|
:is-email-inbox="isAnEmailChannel"
|
||||||
|
:in-reply-to="getInReplyToMessage(message)"
|
||||||
|
:group-with-next="shouldGroupWithNext(index, readMessages)"
|
||||||
|
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||||
|
:current-user-id="currentUserId"
|
||||||
|
data-clarity-mask="True"
|
||||||
|
/>
|
||||||
|
<li v-show="unreadMessageCount != 0" class="unread--toast">
|
||||||
|
<span>
|
||||||
|
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
|
||||||
|
{{
|
||||||
|
unreadMessageCount > 1
|
||||||
|
? $t('CONVERSATION.UNREAD_MESSAGES')
|
||||||
|
: $t('CONVERSATION.UNREAD_MESSAGE')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<Message
|
||||||
|
v-for="(message, index) in unread"
|
||||||
|
:key="message.id"
|
||||||
|
v-bind="message"
|
||||||
|
:in-reply-to="getInReplyToMessage(message)"
|
||||||
|
:group-with-next="shouldGroupWithNext(index, unReadMessages)"
|
||||||
|
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||||
|
:current-user-id="currentUserId"
|
||||||
|
:is-email-inbox="isAnEmailChannel"
|
||||||
|
data-clarity-mask="True"
|
||||||
|
/>
|
||||||
|
<slot name="after" />
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
@@ -4,8 +4,9 @@ import { messageTimestamp } from 'shared/helpers/timeHelper';
|
|||||||
|
|
||||||
import MessageStatus from './MessageStatus.vue';
|
import MessageStatus from './MessageStatus.vue';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
import { useInbox } from 'dashboard/composables/useInbox';
|
||||||
|
|
||||||
import { MESSAGE_STATUS } from './constants';
|
import { MESSAGE_STATUS, MESSAGE_TYPES } from './constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Sender
|
* @typedef {Object} Sender
|
||||||
@@ -43,21 +44,116 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
isMyMessage: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
createdAt: {
|
createdAt: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
sourceId: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
messageType: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
validator: value => Object.values(MESSAGE_TYPES).includes(value),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
isAFacebookInbox,
|
||||||
|
isALineChannel,
|
||||||
|
isAPIInbox,
|
||||||
|
isASmsInbox,
|
||||||
|
isATelegramChannel,
|
||||||
|
isATwilioChannel,
|
||||||
|
isAWebWidgetInbox,
|
||||||
|
isAWhatsAppChannel,
|
||||||
|
isAnEmailChannel,
|
||||||
|
} = useInbox();
|
||||||
|
|
||||||
const readableTime = computed(() =>
|
const readableTime = computed(() =>
|
||||||
messageTimestamp(props.createdAt, 'LLL d, h:mm a')
|
messageTimestamp(props.createdAt, 'LLL d, h:mm a')
|
||||||
);
|
);
|
||||||
|
|
||||||
const showSender = computed(() => !props.isMyMessage && props.sender);
|
const showSender = computed(() => !props.isMyMessage && props.sender);
|
||||||
|
|
||||||
|
const showStatusIndicator = computed(() => {
|
||||||
|
if (props.private) return false;
|
||||||
|
if (props.messageType === MESSAGE_TYPES.OUTGOING) return true;
|
||||||
|
if (props.messageType === MESSAGE_TYPES.TEMPLATE) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSent = computed(() => {
|
||||||
|
if (!showStatusIndicator.value) return false;
|
||||||
|
|
||||||
|
// Messages will be marked as sent for the Email channel if they have a source ID.
|
||||||
|
if (isAnEmailChannel.value) return !!props.sourceId;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isAWhatsAppChannel.value ||
|
||||||
|
isATwilioChannel.value ||
|
||||||
|
isAFacebookInbox.value ||
|
||||||
|
isASmsInbox.value ||
|
||||||
|
isATelegramChannel.value
|
||||||
|
) {
|
||||||
|
return props.sourceId && props.status === MESSAGE_STATUS.SENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All messages will be mark as sent for the Line channel, as there is no source ID.
|
||||||
|
if (props.isALineChannel) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDelivered = computed(() => {
|
||||||
|
if (!showStatusIndicator.value) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isAWhatsAppChannel.value ||
|
||||||
|
isATwilioChannel.value ||
|
||||||
|
isASmsInbox.value ||
|
||||||
|
isAFacebookInbox.value
|
||||||
|
) {
|
||||||
|
return props.sourceId && props.status === MESSAGE_STATUS.DELIVERED;
|
||||||
|
}
|
||||||
|
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
|
||||||
|
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||||
|
return props.status === MESSAGE_STATUS.SENT;
|
||||||
|
}
|
||||||
|
if (isALineChannel.value) {
|
||||||
|
return props.status === MESSAGE_STATUS.DELIVERED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isRead = computed(() => {
|
||||||
|
if (!showStatusIndicator.value) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isAWhatsAppChannel.value ||
|
||||||
|
isATwilioChannel.value ||
|
||||||
|
isAFacebookInbox.value
|
||||||
|
) {
|
||||||
|
return props.sourceId && props.status === MESSAGE_STATUS.READ;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||||
|
return props.status === MESSAGE_STATUS.READ;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusToShow = computed(() => {
|
||||||
|
if (isRead.value) return MESSAGE_STATUS.READ;
|
||||||
|
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
|
||||||
|
if (isSent.value) return MESSAGE_STATUS.SENT;
|
||||||
|
|
||||||
|
return MESSAGE_STATUS.PROGRESS;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -72,6 +168,6 @@ const showSender = computed(() => !props.isMyMessage && props.sender);
|
|||||||
icon="i-lucide-lock-keyhole"
|
icon="i-lucide-lock-keyhole"
|
||||||
class="text-n-slate-10 size-3"
|
class="text-n-slate-10 size-3"
|
||||||
/>
|
/>
|
||||||
<MessageStatus v-if="props.isMyMessage" :status />
|
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
|
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
|
||||||
import BaseAttachmentBubble from './BaseAttachment.vue';
|
import BaseAttachmentBubble from './BaseAttachment.vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import maplibregl from 'maplibre-gl';
|
// import maplibregl from 'maplibre-gl';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Attachment
|
* @typedef {Object} Attachment
|
||||||
@@ -57,28 +57,28 @@ const mapUrl = computed(
|
|||||||
|
|
||||||
const mapContainer = useTemplateRef('mapContainer');
|
const mapContainer = useTemplateRef('mapContainer');
|
||||||
|
|
||||||
const setupMap = () => {
|
// const setupMap = () => {
|
||||||
const map = new maplibregl.Map({
|
// const map = new maplibregl.Map({
|
||||||
style: 'https://tiles.openfreemap.org/styles/positron',
|
// style: 'https://tiles.openfreemap.org/styles/positron',
|
||||||
center: [long.value, lat.value],
|
// center: [long.value, lat.value],
|
||||||
zoom: 9.5,
|
// zoom: 15,
|
||||||
container: mapContainer.value,
|
// container: mapContainer.value,
|
||||||
attributionControl: false,
|
// attributionControl: false,
|
||||||
dragPan: false,
|
// dragPan: false,
|
||||||
dragRotate: false,
|
// dragRotate: false,
|
||||||
scrollZoom: false,
|
// scrollZoom: false,
|
||||||
touchZoom: false,
|
// touchZoom: false,
|
||||||
touchRotate: false,
|
// touchRotate: false,
|
||||||
keyboard: false,
|
// keyboard: false,
|
||||||
doubleClickZoom: false,
|
// doubleClickZoom: false,
|
||||||
});
|
// });
|
||||||
|
// new maplibregl.Marker().setLngLat([long.value, lat.value]).addTo(map);
|
||||||
return map;
|
// return map;
|
||||||
};
|
// };
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
setupMap();
|
// setupMap();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { ref } from 'vue';
|
|||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import { useAI } from 'dashboard/composables/useAI';
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
|
|
||||||
// components
|
// components
|
||||||
import ReplyBox from './ReplyBox.vue';
|
import ReplyBox from './ReplyBox.vue';
|
||||||
import Message from './Message.vue';
|
import Message from './Message.vue';
|
||||||
|
import NextMessage from 'next/message/Message.vue';
|
||||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||||
|
|
||||||
@@ -34,9 +37,26 @@ import { REPLY_POLICY } from 'shared/constants/links';
|
|||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
|
|
||||||
|
function shouldGroupWithNext(index, messages) {
|
||||||
|
if (index === messages.length - 1) return false;
|
||||||
|
|
||||||
|
const current = messages[index];
|
||||||
|
const next = messages[index + 1];
|
||||||
|
|
||||||
|
if (next.status === 'failed') return false;
|
||||||
|
|
||||||
|
const nextSenderId = next.senderId ?? next.sender?.id;
|
||||||
|
const currentSenderId = current.senderId ?? current.sender?.id;
|
||||||
|
if (currentSenderId !== nextSenderId) return false;
|
||||||
|
|
||||||
|
// Check if messages are in the same minute by rounding down to nearest minute
|
||||||
|
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Message,
|
Message,
|
||||||
|
NextMessage,
|
||||||
ReplyBox,
|
ReplyBox,
|
||||||
Banner,
|
Banner,
|
||||||
ConversationLabelSuggestion,
|
ConversationLabelSuggestion,
|
||||||
@@ -56,6 +76,7 @@ export default {
|
|||||||
setup() {
|
setup() {
|
||||||
const isPopOutReplyBox = ref(false);
|
const isPopOutReplyBox = ref(false);
|
||||||
const { isEnterprise } = useConfig();
|
const { isEnterprise } = useConfig();
|
||||||
|
const { accountId } = useAccount();
|
||||||
|
|
||||||
const closePopOutReplyBox = () => {
|
const closePopOutReplyBox = () => {
|
||||||
isPopOutReplyBox.value = false;
|
isPopOutReplyBox.value = false;
|
||||||
@@ -80,6 +101,10 @@ export default {
|
|||||||
fetchLabelSuggestions,
|
fetchLabelSuggestions,
|
||||||
} = useAI();
|
} = useAI();
|
||||||
|
|
||||||
|
const showNextBubbles = LocalStorage.get(
|
||||||
|
LOCAL_STORAGE_KEYS.USE_NEXT_BUBBLE
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isEnterprise,
|
isEnterprise,
|
||||||
isPopOutReplyBox,
|
isPopOutReplyBox,
|
||||||
@@ -89,6 +114,8 @@ export default {
|
|||||||
isLabelSuggestionFeatureEnabled,
|
isLabelSuggestionFeatureEnabled,
|
||||||
fetchIntegrationsIfRequired,
|
fetchIntegrationsIfRequired,
|
||||||
fetchLabelSuggestions,
|
fetchLabelSuggestions,
|
||||||
|
accountId,
|
||||||
|
showNextBubbles,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -106,6 +133,7 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
currentChat: 'getSelectedChat',
|
currentChat: 'getSelectedChat',
|
||||||
|
currentUserId: 'getCurrentUserID',
|
||||||
listLoadingStatus: 'getAllMessagesLoaded',
|
listLoadingStatus: 'getAllMessagesLoaded',
|
||||||
currentAccountId: 'getCurrentAccountId',
|
currentAccountId: 'getCurrentAccountId',
|
||||||
}),
|
}),
|
||||||
@@ -153,16 +181,28 @@ export default {
|
|||||||
return messages;
|
return messages;
|
||||||
},
|
},
|
||||||
readMessages() {
|
readMessages() {
|
||||||
return getReadMessages(
|
const readMessages = getReadMessages(
|
||||||
this.getMessages,
|
this.getMessages,
|
||||||
this.currentChat.agent_last_seen_at
|
this.currentChat.agent_last_seen_at
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.showNextBubbles) {
|
||||||
|
return useCamelCase(readMessages, { deep: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return readMessages;
|
||||||
},
|
},
|
||||||
unReadMessages() {
|
unReadMessages() {
|
||||||
return getUnreadMessages(
|
const unreadMessages = getUnreadMessages(
|
||||||
this.getMessages,
|
this.getMessages,
|
||||||
this.currentChat.agent_last_seen_at
|
this.currentChat.agent_last_seen_at
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.showNextBubbles) {
|
||||||
|
return useCamelCase(unreadMessages, { deep: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return unreadMessages;
|
||||||
},
|
},
|
||||||
shouldShowSpinner() {
|
shouldShowSpinner() {
|
||||||
return (
|
return (
|
||||||
@@ -436,11 +476,19 @@ export default {
|
|||||||
makeMessagesRead() {
|
makeMessagesRead() {
|
||||||
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
||||||
},
|
},
|
||||||
|
|
||||||
getInReplyToMessage(parentMessage) {
|
getInReplyToMessage(parentMessage) {
|
||||||
if (!parentMessage) return {};
|
// the old implementation took an empty object, but the
|
||||||
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
|
// new implementation takes null
|
||||||
if (!inReplyToMessageId) return {};
|
const emptyOption = this.showNextBubbles ? null : {};
|
||||||
|
|
||||||
|
if (!parentMessage) return emptyOption;
|
||||||
|
// to maintain backward compatibility we use both the keys
|
||||||
|
// contentAttributes and content_attributes
|
||||||
|
// TODO: Remove this once we've migrated all the keys to camelCase
|
||||||
|
const inReplyToMessageId =
|
||||||
|
parentMessage.contentAttributes?.inReplyTo ??
|
||||||
|
parentMessage.content_attributes?.in_reply_to;
|
||||||
|
if (!inReplyToMessageId) return emptyOption;
|
||||||
|
|
||||||
return this.currentChat?.messages.find(message => {
|
return this.currentChat?.messages.find(message => {
|
||||||
if (message.id === inReplyToMessageId) {
|
if (message.id === inReplyToMessageId) {
|
||||||
@@ -449,6 +497,7 @@ export default {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
shouldGroupWithNext: shouldGroupWithNext,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -473,28 +522,46 @@ export default {
|
|||||||
@click="onToggleContactPanel"
|
@click="onToggleContactPanel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ul class="conversation-panel">
|
<ul
|
||||||
|
class="conversation-panel"
|
||||||
|
:class="{ 'px-4 bg-n-background': showNextBubbles }"
|
||||||
|
>
|
||||||
<transition name="slide-up">
|
<transition name="slide-up">
|
||||||
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
||||||
<li class="min-h-[4rem]">
|
<li class="min-h-[4rem]">
|
||||||
<span v-if="shouldShowSpinner" class="spinner message" />
|
<span v-if="shouldShowSpinner" class="spinner message" />
|
||||||
</li>
|
</li>
|
||||||
</transition>
|
</transition>
|
||||||
<Message
|
<template v-if="showNextBubbles">
|
||||||
v-for="message in readMessages"
|
<NextMessage
|
||||||
:key="message.id"
|
v-for="(message, index) in readMessages"
|
||||||
class="message--read ph-no-capture"
|
:key="message.id"
|
||||||
data-clarity-mask="True"
|
v-bind="message"
|
||||||
:data="message"
|
:is-email-inbox="isAnEmailChannel"
|
||||||
:is-a-tweet="isATweet"
|
:in-reply-to="getInReplyToMessage(message)"
|
||||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
:group-with-next="shouldGroupWithNext(index, readMessages)"
|
||||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||||
:is-a-facebook-inbox="isAFacebookInbox"
|
:current-user-id="currentUserId"
|
||||||
:is-an-email-inbox="isAnEmailChannel"
|
data-clarity-mask="True"
|
||||||
:is-instagram="isInstagramDM"
|
/>
|
||||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
</template>
|
||||||
:in-reply-to="getInReplyToMessage(message)"
|
<template v-else>
|
||||||
/>
|
<Message
|
||||||
|
v-for="message in readMessages"
|
||||||
|
:key="message.id"
|
||||||
|
class="message--read ph-no-capture"
|
||||||
|
data-clarity-mask="True"
|
||||||
|
:data="message"
|
||||||
|
:is-a-tweet="isATweet"
|
||||||
|
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||||
|
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||||
|
:is-a-facebook-inbox="isAFacebookInbox"
|
||||||
|
:is-an-email-inbox="isAnEmailChannel"
|
||||||
|
:is-instagram="isInstagramDM"
|
||||||
|
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||||
|
:in-reply-to="getInReplyToMessage(message)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<li v-show="unreadMessageCount != 0" class="unread--toast">
|
<li v-show="unreadMessageCount != 0" class="unread--toast">
|
||||||
<span>
|
<span>
|
||||||
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
|
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
|
||||||
@@ -505,20 +572,35 @@ export default {
|
|||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<Message
|
<template v-if="showNextBubbles">
|
||||||
v-for="message in unReadMessages"
|
<NextMessage
|
||||||
:key="message.id"
|
v-for="(message, index) in unReadMessages"
|
||||||
class="message--unread ph-no-capture"
|
:key="message.id"
|
||||||
data-clarity-mask="True"
|
v-bind="message"
|
||||||
:data="message"
|
:in-reply-to="getInReplyToMessage(message)"
|
||||||
:is-a-tweet="isATweet"
|
:group-with-next="shouldGroupWithNext(index, unReadMessages)"
|
||||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
:current-user-id="currentUserId"
|
||||||
:is-a-facebook-inbox="isAFacebookInbox"
|
:is-email-inbox="isAnEmailChannel"
|
||||||
:is-instagram-dm="isInstagramDM"
|
data-clarity-mask="True"
|
||||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
/>
|
||||||
:in-reply-to="getInReplyToMessage(message)"
|
</template>
|
||||||
/>
|
<template v-else>
|
||||||
|
<Message
|
||||||
|
v-for="message in unReadMessages"
|
||||||
|
:key="message.id"
|
||||||
|
class="message--unread ph-no-capture"
|
||||||
|
data-clarity-mask="True"
|
||||||
|
:data="message"
|
||||||
|
:is-a-tweet="isATweet"
|
||||||
|
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||||
|
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||||
|
:is-a-facebook-inbox="isAFacebookInbox"
|
||||||
|
:is-instagram-dm="isInstagramDM"
|
||||||
|
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||||
|
:in-reply-to="getInReplyToMessage(message)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<ConversationLabelSuggestion
|
<ConversationLabelSuggestion
|
||||||
v-if="shouldShowLabelSuggestions"
|
v-if="shouldShowLabelSuggestions"
|
||||||
:suggested-labels="labelSuggestions"
|
:suggested-labels="labelSuggestions"
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
|
export const INBOX_FEATURES = {
|
||||||
|
REPLY_TO: 'replyTo',
|
||||||
|
REPLY_TO_OUTGOING: 'replyToOutgoing',
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is a single source of truth for inbox features
|
||||||
|
// This is used to check if a feature is available for a particular inbox or not
|
||||||
|
export const INBOX_FEATURE_MAP = {
|
||||||
|
[INBOX_FEATURES.REPLY_TO]: [
|
||||||
|
INBOX_TYPES.FB,
|
||||||
|
INBOX_TYPES.WEB,
|
||||||
|
INBOX_TYPES.TWITTER,
|
||||||
|
INBOX_TYPES.WHATSAPP,
|
||||||
|
INBOX_TYPES.TELEGRAM,
|
||||||
|
INBOX_TYPES.API,
|
||||||
|
],
|
||||||
|
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||||
|
INBOX_TYPES.WEB,
|
||||||
|
INBOX_TYPES.TWITTER,
|
||||||
|
INBOX_TYPES.WHATSAPP,
|
||||||
|
INBOX_TYPES.TELEGRAM,
|
||||||
|
INBOX_TYPES.API,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for handling macro-related functionality
|
||||||
|
* @returns {Object} An object containing the getMacroDropdownValues function
|
||||||
|
*/
|
||||||
|
export const useInbox = () => {
|
||||||
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
const inboxGetter = useMapGetter('inboxes/getInboxById');
|
||||||
|
|
||||||
|
const inbox = computed(() => {
|
||||||
|
const inboxId = currentChat.value.inbox_id;
|
||||||
|
|
||||||
|
return useCamelCase(inboxGetter.value(inboxId), { deep: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const channelType = computed(() => {
|
||||||
|
return inbox.value.channelType;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAPIInbox = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.API;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAFacebookInbox = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.FB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAWebWidgetInbox = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.WEB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isATwilioChannel = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.TWILIO;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isALineChannel = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.LINE;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAnEmailChannel = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.EMAIL;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isATelegramChannel = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.TELEGRAM;
|
||||||
|
});
|
||||||
|
|
||||||
|
const whatsAppAPIProvider = computed(() => {
|
||||||
|
return inbox.value.provider || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAMicrosoftInbox = computed(() => {
|
||||||
|
return isAnEmailChannel.value && inbox.value.provider === 'microsoft';
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAGoogleInbox = computed(() => {
|
||||||
|
return isAnEmailChannel.value && inbox.value.provider === 'google';
|
||||||
|
});
|
||||||
|
|
||||||
|
const isATwilioSMSChannel = computed(() => {
|
||||||
|
const { medium: medium = '' } = inbox.value;
|
||||||
|
return isATwilioChannel.value && medium === 'sms';
|
||||||
|
});
|
||||||
|
|
||||||
|
const isASmsInbox = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.SMS || isATwilioSMSChannel.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isATwilioWhatsAppChannel = computed(() => {
|
||||||
|
const { medium: medium = '' } = inbox.value;
|
||||||
|
return isATwilioChannel.value && medium === 'whatsapp';
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAWhatsAppCloudChannel = computed(() => {
|
||||||
|
return (
|
||||||
|
channelType.value === INBOX_TYPES.WHATSAPP &&
|
||||||
|
whatsAppAPIProvider.value === 'whatsapp_cloud'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const is360DialogWhatsAppChannel = computed(() => {
|
||||||
|
return (
|
||||||
|
channelType.value === INBOX_TYPES.WHATSAPP &&
|
||||||
|
whatsAppAPIProvider.value === 'default'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAWhatsAppChannel = computed(() => {
|
||||||
|
return (
|
||||||
|
channelType.value === INBOX_TYPES.WHATSAPP ||
|
||||||
|
isATwilioWhatsAppChannel.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
inbox,
|
||||||
|
isAFacebookInbox,
|
||||||
|
isALineChannel,
|
||||||
|
isAPIInbox,
|
||||||
|
isASmsInbox,
|
||||||
|
isATelegramChannel,
|
||||||
|
isATwilioChannel,
|
||||||
|
isAWebWidgetInbox,
|
||||||
|
isAWhatsAppChannel,
|
||||||
|
isAMicrosoftInbox,
|
||||||
|
isAGoogleInbox,
|
||||||
|
isATwilioWhatsAppChannel,
|
||||||
|
isAWhatsAppCloudChannel,
|
||||||
|
is360DialogWhatsAppChannel,
|
||||||
|
isAnEmailChannel,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { unref } from 'vue';
|
import { unref } from 'vue';
|
||||||
import camelcaseKeys from 'camelcase-keys';
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
import snakecaseKeys from 'snakecase-keys';
|
import snakecaseKeys from 'snakecase-keys';
|
||||||
|
import * as Sentry from '@sentry/vue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vue composable that converts object keys to camelCase
|
* Vue composable that converts object keys to camelCase
|
||||||
@@ -12,8 +13,18 @@ import snakecaseKeys from 'snakecase-keys';
|
|||||||
* @returns {Object|Array} Converted payload with camelCase keys
|
* @returns {Object|Array} Converted payload with camelCase keys
|
||||||
*/
|
*/
|
||||||
export function useCamelCase(payload, options) {
|
export function useCamelCase(payload, options) {
|
||||||
const unrefPayload = unref(payload);
|
try {
|
||||||
return camelcaseKeys(unrefPayload, options);
|
const unrefPayload = unref(payload);
|
||||||
|
return camelcaseKeys(unrefPayload, options);
|
||||||
|
} catch (e) {
|
||||||
|
Sentry.setContext('transform-keys-error', {
|
||||||
|
payload,
|
||||||
|
options,
|
||||||
|
op: 'camelCase',
|
||||||
|
});
|
||||||
|
Sentry.captureException(e);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +35,16 @@ export function useCamelCase(payload, options) {
|
|||||||
* @returns {Object|Array} Converted payload with snake_case keys
|
* @returns {Object|Array} Converted payload with snake_case keys
|
||||||
*/
|
*/
|
||||||
export function useSnakeCase(payload, options) {
|
export function useSnakeCase(payload, options) {
|
||||||
const unrefPayload = unref(payload);
|
try {
|
||||||
return snakecaseKeys(unrefPayload, options);
|
const unrefPayload = unref(payload);
|
||||||
|
return snakecaseKeys(unrefPayload, options);
|
||||||
|
} catch (e) {
|
||||||
|
Sentry.setContext('transform-keys-error', {
|
||||||
|
payload,
|
||||||
|
options,
|
||||||
|
op: 'snakeCase',
|
||||||
|
});
|
||||||
|
Sentry.captureException(e);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export const LOCAL_STORAGE_KEYS = {
|
|||||||
COLOR_SCHEME: 'color_scheme',
|
COLOR_SCHEME: 'color_scheme',
|
||||||
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
||||||
MESSAGE_REPLY_TO: 'messageReplyTo',
|
MESSAGE_REPLY_TO: 'messageReplyTo',
|
||||||
|
USE_NEXT_BUBBLE: 'useNextBubble',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { mapGetters } from 'vuex';
|
|||||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||||
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
|
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
|
||||||
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||||
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
|
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +39,10 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
|
hideButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: ['open', 'close', 'replyTo'],
|
emits: ['open', 'close', 'replyTo'],
|
||||||
setup() {
|
setup() {
|
||||||
@@ -62,7 +67,7 @@ export default {
|
|||||||
return this.getPlainText(this.messageContent);
|
return this.getPlainText(this.messageContent);
|
||||||
},
|
},
|
||||||
conversationId() {
|
conversationId() {
|
||||||
return this.message.conversation_id;
|
return this.message.conversation_id ?? this.message.conversationId;
|
||||||
},
|
},
|
||||||
messageId() {
|
messageId() {
|
||||||
return this.message.id;
|
return this.message.id;
|
||||||
@@ -71,7 +76,9 @@ export default {
|
|||||||
return this.message.content;
|
return this.message.content;
|
||||||
},
|
},
|
||||||
contentAttributes() {
|
contentAttributes() {
|
||||||
return this.message.content_attributes;
|
return useSnakeCase(
|
||||||
|
this.message.content_attributes ?? this.message.contentAttributes
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -183,6 +190,7 @@ export default {
|
|||||||
:reject-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.CANCEL')"
|
:reject-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.CANCEL')"
|
||||||
/>
|
/>
|
||||||
<woot-button
|
<woot-button
|
||||||
|
v-if="!hideButton"
|
||||||
icon="more-vertical"
|
icon="more-vertical"
|
||||||
color-scheme="secondary"
|
color-scheme="secondary"
|
||||||
variant="clear"
|
variant="clear"
|
||||||
|
|||||||
Reference in New Issue
Block a user