feat: improve inbox management and conversation list

This commit is contained in:
2026-09-12 14:42:57 +08:00
parent ca63efaa79
commit c415d6d13f
22 changed files with 551 additions and 537 deletions
@@ -237,7 +237,11 @@ const conversationListPagination = computed(() => {
activeAssigneeTabCount.value < conversationsPerPage &&
activeAssigneeTabCount.value > chatsOnView.value.length;
if (isNoFiltersOrFoldersAndChatListNotEmpty && isUnderPerPage && !hasCurrentPageEndReached.value) {
if (
isNoFiltersOrFoldersAndChatListNotEmpty &&
isUnderPerPage &&
!hasCurrentPageEndReached.value
) {
return 1;
}
@@ -359,6 +363,10 @@ const uniqueInboxes = computed(() => {
return [...new Set(selectedInboxes.value)];
});
const showInboxNameInConversationCard = computed(() => {
return !props.conversationInbox;
});
// ---------------------- Methods -----------------------
function setFiltersFromUISettings() {
const { conversations_filter_by: filterBy = {} } = uiSettings.value;
@@ -791,6 +799,9 @@ onMounted(() => {
setFiltersFromUISettings();
store.dispatch('setChatStatusFilter', activeStatus.value);
store.dispatch('setChatSortFilter', activeSortBy.value);
if (!inboxesList.value.length) {
store.dispatch('inboxes/get');
}
resetAndFetchData();
if (hasActiveFolders.value) {
store.dispatch('campaigns/get');
@@ -800,15 +811,26 @@ onMounted(() => {
// Fallback: if fetchAllConversations wasn't triggered during onMounted
// (e.g., race condition with route transition), retry after store settles.
// Uses a one-shot watch on conversationStats to detect empty state.
const _fallbackWatch = watch(conversationStats, (stats) => {
if (!stats) return;
const totalCount = (stats.mineCount || 0) + (stats.unAssignedCount || 0) + (stats.allCount || 0);
// If stats show conversations exist but chatList is empty, re-fetch
if (totalCount > 0 && chatLists.value.length === 0 && !chatListLoading.value) {
_fallbackWatch(); // unwatch immediately — one-shot
fetchConversations();
}
}, { immediate: false });
const stopFallbackWatch = watch(
conversationStats,
stats => {
if (!stats) return;
const totalCount =
(stats.mineCount || 0) +
(stats.unAssignedCount || 0) +
(stats.allCount || 0);
// If stats show conversations exist but chatList is empty, re-fetch
if (
totalCount > 0 &&
chatLists.value.length === 0 &&
!chatListLoading.value
) {
stopFallbackWatch(); // unwatch immediately — one-shot
fetchConversations();
}
},
{ immediate: false }
);
const deleteConversationDialogRef = ref(null);
const selectedConversationId = ref(null);
@@ -877,7 +899,7 @@ watch(chatLists, () => {
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1 relative"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
isOnExpandedLayout ? 'basis-full' : 'w-[320px] 2xl:w-[360px]',
]"
>
<slot />
@@ -957,6 +979,7 @@ watch(chatLists, () => {
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:show-inbox-name="showInboxNameInConversationCard"
:is-on-expanded-layout="isOnExpandedLayout"
@load-more="loadMoreConversations"
/>
@@ -15,6 +15,7 @@ const props = defineProps({
conversationType: { type: String, default: '' },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
showExpanded: { type: Boolean, default: false },
});
@@ -52,7 +53,6 @@ watch(
);
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
@@ -68,13 +68,22 @@ const isActiveChat = computed(() => currentChat.value.id === props.source.id);
const inbox = computed(() => {
const inboxId = props.source.inbox_id;
return inboxId ? store.getters['inboxes/getInbox'](inboxId) : {};
if (!inboxId) return {};
const inboxRecord = store.getters['inboxes/getInbox'](inboxId);
return {
...inboxRecord,
id: inboxRecord.id || inboxId,
name: inboxRecord.name || `#${inboxId}`,
channel_type: inboxRecord.channel_type || props.source.meta?.channel,
};
});
const showInboxName = computed(
() => !activeInbox.value && inboxesList.value.length > 1
const showInboxName = computed(() => props.showInboxName);
const activeInboxForRoute = computed(() =>
props.showInboxName ? null : activeInbox.value
);
const isInboxView = computed(() => !!activeInbox.value);
const isInboxView = computed(() => !!activeInboxForRoute.value);
const showAssigneeForExpandedCard = computed(
() => props.showExpanded || props.showAssignee
);
@@ -83,7 +92,7 @@ const conversationPath = computed(() =>
frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
activeInbox: activeInboxForRoute.value,
id: props.source.id,
label: props.label,
teamId: props.teamId,
@@ -19,6 +19,7 @@ const props = defineProps({
foldersId: { type: [String, Number], default: 0 },
conversationType: { type: String, default: '' },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
isOnExpandedLayout: { type: Boolean, default: false },
});
@@ -77,6 +78,7 @@ defineExpose({ conversationListRef });
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssignee"
:show-inbox-name="showInboxName"
:show-expanded="showExpandedCards"
/>
</Virtualizer>
@@ -316,7 +316,7 @@ const resetDatePicker = () => {
const emitDateRange = () => {
if (!isValid(selectedStartDate.value) || !isValid(selectedEndDate.value)) {
useAlert('Please select a valid time range');
useAlert(t('DATE_PICKER.INVALID_TIME_RANGE'));
} else {
showDatePicker.value = false;
emit('dateRangeChanged', [
@@ -13,23 +13,23 @@ import VoiceCallStatus from './VoiceCallStatus.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
});
const emit = defineEmits([
'click',
'contextmenu',
'selectConversation',
'deSelectConversation',
'click',
'contextmenu',
'selectConversation',
'deSelectConversation',
]);
const hovered = ref(false);
@@ -39,205 +39,198 @@ const hasUnread = computed(() => unreadCount.value > 0);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => {
const last = lastMessageInChat.value;
if (last?.content_type !== 'voice_call' || !last.call) {
return { status: null, direction: null };
}
return {
status: last.call.status,
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
};
const last = lastMessageInChat.value;
if (last?.content_type !== 'voice_call' || !last.call) {
return { status: null, direction: null };
}
return {
status: last.call.status,
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
};
});
const showMetaSection = computed(() => {
return (
props.showInboxName ||
(props.showAssignee && props.assignee.name) ||
props.chat.priority
);
return (
props.showInboxName ||
(props.showAssignee && props.assignee.name) ||
props.chat.priority
);
});
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
});
const messagePreviewClass = computed(() => {
return [
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
];
return [
'!h-5 !leading-5',
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
];
});
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
hovered.value = false;
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, props.inbox.id);
} else {
emit('deSelectConversation', props.chat.id, props.inbox.id);
}
if (checked) {
emit('selectConversation', props.chat.id, props.inbox.id);
} else {
emit('deSelectConversation', props.chat.id, props.inbox.id);
}
};
const selectedModel = computed({
get: () => props.selected,
set: value => onSelectConversation(value),
get: () => props.selected,
set: value => onSelectConversation(value),
});
watch(
() => props.chat.id,
() => {
hovered.value = false;
}
() => props.chat.id,
() => {
hovered.value = false;
}
);
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
:class="{
'active animate-card-select bg-n-background !border-n-surface-1':
isActiveChat,
'selected bg-n-slate-2 !border-n-surface-1': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<div
class="relative"
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<Avatar
v-if="!hideThumbnail"
:name="currentContact.name"
:src="currentContact.thumbnail"
:size="32"
:status="currentContact.availability_status"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
hide-offline-status
>
<template #overlay="{ size }">
<label
v-if="hovered || selected"
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<Checkbox v-model="selectedModel" />
</label>
</template>
</Avatar>
</div>
<div class="px-0 py-3 flex-1 min-w-0 border-line">
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
:class="{
'ltr:ml-2 rtl:mr-2': !compact,
'mx-2': compact,
}"
>
<InboxName
v-if="showInboxName"
:inbox="inbox"
class="flex-1 min-w-0"
/>
<div
class="flex items-baseline gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
>
<fluent-icon
icon="person"
size="12"
class="text-n-slate-11"
/>
{{ assignee.name }}
</span>
<CardPriorityIcon
:priority="chat.priority"
class="flex-shrink-0 !size-3.5"
/>
</div>
</div>
<div class="flex min-w-0 items-center gap-2 mx-2 pt-0.5">
<h4
class="conversation--user text-sm my-0 min-w-0 flex-1 capitalize text-ellipsis overflow-hidden whitespace-nowrap text-n-slate-12"
:class="hasUnread ? 'font-semibold' : 'font-medium'"
>
{{ currentContact.name }}
</h4>
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
:conversation-id="chat.id"
class="w-[4.375rem] flex-shrink-0 text-end font-normal"
/>
</div>
<VoiceCallStatus
v-if="voiceCallData.status"
key="voice-status-row"
:status="voiceCallData.status"
:direction="voiceCallData.direction"
:message-preview-class="messagePreviewClass"
/>
<MessagePreview
v-else-if="lastMessageInChat"
key="message-preview"
:message="lastMessageInChat"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
:class="messagePreviewClass"
/>
<p
v-else
key="no-messages"
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-10"
icon="info"
/>
<span class="mx-0.5">
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<div
v-if="hasUnread"
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<UnreadBadge
:count="unreadCount"
class="ltr:ml-auto rtl:mr-auto mt-1"
/>
</div>
<CardLabels
v-if="showLabelsSection"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
</CardLabels>
</div>
</div>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
:class="{
'active animate-card-select bg-n-background !border-n-surface-1':
isActiveChat,
'selected bg-n-slate-2 !border-n-surface-1': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<div
class="relative"
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<Avatar
v-if="!hideThumbnail"
:name="currentContact.name"
:src="currentContact.thumbnail"
:size="32"
:status="currentContact.availability_status"
:class="!showInboxName ? 'mt-2' : 'mt-6'"
hide-offline-status
>
<template #overlay="{ size }">
<label
v-if="hovered || selected"
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<Checkbox v-model="selectedModel" />
</label>
</template>
</Avatar>
</div>
<div class="px-0 py-2 flex-1 min-w-0 border-line">
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
:class="{
'ltr:ml-2 rtl:mr-2': !compact,
'mx-2': compact,
}"
>
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
<div
class="flex items-baseline gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<CardPriorityIcon
:priority="chat.priority"
class="flex-shrink-0 !size-3.5"
/>
</div>
</div>
<div class="flex min-w-0 items-center gap-2 mx-2 pt-0.5">
<h4
class="conversation--user text-sm my-0 min-w-0 flex-1 capitalize text-ellipsis overflow-hidden whitespace-nowrap text-n-slate-12"
:class="hasUnread ? 'font-semibold' : 'font-medium'"
>
{{ currentContact.name }}
</h4>
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
:conversation-id="chat.id"
class="w-[4.375rem] flex-shrink-0 text-end font-normal"
/>
</div>
<VoiceCallStatus
v-if="voiceCallData.status"
key="voice-status-row"
:status="voiceCallData.status"
:direction="voiceCallData.direction"
:message-preview-class="messagePreviewClass"
/>
<MessagePreview
v-else-if="lastMessageInChat"
key="message-preview"
:message="lastMessageInChat"
class="my-0 mx-2 leading-5 h-5 flex-1 min-w-0 text-sm"
:class="messagePreviewClass"
/>
<p
v-else
key="no-messages"
class="text-n-slate-11 text-sm my-0 mx-2 leading-5 h-5 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-10"
icon="info"
/>
<span class="mx-0.5">
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<div
v-if="hasUnread"
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<UnreadBadge
:count="unreadCount"
class="ltr:ml-auto rtl:mr-auto mt-1"
/>
</div>
<CardLabels
v-if="showLabelsSection"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
</CardLabels>
</div>
</div>
</template>