Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,595 @@
<script setup>
import { onMounted, computed, ref, toRefs } from 'vue';
import { useTimeoutFn } from '@vueuse/core';
import { provideMessageContext } from './provider.js';
import { useTrack } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
MESSAGE_TYPES,
ATTACHMENT_TYPES,
MESSAGE_VARIANTS,
SENDER_TYPES,
ORIENTATION,
MESSAGE_STATUS,
CONTENT_TYPES,
} from './constants';
import Avatar from 'next/avatar/Avatar.vue';
import TextBubble from './bubbles/Text/Index.vue';
import ActivityBubble from './bubbles/Activity.vue';
import ImageBubble from './bubbles/Image.vue';
import FileBubble from './bubbles/File.vue';
import AudioBubble from './bubbles/Audio.vue';
import VideoBubble from './bubbles/Video.vue';
import EmbedBubble from './bubbles/Embed.vue';
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
import EmailBubble from './bubbles/Email/Index.vue';
import UnsupportedBubble from './bubbles/Unsupported.vue';
import ContactBubble from './bubbles/Contact.vue';
import DyteBubble from './bubbles/Dyte.vue';
import LocationBubble from './bubbles/Location.vue';
import CSATBubble from './bubbles/CSAT.vue';
import FormBubble from './bubbles/Form.vue';
import VoiceCallBubble from './bubbles/VoiceCall.vue';
import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import { useBranding } from 'shared/composables/useBranding';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
/**
* @typedef {Object} Sender
* @property {Object} additional_attributes - Additional attributes of the sender
* @property {Object} custom_attributes - Custom attributes of the sender
* @property {string} email - Email of the sender
* @property {number} id - ID of the sender
* @property {string|null} identifier - Identifier of the sender
* @property {string} name - Name of the sender
* @property {string|null} phone_number - Phone number of the sender
* @property {string} thumbnail - Thumbnail URL of the sender
* @property {string} type - Type of sender
*/
/**
* @typedef {Object} ContentAttributes
* @property {string} externalError - an error message to be shown if the message failed to send
*/
/**
* @typedef {Object} Props
* @property {('sent'|'delivered'|'read'|'failed'|'progress')} status - The delivery status of the message
* @property {ContentAttributes} [contentAttributes={}] - Additional attributes of the message content
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
* @property {Sender|null} [sender=null] - The sender information
* @property {boolean} [private=false] - Whether the message is private
* @property {number|null} [senderId=null] - The ID of the sender
* @property {number} createdAt - Timestamp when the message was created
* @property {number} currentUserId - The ID of the current user
* @property {number} id - The unique identifier for the message
* @property {number} messageType - The type of message (must be one of MESSAGE_TYPES)
* @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} 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
const props = defineProps({
id: { type: Number, required: true },
messageType: {
type: Number,
required: true,
validator: value => Object.values(MESSAGE_TYPES).includes(value),
},
status: {
type: String,
required: true,
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
attachments: { type: Array, default: () => [] },
content: { type: String, default: null },
contentAttributes: { type: Object, default: () => ({}) },
contentType: {
type: String,
default: 'text',
validator: value => Object.values(CONTENT_TYPES).includes(value),
},
conversationId: { type: Number, required: true },
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
groupWithNext: { type: Boolean, default: false },
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
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: '' }, // eslint-disable-line vue/no-unused-properties
});
const emit = defineEmits(['retry']);
const contextMenuPosition = ref({});
const showBackgroundHighlight = ref(false);
const showContextMenu = ref(false);
const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
const { replaceInstallationName } = useBranding();
/**
* Computes the message variant based on props
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
*/
const variant = computed(() => {
if (props.private) return MESSAGE_VARIANTS.PRIVATE;
if (props.isEmailInbox) {
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
if (emailInboxTypes.includes(props.messageType)) {
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.contentAttributes?.isUnsupported)
return MESSAGE_VARIANTS.UNSUPPORTED;
if (props.contentAttributes?.externalEcho) {
return MESSAGE_VARIANTS.AGENT;
}
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
return MESSAGE_VARIANTS.BOT;
}
const variants = {
[MESSAGE_TYPES.INCOMING]: MESSAGE_VARIANTS.USER,
[MESSAGE_TYPES.ACTIVITY]: MESSAGE_VARIANTS.ACTIVITY,
[MESSAGE_TYPES.OUTGOING]: MESSAGE_VARIANTS.AGENT,
[MESSAGE_TYPES.TEMPLATE]: MESSAGE_VARIANTS.TEMPLATE,
};
return variants[props.messageType] || MESSAGE_VARIANTS.USER;
});
const isBotOrAgentMessage = computed(() => {
if (props.messageType === MESSAGE_TYPES.ACTIVITY) {
return false;
}
// 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 senderType = props.sender?.type ?? props.senderType;
if (!senderType || !senderId) {
return true;
}
if (
[SENDER_TYPES.AGENT_BOT, SENDER_TYPES.CAPTAIN_ASSISTANT].includes(
senderType
)
) {
return true;
}
return senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase();
});
/**
* Computes the message orientation based on sender type and message type
* @returns {import('vue').ComputedRef<'left'|'right'|'center'>} The computed orientation
*/
const orientation = computed(() => {
if (isBotOrAgentMessage.value) {
return ORIENTATION.RIGHT;
}
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return ORIENTATION.CENTER;
return ORIENTATION.LEFT;
});
const flexOrientationClass = computed(() => {
const map = {
[ORIENTATION.LEFT]: 'justify-start',
[ORIENTATION.RIGHT]: 'justify-end',
[ORIENTATION.CENTER]: 'justify-center',
};
return map[orientation.value];
});
const gridClass = computed(() => {
const map = {
[ORIENTATION.LEFT]: 'grid grid-cols-1fr',
[ORIENTATION.RIGHT]: 'grid grid-cols-[1fr_24px]',
};
return map[orientation.value];
});
const gridTemplate = computed(() => {
const map = {
[ORIENTATION.LEFT]: `
"bubble"
"meta"
`,
[ORIENTATION.RIGHT]: `
"bubble avatar"
"meta spacer"
`,
};
return map[orientation.value];
});
const shouldGroupWithNext = computed(() => {
if (props.status === MESSAGE_STATUS.FAILED) return false;
return props.groupWithNext;
});
const shouldShowAvatar = computed(() => {
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return false;
if (orientation.value === ORIENTATION.LEFT) return false;
return true;
});
const componentToRender = computed(() => {
if (props.isEmailInbox && !props.private) {
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
if (emailInboxTypes.includes(props.messageType)) return EmailBubble;
}
if (props.contentType === CONTENT_TYPES.INPUT_CSAT) {
return CSATBubble;
}
if (
[CONTENT_TYPES.INPUT_SELECT, CONTENT_TYPES.FORM].includes(props.contentType)
) {
return FormBubble;
}
if (props.contentType === CONTENT_TYPES.VOICE_CALL) {
return VoiceCallBubble;
}
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
return EmailBubble;
}
if (props.contentAttributes?.isUnsupported) {
return UnsupportedBubble;
}
if (props.contentAttributes.type === 'dyte') {
return DyteBubble;
}
const instagramSharedTypes = [
ATTACHMENT_TYPES.STORY_MENTION,
ATTACHMENT_TYPES.IG_STORY,
ATTACHMENT_TYPES.IG_STORY_REPLY,
ATTACHMENT_TYPES.IG_POST,
];
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
return InstagramStoryBubble;
}
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
const fileType = props.attachments[0].fileType;
if (!props.content) {
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
if (fileType === ATTACHMENT_TYPES.EMBED) return EmbedBubble;
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
}
// Attachment content is the name of the contact
if (fileType === ATTACHMENT_TYPES.CONTACT) return ContactBubble;
}
return TextBubble;
});
const shouldShowContextMenu = computed(() => {
return !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;
const isFailedOrProcessing =
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS;
return {
copy: hasText,
delete:
(hasText || hasAttachments) &&
!isFailedOrProcessing &&
!isMessageDeleted.value,
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
copyLink: !isFailedOrProcessing,
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
replyTo:
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
};
});
const shouldRenderMessage = computed(() => {
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
const isEmailContentType = props.contentType === CONTENT_TYPES.INCOMING_EMAIL;
const isUnsupported = props.contentAttributes?.isUnsupported;
const isAnIntegrationMessage =
props.contentType === CONTENT_TYPES.INTEGRATIONS;
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
const hasExternalError = !!props.contentAttributes?.externalError;
return (
hasAttachments ||
props.content ||
isEmailContentType ||
isUnsupported ||
isAnIntegrationMessage ||
isFailedMessage ||
hasExternalError
);
});
function openContextMenu(e) {
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
['a', 'img'].includes(e.target?.tagName.toLowerCase());
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);
}
const avatarInfo = computed(() => {
if (props.contentAttributes?.externalEcho) {
const { name, avatar_url, channel_type, medium } = inbox.value;
const iconName = avatar_url
? null
: getInboxIconByType(channel_type, medium);
return {
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
src: avatar_url || '',
iconName,
};
}
// If no sender, return bot info
if (!props.sender) {
return {
name: t('CONVERSATION.BOT'),
src: '',
};
}
const { sender } = props;
const { name, type, avatarUrl, thumbnail } = sender || {};
// If sender type is agent bot, use avatarUrl
if ([SENDER_TYPES.AGENT_BOT, SENDER_TYPES.CAPTAIN_ASSISTANT].includes(type)) {
return {
name: name ?? '',
src: avatarUrl ?? '',
};
}
// For all other senders, use thumbnail
return {
name: name ?? '',
src: thumbnail ?? '',
};
});
const avatarTooltip = computed(() => {
if (props.contentAttributes?.externalEcho) {
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
}
if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
});
const setupHighlightTimer = () => {
if (Number(route.query.messageId) !== Number(props.id)) {
return;
}
showBackgroundHighlight.value = true;
const HIGHLIGHT_TIMER = 1000;
useTimeoutFn(() => {
showBackgroundHighlight.value = false;
}, HIGHLIGHT_TIMER);
};
onMounted(setupHighlightTimer);
provideMessageContext({
...toRefs(props),
isPrivate: computed(() => props.private),
variant,
orientation,
isBotOrAgentMessage,
shouldGroupWithNext,
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex w-full mb-2 message-bubble-container"
:data-message-id="props.id"
:class="[
flexOrientationClass,
{
'group-with-next': shouldGroupWithNext,
'bg-n-alpha-1': showBackgroundHighlight,
},
]"
>
<div v-if="variant === MESSAGE_VARIANTS.ACTIVITY">
<ActivityBubble :content="content" />
</div>
<div
v-else
:class="[
gridClass,
{
'gap-y-2': contentAttributes.externalError,
'w-full': variant === MESSAGE_VARIANTS.EMAIL,
},
]"
class="gap-x-2"
:style="{
gridTemplateAreas: gridTemplate,
}"
>
<div
v-if="!shouldGroupWithNext && shouldShowAvatar"
v-tooltip.left-end="avatarTooltip"
class="[grid-area:avatar] flex items-end"
>
<Avatar v-bind="avatarInfo" :size="24" />
</div>
<div
class="[grid-area:bubble] flex"
:class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"
>
<Component :is="componentToRender" />
</div>
<MessageError
v-if="contentAttributes.externalError"
class="[grid-area:meta]"
:class="flexOrientationClass"
:error="contentAttributes.externalError"
@retry="emit('retry')"
/>
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<ContextMenu
v-if="isBubble"
:context-menu-position="contextMenuPosition"
:is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions"
:message="payloadForContextMenu"
hide-button
@open="openContextMenu"
@close="closeContextMenu"
@reply-to="handleReplyTo"
/>
</div>
</div>
</template>
<style lang="scss">
.group-with-next + .message-bubble-container {
.left-bubble {
@apply ltr:rounded-tl-sm rtl:rounded-tr-sm;
}
.right-bubble {
@apply ltr:rounded-tr-sm rtl:rounded-tl-sm;
}
}
</style>

View File

@@ -0,0 +1,59 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from './provider.js';
import { hasOneDayPassed } from 'shared/helpers/timeHelper';
import { ORIENTATION, MESSAGE_STATUS } from './constants';
defineProps({
error: { type: String, required: true },
});
const emit = defineEmits(['retry']);
const { orientation, status, createdAt, content, attachments } =
useMessageContext();
const { t } = useI18n();
const canRetry = computed(() => {
const hasContent = content.value !== null;
const hasAttachments = attachments.value && attachments.value.length > 0;
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
});
</script>
<template>
<div class="text-xs text-n-ruby-11 flex items-center gap-1.5">
<span>{{ t('CHAT_LIST.FAILED_TO_SEND') }}</span>
<div class="relative group">
<div
class="bg-n-alpha-2 rounded-md size-5 grid place-content-center cursor-pointer"
>
<Icon
icon="i-lucide-alert-triangle"
class="text-n-ruby-11 size-[14px]"
/>
</div>
<div
class="absolute bg-n-alpha-3 px-4 py-3 border rounded-xl border-n-strong text-n-slate-12 bottom-6 w-52 text-xs backdrop-blur-[100px] shadow-[0px_0px_24px_0px_rgba(0,0,0,0.12)] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all break-all"
:class="{
'ltr:left-0 rtl:right-0': orientation === ORIENTATION.LEFT,
'ltr:right-0 rtl:left-0': orientation === ORIENTATION.RIGHT,
}"
>
{{ error }}
</div>
</div>
<button
v-if="canRetry"
type="button"
:disabled="status !== MESSAGE_STATUS.FAILED"
class="bg-n-alpha-2 rounded-md size-5 grid place-content-center cursor-pointer"
@click="emit('retry')"
>
<Icon icon="i-lucide-refresh-ccw" class="text-n-ruby-11 size-[14px]" />
</button>
</div>
</template>

View File

@@ -0,0 +1,186 @@
<script setup>
import { defineProps, computed, reactive } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useMapGetter } from 'dashboard/composables/store.js';
import MessageApi from 'dashboard/api/inbox/message.js';
/**
* 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 [These are not in camelcase]
*/
const props = defineProps({
currentUserId: {
type: Number,
required: true,
},
firstUnreadId: {
type: Number,
default: null,
},
isAnEmailChannel: {
type: Boolean,
default: false,
},
inboxSupportsReplyTo: {
type: Object,
default: () => ({ incoming: false, outgoing: false }),
},
messages: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['retry']);
const allMessages = computed(() => {
return useCamelCase(props.messages, {
deep: true,
stopPaths: ['content_attributes.translations'],
});
});
const currentChat = useMapGetter('getSelectedChat');
// Cache for fetched reply messages to avoid duplicate API calls
const fetchedReplyMessages = reactive(new Map());
/**
* Fetches a specific message from the API by trying to get messages around it
* @param {number} messageId - The ID of the message to fetch
* @param {number} conversationId - The ID of the conversation
* @returns {Promise<Object|null>} - The fetched message or null if not found/error
*/
const fetchReplyMessage = async (messageId, conversationId) => {
// Return cached result if already fetched
if (fetchedReplyMessages.has(messageId)) {
return fetchedReplyMessages.get(messageId);
}
try {
const response = await MessageApi.getPreviousMessages({
conversationId,
before: messageId + 100,
after: messageId - 100,
});
const messages = response.data?.payload || [];
const targetMessage = messages.find(msg => msg.id === messageId);
if (targetMessage) {
const camelCaseMessage = useCamelCase(targetMessage);
fetchedReplyMessages.set(messageId, camelCaseMessage);
return camelCaseMessage;
}
// Cache null result to avoid repeated API calls
fetchedReplyMessages.set(messageId, null);
return null;
} catch (error) {
fetchedReplyMessages.set(messageId, null);
return null;
}
};
/**
* Determines if a message should be grouped with the next message
* @param {Number} index - Index of the current message
* @param {Array} searchList - Array of messages to check
* @returns {Boolean} - Whether the message should be grouped with next
*/
const shouldGroupWithNext = (index, searchList) => {
if (index === searchList.length - 1) return false;
const current = searchList[index];
const next = searchList[index + 1];
if (next.status === 'failed') return false;
const nextSenderId = next.senderId ?? next.sender?.id;
const currentSenderId = current.senderId ?? current.sender?.id;
const hasSameSender = nextSenderId === currentSenderId;
const nextMessageType = next.messageType;
const currentMessageType = current.messageType;
const areBothTemplates =
nextMessageType === MESSAGE_TYPES.TEMPLATE &&
currentMessageType === MESSAGE_TYPES.TEMPLATE;
if (!hasSameSender || areBothTemplates) return false;
if (currentMessageType !== nextMessageType) 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;
// Try to find in current messages first
let replyMessage = props.messages?.find(msg => msg.id === inReplyToMessageId);
// Then try store messages
if (!replyMessage && currentChat.value?.messages) {
replyMessage = currentChat.value.messages.find(
msg => msg.id === inReplyToMessageId
);
}
// Then check fetch cache
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
}
// If still not found and we have conversation context, fetch it
if (!replyMessage && currentChat.value?.id) {
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
return null; // Let UI handle loading state
}
return replyMessage ? useCamelCase(replyMessage) : null;
};
</script>
<template>
<ul class="px-4 bg-n-surface-1">
<slot name="beforeAll" />
<template v-for="(message, index) in allMessages" :key="message.id">
<slot
v-if="firstUnreadId && message.id === firstUnreadId"
name="unreadBadge"
/>
<Message
v-bind="message"
:is-email-inbox="isAnEmailChannel"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, allMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
@retry="emit('retry', message)"
/>
</template>
<slot name="after" />
</ul>
</template>

