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
@@ -133,7 +133,7 @@ const STYLE_CONFIG = {
},
slate: {
solid:
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
'bg-n-button-color dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded:
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent',
outline:
@@ -498,7 +498,7 @@ provideMessageContext({
'w-full': variant === MESSAGE_VARIANTS.EMAIL,
},
]"
class="gap-x-2"
class="gap-x-3"
:style="{
gridTemplateAreas: gridTemplate,
}"
@@ -508,7 +508,7 @@ provideMessageContext({
v-tooltip.left-end="avatarTooltip"
class="[grid-area:avatar] flex items-end"
>
<Avatar v-bind="avatarInfo" :size="24" />
<Avatar v-bind="avatarInfo" :size="24" rounded-full />
</div>
<div
class="[grid-area:bubble] flex"
@@ -112,7 +112,11 @@ const shouldGroupWithNext = (index, searchList) => {
nextMessageType === MESSAGE_TYPES.TEMPLATE &&
currentMessageType === MESSAGE_TYPES.TEMPLATE;
if (!hasSameSender || areBothTemplates) return false;
const areBothActivity =
nextMessageType === MESSAGE_TYPES.ACTIVITY &&
currentMessageType === MESSAGE_TYPES.ACTIVITY;
if (!hasSameSender || areBothTemplates || areBothActivity) return false;
if (currentMessageType !== nextMessageType) return false;
@@ -157,6 +161,17 @@ const getInReplyToMessage = parentMessage => {
return replyMessage ? useCamelCase(replyMessage) : null;
};
const getMessageSpacingClass = (message, messages, index) => {
// For non-activity messages, use groupWithNext logic
if (message.messageType !== MESSAGE_TYPES.ACTIVITY) {
return shouldGroupWithNext(index, messages) ? 'mb-1' : 'mb-6';
}
// For activity messages, check next message exists and is also an activity
const nextMessage = messages[index + 1];
return nextMessage?.messageType === MESSAGE_TYPES.ACTIVITY ? 'mb-2' : 'mb-6';
};
</script>
<template>
@@ -175,6 +190,7 @@ const getInReplyToMessage = parentMessage => {
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
:class="getMessageSpacingClass(message, allMessages, index)"
@retry="emit('retry', message)"
/>
</template>
@@ -123,10 +123,13 @@ const statusToShow = computed(() => {
<template>
<div class="text-xs flex items-center gap-1.5">
<div v-if="isPrivate" class="flex items-center gap-2 px-1">
<Icon icon="i-lucide-lock-keyhole" class="size-3" />
<div class="w-px h-2 rounded-lg bg-n-amber-text" />
</div>
<div class="inline">
<time class="inline">{{ readableTime }}</time>
</div>
<Icon v-if="isPrivate" icon="i-lucide-lock-keyhole" class="size-3" />
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
</div>
</template>
@@ -58,7 +58,7 @@ const statusColor = computed(() => {
const statusIconMap = {
[MESSAGE_STATUS.SENT]: 'text-n-slate-10',
[MESSAGE_STATUS.DELIVERED]: 'text-n-slate-10',
[MESSAGE_STATUS.READ]: 'text-[#7EB6FF]',
[MESSAGE_STATUS.READ]: 'text-n-blue-11',
};
return statusIconMap[status];
@@ -18,14 +18,14 @@ const { variant, orientation, inReplyTo, shouldGroupWithNext } =
useMessageContext();
const { t } = useI18n();
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
const variantBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-blue-text',
[MESSAGE_VARIANTS.PRIVATE]:
'bg-n-solid-amber text-n-amber-12 [&_.prosemirror-mention-node]:font-semibold',
'bg-n-solid-amber text-n-amber-text [&_.prosemirror-mention-node]:font-semibold',
[MESSAGE_VARIANTS.USER]: 'bg-n-slate-4 text-n-slate-12',
[MESSAGE_VARIANTS.ACTIVITY]: 'bg-n-alpha-1 text-n-slate-11 text-sm',
[MESSAGE_VARIANTS.BOT]: 'bg-n-solid-iris text-n-slate-12',
[MESSAGE_VARIANTS.TEMPLATE]: 'bg-n-solid-iris text-n-slate-12',
[MESSAGE_VARIANTS.BOT]: 'bg-n-solid-purple text-n-purple-text',
[MESSAGE_VARIANTS.TEMPLATE]: 'bg-n-solid-purple text-n-purple-text',
[MESSAGE_VARIANTS.ERROR]: 'bg-n-ruby-4 text-n-ruby-12',
[MESSAGE_VARIANTS.EMAIL]: 'w-full',
[MESSAGE_VARIANTS.UNSUPPORTED]:
@@ -51,7 +51,7 @@ const flexOrientationClass = computed(() => {
});
const messageClass = computed(() => {
const classToApply = [varaintBaseMap[variant.value]];
const classToApply = [variantBaseMap[variant.value]];
if (variant.value !== MESSAGE_VARIANTS.ACTIVITY) {
classToApply.push(orientationMap[orientation.value]);
@@ -118,7 +118,7 @@ const replyToPreview = computed(() => {
flexOrientationClass,
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
variant === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
? 'text-n-amber-text'
: 'text-n-slate-11',
]"
class="mt-2"
@@ -139,8 +139,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template>
<div class="relative flex items-center justify-end resolve-actions">
<ButtonGroup
class="rounded-lg shadow outline-1 outline flex-shrink-0"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
class="rounded-lg shadow outline-1 outline outline-n-weak flex-shrink-0"
>
<Button
v-if="isOpen"
@@ -178,7 +177,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:disabled="isLoading"
size="sm"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none !outline-0"
class="ltr:rounded-l-none rtl:rounded-r-none !outline-n-weak"
color="slate"
trailing-icon
@click="openDropdown"
@@ -135,7 +135,7 @@ export default {
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
slate
faded
ghost
sm
@click="openAIAssist"
/>
@@ -54,7 +54,7 @@ export default {
"
icon="i-ph-video-camera"
slate
faded
ghost
sm
@click="onClick"
/>
@@ -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')"
/>
@@ -1,92 +1,83 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
export default {
components: {
ConversationHeader,
DashboardAppFrame,
EmptyState,
MessagesView,
defineProps({
inboxId: {
type: [Number, String],
default: '',
required: false,
},
props: {
inboxId: {
type: [Number, String],
default: '',
required: false,
},
isInboxView: {
type: Boolean,
default: false,
},
isContactPanelOpen: {
type: Boolean,
default: true,
},
isOnExpandedLayout: {
type: Boolean,
default: true,
},
isInboxView: {
type: Boolean,
default: false,
},
data() {
return { activeIndex: 0 };
isOnExpandedLayout: {
type: Boolean,
default: true,
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
dashboardApps: 'dashboardApps/getRecords',
}),
dashboardAppTabs() {
return [
{
key: 'messages',
index: 0,
name: this.$t('CONVERSATION.DASHBOARD_APP_TAB_MESSAGES'),
},
...this.dashboardApps.map((dashboardApp, index) => ({
key: `dashboard-${dashboardApp.id}`,
index: index + 1,
name: dashboardApp.title,
})),
];
},
showContactPanel() {
return this.isContactPanelOpen && this.currentChat.id;
},
},
watch: {
'currentChat.inbox_id': {
immediate: true,
handler(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
}
},
},
'currentChat.id'() {
this.fetchLabels();
this.activeIndex = 0;
},
},
mounted() {
this.fetchLabels();
this.$store.dispatch('dashboardApps/get');
},
methods: {
fetchLabels() {
if (!this.currentChat.id) {
return;
}
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
},
onDashboardAppTabChange(index) {
this.activeIndex = index;
},
});
const { t } = useI18n();
const store = useStore();
const currentChat = useMapGetter('getSelectedChat');
const dashboardApps = useMapGetter('dashboardApps/getRecords');
const activeIndex = ref(0);
const dashboardAppTabs = computed(() => [
{
key: 'messages',
index: 0,
name: t('CONVERSATION.DASHBOARD_APP_TAB_MESSAGES'),
},
...dashboardApps.value.map((dashboardApp, index) => ({
key: `dashboard-${dashboardApp.id}`,
index: index + 1,
name: dashboardApp.title,
})),
]);
const fetchLabels = () => {
if (!currentChat.value.id) {
return;
}
store.dispatch('conversationLabels/get', currentChat.value.id);
};
const onDashboardAppTabChange = index => {
activeIndex.value = index;
};
watch(
() => currentChat.value.inbox_id,
inboxId => {
if (inboxId) {
store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
}
},
{ immediate: true }
);
watch(
() => currentChat.value.id,
() => {
fetchLabels();
activeIndex.value = 0;
}
);
onMounted(() => {
fetchLabels();
store.dispatch('dashboardApps/get');
});
</script>
<template>
@@ -99,12 +90,13 @@ export default {
<ConversationHeader
v-if="currentChat.id"
:chat="currentChat"
:is-on-expanded-view="isOnExpandedLayout"
:show-back-button="isOnExpandedLayout && !isInboxView"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
:index="activeIndex"
class="-mt-px"
class="[&>ul]:px-5 h-10"
@change="onDashboardAppTabChange"
>
<woot-tabs-item
@@ -114,7 +106,6 @@ export default {
:name="tab.name"
:show-badge="false"
is-compact
class="[&_a]:pt-1"
/>
</woot-tabs>
<div v-show="!activeIndex" class="flex h-full min-h-0 m-0">
@@ -4,9 +4,8 @@ import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MoreActions from './MoreActions.vue';
import Avatar from 'next/avatar/Avatar.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
@@ -23,6 +22,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isOnExpandedView: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -80,78 +83,73 @@ const snoozedDisplayText = computed(() => {
return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
});
const inbox = computed(() => {
const { inbox_id: inboxId } = props.chat;
return store.getters['inboxes/getInbox'](inboxId);
});
const hasMultipleInboxes = computed(
() => store.getters['inboxes/getInboxes'].length > 1
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
</script>
<template>
<div
ref="conversationHeader"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 pt-3 pb-2 h-24 xl:h-12"
class="flex flex-col items-start gap-x-6 gap-y-2 w-full min-w-0 pt-3 pb-2 ltr:pl-4 rtl:pr-4 ltr:pr-1 rtl:pl-1"
:class="{
'md:flex-row md:items-center': isOnExpandedView,
'lg:flex-row lg:items-center': !isOnExpandedView,
}"
>
<div
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
class="flex items-center gap-2 min-w-0 w-full"
:class="{
'md:flex-1 md:basis-0': isOnExpandedView,
'lg:flex-1 lg:basis-0': !isOnExpandedView,
}"
>
<BackButton
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Avatar
:name="currentContact.name"
:src="currentContact.thumbnail"
:size="32"
:status="currentContact.availability_status"
hide-offline-status
rounded-full
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
>
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px] flex-shrink-0"
icon="warning"
/>
</div>
<BackButton v-if="showBackButton" :back-url="backButtonUrl" />
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
</div>
<div class="flex items-center gap-1 min-w-0 flex-1">
<span class="text-base font-medium text-n-slate-12 truncate min-w-0">
{{ currentContact.name }}
</span>
<Icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
icon="i-lucide-triangle-alert"
class="text-n-amber-10 size-3.5 shrink-0"
/>
</div>
</div>
<div
class="flex flex-row items-center justify-start xl:justify-end flex-shrink-0 gap-2 w-full xl:w-auto header-actions-wrap"
class="flex items-center gap-2 min-w-0 w-full"
:class="{
'md:flex-1 md:basis-0 md:justify-end': isOnExpandedView,
'lg:flex-1 lg:basis-0 lg:justify-end': !isOnExpandedView,
}"
>
<div v-if="isSnoozed" class="flex items-center gap-1 min-w-0 shrink">
<Icon
icon="i-lucide-bell-off"
class="text-n-slate-11 size-3.5 shrink-0"
/>
<span class="text-xs text-n-slate-11 font-420 truncate min-w-0">
{{ snoozedDisplayText }}
</span>
</div>
<div v-if="isSnoozed" class="w-px h-3 rounded-lg bg-n-strong shrink-0" />
<SLACardLabel
v-if="hasSlaPolicyId"
:chat="chat"
show-extended-info
:parent-width="width"
class="hidden md:flex"
class="p-1 shrink-0"
/>
<MoreActions :conversation-id="currentChat.id" />
<div
v-if="hasSlaPolicyId"
class="w-px h-3 rounded-lg bg-n-strong ltr:mx-2 rtl:mx-2 shrink-0"
/>
<MoreActions :conversation-id="currentChat.id" class="shrink-0" />
</div>
</div>
</template>
@@ -1,452 +1,462 @@
<script>
import { ref, provide } from 'vue';
// composable
<script setup>
import {
ref,
computed,
watch,
onMounted,
onUnmounted,
nextTick,
provide,
getCurrentInstance,
} from 'vue';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import {
useInbox,
INBOX_FEATURES,
INBOX_FEATURE_MAP,
} from 'dashboard/composables/useInbox';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
// components
import ReplyBox from './ReplyBox.vue';
import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// stores and apis
import { mapGetters } from 'vuex';
// mixins
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
// utils
import { emitter } from 'shared/helpers/mitt';
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { LocalStorage } from 'shared/helpers/localStorage';
import {
filterDuplicateSourceMessages,
getReadMessages,
getUnreadMessages,
} from 'dashboard/helper/conversationHelper';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
MessageList,
ReplyBox,
Banner,
ConversationLabelSuggestion,
Spinner,
},
mixins: [inboxMixin],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const store = useStore();
const route = useRoute();
const { t } = useI18n();
const instance = getCurrentInstance();
const { isEnterprise } = useConfig();
const keyboardEvents = {
Escape: {
action: () => {
isPopOutReplyBox.value = false;
},
},
};
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
useKeyboardEvents(keyboardEvents);
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const isLoadingPrevious = ref(true);
const heightBeforeLoad = ref(null);
const scrollTopBeforeLoad = ref(null);
const conversationPanel = ref(null);
const hasUserScrolled = ref(false);
const isProgrammaticScroll = ref(false);
const messageSentSinceOpened = ref(false);
const labelSuggestions = ref([]);
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
conversationPanelRef,
};
},
data() {
return {
isLoadingPrevious: true,
heightBeforeLoad: null,
conversationPanel: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
messageSentSinceOpened: false,
labelSuggestions: [],
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
},
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.isEnterprise &&
this.isAIIntegrationEnabled &&
!this.messageSentSinceOpened
);
},
inboxId() {
return this.currentChat.inbox_id;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
typingUsersList() {
const userList = this.$store.getters[
'conversationTypingStatus/getUserList'
](this.currentChat.id);
return userList;
},
isAnyoneTyping() {
const userList = this.typingUsersList;
return userList.length !== 0;
},
typingUserNames() {
const userList = this.typingUsersList;
if (this.isAnyoneTyping) {
const [i18nKey, params] = getTypingUsersText(userList);
return this.$t(i18nKey, params);
}
return '';
},
getMessages() {
const messages = this.currentChat.messages || [];
if (this.isAWhatsAppChannel) {
return filterDuplicateSourceMessages(messages);
}
return messages;
},
readMessages() {
return getReadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
unReadMessages() {
return getUnreadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
shouldShowSpinner() {
return (
(this.currentChat && this.currentChat.dataFetched === undefined) ||
(!this.listLoadingStatus && this.isLoadingPrevious)
);
},
// Check there is a instagram inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
const { additional_attributes: additionalAttributes = {} } = this.inbox;
const instagramInbox =
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
instagramId
);
return (
this.inbox.channel_type === INBOX_TYPES.FB &&
additionalAttributes.type === 'instagram_direct_message' &&
instagramInbox
);
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
}
if (this.isAPIInbox) {
const { additional_attributes: additionalAttributes = {} } = this.inbox;
if (additionalAttributes) {
const {
agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes;
return (
agentReplyTimeWindowMessage ||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
}
return '';
}
return this.$t('CONVERSATION.CANNOT_REPLY');
},
replyWindowLink() {
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
return REPLY_POLICY.FACEBOOK;
}
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
},
replyWindowLinkText() {
if (
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAnInstagramChannel
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}
return '';
},
unreadMessageCount() {
return this.currentChat.unread_count || 0;
},
unreadMessageLabel() {
const count =
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
const label =
this.unreadMessageCount > 1
? 'CONVERSATION.UNREAD_MESSAGES'
: 'CONVERSATION.UNREAD_MESSAGE';
return `${count} ${this.$t(label)}`;
},
inboxSupportsReplyTo() {
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
!this.is360DialogWhatsAppChannel;
return { incoming, outgoing };
},
},
watch: {
currentChat(newChat, oldChat) {
if (newChat.id === oldChat.id) {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
},
},
created() {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
this.messageSentSinceOpened = true;
});
},
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
},
unmounted() {
this.removeBusListeners();
this.removeScrollListener();
},
methods: {
async fetchSuggestions() {
// start empty, this ensures that the label suggestions are not shown
this.labelSuggestions = [];
if (this.isLabelSuggestionDismissed()) {
return;
}
if (!this.isEnterprise) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isLabelSuggestionFeatureEnabled) {
return;
}
this.labelSuggestions = await this.fetchLabelSuggestions({
conversationId: this.currentChat.id,
});
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
// so we use the nextTick method
this.$nextTick(() => {
// this param is added to route, telling the UI to navigate to the message
// it is triggered by the SCROLL_TO_MESSAGE method
// see setActiveChat on ConversationView.vue for more info
const { messageId } = this.$route.query;
// only trigger the scroll to bottom if the user has not scrolled
// and there's no active messageId that is selected in view
if (!messageId && !this.hasUserScrolled) {
this.scrollToBottom();
}
});
},
isLabelSuggestionDismissed() {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.currentChat.id
);
},
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
},
onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
this.isProgrammaticScroll = true;
messageElement.scrollIntoView({ behavior: 'smooth' });
this.fetchPreviousMessages();
} else {
this.scrollToBottom();
}
});
this.makeMessagesRead();
},
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
this.setScrollParams();
this.conversationPanel.addEventListener('scroll', this.handleScroll);
this.$nextTick(() => this.scrollToBottom());
this.isLoadingPrevious = false;
},
removeScrollListener() {
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
},
scrollToBottom() {
this.isProgrammaticScroll = true;
let relevantMessages = [];
// label suggestions are not part of the messages list
// so we need to handle them separately
let labelSuggestions =
this.conversationPanel.querySelector('.label-suggestion');
// if there are unread messages, scroll to the first unread message
if (this.unreadMessageCount > 0) {
// capturing only the unread messages
relevantMessages =
this.conversationPanel.querySelectorAll('.message--unread');
} else if (labelSuggestions) {
// when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages
// Unread messages always take the highest priority
relevantMessages = [labelSuggestions];
} else {
// if there are no unread messages or label suggestion, scroll to the last message
// capturing last message from the messages list
relevantMessages = Array.from(
this.conversationPanel.querySelectorAll('.message--read')
).slice(-1);
}
this.conversationPanel.scrollTop = calculateScrollTop(
this.conversationPanel.scrollHeight,
this.$el.scrollHeight,
relevantMessages
);
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
},
async fetchPreviousMessages(scrollTop = 0) {
this.setScrollParams();
const shouldLoadMoreMessages =
this.currentChat.dataFetched === true &&
!this.listLoadingStatus &&
!this.isLoadingPrevious;
if (
scrollTop < 100 &&
!this.isLoadingPrevious &&
shouldLoadMoreMessages
) {
this.isLoadingPrevious = true;
try {
await this.$store.dispatch('fetchPreviousMessages', {
conversationId: this.currentChat.id,
before: this.currentChat.messages[0].id,
});
const heightDifference =
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
this.conversationPanel.scrollTop =
this.scrollTopBeforeLoad + heightDifference;
this.setScrollParams();
} catch (error) {
// Ignore Error
} finally {
this.isLoadingPrevious = false;
}
}
},
handleScroll(e) {
if (this.isProgrammaticScroll) {
// Reset the flag
this.isProgrammaticScroll = false;
this.hasUserScrolled = false;
} else {
this.hasUserScrolled = true;
}
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
async handleMessageRetry(message) {
if (!message) return;
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
const keyboardEvents = {
Escape: {
action: () => {
isPopOutReplyBox.value = false;
},
},
};
useKeyboardEvents(keyboardEvents);
provide('contextMenuElementTarget', conversationPanelRef);
const currentChat = useMapGetter('getSelectedChat');
const currentUserId = useMapGetter('getCurrentUserID');
const listLoadingStatus = useMapGetter('getAllMessagesLoaded');
const currentAccountId = useMapGetter('getCurrentAccountId');
const instagramInboxByIdGetter = useMapGetter(
'inboxes/getInstagramInboxByInstagramId'
);
const inboxGetter = useMapGetter('inboxes/getInbox');
const typingUsersListGetter = useMapGetter(
'conversationTypingStatus/getUserList'
);
const isOpen = computed(() => {
return currentChat.value?.status === wootConstants.STATUS_TYPE.OPEN;
});
const shouldShowLabelSuggestions = computed(() => {
return (
isOpen.value &&
isEnterprise &&
isAIIntegrationEnabled.value &&
!messageSentSinceOpened.value
);
});
const inboxId = computed(() => {
return currentChat.value?.inbox_id;
});
const inbox = computed(() => {
return inboxGetter.value(inboxId.value);
});
const {
isAPIInbox,
isAFacebookInbox,
isAWhatsAppChannel,
isAWhatsAppCloudChannel,
isAnInstagramChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
} = useInbox();
const inboxHasFeature = feature => {
const channelType = inbox.value?.channel_type;
return INBOX_FEATURE_MAP[feature]?.includes(channelType) ?? false;
};
const typingUsersList = computed(() => {
const userList = typingUsersListGetter.value(currentChat.value.id);
return userList;
});
const isAnyoneTyping = computed(() => {
const userList = typingUsersList.value;
return userList.length !== 0;
});
const typingUserNames = computed(() => {
const userList = typingUsersList.value;
if (isAnyoneTyping.value) {
const [i18nKey, params] = getTypingUsersText(userList);
return t(i18nKey, params);
}
return '';
});
const getMessages = computed(() => {
const messages = currentChat.value.messages || [];
if (isAWhatsAppChannel.value) {
return filterDuplicateSourceMessages(messages);
}
return messages;
});
const unReadMessages = computed(() => {
return getUnreadMessages(
getMessages.value,
currentChat.value.agent_last_seen_at
);
});
const shouldShowSpinner = computed(() => {
return (
(currentChat.value && currentChat.value.dataFetched === undefined) ||
(!listLoadingStatus.value && isLoadingPrevious.value)
);
});
// Check there is a instagram inbox exists with the same instagram_id
const hasDuplicateInstagramInbox = computed(() => {
const instagramId = inbox.value.instagram_id;
const { additional_attributes: additionalAttributes = {} } = inbox.value;
const instagramInbox = instagramInboxByIdGetter.value(instagramId);
return (
inbox.value.channel_type === INBOX_TYPES.FB &&
additionalAttributes.type === 'instagram_direct_message' &&
instagramInbox
);
});
const replyWindowBannerMessage = computed(() => {
if (isAWhatsAppChannel.value) {
return t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
}
if (isAPIInbox.value) {
const { additional_attributes: additionalAttributes = {} } = inbox.value;
if (additionalAttributes) {
const {
agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes;
return (
agentReplyTimeWindowMessage ||
t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
}
return '';
}
return t('CONVERSATION.CANNOT_REPLY');
});
const replyWindowLink = computed(() => {
if (isAFacebookInbox.value || isAnInstagramChannel.value) {
return REPLY_POLICY.FACEBOOK;
}
if (isAWhatsAppCloudChannel.value) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (!isAPIInbox.value) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
});
const replyWindowLinkText = computed(() => {
if (
isAWhatsAppChannel.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value
) {
return t('CONVERSATION.24_HOURS_WINDOW');
}
if (!isAPIInbox.value) {
return t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}
return '';
});
const unreadMessageCount = computed(() => {
return currentChat.value.unread_count || 0;
});
const unreadMessageLabel = computed(() => {
const count = unreadMessageCount.value > 9 ? '9+' : unreadMessageCount.value;
const label =
unreadMessageCount.value > 1
? 'CONVERSATION.UNREAD_MESSAGES'
: 'CONVERSATION.UNREAD_MESSAGE';
return `${count} ${t(label)}`;
});
const inboxSupportsReplyTo = computed(() => {
const incoming = inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
!is360DialogWhatsAppChannel.value;
return { incoming, outgoing };
});
const isLabelSuggestionDismissed = () => {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
currentAccountId.value,
currentChat.value.id
);
};
const fetchAllAttachmentsFromCurrentChat = () => {
store.dispatch('fetchAllAttachments', currentChat.value.id);
};
const makeMessagesRead = () => {
store.dispatch('markMessagesRead', { id: currentChat.value.id });
};
const setScrollParams = () => {
heightBeforeLoad.value = conversationPanel.value.scrollHeight;
scrollTopBeforeLoad.value = conversationPanel.value.scrollTop;
};
const scrollToBottom = () => {
isProgrammaticScroll.value = true;
let relevantMessages = [];
// label suggestions are not part of the messages list
// so we need to handle them separately
let labelSuggestionsEl =
conversationPanel.value.querySelector('.label-suggestion');
// if there are unread messages, scroll to the first unread message
if (unreadMessageCount.value > 0) {
// capturing only the unread messages
relevantMessages =
conversationPanel.value.querySelectorAll('.message--unread');
} else if (labelSuggestionsEl) {
// when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages
// Unread messages always take the highest priority
relevantMessages = [labelSuggestionsEl];
} else {
// if there are no unread messages or label suggestion, scroll to the last message
// capturing last message from the messages list
relevantMessages = Array.from(
conversationPanel.value.querySelectorAll('.message--read')
).slice(-1);
}
conversationPanel.value.scrollTop = calculateScrollTop(
conversationPanel.value.scrollHeight,
instance.proxy.$el.scrollHeight,
relevantMessages
);
};
const fetchPreviousMessages = async (scrollTop = 0) => {
setScrollParams();
const shouldLoadMoreMessages =
currentChat.value.dataFetched === true &&
!listLoadingStatus.value &&
!isLoadingPrevious.value;
if (scrollTop < 100 && !isLoadingPrevious.value && shouldLoadMoreMessages) {
isLoadingPrevious.value = true;
try {
await store.dispatch('fetchPreviousMessages', {
conversationId: currentChat.value.id,
before: currentChat.value.messages[0].id,
});
const heightDifference =
conversationPanel.value.scrollHeight - heightBeforeLoad.value;
conversationPanel.value.scrollTop =
scrollTopBeforeLoad.value + heightDifference;
setScrollParams();
} catch (error) {
// Ignore Error
} finally {
isLoadingPrevious.value = false;
}
}
};
const onScrollToMessage = ({ messageId = '' } = {}) => {
nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
isProgrammaticScroll.value = true;
messageElement.scrollIntoView({ behavior: 'smooth' });
fetchPreviousMessages();
} else {
scrollToBottom();
}
});
makeMessagesRead();
};
const handleScroll = e => {
if (isProgrammaticScroll.value) {
// Reset the flag
isProgrammaticScroll.value = false;
hasUserScrolled.value = false;
} else {
hasUserScrolled.value = true;
}
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
fetchPreviousMessages(e.target.scrollTop);
};
const addScrollListener = () => {
conversationPanel.value = instance.proxy.$el.querySelector(
'.conversation-panel'
);
setScrollParams();
conversationPanel.value.addEventListener('scroll', handleScroll);
nextTick(() => scrollToBottom());
isLoadingPrevious.value = false;
};
const removeScrollListener = () => {
conversationPanel.value.removeEventListener('scroll', handleScroll);
};
const removeBusListeners = () => {
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, onScrollToMessage);
};
const fetchSuggestions = async () => {
// start empty, this ensures that the label suggestions are not shown
labelSuggestions.value = [];
if (isLabelSuggestionDismissed()) {
return;
}
if (!isEnterprise) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = currentChat.value?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await fetchIntegrationsIfRequired();
if (!isLabelSuggestionFeatureEnabled.value) {
return;
}
labelSuggestions.value = await fetchLabelSuggestions({
conversationId: currentChat.value.id,
});
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
// so we use the nextTick method
nextTick(() => {
// this param is added to route, telling the UI to navigate to the message
// it is triggered by the SCROLL_TO_MESSAGE method
// see setActiveChat on ConversationView.vue for more info
const { messageId } = route.query;
// only trigger the scroll to bottom if the user has not scrolled
// and there's no active messageId that is selected in view
if (!messageId && !hasUserScrolled.value) {
scrollToBottom();
}
});
};
const handleMessageRetry = async message => {
if (!message) return;
const payload = useSnakeCase(message);
await store.dispatch('sendMessageWithData', payload);
};
watch(currentChat, (newChat, oldChat) => {
if (newChat.id === oldChat.id) {
return;
}
fetchAllAttachmentsFromCurrentChat();
fetchSuggestions();
messageSentSinceOpened.value = false;
});
onMounted(() => {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, onScrollToMessage);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
messageSentSinceOpened.value = true;
});
addScrollListener();
fetchAllAttachmentsFromCurrentChat();
fetchSuggestions();
});
onUnmounted(() => {
removeBusListeners();
removeScrollListener();
});
</script>
<template>
@@ -463,7 +473,7 @@ export default {
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
:banner-message="t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<MessageList
ref="conversationPanelRef"
@@ -91,7 +91,7 @@ onUnmounted(() => {
</script>
<template>
<div class="relative flex items-center gap-2 actions--container">
<div class="relative flex items-center gap-1">
<ResolveAction
:conversation-id="currentChat.id"
:status="currentChat.status"
@@ -1355,11 +1355,12 @@ export default {
.reply-box {
transition: height 2s cubic-bezier(0.37, 0, 0.63, 1);
box-shadow: 0 2px 4px 0 rgba(27, 28, 29, 0.04);
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
@apply relative mb-3 mx-3 border border-n-weak rounded-xl bg-n-solid-1;
&.is-private {
@apply bg-n-solid-amber dark:border-n-amber-3/10 border-n-amber-12/5;
@apply bg-n-amber-2 border-n-amber-5;
}
}
@@ -134,6 +134,7 @@ export const useInbox = (inboxId = null) => {
return {
inbox,
channelType,
isAFacebookInbox,
isALineChannel,
isAPIInbox,
@@ -1,9 +1,12 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, watch, onMounted, onBeforeMount } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { onBeforeRouteLeave } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import ChatList from '../../../components/ChatList.vue';
import ConversationBox from '../../../components/widgets/conversation/ConversationBox.vue';
import ChatList from 'dashboard/components/ChatList.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
@@ -11,187 +14,144 @@ import { emitter } from 'shared/helpers/mitt';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
export default {
components: {
ChatList,
ConversationBox,
CmdBarConversationSnooze,
SidepanelSwitch,
ConversationSidebar,
const props = defineProps({
inboxId: {
type: [String, Number],
default: 0,
},
beforeRouteLeave(to, from, next) {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
// and resolves timing issues during navigation with conversation view and other screens
if (this.conversationId) {
this.$store.dispatch('clearSelectedState');
}
next(); // Continue with navigation
conversationId: {
type: [String, Number],
default: 0,
},
props: {
inboxId: {
type: [String, Number],
default: 0,
},
conversationId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
teamId: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
setup() {
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
teamId: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
});
return {
uiSettings,
updateUISettings,
accountId,
};
},
data() {
return {
showSearchModal: false,
};
},
computed: {
...mapGetters({
chatList: 'getAllConversations',
currentChat: 'getSelectedChat',
}),
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
},
showMessageView() {
return this.conversationId ? true : !this.isOnExpandedLayout;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
const route = useRoute();
const store = useStore();
const { uiSettings } = useUISettings();
shouldShowSidebar() {
if (!this.currentChat.id) {
return false;
}
const chatList = useMapGetter('getAllConversations');
const currentChat = useMapGetter('getSelectedChat');
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
return isContactSidebarOpen;
},
},
watch: {
conversationId() {
this.fetchConversationIfUnavailable();
},
},
const isOnExpandedLayout = computed(() => {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
uiSettings.value;
return conversationDisplayType !== CONDENSED;
});
created() {
// Clear selected state early if no conversation is selected
// This prevents child components from accessing stale data
// and resolves timing issues during navigation
// with conversation view and other screens
if (!this.conversationId) {
this.$store.dispatch('clearSelectedState');
}
},
const showConversationList = computed(() =>
isOnExpandedLayout.value ? !props.conversationId : true
);
mounted() {
this.$store.dispatch('agents/get');
this.$store.dispatch('portals/index');
this.initialize();
this.$watch('$store.state.route', () => this.initialize());
this.$watch('chatList.length', () => {
this.setActiveChat();
});
},
const showMessageView = computed(() =>
props.conversationId ? true : !isOnExpandedLayout.value
);
methods: {
onConversationLoad() {
this.fetchConversationIfUnavailable();
},
initialize() {
this.$store.dispatch('setActiveInbox', this.inboxId);
this.setActiveChat();
},
toggleConversationLayout() {
const { LAYOUT_TYPES } = wootConstants;
const {
conversation_display_type:
conversationDisplayType = LAYOUT_TYPES.CONDENSED,
} = this.uiSettings;
const newViewType =
conversationDisplayType === LAYOUT_TYPES.CONDENSED
? LAYOUT_TYPES.EXPANDED
: LAYOUT_TYPES.CONDENSED;
this.updateUISettings({
conversation_display_type: newViewType,
previously_used_conversation_display_type: newViewType,
});
},
fetchConversationIfUnavailable() {
if (!this.conversationId) {
return;
}
const chat = this.findConversation();
if (!chat) {
this.$store.dispatch('getConversation', this.conversationId);
}
},
findConversation() {
const conversationId = parseInt(this.conversationId, 10);
const [chat] = this.chatList.filter(c => c.id === conversationId);
return chat;
},
setActiveChat() {
if (this.conversationId) {
const selectedConversation = this.findConversation();
// If conversation doesn't exist or selected conversation is same as the active
// conversation, don't set active conversation.
if (
!selectedConversation ||
selectedConversation.id === this.currentChat.id
) {
return;
}
const { messageId } = this.$route.query;
this.$store
.dispatch('setActiveChat', {
data: selectedConversation,
after: messageId,
})
.then(() => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
});
} else {
this.$store.dispatch('clearSelectedState');
}
},
onSearch() {
this.showSearchModal = true;
},
closeSearch() {
this.showSearchModal = false;
},
},
const shouldShowSidebar = computed(() => {
if (!currentChat.value.id) {
return false;
}
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
return isContactSidebarOpen;
});
const findConversation = () => {
const conversationId = parseInt(props.conversationId, 10);
const [chat] = chatList.value.filter(c => c.id === conversationId);
return chat;
};
const fetchConversationIfUnavailable = () => {
if (!props.conversationId) {
return;
}
const chat = findConversation();
if (!chat) {
store.dispatch('getConversation', props.conversationId);
}
};
const setActiveChat = () => {
if (props.conversationId) {
const selectedConversation = findConversation();
// If conversation doesn't exist or selected conversation is same as the active
// conversation, don't set active conversation.
if (
!selectedConversation ||
selectedConversation.id === currentChat.value.id
) {
return;
}
const { messageId } = route.query;
store
.dispatch('setActiveChat', {
data: selectedConversation,
after: messageId,
})
.then(() => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
});
} else {
store.dispatch('clearSelectedState');
}
};
const initialize = () => {
store.dispatch('setActiveInbox', props.inboxId);
setActiveChat();
};
const onConversationLoad = () => {
fetchConversationIfUnavailable();
};
onBeforeRouteLeave((to, from, next) => {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
// and resolves timing issues during navigation with conversation view and other screens
if (props.conversationId) {
store.dispatch('clearSelectedState');
}
next(); // Continue with navigation
});
watch(() => props.conversationId, fetchConversationIfUnavailable);
watch(() => store.state.route, initialize);
watch(() => chatList.value.length, setActiveChat);
onBeforeMount(() => {
// Clear selected state early if no conversation is selected
// This prevents child components from accessing stale data
// and resolves timing issues during navigation
// with conversation view and other screens
if (!props.conversationId) {
store.dispatch('clearSelectedState');
}
});
onMounted(() => {
store.dispatch('agents/get');
store.dispatch('portals/index');
initialize();
});
</script>
<template>