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

This commit is contained in:
Shivam Mishra
2025-10-15 16:13:15 +05:30
2910 changed files with 158130 additions and 24601 deletions
@@ -15,6 +15,7 @@ import CannedResponse from '../conversation/CannedResponse.vue';
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
@@ -72,6 +73,7 @@ const props = defineProps({
updateSelectionWith: { type: String, default: '' },
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
@@ -89,6 +91,7 @@ const emit = defineEmits([
'toggleUserMention',
'toggleCannedMenu',
'toggleVariablesMenu',
'toggleToolsMenu',
'clearSelection',
'blur',
'focus',
@@ -140,7 +143,9 @@ const showUserMentions = ref(false);
const showCannedMenu = ref(false);
const showVariables = ref(false);
const showEmojiMenu = ref(false);
const showToolsMenu = ref(false);
const mentionSearchKey = ref('');
const toolSearchKey = ref('');
const cannedSearchTerm = ref('');
const variableSearchTerm = ref('');
const emojiSearchTerm = ref('');
@@ -220,11 +225,17 @@ const plugins = computed(() => {
}
return [
createSuggestionPlugin({
trigger: '@',
showMenu: showToolsMenu,
searchTerm: toolSearchKey,
isAllowed: () => props.enableCaptainTools,
}),
createSuggestionPlugin({
trigger: '@',
showMenu: showUserMentions,
searchTerm: mentionSearchKey,
isAllowed: () => props.isPrivate,
isAllowed: () => props.isPrivate || !props.enableCaptainTools,
}),
createSuggestionPlugin({
trigger: '/',
@@ -266,6 +277,9 @@ watch(showCannedMenu, updatedValue => {
watch(showVariables, updatedValue => {
emit('toggleVariablesMenu', !props.isPrivate && updatedValue);
});
watch(showToolsMenu, updatedValue => {
emit('toggleToolsMenu', props.enableCaptainTools && updatedValue);
});
function focusEditorInputField(pos = 'end') {
const { tr } = editorView.state;
@@ -546,6 +560,7 @@ function insertSpecialContent(type, content) {
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
tool: CONVERSATION_EVENTS.INSERTED_A_TOOL,
};
useTrack(event_map[type]);
@@ -707,6 +722,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="emojiSearchTerm"
@select-emoji="emoji => insertSpecialContent('emoji', emoji)"
/>
<TagTools
v-if="showToolsMenu"
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
<input
ref="imageUpload"
type="file"
@@ -717,7 +737,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
<div ref="editor" />
<div
v-show="isImageNodeSelected && showImageResizeToolbar"
class="absolute shadow-md rounded-[4px] flex gap-1 py-1 px-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50"
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
:style="{
top: toolbarPosition.top,
left: toolbarPosition.left,
@@ -726,7 +746,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
<button
v-for="size in sizes"
:key="size.name"
class="text-xs font-medium rounded-[4px] border border-solid border-slate-200 dark:border-slate-600 px-1.5 py-0.5 hover:bg-slate-100 dark:hover:bg-slate-800"
class="text-xs font-medium rounded-[4px] outline outline-1 outline-n-strong px-1.5 py-0.5 hover:bg-n-slate-5"
@click="setURLWithQueryAndImageSize(size)"
>
{{ size.name }}
@@ -747,16 +767,16 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.ProseMirror-menubar {
min-height: var(--space-two) !important;
min-height: 1.25rem !important;
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
.ProseMirror-menu-active {
@apply bg-slate-75 dark:bg-slate-800;
@apply bg-n-slate-5 dark:bg-n-solid-3;
}
}
> .ProseMirror {
@apply p-0 break-words text-slate-800 dark:text-slate-100;
@apply p-0 break-words text-n-slate-12;
h1,
h2,
@@ -765,14 +785,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
h5,
h6,
p {
@apply text-slate-800 dark:text-slate-100;
@apply text-n-slate-12;
}
blockquote {
@apply border-slate-400 dark:border-slate-500;
@apply border-n-slate-7;
p {
@apply text-slate-600 dark:text-slate-400;
@apply text-n-slate-11;
}
}
@@ -824,6 +844,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
}
.prosemirror-tools-node {
@apply font-medium text-n-slate-12 py-0;
}
.editor-wrap {
@apply mb-4;
}
@@ -837,6 +861,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.editor-warning__message {
@apply text-red-400 dark:text-red-400 font-normal text-sm pt-1 pb-0 px-0;
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
</style>
@@ -8,6 +8,10 @@ const props = defineProps({
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['toggleMode']);
@@ -20,9 +24,12 @@ const privateModeSize = useElementSize(wootEditorPrivateMode);
/**
* Computed boolean indicating if the editor is in private note mode
* When disabled, always show NOTE mode regardless of actual mode prop
* @type {ComputedRef<boolean>}
*/
const isPrivate = computed(() => props.mode === REPLY_EDITOR_MODES.NOTE);
const isPrivate = computed(() => {
return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
* Computes the width of the sliding background chip in pixels
@@ -53,6 +60,10 @@ const translateValue = computed(() => {
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
:disabled="disabled"
:class="{
'cursor-not-allowed': disabled,
}"
@click="$emit('toggleMode')"
>
<div
@@ -68,7 +79,10 @@ const translateValue = computed(() => {
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</div>
<div
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] transition-all duration-300 ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="{
'transition-all duration-300': !disabled,
}"
:style="{
'--chip-width': width,
'--translate-x': translateValue,
@@ -6,15 +6,11 @@ import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE,
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
} from 'shared/constants/messages';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { REPLY_EDITOR_MODES } from './constants';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -23,9 +19,9 @@ export default {
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [inboxMixin],
props: {
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
isNote: {
type: Boolean,
default: false,
},
onSend: {
type: Function,
@@ -94,7 +90,11 @@ export default {
type: Boolean,
default: true,
},
hasWhatsappTemplates: {
enableWhatsAppTemplates: {
type: Boolean,
default: false,
},
enableContentTemplates: {
type: Boolean,
default: false,
},
@@ -124,12 +124,22 @@ export default {
allowVideoCall: { type: Boolean, default: false },
allowFileUpload: { type: Boolean, default: false },
allowAudioRecorder: { type: Boolean, default: false },
showQuotedReplyToggle: {
type: Boolean,
default: false,
},
quotedReplyEnabled: {
type: Boolean,
default: false,
},
},
emits: [
'replaceText',
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
],
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
@@ -161,15 +171,17 @@ export default {
uploadRef,
};
},
data() {
return {
ALLOWED_FILE_TYPES,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
uiFlags: 'integrations/getUIFlags',
}),
isNote() {
return this.mode === REPLY_EDITOR_MODES.NOTE;
},
wrapClass() {
return {
'is-note-mode': this.isNote,
@@ -202,17 +214,21 @@ export default {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
}
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
if (this.isAnInstagramChannel || this.isInstagramDM) {
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
// Use default file types for private notes
if (this.isOnPrivateNote) {
return this.ALLOWED_FILE_TYPES;
}
return ALLOWED_FILE_TYPES;
let channelType = this.channelType || this.inbox?.channel_type;
if (this.isAnInstagramChannel || this.isInstagramDM) {
channelType = INBOX_TYPES.INSTAGRAM;
}
return getAllowedFileTypesByChannel({
channelType,
medium: this.inbox?.medium,
});
},
enableDragAndDrop() {
return !this.newConversationModalActive;
@@ -248,6 +264,11 @@ export default {
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
quotedReplyToggleTooltip() {
return this.quotedReplyEnabled
? this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.DISABLE_TOOLTIP')
: this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.ENABLE_TOOLTIP');
},
},
mounted() {
ActiveStorage.start();
@@ -345,7 +366,17 @@ export default {
@click="toggleMessageSignature"
/>
<NextButton
v-if="hasWhatsappTemplates"
v-if="showQuotedReplyToggle"
v-tooltip.top-end="quotedReplyToggleTooltip"
icon="i-ph-quotes"
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
color="slate"
sm
:aria-pressed="quotedReplyEnabled"
@click="$emit('toggleQuotedReply')"
/>
<NextButton
v-if="enableWhatsAppTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp-logo"
slate
@@ -353,6 +384,15 @@ export default {
sm
@click="$emit('selectWhatsappTemplate')"
/>
<NextButton
v-if="enableContentTemplates"
v-tooltip.top-end="'Content Templates'"
icon="i-ph-whatsapp-logo"
slate
faded
sm
@click="$emit('selectContentTemplate')"
/>
<VideoCallButton
v-if="
allowVideoCall &&
@@ -371,10 +411,10 @@ export default {
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-slate-900 dark:text-slate-50 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
>
<fluent-icon icon="cloud-backup" size="40" />
<h4 class="text-2xl break-words text-slate-900 dark:text-slate-50">
<h4 class="text-2xl break-words text-n-slate-12">
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
</h4>
</div>
@@ -418,7 +458,7 @@ export default {
}
&:hover button {
@apply dark:bg-slate-800 bg-slate-100;
@apply enabled:bg-n-slate-9/20;
}
}
</style>
@@ -19,6 +19,10 @@ export default {
type: Boolean,
default: false,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -34,6 +38,7 @@ export default {
emit('setReplyMode', mode);
};
const handleReplyClick = () => {
if (props.isReplyRestricted) return;
setReplyMode(REPLY_EDITOR_MODES.REPLY);
};
const handleNoteClick = () => {
@@ -77,7 +82,7 @@ export default {
};
},
charLengthClass() {
return this.charactersRemaining < 0 ? 'text-red-600' : 'text-slate-600';
return this.charactersRemaining < 0 ? 'text-n-ruby-9' : 'text-n-slate-11';
},
characterLengthWarning() {
return this.charactersRemaining < 0
@@ -92,6 +97,7 @@ export default {
<div class="flex justify-between h-[3.25rem] gap-2 ms-3">
<EditorModeToggle
:mode="mode"
:disabled="isReplyRestricted"
class="mt-3"
@toggle-mode="handleModeToggle"
/>
@@ -52,13 +52,13 @@ onMounted(() => {
>
<template #default="{ item, selected }">
<span
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 truncate"
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-n-slate-12 group-hover:text-n-brand truncate"
>
{{ item.emoji }}
<p
class="relative mb-0 truncate bottom-px"
:class="{
'text-woot-500 dark:text-woot-500': selected,
'text-n-brand': selected,
'font-normal': !selected,
}"
>