Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a1dd481e9 | ||
|
|
a98666030b | ||
|
|
bae20ca83e | ||
|
|
954e5844a8 | ||
|
|
0efab5fb43 | ||
|
|
34ad78b122 |
+1
-1
@@ -1 +1 @@
|
||||
4.16.0
|
||||
4.16.1
|
||||
|
||||
+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>
|
||||
|
||||
@@ -33,9 +33,7 @@ import {
|
||||
// constants
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants, {
|
||||
META_RESTRICTION_STATUS_URL,
|
||||
} from 'dashboard/constants/globals';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
@@ -95,7 +93,6 @@ export default {
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
isOpen() {
|
||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
@@ -173,13 +170,6 @@ export default {
|
||||
instagramInbox
|
||||
);
|
||||
},
|
||||
isInstagramRestrictionBannerVisible() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
instagramRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
|
||||
replyWindowBannerMessage() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
||||
@@ -464,15 +454,7 @@ export default {
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<Banner
|
||||
v-if="isInstagramRestrictionBannerVisible"
|
||||
color-scheme="warning"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
|
||||
:href-link="instagramRestrictionStatusUrl"
|
||||
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="!currentChat.can_reply"
|
||||
v-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -78,5 +78,3 @@ export default {
|
||||
},
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
export const META_RESTRICTION_STATUS_URL =
|
||||
'https://status.chatwoot.com/incident/948346';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,8 +44,6 @@
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
@@ -195,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",
|
||||
|
||||
@@ -58,9 +58,7 @@
|
||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
|
||||
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"STATUS_LINK": "View status update"
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||
},
|
||||
"TIKTOK": {
|
||||
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ export default {
|
||||
<label :class="{ error: v$.selectedAgentIds.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
|
||||
<div
|
||||
data-testid="agent-selector"
|
||||
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
|
||||
>
|
||||
<TagInput
|
||||
|
||||
@@ -4,8 +4,6 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
@@ -46,11 +44,9 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
|
||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner,
|
||||
BotConfiguration,
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
@@ -84,7 +80,6 @@ export default {
|
||||
WhatsappManualMigrationBanner,
|
||||
Widget,
|
||||
AccessToken,
|
||||
Icon,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@@ -286,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'];
|
||||
@@ -348,12 +347,6 @@ export default {
|
||||
instagramUnauthorized() {
|
||||
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
showInstagramRestrictionSettingsBanner() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
metaRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
tiktokUnauthorized() {
|
||||
return this.isATiktokChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
@@ -820,29 +813,6 @@ export default {
|
||||
:class="bannerMaxWidth"
|
||||
@start="openWhatsAppManualMigrationDialog"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showInstagramRestrictionSettingsBanner"
|
||||
color="amber"
|
||||
class="mx-6 mb-4 max-w-4xl"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-start">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
|
||||
<a
|
||||
:href="metaRestrictionStatusUrl"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div
|
||||
v-if="selectedTabKey === 'inbox-settings'"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.16.0'
|
||||
version: '4.16.1'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,19 +7,16 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
|
||||
|
||||
def perform(conversation, assistant, trigger_message_id = nil)
|
||||
def perform(conversation, assistant)
|
||||
@conversation = conversation
|
||||
@inbox = conversation.inbox
|
||||
@assistant = assistant
|
||||
@trigger_message_id = trigger_message_id if captain_v2_enabled?
|
||||
|
||||
return unless conversation_pending?
|
||||
|
||||
Current.executed_by = @assistant
|
||||
|
||||
if captain_v2_enabled?
|
||||
return unless trigger_message_current?
|
||||
|
||||
generate_response_with_v2
|
||||
else
|
||||
generate_and_process_response
|
||||
@@ -48,18 +45,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def generate_response_with_v2
|
||||
runner_service = Captain::Assistant::AgentRunnerService.new(
|
||||
assistant: @assistant,
|
||||
conversation: @conversation,
|
||||
trigger_message_id: @trigger_message_id
|
||||
)
|
||||
runner_service = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation)
|
||||
message_history = Captain::Conversation::MessageHistoryBuilderService.new(conversation: @conversation).perform
|
||||
@response = runner_service.generate_response(message_history: message_history)
|
||||
@run_result = runner_service.last_run_result
|
||||
|
||||
return if runner_service.response_discarded?
|
||||
return unless trigger_message_current?
|
||||
|
||||
process_response
|
||||
end
|
||||
|
||||
@@ -87,24 +77,16 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
process_v1_handoff
|
||||
elsif conversation_pending?
|
||||
process_standard_response
|
||||
message = nil
|
||||
ActiveRecord::Base.transaction do
|
||||
message = create_messages
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
def process_standard_response
|
||||
message = nil
|
||||
ActiveRecord::Base.transaction do
|
||||
next unless trigger_message_current?
|
||||
|
||||
message = create_messages
|
||||
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
|
||||
account.increment_response_usage
|
||||
end
|
||||
return unless message
|
||||
|
||||
capture_assistant_session(result_message: message, credits_consumed: 1.0)
|
||||
end
|
||||
|
||||
def v1_handoff_requested?
|
||||
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
|
||||
end
|
||||
@@ -135,8 +117,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def process_v2_handoff
|
||||
return unless trigger_message_current?
|
||||
|
||||
# HandoffTool already ran bot_handoff! + OOO inside the agent loop. Preserve
|
||||
# waiting_since so this message doesn't clear the timestamp it left in place.
|
||||
I18n.with_locale(@assistant.account.locale) do
|
||||
@@ -172,7 +152,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
@response ||= {}
|
||||
@response['action_source'] ||= 'error'
|
||||
@response['action_reason'] ||= error_action_reason(error)
|
||||
process_v1_handoff if conversation_pending? && (!captain_v2_enabled? || trigger_message_current?)
|
||||
process_v1_handoff if conversation_pending?
|
||||
true
|
||||
end
|
||||
|
||||
@@ -201,14 +181,4 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
status = Conversation.uncached { Conversation.where(id: @conversation.id).pick(:status) }
|
||||
status == 'pending' || status == Conversation.statuses[:pending]
|
||||
end
|
||||
|
||||
def trigger_message_current?
|
||||
return true if @trigger_message_id.blank?
|
||||
|
||||
latest_incoming_message_id = Conversation.uncached do
|
||||
@conversation.messages.incoming.maximum(:id)
|
||||
end
|
||||
|
||||
latest_incoming_message_id == @trigger_message_id
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,12 +9,11 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
attr_reader :last_run_result
|
||||
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil, trigger_message_id: nil)
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@callbacks = callbacks
|
||||
@source = source
|
||||
@trigger_message_id = trigger_message_id
|
||||
@handoff_tool_called = false
|
||||
end
|
||||
|
||||
@@ -32,10 +31,6 @@ class Captain::Assistant::AgentRunnerService
|
||||
error_response(e.message)
|
||||
end
|
||||
|
||||
def response_discarded?
|
||||
@response_discarded == true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_context(message_history)
|
||||
@@ -156,9 +151,10 @@ class Captain::Assistant::AgentRunnerService
|
||||
track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
|
||||
end
|
||||
|
||||
runner.on_run_complete do |_agent_name, _result, context_wrapper|
|
||||
@response_discarded = trigger_message_stale?
|
||||
write_run_metadata(context_wrapper) if ChatwootApp.otel_enabled?
|
||||
if ChatwootApp.otel_enabled?
|
||||
runner.on_run_complete do |_agent_name, _result, context_wrapper|
|
||||
write_credits_used_metadata(context_wrapper)
|
||||
end
|
||||
end
|
||||
runner
|
||||
end
|
||||
@@ -173,22 +169,11 @@ class Captain::Assistant::AgentRunnerService
|
||||
@handoff_tool_called = true
|
||||
end
|
||||
|
||||
def write_run_metadata(context_wrapper)
|
||||
def write_credits_used_metadata(context_wrapper)
|
||||
root_span = context_wrapper&.context&.dig(:__otel_tracing, :root_span)
|
||||
return unless root_span
|
||||
|
||||
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'discarded'), response_discarded?.to_s)
|
||||
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), (!@handoff_tool_called && !response_discarded?).to_s)
|
||||
end
|
||||
|
||||
def trigger_message_stale?
|
||||
return false if @trigger_message_id.blank? || @conversation.blank?
|
||||
|
||||
latest_incoming_message_id = Conversation.uncached do
|
||||
@conversation.messages.incoming.maximum(:id)
|
||||
end
|
||||
|
||||
latest_incoming_message_id != @trigger_message_id
|
||||
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), @handoff_tool_called ? 'false' : 'true')
|
||||
end
|
||||
|
||||
def runner
|
||||
|
||||
@@ -13,8 +13,7 @@ class Captain::Assistant::InstrumentationAttributeProvider
|
||||
|
||||
def generation_attributes(_context_wrapper, _chat, message)
|
||||
{
|
||||
format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'generation_stage') => generation_stage(message),
|
||||
format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'discarded') => @service.send(:trigger_message_stale?).to_s
|
||||
format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'generation_stage') => generation_stage(message)
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ module Captain::Assistant::RunnerStateHelper
|
||||
timezone: @conversation&.inbox&.timezone.presence || 'UTC'
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
state[:trigger_message_id] = @trigger_message_id if @trigger_message_id.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
|
||||
@@ -31,7 +31,6 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
|
||||
def schedule_captain_response
|
||||
job_args = [conversation, conversation.inbox.captain_assistant]
|
||||
job_args << message.id if conversation.account.feature_enabled?('captain_integration_v2')
|
||||
|
||||
if message.attachments.blank?
|
||||
Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,6 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
def perform(tool_context, reason: nil)
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
return 'Handoff skipped because a newer customer message arrived' unless trigger_message_current?(tool_context.state, conversation)
|
||||
|
||||
# Log the handoff with reason
|
||||
log_tool_usage('tool_handoff', {
|
||||
@@ -24,17 +23,6 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
|
||||
private
|
||||
|
||||
def trigger_message_current?(state, conversation)
|
||||
trigger_message_id = state&.dig(:trigger_message_id)
|
||||
return true if trigger_message_id.blank?
|
||||
|
||||
latest_incoming_message_id = Conversation.uncached do
|
||||
conversation.messages.incoming.maximum(:id)
|
||||
end
|
||||
|
||||
latest_incoming_message_id == trigger_message_id
|
||||
end
|
||||
|
||||
def trigger_handoff(tool_context, conversation, reason)
|
||||
# post the reason as a private note
|
||||
note = conversation.messages.create!(
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.16.0",
|
||||
"version": "4.16.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,6 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) }
|
||||
let(:mock_false_promise_service) { instance_double(Captain::Llm::AssistantFalsePromiseService) }
|
||||
let(:assistant_model) { Llm::Models.default_model_for('assistant') }
|
||||
let(:trigger_message) { conversation.messages.find_by!(content: 'Hello') }
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
|
||||
@@ -24,7 +23,6 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service)
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
|
||||
allow(mock_agent_runner_service).to receive(:last_run_result).and_return(nil)
|
||||
allow(mock_agent_runner_service).to receive(:response_discarded?).and_return(false)
|
||||
allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service)
|
||||
allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' })
|
||||
allow(Captain::Llm::AssistantFalsePromiseService).to receive(:new).and_return(mock_false_promise_service)
|
||||
@@ -360,28 +358,14 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
it 'uses Captain::Assistant::AgentRunnerService' do
|
||||
expect(Captain::Assistant::AgentRunnerService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
conversation: conversation,
|
||||
trigger_message_id: trigger_message.id
|
||||
conversation: conversation
|
||||
)
|
||||
expect(Captain::Llm::AssistantChatService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(conversation, assistant, trigger_message.id)
|
||||
described_class.perform_now(conversation, assistant)
|
||||
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain V2')
|
||||
end
|
||||
|
||||
it 'discards a response when a newer message arrives during generation' do
|
||||
allow(mock_agent_runner_service).to receive(:generate_response) do
|
||||
create(:message, conversation: conversation, content: 'New context', message_type: :incoming)
|
||||
{ 'response' => 'Stale response', 'handoff_tool_called' => false }
|
||||
end
|
||||
allow(mock_agent_runner_service).to receive(:response_discarded?).and_return(true)
|
||||
|
||||
described_class.perform_now(conversation, assistant, trigger_message.id)
|
||||
|
||||
expect(conversation.messages.outgoing.count).to eq(0)
|
||||
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
|
||||
end
|
||||
|
||||
it 'passes message history with resolution markers to agent runner service' do
|
||||
same_second = Time.current.change(usec: 0)
|
||||
conversation.messages.find_by!(content: 'Hello').update!(created_at: same_second, updated_at: same_second)
|
||||
|
||||
@@ -28,37 +28,6 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation exists' do
|
||||
context 'with a trigger message id' do
|
||||
let(:trigger_message) do
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox, message_type: :incoming, created_at: same_second)
|
||||
end
|
||||
let(:same_second) { Time.current.change(usec: 0) }
|
||||
let(:tool_context) do
|
||||
Struct.new(:state).new({ conversation: { id: conversation.id }, trigger_message_id: trigger_message.id })
|
||||
end
|
||||
|
||||
it 'hands off when the trigger message is still the latest message' do
|
||||
trigger_message
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, reason: 'Customer needs specialized support')
|
||||
expect(result).to include('Conversation handed off')
|
||||
end.to change(Message, :count).by(1)
|
||||
end
|
||||
|
||||
it 'skips the handoff when a newer message has arrived' do
|
||||
trigger_message
|
||||
conversation.update!(status: :pending)
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox, message_type: :incoming, created_at: same_second)
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, reason: 'Customer needs specialized support')
|
||||
expect(result).to eq('Handoff skipped because a newer customer message arrived')
|
||||
end.not_to change(Message, :count)
|
||||
expect(conversation.reload.status).to eq('pending')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with reason provided' do
|
||||
it 'creates a private note with reason and hands off conversation' do
|
||||
reason = 'Customer needs specialized support'
|
||||
|
||||
@@ -52,12 +52,6 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
|
||||
expect(service.instance_variable_get(:@callbacks)).to eq(callbacks)
|
||||
end
|
||||
|
||||
it 'accepts the trigger message id' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: 123)
|
||||
|
||||
expect(service.instance_variable_get(:@trigger_message_id)).to eq(123)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_response' do
|
||||
@@ -105,18 +99,6 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
it 'adds the trigger message id to the runner state' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: 123)
|
||||
|
||||
expect(mock_runner).to receive(:run).with(
|
||||
'I need help with my account',
|
||||
context: hash_including(state: hash_including(trigger_message_id: 123)),
|
||||
max_turns: 10
|
||||
)
|
||||
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
context 'when the latest user message is multimodal' do
|
||||
let(:multimodal_message_history) do
|
||||
[
|
||||
@@ -464,7 +446,6 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
attributes = provider.generation_attributes(nil, nil, message)
|
||||
|
||||
expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('final_response')
|
||||
expect(attributes['langfuse.observation.metadata.discarded']).to eq('false')
|
||||
end
|
||||
|
||||
it 'marks tool call generations separately from final responses' do
|
||||
@@ -475,18 +456,6 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
|
||||
expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('tool_call')
|
||||
end
|
||||
|
||||
it 'marks a generation as discarded when a newer message has arrived' do
|
||||
trigger_message = create(:message, conversation: conversation, message_type: :incoming)
|
||||
runner_service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: trigger_message.id)
|
||||
attribute_provider = Captain::Assistant::InstrumentationAttributeProvider.new(runner_service)
|
||||
message = instance_double(RubyLLM::Message, tool_calls: {})
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
|
||||
attributes = attribute_provider.generation_attributes(nil, nil, message)
|
||||
|
||||
expect(attributes['langfuse.observation.metadata.discarded']).to eq('true')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_state' do
|
||||
@@ -605,7 +574,6 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
|
||||
tool_complete_callback.call(Captain::Tools::HandoffTool.new(assistant).name, 'ok', context_wrapper)
|
||||
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.discarded', 'false')
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'false')
|
||||
run_complete_callback.call('assistant', nil, context_wrapper)
|
||||
end
|
||||
@@ -620,7 +588,6 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
tool_complete_callback = block
|
||||
runner
|
||||
end
|
||||
allow(runner).to receive(:on_run_complete).and_return(runner)
|
||||
|
||||
service.send(:add_usage_metadata_callback, runner)
|
||||
|
||||
@@ -632,24 +599,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
expect(context_wrapper.context[:captain_v2_handoff_tool_called]).to be true
|
||||
end
|
||||
|
||||
it 'tracks discarded responses when OTEL is disabled' do
|
||||
trigger_message = create(:message, conversation: conversation, message_type: :incoming)
|
||||
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: trigger_message.id)
|
||||
it 'does not register OTEL run callback when OTEL is disabled' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
runner = instance_double(Agents::AgentRunner)
|
||||
run_complete_callback = nil
|
||||
|
||||
allow(ChatwootApp).to receive(:otel_enabled?).and_return(false)
|
||||
allow(runner).to receive(:on_tool_complete).and_return(runner)
|
||||
allow(runner).to receive(:on_run_complete) do |&block|
|
||||
run_complete_callback = block
|
||||
runner
|
||||
end
|
||||
expect(runner).not_to receive(:on_run_complete)
|
||||
|
||||
service.send(:add_usage_metadata_callback, runner)
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
run_complete_callback.call('assistant', nil, Struct.new(:context).new({}))
|
||||
|
||||
expect(service.response_discarded?).to be true
|
||||
end
|
||||
|
||||
it 'sets credit_used=true when handoff tool is not used' do
|
||||
@@ -671,38 +629,9 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
|
||||
service.send(:add_usage_metadata_callback, runner)
|
||||
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.discarded', 'false')
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'true')
|
||||
run_complete_callback.call('assistant', nil, context_wrapper)
|
||||
end
|
||||
|
||||
it 'marks the trace discarded and does not use credit when a newer message arrived' do
|
||||
trigger_message = create(:message, conversation: conversation, message_type: :incoming)
|
||||
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: trigger_message.id)
|
||||
runner = instance_double(Agents::AgentRunner)
|
||||
run_complete_callback = nil
|
||||
span_class = Class.new do
|
||||
def set_attribute(*); end
|
||||
end
|
||||
root_span = instance_double(span_class)
|
||||
context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } })
|
||||
|
||||
allow(ChatwootApp).to receive(:otel_enabled?).and_return(true)
|
||||
allow(runner).to receive(:on_tool_complete).and_return(runner)
|
||||
allow(runner).to receive(:on_run_complete) do |&block|
|
||||
run_complete_callback = block
|
||||
runner
|
||||
end
|
||||
|
||||
service.send(:add_usage_metadata_callback, runner)
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.discarded', 'true')
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'false')
|
||||
run_complete_callback.call('assistant', nil, context_wrapper)
|
||||
|
||||
expect(service.response_discarded?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'constants' do
|
||||
|
||||
+2
-6
@@ -22,13 +22,9 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
end
|
||||
|
||||
it 'schedules captain response job for incoming messages on pending conversations' do
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||
allow(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later)
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
|
||||
|
||||
message = create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to have_received(:perform_later).with(conversation, assistant, message.id)
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class AddAgentModal {
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
getModalTitle() {
|
||||
return this.page.locator('[data-test-id="modal-header-title"]');
|
||||
}
|
||||
|
||||
getAgentNameInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Agent Name' });
|
||||
}
|
||||
|
||||
getEmailInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Email Address' });
|
||||
}
|
||||
|
||||
getRoleCombobox() {
|
||||
return this.page.getByRole('combobox', { name: 'Role' });
|
||||
}
|
||||
|
||||
getSubmitButton() {
|
||||
return this.page.locator('form').getByRole('button', { name: 'Add Agent' });
|
||||
}
|
||||
|
||||
getCancelButton() {
|
||||
return this.page.getByRole('button', { name: 'Cancel' });
|
||||
}
|
||||
|
||||
getSuccessMessage() {
|
||||
return this.page.getByText('Agent added successfully');
|
||||
}
|
||||
|
||||
async fillAgentName(name: string) {
|
||||
await this.getAgentNameInput().fill(name);
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async fillEmail(email: string) {
|
||||
await this.getEmailInput().fill(email);
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
await this.getSubmitButton().click();
|
||||
}
|
||||
|
||||
async cancelForm() {
|
||||
await this.getCancelButton().click();
|
||||
}
|
||||
|
||||
async createAgent(name: string, email: string) {
|
||||
await this.fillAgentName(name);
|
||||
await this.fillEmail(email);
|
||||
await this.submitForm();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class AddAgentsForm {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page
|
||||
.locator('form')
|
||||
.getByRole('heading', { name: 'Agents', level: 2, exact: true });
|
||||
}
|
||||
|
||||
getAgentDropdown() {
|
||||
return this.page.getByPlaceholder('Pick agents for the inbox');
|
||||
}
|
||||
|
||||
getAgentSelector() {
|
||||
return this.page.getByTestId('agent-selector');
|
||||
}
|
||||
|
||||
getAgentOption(agentName: string) {
|
||||
return this.getAgentSelector().getByRole('button', {
|
||||
name: agentName,
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
|
||||
getDropdownButtons() {
|
||||
return this.getAgentSelector().getByRole('button');
|
||||
}
|
||||
|
||||
getSubmitButton() {
|
||||
return this.page.getByRole('button', { name: 'Add agents' });
|
||||
}
|
||||
|
||||
async openAgentDropdown() {
|
||||
await this.getAgentDropdown().click();
|
||||
}
|
||||
|
||||
async selectAgent(agentName: string) {
|
||||
await this.openAgentDropdown();
|
||||
await this.getAgentOption(agentName).waitFor({ state: 'visible' });
|
||||
await this.getAgentOption(agentName).click();
|
||||
}
|
||||
|
||||
async selectAgentByIndex(index: number = 0) {
|
||||
await this.openAgentDropdown();
|
||||
const buttons = this.getDropdownButtons();
|
||||
await buttons.first().waitFor({ state: 'visible' });
|
||||
await buttons.nth(index).click();
|
||||
}
|
||||
|
||||
async closeDropdown() {
|
||||
await this.page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
await this.getSubmitButton().click();
|
||||
}
|
||||
|
||||
async addAgents(agentNames: string[]) {
|
||||
for (const agentName of agentNames) {
|
||||
await this.selectAgent(agentName);
|
||||
}
|
||||
await this.closeDropdown();
|
||||
await this.submitForm();
|
||||
}
|
||||
|
||||
async addFirstAgent() {
|
||||
await this.selectAgentByIndex(0);
|
||||
await this.closeDropdown();
|
||||
await this.submitForm();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class AgentPage {
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async navigate(accountId: number = 1) {
|
||||
await this.page.goto(`/app/accounts/${accountId}/settings/agents/list`);
|
||||
}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', { name: 'Agents', level: 1 });
|
||||
}
|
||||
|
||||
getDescriptionText() {
|
||||
return this.page.getByText(
|
||||
'An agent is a member of your customer support team who can view and respond to user messages.'
|
||||
);
|
||||
}
|
||||
|
||||
getLearnLink() {
|
||||
return this.page.getByRole('link', { name: 'Learn about user roles' });
|
||||
}
|
||||
|
||||
getAddAgentButton() {
|
||||
return this.page.getByRole('button', { name: 'Add Agent' });
|
||||
}
|
||||
|
||||
async openAddAgentModal() {
|
||||
await this.getAddAgentButton().click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class ApiChannelForm {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getChannelNameInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Channel Name' });
|
||||
}
|
||||
|
||||
getWebhookUrlInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Webhook URL' });
|
||||
}
|
||||
|
||||
getSubmitButton() {
|
||||
return this.page.getByRole('button', { name: 'Create API Channel' });
|
||||
}
|
||||
|
||||
async fillChannelName(name: string) {
|
||||
await this.getChannelNameInput().fill(name);
|
||||
}
|
||||
|
||||
async fillWebhookUrl(url: string) {
|
||||
await this.getWebhookUrlInput().fill(url);
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
await this.getSubmitButton().click();
|
||||
}
|
||||
|
||||
async createApiChannel(channelName: string, webhookUrl?: string) {
|
||||
await this.fillChannelName(channelName);
|
||||
if (webhookUrl) {
|
||||
await this.fillWebhookUrl(webhookUrl);
|
||||
}
|
||||
await this.submitForm();
|
||||
}
|
||||
|
||||
getValidationError() {
|
||||
return this.page.locator('.message, .error-message').first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class ChannelSelector {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', { name: /choose channel/i });
|
||||
}
|
||||
|
||||
getApiChannelCard() {
|
||||
return this.page.getByRole('button', { name: /API.*Make a custom channel/i });
|
||||
}
|
||||
|
||||
getWebsiteChannelCard() {
|
||||
return this.page.getByRole('button', { name: /Website.*Create a live-chat widget/i });
|
||||
}
|
||||
|
||||
async selectApiChannel() {
|
||||
await this.getApiChannelCard().click();
|
||||
}
|
||||
|
||||
async selectWebsiteChannel() {
|
||||
await this.getWebsiteChannelCard().click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class FinishSetup {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', {
|
||||
name: 'Your Inbox is ready!',
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
|
||||
getGoToInboxButton() {
|
||||
return this.page.getByRole('button', { name: /go to inbox|view inbox/i });
|
||||
}
|
||||
|
||||
getMoreSettingsButton() {
|
||||
return this.page.getByRole('button', { name: /more settings|settings/i });
|
||||
}
|
||||
|
||||
getWebhookUrl() {
|
||||
return this.page.locator('code, pre').filter({ hasText: /http/i }).first();
|
||||
}
|
||||
|
||||
async goToInbox() {
|
||||
await this.getGoToInboxButton().click();
|
||||
}
|
||||
|
||||
async goToSettings() {
|
||||
await this.getMoreSettingsButton().click();
|
||||
}
|
||||
}
|
||||
@@ -1 +1,8 @@
|
||||
export { Login } from './login.component';
|
||||
export { AgentPage } from './agent-page.component';
|
||||
export { AddAgentModal } from './add-agent-modal.component';
|
||||
export { AddAgentsForm } from './add-agents-form.component';
|
||||
export { SettingsInboxPage } from './settings-inbox-page.component';
|
||||
export { ChannelSelector } from './channel-selector.component';
|
||||
export { ApiChannelForm } from './api-channel-form.component';
|
||||
export { FinishSetup } from './finish-setup.component';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
type DashboardApi = {
|
||||
delete: (url: string) => Promise<unknown>;
|
||||
get: (url: string) => Promise<{
|
||||
data: {
|
||||
payload: Array<{ id: number }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export class SettingsInboxPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async navigate(accountId: number = 1) {
|
||||
await this.page.goto(`/app/accounts/${accountId}/settings/inboxes/list`);
|
||||
}
|
||||
|
||||
getAddInboxButton() {
|
||||
return this.page.getByRole('link', { name: 'Add Inbox' });
|
||||
}
|
||||
|
||||
async clickAddInboxButton() {
|
||||
await this.getAddInboxButton().click();
|
||||
}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', { name: /inboxes/i });
|
||||
}
|
||||
|
||||
async deleteInbox(accountId: number, inboxId: number) {
|
||||
await this.page.evaluate(
|
||||
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
|
||||
const api = (window as typeof window & { axios: DashboardApi }).axios;
|
||||
await api.delete(
|
||||
`/api/v1/accounts/${currentAccountId}/inboxes/${currentInboxId}`
|
||||
);
|
||||
},
|
||||
{ accountId, inboxId }
|
||||
);
|
||||
}
|
||||
|
||||
async isInboxPresent(accountId: number, inboxId: number) {
|
||||
return this.page.evaluate(
|
||||
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
|
||||
const api = (window as typeof window & { axios: DashboardApi }).axios;
|
||||
const response = await api.get(
|
||||
`/api/v1/accounts/${currentAccountId}/inboxes`
|
||||
);
|
||||
return response.data.payload.some(
|
||||
inbox => inbox.id === currentInboxId
|
||||
);
|
||||
},
|
||||
{ accountId, inboxId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { AddAgentModal, AgentPage, Login } from '@components/ui';
|
||||
|
||||
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
|
||||
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
|
||||
|
||||
test.describe('Agent Onboarding - UI', () => {
|
||||
let loginComponent: Login;
|
||||
let agentPage: AgentPage;
|
||||
let addAgentModal: AddAgentModal;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginComponent = new Login(page);
|
||||
agentPage = new AgentPage(page);
|
||||
addAgentModal = new AddAgentModal(page);
|
||||
|
||||
await loginComponent.navigate();
|
||||
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
|
||||
|
||||
await expect(page).toHaveURL(/\/app\/accounts\/\d+\/dashboard/);
|
||||
const accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
|
||||
await agentPage.navigate(accountId);
|
||||
});
|
||||
|
||||
test('should validate all UI elements on agents page', async () => {
|
||||
await expect(agentPage.getPageHeading()).toBeVisible();
|
||||
await expect(agentPage.getDescriptionText()).toBeVisible();
|
||||
|
||||
const learnLink = agentPage.getLearnLink();
|
||||
await expect(learnLink).toBeVisible();
|
||||
await expect(learnLink).toHaveAttribute('href', 'https://chwt.app/hc/agents');
|
||||
|
||||
await expect(agentPage.getAddAgentButton()).toBeVisible();
|
||||
await agentPage.openAddAgentModal();
|
||||
|
||||
await expect(addAgentModal.getModalTitle()).toBeVisible();
|
||||
await expect(addAgentModal.getModalTitle()).toHaveText('Add agent to your team');
|
||||
|
||||
await expect(addAgentModal.getAgentNameInput()).toBeVisible();
|
||||
await expect(addAgentModal.getEmailInput()).toBeVisible();
|
||||
await expect(addAgentModal.getRoleCombobox()).toBeVisible();
|
||||
await expect(addAgentModal.getSubmitButton()).toBeVisible();
|
||||
await expect(addAgentModal.getCancelButton()).toBeVisible();
|
||||
|
||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
||||
|
||||
await addAgentModal.getAgentNameInput().fill('Test');
|
||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
||||
|
||||
await addAgentModal.getAgentNameInput().clear();
|
||||
await addAgentModal.getEmailInput().fill('test@example.com');
|
||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
||||
|
||||
await addAgentModal.getAgentNameInput().fill('Test');
|
||||
await expect(addAgentModal.getSubmitButton()).toBeEnabled();
|
||||
|
||||
await addAgentModal.cancelForm();
|
||||
await expect(addAgentModal.getModalTitle()).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
AddAgentsForm,
|
||||
ApiChannelForm,
|
||||
ChannelSelector,
|
||||
FinishSetup,
|
||||
Login,
|
||||
SettingsInboxPage,
|
||||
} from '@components/ui';
|
||||
|
||||
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
|
||||
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
|
||||
|
||||
test.describe('Inbox Creation - UI Flow', () => {
|
||||
const testInbox = {
|
||||
name: `Test Inbox ${Date.now()}`,
|
||||
webhookUrl: 'https://example.com/webhook',
|
||||
};
|
||||
|
||||
let accountId: number | undefined;
|
||||
let inboxId: number | undefined;
|
||||
|
||||
test.beforeEach(() => {
|
||||
accountId = undefined;
|
||||
inboxId = undefined;
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
const currentAccountId = accountId;
|
||||
const currentInboxId = inboxId;
|
||||
if (!currentAccountId || !currentInboxId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsInboxPage = new SettingsInboxPage(page);
|
||||
await settingsInboxPage.deleteInbox(currentAccountId, currentInboxId);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
settingsInboxPage.isInboxPresent(currentAccountId, currentInboxId),
|
||||
{
|
||||
message: `Inbox ${currentInboxId} was not deleted`,
|
||||
timeout: 30_000,
|
||||
}
|
||||
)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test('should complete full inbox creation flow with UI validation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const loginComponent = new Login(page);
|
||||
await loginComponent.navigate();
|
||||
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
|
||||
await page.waitForURL(/\/app\/accounts\/\d+\/dashboard/);
|
||||
|
||||
accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
|
||||
const settingsInboxPage = new SettingsInboxPage(page);
|
||||
await settingsInboxPage.navigate(accountId);
|
||||
|
||||
await expect(settingsInboxPage.getPageHeading()).toBeVisible();
|
||||
await expect(settingsInboxPage.getAddInboxButton()).toBeVisible();
|
||||
|
||||
await settingsInboxPage.clickAddInboxButton();
|
||||
await page.waitForURL(/\/settings\/inboxes\/new/);
|
||||
|
||||
const channelSelector = new ChannelSelector(page);
|
||||
await expect(channelSelector.getPageHeading()).toBeVisible();
|
||||
await channelSelector.selectApiChannel();
|
||||
|
||||
page.on('response', async response => {
|
||||
if (
|
||||
response.url().includes('/api/v1/accounts/') &&
|
||||
response.url().includes('/inboxes') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 200
|
||||
) {
|
||||
try {
|
||||
const responseData = await response.json();
|
||||
if (responseData.id) {
|
||||
inboxId = responseData.id;
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON responses
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const apiChannelForm = new ApiChannelForm(page);
|
||||
await apiChannelForm.fillChannelName(testInbox.name);
|
||||
await apiChannelForm.fillWebhookUrl(testInbox.webhookUrl);
|
||||
await apiChannelForm.submitForm();
|
||||
await expect.poll(() => inboxId).toBeTruthy();
|
||||
|
||||
const addAgentsForm = new AddAgentsForm(page);
|
||||
await expect(addAgentsForm.getPageHeading()).toBeVisible();
|
||||
await addAgentsForm.addFirstAgent();
|
||||
|
||||
await page.waitForURL(/\/settings\/inboxes\/.*\/finish/);
|
||||
const finishSetup = new FinishSetup(page);
|
||||
await expect(finishSetup.getPageHeading()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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