mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-04 13:07:55 +00:00 
			
		
		
		
	# Pull Request Template ## Description This PR introduces a `CustomTeleport` component that wraps Vue's Teleport to preserve directionality context (ltr / rtl) when teleporting content outside the app’s root container. ### Problem Currently, the app sets the text direction (`[dir="ltr"]` or `[dir="rtl"]`) on a container `div` inside `<body>`, not on `<body>` itself. When content is teleported directly into `body`, it no longer inherits the correct direction context. As a result, direction-aware utility classes like `ltr:pl-2`, `rtl:ml-1`, etc., break because CSS selectors like `[dir="ltr"] .ltr\:pl-2` no longer match. Identified this issue when working on this [PR](https://github.com/chatwoot/chatwoot/pull/11382) ### Solution The `CustomTeleport` component automatically applies the correct `[dir]` attribute (`ltr` or `rtl`) on the teleported content's wrapper based on the current `isRTL` setting from the store. This ensures that direction-specific Tailwind utility classes continue to work as expected, even when the content is rendered outside the app root. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## 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 - [ ] 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: Pranav <pranavrajs@gmail.com>
		
			
				
	
	
		
			966 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			966 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
<script setup>
 | 
						|
// [TODO] This componet is too big and bulky to be in the same file, we can consider splitting this into multiple
 | 
						|
// composables and components, useVirtualChatList, useChatlistFilters
 | 
						|
import {
 | 
						|
  ref,
 | 
						|
  unref,
 | 
						|
  provide,
 | 
						|
  computed,
 | 
						|
  watch,
 | 
						|
  onMounted,
 | 
						|
  onUnmounted,
 | 
						|
  defineEmits,
 | 
						|
} from 'vue';
 | 
						|
import { useStore } from 'vuex';
 | 
						|
import { useRoute, useRouter } from 'vue-router';
 | 
						|
import {
 | 
						|
  useMapGetter,
 | 
						|
  useFunctionGetter,
 | 
						|
} from 'dashboard/composables/store.js';
 | 
						|
 | 
						|
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
 | 
						|
// from the current one. But we should consider using tanstack virtual in the future
 | 
						|
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
 | 
						|
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
 | 
						|
import ChatListHeader from './ChatListHeader.vue';
 | 
						|
import ConversationFilter from 'next/filter/ConversationFilter.vue';
 | 
						|
import SaveCustomView from 'next/filter/SaveCustomView.vue';
 | 
						|
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
 | 
						|
import ConversationItem from './ConversationItem.vue';
 | 
						|
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
 | 
						|
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
 | 
						|
import IntersectionObserver from './IntersectionObserver.vue';
 | 
						|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
 | 
						|
 | 
						|
import { useUISettings } from 'dashboard/composables/useUISettings';
 | 
						|
import { useAlert } from 'dashboard/composables';
 | 
						|
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
 | 
						|
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
 | 
						|
import { useFilter } from 'shared/composables/useFilter';
 | 
						|
import { useTrack } from 'dashboard/composables';
 | 
						|
import { useI18n } from 'vue-i18n';
 | 
						|
import {
 | 
						|
  useCamelCase,
 | 
						|
  useSnakeCase,
 | 
						|
} from 'dashboard/composables/useTransformKeys';
 | 
						|
import { useEmitter } from 'dashboard/composables/emitter';
 | 
						|
import { useEventListener, useScrollLock } from '@vueuse/core';
 | 
						|
 | 
						|
import { emitter } from 'shared/helpers/mitt';
 | 
						|
 | 
						|
import wootConstants from 'dashboard/constants/globals';
 | 
						|
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
 | 
						|
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
 | 
						|
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
 | 
						|
import countries from 'shared/constants/countries';
 | 
						|
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
 | 
						|
import { conversationListPageURL } from '../helper/URLHelper';
 | 
						|
import {
 | 
						|
  isOnMentionsView,
 | 
						|
  isOnUnattendedView,
 | 
						|
} from '../store/modules/conversations/helpers/actionHelpers';
 | 
						|
import {
 | 
						|
  getUserPermissions,
 | 
						|
  filterItemsByPermission,
 | 
						|
} from 'dashboard/helper/permissionsHelper.js';
 | 
						|
import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers';
 | 
						|
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
 | 
						|
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
 | 
						|
 | 
						|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
 | 
						|
 | 
						|
const props = defineProps({
 | 
						|
  conversationInbox: { type: [String, Number], default: 0 },
 | 
						|
  teamId: { type: [String, Number], default: 0 },
 | 
						|
  label: { type: String, default: '' },
 | 
						|
  conversationType: { type: String, default: '' },
 | 
						|
  foldersId: { type: [String, Number], default: 0 },
 | 
						|
  showConversationList: { default: true, type: Boolean },
 | 
						|
  isOnExpandedLayout: { default: false, type: Boolean },
 | 
						|
});
 | 
						|
 | 
						|
const emit = defineEmits(['conversationLoad']);
 | 
						|
const { uiSettings } = useUISettings();
 | 
						|
const { t } = useI18n();
 | 
						|
const router = useRouter();
 | 
						|
const store = useStore();
 | 
						|
 | 
						|
const conversationListRef = ref(null);
 | 
						|
const conversationDynamicScroller = ref(null);
 | 
						|
const conversationListScrollableElement = computed(
 | 
						|
  () => conversationDynamicScroller.value?.$el
 | 
						|
);
 | 
						|
const conversationListScrollLock = useScrollLock(
 | 
						|
  conversationListScrollableElement
 | 
						|
);
 | 
						|
 | 
						|
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
 | 
						|
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
 | 
						|
