Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/ui-lib

This commit is contained in:
Shivam Mishra
2026-03-20 17:34:27 +05:30
2279 changed files with 77643 additions and 20821 deletions
@@ -47,7 +47,7 @@ defineProps({
<div
v-if="isComingSoon"
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95 cursor-not-allowed"
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
>
<span class="text-n-slate-12 font-medium text-sm">
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
@@ -17,10 +17,7 @@ import {
useFunctionGetter,
} from 'dashboard/composables/store.js';
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
// from the current one. But we should consider using tanstack virtual in the future
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import { Virtualizer } from 'virtua/vue';
import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
@@ -29,9 +26,9 @@ import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import IntersectionObserver from './IntersectionObserver.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -46,7 +43,6 @@ import {
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener } from '@vueuse/core';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
import { emitter } from 'shared/helpers/mitt';
@@ -70,8 +66,6 @@ import { matchesFilters } from '../store/modules/conversations/helpers/filterHel
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
const props = defineProps({
conversationInbox: { type: [String, Number], default: 0 },
teamId: { type: [String, Number], default: 0 },
@@ -91,9 +85,9 @@ const store = useStore();
const resolveAttributesModalRef = ref(null);
const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null);
const virtualListRef = ref(null);
provide('contextMenuElementTarget', conversationDynamicScroller);
provide('contextMenuElementTarget', virtualListRef);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
@@ -145,6 +139,7 @@ const {
isConversationSelected,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions();
@@ -160,12 +155,6 @@ const {
const { checkMissingAttributes } = useConversationRequiredAttributes();
// computed
const intersectionObserverOptions = computed(() => {
return {
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
};
});
const hasAppliedFilters = computed(() => {
return appliedFilters.value.length !== 0;
@@ -383,18 +372,6 @@ function setFiltersFromUISettings() {
function emitConversationLoaded() {
emit('conversationLoad');
// [VITE] removing this since the library has changed
// nextTick(() => {
// // Addressing a known issue in the virtual list library where dynamically added items
// // might not render correctly. This workaround involves a slight manual adjustment
// // to the scroll position, triggering the list to refresh its rendering.
// const virtualList = conversationListRef.value;
// const scrollToOffset = virtualList?.scrollToOffset;
// const currentOffset = virtualList?.getOffset() || 0;
// if (scrollToOffset) {
// scrollToOffset(currentOffset + 1);
// }
// });
}
function fetchFilteredConversations(payload) {
@@ -606,16 +583,13 @@ function loadMoreConversations() {
}
}
// Add a method to handle scroll events
function handleScroll() {
const scroller = conversationDynamicScroller.value;
if (scroller && scroller.hasScrollbar) {
const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
if (scrollHeight - (scrollTop + clientHeight) < 100) {
loadMoreConversations();
}
}
}
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
// If the list doesnt fill the viewport, loading can stall.
// IntersectionObserver triggers as soon as the sentinel is visible.
const intersectionObserverOptions = computed(() => ({
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
}));
function updateAssigneeTab(selectedTab) {
if (activeAssigneeTab.value !== selectedTab) {
@@ -821,8 +795,6 @@ useEmitter('fetch_conversation_stats', () => {
store.dispatch('conversationStats/get', conversationFilters.value);
});
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
onMounted(() => {
store.dispatch('setChatListFilters', conversationFilters.value);
setFiltersFromUISettings();
@@ -859,6 +831,7 @@ provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
@@ -975,61 +948,40 @@ watch(conversationFilters, (newVal, oldVal) => {
/>
<div
ref="conversationListRef"
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
:class="{ 'overflow-hidden': isContextMenuOpen }"
class="flex-1 min-h-0 overflow-y-auto conversations-list"
:class="{ '!overflow-hidden': isContextMenuOpen }"
>
<DynamicScroller
ref="conversationDynamicScroller"
:items="conversationList"
:min-item-size="24"
class="overflow-auto w-full h-full"
<Virtualizer
ref="virtualListRef"
v-slot="{ item, index }"
:data="conversationList"
>
<template #default="{ item, index, active }">
<!--
If we encounter resizing issues, we can set the `watchData` prop to true
this will deeply watch the entire object instead of just size dependencies
But it can impact performance
-->
<DynamicScrollerItem
:item="item"
:active="active"
:data-index="index"
:size-dependencies="[
item.messages,
item.labels,
item.uuid,
item.inbox_id,
]"
>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/>
</DynamicScrollerItem>
</template>
<template #after>
<div v-if="chatListLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-n-slate-11"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-else
:options="intersectionObserverOptions"
@observed="loadMoreConversations"
/>
</template>
</DynamicScroller>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:data-index="index"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/>
</Virtualizer>
<div v-if="chatListLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-n-slate-11"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-else
:options="intersectionObserverOptions"
@observed="loadMoreConversations"
/>
</div>
<Dialog
ref="deleteConversationDialogRef"
@@ -10,6 +10,7 @@ export default {
'assignAgent',
'assignTeam',
'assignLabels',
'removeLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
@@ -49,7 +50,6 @@ export default {
<template>
<ConversationCard
:key="source.id"
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
@@ -63,6 +63,7 @@ export default {
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@remove-label="removeLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@@ -1,31 +0,0 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
<h4 v-if="title" class="text-lg font-medium text-n-slate-12">
{{ title }}
</h4>
<div class="flex flex-row items-center justify-between">
<div class="flex-grow h-px bg-n-weak" />
</div>
<p v-if="description" class="mb-0 text-sm font-normal text-n-slate-12">
{{ description }}
</p>
</div>
<div class="flex flex-col w-full gap-6">
<slot />
</div>
</div>
</template>
@@ -24,6 +24,10 @@ export default {
type: [String, Date, Number],
default: '',
},
conversationId: {
type: [String, Number],
default: '',
},
},
data() {
return {
@@ -74,6 +78,15 @@ export default {
createdAtTimestamp() {
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
},
conversationId() {
// Reset display values and timer when the row is recycled to a different conversation.
this.lastActivityAtTimeAgo = dynamicTime(this.lastActivityTimestamp);
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
if (this.isAutoRefreshEnabled) {
clearTimeout(this.timer);
this.createTimer();
}
},
},
mounted() {
if (this.isAutoRefreshEnabled) {
@@ -111,7 +124,6 @@ export default {
v-tooltip.top="{
content: tooltipText,
delay: { show: 1000, hide: 0 },
hideOnClick: true,
}"
class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-n-slate-11"
>
@@ -3,6 +3,9 @@ import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput
import AutomationActionFileInput from './AutomationFileInput.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
export default {
components: {
@@ -10,6 +13,9 @@ export default {
AutomationActionFileInput,
WootMessageEditor,
NextButton,
SingleSelect,
MultiSelect,
NextInput,
},
props: {
modelValue: {
@@ -40,6 +46,10 @@ export default {
type: Boolean,
default: false,
},
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
},
emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'],
computed: {
@@ -69,11 +79,21 @@ export default {
return this.actionTypes.find(action => action.key === this.action_name)
.inputType;
},
actionInputStyles() {
return {
'has-error': this.errorMessage,
'is-a-macro': this.isMacro,
};
actionNameAsSelectModel: {
get() {
if (!this.action_name) return null;
const found = this.actionTypes.find(a => a.key === this.action_name);
return found ? { id: found.key, name: found.label } : null;
},
set(value) {
this.action_name = value?.id || value;
},
},
actionTypesAsOptions() {
return this.actionTypes.map(a => ({ id: a.key, name: a.label }));
},
isVerticalLayout() {
return ['team_message', 'textarea'].includes(this.inputType);
},
castMessageVmodel: {
get() {
@@ -94,203 +114,89 @@ export default {
resetAction() {
this.$emit('resetAction');
},
onActionNameChange(value) {
this.actionNameAsSelectModel = value;
this.resetAction();
},
},
};
</script>
<template>
<div class="filter" :class="actionInputStyles">
<div class="filter-inputs">
<select
v-model="action_name"
class="action__question"
:class="{ 'full-width': !showActionInput }"
@change="resetAction()"
>
<option
v-for="attribute in actionTypes"
:key="attribute.key"
:value="attribute.key"
>
{{ attribute.label }}
</option>
</select>
<div v-if="showActionInput" class="filter__answer--wrap">
<div v-if="inputType" class="w-full">
<div
<li class="list-none py-2 first:pt-0 last:pb-0">
<div
class="flex flex-col gap-2"
:class="{ 'animate-wiggle': errorMessage }"
>
<div class="flex items-center gap-2">
<SingleSelect
:model-value="actionNameAsSelectModel"
:options="actionTypesAsOptions"
:dropdown-max-height="dropdownMaxHeight"
disable-deselect
class="flex-shrink-0"
@update:model-value="onActionNameChange"
/>
<template v-if="showActionInput && !isVerticalLayout">
<SingleSelect
v-if="inputType === 'search_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-model="action_params"
:options="dropdownValues"
:dropdown-max-height="dropdownMaxHeight"
/>
<MultiSelect
v-else-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<input
v-model="action_params"
:options="dropdownValues"
:dropdown-max-height="dropdownMaxHeight"
/>
<NextInput
v-else-if="inputType === 'email'"
v-model="action_params"
type="email"
class="answer--text-input"
size="sm"
:placeholder="$t('AUTOMATION.ACTION.EMAIL_INPUT_PLACEHOLDER')"
/>
<input
<NextInput
v-else-if="inputType === 'url'"
v-model="action_params"
type="url"
class="answer--text-input"
size="sm"
:placeholder="$t('AUTOMATION.ACTION.URL_INPUT_PLACEHOLDER')"
/>
<AutomationActionFileInput
v-if="inputType === 'attachment'"
v-else-if="inputType === 'attachment'"
v-model="action_params"
:initial-file-name="initialFileName"
/>
</div>
</template>
<NextButton
v-if="!isMacro"
sm
solid
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click="removeAction"
/>
</div>
<NextButton
v-if="!isMacro"
icon="i-lucide-x"
slate
ghost
class="flex-shrink-0"
@click="removeAction"
<AutomationActionTeamMessageInput
v-if="inputType === 'team_message'"
v-model="action_params"
:teams="dropdownValues"
:dropdown-max-height="dropdownMaxHeight"
/>
<WootMessageEditor
v-if="inputType === 'textarea'"
v-model="castMessageVmodel"
rows="4"
enable-variables
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="[&_.ProseMirror-menubar]:hidden px-3 py-1 bg-n-alpha-1 rounded-lg outline outline-1 outline-n-weak dark:outline-n-strong"
/>
</div>
<AutomationActionTeamMessageInput
v-if="inputType === 'team_message'"
v-model="action_params"
:teams="dropdownValues"
/>
<WootMessageEditor
v-if="inputType === 'textarea'"
v-model="castMessageVmodel"
rows="4"
enable-variables
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="action-message"
/>
<p v-if="errorMessage" class="filter-error">
<span v-if="errorMessage" class="text-sm text-n-ruby-11">
{{ errorMessage }}
</p>
</div>
</span>
</li>
</template>
<style lang="scss" scoped>
.filter {
@apply bg-n-background p-2 border border-solid border-n-strong dark:border-n-strong rounded-lg mb-2;
&.is-a-macro {
@apply mb-0 bg-n-background dark:bg-n-solid-1 p-0 border-0 rounded-none;
}
}
.no-margin-bottom {
@apply mb-0;
}
.filter.has-error {
@apply bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5;
&.is-a-macro {
@apply bg-transparent;
}
}
.filter-inputs {
@apply flex gap-1;
}
.filter-error {
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
}
.action__question,
.filter__operator {
@apply mb-0 mr-1;
}
.action__question {
@apply max-w-[50%];
}
.action__question.full-width {
@apply max-w-full;
}
.filter__answer--wrap {
@apply max-w-[50%] flex-grow mr-1 flex w-full items-center justify-start;
input {
@apply mb-0;
}
}
.filter__answer {
&.answer--text-input {
@apply mb-0;
}
}
.filter__join-operator-wrap {
@apply relative z-20 m-0;
}
.filter__join-operator {
@apply flex items-center justify-center relative my-2.5 mx-0;
.operator__line {
@apply absolute w-full border-b border-solid border-n-weak;
}
.operator__select {
margin-bottom: 0 !important;
@apply relative w-auto;
}
}
.multiselect {
@apply mb-0;
}
.action-message {
@apply mt-2 mx-0 mb-0;
}
// Prosemirror does not have a native way of hiding the menu bar, hence
::v-deep .ProseMirror-menubar {
@apply hidden;
}
</style>
@@ -1,8 +1,14 @@
<script>
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
export default {
components: {
MultiSelect,
},
props: {
teams: { type: Array, required: true },
modelValue: { type: Object, required: true },
dropdownMaxHeight: { type: String, default: 'max-h-80' },
},
emits: ['update:modelValue'],
data() {
@@ -12,9 +18,9 @@ export default {
};
},
mounted() {
const { team_ids: teamIds } = this.modelValue;
this.selectedTeams = teamIds;
this.message = this.modelValue.message;
const { team_ids: teamIds, message } = this.modelValue || {};
this.selectedTeams = teamIds || [];
this.message = message || '';
},
methods: {
updateValue() {
@@ -28,37 +34,19 @@ export default {
</script>
<template>
<div>
<div class="multiselect-wrap--small flex flex-col gap-1 mt-1">
<multiselect
v-model="selectedTeams"
track-by="id"
label="name"
:placeholder="$t('AUTOMATION.ACTION.TEAM_DROPDOWN_PLACEHOLDER')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="teams"
:allow-empty="false"
@update:model-value="updateValue"
/>
<textarea
v-model="message"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
/>
</div>
<div class="flex flex-col gap-2">
<MultiSelect
v-model="selectedTeams"
:options="teams"
:dropdown-max-height="dropdownMaxHeight"
@update:model-value="updateValue"
/>
<textarea
v-model="message"
class="mb-0 !text-sm"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
/>
</div>
</template>
<style scoped>
.multiselect {
margin: 0.25rem 0;
}
textarea {
margin-bottom: 0;
}
</style>
@@ -79,7 +79,7 @@ input[type='file'] {
@apply hidden;
}
.input-wrapper {
@apply flex h-9 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-sm border border-dashed border-n-strong;
@apply flex h-8 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-lg border border-dashed border-n-strong;
}
.success-icon {
@apply text-n-teal-9 mr-2;
@@ -1,305 +0,0 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'FilterInput',
components: {
NextButton,
},
props: {
modelValue: {
type: Object,
default: () => {},
},
filterAttributes: {
type: Array,
default: () => [],
},
inputType: {
type: String,
default: 'plain_text',
},
operators: {
type: Array,
default: () => [],
},
dropdownValues: {
type: Array,
default: () => [],
},
showQueryOperator: {
type: Boolean,
default: false,
},
showUserInput: {
type: Boolean,
default: true,
},
groupedFilters: {
type: Boolean,
default: false,
},
filterGroups: {
type: Array,
default: () => [],
},
customAttributeType: {
type: String,
default: '',
},
errorMessage: {
type: String,
default: '',
},
},
emits: ['update:modelValue', 'removeFilter', 'resetFilter'],
computed: {
attributeKey: {
get() {
if (!this.modelValue) return null;
return this.modelValue.attribute_key;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, attribute_key: value });
},
},
filterOperator: {
get() {
if (!this.modelValue) return null;
return this.modelValue.filter_operator;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, filter_operator: value });
},
},
values: {
get() {
if (!this.modelValue) return null;
return this.modelValue.values;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, values: value });
},
},
query_operator: {
get() {
if (!this.modelValue) return null;
return this.modelValue.query_operator;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, query_operator: value });
},
},
custom_attribute_type: {
get() {
if (!this.customAttributeType) return '';
return this.customAttributeType;
},
set() {
const payload = this.modelValue || {};
this.$emit('update:modelValue', {
...payload,
custom_attribute_type: this.customAttributeType,
});
},
},
},
watch: {
customAttributeType: {
handler(value) {
if (
value === 'conversation_attribute' ||
value === 'contact_attribute'
) {
// eslint-disable-next-line vue/no-mutating-props
this.modelValue.custom_attribute_type = this.customAttributeType;
// eslint-disable-next-line vue/no-mutating-props
} else this.modelValue.custom_attribute_type = '';
},
immediate: true,
},
},
methods: {
removeFilter() {
this.$emit('removeFilter');
},
resetFilter() {
this.$emit('resetFilter');
},
getInputErrorClass(errorMessage) {
return errorMessage
? 'bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5'
: 'bg-n-background border-n-weak dark:border-n-weak';
},
},
};
</script>
<!-- eslint-disable vue/no-mutating-props -->
<template>
<div>
<div
class="p-2 border border-solid rounded-lg"
:class="getInputErrorClass(errorMessage)"
>
<div class="flex gap-1">
<select
v-if="groupedFilters"
v-model="attributeKey"
class="max-w-[30%] mb-0 mr-1"
@change="resetFilter()"
>
<optgroup
v-for="(group, i) in filterGroups"
:key="i"
:label="group.name"
>
<option
v-for="attribute in group.attributes"
:key="attribute.key"
:value="attribute.key"
:selected="true"
>
{{ attribute.name }}
</option>
</optgroup>
</select>
<select
v-else
v-model="attributeKey"
class="max-w-[30%] mb-0 mr-1"
@change="resetFilter()"
>
<option
v-for="attribute in filterAttributes"
:key="attribute.key"
:value="attribute.key"
:disabled="attribute.disabled"
>
{{ attribute.name }}
</option>
</select>
<select v-model="filterOperator" class="max-w-[20%] mb-0 mr-1">
<option
v-for="(operator, o) in operators"
:key="o"
:value="operator.value"
>
{{ $t(`FILTER.OPERATOR_LABELS.${operator.value}`) }}
</option>
</select>
<div v-if="showUserInput" class="flex-grow mr-1 filter__answer--wrap">
<div
v-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="values"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'search_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="values"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
<input
v-model="values"
type="date"
:editable="false"
class="!mb-0 datepicker"
/>
</div>
<input
v-else
v-model="values"
type="text"
class="!mb-0"
:placeholder="$t('FILTER.INPUT_PLACEHOLDER')"
/>
</div>
<NextButton
icon="i-lucide-x"
slate
ghost
class="flex-shrink-0"
@click="removeFilter"
/>
</div>
<p v-if="errorMessage" class="filter-error">
{{ errorMessage }}
</p>
</div>
<div
v-if="showQueryOperator"
class="flex items-center justify-center relative my-2.5 mx-0"
>
<hr class="absolute w-full border-b border-solid border-n-weak" />
<select
v-model="query_operator"
class="relative w-auto mb-0 bg-n-background text-n-slate-12 border-n-weak"
>
<option value="and">
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.AND') }}
</option>
<option value="or">
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.OR') }}
</option>
</select>
</div>
</div>
</template>
<style lang="scss" scoped>
.filter__answer--wrap {
input {
@apply bg-n-background mb-0 text-n-slate-12 border-n-weak;
}
}
.filter-error {
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
}
.multiselect {
@apply mb-0;
}
</style>
@@ -10,12 +10,9 @@ defineProps({
</script>
<template>
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
<ChannelIcon
:inbox="inbox"
class="size-3 ltr:mr-1 rtl:ml-1 flex-shrink-0"
/>
<span class="truncate">
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-label-small text-n-slate-11">
{{ inbox.name }}
</span>
</div>
@@ -11,7 +11,7 @@ defineProps({
<h6
class="flex items-center gap-3 text-base text-center w-100 text-n-slate-11"
>
<span class="text-base font-medium text-n-slate-12">
<span class="text-body-main !text-base text-n-slate-12">
{{ message }}
</span>
<Spinner class="text-n-brand" />
@@ -15,7 +15,7 @@ export default {
<template>
<div class="border-b border-solid border-n-weak/60">
<div class="max-w-6xl w-full mx-auto pt-4 pb-0 px-8">
<div class="max-w-7xl w-full mx-auto pt-4 pb-0 px-6">
<h2 class="text-2xl text-n-slate-12 mb-1 font-medium">
{{ headerTitle }}
</h2>
@@ -107,7 +107,8 @@ function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
// Skip if IME composition is active (CJK character confirmation)
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
@@ -10,11 +10,23 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
defineProps({
const props = defineProps({
hasSelection: {
type: Boolean,
default: false,
},
isEditorMenuPopover: {
type: Boolean,
default: false,
},
editorContent: {
type: String,
default: undefined,
},
conversationId: {
type: Number,
default: null,
},
});
const emit = defineEmits(['executeCopilotAction']);
@@ -25,6 +37,13 @@ const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// When editorContent prop is passed, use it exclusively (even if empty)
// This ensures each editor instance shows menu items based on its own content
// Falls back to global draftMessage only when editorContent is not provided
const effectiveContent = computed(() =>
props.editorContent !== undefined ? props.editorContent : draftMessage.value
);
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
@@ -42,8 +61,9 @@ const menuItems = computed(() => {
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
props.conversationId &&
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value
effectiveContent.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
@@ -52,7 +72,7 @@ const menuItems = computed(() => {
});
}
if (draftMessage.value) {
if (effectiveContent.value) {
items.push(
{
label: t(
@@ -105,7 +125,7 @@ const menuItems = computed(() => {
const generalMenuItems = computed(() => {
const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
if (props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion',
@@ -113,7 +133,10 @@ const generalMenuItems = computed(() => {
});
}
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
if (
props.conversationId &&
(replyMode.value === REPLY_EDITOR_MODES.NOTE || true)
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
@@ -176,8 +199,8 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }"
:style="hasSelection ? selectionMenuStyle : {}"
:class="{ 'selection-menu': hasSelection && isEditorMenuPopover }"
:style="hasSelection && isEditorMenuPopover ? selectionMenuStyle : {}"
>
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div
@@ -28,7 +28,10 @@ import { useAlert } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
@@ -87,6 +90,7 @@ const props = defineProps({
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
forceSignature: { type: Boolean, default: false },
conversationId: { type: Number, default: null },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
@@ -199,6 +203,11 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
const isEditorMenuPopover = computed(
() =>
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
);
const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection;
@@ -208,7 +217,7 @@ const handleCopilotAction = actionKey => {
emit('executeCopilotAction', 'improve', selectedText);
}
} else {
emit('executeCopilotAction', actionKey);
emit('executeCopilotAction', actionKey, props.modelValue);
}
showSelectionMenu.value = false;
@@ -401,7 +410,14 @@ function openFileBrowser() {
}
function handleCopilotClick() {
showSelectionMenu.value = !showSelectionMenu.value;
const isOpening = !showSelectionMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'inline',
});
}
showSelectionMenu.value = isOpening;
}
function handleClickOutside(event) {
@@ -478,6 +494,7 @@ function setToolbarPosition() {
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
if (!isEditorMenuPopover.value) return;
const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
@@ -823,6 +840,8 @@ onMounted(async () => {
}
});
defineExpose({ focusEditorInputField });
// BUS Event to insert text or markdown into the editor at the
// current cursor position.
// Components using this
@@ -831,7 +850,13 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
</script>
<template>
<div ref="editorRoot" class="relative w-full">
<div
ref="editorRoot"
class="relative w-full"
:class="{
'opacity-50 cursor-not-allowed pointer-events-none': disabled,
}"
>
<TagAgents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@@ -861,8 +886,12 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:is-editor-menu-popover="isEditorMenuPopover"
:editor-content="modelValue"
:conversation-id="conversationId"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
class="copilot-editor-menu"
@execute-copilot-action="handleCopilotAction"
/>
<input
@@ -967,11 +996,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
}
.ProseMirror-prompt-backdrop::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px];
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
@apply bg-n-alpha-3 border border-n-strong p-6 shadow-xl rounded-xl w-96 !important;
h5 {
@apply text-n-slate-12 mb-1.5;
@apply text-n-slate-12 mb-3;
}
.ProseMirror-prompt-buttons {
@@ -1025,6 +1058,17 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
// Default copilot menu position (non-popover editors like components-next/Editor)
// When popover-prosemirror-menu is NOT on the wrapper, anchor below the menubar
:not(.popover-prosemirror-menu) > .copilot-editor-menu {
top: 1.5rem !important;
[dir='rtl'] & {
left: auto !important;
right: 0 !important;
}
}
// Float editor menu
.popover-prosemirror-menu {
position: relative;
@@ -326,26 +326,4 @@ export default {
max-height: 7.5rem;
overflow: auto;
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply text-n-slate-12 mb-1.5;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
}
</style>
@@ -128,6 +128,10 @@ export default {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
},
emits: [
'replaceText',
@@ -136,7 +140,7 @@ export default {
'selectContentTemplate',
'toggleQuotedReply',
],
setup() {
setup(props) {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
@@ -145,6 +149,9 @@ export default {
const keyboardEvents = {
'$mod+Alt+KeyA': {
action: () => {
// Skip if editor is disabled (e.g., WhatsApp 24-hour window expired)
if (props.isEditorDisabled) return;
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
@@ -152,7 +159,7 @@ export default {
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
uploadTriggerButton.click();
if (uploadTriggerButton) uploadTriggerButton.click();
},
allowOnFocusedInput: true,
},
@@ -183,10 +190,12 @@ export default {
};
},
showAttachButton() {
if (this.isEditorDisabled) return false;
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
if (this.isALineChannel) {
if (this.isEditorDisabled) return false;
if (this.isALineChannel || this.isATiktokChannel) {
return false;
}
// Disable audio recorder for safari browser as recording is not supported
@@ -203,6 +212,7 @@ export default {
);
},
showAudioPlayStopButton() {
if (this.isEditorDisabled) return false;
return this.showAudioRecorder && this.isRecordingAudio;
},
isInstagramDM() {
@@ -242,6 +252,7 @@ export default {
}
},
showMessageSignatureButton() {
if (this.isEditorDisabled) return false;
return !this.isOnPrivateNote;
},
sendWithSignature() {
@@ -287,6 +298,7 @@ export default {
<div class="left-wrap">
<NextButton
v-if="allowEmoji"
v-if="!isEditorDisabled"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="i-ph-smiley-sticker"
slate
@@ -296,6 +308,7 @@ export default {
/>
<FileUpload
v-if="allowFileUpload"
v-if="showAttachButton"
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
@@ -374,11 +387,12 @@ export default {
sm
@click="$emit('selectContentTemplate')"
/>
<VideoCallButton
<VideoCallButtonnPrivateNote
v-if="
allowVideoCall &&
(isAWebWidgetInbox || isAPIInbox) &&
!isOnPrivateNote
!isOnPrivateNote &&
!isEditorDisabled
"
:conversation-id="conversationId"
/>
@@ -2,8 +2,10 @@
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
@@ -35,6 +37,14 @@ export default {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
default: null,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -43,6 +53,10 @@ export default {
type: Number,
default: () => 0,
},
editorContent: {
type: String,
default: undefined,
},
},
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
@@ -67,13 +81,20 @@ export default {
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey);
const handleCopilotAction = (actionKey, data) => {
emit('executeCopilotAction', actionKey, data || props.editorContent);
showCopilotMenu.value = false;
};
const toggleCopilotMenu = () => {
showCopilotMenu.value = !showCopilotMenu.value;
const isOpening = !showCopilotMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'top_panel',
});
}
showCopilotMenu.value = isOpening;
};
const handleClickOutside = () => {
@@ -148,7 +169,7 @@ export default {
<div class="relative">
<NextButton
ghost
:disabled="disabled"
:disabled="disabled || isEditorDisabled"
:class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
@@ -161,6 +182,8 @@ export default {
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
:editor-content="editorContent"
:conversation-id="conversationId"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>
@@ -86,6 +86,10 @@ const chatSortOptions = computed(() => [
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'),
value: 'priority_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc_created_at_asc.TEXT'),
value: 'priority_desc_created_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'),
value: 'waiting_since_asc',
@@ -100,6 +100,9 @@ export default {
v-if="currentChat.id"
:chat="currentChat"
:show-back-button="isOnExpandedLayout && !isInboxView"
:class="{
'border-b border-b-n-weak !pt-2': !dashboardApps.length,
}"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
@@ -34,6 +34,7 @@ const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
@@ -49,10 +50,21 @@ const store = useStore();
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
y: null,
});
const contextMenu = ref({ x: null, y: null });
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
// This prevents context menu/hover state from leaking to a different conversation
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
const resetState = () => {
if (showContextMenu.value) {
emit('contextMenuToggle', false);
}
hovered.value = false;
showContextMenu.value = false;
contextMenu.value = { x: null, y: null };
};
watch(() => props.chat.id, resetState);
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
@@ -203,7 +215,10 @@ const onAssignAgent = agent => {
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
closeContextMenu();
};
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
};
const onAssignTeam = team => {
@@ -348,6 +363,7 @@ const deleteConversation = () => {
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
:conversation-id="chat.id"
/>
</span>
<span
@@ -379,11 +395,13 @@ const deleteConversation = () => {
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@@ -78,7 +78,7 @@ const onSend = () => {
<div
v-else-if="isGeneratingContent"
key="loading-state"
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" />
@@ -85,7 +85,12 @@ export default {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'));
this.onCancel();
} catch (error) {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
const status = error?.response?.status;
if (status === 402) {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_PAYMENT_REQUIRED'));
} else {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
}
} finally {
this.isSubmitting = false;
}
@@ -36,7 +36,6 @@ export default {
v-tooltip="{
content: tooltipText,
delay: { show: 1500, hide: 0 },
hideOnClick: true,
}"
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
:class="{
@@ -41,7 +41,10 @@ import {
truncatePreviewText,
appendQuotedTextToMessage,
} from 'dashboard/helper/quotedEmailHelper';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import {
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
@@ -96,6 +99,7 @@ export default {
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
const messageEditor = useTemplateRef('messageEditor');
const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']);
@@ -106,6 +110,7 @@ export default {
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
messageEditor,
copilot,
shortcutKey,
};
@@ -136,6 +141,7 @@ export default {
newConversationModalActive: false,
showArticleSearchPopover: false,
hasRecordedAudio: false,
copilotAcceptedMessages: {},
};
},
computed: {
@@ -158,7 +164,8 @@ export default {
this.inReplyTo?.id &&
!this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
!this.is360DialogWhatsAppChannel
!this.is360DialogWhatsAppChannel &&
!this.copilot.isActive.value
);
},
showWhatsappTemplates() {
@@ -195,6 +202,15 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
messagePlaceHolder() {
if (this.isEditorDisabled) {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
}
if (this.isAPIInbox) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_API');
}
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
}
return this.isPrivate
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
: this.$t('CONVERSATION.FOOTER.MSG_INPUT');
@@ -206,6 +222,7 @@ export default {
return this.maxLength - this.message.length;
},
isReplyButtonDisabled() {
if (this.isEditorDisabled) return true;
if (this.isATwitterInbox) return true;
if (this.hasAttachments || this.hasRecordedAudio) return false;
@@ -269,7 +286,8 @@ export default {
this.isASmsInbox ||
this.isATelegramChannel ||
this.isALineChannel ||
this.isAnInstagramChannel
this.isAnInstagramChannel ||
this.isATiktokChannel
);
},
replyButtonLabel() {
@@ -414,6 +432,13 @@ export default {
isDefaultEditorMode() {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
isEditorDisabled() {
return (
(this.isAWhatsAppChannel || this.isAPIInbox) &&
!this.isOnPrivateNote &&
!this.currentChat.can_reply
);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -484,7 +509,7 @@ export default {
);
this.fetchAndSetReplyTo();
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
// A hacky fix to solve the drag and drop
// Is showing on top of new conversation modal drag and drop
@@ -499,7 +524,7 @@ export default {
unmounted() {
document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents);
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
emitter.off(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
@@ -508,6 +533,24 @@ export default {
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
},
methods: {
getDraftKey(
conversationId = this.conversationIdByRoute,
replyType = this.replyType
) {
return `draft-${conversationId}-${replyType}`;
},
getCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
return this.copilotAcceptedMessages[key] || '';
},
setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
this.copilotAcceptedMessages[key] = trimContent(message || '');
},
clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
delete this.copilotAcceptedMessages[key];
},
handleInsert(article) {
const { url, title } = article;
// Removing empty lines from the title
@@ -559,7 +602,7 @@ export default {
},
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const key = `draft-${conversationId}-${replyType}`;
const key = this.getDraftKey(conversationId, replyType);
const draftToSave = trimContent(this.message || '');
this.$store.dispatch('draftMessages/set', {
@@ -574,7 +617,7 @@ export default {
},
getFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
const key = this.getDraftKey();
const messageFromStore =
this.$store.getters['draftMessages/get'](key) || '';
@@ -597,7 +640,7 @@ export default {
},
removeFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
const key = this.getDraftKey();
this.$store.dispatch('draftMessages/delete', { key });
}
},
@@ -655,6 +698,9 @@ export default {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return;
// Don't handle paste if editor is disabled
if (this.isEditorDisabled) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
@@ -708,19 +754,29 @@ export default {
return;
}
if (!this.showMentions) {
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
const isOnWhatsApp =
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
this.is360DialogWhatsAppChannel;
// When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages.
// Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
// Instagram and TikTok do not support sending text and attachments in the same message.
// For Instagram, combining them causes duplicate messages due to separate echo events per component.
// For TikTok, the API rejects messages that mix text and media.
// To handle both cases, text and attachments are always sent as separate messages.
const isOnInstagram = this.isAnInstagramChannel;
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
this.sendMessageAsMultipleMessages(this.message);
const isOnTiktok = this.isATiktokChannel;
if ((isOnWhatsApp || isOnInstagram || isOnTiktok) && !this.isPrivate) {
this.sendMessageAsMultipleMessages(
this.message,
copilotAcceptedMessage
);
} else {
const messagePayload = this.getMessagePayload(this.message);
this.sendMessage(messagePayload);
this.sendMessage(
messagePayload,
this.message,
copilotAcceptedMessage
);
}
if (!this.isPrivate) {
@@ -732,13 +788,53 @@ export default {
this.$emit('update:popOutReplyBox', false);
}
},
sendMessageAsMultipleMessages(message) {
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
const messages = this.getMultipleMessagesPayload(message);
messages.forEach(messagePayload => {
this.sendMessage(messagePayload);
this.sendMessage(
messagePayload,
messagePayload.message || '',
copilotAcceptedMessage
);
});
},
sendMessageAnalyticsData(isPrivate) {
sendMessageAnalyticsData(
isPrivate,
{ editorMessage = '', copilotAcceptedMessage = '' } = {}
) {
const normalizeForComparison = message => {
let normalizedMessage = message || '';
if (this.sendWithSignature && this.messageSignature && !isPrivate) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
normalizedMessage = removeSignature(
normalizedMessage,
this.messageSignature,
effectiveChannelType
);
}
return trimContent(normalizedMessage);
};
const normalizedAcceptedMessage = normalizeForComparison(
copilotAcceptedMessage
);
const normalizedEditorMessage = normalizeForComparison(editorMessage);
if (normalizedAcceptedMessage && normalizedEditorMessage) {
useTrack(CAPTAIN_EVENTS.AI_ASSISTED_MESSAGE_SENT, {
conversationId: this.conversationIdByRoute,
channelType: this.channelType,
editedBeforeSend:
normalizedAcceptedMessage !== normalizedEditorMessage,
isPrivate,
});
}
// Analytics data for message signature is enabled or not in channels
return isPrivate
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
@@ -772,7 +868,11 @@ export default {
this.confirmOnSendReply();
}
},
async sendMessage(messagePayload) {
async sendMessage(
messagePayload,
editorMessage = '',
copilotAcceptedMessage = ''
) {
try {
await this.$store.dispatch(
'createPendingMessageAndSend',
@@ -781,7 +881,10 @@ export default {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft();
this.sendMessageAnalyticsData(messagePayload.private);
this.sendMessageAnalyticsData(messagePayload.private, {
editorMessage,
copilotAcceptedMessage,
});
} catch (error) {
const errorMessage =
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
@@ -855,6 +958,7 @@ export default {
},
clearMessage() {
this.message = '';
this.clearCopilotAcceptedMessage();
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
const effectiveChannelType = getEffectiveChannelType(
@@ -975,7 +1079,8 @@ export default {
const multipleMessagePayload = [];
if (this.attachedFiles && this.attachedFiles.length) {
let caption = this.isAnInstagramChannel ? '' : message;
let caption =
this.isAnInstagramChannel || this.isATiktokChannel ? '' : message;
this.attachedFiles.forEach(attachment => {
const attachedFile = this.globalConfig.directUploadsEnabled
? attachment.blobSignedId
@@ -997,11 +1102,13 @@ export default {
const hasNoAttachments =
!this.attachedFiles || !this.attachedFiles.length;
// For Instagram, we need a separate text message
// For WhatsApp, we only need a text message if there are no attachments
// For Instagram and TikTok, text must always be sent as a separate message (no captions on attachments).
// For WhatsApp, we only need a text message if there are no attachments.
if (
(this.isAnInstagramChannel && this.message) ||
(!this.isAnInstagramChannel && hasNoAttachments)
((this.isAnInstagramChannel || this.isATiktokChannel) &&
this.message) ||
(!(this.isAnInstagramChannel || this.isATiktokChannel) &&
hasNoAttachments)
) {
let messagePayload = {
conversationId: this.currentChat.id,
@@ -1086,6 +1193,15 @@ export default {
return false;
});
},
onReplyToMessage() {
this.fetchAndSetReplyTo();
if (this.inReplyTo) {
this.$nextTick(() => {
const pos = this.isSignatureEnabledForInbox ? 'start' : 'end';
this.messageEditor?.focusEditorInputField(pos);
});
}
},
resetReplyToMessage() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
@@ -1119,7 +1235,9 @@ export default {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
this.message = this.copilot.accept();
const acceptedMessage = this.copilot.accept();
this.message = acceptedMessage;
this.setCopilotAcceptedMessage(acceptedMessage);
},
},
};
@@ -1130,13 +1248,16 @@ export default {
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
<ReplyTopPanel
:mode="replyType"
:conversation-id="conversationId"
:is-reply-restricted="isReplyRestricted"
:disabled="
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
showAudioRecorderEditor
"
:is-editor-disabled="isEditorDisabled"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:editor-content="message"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@@ -1203,13 +1324,16 @@ export default {
/>
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
ref="messageEditor"
v-model="message"
:conversation-id="conversationId"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
:disabled="isEditorDisabled"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
@@ -1285,6 +1409,7 @@ export default {
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:is-editor-disabled="isEditorDisabled"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
@@ -1352,7 +1477,7 @@ export default {
}
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
@apply relative py-0 px-3 -mt-px;
}
.emoji-dialog {
@@ -31,7 +31,10 @@ const assignedAgent = computed({
},
set(agent) {
const agentId = agent ? agent.id : null;
store.dispatch('setCurrentChatAssignee', agent);
store.dispatch('setCurrentChatAssignee', {
conversationId: currentChat.value?.id,
assignee: agent,
});
store.dispatch('assignAgent', {
conversationId: currentChat.value?.id,
agentId,
@@ -14,7 +14,7 @@ const emit = defineEmits(['dismiss']);
<template>
<div
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5"
>
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate">
@@ -32,6 +32,7 @@ const emit = defineEmits(['dismiss']);
xs
slate
icon="i-lucide-x"
class="flex-shrink-0"
@click.stop="emit('dismiss')"
/>
</div>
@@ -53,6 +53,10 @@ export default {
type: String,
default: null,
},
conversationLabels: {
type: Array,
default: () => [],
},
conversationUrl: {
type: String,
default: '',
@@ -70,6 +74,7 @@ export default {
'assignAgent',
'assignTeam',
'assignLabel',
'removeLabel',
'deleteConversation',
'close',
],
@@ -334,8 +339,16 @@ export default {
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
@@ -1,5 +1,6 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
option: {
@@ -22,7 +23,9 @@ defineProps({
class="flex-shrink-0"
/>
<span
v-if="variant === 'label' && option.color"
v-if="
(variant === 'label' || variant === 'label-assigned') && option.color
"
class="label-pill flex-shrink-0"
:style="{ backgroundColor: option.color }"
/>
@@ -37,6 +40,11 @@ defineProps({
<p class="menu-label truncate min-w-0 flex-1">
{{ option.label }}
</p>
<Icon
v-if="variant === 'label-assigned'"
icon="i-lucide-check"
class="flex-shrink-0 size-3.5 mr-1"
/>
</div>
</template>
@@ -2,6 +2,7 @@
// components
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useBranding } from 'shared/composables/useBranding';
// composables
import { useCaptain } from 'dashboard/composables/useCaptain';
@@ -34,8 +35,9 @@ export default {
},
setup() {
const { captainTasksEnabled } = useCaptain();
const { replaceInstallationName } = useBranding();
return { captainTasksEnabled };
return { captainTasksEnabled, replaceInstallationName };
},
data() {
return {
@@ -228,7 +230,9 @@ export default {
<div class="sender--info has-tooltip" data-original-title="null">
<Avatar
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
content: replaceInstallationName(
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
@@ -100,6 +100,8 @@ export default {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else if (typeof snoozeType === 'number') {
this.updateConversations('snoozed', snoozeType);
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
@@ -61,7 +61,7 @@ export default {
<template>
<label class="input-container">
<span v-if="label">{{ label }}</span>
<span v-if="label" class="text-heading-3">{{ label }}</span>
<input
:value="modelValue"
:type="type"
@@ -71,7 +71,7 @@ export default {
@input="onChange"
@blur="onBlur"
/>
<p v-if="helpText" class="help-text">{{ helpText }}</p>
<p v-if="helpText" class="help-text text-label-small">{{ helpText }}</p>
<span v-if="error" class="message">
{{ error }}
</span>
@@ -81,7 +81,7 @@ export default {
<style scoped lang="scss">
.help-text {
@apply mt-0.5 text-xs not-italic text-n-slate-11;
@apply mt-0.5 not-italic text-n-slate-11;
}
.message {