feat: Add new message bubbles (#10481)

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
Shivam Mishra
2024-12-13 07:12:22 +05:30
committed by GitHub
parent 67e52d7d51
commit 19ff5bdd5e
53 changed files with 7781 additions and 33 deletions

View File

@@ -0,0 +1,352 @@
<script setup>
import { computed, defineAsyncComponent } from 'vue';
import { provideMessageContext } from './provider.js';
import {
MESSAGE_TYPES,
ATTACHMENT_TYPES,
MESSAGE_VARIANTS,
SENDER_TYPES,
ORIENTATION,
MESSAGE_STATUS,
} 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 InstagramStoryBubble from './bubbles/InstagramStory.vue';
import AttachmentsBubble from './bubbles/Attachments.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';
const LocationBubble = defineAsyncComponent(
() => import('./bubbles/Location.vue')
);
import MessageError from './MessageError.vue';
import MessageMeta from './MessageMeta.vue';
/**
* @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')} 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
*/
// 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: () => [],
},
private: {
type: Boolean,
default: false,
},
createdAt: {
type: Number,
required: true,
},
sender: {
type: Object,
default: null,
},
senderId: {
type: Number,
default: null,
},
senderType: {
type: String,
default: null,
},
content: {
type: String,
required: true,
},
contentAttributes: {
type: Object,
default: () => {},
},
currentUserId: {
type: Number,
required: true,
},
groupWithNext: {
type: Boolean,
default: false,
},
inReplyTo: {
type: Object,
default: null,
},
isEmailInbox: {
type: Boolean,
default: false,
},
});
/**
* 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.status === MESSAGE_STATUS.FAILED) return MESSAGE_VARIANTS.ERROR;
if (props.contentAttributes.isUnsupported)
return MESSAGE_VARIANTS.UNSUPPORTED;
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 isMyMessage = computed(() => {
const senderId = props.senderId ?? props.sender?.id;
const senderType = props.senderType ?? props.sender?.type;
if (!senderType || !senderId) return false;
return (
senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase() &&
props.currentUserId === senderId
);
});
/**
* 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 (isMyMessage.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-[24px_1fr]',
[ORIENTATION.RIGHT]: 'grid grid-cols-1fr',
};
return map[orientation.value];
});
const gridTemplate = computed(() => {
const map = {
[ORIENTATION.LEFT]: `
"avatar bubble"
"spacer meta"
`,
[ORIENTATION.RIGHT]: `
"bubble"
"meta"
`,
};
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.RIGHT) 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.contentAttributes.isUnsupported) {
return UnsupportedBubble;
}
if (props.contentAttributes.type === 'dyte') {
return DyteBubble;
}
if (props.contentAttributes.imageType === 'story_mention') {
return InstagramStoryBubble;
}
if (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.LOCATION) return LocationBubble;
}
// Attachment content is the name of the contact
if (fileType === ATTACHMENT_TYPES.CONTACT) return ContactBubble;
}
if (props.attachments.length > 1 && !props.content) {
return AttachmentsBubble;
}
return TextBubble;
});
provideMessageContext({
variant,
inReplyTo: props.inReplyTo,
orientation,
isMyMessage,
});
</script>
<template>
<div
class="flex w-full"
:data-message-id="props.id"
:class="[flexOrientationClass, shouldGroupWithNext ? 'mb-2' : 'mb-4']"
>
<div v-if="variant === MESSAGE_VARIANTS.ACTIVITY">
<ActivityBubble :content="content" />
</div>
<div
v-else
:class="[
gridClass,
{
'gap-y-2': !shouldGroupWithNext,
'w-full': variant === MESSAGE_VARIANTS.EMAIL,
},
]"
class="gap-x-3"
:style="{
gridTemplateAreas: gridTemplate,
}"
>
<div
v-if="!shouldGroupWithNext && shouldShowAvatar"
class="[grid-area:avatar] flex items-end"
>
<Avatar
:name="sender ? sender.name : ''"
:src="sender?.thumbnail"
:size="24"
/>
</div>
<div
class="[grid-area:bubble]"
:class="{
'pl-9': ORIENTATION.RIGHT === orientation,
}"
>
<Component :is="componentToRender" v-bind="props" />
</div>
<MessageError
v-if="contentAttributes.externalError"
class="[grid-area:meta]"
:class="flexOrientationClass"
:error="contentAttributes.externalError"
/>
<MessageMeta
v-else-if="!shouldGroupWithNext"
class="[grid-area:meta]"
:class="flexOrientationClass"
:sender="props.sender"
:status="props.status"
:private="props.private"
:is-my-message="isMyMessage"
:created-at="props.createdAt"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
defineProps({
error: { type: String, required: true },
});
const { t } = useI18n();
</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 right-0 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"
>
{{ error }}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,77 @@
<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 { MESSAGE_STATUS } from './constants';
/**
* @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} Props
* @property {('sent'|'delivered'|'read'|'failed')} status - The delivery status of the message
* @property {boolean} [private=false] - Whether the message is private
* @property {isMyMessage} [private=false] - Whether the message is sent by the current user or not
* @property {number} createdAt - Timestamp when the message was created
* @property {Sender|null} [sender=null] - The sender information
*/
const props = defineProps({
sender: {
type: Object,
required: true,
},
status: {
type: String,
required: true,
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
private: {
type: Boolean,
default: false,
},
isMyMessage: {
type: Boolean,
default: false,
},
createdAt: {
type: Number,
required: true,
},
});
const readableTime = computed(() =>
messageTimestamp(props.createdAt, 'LLL d, h:mm a')
);
const showSender = computed(() => !props.isMyMessage && props.sender);
</script>
<template>
<div class="text-xs text-n-slate-11 flex items-center gap-1.5">
<div class="inline">
<span v-if="showSender" class="inline capitalize">{{ sender.name }}</span>
<span v-if="showSender && readableTime" class="inline"> </span>
<span class="inline">{{ readableTime }}</span>
</div>
<Icon
v-if="props.private"
icon="i-lucide-lock-keyhole"
class="text-n-slate-10 size-3"
/>
<MessageStatus v-if="props.isMyMessage" :status />
</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,16 @@
<script setup>
import BaseBubble from './Base.vue';
defineProps({
content: {
type: String,
required: true,
},
});
</script>
<template>
<BaseBubble class="px-2 py-0.5" data-bubble-name="activity">
<span v-dompurify-html="content" />
</BaseBubble>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import BaseBubble from 'next/message/bubbles/Base.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
/**
* @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
*/
defineProps({
attachments: {
type: Array,
default: () => [],
},
});
</script>
<template>
<BaseBubble class="grid gap-2 bg-transparent" data-bubble-name="attachments">
<AttachmentChips :attachments="attachments" class="gap-1" />
</BaseBubble>
</template>

View File

@@ -0,0 +1,44 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import AudioChip from 'next/message/chips/Audio.vue';
/**
* @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} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
attachments: {
type: Array,
required: true,
},
});
const attachment = computed(() => {
return props.attachments[0];
});
</script>
<template>
<BaseBubble class="bg-transparent" data-bubble-name="audio">
<AudioChip
:attachment="attachment"
class="p-2 text-n-slate-12 bg-n-alpha-3"
/>
</BaseBubble>
</template>

View File

@@ -0,0 +1,90 @@
<script setup>
import { computed } from 'vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
const { variant, orientation, inReplyTo } = 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]: 'bg-n-alpha-2 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]: 'rounded-xl rounded-bl-sm',
[ORIENTATION.RIGHT]: 'rounded-xl rounded-br-sm',
[ORIENTATION.CENTER]: 'rounded-md',
};
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: this.message.id,
});
};
const previewMessage = computed(() => {
if (!inReplyTo) return '';
const { content, attachments } = inReplyTo;
if (content) return content;
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 min-w-32 break-words"
:class="[
messageClass,
{
'max-w-md': variant !== MESSAGE_VARIANTS.EMAIL,
},
]"
>
<div
v-if="inReplyTo"
class="bg-n-alpha-black1 rounded-lg p-2"
@click="scrollToMessage"
>
<span class="line-clamp-2">
{{ previewMessage }}
</span>
</div>
<slot />
</div>
</template>

View File

@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import BaseBubble from './Base.vue';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
icon: { type: [String, Object], required: true },
iconBgColor: { type: String, default: 'bg-n-alpha-3' },
sender: { type: Object, default: () => ({}) },
senderTranslationKey: { type: String, required: true },
content: { type: String, required: true },
action: {
type: Object,
required: true,
validator: action => {
return action.label && (action.href || action.onClick);
},
},
});
const { t } = useI18n();
const senderName = computed(() => {
return props.sender.name;
});
</script>
<template>
<BaseBubble
class="overflow-hidden grid gap-4 min-w-64 p-0"
data-bubble-name="attachment"
>
<slot name="before" />
<div class="grid gap-3 px-3 pt-3 z-20">
<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">
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
{{
t(senderTranslationKey, {
sender: senderName,
})
}}
</div>
<slot>
<div v-if="content" class="truncate text-sm text-n-slate-11">
{{ content }}
</div>
</slot>
</div>
</div>
<div v-if="action" class="px-3 pb-3">
<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"
>
{{ action.label }}
</a>
<button
v-else
class="w-full bg-n-solid-3 px-4 py-2 rounded-lg text-sm"
@click="action.onClick"
>
{{ action.label }}
</button>
</div>
</BaseBubble>
</template>

View File

@@ -0,0 +1,137 @@
<script setup>
import { computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import BaseAttachmentBubble from './BaseAttachment.vue';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
/**
* @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} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
content: {
type: String,
required: true,
},
attachments: {
type: Array,
required: true,
},
sender: {
type: Object,
default: () => ({}),
},
});
const $store = useStore();
const { t } = useI18n();
const attachment = computed(() => {
return props.attachments[0];
});
const phoneNumber = computed(() => {
return attachment.value.fallbackTitle;
});
const formattedPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\s|-|[A-Za-z]/g, '');
});
const rawPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\D/g, '');
});
const name = computed(() => {
return props.content;
});
function getContactObject() {
const contactItem = {
name: name.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="sender"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.CONTACT"
:content="phoneNumber"
:action="formattedPhoneNumber ? action : null"
/>
</template>

View File

@@ -0,0 +1,109 @@
<script setup>
import { computed, ref } from 'vue';
import DyteAPI from 'dashboard/api/integrations/dyte';
import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import BaseAttachmentBubble from './BaseAttachment.vue';
const props = defineProps({
contentAttributes: {
type: String,
required: true,
},
sender: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const meetingData = computed(() => {
return useCamelCase(props.contentAttributes.data);
});
const isLoading = ref(false);
const dyteAuthToken = ref('');
const meetingLink = computed(() => {
return buildDyteURL(meetingData.value.roomName, dyteAuthToken.value);
});
const joinTheCall = async () => {
isLoading.value = true;
try {
const { data: { authResponse: { authToken } = {} } = {} } =
await DyteAPI.addParticipantToMeeting(meetingData.value.messageId);
dyteAuthToken.value = authToken;
} catch (err) {
useAlert(t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));
} finally {
isLoading.value = false;
}
};
const leaveTheRoom = () => {
this.dyteAuthToken = '';
};
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="sender"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.MEETING"
:action="action"
>
<div v-if="dyteAuthToken" class="video-call--container">
<iframe
:src="meetingLink"
allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;"
/>
<button
class="bg-n-solid-3 px-4 py-2 rounded-lg text-sm"
@click="leaveTheRoom"
>
{{ $t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM') }}
</button>
</div>
<div v-else>
{{ '' }}
</div>
</BaseAttachmentBubble>
</template>
<style lang="scss">
.join-call-button {
margin: var(--space-small) 0;
}
.video-call--container {
position: fixed;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
z-index: var(--z-index-high);
padding: var(--space-smaller);
background: var(--b-800);
iframe {
width: 100%;
height: 100%;
border: 0;
}
button {
position: absolute;
top: var(--space-smaller);
right: 10rem;
}
}
</style>

View File

@@ -0,0 +1,98 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_STATUS } from '../../constants';
const props = defineProps({
contentAttributes: {
type: Object,
default: () => ({}),
},
status: {
type: String,
required: true,
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
sender: {
type: Object,
default: () => ({}),
},
});
const hasError = computed(() => {
return props.status === MESSAGE_STATUS.FAILED;
});
const fromEmail = computed(() => {
return props.contentAttributes?.email?.from ?? [];
});
const toEmail = computed(() => {
return props.contentAttributes?.email?.to ?? [];
});
const ccEmail = computed(() => {
return (
props.contentAttributes?.ccEmails ??
props.contentAttributes?.email?.cc ??
[]
);
});
const senderName = computed(() => {
return props.sender.name ?? '';
});
const bccEmail = computed(() => {
return (
props.contentAttributes?.bccEmails ??
props.contentAttributes?.email?.bcc ??
[]
);
});
const subject = computed(() => {
return props.contentAttributes?.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="p-4 space-y-1 pr-9 border-b border-n-strong"
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
<template v-if="showMeta">
<div v-if="fromEmail[0]">
<span :class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'">
{{ senderName }}
</span>
&lt;{{ fromEmail[0] }}&gt;
</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,133 @@
<script setup>
import { computed, useTemplateRef, ref, onMounted } from 'vue';
import { Letter } from 'vue-letter';
import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from './removeReply.js';
import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue';
import { MESSAGE_STATUS, MESSAGE_TYPES } from '../../constants';
const props = defineProps({
content: {
type: String,
required: true,
},
contentAttributes: {
type: Object,
default: () => ({}),
},
attachments: {
type: Array,
default: () => [],
},
status: {
type: String,
required: true,
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
sender: {
type: Object,
default: () => ({}),
},
messageType: {
type: Number,
required: true,
},
});
const isExpandable = ref(false);
const isExpanded = ref(false);
const showQuotedMessage = ref(false);
const contentContainer = useTemplateRef('contentContainer');
onMounted(() => {
isExpandable.value = contentContainer.value.scrollHeight > 400;
});
const isOutgoing = computed(() => {
return props.messageType === MESSAGE_TYPES.OUTGOING;
});
const fullHTML = computed(() => {
return props.contentAttributes?.email?.htmlContent?.full ?? props.content;
});
const unquotedHTML = computed(() => {
return EmailQuoteExtractor.extractQuotes(fullHTML.value);
});
const hasQuotedMessage = computed(() => {
return EmailQuoteExtractor.hasQuotes(fullHTML.value);
});
const textToShow = computed(() => {
const text =
props.contentAttributes?.email?.textContent?.full ?? props.content;
return text.replace(/\n/g, '<br>');
});
</script>
<template>
<BaseBubble class="w-full overflow-hidden" data-bubble-name="email">
<EmailMeta :status :sender :content-attributes />
<section
ref="contentContainer"
class="p-4"
:class="{
'max-h-[400px] overflow-hidden relative': !isExpanded && isExpandable,
}"
>
<div
v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-40 p-8 flex items-end bg-gradient-to-t dark:from-[#24252b] from-[#F5F5F6] dark:via-[rgba(36,37,43,0.5)] via-[rgba(245,245,246,0.50)] dark:to-transparent to-[rgba(245,245,246,0.00)]"
>
<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" :content="content" />
<template v-else>
<Letter
v-if="showQuotedMessage"
class-name="prose prose-email !max-w-none"
:html="fullHTML"
:text="textToShow"
/>
<Letter
v-else
class-name="prose prose-email !max-w-none"
:html="unquotedHTML"
: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>
</section>
<section v-if="attachments.length" class="px-4 pb-4 space-y-2">
<AttachmentChips :attachments="attachments" class="gap-1" />
</section>
</BaseBubble>
</template>

View File

@@ -0,0 +1,126 @@
// Quote detection strategies
const QUOTE_INDICATORS = [
'.gmail_quote_container',
'.gmail_quote',
'.OutlookQuote',
'.email-quote',
'.quoted-text',
'.quote',
'[class*="quote"]',
'[class*="Quote"]',
];
// Regex patterns for quote identification
const QUOTE_PATTERNS = [
/On .* wrote:/i,
/-----Original Message-----/i,
/Sent: /i,
/From: /i,
];
export class EmailQuoteExtractor {
/**
* Remove quotes from email HTML and return cleaned HTML
* @param {string} htmlContent - Full HTML content of the email
* @returns {string} HTML content with quotes removed
*/
static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
// Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => {
tempDiv.querySelectorAll(selector).forEach(el => {
el.remove();
});
});
// Remove text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
textNodeQuotes.forEach(el => {
el.remove();
});
return tempDiv.innerHTML;
}
/**
* Check if HTML content contains any quotes
* @param {string} htmlContent - Full HTML content of the email
* @returns {boolean} True if quotes are detected, false otherwise
*/
static hasQuotes(htmlContent) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
// Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax
for (const selector of QUOTE_INDICATORS) {
if (tempDiv.querySelector(selector)) {
return true;
}
}
// Check for text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
return textNodeQuotes.length > 0;
}
/**
* Find text nodes that match quote patterns
* @param {Element} rootElement - Root element to search
* @returns {Element[]} Array of parent block elements containing quote-like text
*/
static findTextNodeQuotes(rootElement) {
const quoteBlocks = [];
const treeWalker = document.createTreeWalker(
rootElement,
NodeFilter.SHOW_TEXT,
null,
false
);
for (
let currentNode = treeWalker.nextNode();
currentNode !== null;
currentNode = treeWalker.nextNode()
) {
const isQuoteLike = QUOTE_PATTERNS.some(pattern =>
pattern.test(currentNode.textContent)
);
if (isQuoteLike) {
const parentBlock = this.findParentBlock(currentNode);
if (parentBlock && !quoteBlocks.includes(parentBlock)) {
quoteBlocks.push(parentBlock);
}
}
}
return quoteBlocks;
}
/**
* Find the closest block-level parent element by recursively traversing up the DOM tree.
* This method searches for common block-level elements like DIV, P, BLOCKQUOTE, and SECTION
* that contain the text node. It's used to identify and remove entire block-level elements
* that contain quote-like text, rather than just removing the text node itself. This ensures
* proper structural removal of quoted content while maintaining HTML integrity.
* @param {Node} node - Starting node to find parent
* @returns {Element|null} Block-level parent element
*/
static findParentBlock(node) {
const blockElements = ['DIV', 'P', 'BLOCKQUOTE', 'SECTION'];
let current = node.parentElement;
while (current) {
if (blockElements.includes(current.tagName)) {
return current;
}
current = current.parentElement;
}
return null;
}
}