const activeSortBy = ref(wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC);
 | 
						|
const showAdvancedFilters = ref(false);
 | 
						|
// chatsOnView is to store the chats that are currently visible on the screen,
 | 
						|
// which mirrors the conversationList.
 | 
						|
const chatsOnView = ref([]);
 | 
						|
const foldersQuery = ref({});
 | 
						|
const showAddFoldersModal = ref(false);
 | 
						|
const showDeleteFoldersModal = ref(false);
 | 
						|
const isContextMenuOpen = ref(false);
 | 
						|
const appliedFilter = ref([]);
 | 
						|
const advancedFilterTypes = ref(
 | 
						|
  advancedFilterOptions.map(filter => ({
 | 
						|
    ...filter,
 | 
						|
    attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
 | 
						|
  }))
 | 
						|
);
 | 
						|
 | 
						|
const currentUser = useMapGetter('getCurrentUser');
 | 
						|
const chatLists = useMapGetter('getFilteredConversations');
 | 
						|
const mineChatsList = useMapGetter('getMineChats');
 | 
						|
const allChatList = useMapGetter('getAllStatusChats');
 | 
						|
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
 | 
						|
const chatListLoading = useMapGetter('getChatListLoadingStatus');
 | 
						|
const activeInbox = useMapGetter('getSelectedInbox');
 | 
						|
const conversationStats = useMapGetter('conversationStats/getStats');
 | 
						|
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
 | 
						|
const folders = useMapGetter('customViews/getConversationCustomViews');
 | 
						|
const agentList = useMapGetter('agents/getAgents');
 | 
						|
const teamsList = useMapGetter('teams/getTeams');
 | 
						|
const inboxesList = useMapGetter('inboxes/getInboxes');
 | 
						|
const campaigns = useMapGetter('campaigns/getAllCampaigns');
 | 
						|
const labels = useMapGetter('labels/getLabels');
 | 
						|
const currentAccountId = useMapGetter('getCurrentAccountId');
 | 
						|
// We can't useFunctionGetter here since it needs to be called on setup?
 | 
						|
const getTeamFn = useMapGetter('teams/getTeam');
 | 
						|
 | 
						|
useChatListKeyboardEvents(conversationListRef);
 | 
						|
const {
 | 
						|
  selectedConversations,
 | 
						|
  selectedInboxes,
 | 
						|
  selectConversation,
 | 
						|
  deSelectConversation,
 | 
						|
  selectAllConversations,
 | 
						|
  resetBulkActions,
 | 
						|
  isConversationSelected,
 | 
						|
  onAssignAgent,
 | 
						|
  onAssignLabels,
 | 
						|
  onAssignTeamsForBulk,
 | 
						|
  onUpdateConversations,
 | 
						|
} = useBulkActions();
 | 
						|
 | 
						|
const {
 | 
						|
  initializeStatusAndAssigneeFilterToModal,
 | 
						|
  initializeInboxTeamAndLabelFilterToModal,
 | 
						|
} = useFilter({
 | 
						|
  filteri18nKey: 'FILTER',
 | 
						|
  attributeModel: 'conversation_attribute',
 | 
						|
});
 | 
						|
 | 
						|
// computed
 | 
						|
const intersectionObserverOptions = computed(() => {
 | 
						|
  return {
 | 
						|
    root: conversationListRef.value,
 | 
						|
    rootMargin: '100px 0px 100px 0px',
 | 
						|
  };
 | 
						|
});
 | 
						|
 | 
						|
const hasAppliedFilters = computed(() => {
 | 
						|
  return appliedFilters.value.length !== 0;
 | 
						|
});
 | 
						|
 | 
						|
const activeFolder = computed(() => {
 | 
						|
  if (props.foldersId) {
 | 
						|
    const activeView = folders.value.filter(
 | 
						|
      view => view.id === Number(props.foldersId)
 | 
						|
    );
 | 
						|
    const [firstValue] = activeView;
 | 
						|
    return firstValue;
 | 
						|
  }
 | 
						|
  return undefined;
 | 
						|
});
 | 
						|
 | 
						|
const activeFolderName = computed(() => {
 | 
						|
  return activeFolder.value?.name;
 | 
						|
});
 | 
						|
 | 
						|
const hasActiveFolders = computed(() => {
 | 
						|
  return Boolean(activeFolder.value && props.foldersId !== 0);
 | 
						|
});
 | 
						|
 | 
						|
const hasAppliedFiltersOrActiveFolders = computed(() => {
 | 
						|
  return hasAppliedFilters.value || hasActiveFolders.value;
 | 
						|
});
 | 
						|
 | 
						|
const currentUserDetails = computed(() => {
 | 
						|
  const { id, name } = currentUser.value;
 | 
						|
  return { id, name };
 | 
						|
});
 | 
						|
 | 
						|
const userPermissions = computed(() => {
 | 
						|
  return getUserPermissions(currentUser.value, currentAccountId.value);
 | 
						|
});
 | 
						|
 | 
						|
const assigneeTabItems = computed(() => {
 | 
						|
  return filterItemsByPermission(
 | 
						|
    ASSIGNEE_TYPE_TAB_PERMISSIONS,
 | 
						|
    userPermissions.value,
 | 
						|
    item => item.permissions
 | 
						|
  ).map(({ key, count: countKey }) => ({
 | 
						|
    key,
 | 
						|
    name: t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
 | 
						|
    count: conversationStats.value[countKey] || 0,
 | 
						|
  }));
 | 
						|
});
 | 
						|
 | 
						|
