Compare commits

..
40 changed files with 820 additions and 923 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
source ~/.rvm/scripts/rvm
rvm install "3.3.3"
rvm use 3.3.3 --default
gem install bundler -v 2.5.16
gem install bundler
- run:
name: Install Application Dependencies
@@ -68,7 +68,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contacts = fetch_contacts(contacts)
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
@@ -46,7 +46,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
-8
View File
@@ -81,12 +81,4 @@ module FilterHelper
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
operator = condition['query_operator'].upcase
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
end
end
@@ -18,11 +18,6 @@ const route = useRoute();
const showDropdown = ref(false);
// Store the currently hovered label's ID
// Using JS state management instead of CSS :hover / group hover
// This will solve the flickering issue when hovering over the last label item
const hoveredLabel = ref(null);
const allLabels = useMapGetter('labels/getLabels');
const contactLabels = useMapGetter('contactLabels/getContactLabels');
@@ -42,7 +37,7 @@ const labelMenuItems = computed(() => {
isSelected: savedLabels.value.some(
savedLabel => savedLabel.id === label.id
),
action: 'contactLabel',
action: 'addLabel',
}))
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
});
@@ -54,7 +49,7 @@ const fetchLabels = async contactId => {
store.dispatch('contactLabels/get', contactId);
};
const handleLabelAction = async ({ value }) => {
const handleLabelAction = async ({ action, value }) => {
try {
// Get current label titles
const currentLabels = savedLabels.value.map(label => label.title);
@@ -64,15 +59,16 @@ const handleLabelAction = async ({ value }) => {
if (!selectedLabel) return;
let updatedLabels;
// If label is already selected, remove it (toggle behavior)
if (currentLabels.includes(selectedLabel.title)) {
updatedLabels = currentLabels.filter(
labelTitle => labelTitle !== selectedLabel.title
);
} else {
// Add the new label
updatedLabels = [...currentLabels, selectedLabel.title];
if (action === 'addLabel') {
// If label is already selected, remove it (toggle behavior)
if (currentLabels.includes(selectedLabel.title)) {
updatedLabels = currentLabels.filter(
labelTitle => labelTitle !== selectedLabel.title
);
} else {
// Add the new label
updatedLabels = [...currentLabels, selectedLabel.title];
}
}
await store.dispatch('contactLabels/update', {
@@ -86,10 +82,6 @@ const handleLabelAction = async ({ value }) => {
}
};
const handleRemoveLabel = labelId => {
return handleLabelAction({ value: labelId });
};
watch(
() => props.contactId,
(newVal, oldVal) => {
@@ -103,31 +95,11 @@ onMounted(() => {
fetchLabels(route.params.contactId);
}
});
const handleMouseLeave = () => {
// Reset hover state when mouse leaves the container
// This ensures all labels return to their default state
hoveredLabel.value = null;
};
const handleLabelHover = labelId => {
// Added this to prevent flickering on when showing remove button on hover
// If the label item is at end of the line, it will show the remove button
// when hovering over the last label item
hoveredLabel.value = labelId;
};
</script>
<template>
<div class="flex flex-wrap items-center gap-2" @mouseleave="handleMouseLeave">
<LabelItem
v-for="label in savedLabels"
:key="label.id"
:label="label"
:is-hovered="hoveredLabel === label.id"
@remove="handleRemoveLabel"
@hover="handleLabelHover(label.id)"
/>
<div class="flex flex-wrap items-center gap-2">
<LabelItem v-for="label in savedLabels" :key="label.id" :label="label" />
<AddLabel
:label-menu-items="labelMenuItems"
@update-label="handleLabelAction"
@@ -94,7 +94,7 @@ const prepareStateBasedOnProps = () => {
phoneNumber,
additionalAttributes = {},
} = props.contactData || {};
const { firstName, lastName } = splitName(name || '');
const { firstName, lastName } = splitName(name);
const {
description = '',
companyName = '',
@@ -1,56 +1,22 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
defineProps({
label: {
type: Object,
default: null,
},
isHovered: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['remove', 'hover']);
const handleRemoveLabel = () => {
emit('remove', props.label?.id);
};
const handleMouseEnter = () => {
// Notify parent component when this label is hovered
// Added this to show the remove button with transition when hovering over the label
// This will solve the flickering issue when hovering over the last label item
emit('hover', props.label?.id);
};
</script>
<template>
<div
class="flex items-center px-1 py-1 overflow-hidden transition-all duration-300 ease-out rounded-md bg-n-alpha-2 h-7"
@mouseenter="handleMouseEnter"
class="bg-n-alpha-2 rounded-md flex items-center h-7 w-fit py-1 ltr:pl-1 rtl:pr-1 ltr:pr-1.5 rtl:pl-1.5"
>
<div
class="w-2 h-2 m-1 rounded-sm"
:style="{ backgroundColor: label.color }"
/>
<span class="text-sm text-n-slate-12 ltr:mr-px rtl:ml-px">
<span class="text-sm text-n-slate-12">
{{ label.title }}
</span>
<div
class="w-0 flex relative ltr:left-1 rtl:right-1 flex-shrink-0 overflow-hidden transition-[width] duration-300 ease-out"
:class="{ 'w-6': isHovered }"
>
<Button
class="transition-opacity duration-200 !h-7 ltr:rounded-r-md rtl:rounded-l-md ltr:rounded-l-none rtl:rounded-r-none w-6 bg-transparent"
:class="{ 'opacity-0': !isHovered, 'opacity-100': isHovered }"
slate
xs
faded
icon="i-lucide-x"
@click="handleRemoveLabel"
/>
</div>
</div>
</template>
@@ -1,11 +1,9 @@
<script setup>
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { INPUT_TYPES } from 'dashboard/components-next/taginput/helper/tagInputHelper.js';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
contacts: {
type: Array,
@@ -44,19 +42,15 @@ const props = defineProps({
default: false,
},
});
const emit = defineEmits([
'searchContacts',
'setSelectedContact',
'clearSelectedContact',
'updateDropdown',
]);
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
const { t } = useI18n();
const inputType = ref(INPUT_TYPES.EMAIL);
const contactsList = computed(() => {
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
id,
@@ -86,14 +80,6 @@ const errorClass = computed(() => {
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: '';
});
const handleInput = value => {
// Update input type based on whether input starts with '+'
// If it does, set input type to 'tel'
// Otherwise, set input type to 'email'
inputType.value = value.startsWith('+') ? INPUT_TYPES.TEL : INPUT_TYPES.EMAIL;
emit('searchContacts', value);
};
</script>
<template>
@@ -140,11 +126,11 @@ const handleInput = value => {
:is-loading="isLoading"
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
allow-create
:type="inputType"
type="email"
class="flex-1 min-h-7"
:class="errorClass"
focus-on-mount
@input="handleInput"
@input="emit('searchContacts', $event)"
@on-click-outside="emit('updateDropdown', 'contacts', false)"
@add="emit('setSelectedContact', $event)"
@remove="emit('clearSelectedContact')"
@@ -193,12 +193,10 @@ export const searchContacts = async ({ keys, query }) => {
return filteredPayload || [];
};
export const createNewContact = async input => {
export const createNewContact = async email => {
const payload = {
name: input.startsWith('+')
? input.slice(1) // Remove the '+' prefix if it exists
: getCapitalizedNameFromEmail(input),
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
name: getCapitalizedNameFromEmail(email),
email,
};
const {
@@ -429,28 +429,6 @@ describe('composeConversationHelper', () => {
email: 'john@example.com',
});
});
it('creates new contact with phone number', async () => {
const mockContact = {
id: 1,
name: '919999999999',
phone_number: '+919999999999',
};
ContactAPI.create.mockResolvedValue({
data: { payload: { contact: mockContact } },
});
const result = await helpers.createNewContact('+919999999999');
expect(result).toEqual({
id: 1,
name: '919999999999',
phoneNumber: '+919999999999',
});
expect(ContactAPI.create).toHaveBeenCalledWith({
name: '919999999999',
phone_number: '+919999999999',
});
});
});
describe('fetchContactableInboxes', () => {
@@ -1,6 +1,12 @@
<script setup>
import { computed, defineAsyncComponent } from 'vue';
import { computed, ref, defineAsyncComponent } from 'vue';
import { provideMessageContext } from './provider.js';
import { useTrack } from 'dashboard/composables';
import { emitter } from 'shared/helpers/mitt';
import { LocalStorage } from 'shared/helpers/localStorage';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
MESSAGE_TYPES,
ATTACHMENT_TYPES,
@@ -8,6 +14,7 @@ import {
SENDER_TYPES,
ORIENTATION,
MESSAGE_STATUS,
CONTENT_TYPES,
} from './constants';
import Avatar from 'next/avatar/Avatar.vue';
@@ -30,6 +37,7 @@ const LocationBubble = defineAsyncComponent(
import MessageError from './MessageError.vue';
import MessageMeta from './MessageMeta.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
/**
* @typedef {Object} Attachment
@@ -65,7 +73,7 @@ import MessageMeta from './MessageMeta.vue';
/**
* @typedef {Object} Props
* @property {('sent'|'delivered'|'read'|'failed')} status - The delivery status of the message
* @property {('sent'|'delivered'|'read'|'failed'|'progress')} status - The delivery status of the message
* @property {ContentAttributes} [contentAttributes={}] - Additional attributes of the message content
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
* @property {Sender|null} [sender=null] - The sender information
@@ -78,6 +86,11 @@ import MessageMeta from './MessageMeta.vue';
* @property {string|null} [error=null] - Error message if the message failed to send
* @property {string|null} [senderType=null] - The type of the sender
* @property {string} content - The message content
* @property {boolean} [groupWithNext=false] - Whether the message should be grouped with the next message
* @property {Object|null} [inReplyTo=null] - The message to which this message is a reply
* @property {boolean} [isEmailInbox=false] - Whether the message is from an email inbox
* @property {number} conversationId - The ID of the conversation to which the message belongs
* @property {number} inboxId - The ID of the inbox to which the message belongs
*/
// eslint-disable-next-line vue/define-macros-order
@@ -93,70 +106,51 @@ const props = defineProps({
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: {
attachments: { type: Array, default: () => [] },
content: { type: String, default: null },
contentAttributes: { type: Object, default: () => ({}) },
contentType: {
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,
default: 'text',
validator: value => Object.values(CONTENT_TYPES).includes(value),
},
conversationId: { type: Number, required: true },
createdAt: { type: Number, required: true },
currentUserId: { type: Number, required: true },
groupWithNext: { type: Boolean, default: false },
inboxId: { type: Number, required: true },
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
inReplyTo: { type: Object, default: null },
isEmailInbox: { type: Boolean, default: false },
private: { type: Boolean, default: false },
sender: { type: Object, default: null },
senderId: { type: Number, default: null },
senderType: { type: String, default: null },
sourceId: { type: String, default: '' },
});
const contextMenuPosition = ref({});
const showContextMenu = ref(false);
/**
* Computes the message variant based on props
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
*/
const variant = computed(() => {
if (props.private) return MESSAGE_VARIANTS.PRIVATE;
if (props.isEmailInbox) {
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
if (emailInboxTypes.includes(props.messageType)) {
return MESSAGE_VARIANTS.EMAIL;
}
}
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
return MESSAGE_VARIANTS.EMAIL;
}
if (props.status === MESSAGE_STATUS.FAILED) return MESSAGE_VARIANTS.ERROR;
if (props.contentAttributes.isUnsupported)
if (props.contentAttributes?.isUnsupported)
return MESSAGE_VARIANTS.UNSUPPORTED;
const variants = {
@@ -170,10 +164,20 @@ const variant = computed(() => {
});
const isMyMessage = computed(() => {
// if an outgoing message is still processing, then it's definitely a
// message sent by the current user
if (
props.status === MESSAGE_STATUS.PROGRESS &&
props.messageType === MESSAGE_TYPES.OUTGOING
) {
return true;
}
const senderId = props.senderId ?? props.sender?.id;
const senderType = props.senderType ?? props.sender?.type;
if (!senderType || !senderId) return false;
if (!senderType || !senderId) {
return false;
}
return (
senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase() &&
@@ -248,7 +252,11 @@ const componentToRender = computed(() => {
if (emailInboxTypes.includes(props.messageType)) return EmailBubble;
}
if (props.contentAttributes.isUnsupported) {
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
return EmailBubble;
}
if (props.contentAttributes?.isUnsupported) {
return UnsupportedBubble;
}
@@ -260,7 +268,7 @@ const componentToRender = computed(() => {
return InstagramStoryBubble;
}
if (props.attachments.length === 1) {
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
const fileType = props.attachments[0].fileType;
if (!props.content) {
@@ -275,13 +283,88 @@ const componentToRender = computed(() => {
if (fileType === ATTACHMENT_TYPES.CONTACT) return ContactBubble;
}
if (props.attachments.length > 1 && !props.content) {
if (
Array.isArray(props.attachments) &&
props.attachments.length > 1 &&
!props.content
) {
return AttachmentsBubble;
}
return TextBubble;
});
const shouldShowContextMenu = computed(() => {
return !(
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS ||
props.contentAttributes?.isUnsupported
);
});
const isBubble = computed(() => {
return props.messageType !== MESSAGE_TYPES.ACTIVITY;
});
const isMessageDeleted = computed(() => {
return props.contentAttributes?.deleted;
});
const payloadForContextMenu = computed(() => {
return {
id: props.id,
content_attributes: props.contentAttributes,
content: props.content,
conversation_id: props.conversationId,
};
});
const contextMenuEnabledOptions = computed(() => {
const hasText = !!props.content;
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
return {
copy: hasText,
delete: hasText || hasAttachments,
cannedResponse: isOutgoing && hasText,
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
};
});
function openContextMenu(e) {
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
e.target?.tagName.toLowerCase() === 'a';
if (shouldSkipContextMenu || getSelection().toString()) {
return;
}
e.preventDefault();
if (e.type === 'contextmenu') {
useTrack(ACCOUNT_EVENTS.OPEN_MESSAGE_CONTEXT_MENU);
}
contextMenuPosition.value = {
x: e.pageX || e.clientX,
y: e.pageY || e.clientY,
};
showContextMenu.value = true;
}
function closeContextMenu() {
showContextMenu.value = false;
contextMenuPosition.value = { x: null, y: null };
}
function handleReplyTo() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
const { conversationId, id: replyTo } = props;
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, props);
}
provideMessageContext({
variant,
inReplyTo: props.inReplyTo,
@@ -292,6 +375,7 @@ provideMessageContext({
<template>
<div
:id="`message${props.id}`"
class="flex w-full"
:data-message-id="props.id"
:class="[flexOrientationClass, shouldGroupWithNext ? 'mb-2' : 'mb-4']"
@@ -324,10 +408,11 @@ provideMessageContext({
/>
</div>
<div
class="[grid-area:bubble]"
class="[grid-area:bubble] flex"
:class="{
'pl-9': ORIENTATION.RIGHT === orientation,
}"
@contextmenu="openContextMenu($event)"
>
<Component :is="componentToRender" v-bind="props" />
</div>
@@ -344,8 +429,22 @@ provideMessageContext({
:sender="props.sender"
:status="props.status"
:private="props.private"
:is-my-message="isMyMessage"
:message-type="props.messageType"
:created-at="props.createdAt"
:source-id="props.sourceId"
/>
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<ContextMenu
v-if="isBubble && !isMessageDeleted"
:context-menu-position="contextMenuPosition"
:is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions"
:message="payloadForContextMenu"
hide-button
@open="openContextMenu"
@close="closeContextMenu"
@reply-to="handleReplyTo"
/>
</div>
</div>
@@ -0,0 +1,146 @@
<script setup>
import { defineProps, computed } from 'vue';
import Message from './Message.vue';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
/**
* Props definition for the component
* @typedef {Object} Props
* @property {Array} readMessages - Array of read messages
* @property {Array} unReadMessages - Array of unread messages
* @property {Number} currentUserId - ID of the current user
* @property {Boolean} isAnEmailChannel - Whether this is an email channel
* @property {Object} inboxSupportsReplyTo - Inbox reply support configuration
* @property {Array} messages - Array of all messages
*/
const props = defineProps({
readMessages: {
type: Array,
default: () => [],
},
unReadMessages: {
type: Array,
default: () => [],
},
currentUserId: {
type: Number,
required: true,
},
isAnEmailChannel: {
type: Boolean,
default: false,
},
inboxSupportsReplyTo: {
type: Object,
default: () => ({ incoming: false, outgoing: false }),
},
messages: {
type: Array,
default: () => [],
},
shouldShowSpinner: {
type: Boolean,
default: false,
},
unreadMessageCount: {
type: Number,
default: 0,
},
});
const unread = computed(() => {
return useCamelCase(props.unReadMessages, { deep: true });
});
const read = computed(() => {
return useCamelCase(props.readMessages, { deep: true });
});
/**
* Determines if a message should be grouped with the next message
* @param {Number} index - Index of the current message
* @param {Array} messages - Array of messages to check
* @returns {Boolean} - Whether the message should be grouped with next
*/
const shouldGroupWithNext = (index, messages) => {
if (index === messages.length - 1) return false;
const current = messages[index];
const next = messages[index + 1];
if (next.status === 'failed') return false;
const nextSenderId = next.senderId ?? next.sender?.id;
const currentSenderId = current.senderId ?? current.sender?.id;
if (currentSenderId !== nextSenderId) return false;
// Check if messages are in the same minute by rounding down to nearest minute
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
};
/**
* Gets the message that was replied to
* @param {Object} parentMessage - The message containing the reply reference
* @returns {Object|null} - The message being replied to, or null if not found
*/
const getInReplyToMessage = parentMessage => {
if (!parentMessage) return null;
const inReplyToMessageId =
parentMessage.contentAttributes?.inReplyTo ??
parentMessage.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return null;
// Find in-reply-to message in the messages prop
const replyMessage = props.messages?.find(
message => message.id === inReplyToMessageId
);
return replyMessage ? useCamelCase(replyMessage) : null;
};
</script>
<template>
<ul class="px-4 bg-n-background">
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
<span v-if="shouldShowSpinner" class="spinner message" />
</li>
</transition>
<Message
v-for="(message, index) in read"
:key="message.id"
v-bind="message"
:is-email-inbox="isAnEmailChannel"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, readMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
/>
<li v-show="unreadMessageCount != 0" class="unread--toast">
<span>
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
{{
unreadMessageCount > 1
? $t('CONVERSATION.UNREAD_MESSAGES')
: $t('CONVERSATION.UNREAD_MESSAGE')
}}
</span>
</li>
<Message
v-for="(message, index) in unread"
:key="message.id"
v-bind="message"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, unReadMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
:is-email-inbox="isAnEmailChannel"
data-clarity-mask="True"
/>
<slot name="after" />
</ul>
</template>
@@ -4,8 +4,9 @@ import { messageTimestamp } from 'shared/helpers/timeHelper';
import MessageStatus from './MessageStatus.vue';
import Icon from 'next/icon/Icon.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { MESSAGE_STATUS } from './constants';
import { MESSAGE_STATUS, MESSAGE_TYPES } from './constants';
/**
* @typedef {Object} Sender
@@ -43,21 +44,116 @@ const props = defineProps({
type: Boolean,
default: false,
},
isMyMessage: {
type: Boolean,
default: false,
},
createdAt: {
type: Number,
required: true,
},
sourceId: {
type: String,
default: '',
},
messageType: {
type: Number,
required: true,
validator: value => Object.values(MESSAGE_TYPES).includes(value),
},
});
const {
isAFacebookInbox,
isALineChannel,
isAPIInbox,
isASmsInbox,
isATelegramChannel,
isATwilioChannel,
isAWebWidgetInbox,
isAWhatsAppChannel,
isAnEmailChannel,
} = useInbox();
const readableTime = computed(() =>
messageTimestamp(props.createdAt, 'LLL d, h:mm a')
);
const showSender = computed(() => !props.isMyMessage && props.sender);
const showStatusIndicator = computed(() => {
if (props.private) return false;
if (props.messageType === MESSAGE_TYPES.OUTGOING) return true;
if (props.messageType === MESSAGE_TYPES.TEMPLATE) return true;
return false;
});
const isSent = computed(() => {
if (!showStatusIndicator.value) return false;
// Messages will be marked as sent for the Email channel if they have a source ID.
if (isAnEmailChannel.value) return !!props.sourceId;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value
) {
return props.sourceId && props.status === MESSAGE_STATUS.SENT;
}
// All messages will be mark as sent for the Line channel, as there is no source ID.
if (props.isALineChannel) return true;
return false;
});
const isDelivered = computed(() => {
if (!showStatusIndicator.value) return false;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isASmsInbox.value ||
isAFacebookInbox.value
) {
return props.sourceId && props.status === MESSAGE_STATUS.DELIVERED;
}
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (isAWebWidgetInbox.value || isAPIInbox.value) {
return props.status === MESSAGE_STATUS.SENT;
}
if (isALineChannel.value) {
return props.status === MESSAGE_STATUS.DELIVERED;
}
return false;
});
const isRead = computed(() => {
if (!showStatusIndicator.value) return false;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value
) {
return props.sourceId && props.status === MESSAGE_STATUS.READ;
}
if (isAWebWidgetInbox.value || isAPIInbox.value) {
return props.status === MESSAGE_STATUS.READ;
}
return false;
});
const statusToShow = computed(() => {
if (isRead.value) return MESSAGE_STATUS.READ;
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
if (isSent.value) return MESSAGE_STATUS.SENT;
return MESSAGE_STATUS.PROGRESS;
});
</script>
<template>
@@ -72,6 +168,6 @@ const showSender = computed(() => !props.isMyMessage && props.sender);
icon="i-lucide-lock-keyhole"
class="text-n-slate-10 size-3"
/>
<MessageStatus v-if="props.isMyMessage" :status />
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
</div>
</template>
@@ -2,7 +2,7 @@
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
import BaseAttachmentBubble from './BaseAttachment.vue';
import { useI18n } from 'vue-i18n';
import maplibregl from 'maplibre-gl';
// import maplibregl from 'maplibre-gl';
/**
* @typedef {Object} Attachment
@@ -57,28 +57,28 @@ const mapUrl = computed(
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;
};
// const setupMap = () => {
// const map = new maplibregl.Map({
// style: 'https://tiles.openfreemap.org/styles/positron',
// center: [long.value, lat.value],
// zoom: 15,
// container: mapContainer.value,
// attributionControl: false,
// dragPan: false,
// dragRotate: false,
// scrollZoom: false,
// touchZoom: false,
// touchRotate: false,
// keyboard: false,
// doubleClickZoom: false,
// });
// new maplibregl.Marker().setLngLat([long.value, lat.value]).addTo(map);
// return map;
// };
onMounted(async () => {
await nextTick();
setupMap();
// setupMap();
});
</script>
@@ -1,24 +1,16 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import {
MODE,
INPUT_TYPES,
getValidationRules,
checkTagTypeValidity,
buildTagMenuItems,
canAddTag,
findMatchingMenuItem,
} from './helper/tagInputHelper';
const props = defineProps({
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
type: { type: String, default: INPUT_TYPES.TEXT },
type: { type: String, default: 'text' },
isLoading: { type: Boolean, default: false },
menuItems: {
type: Array,
@@ -31,8 +23,8 @@ const props = defineProps({
showDropdown: { type: Boolean, default: false },
mode: {
type: String,
default: MODE.MULTIPLE,
validator: value => [MODE.SINGLE, MODE.MULTIPLE].includes(value),
default: 'multiple',
validator: value => ['single', 'multiple'].includes(value),
},
focusOnMount: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
@@ -53,17 +45,22 @@ const modelValue = defineModel({
default: () => [],
});
const MODE = {
SINGLE: 'single',
MULTIPLE: 'multiple',
};
const tagInputRef = ref(null);
const tags = ref(props.modelValue);
const newTag = ref('');
const isFocused = ref(true);
const rules = computed(() => getValidationRules(props.type));
const v$ = useVuelidate(rules, { newTag });
const rules = computed(() => ({
newTag: props.type === 'email' ? { email } : {},
}));
const isNewTagInValidType = computed(() =>
checkTagTypeValidity(props.type, newTag.value, v$.value)
);
const v$ = useVuelidate(rules, { newTag });
const isNewTagInValidType = computed(() => v$.value.$invalid);
const showInput = computed(() =>
props.mode === MODE.SINGLE
@@ -77,49 +74,68 @@ const showDropdownMenu = computed(() =>
: props.showDropdown
);
const filteredMenuItems = computed(() =>
buildTagMenuItems({
mode: props.mode,
tags: tags.value,
menuItems: props.menuItems,
newTag: newTag.value,
isLoading: props.isLoading,
type: props.type,
isNewTagInValidType: isNewTagInValidType.value,
})
);
const filteredMenuItems = computed(() => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return [];
const availableMenuItems = props.menuItems.filter(
item => !tags.value.includes(item.label)
);
// Show typed value as suggestion only if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. Email validation passes (if type is email) and There are no menu items available
const trimmedNewTag = newTag.value?.trim();
const shouldShowTypedValue =
trimmedNewTag &&
!tags.value.includes(trimmedNewTag) &&
!props.isLoading &&
!availableMenuItems.length &&
(props.type === 'email' ? !isNewTagInValidType.value : true);
if (shouldShowTypedValue) {
return [
{
label: trimmedNewTag,
value: trimmedNewTag,
email: trimmedNewTag,
thumbnail: { name: trimmedNewTag, src: '' },
action: 'create',
},
];
}
return availableMenuItems;
});
const emitDataOnAdd = emailValue => {
const matchingMenuItem = props.menuItems.find(
item => item.email === emailValue
);
const emitDataOnAdd = value => {
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
return matchingMenuItem
? emit('add', { value: value, ...matchingMenuItem })
: emit('add', { value: value, action: 'create' });
};
const updateValueAndFocus = value => {
tags.value.push(value);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
? emit('add', { email: emailValue, ...matchingMenuItem })
: emit('add', { value: emailValue, action: 'create' });
};
const addTag = async () => {
const trimmedTag = newTag.value?.trim();
if (!trimmedTag) return;
if (!canAddTag(props.mode, tags.value.length)) {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
newTag.value = '';
return;
}
if (
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
props.allowCreate
) {
if (props.type === 'email' || props.allowCreate) {
if (!(await v$.value.$validate())) return;
emitDataOnAdd(trimmedTag);
}
updateValueAndFocus(trimmedTag);
tags.value.push(trimmedTag);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
};
const removeTag = index => {
@@ -128,25 +144,19 @@ const removeTag = index => {
emit('remove');
};
const handleDropdownAction = async ({
email: emailAddress,
phoneNumber,
...rest
}) => {
const handleDropdownAction = async ({ email: emailAddress, ...rest }) => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (!props.showDropdown) return;
const isEmail = props.type === 'email';
newTag.value = isEmail ? emailAddress : phoneNumber;
if (props.type === 'email' && props.showDropdown) {
newTag.value = emailAddress;
if (!(await v$.value.$validate())) return;
emit('add', { email: emailAddress, ...rest });
}
if (!(await v$.value.$validate())) return;
const payload = isEmail
? { email: emailAddress, ...rest }
: { phoneNumber, ...rest };
emit('add', payload);
updateValueAndFocus(emailAddress);
tags.value.push(emailAddress);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
};
const handleFocus = () => {
@@ -216,6 +226,7 @@ const handleBlur = e => emit('blur', e);
ref="tagInputRef"
v-model="newTag"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
class="w-full"
:focus-on-mount="focusOnMount"
@@ -1,299 +0,0 @@
import {
validatePhoneNumber,
formatPhoneNumber,
buildTagMenuItems,
MODE,
INPUT_TYPES,
getValidationRules,
validateAndFormatNewTag,
createNewTagMenuItem,
canAddTag,
findMatchingMenuItem,
} from '../tagInputHelper';
import { email } from '@vuelidate/validators';
describe('tagInputHelper', () => {
describe('validatePhoneNumber', () => {
it('returns true for empty value', () => {
expect(validatePhoneNumber('')).toBe(true);
});
it('validates correct phone number', () => {
expect(validatePhoneNumber('+918283838283')).toBe(true);
});
it('validates correct phone number id + is present and number is not valid', () => {
expect(validatePhoneNumber('+91828383834283')).toBe(false);
});
it('validates correct phone number if + is not present', () => {
expect(validatePhoneNumber('91828383834283')).toBe(false);
});
it('invalidates incorrect phone number', () => {
expect(validatePhoneNumber('invalid')).toBe(false);
});
it('handles null value', () => {
expect(validatePhoneNumber(null)).toBe(true);
});
});
describe('formatPhoneNumber', () => {
it('formats valid phone number', () => {
const result = formatPhoneNumber('+918283838283');
expect(result.isValid).toBe(true);
expect(result.formattedValue).toBe('+91 82838 38283');
});
it('handles invalid phone number', () => {
const result = formatPhoneNumber('invalid');
expect(result.isValid).toBe(false);
expect(result.formattedValue).toBe('invalid');
});
it('handles error case', () => {
const result = formatPhoneNumber(null);
expect(result.isValid).toBe(false);
expect(result.formattedValue).toBe(null);
});
});
describe('getValidationRules', () => {
it('returns email validation for email type', () => {
const rules = getValidationRules(INPUT_TYPES.EMAIL);
expect(rules.newTag).toHaveProperty('email');
expect(rules.newTag).not.toHaveProperty('isValidPhone');
expect(rules.newTag.email).toBe(email);
});
it('returns phone validation for tel type', () => {
const rules = getValidationRules(INPUT_TYPES.TEL);
expect(rules.newTag).toHaveProperty('isValidPhone');
expect(rules.newTag).not.toHaveProperty('email');
expect(rules.newTag.isValidPhone).toBe(validatePhoneNumber);
});
it('returns empty rules for text type', () => {
const rules = getValidationRules(INPUT_TYPES.TEXT);
expect(Object.keys(rules.newTag)).toHaveLength(0);
});
});
describe('validateAndFormatNewTag', () => {
it('validates and formats email tag', () => {
const result = validateAndFormatNewTag(
'test@example.com',
INPUT_TYPES.EMAIL,
false
);
expect(result).toEqual({
isValid: true,
formattedValue: 'test@example.com',
});
});
it('validates and formats phone tag', () => {
const result = validateAndFormatNewTag(
'+918283838283',
INPUT_TYPES.TEL,
false
);
expect(result.isValid).toBe(true);
expect(result.formattedValue).toBe('+91 82838 38283');
});
it('handles invalid email', () => {
const result = validateAndFormatNewTag(
'test@example.com',
INPUT_TYPES.EMAIL,
true
);
expect(result.isValid).toBe(false);
expect(result.formattedValue).toBe('test@example.com');
});
it('handles text type', () => {
const result = validateAndFormatNewTag(
'sample text',
INPUT_TYPES.TEXT,
false
);
expect(result.isValid).toBe(true);
expect(result.formattedValue).toBe('sample text');
});
});
describe('createNewTagMenuItem', () => {
it('creates email menu item', () => {
const result = createNewTagMenuItem(
'test@example.com',
'test@example.com',
INPUT_TYPES.EMAIL
);
expect(result).toEqual({
label: 'test@example.com',
value: 'test@example.com',
email: 'test@example.com',
thumbnail: { name: 'test@example.com', src: '' },
action: 'create',
});
});
it('creates phone menu item', () => {
const result = createNewTagMenuItem(
'+91 82838 38283',
'+918283838283',
INPUT_TYPES.TEL
);
expect(result).toEqual({
label: '+91 82838 38283',
value: '+918283838283',
phoneNumber: '+918283838283',
thumbnail: { name: '+91 82838 38283', src: '' },
action: 'create',
});
});
it('creates text menu item', () => {
const result = createNewTagMenuItem(
'sample text',
'sample text',
INPUT_TYPES.TEXT
);
expect(result).toEqual({
label: 'sample text',
value: 'sample text',
thumbnail: { name: 'sample text', src: '' },
action: 'create',
});
});
});
describe('buildTagMenuItems', () => {
const baseParams = {
mode: MODE.MULTIPLE,
tags: [],
menuItems: [],
newTag: '',
isLoading: false,
type: INPUT_TYPES.TEXT,
isNewTagInValidType: false,
};
it('returns empty array in single mode with existing tag', () => {
const result = buildTagMenuItems({
...baseParams,
mode: MODE.SINGLE,
tags: ['existing'],
});
expect(result).toEqual([]);
});
it('filters out existing tags', () => {
const result = buildTagMenuItems({
...baseParams,
menuItems: [
{ label: 'item1', value: '1' },
{ label: 'item2', value: '2' },
],
tags: ['item1'],
});
expect(result).toHaveLength(1);
expect(result[0].label).toBe('item2');
});
it('creates new email item when valid', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
menuItems: [],
});
expect(result[0]).toMatchObject({
label: 'test@example.com',
email: 'test@example.com',
action: 'create',
});
});
it('creates new phone item when valid', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.TEL,
newTag: '+918283838283',
menuItems: [],
});
expect(result[0]).toMatchObject({
value: '+918283838283',
label: '+91 82838 38283',
action: 'create',
});
});
it('returns empty array when loading', () => {
const result = buildTagMenuItems({
...baseParams,
isLoading: true,
newTag: 'test',
});
expect(result).toEqual([]);
});
it('returns empty array for invalid tag', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'invalid-email',
isNewTagInValidType: true,
});
expect(result).toEqual([]);
});
it('returns available menu items when no new tag', () => {
const menuItems = [
{ label: 'item1', value: '1' },
{ label: 'item2', value: '2' },
];
const result = buildTagMenuItems({
...baseParams,
menuItems,
});
expect(result).toEqual(menuItems);
});
});
describe('canAddTag', () => {
it('prevents adding tags in single mode when tag exists', () => {
expect(canAddTag(MODE.SINGLE, 1)).toBe(false);
expect(canAddTag(MODE.SINGLE, 0)).toBe(true);
});
it('allows adding tags in multiple mode', () => {
expect(canAddTag(MODE.MULTIPLE, 1)).toBe(true);
expect(canAddTag(MODE.MULTIPLE, 0)).toBe(true);
});
});
describe('findMatchingMenuItem', () => {
const menuItems = [
{ email: 'test1@example.com', label: 'Test 1' },
{ email: 'test2@example.com', label: 'Test 2' },
];
it('finds matching menu item by email', () => {
const result = findMatchingMenuItem(menuItems, 'test1@example.com');
expect(result).toEqual(menuItems[0]);
});
it('returns undefined when no match found', () => {
const result = findMatchingMenuItem(menuItems, 'nonexistent@example.com');
expect(result).toBeUndefined();
});
it('handles empty menu items', () => {
const result = findMatchingMenuItem([], 'test@example.com');
expect(result).toBeUndefined();
});
});
});
@@ -1,124 +0,0 @@
import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js';
import { email } from '@vuelidate/validators';
export const MODE = {
SINGLE: 'single',
MULTIPLE: 'multiple',
};
export const INPUT_TYPES = {
EMAIL: 'email',
TEL: 'tel',
TEXT: 'text',
};
export const validatePhoneNumber = value => {
if (!value) return true;
try {
return isValidPhoneNumber(value);
} catch (error) {
return false;
}
};
export const formatPhoneNumber = value => {
try {
const phoneNumber = parsePhoneNumber(value);
return {
isValid: phoneNumber?.isValid() || false,
formattedValue: phoneNumber?.formatInternational() || value,
};
} catch (error) {
return { isValid: false, formattedValue: value };
}
};
export const getValidationRules = type => ({
newTag: {
...(type === INPUT_TYPES.EMAIL ? { email } : {}),
...(type === INPUT_TYPES.TEL ? { isValidPhone: validatePhoneNumber } : {}),
},
});
export const checkTagTypeValidity = (type, value, v$) => {
if (type === INPUT_TYPES.TEL) {
return !validatePhoneNumber(value);
}
return v$.$invalid;
};
export const validateAndFormatNewTag = (
trimmedNewTag,
type,
isNewTagInValidType
) => {
let isValid = true;
let formattedValue = trimmedNewTag;
if (type === INPUT_TYPES.EMAIL) {
isValid = !isNewTagInValidType;
} else if (type === INPUT_TYPES.TEL) {
const { isValid: phoneValid, formattedValue: phoneFormatted } =
formatPhoneNumber(trimmedNewTag);
isValid = phoneValid;
formattedValue = phoneFormatted;
}
return { isValid, formattedValue };
};
export const createNewTagMenuItem = (formattedValue, trimmedNewTag, type) => ({
label: formattedValue,
value: trimmedNewTag,
...(type === INPUT_TYPES.EMAIL ? { email: trimmedNewTag } : {}),
...(type === INPUT_TYPES.TEL ? { phoneNumber: trimmedNewTag } : {}),
thumbnail: { name: formattedValue, src: '' },
action: 'create',
});
export const buildTagMenuItems = ({
mode,
tags,
menuItems,
newTag,
isLoading,
type,
isNewTagInValidType,
}) => {
if (mode === MODE.SINGLE && tags.length >= 1) return [];
const availableMenuItems = menuItems.filter(
item => !tags.includes(item.label)
);
// Show typed value as suggestion only if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. Validation passes (email/phone) and There are no menu items available
const trimmedNewTag = newTag?.trim();
const shouldShowTypedValue =
trimmedNewTag &&
!tags.includes(trimmedNewTag) &&
!isLoading &&
!availableMenuItems.length;
if (shouldShowTypedValue) {
const { isValid, formattedValue } = validateAndFormatNewTag(
trimmedNewTag,
type,
isNewTagInValidType
);
if (isValid) {
return [createNewTagMenuItem(formattedValue, trimmedNewTag, type)];
}
}
return availableMenuItems;
};
export const canAddTag = (mode, tagsLength) =>
!(mode === MODE.SINGLE && tagsLength >= 1);
export const findMatchingMenuItem = (menuItems, value) =>
menuItems.find(item => item.email === value);
@@ -4,10 +4,13 @@ import { ref } from 'vue';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
// components
import ReplyBox from './ReplyBox.vue';
import Message from './Message.vue';
import NextMessage from 'next/message/Message.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
@@ -34,9 +37,26 @@ import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
function shouldGroupWithNext(index, messages) {
if (index === messages.length - 1) return false;
const current = messages[index];
const next = messages[index + 1];
if (next.status === 'failed') return false;
const nextSenderId = next.senderId ?? next.sender?.id;
const currentSenderId = current.senderId ?? current.sender?.id;
if (currentSenderId !== nextSenderId) return false;
// Check if messages are in the same minute by rounding down to nearest minute
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
}
export default {
components: {
Message,
NextMessage,
ReplyBox,
Banner,
ConversationLabelSuggestion,
@@ -56,6 +76,7 @@ export default {
setup() {
const isPopOutReplyBox = ref(false);
const { isEnterprise } = useConfig();
const { accountId } = useAccount();
const closePopOutReplyBox = () => {
isPopOutReplyBox.value = false;
@@ -80,6 +101,10 @@ export default {
fetchLabelSuggestions,
} = useAI();
const showNextBubbles = LocalStorage.get(
LOCAL_STORAGE_KEYS.USE_NEXT_BUBBLE
);
return {
isEnterprise,
isPopOutReplyBox,
@@ -89,6 +114,8 @@ export default {
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
accountId,
showNextBubbles,
};
},
data() {
@@ -106,6 +133,7 @@ export default {
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
}),
@@ -153,16 +181,28 @@ export default {
return messages;
},
readMessages() {
return getReadMessages(
const readMessages = getReadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
if (this.showNextBubbles) {
return useCamelCase(readMessages, { deep: true });
}
return readMessages;
},
unReadMessages() {
return getUnreadMessages(
const unreadMessages = getUnreadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
if (this.showNextBubbles) {
return useCamelCase(unreadMessages, { deep: true });
}
return unreadMessages;
},
shouldShowSpinner() {
return (
@@ -436,11 +476,19 @@ export default {
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
getInReplyToMessage(parentMessage) {
if (!parentMessage) return {};
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return {};
// the old implementation took an empty object, but the
// new implementation takes null
const emptyOption = this.showNextBubbles ? null : {};
if (!parentMessage) return emptyOption;
// to maintain backward compatibility we use both the keys
// contentAttributes and content_attributes
// TODO: Remove this once we've migrated all the keys to camelCase
const inReplyToMessageId =
parentMessage.contentAttributes?.inReplyTo ??
parentMessage.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return emptyOption;
return this.currentChat?.messages.find(message => {
if (message.id === inReplyToMessageId) {
@@ -449,6 +497,7 @@ export default {
return false;
});
},
shouldGroupWithNext: shouldGroupWithNext,
},
};
</script>
@@ -473,28 +522,46 @@ export default {
@click="onToggleContactPanel"
/>
</div>
<ul class="conversation-panel">
<ul
class="conversation-panel"
:class="{ 'px-4 bg-n-background': showNextBubbles }"
>
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
<span v-if="shouldShowSpinner" class="spinner message" />
</li>
</transition>
<Message
v-for="message in readMessages"
:key="message.id"
class="message--read ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-an-email-inbox="isAnEmailChannel"
:is-instagram="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
<template v-if="showNextBubbles">
<NextMessage
v-for="(message, index) in readMessages"
:key="message.id"
v-bind="message"
:is-email-inbox="isAnEmailChannel"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, readMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
/>
</template>
<template v-else>
<Message
v-for="message in readMessages"
:key="message.id"
class="message--read ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-an-email-inbox="isAnEmailChannel"
:is-instagram="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
</template>
<li v-show="unreadMessageCount != 0" class="unread--toast">
<span>
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
@@ -505,20 +572,35 @@ export default {
}}
</span>
</li>
<Message
v-for="message in unReadMessages"
:key="message.id"
class="message--unread ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-instagram-dm="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
<template v-if="showNextBubbles">
<NextMessage
v-for="(message, index) in unReadMessages"
:key="message.id"
v-bind="message"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, unReadMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
:is-email-inbox="isAnEmailChannel"
data-clarity-mask="True"
/>
</template>
<template v-else>
<Message
v-for="message in unReadMessages"
:key="message.id"
class="message--unread ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-instagram-dm="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
</template>
<ConversationLabelSuggestion
v-if="shouldShowLabelSuggestions"
:suggested-labels="labelSuggestions"
@@ -0,0 +1,141 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
REPLY_TO_OUTGOING: 'replyToOutgoing',
};
// This is a single source of truth for inbox features
// This is used to check if a feature is available for a particular inbox or not
export const INBOX_FEATURE_MAP = {
[INBOX_FEATURES.REPLY_TO]: [
INBOX_TYPES.FB,
INBOX_TYPES.WEB,
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
INBOX_TYPES.WEB,
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.API,
],
};
/**
* Composable for handling macro-related functionality
* @returns {Object} An object containing the getMacroDropdownValues function
*/
export const useInbox = () => {
const currentChat = useMapGetter('getSelectedChat');
const inboxGetter = useMapGetter('inboxes/getInboxById');
const inbox = computed(() => {
const inboxId = currentChat.value.inbox_id;
return useCamelCase(inboxGetter.value(inboxId), { deep: true });
});
const channelType = computed(() => {
return inbox.value.channelType;
});
const isAPIInbox = computed(() => {
return channelType.value === INBOX_TYPES.API;
});
const isAFacebookInbox = computed(() => {
return channelType.value === INBOX_TYPES.FB;
});
const isAWebWidgetInbox = computed(() => {
return channelType.value === INBOX_TYPES.WEB;
});
const isATwilioChannel = computed(() => {
return channelType.value === INBOX_TYPES.TWILIO;
});
const isALineChannel = computed(() => {
return channelType.value === INBOX_TYPES.LINE;
});
const isAnEmailChannel = computed(() => {
return channelType.value === INBOX_TYPES.EMAIL;
});
const isATelegramChannel = computed(() => {
return channelType.value === INBOX_TYPES.TELEGRAM;
});
const whatsAppAPIProvider = computed(() => {
return inbox.value.provider || '';
});
const isAMicrosoftInbox = computed(() => {
return isAnEmailChannel.value && inbox.value.provider === 'microsoft';
});
const isAGoogleInbox = computed(() => {
return isAnEmailChannel.value && inbox.value.provider === 'google';
});
const isATwilioSMSChannel = computed(() => {
const { medium: medium = '' } = inbox.value;
return isATwilioChannel.value && medium === 'sms';
});
const isASmsInbox = computed(() => {
return channelType.value === INBOX_TYPES.SMS || isATwilioSMSChannel.value;
});
const isATwilioWhatsAppChannel = computed(() => {
const { medium: medium = '' } = inbox.value;
return isATwilioChannel.value && medium === 'whatsapp';
});
const isAWhatsAppCloudChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP &&
whatsAppAPIProvider.value === 'whatsapp_cloud'
);
});
const is360DialogWhatsAppChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP &&
whatsAppAPIProvider.value === 'default'
);
});
const isAWhatsAppChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP ||
isATwilioWhatsAppChannel.value
);
});
return {
inbox,
isAFacebookInbox,
isALineChannel,
isAPIInbox,
isASmsInbox,
isATelegramChannel,
isATwilioChannel,
isAWebWidgetInbox,
isAWhatsAppChannel,
isAMicrosoftInbox,
isAGoogleInbox,
isATwilioWhatsAppChannel,
isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
};
};
@@ -3,6 +3,7 @@
import { unref } from 'vue';
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';
import * as Sentry from '@sentry/vue';
/**
* Vue composable that converts object keys to camelCase
@@ -12,8 +13,18 @@ import snakecaseKeys from 'snakecase-keys';
* @returns {Object|Array} Converted payload with camelCase keys
*/
export function useCamelCase(payload, options) {
const unrefPayload = unref(payload);
return camelcaseKeys(unrefPayload, options);
try {
const unrefPayload = unref(payload);
return camelcaseKeys(unrefPayload, options);
} catch (e) {
Sentry.setContext('transform-keys-error', {
payload,
options,
op: 'camelCase',
});
Sentry.captureException(e);
return payload;
}
}
/**
@@ -24,6 +35,16 @@ export function useCamelCase(payload, options) {
* @returns {Object|Array} Converted payload with snake_case keys
*/
export function useSnakeCase(payload, options) {
const unrefPayload = unref(payload);
return snakecaseKeys(unrefPayload, options);
try {
const unrefPayload = unref(payload);
return snakecaseKeys(unrefPayload, options);
} catch (e) {
Sentry.setContext('transform-keys-error', {
payload,
options,
op: 'snakeCase',
});
Sentry.captureException(e);
return payload;
}
}
@@ -5,4 +5,5 @@ export const LOCAL_STORAGE_KEYS = {
COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo',
USE_NEXT_BUBBLE: 'useNextBubble',
};
@@ -4,6 +4,7 @@ import { mapGetters } from 'vuex';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
import {
@@ -38,6 +39,10 @@ export default {
type: Object,
default: () => ({}),
},
hideButton: {
type: Boolean,
default: false,
},
},
emits: ['open', 'close', 'replyTo'],
setup() {
@@ -62,7 +67,7 @@ export default {
return this.getPlainText(this.messageContent);
},
conversationId() {
return this.message.conversation_id;
return this.message.conversation_id ?? this.message.conversationId;
},
messageId() {
return this.message.id;
@@ -71,7 +76,9 @@ export default {
return this.message.content;
},
contentAttributes() {
return this.message.content_attributes;
return useSnakeCase(
this.message.content_attributes ?? this.message.contentAttributes
);
},
},
methods: {
@@ -183,6 +190,7 @@ export default {
:reject-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.CANCEL')"
/>
<woot-button
v-if="!hideButton"
icon="more-vertical"
color-scheme="secondary"
variant="clear"
-19
View File
@@ -27,7 +27,6 @@ class AutomationRule < ApplicationRecord
validate :json_conditions_format
validate :json_actions_format
validate :query_operator_presence
validate :query_operator_value
validates :account_id, presence: true
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
@@ -84,24 +83,6 @@ class AutomationRule < ApplicationRecord
operators = conditions.select { |obj, _| obj['query_operator'].nil? }
errors.add(:conditions, 'Automation conditions should have query operator.') if operators.length > 1
end
# This validation ensures logical operators are being used correctly in automation conditions.
# And we don't push any unsanitized query operators to the database.
def query_operator_value
conditions.each do |obj|
validate_single_condition(obj)
end
end
def validate_single_condition(condition)
query_operator = condition['query_operator']
return if query_operator.nil?
return if query_operator.empty?
operator = query_operator.upcase
errors.add(:conditions, 'Query operator must be either "AND" or "OR"') unless %w[AND OR].include?(operator)
end
end
AutomationRule.include_mod_with('Audit::AutomationRule')
@@ -15,7 +15,7 @@ class AutomationRules::ConditionValidationService
def perform
@rule.conditions.each do |condition|
return false unless valid_condition?(condition) && valid_query_operator?(condition)
return false unless valid_condition?(condition)
end
true
@@ -23,15 +23,6 @@ class AutomationRules::ConditionValidationService
private
def valid_query_operator?(condition)
query_operator = condition['query_operator']
return true if query_operator.nil?
return true if query_operator.empty?
%w[AND OR].include?(query_operator.upcase)
end
def valid_condition?(condition)
key = condition['attribute_key']
-1
View File
@@ -9,7 +9,6 @@ class Contacts::FilterService < FilterService
end
def perform
validate_query_operator
@contacts = query_builder(@filters['contacts'])
{
@@ -7,7 +7,6 @@ class Conversations::FilterService < FilterService
end
def perform
validate_query_operator
@conversations = query_builder(@filters['conversations'])
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
-6
View File
@@ -204,10 +204,4 @@ class FilterService
end
base_relation.where(@query_string, @filter_values.with_indifferent_access)
end
def validate_query_operator
@params[:payload].each do |query_hash|
validate_single_condition(query_hash)
end
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.16.0'
version: '3.15.0'
development:
<<: *shared
+1 -1
View File
@@ -11,7 +11,7 @@ Bundler.require(*Rails.groups)
## Load the specific APM agent
# We rely on DOTENV to load the environment variables
# We need these environment variables to load the specific APM agent
Dotenv::Rails.load
Dotenv::Railtie.load
require 'ddtrace' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
require 'elastic-apm' if ENV.fetch('ELASTIC_APM_SECRET_TOKEN', false).present?
require 'scout_apm' if ENV.fetch('SCOUT_KEY', false).present?
-1
View File
@@ -80,7 +80,6 @@ en:
number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
reports:
period: Reporting period %{since} to %{until}
@@ -1,18 +0,0 @@
class FixOldAudioAlertData < ActiveRecord::Migration[7.0]
def change
# rubocop:disable Rails/SkipsModelValidations
# Update users with audio alerts enabled to 'mine'
User.where(
"users.ui_settings #>> '{enable_audio_alerts}' = ?", 'true'
).update_all(
"ui_settings = jsonb_set(ui_settings, '{enable_audio_alerts}', '\"mine\"')"
)
# Update users with audio alerts enabled to 'none'
User
.where("users.ui_settings #>> '{enable_audio_alerts}' = ?", 'true')
.update_all("ui_settings = jsonb_set(ui_settings, '{enable_audio_alerts}', '\"mine\"')")
# rubocop:enable Rails/SkipsModelValidations
end
end
+1 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_12_17_041352) do
ActiveRecord::Schema[7.0].define(version: 2024_09_23_215335) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
-6
View File
@@ -11,12 +11,6 @@ module CustomExceptions::CustomFilter
end
end
class InvalidQueryOperator < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_query_operator')
end
end
class InvalidValue < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_value', attribute_name: @data[:attribute_name])
+1 -22
View File
@@ -1,11 +1,8 @@
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
# TODO: let move this regex from here to a config file where we can update this list much more easily
# the config file will also have the matching embed template as well.
YOUTUBE_REGEX = %r{https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([^&/]+)}
LOOM_REGEX = %r{https?://(?:www\.)?loom\.com/share/([^&/]+)}
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
def text(node)
content = node.string_content
@@ -49,8 +46,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
YOUTUBE_REGEX => :make_youtube_embed,
VIMEO_REGEX => :make_vimeo_embed,
MP4_REGEX => :make_video_embed,
LOOM_REGEX => :make_loom_embed,
ARCADE_REGEX => :make_arcade_embed
LOOM_REGEX => :make_loom_embed
}
embedding_methods.each do |regex, method|
@@ -122,21 +118,4 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
</video>
)
end
def make_arcade_embed(arcade_match)
video_id = arcade_match[1]
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://app.arcade.software/embed/#{video_id}"
frameborder="0"
webkitallowfullscreen
mozallowfullscreen
allowfullscreen
allow="fullscreen"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</iframe>
</div>
)
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.16.0",
"version": "3.15.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -76,23 +76,6 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
}
end
it 'processes invalid query operator' do
expect(account.automation_rules.count).to eq(0)
params[:conditions] << {
'attribute_key': 'browser_language',
'filter_operator': 'equal_to',
'values': ['en'],
'query_operator': 'invalid'
}
post "/api/v1/accounts/#{account.id}/automation_rules",
headers: administrator.create_new_auth_token,
params: params
expect(response).to have_http_status(:unprocessable_entity)
expect(account.automation_rules.count).to eq(0)
end
it 'throws an error for unknown attributes in condtions' do
expect(account.automation_rules.count).to eq(0)
params[:conditions] << {
-28
View File
@@ -137,33 +137,5 @@ describe CustomMarkdownRenderer do
expect(output).to include('src="https://player.vimeo.com/video/1234567"')
end
end
context 'when link is an Arcade URL' do
let(:arcade_url) { 'https://app.arcade.software/share/ARCADE_ID' }
it 'renders an iframe with Arcade embed code' do
output = render_markdown_link(arcade_url)
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
expect(output).to include('<iframe')
expect(output).to include('webkitallowfullscreen')
expect(output).to include('mozallowfullscreen')
expect(output).to include('allowfullscreen')
end
it 'wraps iframe in responsive container' do
output = render_markdown_link(arcade_url)
expect(output).to include('position: relative; padding-bottom: 62.5%; height: 0;')
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
end
end
context 'when multiple links including Arcade are present' do
it 'renders Arcade embed along with other content types' do
markdown = "\n[arcade](https://app.arcade.software/share/ARCADE_ID)\n\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n"
output = render_markdown(markdown)
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
expect(output).to include('src="https://www.youtube.com/embed/VIDEO_ID"')
end
end
end
end
@@ -46,17 +46,6 @@ RSpec.describe AutomationRules::ConditionValidationService do
end
end
context 'with wrong query operator' do
before do
rule.conditions = [{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': 'invalid', 'filter_operator': 'attribute_changed' }]
rule.save
end
it 'returns false' do
expect(described_class.new(rule).perform).to be(false)
end
end
context 'with "attribute_changed" filter operator' do
before do
rule.conditions = [
@@ -146,19 +146,6 @@ describe Contacts::FilterService do
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.id).to eq el_contact.id
end
it 'handles invalid query conditions' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'is_not_present',
values: [],
query_operator: 'INVALID'
}.with_indifferent_access
]
expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator)
end
end
context 'with standard attributes - last_activity_at' do
@@ -185,30 +185,6 @@ describe Conversations::FilterService do
expect(result[:count][:all_count]).to be 2
expect(result[:conversations].pluck(:campaign_id).sort).to eq [campaign_2.id, campaign_1.id].sort
end
it 'handles invalid query conditions' do
params[:payload] = [
{
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: [
user_1.id,
user_2.id
],
query_operator: 'INVALID',
custom_attribute_type: ''
}.with_indifferent_access,
{
attribute_key: 'campaign_id',
filter_operator: 'is_present',
values: [],
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access
]
expect { filter_service.new(params, user_1).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator)
end
end
end