Compare commits

...
Author SHA1 Message Date
Shivam Mishra 9d48f5c737 feat: allow toggle bg 2024-12-25 19:42:40 +05:30
Shivam MishraandGitHub c19d70a6a0 fix: bubble UI issues (#10608)
This PR has fixes for the following issues

- Inconsistent spacing between meta and text in text bubble
- Activity bubble overflows for longer text (for now I have truncated
it, I'll work with @absurdiya on a better solution)
- Ugly lookinh gradient for expand button on email bubble
- Email bubble overflow issues and text rendering issues
- Alignment for error message
- Minute-wise grouping not working
- Link color should not be blue
- Use `gray-3` for bubble background instead of `gray-4`
2024-12-21 13:36:46 +05:30
PranavandGitHub c52282307a feat(v4): Update team, agent summary builder to include resolution metrics (#10607)
Following https://github.com/chatwoot/chatwoot/pull/10604, this PR
introduces similar reporting features for Agents and Teams.

Updates in this PR:
- Added additional methods to the base class to avoid repetition.
- Improve reporting for Teams and Agents to include resolution count.
2024-12-20 19:16:56 +05:30
4fd9bddb9d feat(v4): Add API to fetch aggregate reports for inboxes (#10604)
The Inbox Overview section is being updated to offer a more detailed
report, showing an overall view of the account grouped by inboxes. To
view detailed reports and access specific graphs for individual inboxes,
click on the inbox name to navigate to its dedicated report page.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-12-19 14:47:19 -08:00
eef70b9bd7 feat: integrate new bubbles (#10550)
To test this, set the `useNextBubble` value to `true` in the
localstorage. Here's a quick command to run in the console

```js
localStorage.setItem('useNextBubble', true)
```

```js
localStorage.setItem('useNextBubble', false)
```

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-19 18:41:55 +05:30
Sojan 9279175199 Merge branch 'release/3.16.0' into develop 2024-12-17 23:03:32 +05:30
Sojan 361878d346 Bump version to 3.16.0 2024-12-17 23:02:59 +05:30
Sivin VargheseandGitHub 2a4c0dfa2a chore: Adds the ability to remove labels from label card (#10591) 2024-12-17 18:43:36 +05:30
Sivin VargheseandGitHub 6b348da807 feat(v4): Compose a new conversation from a phone number. (#10568) 2024-12-17 18:07:58 +05:30
96ae298464 fix: Dotenv::Railtie is deprecated (#10515)
https://github.com/bkeepers/dotenv/pull/468

Renames Dotenv::Railtie => Dotenv::Rails

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-12-17 17:20:44 +05:30
932244a1ec feat: Add support for Arcade videos on articles (#10585)
Fixes
https://linear.app/chatwoot/issue/CW-3779/add-support-for-arcade-videos-on-articles-loom-alternative

**Loom video**

https://www.loom.com/share/917bdecb4eaf4d3f9782b4fa84ee4bd4?sid=d11f0d71-0cf5-424a-9268-9d9fb3797ee2


Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-12-17 17:17:42 +05:30
PranavandGitHub d69571f6f8 fix: Update old data to fix login issues (#10594)
The following lines caused issues for some users, specifically those who
signed up in 2021 when audio alerts were implemented as a flag. The data
type update for the flag was not handled correctly. This PR fixes the
issue by updating it to a compatible value.


https://github.com/chatwoot/chatwoot/blob/9410b3bcbbb7aba4b790f33bea52a430bf69466c/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js#L76-L81
2024-12-17 17:17:11 +05:30
Sojan JoseandGitHub 1d88e0dd28 fix: Contact form breaks if name is empty (#10597)
- Handles the case where the form and contact display page breaks if
name is `null`
2024-12-17 17:16:50 +05:30
Shivam MishraandGitHub b34dac7bbe feat: validate query conditions (#10595)
Query conditions can take in arbitrary values, this can cause SQL
errors. This PR fixes it
2024-12-17 17:16:37 +05:30
Vishnu NarayananandGitHub e3109dbb22 chore: pin bundler version to 2.5.x in circleci (#10596) 2024-12-17 16:31:55 +05:30
73 changed files with 2417 additions and 1108 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
gem install bundler -v 2.5.16
- run:
name: Install Application Dependencies
@@ -2,52 +2,38 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
load_data
prepare_report
end
private
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
end
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
def prepare_report
account.account_users.map do |account_user|
build_agent_stats(account_user)
end
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
def build_agent_stats(account_user)
user_id = account_user.user_id
{
id: user_id,
conversations_count: conversations_count[user_id] || 0,
resolved_conversations_count: resolved_count[user_id] || 0,
avg_resolution_time: avg_resolution_time[user_id],
avg_first_response_time: avg_first_response_time[user_id],
avg_reply_time: avg_reply_time[user_id]
}
end
def group_by_key
:user_id
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
end
def prepare_report
account.account_users.each_with_object([]) do |account_user, arr|
arr << {
id: account_user.user_id,
conversations_count: @grouped_conversations_count[account_user.user_id],
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
}
end
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end
@@ -1,17 +1,50 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
def build
load_data
prepare_report
end
private
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
def fetch_conversations_count
# Override this method
end
def fetch_average_time(event_name)
get_grouped_average(reporting_events.where(name: event_name))
end
def fetch_resolved_count
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def group_by_key
# Override this method
end
def prepare_report
# Override this method
end
def get_grouped_average(events)
events.group(group_by_key).average(average_value_key)
end
def average_value_key
params[:business_hours].present? ? :value_in_business_hours : :value
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end
@@ -0,0 +1,50 @@
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
load_data
prepare_report
end
private
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report
account.inboxes.map do |inbox|
build_inbox_stats(inbox)
end
end
def build_inbox_stats(inbox)
{
id: inbox.id,
conversations_count: conversations_count[inbox.id] || 0,
resolved_conversations_count: resolved_count[inbox.id] || 0,
avg_resolution_time: avg_resolution_time[inbox.id],
avg_first_response_time: avg_first_response_time[inbox.id],
avg_reply_time: avg_reply_time[inbox.id]
}
end
def group_by_key
:inbox_id
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end
+22 -34
View File
@@ -1,49 +1,37 @@
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count
end
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
def fetch_conversations_count
account.conversations.where(created_at: range).group(:team_id).count
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report
account.teams.map do |team|
build_team_stats(team)
end
end
def build_team_stats(team)
{
id: team.id,
conversations_count: conversations_count[team.id] || 0,
resolved_conversations_count: resolved_count[team.id] || 0,
avg_resolution_time: avg_resolution_time[team.id],
avg_first_response_time: avg_first_response_time[team.id],
avg_reply_time: avg_reply_time[team.id]
}
end
def group_by_key
'conversations.team_id'
end
def prepare_report
account.teams.each_with_object([]) do |team, arr|
arr << {
id: team.id,
conversations_count: @grouped_conversations_count[team.id],
avg_resolution_time: @grouped_avg_resolution_time[team.id],
avg_first_response_time: @grouped_avg_first_response_time[team.id],
avg_reply_time: @grouped_avg_reply_time[team.id]
}
end
end
end
@@ -68,6 +68,7 @@ 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,6 +46,7 @@ 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
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team]
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -10,6 +10,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::TeamSummaryBuilder)
end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
end
private
def check_authorization
@@ -26,8 +30,7 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
data = builder.build
render json: data
render json: builder.build
end
def permitted_params
+8
View File
@@ -81,4 +81,12 @@ 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
@@ -124,6 +124,19 @@
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--gray-1: 252 252 252;
--gray-2: 249 249 249;
--gray-3: 240 240 240;
--gray-4: 232 232 232;
--gray-5: 224 224 224;
--gray-6: 217 217 217;
--gray-7: 206 206 206;
--gray-8: 187 187 187;
--gray-9: 141 141 141;
--gray-10: 131 131 131;
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
@@ -213,6 +226,19 @@
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--gray-1: 17 17 17;
--gray-2: 25 25 25;
--gray-3: 34 34 34;
--gray-4: 42 42 42;
--gray-5: 49 49 49;
--gray-6: 58 58 58;
--gray-7: 72 72 72;
--gray-8: 96 96 96;
--gray-9: 110 110 110;
--gray-10: 123 123 123;
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
@@ -232,7 +258,7 @@
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.8;
--white-alpha: 255, 255, 255, 0.1;
}
/* NEXT COLORS END */
@@ -18,6 +18,11 @@ 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');
@@ -37,7 +42,7 @@ const labelMenuItems = computed(() => {
isSelected: savedLabels.value.some(
savedLabel => savedLabel.id === label.id
),
action: 'addLabel',
action: 'contactLabel',
}))
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
});
@@ -49,7 +54,7 @@ const fetchLabels = async contactId => {
store.dispatch('contactLabels/get', contactId);
};
const handleLabelAction = async ({ action, value }) => {
const handleLabelAction = async ({ value }) => {
try {
// Get current label titles
const currentLabels = savedLabels.value.map(label => label.title);
@@ -59,16 +64,15 @@ const handleLabelAction = async ({ action, value }) => {
if (!selectedLabel) return;
let updatedLabels;
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];
}
// 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', {
@@ -82,6 +86,10 @@ const handleLabelAction = async ({ action, value }) => {
}
};
const handleRemoveLabel = labelId => {
return handleLabelAction({ value: labelId });
};
watch(
() => props.contactId,
(newVal, oldVal) => {
@@ -95,11 +103,31 @@ 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">
<LabelItem v-for="label in savedLabels" :key="label.id" :label="label" />
<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)"
/>
<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,22 +1,56 @@
<script setup>
defineProps({
import Button from 'dashboard/components-next/button/Button.vue';
const props = 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="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"
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"
>
<div
class="w-2 h-2 m-1 rounded-sm"
:style="{ backgroundColor: label.color }"
/>
<span class="text-sm text-n-slate-12">
<span class="text-sm text-n-slate-12 ltr:mr-px rtl:ml-px">
{{ 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,9 +1,11 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } 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,
@@ -42,15 +44,19 @@ 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,
@@ -80,6 +86,14 @@ 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>
@@ -126,11 +140,11 @@ const errorClass = computed(() => {
:is-loading="isLoading"
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
allow-create
type="email"
:type="inputType"
class="flex-1 min-h-7"
:class="errorClass"
focus-on-mount
@input="emit('searchContacts', $event)"
@input="handleInput"
@on-click-outside="emit('updateDropdown', 'contacts', false)"
@add="emit('setSelectedContact', $event)"
@remove="emit('clearSelectedContact')"
@@ -193,10 +193,12 @@ export const searchContacts = async ({ keys, query }) => {
return filteredPayload || [];
};
export const createNewContact = async email => {
export const createNewContact = async input => {
const payload = {
name: getCapitalizedNameFromEmail(email),
email,
name: input.startsWith('+')
? input.slice(1) // Remove the '+' prefix if it exists
: getCapitalizedNameFromEmail(input),
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
};
const {
@@ -429,6 +429,28 @@ 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, toRefs } 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';
@@ -19,17 +26,14 @@ 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 LocationBubble from './bubbles/Location.vue';
import MessageError from './MessageError.vue';
import MessageMeta from './MessageMeta.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
/**
* @typedef {Object} Attachment
@@ -65,7 +69,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 +82,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 +102,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 }, // eslint-disable-line vue/no-unused-properties
currentUserId: { type: Number, required: true },
groupWithNext: { type: Boolean, default: false },
inboxId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
isEmailInbox: { type: Boolean, default: false },
private: { type: Boolean, default: false },
sender: { type: Object, default: null },
senderId: { type: Number, default: null },
senderType: { type: String, default: null },
sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties
});
const 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 +160,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 +248,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 +264,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,23 +279,108 @@ const componentToRender = computed(() => {
if (fileType === ATTACHMENT_TYPES.CONTACT) return ContactBubble;
}
if (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,
toggleBg: variant.value === MESSAGE_VARIANTS.EMAIL,
};
});
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);
}
function toggleBg() {
const key = `bg-light-toggle::${props.id}`;
const isToggled = LocalStorage.get(key);
if (isToggled) {
LocalStorage.remove(key);
} else {
LocalStorage.set(`bg-light-toggle::${props.id}`, true);
}
emitter.emit(BUS_EVENTS.TOGGLE_MESSAGE_BG, props.id);
closeContextMenu();
}
provideMessageContext({
...toRefs(props),
isPrivate: computed(() => props.private),
variant,
inReplyTo: props.inReplyTo,
orientation,
isMyMessage,
shouldGroupWithNext,
});
</script>
<template>
<div
:id="`message${props.id}`"
class="flex w-full"
:data-message-id="props.id"
:class="[flexOrientationClass, shouldGroupWithNext ? 'mb-2' : 'mb-4']"
@@ -324,12 +413,14 @@ provideMessageContext({
/>
</div>
<div
class="[grid-area:bubble]"
class="[grid-area:bubble] flex"
:class="{
'pl-9': ORIENTATION.RIGHT === orientation,
'pl-9 justify-end': orientation === ORIENTATION.RIGHT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"
>
<Component :is="componentToRender" v-bind="props" />
<Component :is="componentToRender" />
</div>
<MessageError
v-if="contentAttributes.externalError"
@@ -337,15 +428,19 @@ provideMessageContext({
: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 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"
@toggle-bg="toggleBg"
/>
</div>
</div>
@@ -0,0 +1,134 @@
<script setup>
import { defineProps, computed } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
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 [These are not in camelcase]
*/
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: () => [],
},
});
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} searchList - Array of messages to check
* @returns {Boolean} - Whether the message should be grouped with next
*/
const shouldGroupWithNext = (index, searchList) => {
if (index === searchList.length - 1) return false;
const current = searchList[index];
const next = searchList[index + 1];
if (next.status === 'failed') return false;
const nextSenderId = next.senderId ?? next.sender?.id;
const currentSenderId = current.senderId ?? current.sender?.id;
const hasSameSender = nextSenderId === currentSenderId;
const nextMessageType = next.messageType;
const currentMessageType = current.messageType;
const areBothTemplates =
nextMessageType === MESSAGE_TYPES.TEMPLATE &&
currentMessageType === MESSAGE_TYPES.TEMPLATE;
if (!hasSameSender || !areBothTemplates) return false;
// 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">
<slot name="beforeAll" />
<template v-for="(message, index) in read" :key="message.id">
<Message
v-bind="message"
:is-email-inbox="isAnEmailChannel"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, read)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
/>
</template>
<slot name="beforeUnread" />
<template v-for="(message, index) in unread" :key="message.id">
<Message
v-bind="message"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, unread)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
:is-email-inbox="isAnEmailChannel"
data-clarity-mask="True"
/>
</template>
<slot name="after" />
</ul>
</template>
@@ -4,74 +4,116 @@ import { messageTimestamp } from 'shared/helpers/timeHelper';
import MessageStatus from './MessageStatus.vue';
import Icon from 'next/icon/Icon.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { useMessageContext } from './provider.js';
import { MESSAGE_STATUS } from './constants';
import { MESSAGE_STATUS, MESSAGE_TYPES } 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
*/
const {
isAFacebookInbox,
isALineChannel,
isAPIInbox,
isASmsInbox,
isATelegramChannel,
isATwilioChannel,
isAWebWidgetInbox,
isAWhatsAppChannel,
isAnEmailChannel,
} = useInbox();
/**
* @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 { status, isPrivate, createdAt, sourceId, messageType } =
useMessageContext();
const readableTime = computed(() =>
messageTimestamp(props.createdAt, 'LLL d, h:mm a')
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
);
const showSender = computed(() => !props.isMyMessage && props.sender);
const showStatusIndicator = computed(() => {
if (isPrivate.value) return false;
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
return false;
});
const isSent = computed(() => {
if (!showStatusIndicator.value) return false;
// Messages will be marked as sent for the Email channel if they have a source ID.
if (isAnEmailChannel.value) return !!sourceId.value;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
// All messages will be mark as sent for the Line channel, as there is no source ID.
if (isALineChannel.value) return true;
return false;
});
const isDelivered = computed(() => {
if (!showStatusIndicator.value) return false;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isASmsInbox.value ||
isAFacebookInbox.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
}
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (isAWebWidgetInbox.value || isAPIInbox.value) {
return status.value === MESSAGE_STATUS.SENT;
}
if (isALineChannel.value) {
return status.value === MESSAGE_STATUS.DELIVERED;
}
return false;
});
const isRead = computed(() => {
if (!showStatusIndicator.value) return false;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
if (isAWebWidgetInbox.value || isAPIInbox.value) {
return status.value === MESSAGE_STATUS.READ;
}
return false;
});
const statusToShow = computed(() => {
if (isRead.value) return MESSAGE_STATUS.READ;
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
if (isSent.value) return MESSAGE_STATUS.SENT;
return MESSAGE_STATUS.PROGRESS;
});
</script>
<template>
<div class="text-xs text-n-slate-11 flex items-center gap-1.5">
<div class="text-xs 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>
<time class="inline">{{ readableTime }}</time>
</div>
<Icon
v-if="props.private"
icon="i-lucide-lock-keyhole"
class="text-n-slate-10 size-3"
/>
<MessageStatus v-if="props.isMyMessage" :status />
<Icon v-if="isPrivate" icon="i-lucide-lock-keyhole" class="size-3" />
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
</div>
</template>
`
@@ -1,16 +1,25 @@
<script setup>
import { computed } from 'vue';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import BaseBubble from './Base.vue';
import { useMessageContext } from '../provider.js';
defineProps({
content: {
type: String,
required: true,
},
});
const { content, createdAt } = useMessageContext();
const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
);
</script>
<template>
<BaseBubble class="px-2 py-0.5" data-bubble-name="activity">
<span v-dompurify-html="content" />
<BaseBubble
class="px-2 py-0.5 !rounded-full flex items-center gap-2"
data-bubble-name="activity"
>
<span v-dompurify-html="content" :title="content" class="truncate" />
<div v-if="readableTime" class="w-px h-3 rounded-full bg-n-slate-7" />
<time class="text-n-slate-10 truncate flex-shrink" :title="readableTime">
{{ readableTime }}
</time>
</BaseBubble>
</template>
@@ -1,30 +0,0 @@
<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>
@@ -2,43 +2,17 @@
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import AudioChip from 'next/message/chips/Audio.vue';
import { useMessageContext } from '../provider.js';
/**
* @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 { attachments } = useMessageContext();
const attachment = computed(() => {
return props.attachments[0];
return attachments.value[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"
/>
<AudioChip :attachment="attachment" class="p-2 text-n-slate-12" />
</BaseBubble>
</template>
@@ -1,6 +1,8 @@
<script setup>
import { computed } from 'vue';
import MessageMeta from '../MessageMeta.vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
@@ -8,19 +10,20 @@ import { useI18n } from 'vue-i18n';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
const { variant, orientation, inReplyTo } = useMessageContext();
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
useMessageContext();
const { t } = useI18n();
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]:
'bg-n-solid-amber text-n-amber-12 [&_.prosemirror-mention-node]:font-semibold',
[MESSAGE_VARIANTS.USER]: 'bg-n-slate-4 text-n-slate-12',
[MESSAGE_VARIANTS.USER]: 'bg-n-gray-3 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.EMAIL]: 'bg-n-gray-3 w-full',
[MESSAGE_VARIANTS.UNSUPPORTED]:
'bg-n-solid-amber/70 border border-dashed border-n-amber-12 text-n-amber-12',
};
@@ -31,6 +34,16 @@ const orientationMap = {
[ORIENTATION.CENTER]: 'rounded-md',
};
const flexOrientationClass = computed(() => {
const map = {
[ORIENTATION.LEFT]: 'justify-start',
[ORIENTATION.RIGHT]: 'justify-end',
[ORIENTATION.CENTER]: 'justify-center',
};
return map[orientation.value];
});
const messageClass = computed(() => {
const classToApply = [varaintBaseMap[variant.value]];
@@ -68,11 +81,11 @@ const previewMessage = computed(() => {
<template>
<div
class="text-sm min-w-32 break-words"
class="text-sm"
:class="[
messageClass,
{
'max-w-md': variant !== MESSAGE_VARIANTS.EMAIL,
'max-w-lg': variant !== MESSAGE_VARIANTS.EMAIL,
},
]"
>
@@ -86,5 +99,16 @@ const previewMessage = computed(() => {
</span>
</div>
<slot />
<MessageMeta
v-if="!shouldGroupWithNext && variant !== MESSAGE_VARIANTS.ACTIVITY"
:class="[
flexOrientationClass,
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
variant === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11',
]"
class="mt-2"
/>
</div>
</template>
@@ -3,11 +3,11 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import BaseBubble from './Base.vue';
import Icon from 'next/icon/Icon.vue';
import { useMessageContext } from '../provider.js';
const props = defineProps({
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: {
@@ -19,60 +19,62 @@ const props = defineProps({
},
});
const { sender } = useMessageContext();
const { t } = useI18n();
const senderName = computed(() => {
return props.sender.name;
return sender?.value.name;
});
</script>
<template>
<BaseBubble
class="overflow-hidden grid gap-4 min-w-64 p-0"
class="overflow-hidden p-3 !bg-n-solid-2 shadow-[0px_0px_12px_0px_rgba(0,0,0,0.05)]"
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 class="grid gap-4 min-w-64">
<div class="grid gap-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>
<slot>
<div v-if="content" class="truncate text-sm text-n-slate-11">
{{ content }}
<div class="space-y-1">
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
{{
t(senderTranslationKey, {
sender: senderName,
})
}}
</div>
</slot>
<slot>
<div v-if="content" class="truncate text-sm text-n-slate-11">
{{ content }}
</div>
</slot>
</div>
</div>
<div v-if="action">
<a
v-if="action.href"
:href="action.href"
rel="noreferrer noopener nofollow"
target="_blank"
class="w-full block bg-n-solid-3 px-4 py-2 rounded-lg text-sm text-center border border-n-container"
>
{{ action.label }}
</a>
<button
v-else
class="w-full bg-n-solid-3 px-4 py-2 rounded-lg text-sm text-center border border-n-container"
@click="action.onClick"
>
{{ action.label }}
</button>
</div>
</div>
<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>
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import BaseAttachmentBubble from './BaseAttachment.vue';
import {
@@ -10,45 +11,13 @@ import {
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 { content, attachments } = useMessageContext();
const $store = useStore();
const { t } = useI18n();
const attachment = computed(() => {
return props.attachments[0];
return attachments.value[0];
});
const phoneNumber = computed(() => {
@@ -64,7 +33,7 @@ const rawPhoneNumber = computed(() => {
});
const name = computed(() => {
return props.content;
return content.value;
});
function getContactObject() {
@@ -129,7 +98,6 @@ const action = computed(() => ({
<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"
@@ -5,23 +5,16 @@ import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import BaseAttachmentBubble from './BaseAttachment.vue';
const props = defineProps({
contentAttributes: {
type: String,
required: true,
},
sender: {
type: Object,
default: () => ({}),
},
});
const { contentAttributes } = useMessageContext();
const { t } = useI18n();
const meetingData = computed(() => {
return useCamelCase(props.contentAttributes.data);
return useCamelCase(contentAttributes.value.data);
});
const isLoading = ref(false);
@@ -57,7 +50,6 @@ const action = computed(() => ({
<BaseAttachmentBubble
icon="i-ph-video-camera-fill"
icon-bg-color="bg-[#2781F6]"
:sender="sender"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.MEETING"
:action="action"
>
@@ -1,57 +1,44 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_STATUS } from '../../constants';
import { useMessageContext } from '../../provider.js';
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 { contentAttributes, status, sender } = useMessageContext();
const hasError = computed(() => {
return props.status === MESSAGE_STATUS.FAILED;
return status.value === MESSAGE_STATUS.FAILED;
});
const fromEmail = computed(() => {
return props.contentAttributes?.email?.from ?? [];
return contentAttributes.value?.email?.from ?? [];
});
const toEmail = computed(() => {
return props.contentAttributes?.email?.to ?? [];
return contentAttributes.value?.email?.to ?? [];
});
const ccEmail = computed(() => {
return (
props.contentAttributes?.ccEmails ??
props.contentAttributes?.email?.cc ??
contentAttributes.value?.ccEmails ??
contentAttributes.value?.email?.cc ??
[]
);
});
const senderName = computed(() => {
return props.sender.name ?? '';
return sender.value.name ?? '';
});
const bccEmail = computed(() => {
return (
props.contentAttributes?.bccEmails ??
props.contentAttributes?.email?.bcc ??
contentAttributes.value?.bccEmails ??
contentAttributes.value?.email?.bcc ??
[]
);
});
const subject = computed(() => {
return props.contentAttributes?.email?.subject ?? '';
return contentAttributes.value?.email?.subject ?? '';
});
const showMeta = computed(() => {
@@ -68,7 +55,7 @@ const showMeta = computed(() => {
<template>
<section
v-show="showMeta"
class="p-4 space-y-1 pr-9 border-b border-n-strong"
class="space-y-1 pr-9 border-b border-n-strong text-sm"
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
<template v-if="showMeta">
@@ -1,59 +1,44 @@
<script setup>
import { computed, useTemplateRef, ref, onMounted } from 'vue';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { Letter } from 'vue-letter';
import { LocalStorage } from 'shared/helpers/localStorage';
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,
},
});
import { useMessageContext } from '../../provider.js';
import { MESSAGE_TYPES } from 'next/message/constants.js';
const { id, content, contentAttributes, attachments, messageType } =
useMessageContext();
const isExpandable = ref(false);
const isExpanded = ref(false);
const showQuotedMessage = ref(false);
const useWhiteBackground = ref(false);
const contentContainer = useTemplateRef('contentContainer');
onMounted(() => {
isExpandable.value = contentContainer.value.scrollHeight > 400;
useWhiteBackground.value = LocalStorage.get(`bg-light-toggle::${id.value}`);
emitter.on(BUS_EVENTS.TOGGLE_MESSAGE_BG, () => {
useWhiteBackground.value = LocalStorage.get(`bg-light-toggle::${id.value}`);
});
});
const isOutgoing = computed(() => {
return props.messageType === MESSAGE_TYPES.OUTGOING;
return messageType.value === MESSAGE_TYPES.OUTGOING;
});
const fullHTML = computed(() => {
return props.contentAttributes?.email?.htmlContent?.full ?? props.content;
return contentAttributes?.value?.email?.htmlContent?.full ?? content.value;
});
const unquotedHTML = computed(() => {
@@ -66,65 +51,73 @@ const hasQuotedMessage = computed(() => {
const textToShow = computed(() => {
const text =
props.contentAttributes?.email?.textContent?.full ?? props.content;
return text.replace(/\n/g, '<br>');
contentAttributes?.value?.email?.textContent?.full ?? content.value;
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,
}"
>
<BaseBubble class="w-full" data-bubble-name="email">
<EmailMeta class="p-3" />
<section ref="contentContainer" class="p-3">
<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)]"
:class="{
'max-h-[400px] overflow-hidden relative': !isExpanded && isExpandable,
'overflow-y-scroll relative': isExpanded,
}"
>
<button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
@click="isExpanded = true"
<div
v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-gray-3 via-n-gray-3 via-20% to-transparent"
>
<Icon icon="i-lucide-maximize-2" />
{{ $t('EMAIL_HEADER.EXPAND') }}
<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" />
<div
v-else
:class="{
'dark:bg-white dark:p-4 rounded-lg text-black': useWhiteBackground,
}"
>
<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"
/>
</div>
<button
v-if="hasQuotedMessage"
class="text-n-slate-11 px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
@click="showQuotedMessage = !showQuotedMessage"
>
<template v-if="showQuotedMessage">
{{ $t('CHAT_LIST.HIDE_QUOTED_TEXT') }}
</template>
<template v-else>
{{ $t('CHAT_LIST.SHOW_QUOTED_TEXT') }}
</template>
<Icon
:icon="
showQuotedMessage
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
/>
</button>
</div>
<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" />
@@ -2,43 +2,16 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import BaseAttachmentBubble from './BaseAttachment.vue';
import FileIcon from 'next/icon/FileIcon.vue';
/**
* @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 { attachments } = useMessageContext();
const { t } = useI18n();
const url = computed(() => {
return props.attachments[0].dataUrl;
return attachments.value[0].dataUrl;
});
const fileName = computed(() => {
@@ -58,7 +31,6 @@ const fileType = computed(() => {
<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="{
@@ -4,44 +4,18 @@ 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 { useMessageContext } from '../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 { filteredCurrentChatAttachments, attachments } = useMessageContext();
const attachment = computed(() => {
return props.attachments[0];
return attachments.value[0];
});
const hasError = ref(false);
const showGallery = ref(false);
const { filteredCurrentChatAttachments } = useMessageContext();
const handleError = () => {
hasError.value = true;
@@ -64,20 +38,17 @@ const downloadAttachment = async () => {
<template>
<BaseBubble
class="overflow-hidden relative group border-[4px] border-n-weak"
class="overflow-hidden p-3"
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"
>
<div v-if="hasError" class="flex items-center gap-1 text-center rounded-lg">
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
<p class="mb-0 text-n-slate-11">
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
</p>
</div>
<template v-else>
<div v-else class="relative group rounded-lg overflow-hidden">
<img
:src="attachment.dataUrl"
:width="attachment.width"
@@ -98,7 +69,7 @@ const downloadAttachment = async () => {
@click="downloadAttachment"
/>
</div>
</template>
</div>
</BaseBubble>
<GalleryView
v-if="showGallery"
@@ -7,46 +7,22 @@ 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 { variant, content, attachments } = useMessageContext();
const attachment = computed(() => {
return props.attachments[0];
return attachments.value[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 content.value;
}
return new MessageFormatter(props.content).formattedMessage;
return new MessageFormatter(content.value).formattedMessage;
});
const onImageLoadError = () => {
@@ -1,43 +1,14 @@
<script setup>
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
import { computed } 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: () => ({}),
},
});
import { useMessageContext } from '../provider.js';
const { attachments } = useMessageContext();
const { t } = useI18n();
const attachment = computed(() => {
return props.attachments[0];
return attachments.value[0];
});
const lat = computed(() => {
@@ -48,61 +19,23 @@ const long = computed(() => {
});
const title = computed(() => {
return attachment.value.fallbackTitle;
return attachment.value.fallbackTitle ?? attachment.value.fallback_title;
});
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>
@@ -26,6 +26,6 @@ const formattedContent = computed(() => {
<template>
<span
v-dompurify-html="formattedContent"
class="[&>p:last-child]:mb-0 [&>ul]:list-inside"
class="[&_.link]:text-n-slate-11 [&_.link]:underline [&>p:last-child]:mb-0 [&>ul]:list-inside"
/>
</template>
@@ -4,57 +4,37 @@ 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';
import { useMessageContext } from '../../provider.js';
/**
* @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 { content, attachments, contentAttributes, messageType } =
useMessageContext();
const isTemplate = computed(() => {
return props.messageType === MESSAGE_TYPES.TEMPLATE;
return messageType.value === MESSAGE_TYPES.TEMPLATE;
});
const isEmpty = computed(() => {
return !content.value && !attachments.value.length;
});
</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 class="px-4 py-3" data-bubble-name="text">
<div class="gap-3 flex flex-col">
<span v-if="isEmpty" class="text-n-slate-11">
{{ $t('CONVERSATION.NO_CONTENT') }}
</span>
<FormattedContent v-if="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>
</div>
</BaseBubble>
</template>
@@ -3,39 +3,14 @@ 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 { useMessageContext } from '../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 { filteredCurrentChatAttachments, attachments } = useMessageContext();
const handleError = () => {
hasError.value = true;
@@ -43,7 +18,7 @@ const handleError = () => {
};
const attachment = computed(() => {
return props.attachments[0];
return attachments.value[0];
});
const isReel = computed(() => {
@@ -53,25 +28,28 @@ const isReel = computed(() => {
<template>
<BaseBubble
class="overflow-hidden relative group border-[4px] border-n-weak"
class="overflow-hidden p-3"
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 class="relative group rounded-lg overflow-hidden">
<div
v-if="isReel"
class="absolute p-2 flex items-start justify-end right-0"
>
<Icon icon="i-lucide-instagram" class="text-white shadow-lg" />
</div>
<video
controls
class="rounded-lg"
:src="attachment.dataUrl"
:class="{
'max-w-48': isReel,
'max-w-full': !isReel,
}"
@error="handleError"
/>
</div>
<video
controls
:src="attachment.dataUrl"
:class="{
'max-w-48': isReel,
'max-w-full': !isReel,
}"
@error="handleError"
/>
</BaseBubble>
<GalleryView
v-if="showGallery"
@@ -53,7 +53,7 @@ const textColorClass = computed(() => {
<template>
<div
class="h-9 bg-n-alpha-white gap-2 items-center flex px-2 rounded-lg border border-n-strong"
class="h-9 bg-n-alpha-white gap-2 items-center flex px-2 rounded-lg border border-n-container"
>
<FileIcon class="flex-shrink-0" :file-type="fileType" />
<span class="mr-1 max-w-32 truncate" :class="textColorClass">
@@ -5,6 +5,112 @@ import { ATTACHMENT_TYPES } from './constants';
const MessageControl = Symbol('MessageControl');
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
/**
* @typedef {Object} Sender
* @property {Object} additional_attributes - Additional attributes of the sender
* @property {Object} custom_attributes - Custom attributes of the sender
* @property {string} email - Email of the sender
* @property {number} id - ID of the sender
* @property {string|null} identifier - Identifier of the sender
* @property {string} name - Name of the sender
* @property {string|null} phone_number - Phone number of the sender
* @property {string} thumbnail - Thumbnail URL of the sender
* @property {string} type - Type of sender
*/
/**
* @typedef {Object} EmailContent
* @property {string[]|null} bcc - BCC recipients
* @property {string[]|null} cc - CC recipients
* @property {string} contentType - Content type of the email
* @property {string} date - Date the email was sent
* @property {string[]} from - From email address
* @property {Object} htmlContent - HTML content of the email
* @property {string} htmlContent.full - Full HTML content
* @property {string} htmlContent.reply - Reply HTML content
* @property {string} htmlContent.quoted - Quoted HTML content
* @property {string|null} inReplyTo - Message ID being replied to
* @property {string} messageId - Unique message identifier
* @property {boolean} multipart - Whether the email is multipart
* @property {number} numberOfAttachments - Number of attachments
* @property {string} subject - Email subject line
* @property {Object} textContent - Text content of the email
* @property {string} textContent.full - Full text content
* @property {string} textContent.reply - Reply text content
* @property {string} textContent.quoted - Quoted text content
* @property {string[]} to - To email addresses
*/
/**
* @typedef {Object} ContentAttributes
* @property {string} externalError - an error message to be shown if the message failed to send
* @property {Object} [data] - Optional data object containing roomName and messageId
* @property {string} data.roomName - Name of the room
* @property {string} data.messageId - ID of the message
* @property {'story_mention'} [imageType] - Flag to indicate this is a story mention
* @property {'dyte'} [type] - Flag to indicate this is a dyte call
* @property {EmailContent} [email] - Email content and metadata
* @property {string|null} [ccEmail] - CC email addresses
* @property {string|null} [bccEmail] - BCC email addresses
*/
/**
* @typedef {'sent'|'delivered'|'read'|'failed'|'progress'} MessageStatus
* @typedef {'text'|'input_text'|'input_textarea'|'input_email'|'input_select'|'cards'|'form'|'article'|'incoming_email'|'input_csat'|'integrations'|'sticker'} MessageContentType
* @typedef {0|1|2|3} MessageType
* @typedef {'contact'|'user'|'Contact'|'User'} SenderType
* @typedef {'user'|'agent'|'activity'|'private'|'bot'|'error'|'template'|'email'|'unsupported'} MessageVariant
* @typedef {'left'|'center'|'right'} MessageOrientation
* @typedef {Object} MessageContext
* @property {import('vue').Ref<string>} content - The message content
* @property {import('vue').Ref<number>} conversationId - The ID of the conversation to which the message belongs
* @property {import('vue').Ref<number>} createdAt - Timestamp when the message was created
* @property {import('vue').Ref<number>} currentUserId - The ID of the current user
* @property {import('vue').Ref<number>} id - The unique identifier for the message
* @property {import('vue').Ref<number>} inboxId - The ID of the inbox to which the message belongs
* @property {import('vue').Ref<boolean>} [groupWithNext=false] - Whether the message should be grouped with the next message
* @property {import('vue').Ref<boolean>} [isEmailInbox=false] - Whether the message is from an email inbox
* @property {import('vue').Ref<boolean>} [private=false] - Whether the message is private
* @property {import('vue').Ref<number|null>} [senderId=null] - The ID of the sender
* @property {import('vue').Ref<string|null>} [error=null] - Error message if the message failed to send
* @property {import('vue').Ref<Attachment[]>} [attachments=[]] - The attachments associated with the message
* @property {import('vue').Ref<ContentAttributes>} [contentAttributes={}] - Additional attributes of the message content
* @property {import('vue').Ref<MessageContentType>} contentType - Content type of the message
* @property {import('vue').Ref<MessageStatus>} status - The delivery status of the message
* @property {import('vue').Ref<MessageType>} messageType - The type of message (must be one of MESSAGE_TYPES)
* @property {import('vue').Ref<Object|null>} [inReplyTo=null] - The message to which this message is a reply
* @property {import('vue').Ref<SenderType>} [senderType=null] - The type of the sender
* @property {import('vue').Ref<Sender|null>} [sender=null] - The sender information
* @property {import('vue').ComputedRef<MessageOrientation>} orientation - The visual variant of the message
* @property {import('vue').ComputedRef<MessageVariant>} variant - The visual variant of the message
* @property {import('vue').ComputedRef<boolean>} isMyMessage - Does the message belong to the current user
* @property {import('vue').ComputedRef<boolean>} isPrivate - Proxy computed value for private
* @property {import('vue').ComputedRef<boolean>} shouldGroupWithNext - Should group with the next message or not, it is differnt from groupWithNext, this has a bypass for a failed message
*/
/**
* Retrieves the message context from the parent Message component.
* Must be used within a component that is a child of a Message component.
*
* @returns {MessageContext & { filteredCurrentChatAttachments: import('vue').ComputedRef<Attachment[]> }}
* Message context object containing message properties and computed values
* @throws {Error} If used outside of a Message component context
*/
export function useMessageContext() {
const context = inject(MessageControl, null);
if (context === null) {
@@ -1,16 +1,24 @@
<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: 'text' },
type: { type: String, default: INPUT_TYPES.TEXT },
isLoading: { type: Boolean, default: false },
menuItems: {
type: Array,
@@ -23,8 +31,8 @@ const props = defineProps({
showDropdown: { type: Boolean, default: false },
mode: {
type: String,
default: 'multiple',
validator: value => ['single', 'multiple'].includes(value),
default: MODE.MULTIPLE,
validator: value => [MODE.SINGLE, MODE.MULTIPLE].includes(value),
},
focusOnMount: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
@@ -45,22 +53,17 @@ 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(() => ({
newTag: props.type === 'email' ? { email } : {},
}));
const rules = computed(() => getValidationRules(props.type));
const v$ = useVuelidate(rules, { newTag });
const isNewTagInValidType = computed(() => v$.value.$invalid);
const isNewTagInValidType = computed(() =>
checkTagTypeValidity(props.type, newTag.value, v$.value)
);
const showInput = computed(() =>
props.mode === MODE.SINGLE
@@ -74,68 +77,49 @@ const showDropdownMenu = computed(() =>
: props.showDropdown
);
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 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 emitDataOnAdd = value => {
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
return matchingMenuItem
? emit('add', { email: emailValue, ...matchingMenuItem })
: emit('add', { value: emailValue, action: 'create' });
? 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();
};
const addTag = async () => {
const trimmedTag = newTag.value?.trim();
if (!trimmedTag) return;
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
if (!canAddTag(props.mode, tags.value.length)) {
newTag.value = '';
return;
}
if (props.type === 'email' || props.allowCreate) {
if (
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
props.allowCreate
) {
if (!(await v$.value.$validate())) return;
emitDataOnAdd(trimmedTag);
}
tags.value.push(trimmedTag);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
updateValueAndFocus(trimmedTag);
};
const removeTag = index => {
@@ -144,19 +128,25 @@ const removeTag = index => {
emit('remove');
};
const handleDropdownAction = async ({ email: emailAddress, ...rest }) => {
const handleDropdownAction = async ({
email: emailAddress,
phoneNumber,
...rest
}) => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (!props.showDropdown) return;
if (props.type === 'email' && props.showDropdown) {
newTag.value = emailAddress;
if (!(await v$.value.$validate())) return;
emit('add', { email: emailAddress, ...rest });
}
const isEmail = props.type === 'email';
newTag.value = isEmail ? emailAddress : phoneNumber;
tags.value.push(emailAddress);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
if (!(await v$.value.$validate())) return;
const payload = isEmail
? { email: emailAddress, ...rest }
: { phoneNumber, ...rest };
emit('add', payload);
updateValueAndFocus(emailAddress);
};
const handleFocus = () => {
@@ -226,7 +216,6 @@ const handleBlur = e => emit('blur', e);
ref="tagInputRef"
v-model="newTag"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
class="w-full"
:focus-on-mount="focusOnMount"
@@ -0,0 +1,299 @@
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();
});
});
});
@@ -0,0 +1,124 @@
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);
@@ -8,6 +8,7 @@ import { useAI } from 'dashboard/composables/useAI';
// components
import ReplyBox from './ReplyBox.vue';
import Message from './Message.vue';
import NextMessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
@@ -37,6 +38,7 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
export default {
components: {
Message,
NextMessageList,
ReplyBox,
Banner,
ConversationLabelSuggestion,
@@ -80,6 +82,10 @@ export default {
fetchLabelSuggestions,
} = useAI();
const showNextBubbles = LocalStorage.get(
LOCAL_STORAGE_KEYS.USE_NEXT_BUBBLE
);
return {
isEnterprise,
isPopOutReplyBox,
@@ -89,6 +95,7 @@ export default {
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
showNextBubbles,
};
},
data() {
@@ -106,6 +113,7 @@ export default {
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
}),
@@ -436,7 +444,6 @@ export default {
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
getInReplyToMessage(parentMessage) {
if (!parentMessage) return {};
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
@@ -473,7 +480,46 @@ export default {
@click="onToggleContactPanel"
/>
</div>
<ul class="conversation-panel">
<NextMessageList
v-if="showNextBubbles"
class="conversation-panel"
:read-messages="readMessages"
:un-read-messages="unReadMessages"
:current-user-id="currentUserId"
:is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:messages="currentChat ? currentChat.messages : []"
>
<template #beforeAll>
<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>
</template>
<template #beforeUnread>
<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>
</template>
<template #after>
<ConversationLabelSuggestion
v-if="shouldShowLabelSuggestions"
:suggested-labels="labelSuggestions"
:chat-labels="currentChat.labels"
:conversation-id="currentChat.id"
/>
</template>
</NextMessageList>
<ul v-else class="conversation-panel">
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
@@ -556,7 +602,6 @@ export default {
<style scoped>
@tailwind components;
@layer components {
.rounded-bl-calc {
border-bottom-left-radius: calc(1.5rem + 1px);
@@ -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',
};
@@ -39,6 +39,7 @@
"DOWNLOAD": "Download",
"UNKNOWN_FILE_TYPE": "Unknown File",
"SAVE_CONTACT": "Save Contact",
"NO_CONTENT": "No content to display",
"SHARED_ATTACHMENT": {
"CONTACT": "{sender} has shared a contact",
"LOCATION": "{sender} has shared a location",
@@ -217,6 +218,7 @@
"DELETE": "Delete",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate",
"TOGGLE_BG": "Toggle background color",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"DELETE_CONFIRMATION": {
@@ -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,8 +39,12 @@ export default {
type: Object,
default: () => ({}),
},
hideButton: {
type: Boolean,
default: false,
},
},
emits: ['open', 'close', 'replyTo'],
emits: ['open', 'close', 'replyTo', 'toggleBg'],
setup() {
const { getPlainText } = useMessageFormatter();
return {
@@ -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: {
@@ -109,6 +116,9 @@ export default {
handleClose(e) {
this.$emit('close', e);
},
toggleBackground(e) {
this.$emit('toggleBg', e);
},
handleTranslate() {
const { locale } = this.getAccount(this.currentAccountId);
this.$store.dispatch('translateMessage', {
@@ -183,6 +193,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"
@@ -224,6 +235,15 @@ export default {
variant="icon"
@click.stop="handleTranslate"
/>
<MenuItem
v-if="enabledOptions['toggleBg']"
:option="{
icon: 'arrow-rotate-clockwise',
label: $t('CONVERSATION.CONTEXT_MENU.TOGGLE_BG'),
}"
variant="icon"
@click.stop="toggleBackground"
/>
<hr />
<MenuItem
:option="{
@@ -15,4 +15,5 @@ export const BUS_EVENTS = {
NEW_CONVERSATION_MODAL: 'newConversationModal',
INSERT_INTO_RICH_EDITOR: 'insertIntoRichEditor',
INSERT_INTO_NORMAL_EDITOR: 'insertIntoNormalEditor',
TOGGLE_MESSAGE_BG: 'toggleMessageBackground',
};
+19
View File
@@ -27,6 +27,7 @@ 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? }
@@ -83,6 +84,24 @@ 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)
return false unless valid_condition?(condition) && valid_query_operator?(condition)
end
true
@@ -23,6 +23,15 @@ 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,6 +9,7 @@ class Contacts::FilterService < FilterService
end
def perform
validate_query_operator
@contacts = query_builder(@filters['contacts'])
{
@@ -7,6 +7,7 @@ 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,4 +204,10 @@ 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.15.0'
version: '3.16.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::Railtie.load
Dotenv::Rails.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,6 +80,7 @@ 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
View File
@@ -328,6 +328,7 @@ Rails.application.routes.draw do
collection do
get :agent
get :team
get :inbox
end
end
resources :reports, only: [:index] do
@@ -0,0 +1,18 @@
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_09_23_215335) do
ActiveRecord::Schema[7.0].define(version: 2024_12_17_041352) 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,6 +11,12 @@ 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])
+22 -1
View File
@@ -1,8 +1,11 @@
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
@@ -46,7 +49,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
YOUTUBE_REGEX => :make_youtube_embed,
VIMEO_REGEX => :make_vimeo_embed,
MP4_REGEX => :make_video_embed,
LOOM_REGEX => :make_loom_embed
LOOM_REGEX => :make_loom_embed,
ARCADE_REGEX => :make_arcade_embed
}
embedding_methods.each do |regex, method|
@@ -118,4 +122,21 @@ 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 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.15.0",
"version": "3.16.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -72,7 +72,6 @@
"idb": "^8.0.0",
"js-cookie": "^3.0.5",
"libphonenumber-js": "^1.11.9",
"maplibre-gl": "^4.7.1",
"markdown-it": "^13.0.2",
"markdown-it-link-attributes": "^4.0.1",
"md5": "^2.3.0",
-259
View File
@@ -139,9 +139,6 @@ importers:
libphonenumber-js:
specifier: ^1.11.9
version: 1.11.9
maplibre-gl:
specifier: ^4.7.1
version: 4.7.1
markdown-it:
specifier: ^13.0.2
version: 13.0.2
@@ -994,34 +991,6 @@ packages:
resolution: {integrity: sha512-dUz8OmYvlY5A9wXaroHIMSPASpSYRLCqbPvxGSyHguhtTQIy24lC+EGxQlwv71AhRCO55WOtgwhzQLpw27JaJQ==}
engines: {node: '>=8'}
'@mapbox/geojson-rewind@0.5.2':
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
hasBin: true
'@mapbox/jsonlint-lines-primitives@2.0.2':
resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==}
engines: {node: '>= 0.6'}
'@mapbox/point-geometry@0.1.0':
resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==}
'@mapbox/tiny-sdf@2.0.6':
resolution: {integrity: sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==}
'@mapbox/unitbezier@0.0.1':
resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==}
'@mapbox/vector-tile@1.3.1':
resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==}
'@mapbox/whoots-js@3.1.0':
resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
engines: {node: '>=6.0.0'}
'@maplibre/maplibre-gl-style-spec@20.4.0':
resolution: {integrity: sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==}
hasBin: true
'@material/mwc-icon@0.25.3':
resolution: {integrity: sha512-36076AWZIRSr8qYOLjuDDkxej/HA0XAosrj7TS1ZeLlUBnLUtbDtvc1S7KSa0hqez7ouzOqGaWK24yoNnTa2OA==}
deprecated: MWC beta is longer supported. Please upgrade to @material/web
@@ -1771,24 +1740,12 @@ packages:
'@types/fs-extra@9.0.13':
resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
'@types/geojson-vt@3.2.5':
resolution: {integrity: sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==}
'@types/geojson@7946.0.14':
resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==}
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/mapbox__point-geometry@0.1.4':
resolution: {integrity: sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==}
'@types/mapbox__vector-tile@1.3.4':
resolution: {integrity: sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==}
'@types/markdown-it@12.2.3':
resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==}
@@ -1798,12 +1755,6 @@ packages:
'@types/node@22.7.0':
resolution: {integrity: sha512-MOdOibwBs6KW1vfqz2uKMlxq5xAfAZ98SZjO8e3XnAbFnTJtAspqhWk7hrdSAs9/Y14ZWMiy7/MxMUzAOadYEw==}
'@types/pbf@3.0.5':
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
'@types/supercluster@7.1.3':
resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==}
'@types/trusted-types@2.0.2':
resolution: {integrity: sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==}
@@ -2609,9 +2560,6 @@ packages:
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
engines: {node: '>=4'}
earcut@3.0.0:
resolution: {integrity: sha512-41Fs7Q/PLq1SDbqjsgcY7GA42T0jvaCNGXgGtsNdvg+Yv8eIu06bxv4/PoREkZ9nMDNwnUSG9OFB9+yv8eKhDg==}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -2982,9 +2930,6 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
geojson-vt@4.0.2:
resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@@ -3016,9 +2961,6 @@ packages:
resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==}
engines: {node: '>= 0.4'}
gl-matrix@3.4.3:
resolution: {integrity: sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -3039,10 +2981,6 @@ packages:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
engines: {node: '>=18'}
global-prefix@4.0.0:
resolution: {integrity: sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==}
engines: {node: '>=16'}
global@4.4.0:
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
@@ -3182,9 +3120,6 @@ packages:
idb@8.0.0:
resolution: {integrity: sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.2.4:
resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
engines: {node: '>= 4'}
@@ -3217,10 +3152,6 @@ packages:
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
ini@4.1.3:
resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
internal-slot@1.0.5:
resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==}
engines: {node: '>= 0.4'}
@@ -3372,10 +3303,6 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isexe@3.1.1:
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
engines: {node: '>=16'}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
@@ -3453,9 +3380,6 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
json-stringify-pretty-compact@4.0.0:
resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
json5@1.0.2:
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
hasBin: true
@@ -3463,9 +3387,6 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
kdbush@4.0.2:
resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==}
keycode@2.2.1:
resolution: {integrity: sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==}
@@ -3619,10 +3540,6 @@ packages:
resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
maplibre-gl@4.7.1:
resolution: {integrity: sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==}
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
markdown-it-anchor@8.6.7:
resolution: {integrity: sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==}
peerDependencies:
@@ -3755,9 +3672,6 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
murmurhash-js@1.0.0:
resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
mux.js@6.0.1:
resolution: {integrity: sha512-22CHb59rH8pWGcPGW5Og7JngJ9s+z4XuSlYvnxhLuc58cA1WqGDQPzuG8I+sPm1/p0CdgpzVTaKW408k5DNn8w==}
engines: {node: '>=8', npm: '>=5'}
@@ -3985,10 +3899,6 @@ packages:
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
engines: {node: '>= 14.16'}
pbf@3.3.0:
resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==}
hasBin: true
picocolors@1.0.1:
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
@@ -4253,9 +4163,6 @@ packages:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
potpack@2.0.0:
resolution: {integrity: sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -4328,9 +4235,6 @@ packages:
proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
protocol-buffers-schema@3.6.0:
resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
@@ -4355,12 +4259,6 @@ packages:
resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==}
engines: {node: '>=12'}
quickselect@2.0.0:
resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
quickselect@3.0.0:
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
radix-vue@1.9.9:
resolution: {integrity: sha512-DuL2o7jxNjzlSP5Ko+kJgrW5db+jC3RlnYQIs3WITTqgzfdeP7hXjcqIUveY1f0uXRpOAN3OAd5MZ/SpRyQzQQ==}
peerDependencies:
@@ -4409,9 +4307,6 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
resolve-protobuf-schema@2.1.0:
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
resolve@1.22.8:
resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==}
hasBin: true
@@ -4456,9 +4351,6 @@ packages:
rust-result@1.0.0:
resolution: {integrity: sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==}
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
@@ -4696,9 +4588,6 @@ packages:
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
supercluster@8.0.1:
resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -4777,9 +4666,6 @@ packages:
resolution: {integrity: sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyqueue@3.0.0:
resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==}
tinyspy@3.0.2:
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
engines: {node: '>=14.0.0'}
@@ -5037,9 +4923,6 @@ packages:
jsdom:
optional: true
vt-pbf@3.1.3:
resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==}
vue-chartjs@5.3.1:
resolution: {integrity: sha512-rZjqcHBxKiHrBl0CIvcOlVEBwRhpWAVf6rDU3vUfa7HuSRmGtCslc0Oc8m16oAVuk0erzc1FCtH1VCriHsrz+A==}
peerDependencies:
@@ -5229,11 +5112,6 @@ packages:
engines: {node: '>= 8'}
hasBin: true
which@4.0.0:
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
engines: {node: ^16.13.0 || >=18.0.0}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -6040,35 +5918,6 @@ snapshots:
dependencies:
'@lukeed/csprng': 1.0.1
'@mapbox/geojson-rewind@0.5.2':
dependencies:
get-stream: 6.0.1
minimist: 1.2.8
'@mapbox/jsonlint-lines-primitives@2.0.2': {}
'@mapbox/point-geometry@0.1.0': {}
'@mapbox/tiny-sdf@2.0.6': {}
'@mapbox/unitbezier@0.0.1': {}
'@mapbox/vector-tile@1.3.1':
dependencies:
'@mapbox/point-geometry': 0.1.0
'@mapbox/whoots-js@3.1.0': {}
'@maplibre/maplibre-gl-style-spec@20.4.0':
dependencies:
'@mapbox/jsonlint-lines-primitives': 2.0.2
'@mapbox/unitbezier': 0.0.1
json-stringify-pretty-compact: 4.0.0
minimist: 1.2.8
quickselect: 2.0.0
rw: 1.3.3
tinyqueue: 3.0.0
'@material/mwc-icon@0.25.3':
dependencies:
lit: 2.2.6
@@ -6910,24 +6759,10 @@ snapshots:
dependencies:
'@types/node': 22.7.0
'@types/geojson-vt@3.2.5':
dependencies:
'@types/geojson': 7946.0.14
'@types/geojson@7946.0.14': {}
'@types/json5@0.0.29': {}
'@types/linkify-it@5.0.0': {}
'@types/mapbox__point-geometry@0.1.4': {}
'@types/mapbox__vector-tile@1.3.4':
dependencies:
'@types/geojson': 7946.0.14
'@types/mapbox__point-geometry': 0.1.4
'@types/pbf': 3.0.5
'@types/markdown-it@12.2.3':
dependencies:
'@types/linkify-it': 5.0.0
@@ -6939,12 +6774,6 @@ snapshots:
dependencies:
undici-types: 6.19.8
'@types/pbf@3.0.5': {}
'@types/supercluster@7.1.3':
dependencies:
'@types/geojson': 7946.0.14
'@types/trusted-types@2.0.2': {}
'@types/web-bluetooth@0.0.20': {}
@@ -7878,8 +7707,6 @@ snapshots:
dset@3.1.4: {}
earcut@3.0.0: {}
eastasianwidth@0.2.0: {}
editorconfig@1.0.4:
@@ -8407,8 +8234,6 @@ snapshots:
functions-have-names@1.2.3: {}
geojson-vt@4.0.2: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.2.0: {}
@@ -8438,8 +8263,6 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.2.4
gl-matrix@3.4.3: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -8470,12 +8293,6 @@ snapshots:
dependencies:
ini: 4.1.1
global-prefix@4.0.0:
dependencies:
ini: 4.1.3
kind-of: 6.0.3
which: 4.0.0
global@4.4.0:
dependencies:
min-document: 2.19.0
@@ -8671,8 +8488,6 @@ snapshots:
idb@8.0.0: {}
ieee754@1.2.1: {}
ignore@5.2.4: {}
immutable@4.3.7:
@@ -8698,8 +8513,6 @@ snapshots:
ini@4.1.1: {}
ini@4.1.3: {}
internal-slot@1.0.5:
dependencies:
get-intrinsic: 1.2.4
@@ -8832,8 +8645,6 @@ snapshots:
isexe@2.0.0: {}
isexe@3.1.1: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
@@ -8955,8 +8766,6 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
json-stringify-pretty-compact@4.0.0: {}
json5@1.0.2:
dependencies:
minimist: 1.2.8
@@ -8967,8 +8776,6 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
kdbush@4.0.2: {}
keycode@2.2.1: {}
keyv@4.5.3:
@@ -9142,35 +8949,6 @@ snapshots:
map-obj@5.0.0: {}
maplibre-gl@4.7.1:
dependencies:
'@mapbox/geojson-rewind': 0.5.2
'@mapbox/jsonlint-lines-primitives': 2.0.2
'@mapbox/point-geometry': 0.1.0
'@mapbox/tiny-sdf': 2.0.6
'@mapbox/unitbezier': 0.0.1
'@mapbox/vector-tile': 1.3.1
'@mapbox/whoots-js': 3.1.0
'@maplibre/maplibre-gl-style-spec': 20.4.0
'@types/geojson': 7946.0.14
'@types/geojson-vt': 3.2.5
'@types/mapbox__point-geometry': 0.1.4
'@types/mapbox__vector-tile': 1.3.4
'@types/pbf': 3.0.5
'@types/supercluster': 7.1.3
earcut: 3.0.0
geojson-vt: 4.0.2
gl-matrix: 3.4.3
global-prefix: 4.0.0
kdbush: 4.0.2
murmurhash-js: 1.0.0
pbf: 3.3.0
potpack: 2.0.0
quickselect: 3.0.0
supercluster: 8.0.1
tinyqueue: 3.0.0
vt-pbf: 3.1.3
markdown-it-anchor@8.6.7(@types/markdown-it@12.2.3)(markdown-it@12.3.2):
dependencies:
'@types/markdown-it': 12.2.3
@@ -9297,8 +9075,6 @@ snapshots:
ms@2.1.3: {}
murmurhash-js@1.0.0: {}
mux.js@6.0.1:
dependencies:
'@babel/runtime': 7.25.6
@@ -9519,11 +9295,6 @@ snapshots:
pathval@2.0.0: {}
pbf@3.3.0:
dependencies:
ieee754: 1.2.1
resolve-protobuf-schema: 2.1.0
picocolors@1.0.1: {}
picocolors@1.1.0: {}
@@ -9814,8 +9585,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
potpack@2.0.0: {}
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0:
@@ -9921,8 +9690,6 @@ snapshots:
proto-list@1.2.4: {}
protocol-buffers-schema@3.6.0: {}
proxy-from-env@1.1.0: {}
psl@1.9.0: {}
@@ -9937,10 +9704,6 @@ snapshots:
quick-lru@6.1.2: {}
quickselect@2.0.0: {}
quickselect@3.0.0: {}
radix-vue@1.9.9(vue@3.5.12(typescript@5.6.2)):
dependencies:
'@floating-ui/dom': 1.6.12
@@ -9996,10 +9759,6 @@ snapshots:
resolve-from@4.0.0: {}
resolve-protobuf-schema@2.1.0:
dependencies:
protocol-buffers-schema: 3.6.0
resolve@1.22.8:
dependencies:
is-core-module: 2.15.1
@@ -10060,8 +9819,6 @@ snapshots:
dependencies:
individual: 2.0.0
rw@1.3.3: {}
sade@1.8.1:
dependencies:
mri: 1.2.0
@@ -10329,10 +10086,6 @@ snapshots:
pirates: 4.0.6
ts-interface-checker: 0.1.13
supercluster@8.0.1:
dependencies:
kdbush: 4.0.2
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -10433,8 +10186,6 @@ snapshots:
tinypool@1.0.0: {}
tinyqueue@3.0.0: {}
tinyspy@3.0.2: {}
to-fast-properties@2.0.0: {}
@@ -10719,12 +10470,6 @@ snapshots:
- supports-color
- terser
vt-pbf@3.1.3:
dependencies:
'@mapbox/point-geometry': 0.1.0
'@mapbox/vector-tile': 1.3.1
pbf: 3.3.0
vue-chartjs@5.3.1(chart.js@4.4.4)(vue@3.5.12(typescript@5.6.2)):
dependencies:
chart.js: 4.4.4
@@ -10912,10 +10657,6 @@ snapshots:
dependencies:
isexe: 2.0.0
which@4.0.0:
dependencies:
isexe: 3.1.1
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
@@ -0,0 +1,143 @@
require 'rails_helper'
RSpec.describe V2::Reports::AgentSummaryBuilder do
let(:account) { create(:account) }
let(:user1) { create(:user, account: account, role: :agent) }
let(:user2) { create(:user, account: account, role: :agent) }
let(:params) do
{
business_hours: business_hours,
since: 1.week.ago.beginning_of_day,
until: Time.current.end_of_day
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#build' do
context 'when there is team data' do
before do
c1 = create(:conversation, account: account, assignee: user1, created_at: Time.current)
c2 = create(:conversation, account: account, assignee: user2, created_at: Time.current)
create(
:reporting_event,
account: account,
conversation: c2,
user: user2,
name: 'conversation_resolved',
value: 50,
value_in_business_hours: 40,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
user: user1,
name: 'first_response',
value: 20,
value_in_business_hours: 10,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
user: user1,
name: 'reply_time',
value: 30,
value_in_business_hours: 15,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
user: user1,
name: 'reply_time',
value: 40,
value_in_business_hours: 25,
created_at: Time.current
)
end
context 'when business hours is disabled' do
let(:business_hours) { false }
it 'returns the correct team stats' do
report = builder.build
expect(report).to eq(
[
{
id: user1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 20.0,
avg_reply_time: 35.0
},
{
id: user2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 50.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end
context 'when business hours is enabled' do
let(:business_hours) { true }
it 'uses business hours values' do
report = builder.build
expect(report).to eq(
[
{
id: user1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 10.0,
avg_reply_time: 20.0
},
{
id: user2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 40.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end
end
context 'when there is no team data' do
let!(:new_user) { create(:user, account: account, role: :agent) }
let(:business_hours) { false }
it 'returns zero values' do
report = builder.build
expect(report).to include(
{
id: new_user.id,
conversations_count: 0,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: nil,
avg_reply_time: nil
}
)
end
end
end
end
@@ -0,0 +1,101 @@
require 'rails_helper'
RSpec.describe V2::Reports::InboxSummaryBuilder do
let(:account) { create(:account) }
let(:i1) { create(:inbox, account: account) }
let(:i2) { create(:inbox, account: account) }
let(:params) do
{
business_hours: business_hours,
since: 1.week.ago.beginning_of_day,
until: Time.current.end_of_day
}
end
let(:builder) { described_class.new(account: account, params: params) }
before do
c1 = create(:conversation, account: account, inbox: i1, created_at: 2.days.ago)
c2 = create(:conversation, account: account, inbox: i2, created_at: 1.day.ago)
c2.resolved!
create(:reporting_event, account: account, conversation: c2, inbox: i2, name: 'conversation_resolved', value: 100, value_in_business_hours: 60,
created_at: 1.day.ago)
create(:reporting_event, account: account, conversation: c1, inbox: i1, name: 'first_response', value: 50, value_in_business_hours: 30,
created_at: 1.day.ago)
create(:reporting_event, account: account, conversation: c1, inbox: i1, name: 'reply_time', value: 30, value_in_business_hours: 10,
created_at: 1.day.ago)
create(:reporting_event, account: account, conversation: c1, inbox: i1, name: 'reply_time', value: 40, value_in_business_hours: 20,
created_at: 1.day.ago)
end
describe '#build' do
subject(:report) { builder.build }
context 'when business hours is disabled' do
let(:business_hours) { false }
it 'includes correct stats for each inbox' do
expect(report).to eq(
[
{
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 50.0,
avg_reply_time: 35.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 100.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end
context 'when business hours is enabled' do
let(:business_hours) { true }
it 'uses business hours values for calculations' do
expect(report).to eq(
[
{
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 30.0,
avg_reply_time: 15.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 60.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end
context 'when there is no data for an inbox' do
let!(:empty_inbox) { create(:inbox, account: account) }
let(:business_hours) { false }
it 'returns nil values for metrics' do
expect(report).to include(
id: empty_inbox.id,
conversations_count: 0,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: nil,
avg_reply_time: nil
)
end
end
end
end
@@ -0,0 +1,138 @@
require 'rails_helper'
RSpec.describe V2::Reports::TeamSummaryBuilder do
let(:account) { create(:account) }
let(:team1) { create(:team, account: account, name: 'team-1') }
let(:team2) { create(:team, account: account, name: 'team-2') }
let(:params) do
{
business_hours: business_hours,
since: 1.week.ago.beginning_of_day,
until: Time.current.end_of_day
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#build' do
context 'when there is team data' do
before do
c1 = create(:conversation, account: account, team: team1, created_at: Time.current)
c2 = create(:conversation, account: account, team: team2, created_at: Time.current)
create(
:reporting_event,
account: account,
conversation: c2,
name: 'conversation_resolved',
value: 50,
value_in_business_hours: 40,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
name: 'first_response',
value: 20,
value_in_business_hours: 10,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
name: 'reply_time',
value: 30,
value_in_business_hours: 15,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
name: 'reply_time',
value: 40,
value_in_business_hours: 25,
created_at: Time.current
)
end
context 'when business hours is disabled' do
let(:business_hours) { false }
it 'returns the correct team stats' do
report = builder.build
expect(report).to eq(
[
{
id: team1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 20.0,
avg_reply_time: 35.0
},
{
id: team2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 50.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end
context 'when business hours is enabled' do
let(:business_hours) { true }
it 'uses business hours values' do
report = builder.build
expect(report).to eq(
[
{
id: team1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 10.0,
avg_reply_time: 20.0
},
{
id: team2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 40.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end
end
context 'when there is no team data' do
let!(:new_team) { create(:team, account: account) }
let(:business_hours) { false }
it 'returns zero values' do
report = builder.build
expect(report).to include(
{
id: new_team.id,
conversations_count: 0,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: nil,
avg_reply_time: nil
}
)
end
end
end
end
@@ -76,6 +76,23 @@ 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] << {
@@ -59,6 +59,57 @@ RSpec.describe 'Summary Reports API', type: :request do
end
end
describe 'GET /api/v2/accounts/:account_id/summary_reports/inbox' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v2/accounts/#{account.id}/summary_reports/inbox"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:params) do
{
since: start_of_today.to_s,
until: end_of_today.to_s,
business_hours: true
}
end
it 'returns unauthorized for inbox' do
get "/api/v2/accounts/#{account.id}/summary_reports/inbox",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'calls V2::Reports::InboxSummaryBuilder with the right params if the user is an admin' do
inbox_summary_builder = double
allow(V2::Reports::InboxSummaryBuilder).to receive(:new).and_return(inbox_summary_builder)
allow(inbox_summary_builder).to receive(:build).and_return([{ id: 1, conversations_count: 110 }])
get "/api/v2/accounts/#{account.id}/summary_reports/inbox",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::InboxSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(inbox_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(1)
expect(json_response.first['conversations_count']).to eq(110)
expect(json_response.first['avg_reply_time']).to be_nil
end
end
end
describe 'GET /api/v2/accounts/:account_id/summary_reports/team' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
+28
View File
@@ -137,5 +137,33 @@ 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,6 +46,17 @@ 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,6 +146,19 @@ 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,6 +185,30 @@ 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
+4 -4
View File
@@ -44,7 +44,7 @@ const tailwindConfig = {
typography: {
email: {
css: {
color: 'rgb(var(--slate-11))',
color: 'rgb(var(--slate-12))',
lineHeight: '1.6',
fontSize: '14px',
'*': {
@@ -129,16 +129,16 @@ const tailwindConfig = {
th: {
padding: '0.75em',
color: 'rgb(var(--slate-12))',
borderBottom: `1px solid rgb(var(--border-strong))`,
border: `none`,
textAlign: 'left',
fontWeight: '600',
},
tr: {
borderBottom: `1px solid rgb(var(--border-strong))`,
border: `none`,
},
td: {
padding: '0.75em',
borderBottom: `1px solid rgb(var(--border-strong))`,
border: `none`,
},
img: {
maxWidth: '100%',
+15
View File
@@ -360,6 +360,21 @@ export const colors = {
12: 'rgb(var(--teal-12) / <alpha-value>)',
},
gray: {
1: 'rgb(var(--gray-1) / <alpha-value>)',
2: 'rgb(var(--gray-2) / <alpha-value>)',
3: 'rgb(var(--gray-3) / <alpha-value>)',
4: 'rgb(var(--gray-4) / <alpha-value>)',
5: 'rgb(var(--gray-5) / <alpha-value>)',
6: 'rgb(var(--gray-6) / <alpha-value>)',
7: 'rgb(var(--gray-7) / <alpha-value>)',
8: 'rgb(var(--gray-8) / <alpha-value>)',
9: 'rgb(var(--gray-9) / <alpha-value>)',
10: 'rgb(var(--gray-10) / <alpha-value>)',
11: 'rgb(var(--gray-11) / <alpha-value>)',
12: 'rgb(var(--gray-12) / <alpha-value>)',
},
black: '#000000',
brand: '#2781F6',
background: 'rgb(var(--background-color) / <alpha-value>)',