const showAssigneeInConversationCard = computed(() => {
 | 
						|
  return (
 | 
						|
    hasAppliedFiltersOrActiveFolders.value ||
 | 
						|
    activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.ALL
 | 
						|
  );
 | 
						|
});
 | 
						|
 | 
						|
const currentPageFilterKey = computed(() => {
 | 
						|
  return hasAppliedFiltersOrActiveFolders.value
 | 
						|
    ? 'appliedFilters'
 | 
						|
    : activeAssigneeTab.value;
 | 
						|
});
 | 
						|
 | 
						|
const inbox = useFunctionGetter('inboxes/getInbox', activeInbox);
 | 
						|
const currentPage = useFunctionGetter(
 | 
						|
  'conversationPage/getCurrentPageFilter',
 | 
						|
  activeAssigneeTab
 | 
						|
);
 | 
						|
const currentFiltersPage = useFunctionGetter(
 | 
						|
  'conversationPage/getCurrentPageFilter',
 | 
						|
  currentPageFilterKey
 | 
						|
);
 | 
						|
const hasCurrentPageEndReached = useFunctionGetter(
 | 
						|
  'conversationPage/getHasEndReached',
 | 
						|
  currentPageFilterKey
 | 
						|
);
 | 
						|
 | 
						|
const conversationCustomAttributes = useFunctionGetter(
 | 
						|
  'attributes/getAttributesByModel',
 | 
						|
  'conversation_attribute'
 | 
						|
);
 | 
						|
 | 
						|
const activeAssigneeTabCount = computed(() => {
 | 
						|
  const count = assigneeTabItems.value.find(
 | 
						|
    item => item.key === activeAssigneeTab.value
 | 
						|
  ).count;
 | 
						|
  return count;
 | 
						|
});
 | 
						|
 | 
						|
const conversationListPagination = computed(() => {
 | 
						|
  const conversationsPerPage = 25;
 | 
						|
  const hasChatsOnView =
 | 
						|
    chatsOnView.value &&
 | 
						|
    Array.isArray(chatsOnView.value) &&
 | 
						|
    !chatsOnView.value.length;
 | 
						|
  const isNoFiltersOrFoldersAndChatListNotEmpty =
 | 
						|
    !hasAppliedFiltersOrActiveFolders.value && hasChatsOnView;
 | 
						|
  const isUnderPerPage =
 | 
						|
    chatsOnView.value.length < conversationsPerPage &&
 | 
						|
    activeAssigneeTabCount.value < conversationsPerPage &&
 | 
						|
    activeAssigneeTabCount.value > chatsOnView.value.length;
 | 
						|
 | 
						|
  if (isNoFiltersOrFoldersAndChatListNotEmpty && isUnderPerPage) {
 | 
						|
    return 1;
 | 
						|
  }
 | 
						|
 | 
						|
  return currentPage.value + 1;
 | 
						|
});
 | 
						|
 | 
						|
const conversationFilters = computed(() => {
 | 
						|
  return {
 | 
						|
    inboxId: props.conversationInbox ? props.conversationInbox : undefined,
 | 
						|
    assigneeType: activeAssigneeTab.value,
 | 
						|
    status: activeStatus.value,
 | 
						|
    sortBy: activeSortBy.value,
 | 
						|
    page: conversationListPagination.value,
 | 
						|
    labels: props.label ? [props.label] : undefined,
 | 
						|
    teamId: props.teamId || undefined,
 | 
						|
    conversationType: props.conversationType || undefined,
 | 
						|
  };
 | 
						|
});
 | 
						|
 | 
						|
const activeTeam = computed(() => {
 | 
						|
  if (props.teamId) {
 | 
						|
    return getTeamFn.value(props.teamId);
 | 
						|
  }
 | 
						|
  return {};
 | 
						|
});
 | 
						|
 | 
						|
const pageTitle = computed(() => {
 | 
						|
  if (hasAppliedFilters.value) {
 | 
						|
    return t('CHAT_LIST.TAB_HEADING');
 | 
						|
  }
 | 
						|
  if (inbox.value.name) {
 | 
						|
    return inbox.value.name;
 | 
						|
  }
 | 
						|
  if (activeTeam.value.name) {
 | 
						|
    return activeTeam.value.name;
 | 
						|
  }
 | 
						|
  if (props.label) {
 | 
						|
    return `#${props.label}`;
 | 
						|
  }
 | 
						|
  if (props.conversationType === 'mention') {
 | 
						|
    return t('CHAT_LIST.MENTION_HEADING');
 | 
						|
  }
 | 
						|
  if (props.conversationType === 'participating') {
 | 
						|
    return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
 | 
						|
  }
 | 
						|
  if (props.conversationType === 'unattended') {
 | 
						|
    return t('CHAT_LIST.UNATTENDED_HEADING');
 | 
						|
  }
 | 
						|
  if (hasActiveFolders.value) {
 | 
						|
    return activeFolder.value.name;
 | 
						|
  }
 | 
						|
  return t('CHAT_LIST.TAB_HEADING');
 | 
						|
});
 | 
						|
 | 
						|