View File

@@ -0,0 +1,73 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import BaseAttachmentBubble from './BaseAttachment.vue';
import FileIcon from 'next/icon/FileIcon.vue';
/**
* @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} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
attachments: {
type: Array,
required: true,
},
sender: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const url = computed(() => {
return props.attachments[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="sender"
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,111 @@
<script setup>
import { ref, computed } from 'vue';
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 'next/message/provider.js';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
/**
* @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} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
attachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['error']);
const attachment = computed(() => {
return props.attachments[0];
});
const hasError = ref(false);
const showGallery = ref(false);
const { filteredCurrentChatAttachments } = useMessageContext();
const handleError = () => {
hasError.value = true;
emit('error');
};
const downloadAttachment = async () => {
const response = await fetch(attachment.value.dataUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `attachment${attachment.value.extension || ''}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
};
</script>
<template>
<BaseBubble
class="overflow-hidden relative group border-[4px] border-n-weak"
data-bubble-name="image"
@click="showGallery = true"
>
<div
v-if="hasError"
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.MEDIA.IMAGE_UNAVAILABLE') }}
</p>
</div>
<template v-else>
<img
:src="attachment.dataUrl"
:width="attachment.width"
:height="attachment.height"
@click="onClick"
@error="handleError"
/>
<div
class="inset-0 p-2 absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex items-end justify-end gap-1.5"
>
<Button xs solid slate icon="i-lucide-expand" class="opacity-60" />
<Button
xs
solid
slate
icon="i-lucide-download"
class="opacity-60"
@click="downloadAttachment"
/>
</div>
</template>
</BaseBubble>
<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,89 @@
<script setup>
import { ref, computed } from 'vue';
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 } 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({
content: {
type: String,
required: true,
},
attachments: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['error']);
const attachment = computed(() => {
return props.attachments[0];
});
const { variant } = useMessageContext();
const hasImgStoryError = ref(false);
const hasVideoStoryError = ref(false);
const formattedContent = computed(() => {
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
return props.content;
}
return new MessageFormatter(props.content).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">
<div v-if="content" class="mb-2" v-html="formattedContent" />
<img
v-if="!hasImgStoryError"
class="rounded-lg max-w-80"
:src="attachment.dataUrl"
@error="onImageLoadError"
/>
<video
v-else-if="!hasVideoStoryError"
class="rounded-lg max-w-80"
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,108 @@
<script setup>
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
import BaseAttachmentBubble from './BaseAttachment.vue';
import { useI18n } from 'vue-i18n';
import maplibregl from 'maplibre-gl';
/**
* @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} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
attachments: {
type: Array,
required: true,
},
sender: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const attachment = computed(() => {
return props.attachments[0];
});
const lat = computed(() => {
return attachment.value.coordinatesLat;
});
const long = computed(() => {
return attachment.value.coordinatesLong;
});
const title = computed(() => {
return attachment.value.fallbackTitle;
});
const mapUrl = computed(
() => `https://maps.google.com/?q=${lat.value},${long.value}`
);
const mapContainer = useTemplateRef('mapContainer');
const setupMap = () => {
const map = new maplibregl.Map({
style: 'https://tiles.openfreemap.org/styles/positron',
center: [long.value, lat.value],
zoom: 9.5,
container: mapContainer.value,
attributionControl: false,
dragPan: false,
dragRotate: false,
scrollZoom: false,
touchZoom: false,
touchRotate: false,
keyboard: false,
doubleClickZoom: false,
});
return map;
};
onMounted(async () => {
await nextTick();
setupMap();
});
</script>
<template>
<BaseAttachmentBubble
icon="i-ph-navigation-arrow-fill"
icon-bg-color="bg-[#0D9B8A]"
:sender="sender"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.LOCATION"
:content="title"
:action="{
label: t('COMPONENTS.LOCATION_BUBBLE.SEE_ON_MAP'),
href: mapUrl,
}"
>
<template #before>
<div
ref="mapContainer"
class="z-10 w-full max-w-md -mb-12 min-w-64 h-28"
/>
</template>
</BaseAttachmentBubble>
</template>
<style>
@import 'maplibre-gl/dist/maplibre-gl.css';
</style>

View File

@@ -0,0 +1,31 @@
<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="[&>p:last-child]:mb-0 [&>ul]:list-inside"
/>
</template>

View File

@@ -0,0 +1,65 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import { MESSAGE_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({
content: {
type: String,
required: true,
},
attachments: {
type: Array,
default: () => [],
},
contentAttributes: {
type: Object,
default: () => ({}),
},
messageType: {
type: Number,
required: true,
validator: value => Object.values(MESSAGE_TYPES).includes(value),
},
});
const isTemplate = computed(() => {
return props.messageType === MESSAGE_TYPES.TEMPLATE;
});
</script>
<template>
<BaseBubble class="flex flex-col gap-3 px-4 py-3" data-bubble-name="text">
<FormattedContent v-if="content" :content="content" />
<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>
</BaseBubble>
</template>
<style>
p:last-child {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,9 @@
<script setup>
import BaseBubble from './Base.vue';
</script>
<template>
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
</BaseBubble>
</template>

View File

@@ -0,0 +1,84 @@
<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 'next/message/provider.js';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
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
*/
/**
* @typedef {Object} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
attachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['error']);
const hasError = ref(false);
const showGallery = ref(false);
const { filteredCurrentChatAttachments } = useMessageContext();
const handleError = () => {
hasError.value = true;
emit('error');
};
const attachment = computed(() => {
return props.attachments[0];
});
const isReel = computed(() => {
return attachment.value.fileType === ATTACHMENT_TYPES.IG_REEL;
});
</script>
<template>
<BaseBubble
class="overflow-hidden relative group border-[4px] border-n-weak"
data-bubble-name="video"
@click="showGallery = true"
>
<div
v-if="isReel"
class="absolute p-2 flex items-start justify-end size-12 bg-gradient-to-bl from-n-alpha-black1 to-transparent right-0"
>
<Icon icon="i-lucide-instagram" class="text-white" />
</div>
<video
controls
:src="attachment.dataUrl"
:class="{
'max-w-48': isReel,
'max-w-full': !isReel,
}"
@error="handleError"
/>
</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,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,133 @@
<script setup>
import { computed, useTemplateRef, ref } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
const { attachment } = defineProps({
attachment: {
type: Object,
required: 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 onLoadedMetadata = () => {
duration.value = audioPlayer.value.duration;
};
const formatTime = time => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${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 {
audioPlayer.value.play();
isPlaying.value = true;
}
};
const onEnd = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const downloadAudio = async () => {
const response = await fetch(timeStampURL.value);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
const filename = timeStampURL.value.split('/').pop().split('?')[0] || 'audio';
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(anchor);
};
</script>
<template>
<audio
ref="audioPlayer"
controls
class="hidden"
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="timeStampURL" />
</audio>
<div
v-bind="$attrs"
class="rounded-xl w-full gap-1 p-1.5 bg-n-alpha-white flex items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
>
<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 items-center 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="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>
</template>

View File

@@ -0,0 +1,72 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
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 fileName = computed(() => {
const url = attachment.dataUrl;
if (url) {
const filename = url.substring(url.lastIndexOf('/') + 1);
return filename || t('CONVERSATION.UNKNOWN_FILE_TYPE');
}
return t('CONVERSATION.UNKNOWN_FILE_TYPE');
});
const fileType = computed(() => {
return fileName.value.split('.').pop();
});
const textColorClass = computed(() => {
const colorMap = {
'7z': 'dark:text-[#EDEEF0] text-[#2F265F]',
csv: 'text-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-ruby-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[fileType.value] || 'text-n-slate-12';
});
</script>
<template>
<div
class="h-9 bg-n-alpha-white gap-2 items-center flex px-2 rounded-lg border border-n-strong"
>
<FileIcon class="flex-shrink-0" :file-type="fileType" />
<span class="mr-1 max-w-32 truncate" :class="textColorClass">
{{ fileName }}
</span>
<a
v-tooltip="t('CONVERSATION.DOWNLOAD')"
class="flex-shrink-0 h-9 grid place-content-center cursor-pointer text-n-slate-11"
:href="url"
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"
: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,72 @@
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',
};
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',
};
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',
};
export const MEDIA_TYPES = [
ATTACHMENT_TYPES.IMAGE,
ATTACHMENT_TYPES.VIDEO,
ATTACHMENT_TYPES.AUDIO,
ATTACHMENT_TYPES.IG_REEL,
];

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 }
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
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');
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,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>