View File

@@ -0,0 +1,137 @@
<script setup>
import { computed } from 'vue';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import MessageStatus from './MessageStatus.vue';
import Icon from 'next/icon/Icon.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { useMessageContext } from './provider.js';
import { MESSAGE_STATUS, MESSAGE_TYPES } from './constants';
const {
isAFacebookInbox,
isALineChannel,
isAPIInbox,
isASmsInbox,
isATelegramChannel,
isATwilioChannel,
isAWebWidgetInbox,
isAWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
} = useInbox();
const {
status,
isPrivate,
createdAt,
sourceId,
messageType,
contentAttributes,
} = useMessageContext();
const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
);
const showStatusIndicator = computed(() => {
if (isPrivate.value) return false;
// Don't show status for failed messages, we already show error message
if (status.value === MESSAGE_STATUS.FAILED) return false;
// Don't show status for deleted messages
if (contentAttributes.value?.deleted) return false;
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
if (messageType.value === 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 !!sourceId.value;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
// All messages will be mark as sent for the Line channel, as there is no source ID.
if (isALineChannel.value) return true;
return false;
});
const isDelivered = computed(() => {
if (!showStatusIndicator.value) return false;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isASmsInbox.value ||
isAFacebookInbox.value ||
isATiktokChannel.value
) {
return sourceId.value && status.value === 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 status.value === MESSAGE_STATUS.SENT;
}
if (isALineChannel.value) {
return status.value === MESSAGE_STATUS.DELIVERED;
}
return false;
});
const isRead = computed(() => {
if (!showStatusIndicator.value) return false;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
if (isAWebWidgetInbox.value || isAPIInbox.value) {
return status.value === 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>
<template>
<div class="text-xs flex items-center gap-1.5">
<div class="inline">
<time class="inline">{{ readableTime }}</time>
</div>
<Icon v-if="isPrivate" icon="i-lucide-lock-keyhole" class="size-3" />
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
</div>
</template>
`

View File

@@ -0,0 +1,93 @@
<script setup>
import { computed, ref } from 'vue';
import { useIntervalFn } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { MESSAGE_STATUS } from './constants';
import Icon from 'next/icon/Icon.vue';
const { status } = defineProps({
status: {
type: String,
required: true,
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
});
const { t } = useI18n();
const progresIconSequence = [
'i-lucide-clock-1',
'i-lucide-clock-2',
'i-lucide-clock-3',
'i-lucide-clock-4',
'i-lucide-clock-5',
'i-lucide-clock-6',
'i-lucide-clock-7',
'i-lucide-clock-8',
'i-lucide-clock-9',
'i-lucide-clock-10',
'i-lucide-clock-11',
'i-lucide-clock-12',
];
const progessIcon = ref(progresIconSequence[0]);
const rotateIcon = () => {
const currentIndex = progresIconSequence.indexOf(progessIcon.value);
const nextIndex = (currentIndex + 1) % progresIconSequence.length;
progessIcon.value = progresIconSequence[nextIndex];
};
useIntervalFn(rotateIcon, 500, {
immediate: status === MESSAGE_STATUS.PROGRESS,
immediateCallback: false,
});
const statusIcon = computed(() => {
const statusIconMap = {
[MESSAGE_STATUS.SENT]: 'i-lucide-check',
[MESSAGE_STATUS.DELIVERED]: 'i-lucide-check-check',
[MESSAGE_STATUS.READ]: 'i-lucide-check-check',
};
return statusIconMap[status];
});
const statusColor = computed(() => {
const statusIconMap = {
[MESSAGE_STATUS.SENT]: 'text-n-slate-10',
[MESSAGE_STATUS.DELIVERED]: 'text-n-slate-10',
[MESSAGE_STATUS.READ]: 'text-[#7EB6FF]',
};
return statusIconMap[status];
});
const tooltipText = computed(() => {
const statusTextMap = {
[MESSAGE_STATUS.SENT]: t('CHAT_LIST.SENT'),
[MESSAGE_STATUS.DELIVERED]: t('CHAT_LIST.DELIVERED'),
[MESSAGE_STATUS.READ]: t('CHAT_LIST.MESSAGE_READ'),
[MESSAGE_STATUS.PROGRESS]: t('CHAT_LIST.SENDING'),
};
return statusTextMap[status];
});
</script>
<template>
<Icon
v-if="status === MESSAGE_STATUS.PROGRESS"
v-tooltip.top-start="tooltipText"
:icon="progessIcon"
class="text-n-slate-10"
/>
<Icon
v-else
v-tooltip.top-start="tooltipText"
:icon="statusIcon"
:class="statusColor"
class="size-[14px]"
/>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
showingOriginal: Boolean,
});
defineEmits(['toggle']);
</script>
<template>
<span>
<span
class="text-xs text-n-slate-11 cursor-pointer hover:underline select-none"
@click="$emit('toggle')"
>
{{
showingOriginal
? $t('CONVERSATION.VIEW_TRANSLATED')
: $t('CONVERSATION.VIEW_ORIGINAL')
}}
</span>
</span>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import { computed } from 'vue';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import BaseBubble from './Base.vue';
import { useMessageContext } from '../provider.js';
const { content, createdAt } = useMessageContext();
const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
);
</script>
<template>
<BaseBubble
v-tooltip.top="readableTime"
class="px-3 py-1 !rounded-xl flex min-w-0 items-center gap-2"
data-bubble-name="activity"
>
<span v-dompurify-html="content" :title="content" />
</BaseBubble>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import AudioChip from 'next/message/chips/Audio.vue';
import { useMessageContext } from '../provider.js';
const { attachments } = useMessageContext();
const attachment = computed(() => {
return attachments.value[0];
});
</script>
<template>
<BaseBubble class="bg-transparent" data-bubble-name="audio">
<AudioChip
:attachment="attachment"
class="p-2 text-n-slate-12 skip-context-menu"
/>
</BaseBubble>
</template>

View File

@@ -0,0 +1,129 @@
<script setup>
import { computed } from 'vue';
import MessageMeta from '../MessageMeta.vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
});
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
useMessageContext();
const { t } = useI18n();
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]:
'bg-n-solid-amber text-n-amber-12 [&_.prosemirror-mention-node]:font-semibold',
[MESSAGE_VARIANTS.USER]: 'bg-n-slate-4 text-n-slate-12',
[MESSAGE_VARIANTS.ACTIVITY]: 'bg-n-alpha-1 text-n-slate-11 text-sm',
[MESSAGE_VARIANTS.BOT]: 'bg-n-solid-iris text-n-slate-12',
[MESSAGE_VARIANTS.TEMPLATE]: 'bg-n-solid-iris text-n-slate-12',
[MESSAGE_VARIANTS.ERROR]: 'bg-n-ruby-4 text-n-ruby-12',
[MESSAGE_VARIANTS.EMAIL]: 'w-full',
[MESSAGE_VARIANTS.UNSUPPORTED]:
'bg-n-solid-amber/70 border border-dashed border-n-amber-12 text-n-amber-12',
};
const orientationMap = {
[ORIENTATION.LEFT]:
'left-bubble rounded-xl ltr:rounded-bl-sm rtl:rounded-br-sm',
[ORIENTATION.RIGHT]:
'right-bubble rounded-xl ltr:rounded-br-sm rtl:rounded-bl-sm',
[ORIENTATION.CENTER]: 'rounded-md',
};
const flexOrientationClass = computed(() => {
const map = {
[ORIENTATION.LEFT]: 'justify-start',
[ORIENTATION.RIGHT]: 'justify-end',
[ORIENTATION.CENTER]: 'justify-center',
};
return map[orientation.value];
});
const messageClass = computed(() => {
const classToApply = [varaintBaseMap[variant.value]];
if (variant.value !== MESSAGE_VARIANTS.ACTIVITY) {
classToApply.push(orientationMap[orientation.value]);
} else {
classToApply.push('rounded-lg');
}
return classToApply;
});
const scrollToMessage = () => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: inReplyTo.value.id,
});
};
const shouldShowMeta = computed(
() =>
!props.hideMeta &&
!shouldGroupWithNext.value &&
variant.value !== MESSAGE_VARIANTS.ACTIVITY
);
const replyToPreview = computed(() => {
if (!inReplyTo) return '';
const { content, attachments } = inReplyTo.value;
if (content) return new MessageFormatter(content).formattedMessage;
if (attachments?.length) {
const firstAttachment = attachments[0];
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
return t(`CHAT_LIST.ATTACHMENTS.${fileType}.CONTENT`);
}
return t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND');
});
</script>
<template>
<div
class="text-sm"
:class="[
messageClass,
{
'max-w-lg': variant !== MESSAGE_VARIANTS.EMAIL,
},
]"
>
<div
v-if="inReplyTo"
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
@click="scrollToMessage"
>
<div
v-dompurify-html="replyToPreview"
class="prose prose-bubble line-clamp-2"
/>
</div>
<slot />
<MessageMeta
v-if="shouldShowMeta"
:class="[
flexOrientationClass,
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
variant === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11',
]"
class="mt-2"
/>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import BaseBubble from './Base.vue';
import Icon from 'next/icon/Icon.vue';
import { useMessageContext } from '../provider.js';
defineProps({
icon: { type: [String, Object], required: true },
iconBgColor: { type: String, default: 'bg-n-alpha-3' },
senderTranslationKey: { type: String, required: true },
content: { type: String, required: true },
title: { type: String, default: '' }, // Title can be any name, description, etc
action: {
type: Object,
required: true,
validator: action => {
return action.label && (action.href || action.onClick);
},
},
});
const { sender } = useMessageContext();
const { t } = useI18n();
const senderName = computed(() => {
return sender?.value?.name || '';
});
</script>
<template>
<BaseBubble class="overflow-hidden p-3" data-bubble-name="attachment">
<div class="grid gap-4 min-w-64">
<div class="grid gap-3">
<div
class="size-8 rounded-lg grid place-content-center"
:class="iconBgColor"
>
<slot name="icon">
<Icon :icon="icon" class="text-white size-4" />
</slot>
</div>
<div class="space-y-1 overflow-hidden">
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
{{
t(senderTranslationKey, {
sender: senderName,
})
}}
</div>
<slot>
<div v-if="title" class="truncate text-sm text-n-slate-12">
{{ title }}
</div>
<div v-if="content" class="truncate text-sm text-n-slate-11">
{{ content }}
</div>
</slot>
</div>
</div>
<div v-if="action" class="mb-2">
<a
v-if="action.href"
:href="action.href"
rel="noreferrer noopener nofollow"
target="_blank"
class="w-full block bg-n-solid-3 px-4 py-2 rounded-lg text-sm text-center border border-n-container"
>
{{ action.label }}
</a>
<button
v-else
class="w-full bg-n-solid-3 px-4 py-2 rounded-lg text-sm text-center border border-n-container"
@click="action.onClick"
>
{{ action.label }}
</button>
</div>
</div>
</BaseBubble>
</template>

View File

@@ -0,0 +1,70 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n';
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import { useMessageContext } from '../provider.js';
const { contentAttributes, content } = useMessageContext();
const { t } = useI18n();
const response = computed(() => {
return contentAttributes.value?.submittedValues?.csatSurveyResponse ?? {};
});
const isRatingSubmitted = computed(() => {
return !!response.value.rating;
});
const displayType = computed(() => {
return contentAttributes.value?.displayType || CSAT_DISPLAY_TYPES.EMOJI;
});
const isStarRating = computed(() => {
return displayType.value === CSAT_DISPLAY_TYPES.STAR;
});
const rating = computed(() => {
if (isRatingSubmitted.value) {
return CSAT_RATINGS.find(
csatOption => csatOption.value === response.value.rating
);
}
return null;
});
const starRatingValue = computed(() => {
return response.value.rating || 0;
});
</script>
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<dl v-if="isRatingSubmitted" class="mt-4">
<dt class="text-n-slate-11 italic">
{{ t('CONVERSATION.RATING_TITLE') }}
</dt>
<dd v-if="!isStarRating">
{{ t(rating.translationKey) }}
</dd>
<dd v-else class="flex mt-1">
<span v-for="n in 5" :key="n" class="text-2xl mr-1">
<i
:class="[
n <= starRatingValue
? 'i-ri-star-fill text-n-amber-9'
: 'i-ri-star-line text-n-slate-10',
]"
/>
</span>
</dd>
<dt v-if="response.feedbackMessage" class="text-n-slate-11 italic mt-2">
{{ t('CONVERSATION.FEEDBACK_TITLE') }}
</dt>
<dd>{{ response.feedbackMessage }}</dd>
</dl>
</BaseBubble>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import { computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import BaseAttachmentBubble from './BaseAttachment.vue';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
const { attachments } = useMessageContext();
const $store = useStore();
const { t } = useI18n();
const attachment = computed(() => {
return attachments.value[0];
});
const phoneNumber = computed(() => {
return attachment.value.fallbackTitle;
});
const contactName = computed(() => {
const { meta } = attachment.value ?? {};
const { firstName, lastName } = meta ?? {};
return `${firstName ?? ''} ${lastName ?? ''}`.trim();
});
const formattedPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\s|-|[A-Za-z]/g, '');
});
const rawPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\D/g, '');
});
function getContactObject() {
const contactItem = {
name: contactName.value,
phone_number: `+${rawPhoneNumber.value}`,
};
return contactItem;
}
async function filterContactByNumber(searchCandidate) {
const query = {
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: [searchCandidate],
attribute_model: 'standard',
custom_attribute_type: '',
};
const queryPayload = { payload: [query] };
const contacts = await $store.dispatch('contacts/filter', {
queryPayload,
resetState: false,
});
return contacts.shift();
}
function openContactNewTab(contactId) {
const accountId = window.location.pathname.split('/')[3];
const url = `/app/accounts/${accountId}/contacts/${contactId}`;
window.open(url, '_blank');
}
async function addContact() {
try {
let contact = await filterContactByNumber(rawPhoneNumber);
if (!contact) {
contact = await $store.dispatch('contacts/create', getContactObject());
useAlert(t('CONTACT_FORM.SUCCESS_MESSAGE'));
}
openContactNewTab(contact.id);
} catch (error) {
if (error instanceof DuplicateContactException) {
if (error.data.includes('phone_number')) {
useAlert(t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
}
const action = computed(() => ({
label: t('CONVERSATION.SAVE_CONTACT'),
onClick: addContact,
}));
</script>
<template>
<BaseAttachmentBubble
icon="i-teenyicons-user-circle-solid"
icon-bg-color="bg-[#D6409F]"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.CONTACT"
:title="contactName"
:content="phoneNumber"
:action="formattedPhoneNumber ? action : null"
/>
</template>

View File

@@ -0,0 +1,102 @@
<script setup>
import { computed, ref } from 'vue';
import DyteAPI from 'dashboard/api/integrations/dyte';
import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import BaseAttachmentBubble from './BaseAttachment.vue';
const { content, sender, id } = useMessageContext();
const { t } = useI18n();
const isLoading = ref(false);
const dyteAuthToken = ref('');
const meetingLink = computed(() => {
return buildDyteURL(dyteAuthToken.value);
});
const joinTheCall = async () => {
isLoading.value = true;
try {
const { data: { token } = {} } = await DyteAPI.addParticipantToMeeting(
id.value
);
dyteAuthToken.value = token;
} catch (err) {
useAlert(t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));
} finally {
isLoading.value = false;
}
};
const leaveTheRoom = () => {
dyteAuthToken.value = '';
};
const action = computed(() => ({
label: t('INTEGRATION_SETTINGS.DYTE.CLICK_HERE_TO_JOIN'),
onClick: joinTheCall,
}));
</script>
<template>
<BaseAttachmentBubble
icon="i-ph-video-camera-fill"
icon-bg-color="bg-[#2781F6]"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.MEETING"
:action="action"
>
<div v-if="!sender" class="text-sm truncate text-n-slate-12">
<!-- Added as a fallback, where the sender is not available (Deleted) -->
<!-- Will show the content, if senderName in BaseAttachment.vue is empty -->
{{ content }}
</div>
<div v-if="dyteAuthToken" class="video-call--container">
<iframe
:src="meetingLink"
allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;"
/>
<button
class="px-4 py-2 text-sm rounded-lg bg-n-solid-3 mt-3"
@click="leaveTheRoom"
>
{{ $t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM') }}
</button>
</div>
<div v-else>
{{ '' }}
</div>
</BaseAttachmentBubble>
</template>
<style lang="scss">
.join-call-button {
margin: 0.5rem 0;
}
.video-call--container {
position: fixed;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
z-index: 1000;
padding: 0.25rem;
@apply bg-n-background;
iframe {
width: 100%;
height: 100%;
border: 0;
}
button {
position: absolute;
top: 0.25rem;
right: 10rem;
}
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_STATUS } from '../../constants';
import { useMessageContext } from '../../provider.js';
const { contentAttributes, status, sender } = useMessageContext();
const hasError = computed(() => {
return status.value === MESSAGE_STATUS.FAILED;
});
const fromEmail = computed(() => {
return contentAttributes.value?.email?.from ?? [];
});
const toEmail = computed(() => {
const { toEmails, email } = contentAttributes.value;
return email?.to ?? toEmails ?? [];
});
const ccEmail = computed(() => {
return (
contentAttributes.value?.ccEmails ??
contentAttributes.value?.email?.cc ??
[]
);
});
const senderName = computed(() => {
const fromEmailAddress = fromEmail.value[0] ?? '';
const senderEmail = sender.value.email ?? '';
if (!fromEmailAddress && !senderEmail) return null;
// if the sender of the conversation and the sender of this particular
// email are the same, only then we return the sender name
if (fromEmailAddress === senderEmail) {
return sender.value.name;
}
return null;
});
const bccEmail = computed(() => {
return (
contentAttributes.value?.bccEmails ??
contentAttributes.value?.email?.bcc ??
[]
);
});
const subject = computed(() => {
return contentAttributes.value?.email?.subject ?? '';
});
const showMeta = computed(() => {
return (
fromEmail.value[0] ||
toEmail.value.length ||
ccEmail.value.length ||
bccEmail.value.length ||
subject.value
);
});
</script>
<template>
<section
v-show="showMeta"
class="space-y-1 rtl:pl-9 ltr:pr-9 text-sm break-words"
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
<template v-if="showMeta">
<div
v-if="fromEmail[0]"
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'"
>
<template v-if="senderName">
<span>
{{ senderName }}
</span>
&lt;{{ fromEmail[0] }}&gt;
</template>
<template v-else>
{{ fromEmail[0] }}
</template>
</div>
<div v-if="toEmail.length">
{{ $t('EMAIL_HEADER.TO') }}: {{ toEmail.join(', ') }}
</div>
<div v-if="ccEmail.length">
{{ $t('EMAIL_HEADER.CC') }}:
{{ ccEmail.join(', ') }}
</div>
<div v-if="bccEmail.length">
{{ $t('EMAIL_HEADER.BCC') }}:
{{ bccEmail.join(', ') }}
</div>
<div v-if="subject">
{{ $t('EMAIL_HEADER.SUBJECT') }}:
{{ subject }}
</div>
</template>
</section>
</template>

View File

@@ -0,0 +1,229 @@
<script setup>
import { computed, useTemplateRef, ref, onMounted } from 'vue';
import { Letter } from 'vue-letter';
import { sanitizeTextForRender } from '@chatwoot/utils';
import { allowedCssProperties } from 'lettersanitizer';
import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from 'dashboard/helper/emailQuoteExtractor.js';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { useMessageContext } from '../../provider.js';
import { MESSAGE_TYPES } from 'next/message/constants.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, contentAttributes, attachments, messageType } =
useMessageContext();
const isExpandable = ref(false);
const isExpanded = ref(false);
const showQuotedMessage = ref(false);
const renderOriginal = ref(false);
const contentContainer = useTemplateRef('contentContainer');
onMounted(() => {
isExpandable.value = contentContainer.value?.scrollHeight > 400;
});
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isIncoming = computed(() => !isOutgoing.value);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
const originalEmailText = computed(() => {
const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value;
return sanitizeTextForRender(text);
});
const originalEmailHtml = computed(
() =>
contentAttributes?.value?.email?.htmlContent?.full ||
originalEmailText.value
);
const hasEmailContent = computed(() => {
return (
contentAttributes?.value?.email?.textContent?.full ||
contentAttributes?.value?.email?.htmlContent?.full
);
});
const messageContent = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original content
return content.value;
});
const textToShow = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original text
return originalEmailText.value;
});
const fullHTML = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original HTML
return originalEmailHtml.value;
});
const unquotedHTML = computed(() =>
EmailQuoteExtractor.extractQuotes(fullHTML.value)
);
const hasQuotedMessage = computed(() =>
EmailQuoteExtractor.hasQuotes(fullHTML.value)
);
// Ensure unique keys for <Letter> when toggling between original and translated views.
// This forces Vue to re-render the component and update content correctly.
const translationKeySuffix = computed(() => {
if (renderOriginal.value) return 'original';
if (hasTranslations.value) return 'translated';
return 'original';
});
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
</script>
<template>
<BaseBubble
class="w-full"
:class="{
'bg-n-slate-4': isIncoming,
'bg-n-solid-blue': isOutgoing,
}"
data-bubble-name="email"
>
<EmailMeta
class="p-3"
:class="{
'border-b border-n-strong': isIncoming,
'border-b border-n-slate-8/20': isOutgoing,
}"
/>
<section ref="contentContainer" class="p-3">
<div
:class="{
'max-h-[400px] overflow-hidden relative': !isExpanded && isExpandable,
'overflow-y-scroll relative': isExpanded,
}"
>
<div
v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end"
:class="{
'bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent':
isIncoming,
'bg-gradient-to-t from-n-solid-blue via-n-solid-blue via-20% to-transparent':
isOutgoing,
}"
>
<button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
@click="isExpanded = true"
>
<Icon icon="i-lucide-maximize-2" />
{{ $t('EMAIL_HEADER.EXPAND') }}
</button>
</div>
<FormattedContent
v-if="isOutgoing && content && !hasEmailContent"
class="text-n-slate-12"
:content="messageContent"
/>
<template v-else>
<Letter
v-if="showQuotedMessage"
:key="`letter-quoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:html="fullHTML"
:text="textToShow"
/>
<Letter
v-else
:key="`letter-unquoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render"
:html="unquotedHTML"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:text="textToShow"
/>
</template>
<button
v-if="hasQuotedMessage"
class="text-n-slate-11 px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
@click="showQuotedMessage = !showQuotedMessage"
>
<template v-if="showQuotedMessage">
{{ $t('CHAT_LIST.HIDE_QUOTED_TEXT') }}
</template>
<template v-else>
{{ $t('CHAT_LIST.SHOW_QUOTED_TEXT') }}
</template>
<Icon
:icon="
showQuotedMessage
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
/>
</button>
</div>
</section>
<TranslationToggle
v-if="hasTranslations"
class="py-2 px-3"
:showing-original="renderOriginal"
@toggle="handleSeeOriginal"
/>
<section
v-if="Array.isArray(attachments) && attachments.length"
class="px-4 pb-4 space-y-2"
>
<AttachmentChips :attachments="attachments" class="gap-1" />
</section>
</BaseBubble>
</template>
<style lang="scss">
// Tailwind resets break the rendering of google drive link in Gmail messages
// This fixes it using https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors
.letter-render [class*='gmail_drive_chip'] {
box-sizing: initial;
@apply bg-n-slate-4 border-n-slate-6 rounded-md !important;
a {
@apply text-n-slate-12 !important;
img {
display: inline-block;
}
}
}
</style>

View File

@@ -0,0 +1,30 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
const { attachments } = useMessageContext();
const { t } = useI18n();
const attachment = computed(() => {
return attachments.value[0];
});
</script>
<template>
<BaseBubble class="overflow-hidden p-3" data-bubble-name="embed">
<div
class="w-full max-w-[360px] sm:max-w-[420px] min-h-[520px] h-[70vh] max-h-[680px]"
>
<iframe
class="w-full h-full border-0 rounded-lg"
:title="t('CHAT_LIST.ATTACHMENTS.embed.CONTENT')"
:src="attachment.dataUrl"
loading="lazy"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
/>
</div>
</BaseBubble>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import BaseAttachmentBubble from './BaseAttachment.vue';
import FileIcon from 'next/icon/FileIcon.vue';
const { attachments } = useMessageContext();
const { t } = useI18n();
const url = computed(() => {
return attachments.value[0].dataUrl;
});
const fileName = computed(() => {
if (url.value) {
const filename = url.value.substring(url.value.lastIndexOf('/') + 1);
return filename || t('CONVERSATION.UNKNOWN_FILE_TYPE');
}
return t('CONVERSATION.UNKNOWN_FILE_TYPE');
});
const fileType = computed(() => {
return fileName.value.split('.').pop();
});
</script>
<template>
<BaseAttachmentBubble
icon="i-teenyicons-user-circle-solid"
icon-bg-color="bg-n-alpha-3 dark:bg-n-alpha-white"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.FILE"
:content="decodeURI(fileName)"
:action="{
href: url,
label: $t('CONVERSATION.DOWNLOAD'),
}"
>
<template #icon>
<FileIcon :file-type="fileType" class="size-4" />
</template>
</BaseAttachmentBubble>
</template>

View File

@@ -0,0 +1,65 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n';
import { CONTENT_TYPES } from '../constants.js';
import { useMessageContext } from '../provider.js';
import { useInbox } from 'dashboard/composables/useInbox';
const { content, contentAttributes, contentType } = useMessageContext();
const { t } = useI18n();
const { isAWebWidgetInbox } = useInbox();
const formValues = computed(() => {
if (contentType.value === CONTENT_TYPES.FORM) {
const { items, submittedValues = [] } = contentAttributes.value;
if (submittedValues.length) {
return submittedValues.map(submittedValue => {
const item = items.find(
formItem => formItem.name === submittedValue.name
);
return {
title: submittedValue.value,
value: submittedValue.value,
label: item?.label,
};
});
}
return [];
}
if (contentType.value === CONTENT_TYPES.INPUT_SELECT) {
const [item] = contentAttributes.value?.submittedValues ?? [];
if (!item) return [];
return [
{
title: item.title,
value: item.value,
label: '',
},
];
}
return [];
});
</script>
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<span v-dompurify-html="content" :title="content" />
<dl v-if="formValues.length" class="mt-4">
<template v-for="item in formValues" :key="item.title">
<dt class="text-n-slate-11 italic mt-2">
{{ item.label || t('CONVERSATION.RESPONSE') }}
</dt>
<dd>{{ item.title }}</dd>
</template>
</dl>
<div v-else-if="isAWebWidgetInbox" class="my-2 font-medium">
{{ t('CONVERSATION.NO_RESPONSE') }}
</div>
</BaseBubble>
</template>

View File

@@ -0,0 +1,96 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import BaseBubble from './Base.vue';
import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js';
import { downloadFile } from '@chatwoot/utils';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
const { t } = useI18n();
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
const attachment = computed(() => {
return attachments.value[0];
});
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry();
const showGallery = ref(false);
const isDownloading = ref(false);
onMounted(() => {
if (attachment.value?.dataUrl) {
loadWithRetry(attachment.value.dataUrl);
}
});
const downloadAttachment = async () => {
const { fileType, dataUrl, extension } = attachment.value;
try {
isDownloading.value = true;
await downloadFile({ url: dataUrl, type: fileType, extension });
} catch (error) {
useAlert(t('GALLERY_VIEW.ERROR_DOWNLOADING'));
} finally {
isDownloading.value = false;
}
};
const handleImageError = () => {
hasError.value = true;
};
</script>
<template>
<BaseBubble
class="overflow-hidden p-3"
data-bubble-name="image"
@click="showGallery = true"
>
<div v-if="hasError" class="flex items-center gap-1 text-center rounded-lg">
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
<p class="mb-0 text-n-slate-11">
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
</p>
</div>
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
<img
class="skip-context-menu"
:src="attachment.dataUrl"
:width="attachment.width"
:height="attachment.height"
/>
<div
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
/>
<div class="absolute right-2 bottom-2 hidden group-hover:flex gap-2">
<Button xs solid slate icon="i-lucide-expand" class="opacity-60" />
<Button
xs
solid
slate
icon="i-lucide-download"
class="opacity-60"
:is-loading="isDownloading"
:disabled="isDownloading"
@click.stop="downloadAttachment"
/>
</div>
</div>
</BaseBubble>
<GalleryView
v-if="showGallery"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@error="handleImageError"
@close="() => (showGallery = false)"
/>
</template>

View File

@@ -0,0 +1,75 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import Icon from 'next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS, ATTACHMENT_TYPES } from '../constants';
const emit = defineEmits(['error']);
const { t } = useI18n();
const { variant, content, contentAttributes, attachments } =
useMessageContext();
const attachment = computed(() => {
return attachments.value[0];
});
const isStoryReply = computed(() => {
return contentAttributes.value?.imageType === ATTACHMENT_TYPES.IG_STORY_REPLY;
});
const hasImgStoryError = ref(false);
const hasVideoStoryError = ref(false);
const formattedContent = computed(() => {
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
return content.value;
}
return new MessageFormatter(content.value).formattedMessage;
});
const onImageLoadError = () => {
hasImgStoryError.value = true;
emit('error');
};
const onVideoLoadError = () => {
hasVideoStoryError.value = true;
emit('error');
};
</script>
<template>
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
<p v-if="isStoryReply" class="mb-1 text-xs text-n-slate-11">
{{ t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_REPLY') }}
</p>
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
<img
v-if="!hasImgStoryError"
class="rounded-lg max-w-80 skip-context-menu"
:src="attachment.dataUrl"
@error="onImageLoadError"
/>
<video
v-else-if="!hasVideoStoryError"
class="rounded-lg max-w-80 skip-context-menu"
controls
:src="attachment.dataUrl"
@error="onVideoLoadError"
/>
<div
v-else
class="flex items-center gap-1 px-5 py-4 text-center rounded-lg bg-n-alpha-1"
>
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
<p class="mb-0 text-n-slate-11">
{{ $t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_UNAVAILABLE') }}
</p>
</div>
</BaseBubble>
</template>

View File

@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import BaseAttachmentBubble from './BaseAttachment.vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
const { attachments } = useMessageContext();
const { t } = useI18n();
const attachment = computed(() => {
return attachments.value[0];
});
const lat = computed(() => {
return attachment.value.coordinatesLat;
});
const long = computed(() => {
return attachment.value.coordinatesLong;
});
const title = computed(() => {
return attachment.value.fallbackTitle ?? attachment.value.fallback_title;
});
const mapUrl = computed(
() => `https://maps.google.com/?q=${lat.value},${long.value}`
);
</script>
<template>
<BaseAttachmentBubble
icon="i-ph-navigation-arrow-fill"
icon-bg-color="bg-[#0D9B8A]"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.LOCATION"
:content="title"
:action="{
label: t('COMPONENTS.LOCATION_BUBBLE.SEE_ON_MAP'),
href: mapUrl,
}"
/>
</template>

View File

@@ -0,0 +1,28 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
buttonText: {
type: String,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div class="flex gap-2">
<Button :label="buttonText" slate class="!text-n-blue-11 w-full" />
</div>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
<div class="p-3 bg-n-alpha-2 rounded-xl">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
</div>
</div>
</template>

View File

@@ -0,0 +1,32 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="px-3 py-2.5">
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<div class="pt-2.5 flex flex-col gap-2">
<h6 class="font-semibold">{{ message.title }}</h6>
<span
v-dompurify-html="message.content"
class="prose prose-bubble text-sm"
/>
</div>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Call us to order" link class="hover:!no-underline" />
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Visit our store" link class="hover:!no-underline" />
</div>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="See options" link class="hover:!no-underline" />
</div>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
>
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
</template>

View File

@@ -0,0 +1,68 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="No, that will be all" link class="hover:!no-underline">
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
<div class="p-3 flex items-center justify-center">
<Button
label="I want to talk to an agents"
link
class="hover:!no-underline"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
<span v-dompurify-html="message.content" class="prose prose-bubble" />
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup>
import { computed } from 'vue';
import { useMessageContext } from '../../provider.js';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS } from '../../constants';
const props = defineProps({
content: {
type: String,
required: true,
},
});
const { variant } = useMessageContext();
const formattedContent = computed(() => {
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
return props.content;
}
return new MessageFormatter(props.content).formattedMessage;
});
</script>
<template>
<span v-dompurify-html="formattedContent" class="prose prose-bubble" />
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { computed, ref } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { MESSAGE_TYPES } from '../../constants';
import { useMessageContext } from '../../provider.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, attachments, contentAttributes, messageType } =
useMessageContext();
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
const renderOriginal = ref(false);
const renderContent = computed(() => {
if (renderOriginal.value) {
return content.value;
}
if (hasTranslations.value) {
return translationContent.value;
}
return content.value;
});
const isTemplate = computed(() => {
return messageType.value === MESSAGE_TYPES.TEMPLATE;
});
const isEmpty = computed(() => {
return !content.value && !attachments.value?.length;
});
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
</script>
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="text">
<div class="gap-3 flex flex-col">
<span v-if="isEmpty" class="text-n-slate-11">
{{ $t('CONVERSATION.NO_CONTENT') }}
</span>
<FormattedContent v-if="renderContent" :content="renderContent" />
<TranslationToggle
v-if="hasTranslations"
class="-mt-3"
:showing-original="renderOriginal"
@toggle="handleSeeOriginal"
/>
<AttachmentChips :attachments="attachments" class="gap-2" />
<template v-if="isTemplate">
<div
v-if="contentAttributes.submittedEmail"
class="px-2 py-1 rounded-lg bg-n-alpha-3"
>
{{ contentAttributes.submittedEmail }}
</div>
</template>
</div>
</BaseBubble>
</template>
<style>
p:last-child {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,27 @@
<script setup>
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
import { useInbox } from 'dashboard/composables/useInbox';
import BaseBubble from './Base.vue';
const { inboxId } = useMessageContext();
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
inboxId.value
);
const unsupportedMessageKey = computed(() => {
if (isAFacebookInbox.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK';
if (isAnInstagramChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
});
</script>
<template>
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
{{ $t(unsupportedMessageKey) }}
</BaseBubble>
</template>

View File

@@ -0,0 +1,63 @@
<script setup>
import { ref, computed } from 'vue';
import BaseBubble from './Base.vue';
import Icon from 'next/icon/Icon.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
import { ATTACHMENT_TYPES } from '../constants';
const emit = defineEmits(['error']);
const hasError = ref(false);
const showGallery = ref(false);
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
const handleError = () => {
hasError.value = true;
emit('error');
};
const attachment = computed(() => {
return attachments.value[0];
});
const isReel = computed(() => {
return attachment.value.fileType === ATTACHMENT_TYPES.IG_REEL;
});
</script>
<template>
<BaseBubble
class="overflow-hidden p-3"
data-bubble-name="video"
@click="showGallery = true"
>
<div class="relative group rounded-lg overflow-hidden">
<div
v-if="isReel"
class="absolute p-2 flex items-start justify-end right-0 pointer-events-none"
>
<Icon icon="i-lucide-instagram" class="text-white shadow-lg" />
</div>
<video
controls
class="rounded-lg skip-context-menu"
:src="attachment.dataUrl"
:class="{
'max-w-48': isReel,
'max-w-full': !isReel,
}"
@click.stop
@error="handleError"
/>
</div>
</BaseBubble>
<GalleryView
v-if="showGallery"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@error="onError"
@close="() => (showGallery = false)"
/>
</template>

View File

@@ -0,0 +1,104 @@
<script setup>
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { contentAttributes, messageType } = useMessageContext();
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
</script>
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div class="flex overflow-hidden flex-col w-full max-w-xs">
<div class="flex gap-3 items-center p-3 w-full">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
:class="bgColor"
>
<Icon
class="size-5"
:icon="iconName"
:class="{
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
}"
/>
</div>
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
</span>
</div>
</div>
</div>
</BaseBubble>
</template>

View File

@@ -0,0 +1,106 @@
<script setup>
import { computed, defineOptions, useAttrs } from 'vue';
import ImageChip from 'next/message/chips/Image.vue';
import VideoChip from 'next/message/chips/Video.vue';
import AudioChip from 'next/message/chips/Audio.vue';
import FileChip from 'next/message/chips/File.vue';
import { useMessageContext } from '../provider.js';
import { ATTACHMENT_TYPES } from '../constants';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
const props = defineProps({
attachments: {
type: Array,
default: () => [],
},
});
defineOptions({
inheritAttrs: false,
});
const attrs = useAttrs();
const { orientation } = useMessageContext();
const classToApply = computed(() => {
const baseClasses = [attrs.class, 'flex', 'flex-wrap'];
if (orientation.value === 'right') {
baseClasses.push('justify-end');
}
return baseClasses;
});
const allAttachments = computed(() => {
return Array.isArray(props.attachments) ? props.attachments : [];
});
const mediaAttachments = computed(() => {
const allowedTypes = [ATTACHMENT_TYPES.IMAGE, ATTACHMENT_TYPES.VIDEO];
const mediaTypes = allAttachments.value.filter(attachment =>
allowedTypes.includes(attachment.fileType)
);
return mediaTypes.sort(
(a, b) =>
allowedTypes.indexOf(a.fileType) - allowedTypes.indexOf(b.fileType)
);
});
const recordings = computed(() => {
return allAttachments.value.filter(
attachment => attachment.fileType === ATTACHMENT_TYPES.AUDIO
);
});
const files = computed(() => {
return allAttachments.value.filter(
attachment => attachment.fileType === ATTACHMENT_TYPES.FILE
);
});
</script>
<template>
<div v-if="mediaAttachments.length" :class="classToApply">
<template v-for="attachment in mediaAttachments" :key="attachment.id">
<ImageChip
v-if="attachment.fileType === ATTACHMENT_TYPES.IMAGE"
:attachment="attachment"
/>
<VideoChip
v-else-if="attachment.fileType === ATTACHMENT_TYPES.VIDEO"
:attachment="attachment"
/>
</template>
</div>
<div v-if="recordings.length" :class="classToApply">
<div v-for="attachment in recordings" :key="attachment.id">
<AudioChip
class="bg-n-alpha-3 dark:bg-n-alpha-2 text-n-slate-12"
:attachment="attachment"
/>
</div>
</div>
<div v-if="files.length" :class="classToApply">
<FileChip
v-for="attachment in files"
:key="attachment.id"
:attachment="attachment"
/>
</div>
</template>

View File

@@ -0,0 +1,195 @@
<script setup>
import {
computed,
onMounted,
useTemplateRef,
ref,
getCurrentInstance,
} from 'vue';
import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
import { downloadFile } from '@chatwoot/utils';
import { useEmitter } from 'dashboard/composables/emitter';
import { emitter } from 'shared/helpers/mitt';
const { attachment } = defineProps({
attachment: {
type: Object,
required: true,
},
showTranscribedText: {
type: Boolean,
default: true,
},
});
defineOptions({
inheritAttrs: false,
});
const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl);
});
const audioPlayer = useTemplateRef('audioPlayer');
const isPlaying = ref(false);
const isMuted = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const playbackSpeed = ref(1);
const { uid } = getCurrentInstance();
const onLoadedMetadata = () => {
duration.value = audioPlayer.value?.duration;
};
const playbackSpeedLabel = computed(() => {
return `${playbackSpeed.value}x`;
});
// There maybe a chance that the audioPlayer ref is not available
// When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted
onMounted(() => {
duration.value = audioPlayer.value?.duration;
audioPlayer.value.playbackRate = playbackSpeed.value;
});
// Listen for global audio play events and pause if it's not this audio
useEmitter('pause_playing_audio', currentPlayingId => {
if (currentPlayingId !== uid && isPlaying.value) {
try {
audioPlayer.value.pause();
} catch {
/* ignore pause errors */
}
isPlaying.value = false;
}
});
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const toggleMute = () => {
audioPlayer.value.muted = !audioPlayer.value.muted;
isMuted.value = audioPlayer.value.muted;
};
const onTimeUpdate = () => {
currentTime.value = audioPlayer.value?.currentTime;
};
const seek = event => {
const time = Number(event.target.value);
audioPlayer.value.currentTime = time;
currentTime.value = time;
};
const playOrPause = () => {
if (isPlaying.value) {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
// Emit event to pause all other audio
emitter.emit('pause_playing_audio', uid);
audioPlayer.value.play();
isPlaying.value = true;
}
};
const onEnd = () => {
isPlaying.value = false;
currentTime.value = 0;
playbackSpeed.value = 1;
audioPlayer.value.playbackRate = 1;
};
const changePlaybackSpeed = () => {
const speeds = [1, 1.5, 2];
const currentIndex = speeds.indexOf(playbackSpeed.value);
const nextIndex = (currentIndex + 1) % speeds.length;
playbackSpeed.value = speeds[nextIndex];
audioPlayer.value.playbackRate = playbackSpeed.value;
};
const downloadAudio = async () => {
const { fileType, dataUrl, extension } = attachment;
downloadFile({ url: dataUrl, type: fileType, extension });
};
</script>
<template>
<audio
ref="audioPlayer"
controls
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="timeStampURL" />
</audio>
<div
v-bind="$attrs"
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
>
<div class="flex gap-1 w-full flex-1 items-center justify-start">
<button class="p-0 border-0 size-8" @click="playOrPause">
<Icon
v-if="isPlaying"
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div>
<div
v-if="attachment.transcribedText && showTranscribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { getFileInfo } from '@chatwoot/utils';
import FileIcon from 'next/icon/FileIcon.vue';
import Icon from 'next/icon/Icon.vue';
const { attachment } = defineProps({
attachment: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const fileDetails = computed(() => {
return getFileInfo(attachment?.dataUrl || '');
});
const displayFileName = computed(() => {
const { base, type } = fileDetails.value;
const truncatedName = (str, maxLength, hasExt) =>
str.length > maxLength
? `${str.substring(0, maxLength).trimEnd()}${hasExt ? '..' : '...'}`
: str;
return type
? `${truncatedName(base, 12, true)}.${type}`
: truncatedName(base, 14, false);
});
const textColorClass = computed(() => {
const colorMap = {
'7z': 'dark:text-[#EDEEF0] text-[#2F265F]',
csv: 'text-n-amber-12',
doc: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
json: 'text-n-slate-12',
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
pdf: 'text-n-slate-12',
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
rtf: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
tar: 'dark:text-[#EDEEF0] text-[#2F265F]',
txt: 'text-n-slate-12',
xls: 'text-n-teal-12',
xlsx: 'text-n-teal-12',
zip: 'dark:text-[#EDEEF0] text-[#2F265F]',
};
return colorMap[fileDetails.value.type] || 'text-n-slate-12';
});
</script>
<template>
<div
class="h-9 bg-n-alpha-white gap-2 overflow-hidden items-center flex px-2 rounded-lg border border-n-container"
>
<FileIcon class="flex-shrink-0" :file-type="fileDetails.type" />
<span
class="flex-1 min-w-0 text-sm max-w-36"
:title="fileDetails.name"
:class="textColorClass"
>
{{ displayFileName }}
</span>
<a
v-tooltip="t('CONVERSATION.DOWNLOAD')"
class="flex-shrink-0 size-9 grid place-content-center cursor-pointer text-n-slate-11 hover:text-n-slate-12 transition-colors"
:href="attachment.dataUrl"
rel="noreferrer noopener nofollow"
target="_blank"
>
<Icon icon="i-lucide-download" />
</a>
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script setup>
import { ref } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
defineProps({
attachment: {
type: Object,
required: true,
},
});
const hasError = ref(false);
const showGallery = ref(false);
const { filteredCurrentChatAttachments } = useMessageContext();
const handleError = () => {
hasError.value = true;
};
</script>
<template>
<div
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer"
@click="showGallery = true"
>
<div
v-if="hasError"
class="flex flex-col items-center justify-center gap-1 text-xs text-center rounded-lg size-full bg-n-alpha-1 text-n-slate-11"
>
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
{{ $t('COMPONENTS.MEDIA.LOADING_FAILED') }}
</div>
<img
v-else
class="object-cover w-full h-full skip-context-menu"
:src="attachment.dataUrl"
@error="handleError"
/>
</div>
<GalleryView
v-if="showGallery"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@error="handleError"
@close="() => (showGallery = false)"
/>
</template>

View File

@@ -0,0 +1,52 @@
<script setup>
import { ref } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
defineProps({
attachment: {
type: Object,
required: true,
},
});
const showGallery = ref(false);
const { filteredCurrentChatAttachments } = useMessageContext();
</script>
<template>
<div
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer relative group"
@click="showGallery = true"
>
<video
:src="attachment.dataUrl"
class="w-full h-full object-cover"
muted
playsInline
/>
<div
class="absolute w-full h-full inset-0 p-1 flex items-center justify-center"
>
<div
class="size-7 bg-n-slate-1/60 backdrop-blur-sm rounded-full overflow-hidden shadow-[0_5px_15px_rgba(0,0,0,0.4)]"
>
<Icon
icon="i-teenyicons-play-small-solid"
class="size-7 text-n-slate-12/80 backdrop-blur"
/>
</div>
</div>
</div>
<GalleryView
v-if="showGallery"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@error="onError"
@close="() => (showGallery = false)"
/>
</template>

View File

@@ -0,0 +1,92 @@
export const MESSAGE_TYPES = {
INCOMING: 0,
OUTGOING: 1,
ACTIVITY: 2,
TEMPLATE: 3,
};
export const MESSAGE_VARIANTS = {
USER: 'user',
AGENT: 'agent',
ACTIVITY: 'activity',
PRIVATE: 'private',
BOT: 'bot',
ERROR: 'error',
TEMPLATE: 'template',
EMAIL: 'email',
UNSUPPORTED: 'unsupported',
};
export const SENDER_TYPES = {
CONTACT: 'Contact',
USER: 'User',
AGENT_BOT: 'agent_bot',
CAPTAIN_ASSISTANT: 'captain_assistant',
};
export const ORIENTATION = {
LEFT: 'left',
RIGHT: 'right',
CENTER: 'center',
};
export const MESSAGE_STATUS = {
SENT: 'sent',
DELIVERED: 'delivered',
READ: 'read',
FAILED: 'failed',
PROGRESS: 'progress',
};
export const ATTACHMENT_TYPES = {
IMAGE: 'image',
AUDIO: 'audio',
VIDEO: 'video',
FILE: 'file',
LOCATION: 'location',
FALLBACK: 'fallback',
SHARE: 'share',
STORY_MENTION: 'story_mention',
CONTACT: 'contact',
IG_REEL: 'ig_reel',
EMBED: 'embed',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
IG_STORY_REPLY: 'ig_story_reply',
};
export const CONTENT_TYPES = {
TEXT: 'text',
INPUT_TEXT: 'input_text',
INPUT_TEXTAREA: 'input_textarea',
INPUT_EMAIL: 'input_email',
INPUT_SELECT: 'input_select',
CARDS: 'cards',
FORM: 'form',
ARTICLE: 'article',
INCOMING_EMAIL: 'incoming_email',
INPUT_CSAT: 'input_csat',
INTEGRATIONS: 'integrations',
STICKER: 'sticker',
VOICE_CALL: 'voice_call',
};
export const MEDIA_TYPES = [
ATTACHMENT_TYPES.IMAGE,
ATTACHMENT_TYPES.VIDEO,
ATTACHMENT_TYPES.AUDIO,
ATTACHMENT_TYPES.IG_REEL,
];
export const VOICE_CALL_STATUS = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
COMPLETED: 'completed',
NO_ANSWER: 'no-answer',
FAILED: 'failed',
};
export const VOICE_CALL_DIRECTION = {
INBOUND: 'inbound',
OUTBOUND: 'outbound',
};

View File

@@ -0,0 +1,382 @@
import camelcaseKeys from 'camelcase-keys';
export default camelcaseKeys(
[
{
id: 60913,
content:
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
inbox_id: 992,
conversation_id: 134,
message_type: 0,
content_type: 'incoming_email',
status: 'sent',
content_attributes: {
email: {
bcc: null,
cc: null,
content_type:
'multipart/mixed; boundary=00000000000098e88e0628704c8b',
date: '2024-12-04T17:13:53+05:30',
from: ['alex@paperlayer.test'],
html_content: {
full: '<div dir="ltr"><p>Dear Sam,</p><p>We are looking for high-quality cotton fabric for our T-shirt production. Please find attached a document with our specifications and requirements. Could you provide us with a quotation and lead time?</p><p>Looking forward to your response.</p><p>Best regards,<br>Alex<br>T-Shirt Co.</p></div>\n',
reply:
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production. Please find attached a document with our specifications and requirements. Could you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
quoted:
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production. Please find attached a document with our specifications and requirements. Could you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
},
in_reply_to: null,
message_id:
'CAM_Qp+-tdJ2Muy4XZmQfYKOPzsFwrH5H=6j=snsFZEDw@mail.gmail.com',
multipart: true,
number_of_attachments: 2,
subject: 'Inquiry and Quotation for Cotton Fabric',
text_content: {
full: 'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.\n',
reply:
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
quoted:
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
},
to: ['sam@cottonmart.test'],
},
cc_email: null,
bcc_email: null,
},
created_at: 1733312661,
private: false,
source_id: 'CAM_Qp+-tdJ2Muy4XZmQfYKOPzsFwrH5H=6j=snsFZEDw@mail.gmail.com',
sender: {
additional_attributes: {
source_id: 'email:CAM_Qp+8beyon41DA@mail.gmail.com',
},
custom_attributes: {},
email: 'alex@paperlayer.test',
id: 111256,
identifier: null,
name: 'Alex',
phone_number: null,
thumbnail: '',
type: 'contact',
},
attachments: [
{
id: 826,
message_id: 60913,
file_type: 'file',
account_id: 51,
extension: null,
data_url:
'https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdFdKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--10170e22f42401a9259e17eba6e59877127353d0/requirements.pdf',
thumb_url:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdFdKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--10170e22f42401a9259e17eba6e59877127353d0/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9UY21WemFYcGxYM1J2WDJacGJHeGJCMmtCK2pBPSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--31a6ed995cc4ac2dd2fa023068ee23b23efa1efb/requirements.pdf',
file_size: 841909,
width: null,
height: null,
},
{
id: 18,
message_id: 5307,
file_type: 'file',
account_id: 2,
extension: null,
data_url:
'http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaUVLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4f7e671db635b73d12ee004e87608bc098ef6b3b/quantity-requirements.xls',
thumb_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaUVLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4f7e671db635b73d12ee004e87608bc098ef6b3b/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9UY21WemFYcGxYM1J2WDJacGJHeGJCMmtCK2pBPSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--5c454d5f03daf1f9f4068cb242cf9885cc1815b6/all-files.zip',
file_size: 99844,
width: null,
height: null,
},
],
},
{
id: 60914,
content:
'Dear Alex,\r\n\r\nThank you for your inquiry. Please find attached our quotation based on your requirements. Let us know if you need further details or wish to discuss specific customizations.\r\n\r\nBest regards, \r\nSam \r\nFabricMart',
account_id: 51,
inbox_id: 992,
conversation_id: 134,
message_type: 1,
created_at: 1733312726,
updated_at: '2024-12-04T11:45:34.451Z',
private: false,
status: 'sent',
source_id:
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60914@reply.chatwoot.dev',
content_type: 'text',
content_attributes: {
cc_emails: [],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Dear Alex,\r\n\r\nThank you for your inquiry. Please find attached our quotation based on your requirements. Let us know if you need further details or wish to discuss specific customizations.\r\n\r\nBest regards, \r\nSam \r\nFabricMart',
sentiment: {},
conversation: {
assignee_id: 110,
unread_count: 0,
last_activity_at: 1733312726,
contact_inbox: {
source_id: 'alex@paperlayer.test',
},
},
attachments: [
{
id: 827,
message_id: 60914,
file_type: 'file',
account_id: 51,
extension: null,
data_url:
'https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf',
thumb_url:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9UY21WemFYcGxYM1J2WDJacGJHeGJCMmtCK2pBPSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--31a6ed995cc4ac2dd2fa023068ee23b23efa1efb/quotation.pdf',
file_size: 841909,
width: null,
height: null,
},
],
sender: {
id: 110,
name: 'Alex',
available_name: 'Alex',
avatar_url:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
type: 'user',
availability_status: 'online',
thumbnail:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
},
},
{
id: 60915,
content:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Thank you for your inquiry. Please find attached our quotation based on\n> your requirements. Let us know if you need further details or wish to\n> discuss specific customizations.\n>\n> Best regards,\n> Sam\n> FabricMart\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf>]\n>',
account_id: 51,
inbox_id: 992,
conversation_id: 134,
message_type: 0,
created_at: 1733312835,
updated_at: '2024-12-04T11:47:15.876Z',
private: false,
status: 'sent',
source_id: 'CAM_Qp+_70EiYJ_nKMgJ6MZaD58Tq3E57QERcZgnd10g@mail.gmail.com',
content_type: 'incoming_email',
content_attributes: {
email: {
bcc: null,
cc: null,
content_type:
'multipart/alternative; boundary=0000000000007191be06287054c4',
date: '2024-12-04T17:16:07+05:30',
from: ['alex@paperlayer.test'],
html_content: {
full: '<div dir="ltr"><p>Dear Sam,</p><p>Thank you for the quotation. Could you share images or samples of the fabric for us to review before proceeding?</p><p>Best,<br>Alex</p></div><br><div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">On Wed, 4 Dec 2024 at 17:15, Sam from CottonMart &lt;<a href="mailto:sam@cottonmart.test">sam@cottonmart.test</a>&gt; wrote:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"> <p>Dear Alex,</p>\n<p>Thank you for your inquiry. Please find attached our quotation based on your requirements. Let us know if you need further details or wish to discuss specific customizations.</p>\n<p>Best regards,<br>\nSam<br>\nFabricMart</p>\n\n attachment [<a href="https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf" target="_blank">click here to view</a>]\n</blockquote></div>\n',
reply:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the fabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test> wrote:\n>',
quoted:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the fabric for us to review before proceeding?\n\nBest,\nAlex',
},
in_reply_to:
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60914@reply.chatwoot.dev',
message_id:
'CAM_Qp+_70EiYJ_nKMgJ6MZaD58Tq3E57QERcZgnd10g@mail.gmail.com',
multipart: true,
number_of_attachments: 0,
subject: 'Re: Inquiry and Quotation for Cotton Fabric',
text_content: {
full: 'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test>\nwrote:\n\n> Dear Alex,\n>\n> Thank you for your inquiry. Please find attached our quotation based on\n> your requirements. Let us know if you need further details or wish to\n> discuss specific customizations.\n>\n> Best regards,\n> Sam\n> FabricMart\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf>]\n>\n',
reply:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Thank you for your inquiry. Please find attached our quotation based on\n> your requirements. Let us know if you need further details or wish to\n> discuss specific customizations.\n>\n> Best regards,\n> Sam\n> FabricMart\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf>]\n>',
quoted:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex',
},
to: ['sam@cottonmart.test'],
},
cc_email: null,
bcc_email: null,
},
sender_type: 'Contact',
sender_id: 111256,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex',
sentiment: {},
conversation: {
assignee_id: 110,
unread_count: 1,
last_activity_at: 1733312835,
contact_inbox: {
source_id: 'alex@paperlayer.test',
},
},
sender: {
additional_attributes: {
source_id: 'email:CAM_Qp+8beyon41DA@mail.gmail.com',
},
custom_attributes: {},
email: 'alex@paperlayer.test',
id: 111256,
identifier: null,
name: 'Alex',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
message_type: 1,
content_type: 'text',
source_id:
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60916@reply.chatwoot.dev',
processed_message_content:
"Dear Alex,\r\n\r\nPlease find attached images of our cotton fabric samples. Let us know if you'd like physical samples sent to you. \r\n\r\nWarm regards, \r\nSam",
id: 60916,
content:
"Dear Alex,\r\n\r\nPlease find attached images of our cotton fabric samples. Let us know if you'd like physical samples sent to you. \r\n\r\nWarm regards, \r\nSam",
account_id: 51,
inbox_id: 992,
conversation_id: 134,
created_at: 1733312866,
updated_at: '2024-12-04T11:47:53.564Z',
private: false,
status: 'sent',
content_attributes: {
cc_emails: [],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 110,
unread_count: 0,
last_activity_at: 1733312866,
contact_inbox: {
source_id: 'alex@paperlayer.test',
},
},
attachments: [
{
id: 828,
message_id: 60916,
file_type: 'image',
account_id: 51,
extension: null,
data_url:
'https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png',
thumb_url:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/image.png',
file_size: 1617507,
width: 1600,
height: 900,
},
],
sender: {
id: 110,
name: 'Alex',
available_name: 'Alex',
avatar_url:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
type: 'user',
availability_status: 'online',
thumbnail:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
},
previous_changes: {
updated_at: ['2024-12-04T11:47:46.879Z', '2024-12-04T11:47:53.564Z'],
source_id: [
null,
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60916@reply.chatwoot.dev',
],
},
},
{
id: 60917,
content:
"Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Please find attached images of our cotton fabric samples. Let us know if\n> you'd like physical samples sent to you.\n>\n> Warm regards,\n> Sam\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png>]\n>",
account_id: 51,
inbox_id: 992,
conversation_id: 134,
message_type: 0,
created_at: 1733312969,
updated_at: '2024-12-04T11:49:29.337Z',
private: false,
status: 'sent',
source_id: 'CAM_Qp+8LuzLTWZXkecjzJAgmb9RAQGm+qTmg@mail.gmail.com',
content_type: 'incoming_email',
content_attributes: {
email: {
bcc: null,
cc: null,
content_type:
'multipart/related; boundary=0000000000007701030628705e31',
date: '2024-12-04T17:18:54+05:30',
from: ['alex@paperlayer.test'],
html_content: {
full: '<div dir="ltr">Great we were looking for something in a different finish see image attached<br><br><img src="https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGlKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--408309fa40f1cfea87ee3320a062a5d16ce09d4e/image.png" alt="image.png" width="472" height="305"><br><div><br></div><div>Let me know if you have different finish options?<br><br>Best Regards</div></div><br><div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">On Wed, 4 Dec 2024 at 17:17, Sam from CottonMart &lt;<a href="mailto:sam@cottonmart.test">sam@cottonmart.test</a>&gt; wrote:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"> <p>Dear Alex,</p>\n<p>Please find attached images of our cotton fabric samples. Let us know if you&#39;d like physical samples sent to you.</p>\n<p>Warm regards,<br>\nSam</p>\n\n attachment [<a href="https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png" target="_blank">click here to view</a>]\n</blockquote></div>\n',
reply:
'Great we were looking for something in a different finish see image attached\n\n[image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n>',
quoted:
'Great we were looking for something in a different finish see image attached\n\n[image.png]\n\nLet me know if you have different finish options?\n\nBest Regards',
},
in_reply_to:
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60916@reply.chatwoot.dev',
message_id: 'CAM_Qp+8LuzLTWZXkecjzJAgmb9RAQGm+qTmg@mail.gmail.com',
multipart: true,
number_of_attachments: 1,
subject: 'Re: Inquiry and Quotation for Cotton Fabric',
text_content: {
full: "Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Please find attached images of our cotton fabric samples. Let us know if\n> you'd like physical samples sent to you.\n>\n> Warm regards,\n> Sam\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png>]\n>",
reply:
"Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Please find attached images of our cotton fabric samples. Let us know if\n> you'd like physical samples sent to you.\n>\n> Warm regards,\n> Sam\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png>]\n>",
quoted:
'Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards',
},
to: ['sam@cottonmart.test'],
},
cc_email: null,
bcc_email: null,
},
sender_type: 'Contact',
sender_id: 111256,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards',
sentiment: {},
conversation: {
assignee_id: 110,
unread_count: 1,
last_activity_at: 1733312969,
contact_inbox: {
source_id: 'alex@paperlayer.test',
},
},
sender: {
additional_attributes: {
source_id: 'email:CAM_Qp+8beyon41DA@mail.gmail.com',
},
custom_attributes: {},
email: 'alex@paperlayer.test',
id: 111256,
identifier: null,
name: 'Alex',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
],
{ deep: true }
);

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,85 @@
import camelcaseKeys from 'camelcase-keys';
export default camelcaseKeys(
[
{
id: 60716,
content:
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.\n------------------------------\nKey Updates\n\n 1.\n\n *Integration Status*:\n The initial integration with Chatwoot has been successful. We've tested:\n - API connectivity\n - Multi-channel messaging\n - Real-time chat updates\n 2.\n\n *Upcoming Tasks*:\n - Streamlining notification workflows\n - Enhancing webhook reliability\n - Testing team collaboration features\n\n*Note:*\nDont forget to check out the automation capabilities in Chatwoot for\nhandling repetitive queries. It can save a ton of time!\n\n------------------------------\nFeatures We Love\n\nHeres what stood out so far:\n\n - *Unified Inbox*: All customer conversations in one place.\n - *Customizable Workflows*: Tailored to our teams unique needs.\n - *Integrations*: Works seamlessly with CRM and Slack.\n\n------------------------------\nAction Items For Next Week:\n\n 1. Implement the webhook for *ticket prioritization*.\n 2. Test *CSAT surveys* post-chat sessions.\n 3. Review *analytics dashboard* insights.\n\n------------------------------\nData Snapshot\n\nHeres a quick overview of our conversation stats this week:\nMetric Value Change (%)\nTotal Conversations 350 +25%\nAverage Response Time 3 minutes -15%\nCSAT Score 92% +10%\n------------------------------\nFeedback\n\n*Do let me know if you have additional feedback or ideas to improve our\nworkflows. Heres an image of how our Chatwoot dashboard looks with recent\nchanges:*\n\n[image: Chatwoot Dashboard Screenshot]\n------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
account_id: 51,
inbox_id: 991,
conversation_id: 46,
message_type: 0,
created_at: 1733141025,
updated_at: '2024-12-02T12:03:45.663Z',
private: false,
status: 'sent',
source_id:
'CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
content_type: 'incoming_email',
content_attributes: {
email: {
bcc: null,
cc: null,
content_type:
'multipart/alternative; boundary=0000000000009d889e0628477235',
date: '2024-12-02T16:29:39+05:30',
from: ['hey@shivam.dev'],
html_content: {
full: '<div dir="ltr"><h3><span style="font-size:small;font-weight:normal">Hi Team,</span></h3>\r\n<p>I hope this email finds you well! I wanted to share some updates regarding our integration with <strong>Chatwoot</strong> and outline some key features weve explored.</p>\r\n<hr>\r\n<h3>Key Updates</h3>\r\n<ol>\r\n<li>\r\n<p><strong>Integration Status</strong>:<br>\r\nThe initial integration with Chatwoot has been successful. We&#39;ve tested:</p>\r\n<ul>\r\n<li>API connectivity</li>\r\n<li>Multi-channel messaging</li>\r\n<li>Real-time chat updates</li>\r\n</ul>\r\n</li>\r\n<li>\r\n<p><strong>Upcoming Tasks</strong>:</p>\r\n<ul>\r\n<li>Streamlining notification workflows</li>\r\n<li>Enhancing webhook reliability</li>\r\n<li>Testing team collaboration features</li>\r\n</ul>\r\n</li>\r\n</ol>\r\n<blockquote>\r\n<p><strong>Note:</strong><br>\r\nDont forget to check out the automation capabilities in Chatwoot for handling repetitive queries. It can save a ton of time!</p>\r\n</blockquote>\r\n<hr>\r\n<h3>Features We Love</h3>\r\n<p>Heres what stood out so far:</p>\r\n<ul>\r\n<li><strong>Unified Inbox</strong>: All customer conversations in one place.</li>\r\n<li><strong>Customizable Workflows</strong>: Tailored to our teams unique needs.</li>\r\n<li><strong>Integrations</strong>: Works seamlessly with CRM and Slack.</li>\r\n</ul>\r\n<hr>\r\n<h3>Action Items</h3>\r\n<h4>For Next Week:</h4>\r\n<ol>\r\n<li>Implement the webhook for <strong>ticket prioritization</strong>.</li>\r\n<li>Test <strong>CSAT surveys</strong> post-chat sessions.</li>\r\n<li>Review <strong>analytics dashboard</strong> insights.</li>\r\n</ol>\r\n<hr>\r\n<h3>Data Snapshot</h3>\r\n<p>Heres a quick overview of our conversation stats this week:</p>\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>Metric</th>\r\n<th>Value</th>\r\n<th>Change (%)</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>Total Conversations</td>\r\n<td>350</td>\r\n<td>+25%</td>\r\n</tr>\r\n<tr>\r\n<td>Average Response Time</td>\r\n<td>3 minutes</td>\r\n<td>-15%</td>\r\n</tr>\r\n<tr>\r\n<td>CSAT Score</td>\r\n<td>92%</td>\r\n<td>+10%</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n<hr>\r\n<h3>Feedback</h3>\r\n<p><i>Do let me know if you have additional feedback or ideas to improve our workflows. Heres an image of how our Chatwoot dashboard looks with recent changes:</i></p>\r\n<p><img src="https://via.placeholder.com/600x300" alt="Chatwoot Dashboard Screenshot" title="Chatwoot Dashboard"></p>\r\n<hr>\r\n<p>Looking forward to hearing your thoughts!</p>\r\n<p>Best regards,<br>~ Shivam Mishra<br></p></div>\r\n',
reply:
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features weve explored.\n\n---------------------------------------------------------------\n\nKey Updates\n\n-\n\nIntegration Status:\nThe initial integration with Chatwoot has been successful. We've tested:\n\n- API connectivity\n- Multi-channel messaging\n- Real-time chat updates\n\n-\n\nUpcoming Tasks:\n\n- Streamlining notification workflows\n- Enhancing webhook reliability\n- Testing team collaboration features\n\n>\n---------------------------------------------------------------\n\nFeatures We Love\n\nHeres what stood out so far:\n\n- Unified Inbox: All customer conversations in one place.\n- Customizable Workflows: Tailored to our teams unique needs.\n- Integrations: Works seamlessly with CRM and Slack.\n\n---------------------------------------------------------------\n\nAction Items\n\nFor Next Week:\n\n- Implement the webhook for ticket prioritization.\n- Test CSAT surveys post-chat sessions.\n- Review analytics dashboard insights.\n\n---------------------------------------------------------------\n\nData Snapshot\n\nHeres a quick overview of our conversation stats this week:\n\nMetric\tValue\tChange (%)\nTotal Conversations\t350\t+25%\nAverage Response Time\t3 minutes\t-15%\nCSAT Score\t92%\t+10%\n---------------------------------------------------------------\n\nFeedback\n\nDo let me know if you have additional feedback or ideas to improve our workflows. Heres an image of how our Chatwoot dashboard looks with recent changes:\n\n[Chatwoot Dashboard]\n\n---------------------------------------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
quoted:
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features weve explored.',
},
in_reply_to: null,
message_id:
'CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
multipart: true,
number_of_attachments: 0,
subject: 'Update on Chatwoot Integration and Features',
text_content: {
full: "Hi Team,\r\n\r\nI hope this email finds you well! I wanted to share some updates regarding\r\nour integration with *Chatwoot* and outline some key features weve\r\nexplored.\r\n------------------------------\r\nKey Updates\r\n\r\n 1.\r\n\r\n *Integration Status*:\r\n The initial integration with Chatwoot has been successful. We've tested:\r\n - API connectivity\r\n - Multi-channel messaging\r\n - Real-time chat updates\r\n 2.\r\n\r\n *Upcoming Tasks*:\r\n - Streamlining notification workflows\r\n - Enhancing webhook reliability\r\n - Testing team collaboration features\r\n\r\n*Note:*\r\nDont forget to check out the automation capabilities in Chatwoot for\r\nhandling repetitive queries. It can save a ton of time!\r\n\r\n------------------------------\r\nFeatures We Love\r\n\r\nHeres what stood out so far:\r\n\r\n - *Unified Inbox*: All customer conversations in one place.\r\n - *Customizable Workflows*: Tailored to our teams unique needs.\r\n - *Integrations*: Works seamlessly with CRM and Slack.\r\n\r\n------------------------------\r\nAction Items For Next Week:\r\n\r\n 1. Implement the webhook for *ticket prioritization*.\r\n 2. Test *CSAT surveys* post-chat sessions.\r\n 3. Review *analytics dashboard* insights.\r\n\r\n------------------------------\r\nData Snapshot\r\n\r\nHeres a quick overview of our conversation stats this week:\r\nMetric Value Change (%)\r\nTotal Conversations 350 +25%\r\nAverage Response Time 3 minutes -15%\r\nCSAT Score 92% +10%\r\n------------------------------\r\nFeedback\r\n\r\n*Do let me know if you have additional feedback or ideas to improve our\r\nworkflows. Heres an image of how our Chatwoot dashboard looks with recent\r\nchanges:*\r\n\r\n[image: Chatwoot Dashboard Screenshot]\r\n------------------------------\r\n\r\nLooking forward to hearing your thoughts!\r\n\r\nBest regards,\r\n~ Shivam Mishra\r\n",
reply:
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.\n------------------------------\nKey Updates\n\n 1.\n\n *Integration Status*:\n The initial integration with Chatwoot has been successful. We've tested:\n - API connectivity\n - Multi-channel messaging\n - Real-time chat updates\n 2.\n\n *Upcoming Tasks*:\n - Streamlining notification workflows\n - Enhancing webhook reliability\n - Testing team collaboration features\n\n*Note:*\nDont forget to check out the automation capabilities in Chatwoot for\nhandling repetitive queries. It can save a ton of time!\n\n------------------------------\nFeatures We Love\n\nHeres what stood out so far:\n\n - *Unified Inbox*: All customer conversations in one place.\n - *Customizable Workflows*: Tailored to our teams unique needs.\n - *Integrations*: Works seamlessly with CRM and Slack.\n\n------------------------------\nAction Items For Next Week:\n\n 1. Implement the webhook for *ticket prioritization*.\n 2. Test *CSAT surveys* post-chat sessions.\n 3. Review *analytics dashboard* insights.\n\n------------------------------\nData Snapshot\n\nHeres a quick overview of our conversation stats this week:\nMetric Value Change (%)\nTotal Conversations 350 +25%\nAverage Response Time 3 minutes -15%\nCSAT Score 92% +10%\n------------------------------\nFeedback\n\n*Do let me know if you have additional feedback or ideas to improve our\nworkflows. Heres an image of how our Chatwoot dashboard looks with recent\nchanges:*\n\n[image: Chatwoot Dashboard Screenshot]\n------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
quoted:
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.',
},
to: ['shivam@chatwoot.com'],
},
cc_email: null,
bcc_email: null,
},
sender_type: 'Contact',
sender_id: 111256,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.',
sentiment: {},
conversation: {
assignee_id: null,
unread_count: 1,
last_activity_at: 1733141025,
contact_inbox: {
source_id: 'hey@shivam.dev',
},
},
sender: {
additional_attributes: {
source_id:
'email:CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
},
custom_attributes: {},
email: 'hey@shivam.dev',
id: 111256,
identifier: null,
name: 'Shivam Mishra',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
],
{ deep: true }
);

View File

@@ -0,0 +1,715 @@
import camelcaseKeys from 'camelcase-keys';
export default camelcaseKeys(
[
{
id: 5272,
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
inbox_id: 475,
conversation_id: 43,
message_type: 0,
content_type: 'text',
status: 'sent',
content_attributes: {
in_reply_to: null,
},
created_at: 1732195656,
private: false,
source_id: null,
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
id: 5273,
content: 'Give the team a way to reach you.',
inbox_id: 475,
conversation_id: 43,
message_type: 3,
content_type: 'text',
status: 'read',
content_attributes: {},
created_at: 1732195656,
private: false,
source_id: null,
},
{
id: 5274,
content: 'Get notified by email',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 3,
created_at: 1732195656,
updated_at: '2024-11-21T13:27:53.612Z',
private: false,
status: 'read',
source_id: null,
content_type: 'input_email',
content_attributes: {
submitted_email: 'hey@example.com',
},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Get notified by email',
sentiment: {},
conversation: {
assignee_id: null,
unread_count: 1,
last_activity_at: 1732195656,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
},
{
id: 5275,
content:
'Does the Startup plan include the two users from the Free plan, or do I have to buy those separately?',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195735,
updated_at: '2024-11-21T13:28:55.508Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Does the Startup plan include the two users from the Free plan, or do I have to buy those separately?',
sentiment: {},
conversation: {
assignee_id: null,
unread_count: 1,
last_activity_at: 1732195735,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
conversation_id: 43,
status: 'read',
content_type: 'text',
processed_message_content: 'John self-assigned this conversation',
id: 5276,
content: 'John self-assigned this conversation',
account_id: 1,
inbox_id: 475,
message_type: 2,
created_at: 1732195741,
updated_at: '2024-11-21T13:30:26.788Z',
private: false,
source_id: null,
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732195826,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
previous_changes: {
updated_at: ['2024-11-21T13:29:01.570Z', '2024-11-21T13:30:26.788Z'],
status: ['sent', 'read'],
},
},
{
conversation_id: 43,
status: 'read',
content_type: 'text',
processed_message_content:
'Hey thanks for your interest in upgrading, no, the seats are not included, you will have to purchase them alongside the rest. How many seats are you planning to upgrade to?',
id: 5277,
content:
'Hey thanks for your interest in upgrading, no, the seats are not included, you will have to purchase them alongside the rest. How many seats are you planning to upgrade to?',
account_id: 1,
inbox_id: 475,
message_type: 1,
created_at: 1732195826,
updated_at: '2024-11-21T13:30:26.837Z',
private: false,
source_id: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732195826,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
id: 1,
name: 'John',
available_name: 'John',
avatar_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
type: 'user',
availability_status: null,
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
},
previous_changes: {
updated_at: ['2024-11-21T13:30:26.149Z', '2024-11-21T13:30:26.837Z'],
status: ['sent', 'read'],
},
},
{
id: 5278,
content: "Oh, that's unfortunate",
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195820,
updated_at: '2024-11-21T13:30:38.070Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content: "Oh, that's unfortunate",
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 1,
last_activity_at: 1732195820,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
id: 5279,
content:
'I plan to upgrade to 4 agents for now, but will grow to 6 in the next three months. ',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195820,
updated_at: '2024-11-21T13:31:05.284Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'I plan to upgrade to 4 agents for now, but will grow to 6 in the next three months. ',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 1,
last_activity_at: 1732195885,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
id: 5280,
content: 'Is it possible to get a discount?',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195886,
updated_at: '2024-11-21T13:31:12.545Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Is it possible to get a discount?',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 1,
last_activity_at: 1732195872,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
conversation_id: 43,
status: 'read',
content_type: 'text',
processed_message_content:
'[@Bruce](mention://user/30/Bruce) should we offer them a discount',
id: 5281,
content:
'[@Bruce](mention://user/30/Bruce) should we offer them a discount',
account_id: 1,
inbox_id: 475,
message_type: 1,
created_at: 1732195887,
updated_at: '2024-11-21T13:32:59.863Z',
private: true,
source_id: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732195972,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
id: 1,
name: 'John',
available_name: 'John',
avatar_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
type: 'user',
availability_status: null,
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
},
previous_changes: {
updated_at: ['2024-11-21T13:31:27.914Z', '2024-11-21T13:32:59.863Z'],
status: ['sent', 'read'],
},
},
{
conversation_id: 43,
status: 'read',
content_type: 'text',
processed_message_content:
'Sure, you can use the discount code KQS3242A at the checkout to get 30% off on your yearly subscription. This coupon only applies for a year, I hope this helps',
id: 5282,
content:
'Sure, you can use the discount code KQS3242A at the checkout to get 30% off on your yearly subscription. This coupon only applies for a year, I hope this helps',
account_id: 1,
inbox_id: 475,
message_type: 1,
created_at: 1732195972,
updated_at: '2024-11-21T13:32:59.902Z',
private: false,
source_id: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732195972,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
id: 1,
name: 'John',
available_name: 'John',
avatar_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
type: 'user',
availability_status: null,
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
},
previous_changes: {
updated_at: ['2024-11-21T13:32:52.722Z', '2024-11-21T13:32:59.902Z'],
status: ['sent', 'read'],
},
},
{
id: 5283,
content: 'Great, thanks',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195982,
updated_at: '2024-11-21T13:33:02.142Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Great, thanks',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 1,
last_activity_at: 1732195982,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
id: 5284,
content: 'Really appreciate it',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195984,
updated_at: '2024-11-21T13:33:04.856Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Really appreciate it',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 1,
last_activity_at: 1732195984,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
conversation_id: 43,
status: 'progress',
content_type: 'text',
processed_message_content: ' Happy to help :)',
id: 5285,
content: ' Happy to help :)',
account_id: 1,
inbox_id: 475,
message_type: 1,
created_at: 1732195991,
updated_at: '2024-11-21T13:33:12.229Z',
private: false,
source_id: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732195991,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
id: 1,
name: 'John',
available_name: 'John',
avatar_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
type: 'user',
availability_status: null,
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
},
previous_changes: {
updated_at: ['2024-11-21T13:33:11.667Z', '2024-11-21T13:33:12.229Z'],
status: ['sent', 'read'],
},
},
{
conversation_id: 43,
status: 'failed',
content_type: 'text',
processed_message_content:
"Let us know if you have any questions, I'll close this conversation for now",
id: 5286,
content:
"Let us know if you have any questions, I'll close this conversation for now",
account_id: 1,
inbox_id: 475,
message_type: 1,
created_at: 1732196013,
updated_at: '2024-11-21T13:33:33.879Z',
private: false,
source_id: null,
content_attributes: {
external_error:
'Business account is restricted from messaging users in this country.',
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732196013,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
id: 1,
name: 'John',
available_name: 'John',
avatar_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
type: 'user',
availability_status: null,
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
},
previous_changes: {
updated_at: ['2024-11-21T13:33:33.511Z', '2024-11-21T13:33:33.879Z'],
status: ['sent', 'read'],
},
},
{
id: 5287,
content: 'John set the priority to urgent',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 2,
created_at: 1732196017,
updated_at: '2024-11-21T13:33:37.569Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'John set the priority to urgent',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732196017,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
},
{
id: 5288,
content: 'John added billing',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 2,
created_at: 1732196020,
updated_at: '2024-11-21T13:33:40.207Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'John added billing',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732196020,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
},
{
id: 5289,
content: 'John added delivery',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 2,
created_at: 1732196020,
updated_at: '2024-11-21T13:33:40.822Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'John added delivery',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732196020,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
},
{
id: 5290,
content: 'Conversation was marked resolved by John',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 2,
created_at: 1732196029,
updated_at: '2024-11-21T13:33:49.059Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Conversation was marked resolved by John',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732196029,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
},
],
{ deep: true }
);

View File

@@ -0,0 +1,139 @@
import { inject, provide, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { ATTACHMENT_TYPES } from './constants';
const MessageControl = Symbol('MessageControl');
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
/**
* @typedef {Object} Sender
* @property {Object} additional_attributes - Additional attributes of the sender
* @property {Object} custom_attributes - Custom attributes of the sender
* @property {string} email - Email of the sender
* @property {number} id - ID of the sender
* @property {string|null} identifier - Identifier of the sender
* @property {string} name - Name of the sender
* @property {string|null} phone_number - Phone number of the sender
* @property {string} thumbnail - Thumbnail URL of the sender
* @property {string} type - Type of sender
*/
/**
* @typedef {Object} EmailContent
* @property {string[]|null} bcc - BCC recipients
* @property {string[]|null} cc - CC recipients
* @property {string} contentType - Content type of the email
* @property {string} date - Date the email was sent
* @property {string[]} from - From email address
* @property {Object} htmlContent - HTML content of the email
* @property {string} htmlContent.full - Full HTML content
* @property {string} htmlContent.reply - Reply HTML content
* @property {string} htmlContent.quoted - Quoted HTML content
* @property {string|null} inReplyTo - Message ID being replied to
* @property {string} messageId - Unique message identifier
* @property {boolean} multipart - Whether the email is multipart
* @property {number} numberOfAttachments - Number of attachments
* @property {string} subject - Email subject line
* @property {Object} textContent - Text content of the email
* @property {string} textContent.full - Full text content
* @property {string} textContent.reply - Reply text content
* @property {string} textContent.quoted - Quoted text content
* @property {string[]} to - To email addresses
*/
/**
* @typedef {Object} ContentAttributes
* @property {string} externalError - an error message to be shown if the message failed to send
* @property {Object} [data] - Optional data object containing roomName and messageId
* @property {string} data.roomName - Name of the room
* @property {string} data.messageId - ID of the message
* @property {'story_mention'} [imageType] - Flag to indicate this is a story mention
* @property {'dyte'} [type] - Flag to indicate this is a dyte call
* @property {EmailContent} [email] - Email content and metadata
* @property {string|null} [ccEmail] - CC email addresses
* @property {string|null} [bccEmail] - BCC email addresses
*/
/**
* @typedef {'sent'|'delivered'|'read'|'failed'|'progress'} MessageStatus
* @typedef {'text'|'input_text'|'input_textarea'|'input_email'|'input_select'|'cards'|'form'|'article'|'incoming_email'|'input_csat'|'integrations'|'sticker'} MessageContentType
* @typedef {0|1|2|3} MessageType
* @typedef {'contact'|'user'|'Contact'|'User'} SenderType
* @typedef {'user'|'agent'|'activity'|'private'|'bot'|'error'|'template'|'email'|'unsupported'} MessageVariant
* @typedef {'left'|'center'|'right'} MessageOrientation
* @typedef {Object} MessageContext
* @property {import('vue').Ref<string>} content - The message content
* @property {import('vue').Ref<number>} conversationId - The ID of the conversation to which the message belongs
* @property {import('vue').Ref<number>} createdAt - Timestamp when the message was created
* @property {import('vue').Ref<number>} currentUserId - The ID of the current user
* @property {import('vue').Ref<number>} id - The unique identifier for the message
* @property {import('vue').Ref<number>} inboxId - The ID of the inbox to which the message belongs
* @property {import('vue').Ref<boolean>} [groupWithNext=false] - Whether the message should be grouped with the next message
* @property {import('vue').Ref<boolean>} [isEmailInbox=false] - Whether the message is from an email inbox
* @property {import('vue').Ref<boolean>} [private=false] - Whether the message is private
* @property {import('vue').Ref<number|null>} [senderId=null] - The ID of the sender
* @property {import('vue').Ref<string|null>} [error=null] - Error message if the message failed to send
* @property {import('vue').Ref<Attachment[]>} [attachments=[]] - The attachments associated with the message
* @property {import('vue').Ref<ContentAttributes>} [contentAttributes={}] - Additional attributes of the message content
* @property {import('vue').Ref<MessageContentType>} contentType - Content type of the message
* @property {import('vue').Ref<MessageStatus>} status - The delivery status of the message
* @property {import('vue').Ref<MessageType>} messageType - The type of message (must be one of MESSAGE_TYPES)
* @property {import('vue').Ref<Object|null>} [inReplyTo=null] - The message to which this message is a reply
* @property {import('vue').Ref<SenderType>} [senderType=null] - The type of the sender
* @property {import('vue').Ref<Sender|null>} [sender=null] - The sender information
* @property {import('vue').ComputedRef<MessageOrientation>} orientation - The visual variant of the message
* @property {import('vue').ComputedRef<MessageVariant>} variant - The visual variant of the message
* @property {import('vue').ComputedRef<boolean>} isBotOrAgentMessage - Does the message belong to the current user
* @property {import('vue').ComputedRef<boolean>} isPrivate - Proxy computed value for private
* @property {import('vue').ComputedRef<boolean>} shouldGroupWithNext - Should group with the next message or not, it is differnt from groupWithNext, this has a bypass for a failed message
*/
/**
* Retrieves the message context from the parent Message component.
* Must be used within a component that is a child of a Message component.
*
* @returns {MessageContext & { filteredCurrentChatAttachments: import('vue').ComputedRef<Attachment[]> }}
* Message context object containing message properties and computed values
* @throws {Error} If used outside of a Message component context
*/
export function useMessageContext() {
const context = inject(MessageControl, null);
if (context === null) {
throw new Error(`Component is missing a parent <Message /> component.`);
}
const currentChatAttachments = useMapGetter('getSelectedChatAttachments');
const filteredCurrentChatAttachments = computed(() => {
const attachments = currentChatAttachments.value.filter(attachment =>
[
ATTACHMENT_TYPES.IMAGE,
ATTACHMENT_TYPES.VIDEO,
ATTACHMENT_TYPES.IG_REEL,
ATTACHMENT_TYPES.AUDIO,
].includes(attachment.file_type)
);
return useSnakeCase(attachments);
});
return { ...context, filteredCurrentChatAttachments };
}
export function provideMessageContext(context) {
provide(MessageControl, context);
}

View File

@@ -0,0 +1,45 @@
<script setup>
import Message from '../Message.vue';
import simpleEmail from '../fixtures/simpleEmail.js';
import fullConversation from '../fixtures/emailConversation.js';
import newsletterEmail from '../fixtures/newsletterEmail.js';
const failedEmail = {
...simpleEmail[0],
status: 'failed',
senderId: 1,
senderType: 'User',
contentAttributes: {
...simpleEmail[0].contentAttributes,
externalError: 'Failed to send email',
},
};
</script>
<template>
<Story
title="Components/Messages/Email"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Simple Email">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<template v-for="message in fullConversation" :key="message.id">
<Message :current-user-id="1" is-email-inbox v-bind="message" />
</template>
</div>
</Variant>
<Variant title="Newsletter">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<template v-for="message in newsletterEmail" :key="message.id">
<Message :current-user-id="1" is-email-inbox v-bind="message" />
</template>
</div>
</Variant>
<Variant title="Failed Email">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" is-email-inbox v-bind="failedEmail" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,137 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import Message from '../Message.vue';
const currentUserId = ref(1);
const state = reactive({
useCurrentUserId: false,
});
const getMessage = overrides => {
const contentAttributes = {
inReplyTo: null,
...(overrides.contentAttributes ?? {}),
};
const sender = {
additionalAttributes: {},
customAttributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'John Doe',
phoneNumber: null,
thumbnail: '',
type: 'contact',
...(overrides.sender ?? {}),
};
return {
id: 5272,
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
inboxId: 475,
conversationId: 43,
messageType: 0,
contentType: 'text',
status: 'sent',
createdAt: 1732195656,
private: false,
sourceId: null,
...overrides,
sender,
contentAttributes,
};
};
const getAttachment = (type, url, overrides) => {
return {
id: 22,
messageId: 5319,
fileType: type,
accountId: 2,
extension: null,
dataUrl: url,
thumbUrl: '',
fileSize: 345644,
width: null,
height: null,
...overrides,
};
};
const baseSenderData = computed(() => {
return {
messageType: state.useCurrentUserId ? 1 : 0,
senderId: state.useCurrentUserId ? currentUserId.value : 597,
sender: {
id: state.useCurrentUserId ? currentUserId.value : 597,
type: state.useCurrentUserId ? 'User' : 'Contact',
},
};
});
const instagramStory = computed(() =>
getMessage({
content: 'cwtestinglocal mentioned you in the story: ',
contentAttributes: {
imageType: 'story_mention',
},
attachments: [
getAttachment(
'image',
'https://images.pexels.com/photos/2587370/pexels-photo-2587370.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2'
),
],
...baseSenderData.value,
})
);
const unsupported = computed(() =>
getMessage({
content: null,
contentAttributes: {
isUnsupported: true,
},
...baseSenderData.value,
})
);
const igReel = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment(
'ig_reel',
'https://videos.pexels.com/video-files/2023708/2023708-hd_720_1280_30fps.mp4'
),
],
...baseSenderData.value,
})
);
</script>
<template>
<Story
title="Components/Message Bubbles/Instagram"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Instagram Reel">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="igReel" />
</div>
</Variant>
<Variant title="Instagram Story">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="instagramStory" />
</div>
</Variant>
<Variant title="Unsupported">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="unsupported" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,44 @@
<script setup>
import Message from '../Message.vue';
import instagramConversation from '../fixtures/instagramConversation.js';
const messages = instagramConversation;
const shouldGroupWithNext = index => {
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);
};
const getReplyToMessage = message => {
const idToCheck = message.contentAttributes.inReplyTo;
if (!idToCheck) return null;
return messages.find(candidate => idToCheck === candidate.id);
};
</script>
<template>
<Story title="Components/Messages/Instagram" :layout="{ type: 'single' }">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<template v-for="(message, index) in messages" :key="message.id">
<Message
:current-user-id="1"
:group-with-next="shouldGroupWithNext(index)"
:in-reply-to="getReplyToMessage(message)"
v-bind="message"
/>
</template>
</div>
</Story>
</template>

