Files
chatwoot/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
Sivin Varghese bc60ffc312 chore: remove old Captain label suggestions (#16056)
# Pull Request Template

## Description


This PR removes the old Captain label suggestion card above the reply
box. Label suggestions now come from the Captain suggestions in the
conversation sidebar.

This also removes the Label Suggestion toggle in Captain settings and
the "Show label suggestions" option on the OpenAI integration. A
migration clears old saved values so existing accounts keep saving.

## Type of change

- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-10-06 16:58:12 -07:00

697 lines
22 KiB
Vue

<script>
import {
ref,
computed,
provide,
inject,
nextTick,
useTemplateRef,
getCurrentInstance,
} from 'vue';
import {
useElementSize,
useEventListener,
useResizeObserver,
useSessionStorage,
} from '@vueuse/core';
// composable
import { useTrack } from 'dashboard/composables';
import { useCampaignHistory } from 'dashboard/composables/useCampaignHistory';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { CONTACT_CONVERSATION_NAVIGATION } from 'dashboard/composables/useContactConversationNavigation';
// components
import ReplyBox from './ReplyBox.vue';
import MessageList from 'next/message/MessageList.vue';
import ContactConversationLink from './ContactConversationLink.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import CollapsedReplyBar from './CollapsedReplyBar.vue';
import ResizableEditorWrapper from './ResizableEditorWrapper.vue';
import ReferralBubble from 'dashboard/components-next/Conversation/ReferralBubble.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 {
captureTimelineAnchor,
getTimelineEntries,
getUnreadScrollTop,
} from './helpers/campaignScrollAnchor';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import {
filterDuplicateSourceMessages,
getReadMessages,
getUnreadMessages,
} from 'dashboard/helper/conversationHelper';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import { REPLY_POLICY } from 'shared/constants/links';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
const FOLD_MOTION =
'duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:duration-0';
const FOLD_ACTIVE_CLASS = `transition-[grid-template-rows,opacity] [&>div]:overflow-hidden ${FOLD_MOTION}`;
const FOLD_HIDDEN_CLASS = '[--fold-rows:0fr] opacity-0';
const FOLD_TRANSITION = {
enterActiveClass: FOLD_ACTIVE_CLASS,
leaveActiveClass: FOLD_ACTIVE_CLASS,
enterFromClass: FOLD_HIDDEN_CLASS,
leaveToClass: FOLD_HIDDEN_CLASS,
};
export default {
components: {
MessageList,
ReplyBox,
Banner,
ContactConversationLink,
Spinner,
NextButton,
CollapsedReplyBar,
ResizableEditorWrapper,
ReferralBubble,
},
mixins: [inboxMixin],
setup() {
const conversationPanelRef = ref(null);
const resizableEditorWrapperRef = ref(null);
const replyBoxRef = ref(null);
const messagesViewRef = useTemplateRef('messagesViewRef');
const topBannerRef = useTemplateRef('topBannerRef');
const { height: containerHeight } = useElementSize(messagesViewRef);
const { height: topBannerHeight } = useElementSize(topBannerRef);
const {
olderConversation,
newerConversation,
isReadingHistory,
latestConversation,
leaveReadingMode,
openConversation,
buildConversationPath,
} = inject(CONTACT_CONVERSATION_NAVIGATION);
provide('contextMenuElementTarget', conversationPanelRef);
const isReplyBoxCollapsed = useSessionStorage(
SESSION_STORAGE_KEYS.REPLY_BOX_COLLAPSED,
false
);
const isReplyFolded = computed(
() => isReadingHistory.value || isReplyBoxCollapsed.value
);
const canSendPublicReply = computed(
() => replyBoxRef.value?.canSendPublicReply ?? true
);
const collapseReplyBox = () => {
isReplyBoxCollapsed.value = true;
useTrack(CONVERSATION_EVENTS.COLLAPSED_REPLY_BOX);
};
const showReplyBox = () => {
isReplyBoxCollapsed.value = false;
leaveReadingMode();
};
// The editor focuses itself on these shortcuts, but only once it is shown.
const revealReplyBox = mode => {
if (!isReplyFolded.value) return;
showReplyBox();
useTrack(CONVERSATION_EVENTS.EXPANDED_REPLY_BOX, { mode });
nextTick(() => {
const replyBox = replyBoxRef.value;
// Switching modes drops attachments, so only switch when it changes
if (mode && replyBox.replyType !== mode) replyBox.setReplyMode(mode);
replyBox.messageEditor?.focusEditorInputField();
});
};
const revealOnShortcut = {
action: () => revealReplyBox(),
allowOnFocusedInput: false,
};
useKeyboardEvents({
'Alt+KeyP': revealOnShortcut,
'Alt+KeyL': revealOnShortcut,
});
// A shrinking list keeps its scrollTop, so a list resting at its end would
// slide under the composer. A growing one is clamped by the browser.
const { proxy } = getCurrentInstance();
let listHeight = 0;
useResizeObserver(conversationPanelRef, ([{ target }]) => {
const shrink = listHeight - target.clientHeight;
listHeight = target.clientHeight;
const { scrollTop, scrollHeight, clientHeight } = target;
const wasAtEnd = scrollHeight - scrollTop - clientHeight <= shrink + 1;
if (shrink <= 0 || !wasAtEnd) return;
proxy.isProgrammaticScroll = true;
target.scrollTop = scrollHeight - clientHeight;
});
// ReplyBox attaches pasted files from anywhere on the page, folded or not.
useEventListener(document, 'paste', e => {
if (e.clipboardData?.files.length) revealReplyBox();
});
return {
...useCampaignHistory(),
olderConversation,
newerConversation,
openConversation,
buildConversationPath,
isReadingHistory,
isReplyFolded,
canSendPublicReply,
foldMotion: FOLD_MOTION,
foldTransition: FOLD_TRANSITION,
latestConversation,
collapseReplyBox,
showReplyBox,
revealReplyBox,
conversationPanelRef,
resizableEditorWrapperRef,
replyBoxRef,
messagesViewRef,
topBannerRef,
containerHeight,
topBannerHeight,
};
},
data() {
return {
isLoadingPrevious: true,
heightBeforeLoad: null,
conversationPanel: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
isMetaMessageSendingDisabled: 'globalConfig/isMetaMessageSendingDisabled',
}),
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;
},
referralData() {
return this.currentChat?.additional_attributes?.referral || null;
},
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) ||
!this.isCampaignHistoryReady
);
},
timelineMessages() {
return this.isCampaignHistoryReady ? this.getMessages : [];
},
// 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
);
},
isInstagramRestrictionBannerVisible() {
return this.isMetaMessageSendingDisabled && this.isAnInstagramChannel;
},
instagramRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
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.isATiktokChannel) {
return REPLY_POLICY.TIKTOK;
}
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.isATiktokChannel) {
return this.$t('CONVERSATION.48_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: {
isCampaignHistoryReady(ready) {
if (!ready) return;
this.onScrollToMessage({ messageId: this.$route.query.messageId });
},
visibleCampaignHistory() {
if (!this.conversationPanel) return;
const conversationId = this.currentChat.id;
const restoreAnchor = captureTimelineAnchor(
this.conversationPanel,
this.$route.query.messageId
);
this.$nextTick(() => {
if (this.currentChat.id !== conversationId) return;
if (!this.hasUserScrolled && !this.$route.query.messageId) {
this.scrollToBottom();
} else {
restoreAnchor();
}
});
},
currentChat(newChat, oldChat) {
if (newChat.id === oldChat.id) {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.resetReplyEditorHeight();
},
// The link is appended once the neighbours arrive, below an already scrolled list.
newerConversation(conversation) {
if (!conversation) return;
this.$nextTick(() => {
if (!this.$route.query.messageId && !this.hasUserScrolled) {
this.scrollToBottom();
}
});
},
},
created() {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// Anything that wants to write must bring the folded editor back first.
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.showReplyBox);
emitter.on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.showReplyBox);
emitter.on(CMD_AI_ASSIST, this.showReplyBox);
},
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
},
unmounted() {
this.removeBusListeners();
this.removeScrollListener();
},
methods: {
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.showReplyBox);
emitter.off(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.showReplyBox);
emitter.off(CMD_AI_ASSIST, this.showReplyBox);
},
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;
// if there are unread messages, scroll to the first unread message
if (this.unreadMessageCount > 0) {
const scrollTop = getUnreadScrollTop(
this.conversationPanel,
this.unReadMessages[0]?.id
);
if (scrollTop !== undefined) {
this.conversationPanel.scrollTop = scrollTop;
return;
}
}
// otherwise scroll to the last message in the timeline
this.conversationPanel.scrollTop = calculateScrollTop(
this.conversationPanel.scrollHeight,
this.$el.scrollHeight,
getTimelineEntries(this.conversationPanel).slice(-1)
);
},
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);
},
toggleReplyEditorSize() {
this.resizableEditorWrapperRef?.toggleEditorExpand?.();
},
resetReplyEditorHeight() {
this.resizableEditorWrapperRef?.resetEditorHeight?.();
},
},
};
</script>
<template>
<div
ref="messagesViewRef"
class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"
>
<div ref="topBannerRef">
<Banner
v-if="isInstagramRestrictionBannerVisible"
color-scheme="warning"
class="mx-2 mt-2 min-h-12 !h-auto rounded-lg"
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
:href-link="instagramRestrictionStatusUrl"
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
/>
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<Banner
v-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
</div>
<MessageList
ref="conversationPanelRef"
class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 transition-[padding]"
:class="[foldMotion, isReplyFolded ? 'pb-16' : 'pb-4']"
:current-user-id="currentUserId"
:first-unread-id="unReadMessages[0]?.id"
:is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:messages="timelineMessages"
:campaign-history="visibleCampaignHistory"
@retry="handleMessageRetry"
>
<template #beforeAll>
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li
class="min-h-[4rem] flex flex-shrink-0 flex-grow-0 items-center flex-auto justify-center max-w-full mt-0 mr-0 mb-1 ml-0 relative first:mt-auto last:mb-0"
>
<Spinner v-if="shouldShowSpinner" class="text-n-brand" />
</li>
</transition>
<ContactConversationLink
v-if="olderConversation && listLoadingStatus"
direction="older"
:conversation="olderConversation"
:to="
buildConversationPath(olderConversation.id, {
keepFolderScope: false,
})
"
@navigate="openConversation(olderConversation)"
/>
<ReferralBubble v-if="referralData" :referral="referralData" />
<li v-if="hasMoreCampaignHistory" class="flex justify-center py-3">
<NextButton
:label="$t('CAMPAIGN.HISTORY.LOAD_MORE')"
:disabled="isCampaignHistoryLoading"
sm
ghost
@click="loadCampaignHistory"
/>
</li>
</template>
<template #unreadBadge>
<li
v-show="unreadMessageCount != 0"
class="list-none flex justify-center items-center"
>
<span
class="shadow-lg rounded-full bg-n-brand text-white text-xs font-medium my-2.5 mx-auto px-2.5 py-1.5"
>
{{ unreadMessageLabel }}
</span>
</li>
</template>
<template #after>
<ContactConversationLink
v-if="newerConversation"
direction="newer"
:conversation="newerConversation"
:to="
buildConversationPath(newerConversation.id, {
keepFolderScope: false,
})
"
@navigate="openConversation(newerConversation)"
/>
</template>
</MessageList>
<div class="flex relative flex-col bg-n-surface-1">
<div
v-if="isAnyoneTyping"
class="absolute flex items-center w-full h-0"
:class="isReplyFolded ? '-top-[5.5rem]' : '-top-7'"
>
<div
class="flex py-2 pr-4 pl-5 shadow-md rounded-full bg-white dark:bg-n-solid-3 text-n-slate-11 text-xs font-semibold my-2.5 mx-auto"
>
{{ typingUserNames }}
<img
class="w-6 ltr:ml-2 rtl:mr-2"
src="assets/images/typing.gif"
alt="Someone is typing"
/>
</div>
</div>
<Transition v-bind="foldTransition">
<div
v-if="isReplyFolded"
class="absolute inset-x-0 bottom-0 z-10 grid grid-rows-[var(--fold-rows,1fr)]"
>
<div class="min-h-0 min-w-0">
<CollapsedReplyBar
class="m-2"
:reply-label="
isReadingHistory
? $t('CONVERSATION.CONTACT_HISTORY.REPLY_TO_OLDER')
: $t('CONVERSATION.REPLYBOX.REPLY')
"
:has-latest="isReadingHistory && Boolean(latestConversation)"
:can-reply="canSendPublicReply"
@open="revealReplyBox"
@go-to-latest="openConversation(latestConversation)"
/>
</div>
</div>
</Transition>
<Transition v-bind="foldTransition">
<div
v-show="!isReplyFolded"
class="grid grid-rows-[var(--fold-rows,1fr)]"
>
<div class="min-h-0 min-w-0">
<ResizableEditorWrapper
ref="resizableEditorWrapperRef"
:container-height="Math.max(0, containerHeight - topBannerHeight)"
@collapse="collapseReplyBox"
>
<ReplyBox
ref="replyBoxRef"
@toggle-editor-size="toggleReplyEditorSize"
/>
</ResizableEditorWrapper>
</div>
</div>
</Transition>
</div>
</div>
</template>