Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b85a8de43e | ||
|
|
a98666030b | ||
|
|
bae20ca83e |
+6
-1
@@ -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>
|
||||
|
||||
|
||||
+2
-2
@@ -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(() => {
|
||||
|
||||
+3
-1
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+14
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
+9
-7
@@ -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)
|
||||
|
||||
+17
-7
@@ -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(() => ({
|
||||
|
||||
+2
-1
@@ -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'];
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user