diff --git a/VERSION_CW b/VERSION_CW
index ecbc3b030..27593c841 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.16.0
+4.16.1
diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js
index 5af6110ab..806b45bb2 100644
--- a/app/javascript/dashboard/api/captain/assistant.js
+++ b/app/javascript/dashboard/api/captain/assistant.js
@@ -26,13 +26,20 @@ class CaptainAssistant extends ApiClient {
});
}
- getStats({ assistantId, range, signal }) {
+ getMetrics({ assistantId, range, signal }) {
const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
};
if (signal) requestConfig.signal = signal;
- return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
+ return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
+ }
+
+ getFaqStats({ assistantId, signal }) {
+ const requestConfig = {};
+ if (signal) requestConfig.signal = signal;
+
+ return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
}
getSummary({ assistantId, range, stats }) {
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue
index 45311ece6..e015f682a 100644
--- a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue
@@ -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
+ ),
};
});
});
diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue
index dd2b3af37..0a54cab65 100644
--- a/app/javascript/dashboard/components-next/Calls/CallListItem.vue
+++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue
@@ -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(() => ({
-
+
({
>
-
+
{{ agentActionLabel }}
@@ -192,7 +196,7 @@ const conversationRoute = computed(() => ({
{{ call.agent.name }}
-
+
({
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"
>
-
+
{{ call.inbox.name }}
@@ -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 }}
diff --git a/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue
index 4d01b0500..647f64cdb 100644
--- a/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue
+++ b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue
@@ -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 }) => {
-
+
{{
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 }}
-
+
@@ -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') }}
diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
index 437933e5f..c71ab0d43 100644
--- a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
+++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
@@ -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);
});
diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue
index be3ddf280..5008e96f0 100644
--- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue
+++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue
@@ -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(() => {
diff --git a/app/javascript/dashboard/components-next/Inbox/InboxCard.vue b/app/javascript/dashboard/components-next/Inbox/InboxCard.vue
index 078d49fe4..88a5487a6 100644
--- a/app/javascript/dashboard/components-next/Inbox/InboxCard.vue
+++ b/app/javascript/dashboard/components-next/Inbox/InboxCard.vue
@@ -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(() => {
diff --git a/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js
index 8e933cd84..f2628c065 100644
--- a/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js
+++ b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js
@@ -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,
});
diff --git a/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js b/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js
index 73bd0ce99..d7bfe427d 100644
--- a/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js
+++ b/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js
@@ -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', () => {
diff --git a/app/javascript/dashboard/components-next/audio/AudioPlayer.vue b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue
index c716ffbfb..68d8bd02b 100644
--- a/app/javascript/dashboard/components-next/audio/AudioPlayer.vue
+++ b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue
@@ -117,8 +117,8 @@ const downloadRecording = () => {
>
@@ -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"
/>
-
+
{{ displayedTime }}
diff --git a/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue b/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue
index f349720b0..8561737a5 100644
--- a/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue
+++ b/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue
@@ -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 }) => {
diff --git a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue
index f680d3f03..e8369122e 100644
--- a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue
+++ b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue
@@ -1,6 +1,5 @@
-
+
-
-
-
diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js
index 0eb91afd5..7328d25a1 100644
--- a/app/javascript/dashboard/components-next/icon/provider.js
+++ b/app/javascript/dashboard/components-next/icon/provider.js
@@ -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';
diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
index 38c001fae..300f21939 100644
--- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
+++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
@@ -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', () => {
diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue
index cd3c7d5dc..ae5565fe0 100644
--- a/app/javascript/dashboard/components-next/message/Message.vue
+++ b/app/javascript/dashboard/components-next/message/Message.vue
@@ -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 || '',
diff --git a/app/javascript/dashboard/components/ui/ContextMenu.vue b/app/javascript/dashboard/components/ui/ContextMenu.vue
index 0773e4fd2..9ce6866cb 100644
--- a/app/javascript/dashboard/components/ui/ContextMenu.vue
+++ b/app/javascript/dashboard/components/ui/ContextMenu.vue
@@ -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"
>
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue
index 1c7787de6..1f3c03a2e 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue
@@ -8,6 +8,8 @@ import {
EditorState,
Selection,
imageResizeView,
+ toggleMark,
+ wrapInList,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -17,8 +19,6 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
-import { toggleMark } from 'prosemirror-commands';
-import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
index 6cc2747da..b013a47b9 100644
--- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
@@ -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 {
>
-
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"
>
-
+
+
+
+
+
+
+
+
+
+
+ {{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }}
+
+
-