feat: Update conversation messages screen UI (#12975)

This commit is contained in:
Sivin Varghese
2025-12-03 15:48:04 +05:30
committed by GitHub
parent 99f17891d0
commit 8ad1bf3d2b
19 changed files with 1089 additions and 1054 deletions
@@ -33,7 +33,7 @@ const isPrivate = computed(() => {
/**
* Computes the width of the sliding background chip in pixels
* Includes 16px of padding in the calculation
* Includes 24px of padding in the calculation (px-3 = 12px on each side)
* @type {ComputedRef<string>}
*/
const width = computed(() => {
@@ -41,17 +41,17 @@ const width = computed(() => {
? privateModeSize.width.value
: replyModeSize.width.value;
const widthWithPadding = widthToUse + 16;
const widthWithPadding = widthToUse + 24;
return `${widthWithPadding}px`;
});
/**
* Computes the X translation value for the sliding background chip
* Translates by the width of reply mode + padding when in private mode
* Translates by the width of reply mode + padding (24px) when in private mode
* @type {ComputedRef<string>}
*/
const translateValue = computed(() => {
const xTranslate = isPrivate.value ? replyModeSize.width.value + 16 : 0;
const xTranslate = isPrivate.value ? replyModeSize.width.value + 24 : 0;
return `${xTranslate}px`;
});
@@ -59,23 +59,39 @@ 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 active:scale-[0.995] active:duration-75"
class="flex items-center w-auto h-8 transition-all border rounded-lg bg-n-input-background group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75 !p-0"
:disabled="disabled"
:class="{
'cursor-not-allowed': disabled,
}"
@click="$emit('toggleMode')"
>
<div ref="wootEditorReplyMode" class="flex items-center gap-1 px-2 z-20">
<div
ref="wootEditorReplyMode"
class="flex items-center gap-1 px-3 z-20"
:class="{
'text-n-slate-12': !isPrivate,
'text-n-slate-10': isPrivate,
}"
>
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
</div>
<div ref="wootEditorPrivateMode" class="flex items-center gap-1 px-2 z-20">
<div
ref="wootEditorPrivateMode"
class="flex items-center gap-1 px-3 z-20"
:class="{
'text-n-amber-text': isPrivate,
'text-n-slate-10': !isPrivate,
}"
>
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</div>
<div
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="absolute shadow-sm rounded-lg h-8 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] outline outline-1 outline-n-container"
:class="{
'transition-all duration-300': !disabled,
'bg-n-surface-active': !isPrivate,
'bg-n-solid-amber': isPrivate,
}"
:style="{
'--chip-width': width,
@@ -1,300 +1,303 @@
<script>
import { ref } from 'vue';
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useMapGetter } from 'dashboard/composables/store';
import { useInbox } from 'dashboard/composables/useInbox';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'ReplyBottomPanel',
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [inboxMixin],
props: {
isNote: {
type: Boolean,
default: false,
},
onSend: {
type: Function,
default: () => {},
},
sendButtonText: {
type: String,
default: '',
},
recordingAudioDurationText: {
type: String,
default: '00:00',
},
// inbox prop is used in /mixins/inboxMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
inbox: {
type: Object,
default: () => ({}),
},
showFileUpload: {
type: Boolean,
default: false,
},
showAudioRecorder: {
type: Boolean,
default: false,
},
onFileUpload: {
type: Function,
default: () => {},
},
toggleEmojiPicker: {
type: Function,
default: () => {},
},
toggleAudioRecorder: {
type: Function,
default: () => {},
},
toggleAudioRecorderPlayPause: {
type: Function,
default: () => {},
},
isRecordingAudio: {
type: Boolean,
default: false,
},
recordingAudioState: {
type: String,
default: '',
},
isSendDisabled: {
type: Boolean,
default: false,
},
showEditorToggle: {
type: Boolean,
default: false,
},
isOnPrivateNote: {
type: Boolean,
default: false,
},
enableMultipleFileUpload: {
type: Boolean,
default: true,
},
enableWhatsAppTemplates: {
type: Boolean,
default: false,
},
enableContentTemplates: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
required: true,
},
message: {
type: String,
default: '',
},
newConversationModalActive: {
type: Boolean,
default: false,
},
portalSlug: {
type: String,
required: true,
},
conversationType: {
type: String,
default: '',
},
showQuotedReplyToggle: {
type: Boolean,
default: false,
},
quotedReplyEnabled: {
type: Boolean,
default: false,
},
const props = defineProps({
isNote: {
type: Boolean,
default: false,
},
emits: [
'replaceText',
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
],
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
const uploadRef = ref(false);
const keyboardEvents = {
'$mod+Alt+KeyA': {
action: () => {
// 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.
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
uploadTriggerButton.click();
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
setSignatureFlagForInbox,
fetchSignatureFlagFromUISettings,
uploadRef,
};
onSend: {
type: Function,
default: () => {},
},
data() {
return {
ALLOWED_FILE_TYPES,
};
sendButtonText: {
type: String,
default: '',
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
uiFlags: 'integrations/getUIFlags',
}),
wrapClass() {
return {
'is-note-mode': this.isNote,
};
},
showAttachButton() {
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
if (this.isALineChannel) {
return false;
}
// Disable audio recorder for safari browser as recording is not supported
// const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
// navigator.userAgent
// );
recordingAudioDurationText: {
type: String,
default: '00:00',
},
inbox: {
type: Object,
default: () => ({}),
},
showFileUpload: {
type: Boolean,
default: false,
},
showAudioRecorder: {
type: Boolean,
default: false,
},
onFileUpload: {
type: Function,
default: () => {},
},
toggleEmojiPicker: {
type: Function,
default: () => {},
},
toggleAudioRecorder: {
type: Function,
default: () => {},
},
toggleAudioRecorderPlayPause: {
type: Function,
default: () => {},
},
isRecordingAudio: {
type: Boolean,
default: false,
},
recordingAudioState: {
type: String,
default: '',
},
isSendDisabled: {
type: Boolean,
default: false,
},
showEditorToggle: {
type: Boolean,
default: false,
},
isOnPrivateNote: {
type: Boolean,
default: false,
},
enableMultipleFileUpload: {
type: Boolean,
default: true,
},
enableWhatsAppTemplates: {
type: Boolean,
default: false,
},
enableContentTemplates: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
required: true,
},
message: {
type: String,
default: '',
},
newConversationModalActive: {
type: Boolean,
default: false,
},
portalSlug: {
type: String,
required: true,
},
conversationType: {
type: String,
default: '',
},
showQuotedReplyToggle: {
type: Boolean,
default: false,
},
quotedReplyEnabled: {
type: Boolean,
default: false,
},
});
return (
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.VOICE_RECORDER
) && this.showAudioRecorder
// !isSafari
const emit = defineEmits([
'replaceText',
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
]);
const { t } = useI18n();
const uploadRef = ref(false);
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
const keyboardEvents = {
'$mod+Alt+KeyA': {
action: () => {
// 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.
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
uploadTriggerButton.click();
},
showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio;
},
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
// Use default file types for private notes
if (this.isOnPrivateNote) {
return this.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;
},
audioRecorderPlayStopIcon() {
switch (this.recordingAudioState) {
// playing paused recording stopped inactive destroyed
case 'playing':
return 'i-ph-pause';
case 'paused':
return 'i-ph-play';
case 'stopped':
return 'i-ph-play';
default:
return 'i-ph-stop';
}
},
showMessageSignatureButton() {
return !this.isOnPrivateNote;
},
sendWithSignature() {
// channelType is sourced from inboxMixin
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
signatureToggleTooltip() {
return this.sendWithSignature
? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
enableInsertArticleInReply() {
return this.portalSlug;
},
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();
},
methods: {
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
},
replaceText(text) {
this.$emit('replaceText', text);
},
toggleInsertArticle() {
this.$emit('toggleInsertArticle');
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
const uiFlags = useMapGetter('integrations/getUIFlags');
const { isCloudFeatureEnabled } = useAccount();
const {
channelType,
isALineChannel,
isAnInstagramChannel,
isAWebWidgetInbox,
isAPIInbox,
} = useInbox();
const showAttachButton = computed(() => {
return props.showFileUpload || props.isNote;
});
const showAudioRecorderButton = computed(() => {
if (isALineChannel.value) {
return false;
}
// Disable audio recorder for safari browser as recording is not supported
// const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
// navigator.userAgent
// );
return (
isCloudFeatureEnabled(FEATURE_FLAGS.VOICE_RECORDER) &&
props.showAudioRecorder
// !isSafari
);
});
const showAudioPlayStopButton = computed(() => {
return props.showAudioRecorder && props.isRecordingAudio;
});
const isInstagramDM = computed(() => {
return props.conversationType === 'instagram_direct_message';
});
const allowedFileTypes = computed(() => {
// Use default file types for private notes
if (props.isOnPrivateNote) {
return ALLOWED_FILE_TYPES;
}
let channelTypeValue = channelType.value || props.inbox?.channel_type;
if (isAnInstagramChannel.value || isInstagramDM.value) {
channelTypeValue = INBOX_TYPES.INSTAGRAM;
}
return getAllowedFileTypesByChannel({
channelType: channelTypeValue,
medium: props.inbox?.medium,
});
});
const enableDragAndDrop = computed(() => {
return !props.newConversationModalActive;
});
const audioRecorderPlayStopIcon = computed(() => {
switch (props.recordingAudioState) {
// playing paused recording stopped inactive destroyed
case 'playing':
return 'i-ph-pause';
case 'paused':
return 'i-ph-play';
case 'stopped':
return 'i-ph-play';
default:
return 'i-ph-stop';
}
});
const showMessageSignatureButton = computed(() => {
return !props.isOnPrivateNote;
});
const sendWithSignature = computed(() => {
return fetchSignatureFlagFromUISettings(channelType.value);
});
const signatureToggleTooltip = computed(() => {
return sendWithSignature.value
? t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
: t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
});
const enableInsertArticleInReply = computed(() => {
return props.portalSlug;
});
const isFetchingAppIntegrations = computed(() => {
return uiFlags.value.isFetching;
});
const quotedReplyToggleTooltip = computed(() => {
return props.quotedReplyEnabled
? t('CONVERSATION.REPLYBOX.QUOTED_REPLY.DISABLE_TOOLTIP')
: t('CONVERSATION.REPLYBOX.QUOTED_REPLY.ENABLE_TOOLTIP');
});
const toggleMessageSignature = () => {
setSignatureFlagForInbox(channelType.value, !sendWithSignature.value);
};
const replaceText = text => {
emit('replaceText', text);
};
const toggleInsertArticle = () => {
emit('toggleInsertArticle');
};
onMounted(() => {
ActiveStorage.start();
});
</script>
<template>
<div class="flex justify-between p-3" :class="wrapClass">
<div class="left-wrap">
<div
class="flex justify-between py-3 ltr:pl-1.5 ltr:pr-3 rtl:pr-1.5 rtl:pl-3 border-t"
:class="{ 'border-n-weak': !isNote, 'border-n-amber-4': isNote }"
>
<div class="items-center flex gap-1.5">
<NextButton
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
v-tooltip.top-end="t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="i-ph-smiley-sticker"
slate
faded
ghost
sm
@click="toggleEmojiPicker"
/>
<div
v-if="showAttachButton"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<FileUpload
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
v-tooltip.top-end="t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
:size="4096 * 4096"
:accept="allowedFileTypes"
@@ -309,82 +312,118 @@ export default {
>
<NextButton
v-if="showAttachButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
v-tooltip.top-end="t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
icon="i-ph-paperclip"
slate
faded
ghost
sm
/>
</FileUpload>
<div
v-if="showAudioRecorderButton"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<NextButton
v-if="showAudioRecorderButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
v-tooltip.top-end="t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
:icon="!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'"
slate
faded
ghost
sm
@click="toggleAudioRecorder"
/>
<div
v-if="showEditorToggle"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<NextButton
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
v-tooltip.top-end="t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="i-ph-quotes"
slate
faded
ghost
sm
@click="$emit('toggleEditor')"
/>
<div
v-if="showAudioPlayStopButton"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<NextButton
v-if="showAudioPlayStopButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
v-tooltip.top-end="t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
:icon="audioRecorderPlayStopIcon"
slate
faded
ghost
sm
:label="recordingAudioDurationText"
@click="toggleAudioRecorderPlayPause"
/>
<div
v-if="showMessageSignatureButton"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<NextButton
v-if="showMessageSignatureButton"
v-tooltip.top-end="signatureToggleTooltip"
icon="i-ph-signature"
slate
faded
ghost
sm
@click="toggleMessageSignature"
/>
<div
v-if="showQuotedReplyToggle"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<NextButton
v-if="showQuotedReplyToggle"
v-tooltip.top-end="quotedReplyToggleTooltip"
icon="i-ph-quotes"
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
:variant="quotedReplyEnabled ? 'faded' : 'ghost'"
color="slate"
sm
:aria-pressed="quotedReplyEnabled"
@click="$emit('toggleQuotedReply')"
/>
<div
v-if="enableWhatsAppTemplates"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<NextButton
v-if="enableWhatsAppTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
v-tooltip.top-end="t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp-logo"
slate
faded
ghost
sm
@click="$emit('selectWhatsappTemplate')"
/>
<div
v-if="enableContentTemplates"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<NextButton
v-if="enableContentTemplates"
v-tooltip.top-end="'Content Templates'"
icon="i-ph-whatsapp-logo"
slate
faded
ghost
sm
@click="$emit('selectContentTemplate')"
/>
<div
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<VideoCallButton
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
/>
<div
v-if="!isFetchingAppIntegrations"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<AIAssistanceButton
v-if="!isFetchingAppIntegrations"
:conversation-id="conversationId"
@@ -399,28 +438,36 @@ export default {
>
<fluent-icon icon="cloud-backup" size="40" />
<h4 class="text-2xl break-words text-n-slate-12">
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
{{ t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
</h4>
</div>
</transition>
<div
v-if="enableInsertArticleInReply"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<NextButton
v-if="enableInsertArticleInReply"
v-tooltip.top-end="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
v-tooltip.top-end="t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
icon="i-ph-article-ny-times"
slate
faded
ghost
sm
@click="toggleInsertArticle"
/>
</div>
<div class="right-wrap">
<div class="flex">
<NextButton
:label="sendButtonText"
type="submit"
sm
:color="isNote ? 'amber' : 'blue'"
color="blue"
:disabled="isSendDisabled"
class="flex-shrink-0"
class="flex-shrink-0 !text-xs"
:class="{
'!bg-n-solid-amber-button !text-[#251801] hover:enabled:!brightness-95 focus-visible:!brightness-95':
isNote,
}"
@click="onSend"
/>
</div>
@@ -428,14 +475,6 @@ export default {
</template>
<style lang="scss" scoped>
.left-wrap {
@apply items-center flex gap-2;
}
.right-wrap {
@apply flex;
}
::v-deep .file-uploads {
label {
@apply cursor-pointer;
@@ -90,11 +90,12 @@ export default {
</script>
<template>
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
<div
class="flex justify-between items-center h-[3.5rem] gap-2 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
>
<EditorModeToggle
:mode="mode"
:disabled="isReplyRestricted"
class="mt-3"
@toggle-mode="handleModeToggle"
/>
<div class="flex items-center mx-4 my-0">
@@ -106,7 +107,7 @@ export default {
</div>
<NextButton
ghost
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>