const conversationList = computed(() => {
 | 
						|
  let localConversationList = [];
 | 
						|
 | 
						|
  if (!hasAppliedFiltersOrActiveFolders.value) {
 | 
						|
    const filters = conversationFilters.value;
 | 
						|
    if (activeAssigneeTab.value === 'me') {
 | 
						|
      localConversationList = [...mineChatsList.value(filters)];
 | 
						|
    } else if (activeAssigneeTab.value === 'unassigned') {
 | 
						|
      localConversationList = [...unAssignedChatsList.value(filters)];
 | 
						|
    } else {
 | 
						|
      localConversationList = [...allChatList.value(filters)];
 | 
						|
    }
 | 
						|
  } else {
 | 
						|
    localConversationList = [...chatLists.value];
 | 
						|
  }
 | 
						|
 | 
						|
  if (activeFolder.value) {
 | 
						|
    const { payload } = activeFolder.value.query;
 | 
						|
    localConversationList = localConversationList.filter(conversation => {
 | 
						|
      return matchesFilters(conversation, payload);
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  return localConversationList;
 | 
						|
});
 | 
						|
 | 
						|
const showEndOfListMessage = computed(() => {
 | 
						|
  return (
 | 
						|
    conversationList.value.length &&
 | 
						|
    hasCurrentPageEndReached.value &&
 | 
						|
    !chatListLoading.value
 | 
						|
  );
 | 
						|
});
 | 
						|
 | 
						|
const allConversationsSelected = computed(() => {
 | 
						|
  return (
 | 
						|
    conversationList.value.length === selectedConversations.value.length &&
 | 
						|
    conversationList.value.every(el =>
 | 
						|
      selectedConversations.value.includes(el.id)
 | 
						|
    )
 | 
						|
  );
 | 
						|
});
 | 
						|
 | 
						|
const uniqueInboxes = computed(() => {
 | 
						|
  return [...new Set(selectedInboxes.value)];
 | 
						|
});
 | 
						|
 | 
						|
// ---------------------- Methods -----------------------
 | 
						|
function setFiltersFromUISettings() {
 | 
						|
  const { conversations_filter_by: filterBy = {} } = uiSettings.value;
 | 
						|
  const { status, order_by: orderBy } = filterBy;
 | 
						|
  activeStatus.value = status || wootConstants.STATUS_TYPE.OPEN;
 | 
						|
  activeSortBy.value = Object.values(wootConstants.SORT_BY_TYPE).includes(
 | 
						|
    orderBy
 | 
						|
  )
 | 
						|
    ? orderBy
 | 
						|
    : wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
 | 
						|
}
 | 
						|
 | 
						|
function emitConversationLoaded() {
 | 
						|
  emit('conversationLoad');
 | 
						|
  // [VITE] removing this since the library has changed
 | 
						|
  // nextTick(() => {
 | 
						|
  //   // Addressing a known issue in the virtual list library where dynamically added items
 | 
						|
  //   // might not render correctly. This workaround involves a slight manual adjustment
 | 
						|
  //   // to the scroll position, triggering the list to refresh its rendering.
 | 
						|
  //   const virtualList = conversationListRef.value;
 | 
						|
  //   const scrollToOffset = virtualList?.scrollToOffset;
 | 
						|
  //   const currentOffset = virtualList?.getOffset() || 0;
 | 
						|
  //   if (scrollToOffset) {
 | 
						|
  //     scrollToOffset(currentOffset + 1);
 | 
						|
  //   }
 | 
						|
  // });
 | 
						|
}
 | 
						|
 | 
						|
function fetchFilteredConversations(payload) {
 | 
						|
  payload = useSnakeCase(payload);
 | 
						|
  let page = currentFiltersPage.value + 1;
 | 
						|
  store
 | 
						|
    .dispatch('fetchFilteredConversations', {
 | 
						|
      queryData: filterQueryGenerator(payload),
 | 
						|
      page,
 | 
						|
    })
 | 
						|
    .then(emitConversationLoaded);
 | 
						|
 | 
						|
  showAdvancedFilters.value = false;
 | 
						|
}
 | 
						|
 | 
						|
function fetchSavedFilteredConversations(payload) {
 | 
						|
  payload = useSnakeCase(payload);
 | 
						|
  let page = currentFiltersPage.value + 1;
 | 
						|
  store
 | 
						|
    .dispatch('fetchFilteredConversations', {
 | 
						|
      queryData: payload,
 | 
						|
      page,
 | 
						|
    })
 | 
						|
    .then(emitConversationLoaded);
 | 
						|
}
 | 
						|
 | 
						|
function onApplyFilter(payload) {
 | 
						|
  payload = useSnakeCase(payload);
 | 
						|
  resetBulkActions();
 | 
						|
  foldersQuery.value = filterQueryGenerator(payload);
 | 
						|
  store.dispatch('conversationPage/reset');
 | 
						|
  store.dispatch('emptyAllConversations');
 | 
						|
  fetchFilteredConversations(payload);
 | 
						|
}
 | 
						|
 | 
						|
function closeAdvanceFiltersModal() {
 | 
						|
  showAdvancedFilters.value = false;
 | 
						|
  appliedFilter.value = [];
 | 
						|
}
 | 
						|
 | 
						|
function onUpdateSavedFilter(payload, folderName) {
 | 
						|
  const transformedPayload = useSnakeCase(payload);
 | 
						|
  const payloadData = {
 | 
						|
    ...unref(activeFolder),
 | 
						|
    name: unref(folderName),
 | 
						|
    query: filterQueryGenerator(transformedPayload),
 | 
						|
  };
 | 
						|
  store.dispatch('customViews/update', payloadData);
 | 
						|
  closeAdvanceFiltersModal();
 | 
						|
}
 | 
						|
 | 
						|
function onClickOpenAddFoldersModal() {
 | 
						|
  showAddFoldersModal.value = true;
 | 
						|
}
 | 
						|
 | 
						|
function onCloseAddFoldersModal() {
 | 
						|
  showAddFoldersModal.value = false;
 | 
						|
}
 | 
						|
 | 
						|
function onClickOpenDeleteFoldersModal() {
 | 
						|
  showDeleteFoldersModal.value = true;
 | 
						|
}
 | 
						|
 | 
						|
function onCloseDeleteFoldersModal() {
 | 
						|
  showDeleteFoldersModal.value = false;
 | 
						|
}
 | 
						|
 | 
						|
function setParamsForEditFolderModal() {
 | 
						|
  // Here we are setting the params for edit folder modal to show the existing values.
 | 
						|
 | 
						|
  // For agent, team, inboxes,and campaigns we get only the id's from the query.
 | 
						|
  // So we are mapping the id's to the actual values.
 | 
						|
 | 
						|
  // For labels we get the name of the label from the query.
 | 
						|
  // If we delete the label from the label list then we will not be able to show the label name.
 | 
						|
 | 
						|
  // For custom attributes we get only attribute key.
 | 
						|
  // So we are mapping it to find the input type of the attribute to show in the edit folder modal.
 | 
						|
  return {
 | 
						|
    agents: agentList.value,
 | 
						|
    teams: teamsList.value,
 | 
						|
    inboxes: inboxesList.value,
 | 
						|
    labels: labels.value,
 | 
						|
    campaigns: campaigns.value,
 | 
						|
    languages: languages,
 | 
						|
    countries: countries,
 | 
						|
    priority: [
 | 
						|
      { id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') },
 | 
						|
      { id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') },
 | 
						|
      { id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
 | 
						|
      { id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
 | 
						|
    ],
 | 
						|
    filterTypes: advancedFilterTypes.value,
 | 
						|
    allCustomAttributes: conversationCustomAttributes.value,
 | 
						|
  };
 | 
						|
}
 | 
						|
 | 
						|
function initializeExistingFilterToModal() {
 | 
						|
  const statusFilter = initializeStatusAndAssigneeFilterToModal(
 | 
						|
    activeStatus.value,
 | 
						|
    currentUserDetails.value,
 | 
						|
    activeAssigneeTab.value
 | 
						|
  );
 | 
						|
  // TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
 | 
						|
  if (statusFilter) {
 | 
						|
    appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
 | 
						|
  }
 | 
						|
 | 
						|
  // TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
 | 
						|
  const otherFilters = initializeInboxTeamAndLabelFilterToModal(
 | 
						|
    props.conversationInbox,
 | 
						|
    inbox.value,
 | 
						|
    props.teamId,
 | 
						|
    activeTeam.value,
 | 
						|
    props.label
 | 
						|
  ).map(useCamelCase);
 | 
						|
 | 
						|
  appliedFilter.value = [...appliedFilter.value, ...otherFilters];
 | 
						|
}
 | 
						|
 | 
						|
function initializeFolderToFilterModal(newActiveFolder) {
 | 
						|
  // Here we are setting the params for edit folder modal.
 | 
						|
  //  To show the existing values. when we click on edit folder button.
 | 
						|
 | 
						|
  // Here we get the query from the active folder.
 | 
						|
  // And we are mapping the query to the actual values.
 | 
						|
  // To show in the edit folder modal by the help of generateValuesForEditCustomViews helper.
 | 
						|
  const query = unref(newActiveFolder)?.query?.payload;
 | 
						|
  if (!Array.isArray(query)) return;
 | 
						|
 | 
						|
  const newFilters = query.map(filter => {
 | 
						|
    const transformed = useCamelCase(filter);
 | 
						|
    const values = Array.isArray(transformed.values)
 | 
						|
      ? generateValuesForEditCustomViews(
 | 
						|
          useSnakeCase(filter),
 | 
						|
          setParamsForEditFolderModal()
 | 
						|
        )
 | 
						|
      : [];
 | 
						|
 | 
						|
    return {
 | 
						|
      attributeKey: transformed.attributeKey,
 | 
						|
      attributeModel: transformed.attributeModel,
 | 
						|
      customAttributeType: transformed.customAttributeType,
 | 
						|
      filterOperator: transformed.filterOperator,
 | 
						|
      queryOperator: transformed.queryOperator ?? 'and',
 | 
						|
      values,
 | 
						|
    };
 | 
						|
  });
 | 
						|
 | 
						|
  appliedFilter.value = [...appliedFilter.value, ...newFilters];
 | 
						|
}
 | 
						|
 | 
						|
function initalizeAppliedFiltersToModal() {
 | 
						|
  appliedFilter.value = [...appliedFilters.value];
 | 
						|
}
 | 
						|
 | 
						|
function onToggleAdvanceFiltersModal() {
 | 
						|
  if (showAdvancedFilters.value === true) {
 | 
						|
    closeAdvanceFiltersModal();
 | 
						|
    return;
 | 
						|
  }
 | 
						|
 | 
						|
  if (!hasAppliedFilters.value && !hasActiveFolders.value) {
 | 
						|
    initializeExistingFilterToModal();
 | 
						|
  }
 | 
						|
  if (hasActiveFolders.value) {
 | 
						|
    initializeFolderToFilterModal(activeFolder.value);
 | 
						|
  }
 | 
						|
  if (hasAppliedFilters.value) {
 | 
						|
    initalizeAppliedFiltersToModal();
 | 
						|
  }
 | 
						|
 | 
						|
  showAdvancedFilters.value = true;
 | 
						|
}
 | 
						|
 | 
						|
function fetchConversations() {
 | 
						|
  store.dispatch('updateChatListFilters', conversationFilters.value);
 | 
						|
  store.dispatch('fetchAllConversations').then(emitConversationLoaded);
 | 
						|
}
 | 
						|
 | 
						|
function resetAndFetchData() {
 | 
						|
  appliedFilter.value = [];
 | 
						|
  resetBulkActions();
 | 
						|
  store.dispatch('conversationPage/reset');
 | 
						|
  store.dispatch('emptyAllConversations');
 | 
						|
  store.dispatch('clearConversationFilters');
 | 
						|
  if (hasActiveFolders.value) {
 | 
						|
    const payload = activeFolder.value.query;
 | 
						|
    fetchSavedFilteredConversations(payload);
 | 
						|
  }
 | 
						|
  if (props.foldersId) {
 | 
						|
    return;
 | 
						|
  }
 | 
						|
  fetchConversations();
 | 
						|
}
 | 
						|
 | 
						|
function loadMoreConversations() {
 | 
						|
  if (hasCurrentPageEndReached.value || chatListLoading.value) {
 | 
						|
    return;
 | 
						|
  }
 | 
						|
 | 
						|
  if (!hasAppliedFiltersOrActiveFolders.value) {
 | 
						|
    fetchConversations();
 | 
						|
  } else if (hasActiveFolders.value) {
 | 
						|
    const payload = activeFolder.value.query;
 | 
						|
    fetchSavedFilteredConversations(payload);
 | 
						|
  } else if (hasAppliedFilters.value) {
 | 
						|
    fetchFilteredConversations(appliedFilters.value);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
// Add a method to handle scroll events
 | 
						|
function handleScroll() {
 | 
						|
  const scroller = conversationDynamicScroller.value;
 | 
						|
  if (scroller && scroller.hasScrollbar) {
 | 
						|
    const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
 | 
						|
    if (scrollHeight - (scrollTop + clientHeight) < 100) {
 | 
						|
      loadMoreConversations();
 | 
						|
    }
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
function updateAssigneeTab(selectedTab) {
 | 
						|
  if (activeAssigneeTab.value !== selectedTab) {
 | 
						|
    resetBulkActions();
 | 
						|
    emitter.emit('clearSearchInput');
 | 
						|
    activeAssigneeTab.value = selectedTab;
 | 
						|
    if (!currentPage.value) {
 | 
						|
      fetchConversations();
 | 
						|
    }
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
function onBasicFilterChange(value, type) {
 | 
						|
  if (type === 'status') {
 | 
						|
    activeStatus.value = value;
 | 
						|
  } else {
 | 
						|
    activeSortBy.value = value;
 | 
						|
  }
 | 
						|
  resetAndFetchData();
 | 
						|
}
 | 
						|
 | 
						|
function openLastSavedItemInFolder() {
 | 
						|
  const lastItemOfFolder = folders.value[folders.value.length - 1];
 | 
						|
  const lastItemId = lastItemOfFolder.id;
 | 
						|
  router.push({
 | 
						|
    name: 'folder_conversations',
 | 
						|
    params: { id: lastItemId },
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
function openLastItemAfterDeleteInFolder() {
 | 
						|
  if (folders.value.length > 0) {
 | 
						|
    openLastSavedItemInFolder();
 | 
						|
  } else {
 | 
						|
    router.push({ name: 'home' });
 | 
						|
    fetchConversations();
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
async function assignPriority(priority, conversationId = null) {
 | 
						|
  store.dispatch('setCurrentChatPriority', {
 | 
						|
    priority,
 | 
						|
    conversationId,
 | 
						|
  });
 | 
						|
  store.dispatch('assignPriority', { conversationId, priority }).then(() => {
 | 
						|
    useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
 | 
						|
      newValue: priority,
 | 
						|
      from: 'Context menu',
 | 
						|
    });
 | 
						|
    useAlert(
 | 
						|
      t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
 | 
						|
        priority,
 | 
						|
        conversationId,
 | 
						|
      })
 | 
						|
    );
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
async function markAsUnread(conversationId) {
 | 
						|
  try {
 | 
						|
    await store.dispatch('markMessagesUnread', {
 | 
						|
      id: conversationId,
 | 
						|
    });
 | 
						|
    const {
 | 
						|
      params: { accountId, inbox_id: inboxId, label, teamId },
 | 
						|
      name,
 | 
						|
    } = useRoute();
 | 
						|
    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,
 | 
						|
      })
 | 
						|
    );
 | 
						|
  } catch (error) {
 | 
						|
    // Ignore error
 | 
						|
  }
 | 
						|
}
 | 
						|
async function markAsRead(conversationId) {
 | 
						|
  try {
 | 
						|
    await store.dispatch('markMessagesRead', {
 | 
						|
      id: conversationId,
 | 
						|
    });
 | 
						|
  } catch (error) {
 | 
						|
    // Ignore error
 | 
						|
  }
 | 
						|
}
 | 
						|
async function onAssignTeam(team, conversationId = null) {
 | 
						|
  try {
 | 
						|
    await store.dispatch('assignTeam', {
 | 
						|
      conversationId,
 | 
						|
      teamId: team.id,
 | 
						|
    });
 | 
						|
    useAlert(
 | 
						|
      t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL', {
 | 
						|
        team: team.name,
 | 
						|
        conversationId,
 | 
						|
      })
 | 
						|
    );
 | 
						|
  } catch (error) {
 | 
						|
    useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED'));
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
function toggleConversationStatus(conversationId, status, snoozedUntil) {
 | 
						|
  store
 | 
						|
    .dispatch('toggleStatus', {
 | 
						|
      conversationId,
 | 
						|
      status,
 | 
						|
      snoozedUntil,
 | 
						|
    })
 | 
						|
    .then(() => {
 | 
						|
      useAlert(t('CONVERSATION.CHANGE_STATUS'));
 | 
						|
    });
 | 
						|
}
 | 
						|
 | 
						|
function allSelectedConversationsStatus(status) {
 | 
						|
  if (!selectedConversations.value.length) return false;
 | 
						|
  return selectedConversations.value.every(item => {
 | 
						|
    return store.getters.getConversationById(item)?.status === status;
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
function onContextMenuToggle(state) {
 | 
						|
  isContextMenuOpen.value = state;
 | 
						|
  conversationListScrollLock.value = state;
 | 
						|
}
 | 
						|
 | 
						|
function toggleSelectAll(check) {
 | 
						|
  selectAllConversations(check, conversationList);
 | 
						|
}
 | 
						|
 | 
						|
useEmitter('fetch_conversation_stats', () => {
 | 
						|
  store.dispatch('conversationStats/get', conversationFilters.value);
 | 
						|
});
 | 
						|
 | 
						|
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
 | 
						|
 | 
						|
onMounted(() => {
 | 
						|
  store.dispatch('setChatListFilters', conversationFilters.value);
 | 
						|
  setFiltersFromUISettings();
 | 
						|
  store.dispatch('setChatStatusFilter', activeStatus.value);
 | 
						|
  store.dispatch('setChatSortFilter', activeSortBy.value);
 | 
						|
  resetAndFetchData();
 | 
						|
  if (hasActiveFolders.value) {
 | 
						|
    store.dispatch('campaigns/get');
 | 
						|
  }
 | 
						|
});
 | 
						|
 | 
						|
onUnmounted(() => {
 | 
						|
  conversationListScrollLock.value = false;
 | 
						|
});
 | 
						|
 | 
						|
provide('selectConversation', selectConversation);
 | 
						|
provide('deSelectConversation', deSelectConversation);
 | 
						|
provide('assignAgent', onAssignAgent);
 | 
						|
provide('assignTeam', onAssignTeam);
 | 
						|
provide('assignLabels', onAssignLabels);
 | 
						|
provide('updateConversationStatus', toggleConversationStatus);
 | 
						|
provide('toggleContextMenu', onContextMenuToggle);
 | 
						|
provide('markAsUnread', markAsUnread);
 | 
						|
provide('markAsRead', markAsRead);
 | 
						|
provide('assignPriority', assignPriority);
 | 
						|
provide('isConversationSelected', isConversationSelected);
 | 
						|
 | 
						|
watch(activeTeam, () => resetAndFetchData());
 | 
						|
 | 
						|
watch(
 | 
						|
  computed(() => props.conversationInbox),
 | 
						|
  () => resetAndFetchData()
 | 
						|
);
 | 
						|
watch(
 | 
						|
  computed(() => props.label),
 | 
						|
  () => resetAndFetchData()
 | 
						|
);
 | 
						|
watch(
 | 
						|
  computed(() => props.conversationType),
 | 
						|
  () => resetAndFetchData()
 | 
						|
);
 | 
						|
 | 
						|
watch(activeFolder, (newVal, oldVal) => {
 | 
						|
  if (newVal !== oldVal) {
 | 
						|
    store.dispatch('customViews/setActiveConversationFolder', newVal || null);
 | 
						|
  }
 | 
						|
  resetAndFetchData();
 | 
						|
});
 | 
						|
 | 
						|
watch(chatLists, () => {
 | 
						|
  chatsOnView.value = conversationList.value;
 | 
						|
});
 | 
						|
 | 
						|
watch(conversationFilters, (newVal, oldVal) => {
 | 
						|
  if (newVal !== oldVal) {
 | 
						|
    store.dispatch('updateChatListFilters', newVal);
 | 
						|
  }
 | 
						|
});
 | 
						|
</script>
 | 
						|
 | 
						|
<template>
 | 
						|
  <div
 | 
						|
    class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
 | 
						|
    :class="[
 | 
						|
      { hidden: !showConversationList },
 | 
						|
      isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
 | 
						|
    ]"
 | 
						|
  >
 | 
						|
    <slot />
 | 
						|
    <ChatListHeader
 | 
						|
      :page-title="pageTitle"
 | 
						|
      :has-applied-filters="hasAppliedFilters"
 | 
						|
      :has-active-folders="hasActiveFolders"
 | 
						|
      :active-status="activeStatus"
 | 
						|
      :is-on-expanded-layout="isOnExpandedLayout"
 | 
						|
      @add-folders="onClickOpenAddFoldersModal"
 | 
						|
      @delete-folders="onClickOpenDeleteFoldersModal"
 | 
						|
      @filters-modal="onToggleAdvanceFiltersModal"
 | 
						|
      @reset-filters="resetAndFetchData"
 | 
						|
      @basic-filter-change="onBasicFilterChange"
 | 
						|
    />
 | 
						|
 | 
						|
    <TeleportWithDirection
 | 
						|
      v-if="showAddFoldersModal"
 | 
						|
      to="#saveFilterTeleportTarget"
 | 
						|
    >
 | 
						|
      <SaveCustomView
 | 
						|
        v-model="appliedFilter"
 | 
						|
        :custom-views-query="foldersQuery"
 | 
						|
        :open-last-saved-item="openLastSavedItemInFolder"
 | 
						|
        @close="onCloseAddFoldersModal"
 | 
						|
      />
 | 
						|
    </TeleportWithDirection>
 | 
						|
 | 
						|
    <DeleteCustomViews
 | 
						|
      v-if="showDeleteFoldersModal"
 | 
						|
      v-model:show="showDeleteFoldersModal"
 | 
						|
      :active-custom-view="activeFolder"
 | 
						|
      :custom-views-id="foldersId"
 | 
						|
      :open-last-item-after-delete="openLastItemAfterDeleteInFolder"
 | 
						|
      @close="onCloseDeleteFoldersModal"
 | 
						|
    />
 | 
						|
 | 
						|
    <ChatTypeTabs
 | 
						|
      v-if="!hasAppliedFiltersOrActiveFolders"
 | 
						|
      :items="assigneeTabItems"
 | 
						|
      :active-tab="activeAssigneeTab"
 | 
						|
      is-compact
 | 
						|
      @chat-tab-change="updateAssigneeTab"
 | 
						|
    />
 | 
						|
 | 
						|
    <p
 | 
						|
      v-if="!chatListLoading && !conversationList.length"
 | 
						|
      class="flex items-center justify-center p-4 overflow-auto"
 | 
						|
    >
 | 
						|
      {{ $t('CHAT_LIST.LIST.404') }}
 | 
						|
    </p>
 | 
						|
    <ConversationBulkActions
 | 
						|
      v-if="selectedConversations.length"
 | 
						|
      :conversations="selectedConversations"
 | 
						|
      :all-conversations-selected="allConversationsSelected"
 | 
						|
      :selected-inboxes="uniqueInboxes"
 | 
						|
      :show-open-action="allSelectedConversationsStatus('open')"
 | 
						|
      :show-resolved-action="allSelectedConversationsStatus('resolved')"
 | 
						|
      :show-snoozed-action="allSelectedConversationsStatus('snoozed')"
 | 
						|
      @select-all-conversations="toggleSelectAll"
 | 
						|
      @assign-agent="onAssignAgent"
 | 
						|
      @update-conversations="onUpdateConversations"
 | 
						|
      @assign-labels="onAssignLabels"
 | 
						|
      @assign-team="onAssignTeamsForBulk"
 | 
						|
    />
 | 
						|
    <div
 | 
						|
      ref="conversationListRef"
 | 
						|
      class="flex-1 overflow-hidden conversations-list hover:overflow-y-auto"
 | 
						|
      :class="{ 'overflow-hidden': isContextMenuOpen }"
 | 
						|
    >
 | 
						|
      <DynamicScroller
 | 
						|
        ref="conversationDynamicScroller"
 | 
						|
        :items="conversationList"
 | 
						|
        :min-item-size="24"
 | 
						|
        class="w-full h-full overflow-auto"
 | 
						|
      >
 | 
						|
        <template #default="{ item, index, active }">
 | 
						|
          <!--
 | 
						|
            If we encounter resizing issues, we can set the `watchData` prop to true
 | 
						|
            this will deeply watch the entire object instead of just size dependencies
 | 
						|
            But it can impact performance
 | 
						|
          -->
 | 
						|
          <DynamicScrollerItem
 | 
						|
            :item="item"
 | 
						|
            :active="active"
 | 
						|
            :data-index="index"
 | 
						|
            :size-dependencies="[
 | 
						|
              item.messages,
 | 
						|
              item.labels,
 | 
						|
              item.uuid,
 | 
						|
              item.inbox_id,
 | 
						|
            ]"
 | 
						|
          >
 | 
						|
            <ConversationItem
 | 
						|
              :source="item"
 | 
						|
              :label="label"
 | 
						|
              :team-id="teamId"
 | 
						|
              :folders-id="foldersId"
 | 
						|
              :conversation-type="conversationType"
 | 
						|
              :show-assignee="showAssigneeInConversationCard"
 | 
						|
              @select-conversation="selectConversation"
 | 
						|
              @de-select-conversation="deSelectConversation"
 | 
						|
            />
 | 
						|
          </DynamicScrollerItem>
 | 
						|
        </template>
 | 
						|
        <template #after>
 | 
						|
          <div v-if="chatListLoading" class="text-center">
 | 
						|
            <span class="mt-4 mb-4 spinner" />
 | 
						|
          </div>
 | 
						|
          <p
 | 
						|
            v-else-if="showEndOfListMessage"
 | 
						|
            class="p-4 text-center text-slate-400 dark:text-slate-300"
 | 
						|
          >
 | 
						|
            {{ $t('CHAT_LIST.EOF') }}
 | 
						|
          </p>
 | 
						|
          <IntersectionObserver
 | 
						|
            v-else
 | 
						|
            :options="intersectionObserverOptions"
 | 
						|
            @observed="loadMoreConversations"
 | 
						|
          />
 | 
						|
        </template>
 | 
						|
      </DynamicScroller>
 | 
						|
    </div>
 | 
						|
    <TeleportWithDirection
 | 
						|
      v-if="showAdvancedFilters"
 | 
						|
      to="#conversationFilterTeleportTarget"
 | 
						|
    >
 | 
						|
      <ConversationFilter
 | 
						|
        v-model="appliedFilter"
 | 
						|
        :folder-name="activeFolderName"
 | 
						|
        :is-folder-view="hasActiveFolders"
 | 
						|
        @apply-filter="onApplyFilter"
 | 
						|
        @update-folder="onUpdateSavedFilter"
 | 
						|
        @close="closeAdvanceFiltersModal"
 | 
						|
      />
 | 
						|
    </TeleportWithDirection>
 | 
						|
  </div>
 | 
						|
</template>
 |