Merge remote-tracking branch 'origin/develop' into feat/whatsapp-call
# Conflicts: # db/migrate/20260408170902_create_calls.rb # db/schema.rb # enterprise/app/models/call.rb
This commit is contained in:
@@ -1,15 +1,5 @@
|
||||
<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,
|
||||
defineEmits,
|
||||
} from 'vue';
|
||||
import { ref, unref, provide, computed, watch, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
@@ -17,23 +7,19 @@ import {
|
||||
useFunctionGetter,
|
||||
} from 'dashboard/composables/store.js';
|
||||
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import ConversationList from './ConversationList.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.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 TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.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';
|
||||
@@ -56,6 +42,7 @@ import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHe
|
||||
import { conversationListPageURL } from '../helper/URLHelper';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
isOnParticipatingView,
|
||||
isOnUnattendedView,
|
||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||
import {
|
||||
@@ -84,10 +71,6 @@ const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
const conversationListRef = ref(null);
|
||||
const virtualListRef = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', virtualListRef);
|
||||
|
||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||
@@ -99,7 +82,6 @@ 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 => ({
|
||||
@@ -113,6 +95,7 @@ const chatLists = useMapGetter('getFilteredConversations');
|
||||
const mineChatsList = useMapGetter('getMineChats');
|
||||
const allChatList = useMapGetter('getAllStatusChats');
|
||||
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
|
||||
const participatingChatsList = useMapGetter('getParticipatingChats');
|
||||
const chatListLoading = useMapGetter('getChatListLoadingStatus');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const conversationStats = useMapGetter('conversationStats/getStats');
|
||||
@@ -128,7 +111,6 @@ const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const getTeamFn = useMapGetter('teams/getTeam');
|
||||
const getConversationById = useMapGetter('getConversationById');
|
||||
|
||||
useChatListKeyboardEvents(conversationListRef);
|
||||
const {
|
||||
selectedConversations,
|
||||
selectedInboxes,
|
||||
@@ -296,13 +278,15 @@ const pageTitle = computed(() => {
|
||||
if (props.label) {
|
||||
return `#${props.label}`;
|
||||
}
|
||||
if (props.conversationType === 'mention') {
|
||||
if (props.conversationType === wootConstants.CONVERSATION_TYPE.MENTION) {
|
||||
return t('CHAT_LIST.MENTION_HEADING');
|
||||
}
|
||||
if (props.conversationType === 'participating') {
|
||||
if (
|
||||
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
|
||||
) {
|
||||
return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
|
||||
}
|
||||
if (props.conversationType === 'unattended') {
|
||||
if (props.conversationType === wootConstants.CONVERSATION_TYPE.UNATTENDED) {
|
||||
return t('CHAT_LIST.UNATTENDED_HEADING');
|
||||
}
|
||||
if (hasActiveFolders.value) {
|
||||
@@ -311,12 +295,30 @@ const pageTitle = computed(() => {
|
||||
return t('CHAT_LIST.TAB_HEADING');
|
||||
});
|
||||
|
||||
function filterByAssigneeTab(conversations) {
|
||||
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.ME) {
|
||||
return conversations.filter(
|
||||
c => c.meta?.assignee?.id === currentUser.value?.id
|
||||
);
|
||||
}
|
||||
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.UNASSIGNED) {
|
||||
return conversations.filter(c => !c.meta?.assignee);
|
||||
}
|
||||
return [...conversations];
|
||||
}
|
||||
|
||||
const conversationList = computed(() => {
|
||||
let localConversationList = [];
|
||||
|
||||
if (!hasAppliedFiltersOrActiveFolders.value) {
|
||||
const filters = conversationFilters.value;
|
||||
if (activeAssigneeTab.value === 'me') {
|
||||
if (
|
||||
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
|
||||
) {
|
||||
localConversationList = filterByAssigneeTab(
|
||||
participatingChatsList.value(filters)
|
||||
);
|
||||
} else if (activeAssigneeTab.value === 'me') {
|
||||
localConversationList = [...mineChatsList.value(filters)];
|
||||
} else if (activeAssigneeTab.value === 'unassigned') {
|
||||
localConversationList = [...unAssignedChatsList.value(filters)];
|
||||
@@ -338,7 +340,7 @@ const conversationList = computed(() => {
|
||||
});
|
||||
|
||||
const showEndOfListMessage = computed(() => {
|
||||
return (
|
||||
return !!(
|
||||
conversationList.value.length &&
|
||||
hasCurrentPageEndReached.value &&
|
||||
!chatListLoading.value
|
||||
@@ -583,14 +585,6 @@ function loadMoreConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
|
||||
// If the list doesn’t fill the viewport, loading can stall.
|
||||
// IntersectionObserver triggers as soon as the sentinel is visible.
|
||||
const intersectionObserverOptions = computed(() => ({
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
function updateAssigneeTab(selectedTab) {
|
||||
if (activeAssigneeTab.value !== selectedTab) {
|
||||
resetBulkActions();
|
||||
@@ -637,9 +631,11 @@ function redirectToConversationList() {
|
||||
|
||||
let conversationType = '';
|
||||
if (isOnMentionsView({ route: { name } })) {
|
||||
conversationType = 'mention';
|
||||
conversationType = wootConstants.CONVERSATION_TYPE.MENTION;
|
||||
} else if (isOnParticipatingView({ route: { name } })) {
|
||||
conversationType = wootConstants.CONVERSATION_TYPE.PARTICIPATING;
|
||||
} else if (isOnUnattendedView({ route: { name } })) {
|
||||
conversationType = 'unattended';
|
||||
conversationType = wootConstants.CONVERSATION_TYPE.UNATTENDED;
|
||||
}
|
||||
router.push(
|
||||
conversationListPageURL({
|
||||
@@ -782,10 +778,6 @@ function allSelectedConversationsStatus(status) {
|
||||
});
|
||||
}
|
||||
|
||||
function onContextMenuToggle(state) {
|
||||
isContextMenuOpen.value = state;
|
||||
}
|
||||
|
||||
function toggleSelectAll(check) {
|
||||
selectAllConversations(check, conversationList);
|
||||
}
|
||||
@@ -833,7 +825,6 @@ provide('assignTeam', onAssignTeam);
|
||||
provide('assignLabels', onAssignLabels);
|
||||
provide('removeLabels', onRemoveLabels);
|
||||
provide('updateConversationStatus', handleResolveConversation);
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
provide('markAsUnread', markAsUnread);
|
||||
provide('markAsRead', markAsRead);
|
||||
provide('assignPriority', assignPriority);
|
||||
@@ -946,43 +937,18 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
@assign-labels="onAssignLabels"
|
||||
@assign-team="onAssignTeamsForBulk"
|
||||
/>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<Virtualizer
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item, index }"
|
||||
:data="conversationList"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:data-index="index"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</Virtualizer>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
<ConversationList
|
||||
:conversation-list="conversationList"
|
||||
:is-loading="chatListLoading"
|
||||
:show-end-of-list-message="showEndOfListMessage"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@load-more="loadMoreConversations"
|
||||
/>
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
type="alert"
|
||||
|
||||
@@ -1,74 +1,244 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref, watch, inject } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import ConversationCard from './widgets/conversation/ConversationCard.vue';
|
||||
export default {
|
||||
components: {
|
||||
ConversationCard,
|
||||
},
|
||||
inject: [
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'assignAgent',
|
||||
'assignTeam',
|
||||
'assignLabels',
|
||||
'removeLabels',
|
||||
'updateConversationStatus',
|
||||
'toggleContextMenu',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'isConversationSelected',
|
||||
'deleteConversation',
|
||||
],
|
||||
props: {
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
teamId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
foldersId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
showAssignee: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
import ConversationCardExpanded from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import ConversationContextMenu from './widgets/conversation/contextMenu/Index.vue';
|
||||
|
||||
const props = defineProps({
|
||||
source: { type: Object, required: true },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
label: { type: String, default: '' },
|
||||
conversationType: { type: String, default: '' },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showExpanded: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const selectConversation = inject('selectConversation');
|
||||
const deSelectConversation = inject('deSelectConversation');
|
||||
const assignAgent = inject('assignAgent');
|
||||
const assignTeam = inject('assignTeam');
|
||||
const assignLabels = inject('assignLabels');
|
||||
const removeLabels = inject('removeLabels');
|
||||
const updateConversationStatus = inject('updateConversationStatus');
|
||||
const toggleContextMenu = inject('toggleContextMenu');
|
||||
const markAsUnread = inject('markAsUnread');
|
||||
const markAsRead = inject('markAsRead');
|
||||
const assignPriority = inject('assignPriority');
|
||||
const isConversationSelected = inject('isConversationSelected');
|
||||
const deleteConversation = inject('deleteConversation');
|
||||
|
||||
// --- Context menu state (shared by both layouts) ---
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
// Reset context menu state when the row is recycled to a different conversation.
|
||||
watch(
|
||||
() => props.source.id,
|
||||
() => {
|
||||
if (showContextMenu.value) {
|
||||
toggleContextMenu(false);
|
||||
}
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value = { x: null, y: null };
|
||||
}
|
||||
);
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const chatMetadata = computed(() => props.source.meta || {});
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||
|
||||
const currentContact = computed(() =>
|
||||
senderId.value ? store.getters['contacts/getContact'](senderId.value) : {}
|
||||
);
|
||||
|
||||
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) : {};
|
||||
});
|
||||
|
||||
const showInboxName = computed(
|
||||
() => !activeInbox.value && inboxesList.value.length > 1
|
||||
);
|
||||
const isInboxView = computed(() => !!activeInbox.value);
|
||||
const showAssigneeForExpandedCard = computed(
|
||||
() => props.showExpanded || props.showAssignee
|
||||
);
|
||||
|
||||
const conversationPath = computed(() =>
|
||||
frontendURL(
|
||||
conversationUrl({
|
||||
accountId: accountId.value,
|
||||
activeInbox: activeInbox.value,
|
||||
id: props.source.id,
|
||||
label: props.label,
|
||||
teamId: props.teamId,
|
||||
conversationType: props.conversationType,
|
||||
foldersId: props.foldersId,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const onCardClick = e => {
|
||||
const path = conversationPath.value;
|
||||
if (!path) return;
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveChat.value) return;
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const onExpandedSelect = checked => {
|
||||
if (checked) {
|
||||
selectConversation(props.source.id, inbox.value.id);
|
||||
} else {
|
||||
deSelectConversation(props.source.id, inbox.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = e => {
|
||||
e.preventDefault();
|
||||
toggleContextMenu(true);
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
toggleContextMenu(false);
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
};
|
||||
|
||||
const onUpdateConversation = (status, snoozedUntil) => {
|
||||
closeContextMenu();
|
||||
updateConversationStatus(props.source.id, status, snoozedUntil);
|
||||
};
|
||||
|
||||
const onAssignAgent = agent => {
|
||||
assignAgent(agent, [props.source.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignLabel = label => {
|
||||
assignLabels([label.title], [props.source.id]);
|
||||
};
|
||||
|
||||
const onRemoveLabel = label => {
|
||||
removeLabels([label.title], [props.source.id]);
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
assignTeam(team, props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onMarkAsUnread = () => {
|
||||
markAsUnread(props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onMarkAsRead = () => {
|
||||
markAsRead(props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignPriority = priority => {
|
||||
assignPriority(priority, props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onDeleteConversation = () => {
|
||||
deleteConversation(props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConversationCard
|
||||
:active-label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
<!-- Expanded layout: wide screen + expanded setting -->
|
||||
<ConversationCardExpanded
|
||||
v-if="showExpanded"
|
||||
:chat="source"
|
||||
:conversation-type="conversationType"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="isConversationSelected(source.id)"
|
||||
:is-active-chat="isActiveChat"
|
||||
:show-assignee="showAssigneeForExpandedCard"
|
||||
:show-inbox-name="showInboxName"
|
||||
:is-inbox-view="isInboxView"
|
||||
@select-conversation="onExpandedSelect"
|
||||
@de-select-conversation="onExpandedSelect"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
/>
|
||||
|
||||
<!-- Default (condensed) layout -->
|
||||
<ConversationCard
|
||||
v-else
|
||||
:chat="source"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="isConversationSelected(source.id)"
|
||||
:is-active-chat="isActiveChat"
|
||||
:show-assignee="showAssignee"
|
||||
enable-context-menu
|
||||
:show-inbox-name="showInboxName"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
@assign-agent="assignAgent"
|
||||
@assign-team="assignTeam"
|
||||
@assign-label="assignLabels"
|
||||
@remove-label="removeLabels"
|
||||
@update-conversation-status="updateConversationStatus"
|
||||
@context-menu-toggle="toggleContextMenu"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
/>
|
||||
|
||||
<!-- Shared context menu for both layouts -->
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<ConversationContextMenu
|
||||
:status="source.status"
|
||||
:inbox-id="inbox.id"
|
||||
:priority="source.priority"
|
||||
:chat-id="source.id"
|
||||
:has-unread-messages="source.unread_count > 0"
|
||||
:conversation-labels="source.labels"
|
||||
:conversation-url="conversationPath"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@remove-label="onRemoveLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="onMarkAsUnread"
|
||||
@mark-as-read="onMarkAsRead"
|
||||
@assign-priority="onAssignPriority"
|
||||
@delete-conversation="onDeleteConversation"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { ref, computed, provide } from 'vue';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import { useBreakpoints } from '@vueuse/core';
|
||||
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
conversationList: { type: Array, default: () => [] },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
showEndOfListMessage: { 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 },
|
||||
isOnExpandedLayout: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['loadMore']);
|
||||
|
||||
const conversationListRef = ref(null);
|
||||
const virtualListRef = ref(null);
|
||||
const isContextMenuOpen = ref(false);
|
||||
|
||||
provide('contextMenuElementTarget', virtualListRef);
|
||||
|
||||
const breakpoints = useBreakpoints({
|
||||
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
|
||||
});
|
||||
const isLgScreen = breakpoints.greaterOrEqual('lg');
|
||||
const showExpandedCards = computed(
|
||||
() => props.isOnExpandedLayout && isLgScreen.value
|
||||
);
|
||||
|
||||
useChatListKeyboardEvents(conversationListRef);
|
||||
|
||||
const intersectionObserverOptions = computed(() => ({
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
const onContextMenuToggle = state => {
|
||||
isContextMenuOpen.value = state;
|
||||
};
|
||||
|
||||
const loadMoreConversations = () => {
|
||||
emit('loadMore');
|
||||
};
|
||||
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
|
||||
defineExpose({ conversationListRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<Virtualizer
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item }"
|
||||
:data="conversationList"
|
||||
class="[&>div:has(+_div_.active)>*]:!border-n-surface-1 [&>div:has(+_div_.selected)>*]:!border-n-surface-1"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssignee"
|
||||
:show-expanded="showExpandedCards"
|
||||
/>
|
||||
</Virtualizer>
|
||||
<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-n-slate-11">
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -27,10 +27,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isPopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -208,10 +204,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 mb-4">
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
|
||||
>
|
||||
<div class="overflow-y-auto max-h-56">
|
||||
<p
|
||||
v-dompurify-html="formatMessage(generatedContent, false)"
|
||||
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
|
||||
|
||||
@@ -984,7 +984,32 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
}
|
||||
|
||||
.ProseMirror-woot-style {
|
||||
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
|
||||
@apply overflow-auto;
|
||||
}
|
||||
|
||||
.ProseMirror-woot-style:not(
|
||||
:where(.resizable-editor-wrapper .ProseMirror-woot-style)
|
||||
) {
|
||||
@apply min-h-[5rem] max-h-[7.5rem];
|
||||
}
|
||||
|
||||
// Resizable editor wrapper styles
|
||||
.resizable-editor-wrapper {
|
||||
.ProseMirror-woot-style {
|
||||
min-height: clamp(
|
||||
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
|
||||
var(--editor-height, var(--editor-min-height, 5rem)),
|
||||
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
|
||||
);
|
||||
max-height: clamp(
|
||||
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
|
||||
var(--editor-height, var(--editor-min-height, 5rem)),
|
||||
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
|
||||
);
|
||||
transition:
|
||||
min-height var(--editor-height-transition, 180ms ease),
|
||||
max-height var(--editor-height-transition, 180ms ease);
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-backdrop::backdrop {
|
||||
|
||||
@@ -8,13 +8,22 @@ import {
|
||||
EditorState,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
triggerCharacters,
|
||||
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
|
||||
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import SlashCommandMenu from './SlashCommandMenu.vue';
|
||||
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const SLASH_MENU_OFFSET = 4;
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
@@ -40,6 +49,7 @@ let editorView = null;
|
||||
let state;
|
||||
|
||||
export default {
|
||||
components: { SlashCommandMenu },
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
modelValue: { type: String, default: '' },
|
||||
@@ -62,8 +72,15 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
plugins: [imagePastePlugin(this.handleImageUpload)],
|
||||
plugins: [
|
||||
imagePastePlugin(this.handleImageUpload),
|
||||
this.createSlashPlugin(),
|
||||
],
|
||||
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
showSlashMenu: false,
|
||||
slashSearchTerm: '',
|
||||
slashRange: null,
|
||||
slashMenuPosition: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -95,6 +112,126 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createSlashPlugin() {
|
||||
return suggestionsPlugin({
|
||||
matcher: triggerCharacters('/', 0),
|
||||
suggestionClass: '',
|
||||
onEnter: args => {
|
||||
this.showSlashMenu = true;
|
||||
this.slashRange = args.range;
|
||||
this.slashSearchTerm = args.text || '';
|
||||
this.updateSlashMenuPosition(args.range.from);
|
||||
return false;
|
||||
},
|
||||
onChange: args => {
|
||||
this.slashRange = args.range;
|
||||
this.slashSearchTerm = args.text;
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
this.slashSearchTerm = '';
|
||||
this.showSlashMenu = false;
|
||||
this.slashMenuPosition = null;
|
||||
return false;
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
return (
|
||||
event.keyCode === 13 &&
|
||||
this.showSlashMenu &&
|
||||
this.$refs.slashMenu?.hasItems
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
updateSlashMenuPosition(pos) {
|
||||
if (!editorView) return;
|
||||
const coords = editorView.coordsAtPos(pos);
|
||||
const editorRect = this.$refs.editor.getBoundingClientRect();
|
||||
const isRtl = getComputedStyle(this.$refs.editor).direction === 'rtl';
|
||||
this.slashMenuPosition = {
|
||||
top: coords.bottom - editorRect.top + SLASH_MENU_OFFSET,
|
||||
...(isRtl
|
||||
? { right: editorRect.right - coords.right }
|
||||
: { left: coords.left - editorRect.left }),
|
||||
};
|
||||
},
|
||||
removeSlashTriggerText() {
|
||||
if (!editorView || !this.slashRange) return;
|
||||
const { from, to } = this.slashRange;
|
||||
editorView.dispatch(editorView.state.tr.delete(from, to));
|
||||
state = editorView.state;
|
||||
},
|
||||
executeSlashCommand(actionKey) {
|
||||
if (!editorView) return;
|
||||
|
||||
this.removeSlashTriggerText();
|
||||
|
||||
const { schema } = editorView.state;
|
||||
const commandMap = {
|
||||
strong: () =>
|
||||
toggleMark(schema.marks.strong)(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
em: () =>
|
||||
toggleMark(schema.marks.em)(editorView.state, editorView.dispatch),
|
||||
strike: () =>
|
||||
toggleMark(schema.marks.strike)(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
code: () =>
|
||||
toggleMark(schema.marks.code)(editorView.state, editorView.dispatch),
|
||||
h1: () =>
|
||||
toggleBlockType(schema.nodes.heading, { level: 1 })(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
h2: () =>
|
||||
toggleBlockType(schema.nodes.heading, { level: 2 })(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
h3: () =>
|
||||
toggleBlockType(schema.nodes.heading, { level: 3 })(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
bulletList: () =>
|
||||
wrapInList(schema.nodes.bullet_list)(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
orderedList: () =>
|
||||
wrapInList(schema.nodes.ordered_list)(
|
||||
editorView.state,
|
||||
editorView.dispatch
|
||||
),
|
||||
insertTable: () => {
|
||||
const { table, table_row, table_header, table_cell, paragraph } =
|
||||
schema.nodes;
|
||||
const headerCells = [0, 1, 2].map(() =>
|
||||
table_header.createAndFill(null, paragraph.create())
|
||||
);
|
||||
const dataCells = [0, 1, 2].map(() =>
|
||||
table_cell.createAndFill(null, paragraph.create())
|
||||
);
|
||||
const headerRow = table_row.create(null, headerCells);
|
||||
const dataRow = table_row.create(null, dataCells);
|
||||
const tableNode = table.create(null, [headerRow, dataRow]);
|
||||
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
|
||||
editorView.dispatch(tr.scrollIntoView());
|
||||
},
|
||||
};
|
||||
|
||||
const command = commandMap[actionKey];
|
||||
if (command) {
|
||||
command();
|
||||
state = editorView.state;
|
||||
this.emitOnChange();
|
||||
editorView.focus();
|
||||
}
|
||||
},
|
||||
contentFromEditor() {
|
||||
if (editorView) {
|
||||
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
|
||||
@@ -262,7 +399,8 @@ export default {
|
||||
|
||||
// Get the editor's width
|
||||
const editorWidth = editor.offsetWidth;
|
||||
const menubarWidth = 480; // Menubar width (adjust as needed (px))
|
||||
const menubar = editor.querySelector('.ProseMirror-menubar');
|
||||
const menubarWidth = menubar ? menubar.scrollWidth : 480;
|
||||
|
||||
// Get the end position of the selection
|
||||
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
|
||||
@@ -290,7 +428,15 @@ export default {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="editor-root editor--article">
|
||||
<div class="editor-root editor--article relative">
|
||||
<SlashCommandMenu
|
||||
v-if="showSlashMenu"
|
||||
ref="slashMenu"
|
||||
:search-key="slashSearchTerm"
|
||||
:enabled-menu-options="enabledMenuOptions"
|
||||
:position="slashMenuPosition"
|
||||
@select-action="executeSlashCommand"
|
||||
/>
|
||||
<input
|
||||
ref="imageUploadInput"
|
||||
type="file"
|
||||
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
|
||||
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
const setReplyMode = mode => {
|
||||
emit('setReplyMode', mode);
|
||||
@@ -189,7 +189,7 @@ export default {
|
||||
class="text-n-slate-11"
|
||||
sm
|
||||
icon="i-lucide-maximize-2"
|
||||
@click="$emit('togglePopout')"
|
||||
@click="$emit('toggleEditorSize')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
enabledMenuOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
position: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectAction']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const EDITOR_ACTIONS = [
|
||||
{
|
||||
value: 'h1',
|
||||
labelKey: 'SLASH_COMMANDS.HEADING_1',
|
||||
icon: 'i-lucide-heading-1',
|
||||
menuKey: 'h1',
|
||||
},
|
||||
{
|
||||
value: 'h2',
|
||||
labelKey: 'SLASH_COMMANDS.HEADING_2',
|
||||
icon: 'i-lucide-heading-2',
|
||||
menuKey: 'h2',
|
||||
},
|
||||
{
|
||||
value: 'h3',
|
||||
labelKey: 'SLASH_COMMANDS.HEADING_3',
|
||||
icon: 'i-lucide-heading-3',
|
||||
menuKey: 'h3',
|
||||
},
|
||||
{
|
||||
value: 'strong',
|
||||
labelKey: 'SLASH_COMMANDS.BOLD',
|
||||
icon: 'i-lucide-bold',
|
||||
menuKey: 'strong',
|
||||
},
|
||||
{
|
||||
value: 'em',
|
||||
labelKey: 'SLASH_COMMANDS.ITALIC',
|
||||
icon: 'i-lucide-italic',
|
||||
menuKey: 'em',
|
||||
},
|
||||
{
|
||||
value: 'insertTable',
|
||||
labelKey: 'SLASH_COMMANDS.TABLE',
|
||||
icon: 'i-lucide-table',
|
||||
menuKey: 'insertTable',
|
||||
},
|
||||
{
|
||||
value: 'strike',
|
||||
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
|
||||
icon: 'i-lucide-strikethrough',
|
||||
menuKey: 'strike',
|
||||
},
|
||||
{
|
||||
value: 'code',
|
||||
labelKey: 'SLASH_COMMANDS.CODE',
|
||||
icon: 'i-lucide-code',
|
||||
menuKey: 'code',
|
||||
},
|
||||
{
|
||||
value: 'bulletList',
|
||||
labelKey: 'SLASH_COMMANDS.BULLET_LIST',
|
||||
icon: 'i-lucide-list',
|
||||
menuKey: 'bulletList',
|
||||
},
|
||||
{
|
||||
value: 'orderedList',
|
||||
labelKey: 'SLASH_COMMANDS.ORDERED_LIST',
|
||||
icon: 'i-lucide-list-ordered',
|
||||
menuKey: 'orderedList',
|
||||
},
|
||||
];
|
||||
|
||||
const listContainerRef = ref(null);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const items = computed(() => {
|
||||
const search = props.searchKey.toLowerCase();
|
||||
return EDITOR_ACTIONS.filter(action => {
|
||||
if (!props.enabledMenuOptions.includes(action.menuKey)) return false;
|
||||
if (!search) return true;
|
||||
return t(action.labelKey).toLowerCase().includes(search);
|
||||
});
|
||||
});
|
||||
|
||||
const hasItems = computed(() => items.value.length > 0);
|
||||
|
||||
const menuStyle = computed(() => {
|
||||
if (!props.position) return {};
|
||||
const style = { top: `${props.position.top}px` };
|
||||
if (props.position.right != null) {
|
||||
style.right = `${props.position.right}px`;
|
||||
} else {
|
||||
style.left = `${props.position.left}px`;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const adjustScroll = () => {
|
||||
nextTick(() => {
|
||||
const container = listContainerRef.value;
|
||||
if (!container) return;
|
||||
const el = container.querySelector(`#slash-item-${selectedIndex.value}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
const item = items.value[selectedIndex.value];
|
||||
if (item) emit('selectAction', item.value);
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
// Reset selection when filtered items change
|
||||
watch(items, () => {
|
||||
selectedIndex.value = 0;
|
||||
});
|
||||
|
||||
const onHover = index => {
|
||||
selectedIndex.value = index;
|
||||
};
|
||||
|
||||
const onItemClick = index => {
|
||||
selectedIndex.value = index;
|
||||
onSelect();
|
||||
};
|
||||
|
||||
defineExpose({ hasItems });
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<div
|
||||
v-if="hasItems"
|
||||
ref="listContainerRef"
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 outline-n-container absolute rounded-xl z-50 flex flex-col min-w-[10rem] shadow-lg p-2 overflow-auto max-h-[15rem]"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:id="`slash-item-${index}`"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 border-0 rounded-lg text-n-slate-12 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-alpha-2': index === selectedIndex,
|
||||
}"
|
||||
@mouseover="onHover(index)"
|
||||
@click="onItemClick(index)"
|
||||
>
|
||||
<Icon :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
||||
<span class="min-w-0 text-sm truncate">
|
||||
{{ t(item.labelKey) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,98 +1,41 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import UnreadBadge from 'dashboard/components-next/Conversation/ConversationCard/UnreadBadge.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
chat: { type: Object, default: () => ({}) },
|
||||
hideInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
conversationType: { type: String, default: '' },
|
||||
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 },
|
||||
enableContextMenu: { type: Boolean, default: false },
|
||||
allowedContextMenuOptions: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'removeLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'updateConversationStatus',
|
||||
'deleteConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const hovered = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
|
||||
// This prevents context menu/hover state from leaking to a different conversation
|
||||
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
|
||||
const resetState = () => {
|
||||
if (showContextMenu.value) {
|
||||
emit('contextMenuToggle', false);
|
||||
}
|
||||
hovered.value = false;
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value = { x: null, y: null };
|
||||
};
|
||||
|
||||
watch(() => props.chat.id, resetState);
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const chatMetadata = computed(() => props.chat.meta || {});
|
||||
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
|
||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||
|
||||
const currentContact = computed(() => {
|
||||
return senderId.value
|
||||
? store.getters['contacts/getContact'](senderId.value)
|
||||
: {};
|
||||
});
|
||||
|
||||
const isActiveChat = computed(() => {
|
||||
return currentChat.value.id === props.chat.id;
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
|
||||
const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
@@ -100,24 +43,10 @@ const voiceCallData = computed(() => ({
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const inboxId = computed(() => props.chat.inbox_id);
|
||||
|
||||
const inbox = computed(() => {
|
||||
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
|
||||
});
|
||||
|
||||
const showInboxName = computed(() => {
|
||||
return (
|
||||
!props.hideInboxName &&
|
||||
isInboxNameVisible.value &&
|
||||
inboxesList.value.length > 1
|
||||
);
|
||||
});
|
||||
|
||||
const showMetaSection = computed(() => {
|
||||
return (
|
||||
showInboxName.value ||
|
||||
(props.showAssignee && assignee.value.name) ||
|
||||
props.showInboxName ||
|
||||
(props.showAssignee && props.assignee.name) ||
|
||||
props.chat.priority
|
||||
);
|
||||
});
|
||||
@@ -136,41 +65,6 @@ const messagePreviewClass = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const conversationPath = computed(() => {
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId: accountId.value,
|
||||
activeInbox: activeInbox.value,
|
||||
id: props.chat.id,
|
||||
label: props.activeLabel,
|
||||
teamId: props.teamId,
|
||||
conversationType: props.conversationType,
|
||||
foldersId: props.foldersId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const onCardClick = e => {
|
||||
const path = conversationPath.value;
|
||||
if (!path) return;
|
||||
|
||||
// Handle Ctrl/Cmd + Click for new tab
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already active
|
||||
if (isActiveChat.value) return;
|
||||
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
@@ -181,83 +75,37 @@ const onThumbnailLeave = () => {
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
if (checked) {
|
||||
emit('selectConversation', props.chat.id, inbox.value.id);
|
||||
emit('selectConversation', props.chat.id, props.inbox.id);
|
||||
} else {
|
||||
emit('deSelectConversation', props.chat.id, inbox.value.id);
|
||||
emit('deSelectConversation', props.chat.id, props.inbox.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = e => {
|
||||
if (!props.enableContextMenu) return;
|
||||
e.preventDefault();
|
||||
emit('contextMenuToggle', true);
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => onSelectConversation(value),
|
||||
});
|
||||
|
||||
const closeContextMenu = () => {
|
||||
emit('contextMenuToggle', false);
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
};
|
||||
|
||||
const onUpdateConversation = (status, snoozedUntil) => {
|
||||
closeContextMenu();
|
||||
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
|
||||
};
|
||||
|
||||
const onAssignAgent = agent => {
|
||||
emit('assignAgent', agent, [props.chat.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignLabel = label => {
|
||||
emit('assignLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onRemoveLabel = label => {
|
||||
emit('removeLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
emit('assignTeam', team, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsUnread = () => {
|
||||
emit('markAsUnread', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsRead = () => {
|
||||
emit('markAsRead', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const assignPriority = priority => {
|
||||
emit('assignPriority', priority, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const deleteConversation = () => {
|
||||
emit('deleteConversation', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
watch(
|
||||
() => 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 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||
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-weak': isActiveChat,
|
||||
'bg-n-slate-2': selected,
|
||||
'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="onCardClick"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
@@ -272,7 +120,6 @@ const deleteConversation = () => {
|
||||
:status="currentContact.availability_status"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<template #overlay="{ size }">
|
||||
<label
|
||||
@@ -281,20 +128,12 @@ const deleteConversation = () => {
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:value="selected"
|
||||
:checked="selected"
|
||||
class="!m-0 cursor-pointer"
|
||||
type="checkbox"
|
||||
@change="onSelectConversation($event.target.checked)"
|
||||
/>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div
|
||||
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
|
||||
>
|
||||
<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"
|
||||
@@ -369,12 +208,11 @@ const deleteConversation = () => {
|
||||
:conversation-id="chat.id"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
||||
:class="hasUnread ? 'block' : 'hidden'"
|
||||
>
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
<UnreadBadge
|
||||
v-if="hasUnread"
|
||||
:count="unreadCount"
|
||||
class="ltr:ml-auto rtl:mr-auto mt-1"
|
||||
/>
|
||||
</div>
|
||||
<CardLabels
|
||||
v-if="showLabelsSection"
|
||||
@@ -386,32 +224,5 @@ const deleteConversation = () => {
|
||||
</template>
|
||||
</CardLabels>
|
||||
</div>
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<ConversationContextMenu
|
||||
:status="chat.status"
|
||||
:inbox-id="inbox.id"
|
||||
:priority="chat.priority"
|
||||
:chat-id="chat.id"
|
||||
:has-unread-messages="hasUnread"
|
||||
:conversation-labels="chat.labels"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="allowedContextMenuOptions"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@remove-label="onRemoveLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -55,6 +55,7 @@ const backButtonUrl = computed(() => {
|
||||
|
||||
const conversationTypeMap = {
|
||||
conversation_through_mentions: 'mention',
|
||||
conversation_through_participating: 'participating',
|
||||
conversation_through_unattended: 'unattended',
|
||||
};
|
||||
return conversationListPageURL({
|
||||
|
||||
@@ -41,7 +41,16 @@ const closeContactPanel = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="() => closeContactPanel()"
|
||||
v-on-click-outside="[
|
||||
() => closeContactPanel(),
|
||||
{
|
||||
ignore: [
|
||||
'dialog.ProseMirror-prompt-backdrop',
|
||||
'[data-popover-content]',
|
||||
'[data-popover-backdrop]',
|
||||
],
|
||||
},
|
||||
]"
|
||||
class="bg-n-surface-2 h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
|
||||
:class="[
|
||||
{
|
||||
|
||||
@@ -16,10 +16,6 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isPopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -69,7 +65,6 @@ const onSend = () => {
|
||||
:generated-content="generatedContent"
|
||||
:min-height="4"
|
||||
:enabled-menu-options="[]"
|
||||
:is-popout="isPopout"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@clear-selection="clearEditorSelection"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { ref, provide } from 'vue';
|
||||
import { ref, provide, useTemplateRef } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
// composable
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
import ResizableEditorWrapper from './ResizableEditorWrapper.vue';
|
||||
|
||||
// stores and apis
|
||||
import { mapGetters } from 'vuex';
|
||||
@@ -43,21 +44,16 @@ export default {
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
Spinner,
|
||||
ResizableEditorWrapper,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const conversationPanelRef = ref(null);
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
isPopOutReplyBox.value = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
const resizableEditorWrapperRef = ref(null);
|
||||
const messagesViewRef = useTemplateRef('messagesViewRef');
|
||||
const topBannerRef = useTemplateRef('topBannerRef');
|
||||
const { height: containerHeight } = useElementSize(messagesViewRef);
|
||||
const { height: topBannerHeight } = useElementSize(topBannerRef);
|
||||
|
||||
const {
|
||||
captainTasksEnabled,
|
||||
@@ -68,11 +64,15 @@ export default {
|
||||
provide('contextMenuElementTarget', conversationPanelRef);
|
||||
|
||||
return {
|
||||
isPopOutReplyBox,
|
||||
captainTasksEnabled,
|
||||
getLabelSuggestions,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
conversationPanelRef,
|
||||
resizableEditorWrapperRef,
|
||||
messagesViewRef,
|
||||
topBannerRef,
|
||||
containerHeight,
|
||||
topBannerHeight,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -254,6 +254,7 @@ export default {
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
this.messageSentSinceOpened = false;
|
||||
this.resetReplyEditorHeight();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -437,26 +438,37 @@ export default {
|
||||
const payload = useSnakeCase(message);
|
||||
await this.$store.dispatch('sendMessageWithData', payload);
|
||||
},
|
||||
toggleReplyEditorSize() {
|
||||
this.resizableEditorWrapperRef?.toggleEditorExpand?.();
|
||||
},
|
||||
resetReplyEditorHeight() {
|
||||
this.resizableEditorWrapperRef?.resetEditorHeight?.();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
|
||||
<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-else-if="hasDuplicateInstagramInbox"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||
/>
|
||||
<div
|
||||
ref="messagesViewRef"
|
||||
class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<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-else-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 pb-4"
|
||||
@@ -498,13 +510,7 @@ export default {
|
||||
/>
|
||||
</template>
|
||||
</MessageList>
|
||||
<div
|
||||
class="flex relative flex-col"
|
||||
:class="{
|
||||
'modal-mask': isPopOutReplyBox,
|
||||
'bg-n-surface-1': !isPopOutReplyBox,
|
||||
}"
|
||||
>
|
||||
<div class="flex relative flex-col bg-n-surface-1">
|
||||
<div
|
||||
v-if="isAnyoneTyping"
|
||||
class="absolute flex items-center w-full h-0 -top-7"
|
||||
@@ -520,42 +526,12 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ReplyBox
|
||||
:pop-out-reply-box="isPopOutReplyBox"
|
||||
@update:pop-out-reply-box="isPopOutReplyBox = $event"
|
||||
/>
|
||||
<ResizableEditorWrapper
|
||||
ref="resizableEditorWrapperRef"
|
||||
:container-height="Math.max(0, containerHeight - topBannerHeight)"
|
||||
>
|
||||
<ReplyBox @toggle-editor-size="toggleReplyEditorSize" />
|
||||
</ResizableEditorWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-mask {
|
||||
@apply fixed;
|
||||
|
||||
&::v-deep {
|
||||
.ProseMirror-woot-style {
|
||||
@apply max-h-[25rem];
|
||||
}
|
||||
|
||||
.reply-box {
|
||||
@apply border border-n-weak max-w-[75rem] w-[70%];
|
||||
|
||||
&.is-private {
|
||||
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-box .reply-box__top {
|
||||
@apply relative min-h-[27.5rem];
|
||||
}
|
||||
|
||||
.reply-box__top .input {
|
||||
@apply min-h-[27.5rem];
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -81,13 +81,7 @@ export default {
|
||||
CopilotReplyBottomPanel,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
popOutReplyBox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:popOutReplyBox'],
|
||||
emits: ['toggleEditorSize'],
|
||||
setup() {
|
||||
const {
|
||||
uiSettings,
|
||||
@@ -797,7 +791,6 @@ export default {
|
||||
|
||||
this.clearMessage();
|
||||
this.hideEmojiPicker();
|
||||
this.$emit('update:popOutReplyBox', false);
|
||||
}
|
||||
},
|
||||
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
||||
@@ -1217,8 +1210,9 @@ export default {
|
||||
file => !file?.isRecordedAudio
|
||||
);
|
||||
},
|
||||
togglePopout() {
|
||||
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
||||
toggleEditorSize() {
|
||||
this.$emit('toggleEditorSize');
|
||||
this.$nextTick(() => this.messageEditor?.focusEditorInputField());
|
||||
},
|
||||
onSubmitCopilotReply() {
|
||||
const acceptedMessage = this.copilot.accept();
|
||||
@@ -1244,9 +1238,8 @@ export default {
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:editor-content="message"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-popout="togglePopout"
|
||||
@toggle-editor-size="toggleEditorSize"
|
||||
@toggle-copilot="copilot.toggleEditor"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
@@ -1275,7 +1268,7 @@ export default {
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:class="{
|
||||
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
|
||||
'emoji-dialog--expanded': isOnExpandedLayout,
|
||||
}"
|
||||
:on-click="addIntoEditor"
|
||||
/>
|
||||
@@ -1299,7 +1292,6 @@ export default {
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
:is-popout="popOutReplyBox"
|
||||
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
const props = defineProps({
|
||||
containerHeight: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const DEFAULT_HEIGHT = 120;
|
||||
const MIN_HEIGHT = 80;
|
||||
const MIN_MESSAGES_HEIGHT = 200;
|
||||
const EXPAND_RATIO = 0.5;
|
||||
const RESET_DELAY_MS = 120;
|
||||
|
||||
const wrapperRef = useTemplateRef('wrapperRef');
|
||||
const surroundingHeight = ref(0);
|
||||
const editorHeight = ref(DEFAULT_HEIGHT);
|
||||
const isResizing = ref(false);
|
||||
const startY = ref(0);
|
||||
const startHeight = ref(0);
|
||||
let resetTimeoutId = null;
|
||||
|
||||
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
|
||||
|
||||
// Measure height of elements surrounding the editor (top panel, email fields, bottom panel)
|
||||
const measureSurroundingHeight = () => {
|
||||
if (wrapperRef.value) {
|
||||
surroundingHeight.value = Math.max(
|
||||
0,
|
||||
wrapperRef.value.offsetHeight - editorHeight.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isContainerReady = computed(() => props.containerHeight > 0);
|
||||
|
||||
const sizeBounds = computed(() => {
|
||||
const h = props.containerHeight;
|
||||
const s = surroundingHeight.value;
|
||||
const max = Math.max(MIN_HEIGHT, h - MIN_MESSAGES_HEIGHT - s);
|
||||
const expanded = clamp(Math.floor(h * EXPAND_RATIO - s / 2), MIN_HEIGHT, max);
|
||||
return {
|
||||
min: MIN_HEIGHT,
|
||||
max: isContainerReady.value ? max : DEFAULT_HEIGHT,
|
||||
expanded,
|
||||
default: clamp(DEFAULT_HEIGHT, MIN_HEIGHT, max),
|
||||
};
|
||||
});
|
||||
|
||||
const clampToBounds = val =>
|
||||
clamp(val, sizeBounds.value.min, sizeBounds.value.max);
|
||||
|
||||
const clearDragStyles = () => {
|
||||
Object.assign(document.body.style, { cursor: '', userSelect: '' });
|
||||
};
|
||||
|
||||
const getClientY = e => (e.touches ? e.touches[0].clientY : e.clientY);
|
||||
|
||||
const onResizeStart = event => {
|
||||
editorHeight.value = clampToBounds(editorHeight.value);
|
||||
measureSurroundingHeight();
|
||||
isResizing.value = true;
|
||||
startY.value = getClientY(event);
|
||||
startHeight.value = clampToBounds(editorHeight.value);
|
||||
editorHeight.value = startHeight.value;
|
||||
Object.assign(document.body.style, {
|
||||
cursor: 'row-resize',
|
||||
userSelect: 'none',
|
||||
});
|
||||
};
|
||||
|
||||
const onResizeMove = event => {
|
||||
if (!isResizing.value) return;
|
||||
if (event.touches) event.preventDefault();
|
||||
editorHeight.value = clampToBounds(
|
||||
startHeight.value + startY.value - getClientY(event)
|
||||
);
|
||||
};
|
||||
|
||||
const onResizeEnd = () => {
|
||||
if (!isResizing.value) return;
|
||||
isResizing.value = false;
|
||||
clearDragStyles();
|
||||
};
|
||||
|
||||
const resetEditorHeight = () => {
|
||||
editorHeight.value = sizeBounds.value.default;
|
||||
};
|
||||
|
||||
const toggleEditorExpand = () => {
|
||||
editorHeight.value = clampToBounds(editorHeight.value);
|
||||
measureSurroundingHeight();
|
||||
const { expanded, max, default: defaultHeight } = sizeBounds.value;
|
||||
const isExpanded = editorHeight.value > defaultHeight;
|
||||
// If expanded is too close to default, use max so the toggle is always noticeable
|
||||
const target = expanded - defaultHeight < 100 ? max : expanded;
|
||||
editorHeight.value = isExpanded ? defaultHeight : target;
|
||||
};
|
||||
|
||||
const handleMessageSent = () => {
|
||||
clearTimeout(resetTimeoutId);
|
||||
resetTimeoutId = setTimeout(resetEditorHeight, RESET_DELAY_MS);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
emitter.off(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
|
||||
clearTimeout(resetTimeoutId);
|
||||
if (isResizing.value) {
|
||||
isResizing.value = false;
|
||||
clearDragStyles();
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener(document, 'mousemove', onResizeMove);
|
||||
useEventListener(document, 'mouseup', onResizeEnd);
|
||||
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
|
||||
useEventListener(document, 'touchend', onResizeEnd);
|
||||
useEventListener(document, 'touchcancel', onResizeEnd);
|
||||
useEventListener(window, 'blur', onResizeEnd);
|
||||
|
||||
defineExpose({ toggleEditorExpand, resetEditorHeight });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="relative resizable-editor-wrapper"
|
||||
:style="{
|
||||
'--editor-height': editorHeight + 'px',
|
||||
'--editor-min-allowed': sizeBounds.min + 'px',
|
||||
'--editor-max-allowed': sizeBounds.max + 'px',
|
||||
'--editor-height-transition': isResizing ? 'none' : '180ms ease',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="group absolute inset-x-0 -top-4 z-10 flex h-4 cursor-row-resize select-none items-center justify-center bg-gradient-to-b from-transparent from-10% dark:to-n-surface-1/80 to-n-surface-1/90 backdrop-blur-[0.01875rem]"
|
||||
@mousedown="onResizeStart"
|
||||
@touchstart.prevent="onResizeStart"
|
||||
@dblclick="resetEditorHeight"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-0.5 mt-1 rounded-full bg-n-slate-6 group-hover:bg-n-slate-8 transition-all duration-200 motion-safe:group-hover:animate-bounce"
|
||||
:class="{ 'bg-n-slate-8 animate-bounce': isResizing }"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user