Merge branch 'develop' into feat/ui-lib

This commit is contained in:
Shivam Mishra
2025-06-04 15:51:21 +05:30
committed by GitHub
828 changed files with 52346 additions and 8928 deletions
@@ -48,12 +48,13 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-4 py-0 tab--chat-type"
class="w-full px-3 -mt-1 py-0 tab--chat-type"
@change="onTabChange"
>
<woot-tabs-item
v-for="(item, index) in items"
:key="item.key"
class="text-sm"
:index="index"
:name="item.name"
:count="item.count"
@@ -25,10 +25,7 @@ defineProps({
:username="user.name"
:status="user.availability_status"
/>
<span
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
:class="textClass"
>
<span class="my-0 truncate text-capitalize" :class="textClass">
{{ user.name }}
</span>
</div>
@@ -38,7 +38,7 @@ const currentSortBy = computed(() => {
);
});
const chatStatusOptions = [
const chatStatusOptions = computed(() => [
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
value: 'open',
@@ -59,9 +59,9 @@ const chatStatusOptions = [
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'),
value: 'all',
},
];
]);
const chatSortOptions = [
const chatSortOptions = computed(() => [
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.last_activity_at_asc.TEXT'),
value: 'last_activity_at_asc',
@@ -94,15 +94,18 @@ const chatSortOptions = [
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_desc.TEXT'),
value: 'waiting_since_desc',
},
];
]);
const activeChatStatusLabel = computed(
() =>
chatStatusOptions.find(m => m.value === chatStatusFilter.value)?.label || ''
chatStatusOptions.value.find(m => m.value === chatStatusFilter.value)
?.label || ''
);
const activeChatSortLabel = computed(
() => chatSortOptions.find(m => m.value === chatSortFilter.value)?.label || ''
() =>
chatSortOptions.value.find(m => m.value === chatSortFilter.value)?.label ||
''
);
const saveSelectedFilter = (type, value) => {
@@ -4,17 +4,14 @@ import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
import ConversationSidebar from './ConversationSidebar.vue';
export default {
components: {
ConversationSidebar,
ConversationHeader,
DashboardAppFrame,
EmptyState,
MessagesView,
},
props: {
inboxId: {
type: [Number, String],
@@ -34,7 +31,6 @@ export default {
default: true,
},
},
emits: ['contactPanelToggle'],
data() {
return { activeIndex: 0 };
},
@@ -86,9 +82,6 @@ export default {
}
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
onDashboardAppTabChange(index) {
this.activeIndex = index;
},
@@ -98,7 +91,7 @@ export default {
<template>
<div
class="conversation-details-wrap bg-n-background"
class="conversation-details-wrap bg-n-background relative"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
@@ -106,15 +99,12 @@ export default {
<ConversationHeader
v-if="currentChat.id"
:chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout && !isInboxView"
@contact-panel-toggle="onToggleContactPanel"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
:index="activeIndex"
class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
class="-mt-px dashboard-app--tabs border-t border-t-n-background"
@change="onDashboardAppTabChange"
>
<woot-tabs-item
@@ -130,18 +120,12 @@ export default {
v-if="currentChat.id"
:inbox-id="inboxId"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
<EmptyState
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<ConversationSidebar
v-if="showContactPanel"
:current-chat="currentChat"
@toggle-contact-panel="onToggleContactPanel"
/>
<slot />
</div>
<DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps"
@@ -243,7 +243,7 @@ export default {
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
@@ -278,7 +278,7 @@ export default {
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="40px"
size="32px"
/>
</div>
<div
@@ -1,8 +1,9 @@
<script>
import { mapGetters } from 'vuex';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue';
@@ -12,203 +13,162 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showBackButton: {
type: Boolean,
default: false,
},
});
export default {
components: {
BackButton,
InboxName,
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
NextButton,
},
mixins: [inboxMixin],
props: {
chat: {
type: Object,
default: () => {},
},
isContactPanelOpen: {
type: Boolean,
default: false,
},
showBackButton: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
emits: ['contactPanelToggle'],
setup(props, { emit }) {
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents);
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
appIntegrations: 'integrations/getAppIntegrations',
}),
chatMetadata() {
return this.chat.meta;
},
backButtonUrl() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
return conversationListPageURL({
accountId,
inboxId,
label,
teamId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
});
},
isHMACVerified() {
if (!this.isAWebWidgetInbox) {
return true;
}
return this.chatMetadata.hmac_verified;
},
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chat.meta.sender.id
);
},
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
snoozedDisplayText() {
const { snoozed_until: snoozedUntil } = this.currentChat;
if (snoozedUntil) {
return `${this.$t(
'CONVERSATION.HEADER.SNOOZED_UNTIL'
)} ${snoozedReopenTime(snoozedUntil)}`;
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
contactPanelToggleText() {
return `${
this.isContactPanelOpen
? this.$t('CONVERSATION.HEADER.CLOSE')
: this.$t('CONVERSATION.HEADER.OPEN')
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
},
hasMultipleInboxes() {
return this.$store.getters['inboxes/getInboxes'].length > 1;
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
};
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
const isFeatureEnabledonAccount = computed(
() => store.getters['accounts/isFeatureEnabledonAccount']
);
const appIntegrations = computed(
() => store.getters['integrations/getAppIntegrations']
);
const chatMetadata = computed(() => props.chat.meta);
const backButtonUrl = computed(() => {
const {
params: { inbox_id: inboxId, label, teamId },
name,
} = route;
return conversationListPageURL({
accountId,
inboxId,
label,
teamId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
});
});
const isHMACVerified = computed(() => {
if (!isAWebWidgetInbox.value) {
return true;
}
return chatMetadata.value.hmac_verified;
});
const currentContact = computed(() =>
store.getters['contacts/getContact'](props.chat.meta.sender.id)
);
const isSnoozed = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
);
const snoozedDisplayText = computed(() => {
const { snoozed_until: snoozedUntil } = currentChat.value;
if (snoozedUntil) {
return `${t('CONVERSATION.HEADER.SNOOZED_UNTIL')} ${snoozedReopenTime(snoozedUntil)}`;
}
return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
});
const inbox = computed(() => {
const { inbox_id: inboxId } = props.chat;
return store.getters['inboxes/getInbox'](inboxId);
});
const hasMultipleInboxes = computed(
() => store.getters['inboxes/getInboxes'].length > 1
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const isLinearIntegrationEnabled = computed(() =>
appIntegrations.value.find(
integration => integration.id === 'linear' && !!integration.hooks.length
)
);
const isLinearFeatureEnabled = computed(() =>
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR)
);
</script>
<template>
<div
class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
ref="conversationHeader"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
>
<div
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
>
<div class="flex items-center justify-start max-w-full min-w-0 w-fit">
<BackButton
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Thumbnail
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
>
<div
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
<BackButton
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Thumbnail
:src="currentContact.thumbnail"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
class="flex-shrink-0"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
>
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
<NextButton link slate @click.prevent="$emit('contactPanelToggle')">
<span
class="text-base font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
</NextButton>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
{{ currentContact.name }}
</span>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px] flex-shrink-0"
icon="warning"
/>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
<NextButton
link
xs
blue
:label="contactPanelToggleText"
@click="$emit('contactPanelToggle')"
/>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
</div>
</div>
<div
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
<div
class="flex flex-row items-center justify-start xl:justify-end flex-shrink-0 gap-2 w-full xl:w-auto header-actions-wrap"
>
<SLACardLabel
v-if="hasSlaPolicyId"
:chat="chat"
show-extended-info
:parent-width="width"
class="hidden md:flex"
/>
<Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
:parent-width="width"
class="hidden md:flex"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
</template>
<style lang="scss" scoped>
.conversation--header--actions {
::v-deep .inbox--name {
@apply m-0;
}
}
</style>
@@ -1,81 +1,36 @@
<script setup>
import { computed, ref } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import { computed } from 'vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
defineProps({
currentChat: {
required: true,
type: Object,
},
});
const emit = defineEmits(['toggleContactPanel']);
const { uiSettings } = useUISettings();
const { t } = useI18n();
const activeTab = computed(() => {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
const channelType = computed(() => props.currentChat?.meta?.channel || '');
const CONTACT_TABS_OPTIONS = [
{ key: 'CONTACT', value: 'contact' },
{ key: 'COPILOT', value: 'copilot' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
value: tab.value,
}));
if (isContactSidebarOpen) {
return 0;
}
return null;
});
const activeTab = ref(0);
const toggleContactPanel = () => {
emit('toggleContactPanel');
};
const handleTabChange = selectedTab => {
activeTab.value = tabs.value.findIndex(
tabItem => tabItem.value === selectedTab.value
);
};
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
</script>
<template>
<div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
>
<div v-if="showCopilotTab" class="p-2">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTab"
class="w-full [&>button]:w-full"
@tab-changed="handleTabChange"
/>
</div>
<div class="flex flex-1 overflow-auto">
<ContactPanel
v-if="!activeTab"
v-show="activeTab === 0"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
:on-toggle="toggleContactPanel"
/>
<CopilotContainer
v-else-if="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
class="flex-1"
/>
</div>
</div>
@@ -185,8 +185,17 @@ export default {
contextMenuEnabledOptions() {
return {
copy: this.hasText,
delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText,
delete:
(this.hasText || this.hasAttachments) &&
!this.isMessageDeleted &&
!this.isFailed,
cannedResponse:
this.isOutgoing && this.hasText && !this.isMessageDeleted,
copyLink: !this.isFailed || !this.isProcessing,
translate:
(!this.isFailed || !this.isProcessing) &&
!this.isMessageDeleted &&
this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
};
},
@@ -328,7 +337,7 @@ export default {
return !this.sender.type || this.sender.type === 'agent_bot';
},
shouldShowContextMenu() {
return !(this.isFailed || this.isPending || this.isUnsupported);
return !this.isUnsupported;
},
showAvatar() {
if (this.isOutgoing || this.isTemplate) {
@@ -1,5 +1,5 @@
<script>
import { ref } from 'vue';
import { ref, provide } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
@@ -38,8 +38,6 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Message,
@@ -47,22 +45,11 @@ export default {
ReplyBox,
Banner,
ConversationLabelSuggestion,
NextButton,
},
mixins: [inboxMixin],
props: {
isContactPanelOpen: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
emits: ['contactPanelToggle'],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const closePopOutReplyBox = () => {
@@ -98,6 +85,8 @@ export default {
FEATURE_FLAGS.CHATWOOT_V4
);
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
@@ -108,6 +97,7 @@ export default {
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
showNextBubbles,
conversationPanelRef,
};
},
data() {
@@ -199,12 +189,6 @@ export default {
isATweet() {
return this.conversationType === 'tweet';
},
isRightOrLeftIcon() {
if (this.isContactPanelOpen) {
return 'arrow-chevron-right';
}
return 'arrow-chevron-left';
},
getLastSeenAt() {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt;
@@ -440,9 +424,6 @@ export default {
relevantMessages
);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
@@ -526,21 +507,9 @@ export default {
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div class="flex justify-end">
<NextButton
faded
xs
slate
class="!rounded-r-none rtl:rotate-180 !rounded-2xl !fixed z-10"
:icon="
isContactPanelOpen ? 'i-ph-caret-right-fill' : 'i-ph-caret-left-fill'
"
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
@click="onToggleContactPanel"
/>
</div>
<NextMessageList
v-if="showNextBubbles"
ref="conversationPanelRef"
class="conversation-panel"
:current-user-id="currentUserId"
:first-unread-id="unReadMessages[0]?.id"
@@ -572,7 +541,7 @@ export default {
/>
</template>
</NextMessageList>
<ul v-else class="conversation-panel">
<ul v-else ref="conversationPanelRef" class="conversation-panel">
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
@@ -1,10 +1,14 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, onUnmounted } from 'vue';
import { useToggle } from '@vueuse/core';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt';
import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import {
CMD_MUTE_CONVERSATION,
@@ -12,97 +16,111 @@ import {
CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
export default {
components: {
EmailTranscriptModal,
ResolveAction,
ButtonV4,
},
data() {
return {
showEmailActionsModal: false,
};
},
computed: {
...mapGetters({ currentChat: 'getSelectedChat' }),
},
mounted() {
emitter.on(CMD_MUTE_CONVERSATION, this.mute);
emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
unmounted() {
emitter.off(CMD_MUTE_CONVERSATION, this.mute);
emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
methods: {
mute() {
this.$store.dispatch('muteConversation', this.currentChat.id);
useAlert(this.$t('CONTACT_PANEL.MUTED_SUCCESS'));
},
unmute() {
this.$store.dispatch('unmuteConversation', this.currentChat.id);
useAlert(this.$t('CONTACT_PANEL.UNMUTED_SUCCESS'));
},
toggleEmailActionsModal() {
this.showEmailActionsModal = !this.showEmailActionsModal;
},
},
// No props needed as we're getting currentChat from the store directly
const store = useStore();
const { t } = useI18n();
const [showEmailActionsModal, toggleEmailModal] = useToggle(false);
const [showActionsDropdown, toggleDropdown] = useToggle(false);
const currentChat = computed(() => store.getters.getSelectedChat);
const actionMenuItems = computed(() => {
const items = [];
if (!currentChat.value.muted) {
items.push({
icon: 'i-lucide-volume-off',
label: t('CONTACT_PANEL.MUTE_CONTACT'),
action: 'mute',
value: 'mute',
});
} else {
items.push({
icon: 'i-lucide-volume-1',
label: t('CONTACT_PANEL.UNMUTE_CONTACT'),
action: 'unmute',
value: 'unmute',
});
}
items.push({
icon: 'i-lucide-share',
label: t('CONTACT_PANEL.SEND_TRANSCRIPT'),
action: 'send_transcript',
value: 'send_transcript',
});
return items;
});
const handleActionClick = ({ action }) => {
toggleDropdown(false);
if (action === 'mute') {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
} else if (action === 'unmute') {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
} else if (action === 'send_transcript') {
toggleEmailModal();
}
};
// These functions are needed for the event listeners
const mute = () => {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
};
const unmute = () => {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
};
emitter.on(CMD_MUTE_CONVERSATION, mute);
emitter.on(CMD_UNMUTE_CONVERSATION, unmute);
emitter.on(CMD_SEND_TRANSCRIPT, toggleEmailModal);
onUnmounted(() => {
emitter.off(CMD_MUTE_CONVERSATION, mute);
emitter.off(CMD_UNMUTE_CONVERSATION, unmute);
emitter.off(CMD_SEND_TRANSCRIPT, toggleEmailModal);
});
</script>
<template>
<div class="relative flex items-center gap-2 actions--container">
<ButtonV4
v-if="!currentChat.muted"
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-off"
@click="mute"
/>
<ButtonV4
v-else
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-1"
@click="unmute"
/>
<ButtonV4
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-share"
@click="toggleEmailActionsModal"
/>
<ResolveAction
:conversation-id="currentChat.id"
:status="currentChat.status"
/>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<ButtonV4
v-tooltip="$t('CONVERSATION.HEADER.MORE_ACTIONS')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-more-vertical"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="actionMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleActionClick"
/>
</div>
<EmailTranscriptModal
v-if="showEmailActionsModal"
:show="showEmailActionsModal"
:current-chat="currentChat"
@cancel="toggleEmailActionsModal"
@cancel="toggleEmailModal"
/>
</div>
</template>
<style scoped lang="scss">
.more--button {
@apply items-center flex ml-2 rtl:ml-0 rtl:mr-2;
}
.dropdown-pane {
@apply -right-2 top-12;
}
.icon {
@apply mr-1 rtl:mr-0 rtl:ml-1 min-w-[1rem];
}
</style>
@@ -11,6 +11,7 @@ import { downloadFile } from '@chatwoot/utils';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
attachment: {
@@ -166,7 +167,7 @@ onMounted(() => {
</script>
<template>
<Teleport to="body">
<TeleportWithDirection to="body">
<woot-modal
v-model:show="show"
full-width
@@ -258,7 +259,7 @@ onMounted(() => {
<div class="flex items-center justify-center w-16 shrink-0">
<NextButton
v-if="hasMoreThanOneAttachment"
icon="i-lucide-chevron-left"
icon="ltr:i-lucide-chevron-left rtl:i-lucide-chevron-right"
class="z-10"
blue
faded
@@ -324,7 +325,7 @@ onMounted(() => {
<div class="flex items-center justify-center w-16 shrink-0">
<NextButton
v-if="hasMoreThanOneAttachment"
icon="i-lucide-chevron-right"
icon="ltr:i-lucide-chevron-right rtl:i-lucide-chevron-left"
class="z-10"
blue
faded
@@ -351,5 +352,5 @@ onMounted(() => {
</footer>
</div>
</woot-modal>
</Teleport>
</TeleportWithDirection>
</template>
@@ -1,132 +1,115 @@
<script>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
parentWidth: {
type: Number,
default: 1000,
},
});
const REFRESH_INTERVAL = 60000;
const { t } = useI18n();
export default {
components: {
SLAPopoverCard,
},
props: {
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
},
data() {
return {
timer: null,
showSlaPopover: false,
slaStatus: {
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
},
};
},
computed: {
slaPolicyId() {
return this.chat?.sla_policy_id;
},
appliedSLA() {
return this.chat?.applied_sla;
},
slaEvents() {
return this.chat?.sla_events;
},
hasSlaThreshold() {
return this.slaStatus?.threshold;
},
isSlaMissed() {
return this.slaStatus?.isSlaMissed;
},
slaTextStyles() {
return this.isSlaMissed ? 'text-n-ruby-11' : 'text-n-amber-11';
},
slaStatusText() {
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
},
showSlaPopoverCard() {
return (
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
);
},
},
watch: {
chat() {
this.updateSlaStatus();
},
},
mounted() {
this.updateSlaStatus();
this.createTimer();
},
unmounted() {
if (this.timer) {
clearTimeout(this.timer);
}
},
methods: {
createTimer() {
this.timer = setTimeout(() => {
this.updateSlaStatus();
this.createTimer();
}, REFRESH_INTERVAL);
},
updateSlaStatus() {
this.slaStatus = evaluateSLAStatus({
appliedSla: this.appliedSLA,
chat: this.chat,
});
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
this.showSlaPopover = true;
},
closeSlaPopover() {
this.showSlaPopover = false;
},
},
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaTextStyles = computed(() =>
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
);
const slaStatusText = computed(() => {
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
});
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
);
const groupClass = computed(() => {
return props.showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong';
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
watch(
() => props.chat,
() => {
updateSlaStatus();
}
);
const slaPopoverClass = computed(() => {
return props.showExtendedInfo
? 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
: '';
});
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="hasSlaThreshold"
class="relative flex items-center cursor-pointer min-w-fit"
:class="
showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong'
"
class="relative flex items-center cursor-pointer min-w-fit group"
:class="groupClass"
>
<div
v-on-clickaway="closeSlaPopover"
class="flex items-center w-full truncate"
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
@mouseover="openSlaPopover()"
class="flex items-center w-full truncate px-1.5"
:class="showExtendedInfo ? '' : 'gap-1'"
>
<div
class="flex items-center gap-1"
:class="
showExtendedInfo &&
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
"
>
<div class="flex items-center gap-1" :class="slaPopoverClass">
<fluent-icon
size="14"
size="12"
:icon="slaStatus.icon"
type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
@@ -134,7 +117,7 @@ export default {
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo"
v-if="showExtendedInfo && parentWidth > 650"
class="text-xs font-medium"
:class="slaTextStyles"
>
@@ -151,7 +134,7 @@ export default {
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="right-0 top-7"
class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex"
/>
</div>
</template>
@@ -16,6 +16,10 @@ const props = defineProps({
type: [Number, String],
required: true,
},
parentWidth: {
type: Number,
default: 10000,
},
});
defineOptions({
@@ -73,6 +77,14 @@ const unlinkIssue = async linkId => {
}
};
const shouldShowIssueIdentifier = computed(() => {
if (!linkedIssue.value) {
return false;
}
return props.parentWidth > 600;
});
const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true;
@@ -119,7 +131,10 @@ onMounted(() => {
class="text-[#5E6AD2] flex-shrink-0"
view-box="0 0 19 19"
/>
<span v-if="linkedIssue" class="text-xs font-medium text-n-slate-11">
<span
v-if="shouldShowIssueIdentifier"
class="text-xs font-medium text-n-slate-11"
>
{{ linkedIssue.issue.identifier }}
</span>
</Button>
@@ -127,7 +142,7 @@ onMounted(() => {
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute right-0 top-[36px] invisible group-hover:visible"
class="absolute start-0 xl:start-auto xl:end-0 top-9 invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<woot-modal
@@ -1,110 +0,0 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import MoreActions from '../MoreActions.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
}));
const mockDirective = {
mounted: () => {},
};
import { emitter } from 'shared/helpers/mitt';
describe('MoveActions', () => {
let currentChat = { id: 8, muted: false };
let store = null;
let muteConversation = null;
let unmuteConversation = null;
beforeEach(() => {
muteConversation = vi.fn(() => Promise.resolve());
unmuteConversation = vi.fn(() => Promise.resolve());
store = createStore({
state: {
authenticated: true,
currentChat,
},
getters: {
getSelectedChat: () => currentChat,
},
modules: {
conversations: {
namespaced: false,
actions: { muteConversation, unmuteConversation },
},
},
});
});
const createWrapper = () =>
mount(MoreActions, {
global: {
plugins: [store],
components: {
'fluent-icon': FluentIcon,
},
directives: {
'on-clickaway': mockDirective,
},
},
});
describe('muting discussion', () => {
it('triggers "muteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(muteConversation).toHaveBeenCalledTimes(1);
expect(muteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message:
'This contact is blocked successfully. You will not be notified of any future conversations.',
action: null,
});
});
});
describe('unmuting discussion', () => {
beforeEach(() => {
currentChat.muted = true;
});
it('triggers "unmuteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(unmuteConversation).toHaveBeenCalledTimes(1);
expect(unmuteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message: 'This contact is unblocked successfully.',
action: null,
});
});
});
});