Files
chatwoot/app/javascript/dashboard/components/ConversationList.vue
T
2026-02-22 22:51:58 +05:30

266 lines
8.1 KiB
Vue

<script setup>
import { provide, useTemplateRef, shallowRef, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useRouter, useRoute } from 'vue-router';
import { Virtualizer } from 'virtua/vue';
import { useInfiniteScroll, useBreakpoints } from '@vueuse/core';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAlert } from 'dashboard/composables';
import ConversationItem from './ConversationItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import wootConstants from 'dashboard/constants/globals';
import {
isOnMentionsView,
isOnUnattendedView,
} from 'dashboard/store/modules/conversations/helpers/actionHelpers';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
const props = defineProps({
items: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
showEndOfListMessage: { type: Boolean, default: false },
isContextMenuOpen: { type: Boolean, default: false },
label: { type: String, default: '' },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
conversationType: { type: String, default: '' },
showAssignee: { type: Boolean, default: false },
isExpandedLayout: { type: Boolean, default: false },
});
const emit = defineEmits(['loadMore']);
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const route = useRoute();
const parentRef = useTemplateRef('parentRef');
const virtualizerRef = shallowRef(null);
const deleteConversationDialogRef = ref(null);
const conversationToDelete = ref(null);
const resolveAttributesModalRef = ref(null);
provide('contextMenuElementTarget', parentRef);
const breakpoints = useBreakpoints({
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
});
const isLargeScreen = breakpoints.greaterOrEqual('lg');
provide('isLargeScreen', isLargeScreen);
useInfiniteScroll(
parentRef,
() => {
if (props.isLoading || props.showEndOfListMessage) return;
emit('loadMore');
},
{
distance: 400,
}
);
// Keyboard navigation for conversation list
const handleConversationNavigation = direction => {
if (!parentRef.value) return;
// Get all wrapper divs with data-id
const allWrappers = Array.from(parentRef.value.querySelectorAll('[data-id]'));
if (allWrappers.length === 0) return;
const activeIndex = allWrappers.findIndex(wrapper =>
wrapper.querySelector('.active')
);
const delta = direction === 'previous' ? -1 : 1;
const newIndex = activeIndex + delta;
// Clamp index to valid range [0, length-1]
const targetIndex = Math.max(0, Math.min(newIndex, allWrappers.length - 1));
// Click the first child (ConversationItem root element)
allWrappers[targetIndex]?.firstElementChild?.click();
};
useKeyboardEvents({
'Alt+KeyJ': {
action: () => handleConversationNavigation('previous'),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => handleConversationNavigation('next'),
allowOnFocusedInput: true,
},
});
function handleDeleteConversation(conversationId) {
conversationToDelete.value = conversationId;
deleteConversationDialogRef.value?.open();
}
function redirectToConversationList() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
}
async function deleteConversation() {
if (!conversationToDelete.value) return;
try {
await store.dispatch('deleteConversation', conversationToDelete.value);
redirectToConversationList();
deleteConversationDialogRef.value?.close();
conversationToDelete.value = null;
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
function handleShowResolveAttributesModal({
conversationId,
snoozedUntil,
missing,
currentCustomAttributes,
}) {
const conversationContext = {
id: conversationId,
snoozedUntil,
};
resolveAttributesModalRef.value?.open(
missing,
currentCustomAttributes,
conversationContext
);
}
function toggleConversationStatus(
conversationId,
status,
snoozedUntil,
customAttributes = null
) {
const payload = {
conversationId,
status,
snoozedUntil,
};
if (customAttributes) {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
});
}
function handleResolveWithAttributes({ attributes, context }) {
if (context) {
const existingConversation = store.getters.getConversationById(context.id);
const currentCustomAttributes =
existingConversation?.custom_attributes || {};
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
toggleConversationStatus(
context.id,
wootConstants.STATUS_TYPE.RESOLVED,
context.snoozedUntil,
mergedAttributes
);
}
}
</script>
<template>
<div
ref="parentRef"
class="flex-1 w-full h-full touch-pan-y overflow-x-hidden overscroll-contain [-webkit-overflow-scrolling:touch] [contain:strict] [overflow-anchor:none] [will-change:scroll-position] px-2 pt-2"
:class="isContextMenuOpen ? 'overflow-hidden' : 'overflow-y-auto'"
>
<Virtualizer
ref="virtualizerRef"
:data="items"
:scroll-ref="parentRef"
:item-size="isLargeScreen && isExpandedLayout ? 48 : 76"
:buffer-size="100"
item="div"
class="[&>div]:after:content-[''] [&>div]:after:absolute [&>div]:after:bottom-0 [&>div]:after:left-0 [&>div]:after:right-0 [&>div]:after:h-px [&>div]:after:bg-n-weak [&>div]:after:pointer-events-none [&>div:has(*:hover)]:after:!bg-n-surface-1 [&>div:has(+_*:hover)]:after:!bg-n-surface-1 [&>div:has(.active)]:after:!bg-n-surface-1 [&>div:has(+_*_.active)]:after:!bg-n-surface-1 [&>div:has(.selected)]:after:!bg-n-surface-1 [&>div:has(+_*_.selected)]:after:!bg-n-surface-1"
>
<template #default="{ item }">
<div :key="item.id" :data-id="item.id">
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssignee"
:is-expanded-layout="isExpandedLayout"
@delete-conversation="handleDeleteConversation"
@show-resolve-attributes-modal="handleShowResolveAttributesModal"
@redirect-to-list="redirectToConversationList"
/>
</div>
</template>
</Virtualizer>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: conversationToDelete,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
/>
<ConversationResolveAttributesModal
ref="resolveAttributesModalRef"
@submit="handleResolveWithAttributes"
/>
<div class="py-2">
<div v-if="isLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-sm text-n-slate-10 whitespace-nowrap"
>
<Icon
icon="i-woot-party"
class="size-4 inline-block align-middle ltr:mr-2 rtl:ml-2"
/>
<span class="align-middle">
{{ t('CHAT_LIST.EOF') }}
</span>
</p>
</div>
</div>
</template>