View File

@@ -0,0 +1,260 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import Message from '../Message.vue';
const currentUserId = ref(1);
const state = reactive({
useCurrentUserId: false,
});
const getMessage = overrides => {
const contentAttributes = {
inReplyTo: null,
...(overrides.contentAttributes ?? {}),
};
const sender = {
additionalAttributes: {},
customAttributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'John Doe',
phoneNumber: null,
thumbnail: '',
type: 'contact',
...(overrides.sender ?? {}),
};
return {
id: 5272,
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
inboxId: 475,
conversationId: 43,
messageType: 0,
contentType: 'text',
status: 'sent',
createdAt: 1732195656,
private: false,
sourceId: null,
...overrides,
sender,
contentAttributes,
};
};
const getAttachment = (type, url, overrides) => {
return {
id: 22,
messageId: 5319,
fileType: type,
accountId: 2,
extension: null,
dataUrl: url,
thumbUrl: '',
fileSize: 345644,
width: null,
height: null,
...overrides,
};
};
const baseSenderData = computed(() => {
return {
messageType: state.useCurrentUserId ? 1 : 0,
senderId: state.useCurrentUserId ? currentUserId.value : 597,
sender: {
id: state.useCurrentUserId ? currentUserId.value : 597,
type: state.useCurrentUserId ? 'User' : 'Contact',
},
};
});
const audioMessage = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment(
'audio',
'https://cdn.freesound.org/previews/769/769025_16085454-lq.mp3'
),
],
...baseSenderData.value,
})
);
const brokenImageMessage = computed(() =>
getMessage({
content: null,
attachments: [getAttachment('image', 'https://chatwoot.dev/broken.png')],
...baseSenderData.value,
})
);
const imageMessage = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment(
'image',
'https://images.pexels.com/photos/28506417/pexels-photo-28506417/free-photo-of-motorbike-on-scenic-road-in-surat-thani-thailand.jpeg'
),
],
...baseSenderData.value,
})
);
const videoMessage = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment(
'video',
'https://videos.pexels.com/video-files/1739010/1739010-hd_1920_1080_30fps.mp4'
),
],
...baseSenderData.value,
})
);
const attachmentsOnly = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment('image', 'https://chatwoot.dev/broken.png'),
getAttachment(
'video',
'https://videos.pexels.com/video-files/1739010/1739010-hd_1920_1080_30fps.mp4'
),
getAttachment(
'image',
'https://images.pexels.com/photos/28506417/pexels-photo-28506417/free-photo-of-motorbike-on-scenic-road-in-surat-thani-thailand.jpeg'
),
getAttachment('file', 'https://chatwoot.dev/invoice.pdf'),
getAttachment('file', 'https://chatwoot.dev/logs.txt'),
getAttachment('file', 'https://chatwoot.dev/contacts.xls'),
getAttachment('file', 'https://chatwoot.dev/customers.csv'),
getAttachment('file', 'https://chatwoot.dev/warehousing-policy.docx'),
getAttachment('file', 'https://chatwoot.dev/pitch-deck.ppt'),
getAttachment('file', 'https://chatwoot.dev/all-files.tar'),
getAttachment(
'audio',
'https://cdn.freesound.org/previews/769/769025_16085454-lq.mp3'
),
],
...baseSenderData.value,
})
);
const singleFile = computed(() =>
getMessage({
content: null,
attachments: [getAttachment('file', 'https://chatwoot.dev/all-files.tar')],
...baseSenderData.value,
})
);
const contact = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment('contact', null, {
fallbackTitle: '+919999999999',
}),
],
...baseSenderData.value,
})
);
const location = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment('location', null, {
coordinatesLat: 37.7937545,
coordinatesLong: -122.3997472,
fallbackTitle: 'Chatwoot Inc',
}),
],
...baseSenderData.value,
})
);
const dyte = computed(() => {
return getMessage({
messageType: 1,
contentType: 'integrations',
contentAttributes: {
type: 'dyte',
data: {
meetingId: 'f16bebe6-08b9-4593-899a-849f59c47397',
roomName: 'zcufnc-adbjcg',
},
},
senderId: 1,
sender: {
id: 1,
name: 'Shivam Mishra',
availableName: 'Shivam Mishra',
type: 'user',
},
});
});
</script>
<template>
<Story
title="Components/Message Bubbles/Media"
:layout="{ type: 'grid', width: '800px' }"
>
<!-- Media Types -->
<Variant title="Audio">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="audioMessage" />
</div>
</Variant>
<Variant title="Image">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="imageMessage" />
</div>
</Variant>
<Variant title="Broken Image">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="brokenImageMessage" />
</div>
</Variant>
<Variant title="Video">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="videoMessage" />
</div>
</Variant>
<!-- Files and Attachments -->
<Variant title="Multiple Attachments">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="attachmentsOnly" />
</div>
</Variant>
<Variant title="File">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="singleFile" />
</div>
</Variant>
<Variant title="Contact">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="contact" />
</div>
</Variant>
<Variant title="Location">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="location" />
</div>
</Variant>
<Variant title="Dyte Video">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="dyte" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,181 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import Message from '../Message.vue';
const currentUserId = ref(1);
const state = reactive({
useCurrentUserId: false,
});
const getMessage = overrides => {
const contentAttributes = {
inReplyTo: null,
...(overrides.contentAttributes ?? {}),
};
const sender = {
additionalAttributes: {},
customAttributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'John Doe',
phoneNumber: null,
thumbnail: '',
type: 'contact',
...(overrides.sender ?? {}),
};
return {
id: 5272,
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
inboxId: 475,
conversationId: 43,
messageType: 0,
contentType: 'text',
status: 'sent',
createdAt: 1732195656,
private: false,
sourceId: null,
...overrides,
sender,
contentAttributes,
};
};
const getAttachment = (type, url, overrides) => {
return {
id: 22,
messageId: 5319,
fileType: type,
accountId: 2,
extension: null,
dataUrl: url,
thumbUrl: '',
fileSize: 345644,
width: null,
height: null,
...overrides,
};
};
const baseSenderData = computed(() => {
return {
messageType: state.useCurrentUserId ? 1 : 0,
senderId: state.useCurrentUserId ? currentUserId.value : 597,
sender: {
id: state.useCurrentUserId ? currentUserId.value : 597,
type: state.useCurrentUserId ? 'User' : 'Contact',
},
};
});
const simpleText = computed(() =>
getMessage({
...baseSenderData.value,
})
);
const privateText = computed(() =>
getMessage({ private: true, ...baseSenderData.value })
);
const activityMessage = computed(() =>
getMessage({
content: 'John self-assigned this conversation',
messageType: 2,
})
);
const email = computed(() =>
getMessage({
content: null,
contentType: 'incoming_email',
contentAttributes: {
email: {
bcc: null,
cc: null,
contentType:
'multipart/alternative; boundary=0000000000009d889e0628477235',
date: '2024-12-02T16:29:39+05:30',
from: ['hey@shivam.dev'],
htmlContent: {
full: '<div dir="ltr"><h3><span style="font-size:small;font-weight:normal">Hi Team,</span></h3>\r\n<p>I hope this email finds you well! I wanted to share some updates regarding our integration with <strong>Chatwoot</strong> and outline some key features weve explored.</p>\r\n<hr>\r\n<h3>Key Updates</h3>\r\n<ol>\r\n<li>\r\n<p><strong>Integration Status</strong>:<br>\r\nThe initial integration with Chatwoot has been successful. We&#39;ve tested:</p>\r\n<ul>\r\n<li>API connectivity</li>\r\n<li>Multi-channel messaging</li>\r\n<li>Real-time chat updates</li>\r\n</ul>\r\n</li>\r\n<li>\r\n<p><strong>Upcoming Tasks</strong>:</p>\r\n<ul>\r\n<li>Streamlining notification workflows</li>\r\n<li>Enhancing webhook reliability</li>\r\n<li>Testing team collaboration features</li>\r\n</ul>\r\n</li>\r\n</ol>\r\n<blockquote>\r\n<p><strong>Note:</strong><br>\r\nDont forget to check out the automation capabilities in Chatwoot for handling repetitive queries. It can save a ton of time!</p>\r\n</blockquote>\r\n<hr>\r\n<h3>Features We Love</h3>\r\n<p>Heres what stood out so far:</p>\r\n<ul>\r\n<li><strong>Unified Inbox</strong>: All customer conversations in one place.</li>\r\n<li><strong>Customizable Workflows</strong>: Tailored to our teams unique needs.</li>\r\n<li><strong>Integrations</strong>: Works seamlessly with CRM and Slack.</li>\r\n</ul>\r\n<hr>\r\n<h3>Action Items</h3>\r\n<h4>For Next Week:</h4>\r\n<ol>\r\n<li>Implement the webhook for <strong>ticket prioritization</strong>.</li>\r\n<li>Test <strong>CSAT surveys</strong> post-chat sessions.</li>\r\n<li>Review <strong>analytics dashboard</strong> insights.</li>\r\n</ol>\r\n<hr>\r\n<h3>Data Snapshot</h3>\r\n<p>Heres a quick overview of our conversation stats this week:</p>\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>Metric</th>\r\n<th>Value</th>\r\n<th>Change (%)</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>Total Conversations</td>\r\n<td>350</td>\r\n<td>+25%</td>\r\n</tr>\r\n<tr>\r\n<td>Average Response Time</td>\r\n<td>3 minutes</td>\r\n<td>-15%</td>\r\n</tr>\r\n<tr>\r\n<td>CSAT Score</td>\r\n<td>92%</td>\r\n<td>+10%</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n<hr>\r\n<h3>Feedback</h3>\r\n<p><i>Do let me know if you have additional feedback or ideas to improve our workflows. Heres an image of how our Chatwoot dashboard looks with recent changes:</i></p>\r\n<p><img src="https://via.placeholder.com/600x300" alt="Chatwoot Dashboard Screenshot" title="Chatwoot Dashboard"></p>\r\n<hr>\r\n<p>Looking forward to hearing your thoughts!</p>\r\n<p>Best regards,<br>~ Shivam Mishra<br></p></div>\r\n',
reply:
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features weve explored.\n\n---------------------------------------------------------------\n\nKey Updates\n\n-\n\nIntegration Status:\nThe initial integration with Chatwoot has been successful. We've tested:\n\n- API connectivity\n- Multi-channel messaging\n- Real-time chat updates\n\n-\n\nUpcoming Tasks:\n\n- Streamlining notification workflows\n- Enhancing webhook reliability\n- Testing team collaboration features\n\n>\n---------------------------------------------------------------\n\nFeatures We Love\n\nHeres what stood out so far:\n\n- Unified Inbox: All customer conversations in one place.\n- Customizable Workflows: Tailored to our teams unique needs.\n- Integrations: Works seamlessly with CRM and Slack.\n\n---------------------------------------------------------------\n\nAction Items\n\nFor Next Week:\n\n- Implement the webhook for ticket prioritization.\n- Test CSAT surveys post-chat sessions.\n- Review analytics dashboard insights.\n\n---------------------------------------------------------------\n\nData Snapshot\n\nHeres a quick overview of our conversation stats this week:\n\nMetric\tValue\tChange (%)\nTotal Conversations\t350\t+25%\nAverage Response Time\t3 minutes\t-15%\nCSAT Score\t92%\t+10%\n---------------------------------------------------------------\n\nFeedback\n\nDo let me know if you have additional feedback or ideas to improve our workflows. Heres an image of how our Chatwoot dashboard looks with recent changes:\n\n[Chatwoot Dashboard]\n\n---------------------------------------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
quoted:
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features weve explored.',
},
inReplyTo: null,
messageId:
'CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
multipart: true,
numberOfAttachments: 0,
subject: 'Update on Chatwoot Integration and Features',
textContent: {
full: "Hi Team,\r\n\r\nI hope this email finds you well! I wanted to share some updates regarding\r\nour integration with *Chatwoot* and outline some key features weve\r\nexplored.\r\n------------------------------\r\nKey Updates\r\n\r\n 1.\r\n\r\n *Integration Status*:\r\n The initial integration with Chatwoot has been successful. We've tested:\r\n - API connectivity\r\n - Multi-channel messaging\r\n - Real-time chat updates\r\n 2.\r\n\r\n *Upcoming Tasks*:\r\n - Streamlining notification workflows\r\n - Enhancing webhook reliability\r\n - Testing team collaboration features\r\n\r\n*Note:*\r\nDont forget to check out the automation capabilities in Chatwoot for\r\nhandling repetitive queries. It can save a ton of time!\r\n\r\n------------------------------\r\nFeatures We Love\r\n\r\nHeres what stood out so far:\r\n\r\n - *Unified Inbox*: All customer conversations in one place.\r\n - *Customizable Workflows*: Tailored to our teams unique needs.\r\n - *Integrations*: Works seamlessly with CRM and Slack.\r\n\r\n------------------------------\r\nAction Items For Next Week:\r\n\r\n 1. Implement the webhook for *ticket prioritization*.\r\n 2. Test *CSAT surveys* post-chat sessions.\r\n 3. Review *analytics dashboard* insights.\r\n\r\n------------------------------\r\nData Snapshot\r\n\r\nHeres a quick overview of our conversation stats this week:\r\nMetric Value Change (%)\r\nTotal Conversations 350 +25%\r\nAverage Response Time 3 minutes -15%\r\nCSAT Score 92% +10%\r\n------------------------------\r\nFeedback\r\n\r\n*Do let me know if you have additional feedback or ideas to improve our\r\nworkflows. Heres an image of how our Chatwoot dashboard looks with recent\r\nchanges:*\r\n\r\n[image: Chatwoot Dashboard Screenshot]\r\n------------------------------\r\n\r\nLooking forward to hearing your thoughts!\r\n\r\nBest regards,\r\n~ Shivam Mishra\r\n",
reply:
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.\n------------------------------\nKey Updates\n\n 1.\n\n *Integration Status*:\n The initial integration with Chatwoot has been successful. We've tested:\n - API connectivity\n - Multi-channel messaging\n - Real-time chat updates\n 2.\n\n *Upcoming Tasks*:\n - Streamlining notification workflows\n - Enhancing webhook reliability\n - Testing team collaboration features\n\n*Note:*\nDont forget to check out the automation capabilities in Chatwoot for\nhandling repetitive queries. It can save a ton of time!\n\n------------------------------\nFeatures We Love\n\nHeres what stood out so far:\n\n - *Unified Inbox*: All customer conversations in one place.\n - *Customizable Workflows*: Tailored to our teams unique needs.\n - *Integrations*: Works seamlessly with CRM and Slack.\n\n------------------------------\nAction Items For Next Week:\n\n 1. Implement the webhook for *ticket prioritization*.\n 2. Test *CSAT surveys* post-chat sessions.\n 3. Review *analytics dashboard* insights.\n\n------------------------------\nData Snapshot\n\nHeres a quick overview of our conversation stats this week:\nMetric Value Change (%)\nTotal Conversations 350 +25%\nAverage Response Time 3 minutes -15%\nCSAT Score 92% +10%\n------------------------------\nFeedback\n\n*Do let me know if you have additional feedback or ideas to improve our\nworkflows. Heres an image of how our Chatwoot dashboard looks with recent\nchanges:*\n\n[image: Chatwoot Dashboard Screenshot]\n------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
quoted:
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.',
},
to: ['shivam@chatwoot.com'],
},
ccEmail: null,
bccEmail: null,
},
attachments: [
getAttachment(
'video',
'https://videos.pexels.com/video-files/1739010/1739010-hd_1920_1080_30fps.mp4'
),
getAttachment(
'image',
'https://images.pexels.com/photos/28506417/pexels-photo-28506417/free-photo-of-motorbike-on-scenic-road-in-surat-thani-thailand.jpeg'
),
getAttachment('file', 'https://chatwoot.dev/invoice.pdf'),
getAttachment('file', 'https://chatwoot.dev/logs.txt'),
getAttachment('file', 'https://chatwoot.dev/contacts.xls'),
getAttachment('file', 'https://chatwoot.dev/customers.csv'),
getAttachment('file', 'https://chatwoot.dev/warehousing-policy.docx'),
getAttachment('file', 'https://chatwoot.dev/pitch-deck.ppt'),
getAttachment('file', 'https://chatwoot.dev/all-files.tar'),
getAttachment(
'audio',
'https://cdn.freesound.org/previews/769/769025_16085454-lq.mp3'
),
],
...baseSenderData.value,
})
);
</script>
<template>
<Story
title="Components/Message Bubbles/Bubbles"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="simpleText" />
</div>
</Variant>
<Variant title="Activity">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="activityMessage" />
</div>
</Variant>
<Variant title="Private Message">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="privateText" />
</div>
</Variant>
<!-- Platform Specific -->
<Variant title="Email">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" is-email-inbox v-bind="email" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import CallToAction from '../../bubbles/Template/CallToAction.vue';
const message = {
content:
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/CallToAction"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Call To Action">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<CallToAction :message="message" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
import Card from '../../bubbles/Template/Card.vue';
const message = {
title: 'Two in one cake (1 pound)',
content: 'Customize your order for special occasions',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Card"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Card">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Card :message="message" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import ListPicker from '../../bubbles/Template/ListPicker.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you? `,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/ListPicker"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="ListPicker">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<ListPicker :message="message" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
import Media from '../../bubbles/Template/Media.vue';
const message = {
content:
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Media"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Image Media">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Media :message="message" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import QuickReply from '../../bubbles/Template/QuickReply.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you?`,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/QuickReply"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Quick Replies">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<QuickReply :message="message" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
import Text from '../../bubbles/Template/Text.vue';
const message = {
content: 'Hello John, how may we assist you?',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Text"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Text :message="message" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
import Message from '../Message.vue';
import textWithMedia from '../fixtures/textWithMedia.js';
const messages = textWithMedia;
const shouldGroupWithNext = index => {
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);
};
const getReplyToMessage = message => {
const idToCheck = message.contentAttributes.inReplyTo;
if (!idToCheck) return null;
return messages.find(candidate => idToCheck === candidate.id);
};
</script>
<template>
<Story title="Components/Messages/Text" :layout="{ type: 'single' }">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<template v-for="(message, index) in messages" :key="message.id">
<Message
:current-user-id="1"
:group-with-next="shouldGroupWithNext(index)"
:in-reply-to="getReplyToMessage(message)"
v-bind="message"
/>
</template>
</div>
</Story>
</template>