Compare commits

..
53 changed files with 442 additions and 269 deletions
@@ -28,7 +28,12 @@ const inboxes = computed(() => {
return {
name: inbox.name,
id: inbox.id,
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
icon: getInboxIconByType(
inbox.channelType,
inbox.medium,
'line',
inbox.voiceEnabled
),
};
});
});
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
import { getInboxVoiceIcon } from 'dashboard/helper/inbox';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
@@ -60,7 +61,7 @@ const resultLabel = computed(() => {
});
const providerIcon = computed(() =>
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
getInboxVoiceIcon(props.call.inbox.channelType, props.call.inbox.medium)
);
const createdAtLabel = computed(() =>
@@ -150,7 +151,7 @@ const conversationRoute = computed(() => ({
<div
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
>
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
<div class="flex items-center gap-2.5 min-w-0 w-52 shrink-0 py-3.5">
<Avatar
:src="call.contact.avatar"
:name="contactName"
@@ -169,9 +170,12 @@ const conversationRoute = computed(() => ({
>
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
<CallStatusBadge :kind="kind" class="shrink-0" />
<template v-if="agentActionLabel">
<div
v-if="agentActionLabel"
class="gap-x-1.5 min-w-0 flex items-center"
>
<span
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
class="text-label-small text-n-slate-10 truncate shrink min-w-8 xl:min-w-14"
>
{{ agentActionLabel }}
</span>
@@ -192,7 +196,7 @@ const conversationRoute = computed(() => ({
{{ call.agent.name }}
</span>
</span>
</template>
</div>
<span
v-else-if="resultLabel"
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
@@ -212,10 +216,10 @@ const conversationRoute = computed(() => ({
content: call.inbox.name,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
class="flex items-center gap-1 justify-end w-40 min-w-4 shrink-[100] py-3.5"
>
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11">
<span class="text-body-main truncate text-n-slate-11 min-w-0">
{{ call.inbox.name }}
</span>
</div>
@@ -232,7 +236,7 @@ const conversationRoute = computed(() => ({
content: createdAtLabel,
delay: { show: 500, hide: 0 },
}"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end min-w-16 max-w-20 shrink-0"
>
{{ createdAtLabel }}
</span>
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
// Null while a fetch is in flight so stale counts are never shown.
@@ -117,10 +118,16 @@ const moreFiltersSections = computed(() => [
},
]);
const selectedAssignee = computed(
() => props.agents.find(agent => agent.id === assigneeId.value) || null
);
const selectedAssigneeLabel = computed(
() =>
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
() => selectedAssignee.value?.name || t('CALLS_PAGE.FILTERS.ASSIGNEE')
);
const isOtherActivitySelected = computed(() =>
OTHER_ACTIVITIES.includes(activity.value)
);
const hasMoreFilters = computed(() => Boolean(inboxId.value));
@@ -143,7 +150,7 @@ const applyMoreFilter = ({ action, value }) => {
<template>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<div class="flex flex-wrap items-center gap-2">
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
{{
totalCount === null
@@ -157,13 +164,13 @@ const applyMoreFilter = ({ action, value }) => {
color="blue"
size="sm"
:icon="ACTIVITY_ICONS[activity]"
class="shrink-0"
class="shrink-0 !h-7 !px-2"
@click="setActivity(null)"
>
{{ activeChipLabel }}
<Icon icon="i-lucide-x" />
</Button>
<div class="w-px h-4 bg-n-strong shrink-0" />
<div class="w-px h-3.5 mx-1 bg-n-strong shrink-0" />
<Button
v-for="chip in inactiveChips"
:key="chip"
@@ -172,7 +179,7 @@ const applyMoreFilter = ({ action, value }) => {
size="sm"
:icon="ACTIVITY_ICONS[chip]"
:label="activityLabel(chip)"
class="shrink-0 text-n-slate-12"
class="shrink-0 text-n-slate-11 !h-7 !px-2"
@click="setActivity(chip)"
/>
<OnClickOutside
@@ -184,7 +191,10 @@ const applyMoreFilter = ({ action, value }) => {
color="slate"
size="sm"
icon="i-lucide-phone"
class="text-n-slate-12"
class="!h-7 !px-2"
:class="
isOtherActivitySelected ? 'text-n-slate-12' : 'text-n-slate-11'
"
@click="toggleMenu('activity')"
>
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
@@ -208,10 +218,19 @@ const applyMoreFilter = ({ action, value }) => {
variant="outline"
color="slate"
size="sm"
icon="i-lucide-user-round-cog"
class="max-w-52 text-n-slate-12"
icon="i-woot-empty-assignee"
class="max-w-52 !h-7 !px-2"
:class="assigneeId ? 'text-n-slate-12' : 'text-n-slate-11'"
@click="toggleMenu('assignee')"
>
<template v-if="selectedAssignee" #icon>
<Avatar
:src="selectedAssignee.thumbnail"
:name="selectedAssignee.name"
:size="16"
rounded-full
/>
</template>
<span class="truncate">{{ selectedAssigneeLabel }}</span>
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
</Button>
@@ -228,8 +247,9 @@ const applyMoreFilter = ({ action, value }) => {
variant="outline"
size="sm"
icon="i-lucide-list-filter"
class="!h-7 !px-2"
:color="hasMoreFilters ? 'blue' : 'slate'"
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
@click="toggleMenu('more')"
>
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
@@ -83,8 +83,12 @@ const campaignStatus = computed(() => {
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium);
const {
medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = props.inbox;
return getInboxIconByType(type, medium, 'fill', voiceEnabled);
});
</script>
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
});
const lastActivityAt = computed(() => {
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
const inbox = computed(() => props.stateInbox);
const inboxIcon = computed(() => {
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
});
const hasSlaThreshold = computed(() => {
@@ -37,10 +37,11 @@ const transformInbox = ({
channelType,
phoneNumber,
medium,
voiceEnabled,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, medium, 'line'),
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
label: generateLabelForContactableInboxesList({
name,
email,
@@ -54,6 +55,7 @@ const transformInbox = ({
phoneNumber,
channelType,
medium,
voiceEnabled,
...rest,
});
@@ -79,6 +79,20 @@ describe('composeConversationHelper', () => {
channelType: INBOX_TYPES.EMAIL,
});
});
it('uses the voice glyph for a voice-enabled inbox', () => {
const inboxes = [
{
id: 2,
name: 'WhatsApp Cloud',
channelType: INBOX_TYPES.WHATSAPP,
voiceEnabled: true,
},
];
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0].icon).toBe('i-woot-whatsapp-voice');
});
});
describe('getCapitalizedNameFromEmail', () => {
@@ -117,8 +117,8 @@ const downloadRecording = () => {
>
<template #icon>
<Icon
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="size-4 flex-shrink-0"
:icon="isPlaying ? 'i-woot-audio-pause' : 'i-woot-audio-play'"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
</template>
</Button>
@@ -127,10 +127,10 @@ const downloadRecording = () => {
min="0"
:max="duration || 0"
:value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-0.5 rounded-full appearance-none cursor-pointer bg-n-slate-12/30 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-n-slate-11 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-n-slate-11"
@input="seek"
/>
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
<span class="text-label-small tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }}
</span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
@@ -58,8 +58,12 @@ const menuItems = computed(() => [
]);
const icon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium, 'outline');
const {
medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = props.inbox;
return getInboxIconByType(type, medium, 'outline', voiceEnabled);
});
const handleAction = ({ action, value }) => {
@@ -1,6 +1,5 @@
<script setup>
import { computed, toRef } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useChannelIcon, useChannelBrandIcon } from './provider';
import Icon from 'next/icon/Icon.vue';
@@ -21,7 +20,6 @@ defineOptions({ inheritAttrs: false });
const inboxRef = toRef(props, 'inbox');
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
const channelIcon = useChannelIcon(inboxRef);
const brandIcon = useChannelBrandIcon(inboxRef);
@@ -31,13 +29,7 @@ const icon = computed(() =>
</script>
<template>
<span class="relative inline-flex" v-bind="$attrs">
<span class="inline-flex" v-bind="$attrs">
<Icon :icon="icon" class="size-full" />
<span
v-if="hasVoiceBadge"
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
>
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
</span>
</span>
</template>
@@ -1,4 +1,9 @@
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import {
INBOX_TYPES,
TWILIO_CHANNEL_MEDIUM,
isVoiceCallEnabled,
getInboxVoiceIcon,
} from 'dashboard/helper/inbox';
import { computed } from 'vue';
const channelTypeIconMap = {
@@ -61,16 +66,9 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp';
}
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
// is presented as a Voice channel, so show the phone icon.
const voiceEnabled =
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
if (
type === INBOX_TYPES.TWILIO &&
voiceEnabled &&
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
icon = 'i-woot-voice';
// Voice-enabled inboxes use the combined channel + voice-wave badge glyph.
if (isVoiceCallEnabled(inboxDetails)) {
icon = getInboxVoiceIcon(type, inboxDetails.medium);
}
return icon ?? 'i-ri-global-fill';
@@ -19,13 +19,32 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp');
});
it('returns correct icon for voice-enabled Twilio channel', () => {
it('returns the voice-call glyph for a voice-enabled Twilio channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice');
expect(icon).toBe('i-woot-voice-call');
});
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp channel', () => {
const inbox = {
channel_type: 'Channel::Whatsapp',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-whatsapp-voice');
});
it('returns the WhatsApp voice glyph for a voice-enabled Twilio WhatsApp channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-whatsapp-voice');
});
it('returns correct icon for Line channel', () => {
@@ -457,10 +457,11 @@ function handleReplyTo() {
const avatarInfo = computed(() => {
if (props.contentAttributes?.externalEcho) {
const { name, avatar_url, channel_type, medium } = inbox.value;
const { name, avatar_url, channel_type, medium, voice_enabled } =
inbox.value;
const iconName = avatar_url
? null
: getInboxIconByType(channel_type, medium);
: getInboxIconByType(channel_type, medium, 'fill', voice_enabled);
return {
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
src: avatar_url || '',
@@ -77,6 +77,15 @@ const handleClose = () => {
emit('close');
};
const handleFocusOut = event => {
// Keep the menu open while focus stays inside it (e.g. the label search
// input); close it once focus leaves the menu entirely.
if (menuRef.value?.contains(event.relatedTarget)) {
return;
}
handleClose();
};
onUnmounted(() => {
isLocked.value = false;
});
@@ -89,7 +98,7 @@ onUnmounted(() => {
class="fixed outline-none z-[9999] cursor-pointer"
:style="position"
tabindex="0"
@blur="handleClose"
@focusout="handleFocusOut"
>
<slot />
</div>
@@ -7,10 +7,13 @@ import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
} from 'dashboard/helper/agentHelper.js';
import { picoSearch } from '@scmmishra/pico-search';
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const MENU = {
MARK_AS_READ: 'mark-as-read',
@@ -31,6 +34,8 @@ export default {
MenuItem,
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
NextInput,
Icon,
},
props: {
chatId: {
@@ -87,6 +92,7 @@ export default {
data() {
return {
MENU,
labelSearchQuery: '',
STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
@@ -216,6 +222,14 @@ export default {
// Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN;
},
filteredLabels() {
const labels = this.labelSearchQuery
? picoSearch(this.labels, this.labelSearchQuery, ['title'])
: this.labels;
// Assigned labels first, keeping each group's existing order.
const isAssigned = label => this.conversationLabels.includes(label.title);
return [...labels].sort((a, b) => isAssigned(b) - isAssigned(a));
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
@@ -335,21 +349,49 @@ export default {
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
<div class="pb-1 w-[12.5rem]">
<NextInput
v-model="labelSearchQuery"
type="search"
size="sm"
class="w-full"
custom-input-class="!ps-8 !text-xs"
:placeholder="$t('CONVERSATION.CARD_CONTEXT_MENU.SEARCH_LABELS')"
@click.stop
@keydown.stop
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute z-10 -translate-y-1/2 pointer-events-none size-3.5 text-n-slate-10 top-1/2 start-2"
/>
</template>
</NextInput>
</div>
<div class="overflow-x-hidden overflow-y-auto max-h-[12.5rem]">
<MenuItem
v-for="label in filteredLabels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@mousedown.prevent
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
<p
v-if="!filteredLabels.length"
class="px-2 py-2 m-0 text-xs text-center text-n-slate-11"
>
{{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }}
</p>
</div>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])"
@@ -15,7 +15,7 @@ defineProps({
</script>
<template>
<div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
<div class="menu group text-n-slate-12 min-h-7 min-w-0" role="button">
<fluent-icon
v-if="variant === 'icon' && option.icon"
:icon="option.icon"
@@ -52,7 +52,7 @@ defineProps({
<Icon
v-if="variant === 'label-assigned'"
icon="i-lucide-check"
class="flex-shrink-0 size-3.5 mr-1"
class="flex-shrink-0 size-3.5 text-n-brand group-hover:text-white"
/>
</div>
</template>
+27 -1
View File
@@ -55,11 +55,30 @@ export const getVoiceCallProvider = inbox => {
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
// Combined channel + voice-wave badge glyph per voice-call provider.
export const VOICE_CALL_ICONS = {
[VOICE_CALL_PROVIDERS.WHATSAPP]: 'i-woot-whatsapp-voice',
[VOICE_CALL_PROVIDERS.TWILIO]: 'i-woot-voice-call',
};
export const getVoiceCallIcon = provider =>
VOICE_CALL_ICONS[provider] ?? VOICE_CALL_ICONS[VOICE_CALL_PROVIDERS.TWILIO];
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
};
export const getInboxVoiceIcon = (channelType, medium) => {
const isWhatsapp =
channelType === INBOX_TYPES.WHATSAPP ||
(channelType === INBOX_TYPES.TWILIO &&
medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP);
return getVoiceCallIcon(
isWhatsapp ? VOICE_CALL_PROVIDERS.WHATSAPP : VOICE_CALL_PROVIDERS.TWILIO
);
};
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
@@ -182,7 +201,14 @@ export const getInboxClassByType = (type, phoneNumber) => {
}
};
export const getInboxIconByType = (type, medium, variant = 'fill') => {
export const getInboxIconByType = (
type,
medium,
variant = 'fill',
voiceEnabled = false
) => {
if (voiceEnabled) return getInboxVoiceIcon(type, medium);
const iconMap =
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
const defaultIcon =
@@ -1,8 +1,11 @@
import {
INBOX_TYPES,
VOICE_CALL_PROVIDERS,
getInboxClassByType,
getInboxIconByType,
getInboxVoiceIcon,
getInboxWarningIconClass,
getVoiceCallIcon,
} from '../inbox';
describe('#Inbox Helpers', () => {
@@ -166,4 +169,63 @@ describe('#Inbox Helpers', () => {
);
});
});
describe('getVoiceCallIcon', () => {
it('returns the WhatsApp voice glyph for the whatsapp provider', () => {
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.WHATSAPP)).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the generic voice-call glyph for the twilio provider', () => {
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.TWILIO)).toBe(
'i-woot-voice-call'
);
});
it('falls back to the generic voice-call glyph for an unknown provider', () => {
expect(getVoiceCallIcon('unknown')).toBe('i-woot-voice-call');
expect(getVoiceCallIcon(undefined)).toBe('i-woot-voice-call');
});
});
describe('getInboxVoiceIcon', () => {
it('returns the WhatsApp voice glyph for a WhatsApp inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.WHATSAPP)).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the WhatsApp voice glyph for a Twilio WhatsApp inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the generic voice-call glyph for a Twilio voice inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'sms')).toBe(
'i-woot-voice-call'
);
});
});
describe('getInboxIconByType with voice enabled', () => {
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp inbox', () => {
expect(
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', true)
).toBe('i-woot-whatsapp-voice');
});
it('returns the generic voice-call glyph for a voice-enabled Twilio inbox', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line', true)).toBe(
'i-woot-voice-call'
);
});
it('returns the normal channel icon when voice is not enabled', () => {
expect(
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', false)
).toBe('i-woot-whatsapp');
});
});
});
@@ -193,6 +193,8 @@
},
"ASSIGN_AGENT": "Assign agent",
"ASSIGN_LABEL": "Assign label",
"SEARCH_LABELS": "Search labels",
"NO_LABELS_FOUND": "No labels found",
"AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
@@ -87,8 +87,8 @@ const inboxName = computed(() => props.inbox?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
});
</script>
@@ -65,8 +65,8 @@ const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
});
const fileAttachments = computed(() => {
@@ -129,22 +129,22 @@ onMounted(async () => {
v-else
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
>
<header class="px-6 pt-6 pb-4 shrink-0">
<div class="w-full">
<header class="shrink-0">
<div class="w-full px-6 pt-6">
<h1 class="text-xl font-medium text-n-slate-12">
{{ t('CALLS_PAGE.HEADER') }}
</h1>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</div>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5 pb-4 border-b border-n-weak mx-6"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full">
@@ -79,13 +79,15 @@ const breadcrumbItems = computed(() => {
});
const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || [];
allInboxes?.map(
({ name, id, email, phoneNumber, channelType, medium, voiceEnabled }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
})
) || [];
const policyInboxes = computed(() =>
buildInboxList(selectedPolicy.value?.inboxes)
@@ -64,13 +64,23 @@ const allInboxes = computed(
inboxes.value
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || []
.map(
({
name,
id,
email,
phoneNumber,
channelType,
medium,
voiceEnabled,
}) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
})
) || []
);
const formData = computed(() => ({
@@ -29,7 +29,8 @@ const inboxIcon = computed(() => {
return getInboxIconByType(
props.inbox.channelType,
props.inbox.medium,
'line'
'line',
props.inbox.voiceEnabled
);
});
@@ -281,8 +281,12 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
},
inboxIcon() {
const { medium, channel_type: type } = this.inbox;
return getInboxIconByType(type, medium, 'line');
const {
medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = this.inbox;
return getInboxIconByType(type, medium, 'line', voiceEnabled);
},
bannerMaxWidth() {
const narrowTabs = ['collaborators', 'bot-configuration'];
+2
View File
@@ -22,6 +22,7 @@
class EmailTemplate < ApplicationRecord
BRANDED_LAYOUT_NAME = 'base'.freeze
DEFAULT_LOCALE = 'en'.freeze
MAX_BODY_LENGTH = 256.kilobytes
CONTENT_FOR_LAYOUT_PATTERN = /\{\{\s*content_for_layout\s*\}\}/
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
@@ -34,6 +35,7 @@ class EmailTemplate < ApplicationRecord
if: :installation_scoped?
validates :name, uniqueness: { scope: %i[account_id template_type locale], conditions: -> { where(inbox_id: nil) } }, if: :account_scoped?
validates :name, uniqueness: { scope: %i[inbox_id template_type locale] }, if: :inbox_scoped?
validates :body, length: { maximum: MAX_BODY_LENGTH }
validate :validate_inbox_account
validate :validate_liquid_body
validate :validate_layout_slot, if: :layout?
@@ -1,17 +0,0 @@
class CreateCaptainMessageSources < ActiveRecord::Migration[7.1]
def change
create_table :captain_message_sources do |t|
t.references :account, null: false, index: true
t.references :assistant, null: false, index: true
t.references :conversation, null: false, index: true
t.references :message, null: false, index: true
t.references :document, null: false, index: true
t.bigint :assistant_response_id, null: false
t.timestamps
end
add_index :captain_message_sources, [:message_id, :assistant_response_id],
unique: true, name: 'idx_captain_message_sources_on_message_and_response'
end
end
+1 -18
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_07_21_100000) do
ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -477,23 +477,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_21_100000) do
t.index ["user_id"], name: "index_captain_message_reports_on_user_id"
end
create_table "captain_message_sources", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "assistant_id", null: false
t.bigint "conversation_id", null: false
t.bigint "message_id", null: false
t.bigint "document_id", null: false
t.bigint "assistant_response_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_captain_message_sources_on_account_id"
t.index ["assistant_id"], name: "index_captain_message_sources_on_assistant_id"
t.index ["conversation_id"], name: "index_captain_message_sources_on_conversation_id"
t.index ["document_id"], name: "index_captain_message_sources_on_document_id"
t.index ["message_id", "assistant_response_id"], name: "idx_captain_message_sources_on_message_and_response", unique: true
t.index ["message_id"], name: "index_captain_message_sources_on_message_id"
end
create_table "captain_scenarios", force: :cascade do |t|
t.string "title"
t.text "description"
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
@documents = filtered_documents
@documents_count = @documents.count
@sync_interval_hours = current_sync_interval&.in_hours&.to_i
@documents = with_document_usage(@documents).page(@current_page).per(RESULTS_PER_PAGE)
@documents = with_responses_count(@documents).page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
@@ -61,23 +61,14 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
apply_sort(documents, permitted_params[:sort])
end
def with_document_usage(scope)
response_counts = Captain::AssistantResponse.where(documentable_type: 'Captain::Document')
.group(:documentable_id)
.select('documentable_id AS document_id, COUNT(*) AS responses_count')
source_counts = Captain::MessageSource.group(:document_id).select(
'document_id, COUNT(DISTINCT message_id) AS used_in_answers_count, COUNT(DISTINCT conversation_id) AS used_in_conversations_count'
)
scope.joins("LEFT JOIN (#{response_counts.to_sql}) response_counts ON response_counts.document_id = captain_documents.id")
.joins("LEFT JOIN (#{source_counts.to_sql}) source_counts ON source_counts.document_id = captain_documents.id")
.select('captain_documents.*, COALESCE(response_counts.responses_count, 0) AS responses_count, ' \
'COALESCE(source_counts.used_in_answers_count, 0) AS used_in_answers_count, ' \
'COALESCE(source_counts.used_in_conversations_count, 0) AS used_in_conversations_count')
def with_responses_count(scope)
scope.left_joins(:responses)
.select('captain_documents.*, COUNT(captain_assistant_responses.id) AS responses_count')
.group('captain_documents.id')
end
def set_document
@document = with_document_usage(@documents).find(permitted_params[:id])
@document = @documents.find(permitted_params[:id])
end
def set_assistant
+1 -1
View File
@@ -62,7 +62,7 @@ class CallFinder
end
def paginated_calls
@calls.includes(:contact, :inbox, :conversation, :accepted_by_agent)
@calls.includes(:contact, :conversation, :accepted_by_agent, inbox: :channel)
.order(created_at: :desc)
.page(@params[:page] || 1)
.per(RESULTS_PER_PAGE)
@@ -68,7 +68,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
# left is the customer-facing follow-up message.
process_v2_handoff
end
capture_assistant_session(result_message: @handoff_message, credits_consumed: 0.0, capture_message_sources: false)
capture_assistant_session(result_message: @handoff_message, credits_consumed: 0.0)
elsif v1_handoff_requested?
# V1 only signals via the response string — no state has been touched yet. If
# the conversation isn't pending anymore, a human took over mid-run; bail out
@@ -83,7 +83,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage
end
capture_assistant_session(result_message: message, credits_consumed: 1.0, capture_message_sources: captain_v2_enabled?)
capture_assistant_session(result_message: message, credits_consumed: 1.0)
end
end
@@ -142,10 +142,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
# Capture runs outside the delivery transaction and never raises (the service
# swallows its own failures): a session-logging bug must never roll back the
# customer reply or trigger the top-level handle_error handoff on top of it.
def capture_assistant_session(result_message:, credits_consumed:, capture_message_sources:)
def capture_assistant_session(result_message:, credits_consumed:)
Captain::Assistant::SessionCaptureService.new(assistant: @assistant, conversation: @conversation, run_result: @run_result,
result_message: result_message, credits_consumed: credits_consumed,
capture_message_sources: capture_message_sources).capture
result_message: result_message, credits_consumed: credits_consumed).capture
end
def handle_error(error)
@@ -34,7 +34,6 @@ class Captain::Document < ApplicationRecord
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
has_many :message_sources, class_name: 'Captain::MessageSource', dependent: :destroy_async
belongs_to :account
has_one_attached :pdf_file
store_accessor :metadata, :content_fingerprint, :last_sync_error_code, :sync_step, :openai_file_id
@@ -1,33 +0,0 @@
# == Schema Information
#
# Table name: captain_message_sources
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
# assistant_response_id :bigint not null
# conversation_id :bigint not null
# document_id :bigint not null
# message_id :bigint not null
#
# Indexes
#
# idx_captain_message_sources_on_message_and_response (message_id,assistant_response_id) UNIQUE
# index_captain_message_sources_on_account_id (account_id)
# index_captain_message_sources_on_assistant_id (assistant_id)
# index_captain_message_sources_on_conversation_id (conversation_id)
# index_captain_message_sources_on_document_id (document_id)
# index_captain_message_sources_on_message_id (message_id)
#
class Captain::MessageSource < ApplicationRecord
self.table_name = 'captain_message_sources'
belongs_to :account
belongs_to :assistant, class_name: 'Captain::Assistant'
belongs_to :conversation, class_name: '::Conversation'
belongs_to :message
belongs_to :document, class_name: 'Captain::Document'
belongs_to :assistant_response, class_name: 'Captain::AssistantResponse', optional: true
end
@@ -16,7 +16,6 @@ module Enterprise::Concerns::Account
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession'
has_many :captain_message_sources, dependent: :destroy_async, class_name: 'Captain::MessageSource'
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
@@ -4,6 +4,5 @@ module Enterprise::Concerns::Message
included do
has_one :call, dependent: :nullify
has_many :message_reports, class_name: 'Captain::MessageReport', dependent: :destroy_async
has_many :captain_message_sources, class_name: 'Captain::MessageSource', dependent: :destroy_async
end
end
@@ -1,13 +1,12 @@
class Captain::Assistant::SessionCaptureService
SCENARIO_AGENT_REGEX = /\A#{Captain::Scenario::HANDOFF_KEY_PREFIX}_(\d+)_/
def initialize(assistant:, conversation:, run_result:, result_message:, **options)
def initialize(assistant:, conversation:, run_result:, result_message:, credits_consumed:)
@assistant = assistant
@conversation = conversation
@run_result = run_result
@result_message = result_message
@credits_consumed = options.fetch(:credits_consumed)
@capture_message_sources = options.fetch(:capture_message_sources, false)
@credits_consumed = credits_consumed
end
def capture
@@ -24,7 +23,7 @@ class Captain::Assistant::SessionCaptureService
def capture!
model = @assistant.agent_model
session = Captain::AgentSession.create!(
Captain::AgentSession.create!(
assistant: @assistant,
session_type: :assistant,
subject: @conversation,
@@ -36,8 +35,6 @@ class Captain::Assistant::SessionCaptureService
scenario_ids: scenario_ids,
run_context: current_turn_history
)
capture_message_sources(metadata) if @capture_message_sources
session
end
private
@@ -73,42 +70,6 @@ class Captain::Assistant::SessionCaptureService
ids & @assistant.scenarios.where(id: ids).pluck(:id)
end
def capture_message_sources(metadata)
sources = Array(metadata[:message_sources])
return if sources.empty?
document_ids = @assistant.documents.where(id: sources.pluck(:document_id)).pluck(:id)
return if document_ids.empty?
insert_message_sources(message_source_rows(sources, document_ids))
end
def message_source_rows(sources, document_ids)
timestamp = Time.current
sources.filter_map do |source|
next unless document_ids.include?(source[:document_id])
{
account_id: @assistant.account_id,
assistant_id: @assistant.id,
conversation_id: @conversation.id,
message_id: @result_message.id,
document_id: source[:document_id],
assistant_response_id: source[:assistant_response_id],
created_at: timestamp,
updated_at: timestamp
}
end
end
def insert_message_sources(rows)
return if rows.empty?
# rubocop:disable Rails/SkipsModelValidations
Captain::MessageSource.insert_all(rows, unique_by: :idx_captain_message_sources_on_message_and_response)
# rubocop:enable Rails/SkipsModelValidations
end
# Trim to the current turn: the last user message and everything after it
# (assistant replies, tool calls/results, handoff hops).
def current_turn_history
@@ -19,6 +19,8 @@ end
json.inbox do
json.id call.inbox_id
json.name call.inbox.name
json.channel_type call.inbox.channel_type
json.medium call.inbox.channel.try(:medium)
end
if call.accepted_by_agent
@@ -11,10 +11,6 @@ json.file_size resource.file_size
json.pdf_document resource.pdf_document?
responses_count = resource.respond_to?(:responses_count) ? resource.responses_count : resource.responses.count
json.responses_count responses_count.to_i
used_in_answers_count = resource.respond_to?(:used_in_answers_count) ? resource.used_in_answers_count : resource.message_sources.distinct.count(:message_id)
json.used_in_answers_count used_in_answers_count.to_i
used_in_conversations_count = resource.respond_to?(:used_in_conversations_count) ? resource.used_in_conversations_count : resource.message_sources.distinct.count(:conversation_id)
json.used_in_conversations_count used_in_conversations_count.to_i
json.id resource.id
json.name resource.name
json.status resource.status
@@ -26,17 +26,8 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
metadata = tool_context.state[:cw_metadata] ||= {}
metadata[:faq_ids] = Array(metadata[:faq_ids]) | responses.map(&:id)
document_ids = document_responses(responses).map(&:documentable_id)
document_ids = responses.filter_map { |response| response.documentable_id if response.documentable_type == 'Captain::Document' }
metadata[:document_ids] = Array(metadata[:document_ids]) | document_ids
metadata[:message_sources] = Array(metadata[:message_sources]) | message_sources(document_responses(responses))
end
def document_responses(responses)
responses.select { |response| response.documentable_type == 'Captain::Document' }
end
def message_sources(responses)
responses.map { |response| { assistant_response_id: response.id, document_id: response.documentable_id } }
end
def format_responses(responses)
@@ -85,6 +85,20 @@ RSpec.describe 'Branded Email Layout API', type: :request do
expect(response.parsed_body['branded_email_layout']).to eq(layout)
end
it 'updates account-scoped branded email layouts up to 100 KiB' do
account.enable_features!(:branded_email_templates)
slot = '{{ content_for_layout }}'
large_layout = "#{'a' * (100.kilobytes - slot.length)}#{slot}"
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: large_layout },
as: :json
expect(response).to have_http_status(:success)
expect(EmailTemplate.account_branded_layout_template_for(account).body.length).to eq(100.kilobytes)
end
it 'clears account-scoped branded email layout when blank value is passed' do
account.enable_features!(:branded_email_templates)
create(:email_template, :layout, account: account, body: layout)
@@ -636,6 +636,22 @@ RSpec.describe 'Inboxes API', type: :request do
expect(response.parsed_body['branded_email_layout']).to eq(layout)
end
it 'rejects branded email layouts larger than 256 KiB' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
slot = '{{ content_for_layout }}'
large_layout = "#{'a' * (EmailTemplate::MAX_BODY_LENGTH - slot.length + 1)}#{slot}"
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: large_layout },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('is too long (maximum is 262144 characters)')
end
it 'rolls back branded email layout when inbox update fails' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
@@ -32,6 +32,7 @@ RSpec.describe 'Calls API', type: :request do
item = body['payload'].find { |c| c['id'] == agent_call.id }
expect(item['transcript']).to eq('hello world')
expect(item['contact']['phone_number']).to eq(contact.phone_number)
expect(item['inbox']).to include('id' => inbox.id, 'name' => inbox.name, 'channel_type' => inbox.channel_type)
end
it 'scopes the list to calls the agent accepted' do
+17
View File
@@ -68,6 +68,23 @@ RSpec.describe EmailTemplate do
expect(template.errors[:body]).to include('must include {{ content_for_layout }}')
end
it 'allows email templates up to 262,144 characters' do
slot = '{{ content_for_layout }}'
body = "#{'a' * (described_class::MAX_BODY_LENGTH - slot.length)}#{slot}"
template = build(:email_template, :layout, account: create(:account), body: body)
expect(template).to be_valid
end
it 'rejects email templates larger than 262,144 characters' do
slot = '{{ content_for_layout }}'
body = "#{'a' * (described_class::MAX_BODY_LENGTH - slot.length + 1)}#{slot}"
template = build(:email_template, :layout, account: create(:account), body: body)
expect(template).not_to be_valid
expect(template.errors[:body]).to include('is too long (maximum is 262144 characters)')
end
it 'validates liquid syntax' do
template = build(:email_template, body: '{{ broken ')
@@ -4,5 +4,6 @@ properties:
type:
- string
- 'null'
description: Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override.
maxLength: 262144
description: Account-scoped Liquid HTML layout for branded email replies. Maximum length is 262,144 characters. Blank or null removes the account override.
example: '<html><body>{{ content_for_layout }}</body></html>'
@@ -113,8 +113,9 @@ properties:
type:
- string
- 'null'
maxLength: 262144
description: |
Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`.
Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}` and be 262,144 characters or shorter.
Available for: `Email`
example: '<html><body>{{ content_for_layout }}</body></html>'
+4 -2
View File
@@ -12804,7 +12804,8 @@
"string",
"null"
],
"description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`.\n\nAvailable for: `Email`\n",
"maxLength": 262144,
"description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}` and be 262,144 characters or shorter.\n\nAvailable for: `Email`\n",
"example": "<html><body>{{ content_for_layout }}</body></html>"
},
"channel": {
@@ -12842,7 +12843,8 @@
"string",
"null"
],
"description": "Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override.",
"maxLength": 262144,
"description": "Account-scoped Liquid HTML layout for branded email replies. Maximum length is 262,144 characters. Blank or null removes the account override.",
"example": "<html><body>{{ content_for_layout }}</body></html>"
}
}
+4 -2
View File
@@ -11311,7 +11311,8 @@
"string",
"null"
],
"description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`.\n\nAvailable for: `Email`\n",
"maxLength": 262144,
"description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}` and be 262,144 characters or shorter.\n\nAvailable for: `Email`\n",
"example": "<html><body>{{ content_for_layout }}</body></html>"
},
"channel": {
@@ -11349,7 +11350,8 @@
"string",
"null"
],
"description": "Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override.",
"maxLength": 262144,
"description": "Account-scoped Liquid HTML layout for branded email replies. Maximum length is 262,144 characters. Blank or null removes the account override.",
"example": "<html><body>{{ content_for_layout }}</body></html>"
}
}
+4 -2
View File
@@ -4119,7 +4119,8 @@
"string",
"null"
],
"description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`.\n\nAvailable for: `Email`\n",
"maxLength": 262144,
"description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}` and be 262,144 characters or shorter.\n\nAvailable for: `Email`\n",
"example": "<html><body>{{ content_for_layout }}</body></html>"
},
"channel": {
@@ -4157,7 +4158,8 @@
"string",
"null"
],
"description": "Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override.",
"maxLength": 262144,
"description": "Account-scoped Liquid HTML layout for branded email replies. Maximum length is 262,144 characters. Blank or null removes the account override.",
"example": "<html><body>{{ content_for_layout }}</body></html>"
}
}
+4 -2
View File
@@ -3534,7 +3534,8 @@
"string",
"null"
],
"description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`.\n\nAvailable for: `Email`\n",
"maxLength": 262144,
"description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}` and be 262,144 characters or shorter.\n\nAvailable for: `Email`\n",
"example": "<html><body>{{ content_for_layout }}</body></html>"
},
"channel": {
@@ -3572,7 +3573,8 @@
"string",
"null"
],
"description": "Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override.",
"maxLength": 262144,
"description": "Account-scoped Liquid HTML layout for branded email replies. Maximum length is 262,144 characters. Blank or null removes the account override.",
"example": "<html><body>{{ content_for_layout }}</body></html>"
}
}
+4 -2
View File
@@ -4295,7 +4295,8 @@
"string",
"null"
],
"description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}`.\n\nAvailable for: `Email`\n",
"maxLength": 262144,
"description": "Liquid HTML layout for outbound email replies. Must include `{{ content_for_layout }}` and be 262,144 characters or shorter.\n\nAvailable for: `Email`\n",
"example": "<html><body>{{ content_for_layout }}</body></html>"
},
"channel": {
@@ -4333,7 +4334,8 @@
"string",
"null"
],
"description": "Account-scoped Liquid HTML layout for branded email replies. Blank or null removes the account override.",
"maxLength": 262144,
"description": "Account-scoped Liquid HTML layout for branded email replies. Maximum length is 262,144 characters. Blank or null removes the account override.",
"example": "<html><body>{{ content_for_layout }}</body></html>"
}
}
+20
View File
@@ -269,6 +269,16 @@ export const icons = {
width: 24,
height: 24,
},
'voice-call': {
body: `<mask id="cvc" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4" r="4" fill="#000"/></mask><g mask="url(#cvc)"><path d="M7.916 10.784a.5.5 0 0 0 .607-.152L8.7 10.4a1 1 0 0 1 .8-.4H11a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1 9 9 0 0 1-9-9 1 1 0 0 1 1-1h1.5a1 1 0 0 1 1 1V6a1 1 0 0 1-.4.8l-.234.176a.5.5 0 0 0-.146.616 7 7 0 0 0 3.196 3.192" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g><circle cx="12" cy="4" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.2v1.6M12 2.4v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
width: 16,
height: 16,
},
'whatsapp-voice': {
body: `<mask id="cwv" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4.5" r="4" fill="#000"/></mask><g mask="url(#cwv)"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.349 3.655A5.6 5.6 0 0 0 8.357 2a5.65 5.65 0 0 0-5.643 5.642c0 .995.26 1.966.753 2.821l-.512 1.873a.63.63 0 0 0 .767.775l1.936-.508a5.64 5.64 0 0 0 2.697.687h.002A5.65 5.65 0 0 0 14 7.647a5.6 5.6 0 0 0-1.652-3.992m-3.992 8.682h-.002a4.7 4.7 0 0 1-2.387-.654l-.171-.102-1.776.466.474-1.73-.111-.178a4.7 4.7 0 0 1-.717-2.496 4.697 4.697 0 0 1 4.692-4.69c1.253 0 2.43.489 3.316 1.376a4.66 4.66 0 0 1 1.372 3.317 4.697 4.697 0 0 1-4.69 4.69m2.573-3.513c-.141-.07-.835-.411-.964-.458-.13-.047-.223-.07-.317.07a8 8 0 0 1-.446.553c-.083.094-.165.106-.306.035s-.595-.22-1.134-.7a4.3 4.3 0 0 1-.784-.976c-.082-.141-.009-.218.061-.288.064-.063.141-.165.212-.247.07-.082.094-.141.141-.235s.024-.176-.012-.247c-.035-.07-.317-.765-.434-1.047-.115-.275-.231-.237-.318-.242a6 6 0 0 0-.27-.005.52.52 0 0 0-.376.177c-.13.14-.493.482-.493 1.176 0 .693.505 1.364.575 1.458.071.094.995 1.518 2.409 2.13.336.145.599.232.804.297.337.107.645.092.888.056.27-.041.834-.342.951-.671.118-.33.118-.612.083-.67-.035-.06-.13-.095-.27-.166" fill="currentColor"/></g><circle cx="12" cy="4.5" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.7v1.6M12 2.9v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
width: 16,
height: 16,
},
instagram: {
body: `<g fill="none" stroke="currentColor"><path d="M12.0003 15.3329C13.8412 15.3329 15.3337 13.8405 15.3337 11.9996C15.3337 10.1586 13.8412 8.66626 12.0003 8.66626C10.1594 8.66626 8.66699 10.1586 8.66699 11.9996C8.66699 13.8405 10.1594 15.3329 12.0003 15.3329Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 15.3333V8.66667C4.5 6.36548 6.36548 4.5 8.66667 4.5H15.3333C17.6345 4.5 19.5 6.36548 19.5 8.66667V15.3333C19.5 17.6345 17.6345 19.5 15.3333 19.5H8.66667C6.36548 19.5 4.5 17.6345 4.5 15.3333Z" stroke-width="1.5"/><path d="M16.583 7.42552L16.5913 7.41626" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></g>`,
width: 24,
@@ -470,5 +480,15 @@ export const icons = {
width: 24,
height: 24,
},
'audio-play': {
body: `<path d="M3.31445 11.3998V2.6002C3.31445 2.35931 3.39947 2.15725 3.56951 1.99401C3.73955 1.83077 3.93793 1.74944 4.16465 1.75C4.2355 1.75 4.31004 1.76049 4.38826 1.78146C4.46647 1.80243 4.54073 1.83446 4.61101 1.87753L11.5401 6.27732C11.6677 6.36234 11.7635 6.46862 11.8275 6.59615C11.8916 6.72368 11.9233 6.85829 11.9227 6.99999C11.9222 7.14169 11.8904 7.27631 11.8275 7.40384C11.7646 7.53137 11.6688 7.63764 11.5401 7.72266L4.61101 12.1224C4.54016 12.165 4.46591 12.197 4.38826 12.2185C4.3106 12.2401 4.23607 12.2505 4.16465 12.25C3.93793 12.25 3.73955 12.1684 3.56951 12.0051C3.39947 11.8419 3.31445 11.6401 3.31445 11.3998Z" fill="currentColor"/>`,
width: 14,
height: 14,
},
'audio-pause': {
body: `<path d="M10.5001 1.75H8.75008C8.42792 1.75 8.16675 2.01117 8.16675 2.33333V11.6667C8.16675 11.9888 8.42792 12.25 8.75008 12.25H10.5001C10.8222 12.25 11.0834 11.9888 11.0834 11.6667V2.33333C11.0834 2.01117 10.8222 1.75 10.5001 1.75Z" fill="currentColor"/><path d="M5.25008 1.75H3.50008C3.17792 1.75 2.91675 2.01117 2.91675 2.33333V11.6667C2.91675 11.9888 3.17792 12.25 3.50008 12.25H5.25008C5.57225 12.25 5.83342 11.9888 5.83342 11.6667V2.33333C5.83342 2.01117 5.57225 1.75 5.25008 1.75Z" fill="currentColor"/>`,
width: 14,
height: 14,
},
/** Ends */
};