Merge branch 'develop' into fix/audio-retry
This commit is contained in:
@@ -98,7 +98,9 @@ export default {
|
||||
mql.onchange = e => setColorTheme(e.matches);
|
||||
},
|
||||
setLocale(locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
if (locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
}
|
||||
},
|
||||
async initializeAccount() {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
|
||||
@@ -25,6 +25,10 @@ class AgentBotsAPI extends ApiClient {
|
||||
resetAccessToken(botId) {
|
||||
return axios.post(`${this.url}/${botId}/reset_access_token`);
|
||||
}
|
||||
|
||||
resetSecret(botId) {
|
||||
return axios.post(`${this.url}/${botId}/reset_secret`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AgentBotsAPI();
|
||||
|
||||
@@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
|
||||
delete(id) {
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
test(data = {}) {
|
||||
return axios.post(`${this.url}/test`, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainCustomTools();
|
||||
|
||||
@@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI {
|
||||
delete({ portalSlug, categoryId }) {
|
||||
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
|
||||
}
|
||||
|
||||
reorder({ portalSlug, reorderedGroup }) {
|
||||
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
|
||||
positions_hash: reorderedGroup,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CategoriesAPI();
|
||||
|
||||
@@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
|
||||
getHealthStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/health`);
|
||||
}
|
||||
|
||||
registerWebhook(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/register_webhook`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InboxHealthAPI();
|
||||
|
||||
@@ -48,6 +48,10 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
template,
|
||||
});
|
||||
}
|
||||
|
||||
resetSecret(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/reset_secret`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => {
|
||||
expect(categoriesAPI).toHaveProperty('create');
|
||||
expect(categoriesAPI).toHaveProperty('update');
|
||||
expect(categoriesAPI).toHaveProperty('delete');
|
||||
expect(categoriesAPI).toHaveProperty('reorder');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,10 @@ select {
|
||||
&[disabled] {
|
||||
@apply field-disabled;
|
||||
}
|
||||
|
||||
option:not(:disabled) {
|
||||
@apply bg-n-solid-2 text-n-slate-12;
|
||||
}
|
||||
}
|
||||
|
||||
// Textarea
|
||||
|
||||
+3
-3
@@ -20,11 +20,11 @@ const excludedLabels = defineModel('excludedLabels', {
|
||||
|
||||
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
|
||||
type: Number,
|
||||
default: 10,
|
||||
default: null,
|
||||
});
|
||||
|
||||
// Duration limits: 10 minutes to 999 days (in minutes)
|
||||
const MIN_DURATION_MINUTES = 10;
|
||||
// Duration limits: 1 minute to 999 days (in minutes)
|
||||
const MIN_DURATION_MINUTES = 1;
|
||||
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
+2
-1
@@ -27,7 +27,7 @@ const { t } = useI18n();
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
|
||||
const DEFAULT_CONVERSATION_LIMIT = 10;
|
||||
const MIN_CONVERSATION_LIMIT = 1;
|
||||
const MIN_CONVERSATION_LIMIT = 0;
|
||||
const MAX_CONVERSATION_LIMIT = 100000;
|
||||
|
||||
const selectedInboxIds = computed(
|
||||
@@ -42,6 +42,7 @@ const availableInboxes = computed(() =>
|
||||
|
||||
const isLimitValid = limit => {
|
||||
return (
|
||||
Number.isInteger(limit.conversationLimit) &&
|
||||
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
|
||||
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
@@ -28,7 +29,11 @@ const handleButtonClick = () => {
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div
|
||||
v-on-clickaway="() => emit('close')"
|
||||
v-on-click-outside="[
|
||||
() => emit('close'),
|
||||
// This will prevent closing the modal when the editor Create link popup is open
|
||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
||||
]"
|
||||
class="relative group/campaign-button"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -107,11 +107,10 @@ const closeMobileSidebar = () => {
|
||||
size="sm"
|
||||
/>
|
||||
<ComposeConversation :contact-id="contactId">
|
||||
<template #trigger="{ toggle }">
|
||||
<template #trigger>
|
||||
<Button
|
||||
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
|
||||
size="sm"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
|
||||
@@ -114,8 +114,8 @@ const emit = defineEmits([
|
||||
</div>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
<template #trigger>
|
||||
<Button :label="buttonLabel" size="sm" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
|
||||
@@ -103,6 +103,7 @@ const showPagination = computed(() => {
|
||||
<ContactsActiveFiltersPreview
|
||||
v-if="showActiveFiltersPreview"
|
||||
:active-segment="activeSegment"
|
||||
class="mb-1"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
@open-filter="openFilter"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contact: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectConversation']);
|
||||
|
||||
const hovered = ref(false);
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
|
||||
const onThumbnailLeave = () => {
|
||||
hovered.value = false;
|
||||
};
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
emit('selectConversation', value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-center flex-shrink-0"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="contact.name"
|
||||
:src="contact.thumbnail"
|
||||
:size="24"
|
||||
:status="contact.availability_status"
|
||||
hide-offline-status
|
||||
>
|
||||
<template v-if="enableSelection" #overlay>
|
||||
<div
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
</template>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import UnreadBadge from './UnreadBadge.vue';
|
||||
|
||||
defineProps({
|
||||
lastMessage: { type: Object, default: null },
|
||||
voiceCallStatus: { type: String, default: '' },
|
||||
voiceCallDirection: { type: String, default: '' },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
showExpandedPreview: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_auto] gap-1.5"
|
||||
:class="showExpandedPreview ? 'items-end' : 'items-center'"
|
||||
>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallStatus"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallStatus"
|
||||
:direction="voiceCallDirection"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessage"
|
||||
key="message-preview"
|
||||
:message="lastMessage"
|
||||
:multi-line="showExpandedPreview"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
>
|
||||
<Icon icon="i-lucide-info" class="size-3.5" />
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
|
||||
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
|
||||
</div>
|
||||
</template>
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, useSlots, watch, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
disableToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const attrs = useAttrs();
|
||||
const slots = useSlots();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
return accountLabels.value.filter(({ title }) =>
|
||||
props.labels.includes(title)
|
||||
);
|
||||
});
|
||||
|
||||
const showAllLabels = ref(false);
|
||||
const showExpandLabelButton = ref(false);
|
||||
const labelPosition = ref(-1);
|
||||
const labelContainer = ref(null);
|
||||
|
||||
// Show if there are labels OR if before slot exists
|
||||
const showSection = computed(
|
||||
() => activeLabels.value.length > 0 || !!slots.before
|
||||
);
|
||||
|
||||
const computeVisibleLabelPosition = () => {
|
||||
const container = labelContainer.value;
|
||||
if (!container || activeLabels.value.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = container.querySelectorAll('[data-label]');
|
||||
if (labels.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Early exit if all labels are visible
|
||||
if (showAllLabels.value) return;
|
||||
|
||||
const beforeSlot = container.querySelector('[data-before-slot]');
|
||||
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
|
||||
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
|
||||
|
||||
let totalWidth = 0;
|
||||
const labelsArray = Array.from(labels);
|
||||
|
||||
// Find last visible label index using some() - stops early on overflow
|
||||
const overflowIndex = labelsArray.findIndex(label => {
|
||||
totalWidth += label.offsetWidth + 6;
|
||||
return totalWidth > availableWidth;
|
||||
});
|
||||
|
||||
const visibleIndex =
|
||||
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
|
||||
|
||||
labelPosition.value = visibleIndex;
|
||||
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
|
||||
};
|
||||
|
||||
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
|
||||
|
||||
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
|
||||
|
||||
const hiddenLabelsCount = computed(() => {
|
||||
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
|
||||
return activeLabels.value.length - labelPosition.value - 1;
|
||||
});
|
||||
|
||||
// Check if all labels are hidden (none visible)
|
||||
const allLabelsHidden = computed(() => {
|
||||
return labelPosition.value === -1 && activeLabels.value.length > 0;
|
||||
});
|
||||
|
||||
// Label text for button when disableToggle is true and all labels are hidden
|
||||
const labelsCountText = computed(() => {
|
||||
if (props.disableToggle && allLabelsHidden.value) {
|
||||
return t('CONVERSATION.CARD.LABELS_COUNT', {
|
||||
count: activeLabels.value.length,
|
||||
});
|
||||
}
|
||||
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
|
||||
return hiddenLabelsCount.value;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const hiddenLabelsTooltip = computed(() => {
|
||||
if (!props.disableToggle) return '';
|
||||
// When all labels are hidden, show all label titles
|
||||
if (allLabelsHidden.value) {
|
||||
return activeLabels.value.map(label => label.title).join(', ');
|
||||
}
|
||||
if (!showExpandLabelButton.value) return '';
|
||||
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
|
||||
return hiddenLabels.map(label => label.title).join(', ');
|
||||
});
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (props.disableToggle && hiddenLabelsTooltip.value) {
|
||||
return hiddenLabelsTooltip.value;
|
||||
}
|
||||
return showAllLabels.value
|
||||
? t('CONVERSATION.CARD.HIDE_LABELS')
|
||||
: t('CONVERSATION.CARD.SHOW_LABELS');
|
||||
});
|
||||
|
||||
const onShowLabels = e => {
|
||||
e.stopPropagation();
|
||||
if (props.disableToggle) return;
|
||||
showAllLabels.value = !showAllLabels.value;
|
||||
nextTick(() => computeVisibleLabelPosition());
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showSection"
|
||||
ref="labelContainer"
|
||||
v-bind="attrs"
|
||||
v-resize="throttledCalculate"
|
||||
data-labels-container
|
||||
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
|
||||
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
|
||||
>
|
||||
<slot name="before" />
|
||||
|
||||
<Label
|
||||
v-for="(label, index) in activeLabels"
|
||||
:key="label ? label.id : index"
|
||||
data-label
|
||||
:label="label"
|
||||
compact
|
||||
:class="{
|
||||
'invisible absolute': !showAllLabels && index > labelPosition,
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
|
||||
v-tooltip.top="{
|
||||
content: tooltipText,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:label="labelsCountText"
|
||||
xs
|
||||
slate
|
||||
:no-animation="disableToggle"
|
||||
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
|
||||
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
|
||||
:class="{ 'cursor-default': disableToggle }"
|
||||
@click="onShowLabels"
|
||||
/>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
+51
-195
@@ -1,207 +1,63 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
|
||||
defineProps({
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
|
||||
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
|
||||
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
|
||||
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
|
||||
};
|
||||
|
||||
const priorityLabels = {
|
||||
[CONVERSATION_PRIORITY.URGENT]: 'CONVERSATION.PRIORITY.OPTIONS.URGENT',
|
||||
[CONVERSATION_PRIORITY.HIGH]: 'CONVERSATION.PRIORITY.OPTIONS.HIGH',
|
||||
[CONVERSATION_PRIORITY.MEDIUM]: 'CONVERSATION.PRIORITY.OPTIONS.MEDIUM',
|
||||
[CONVERSATION_PRIORITY.LOW]: 'CONVERSATION.PRIORITY.OPTIONS.LOW',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.priority && icons[props.priority]) {
|
||||
return icons[props.priority];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-priority-empty' : '';
|
||||
});
|
||||
|
||||
const tooltipContent = computed(() => {
|
||||
if (props.priority && priorityLabels[props.priority]) {
|
||||
return t(priorityLabels[props.priority]);
|
||||
}
|
||||
|
||||
if (props.showEmpty) {
|
||||
return t('CONVERSATION.PRIORITY.OPTIONS.NONE');
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: tooltipContent,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 text-n-slate-5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
|
||||
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
|
||||
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
|
||||
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.status && icons[props.status]) {
|
||||
return icons[props.status];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-status-empty' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: status,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import CardAvatar from './CardAvatar.vue';
|
||||
import CardContent from './CardContent.vue';
|
||||
import CardLabels from './CardLabelsV5.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||
import CardStatusIcon from './CardStatusIcon.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
isInboxView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
]);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
if (value) {
|
||||
emit('selectConversation', value);
|
||||
} else {
|
||||
emit('deSelectConversation', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="conversation relative cursor-pointer group grid gap-4 items-center px-3 h-12 border-b border-n-slate-3 hover:border-n-surface-1 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-alpha-1 dark:bg-n-alpha-3 !border-n-surface-1':
|
||||
isActiveChat,
|
||||
'selected bg-n-slate-2 dark:bg-n-slate-3 !border-n-surface-1': selected,
|
||||
'hover:bg-n-alpha-1': !isActiveChat && !selected,
|
||||
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
|
||||
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<!-- LEFT SECTION -->
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardPriorityIcon :priority="chat.priority" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="{
|
||||
content: assignee.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-woot-empty-assignee"
|
||||
class="size-4 text-n-slate-7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardStatusIcon :status="chat.status" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isInboxView && showInboxName"
|
||||
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: chat.id,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0 flex-shrink-0"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-hash"
|
||||
class="size-3.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ chat.id }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CardAvatar
|
||||
:contact="currentContact"
|
||||
:selected="false"
|
||||
:enable-selection="false"
|
||||
:hide-thumbnail="false"
|
||||
/>
|
||||
|
||||
<h4
|
||||
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
|
||||
<CardContent
|
||||
:last-message="lastMessageInChat"
|
||||
:voice-call-status="voiceCallData.status"
|
||||
:voice-call-direction="voiceCallData.direction"
|
||||
:unread-count="unreadCount"
|
||||
:show-expanded-preview="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT SECTION -->
|
||||
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
|
||||
<div v-if="showLabelsSection" class="min-w-0 w-full">
|
||||
<CardLabels
|
||||
:labels="chat.labels"
|
||||
disable-toggle
|
||||
class="my-0 [&>div]:justify-end justify-end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
|
||||
<SLACardLabel ref="slaCardLabel" :chat="chat" />
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 w-[4.375rem] text-end">
|
||||
<TimeAgo
|
||||
:conversation-id="chat.id"
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
class="font-440 !text-xs text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showMessageType: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultEmptyMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
multiLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const attachmentIcons = {
|
||||
image: 'i-lucide-image',
|
||||
audio: 'i-lucide-headphones',
|
||||
video: 'i-lucide-video',
|
||||
file: 'i-lucide-file',
|
||||
location: 'i-lucide-map-pin',
|
||||
fallback: 'i-lucide-link-2',
|
||||
};
|
||||
|
||||
const messageByAgent = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.OUTGOING;
|
||||
});
|
||||
|
||||
const isMessageAnActivity = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.ACTIVITY;
|
||||
});
|
||||
|
||||
const isMessagePrivate = computed(() => {
|
||||
const { private: isPrivate } = props.message;
|
||||
return isPrivate;
|
||||
});
|
||||
|
||||
const parsedLastMessage = computed(() => {
|
||||
const { content_attributes: contentAttributes } = props.message;
|
||||
const { email: { subject } = {} } = contentAttributes || {};
|
||||
return getPlainText(subject || props.message.content);
|
||||
});
|
||||
|
||||
const lastMessageFileType = computed(() => {
|
||||
const [{ file_type: fileType } = {}] = props.message.attachments;
|
||||
return fileType;
|
||||
});
|
||||
|
||||
const attachmentIcon = computed(() => {
|
||||
return attachmentIcons[lastMessageFileType.value];
|
||||
});
|
||||
|
||||
const attachmentMessageContent = computed(() => {
|
||||
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
|
||||
});
|
||||
|
||||
const isMessageSticker = computed(() => {
|
||||
return props.message && props.message.content_type === 'sticker';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-0 text-sm"
|
||||
:class="
|
||||
multiLine
|
||||
? 'flex items-start gap-1'
|
||||
: 'grid grid-cols-[auto_1fr] items-center gap-1'
|
||||
"
|
||||
>
|
||||
<template v-if="showMessageType && !multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="min-w-0 text-body-main"
|
||||
:class="multiLine ? 'line-clamp-2' : 'truncate'"
|
||||
>
|
||||
<!-- Case for previous and conversation conversation card -->
|
||||
<template v-if="showMessageType && multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="message.content && isMessageSticker"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
|
||||
>
|
||||
<Icon icon="i-lucide-image" class="size-3.5" />
|
||||
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||
</span>
|
||||
|
||||
<template v-else-if="message.content">
|
||||
{{ parsedLastMessage }}
|
||||
</template>
|
||||
|
||||
<span
|
||||
v-else-if="message.attachments"
|
||||
class="inline-block align-middle truncate"
|
||||
>
|
||||
<Icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
:icon="attachmentIcon"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<span class="inline-block align-middle">
|
||||
{{ $t(attachmentMessageContent) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<template v-else>
|
||||
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
count: { type: Number, required: true },
|
||||
alignBottom: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const displayCount = computed(() =>
|
||||
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="count > 0"
|
||||
class="bg-n-teal-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
|
||||
:class="{
|
||||
'mb-0.5': alignBottom,
|
||||
}"
|
||||
>
|
||||
{{ displayCount }}
|
||||
</span>
|
||||
<span v-else />
|
||||
</template>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: String, default: '' },
|
||||
direction: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const LABEL_KEYS = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||
};
|
||||
|
||||
const isOutbound = computed(
|
||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
|
||||
if (props.status === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const statusColor = computed(
|
||||
() => COLOR_MAP[props.status] || 'text-n-slate-11'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
|
||||
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
|
||||
<span class="truncate text-body-main" :class="statusColor">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
|
||||
defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
|
||||
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
|
||||
<span class="truncate text-body-main text-n-slate-11">
|
||||
{{ inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -57,7 +57,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
|
||||
<template>
|
||||
<ButtonGroup
|
||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
|
||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow !z-20"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: props.chat,
|
||||
});
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.chat, updateSlaStatus);
|
||||
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="hasSlaThreshold"
|
||||
v-bind="$attrs"
|
||||
class="relative flex items-center cursor-pointer min-w-fit group"
|
||||
>
|
||||
<Label
|
||||
:label="slaStatus.threshold"
|
||||
:color="isSlaMissed ? 'ruby' : 'amber'"
|
||||
compact
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
|
||||
</template>
|
||||
</Label>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
+1
@@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('HELP_CENTER.TITLE')"
|
||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||
class="bg-n-surface-1"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4 p-px">
|
||||
|
||||
+18
-2
@@ -1,8 +1,22 @@
|
||||
<script setup>
|
||||
import LocaleCard from './LocaleCard.vue';
|
||||
const locales = [
|
||||
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
|
||||
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
|
||||
{
|
||||
name: 'English',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -19,6 +33,8 @@ const locales = [
|
||||
<LocaleCard
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articleCount"
|
||||
:category-count="locale.categoryCount"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
|
||||
import { buildLocaleMenuItems } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -17,6 +17,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isDraft: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
localeCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -37,11 +41,28 @@ const { t } = useI18n();
|
||||
|
||||
const [showDropdownMenu, toggleDropdown] = useToggle();
|
||||
|
||||
const localeLabel = computed(() => `${props.locale} (${props.localeCode})`);
|
||||
|
||||
const localeMenuLabels = computed(() => ({
|
||||
'change-default': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT'
|
||||
),
|
||||
'move-to-draft': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT'
|
||||
),
|
||||
'publish-locale': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
const localeMenuItems = computed(() =>
|
||||
LOCALE_MENU_ITEMS.map(item => ({
|
||||
buildLocaleMenuItems({
|
||||
isDefault: props.isDefault,
|
||||
isDraft: props.isDraft,
|
||||
}).map(item => ({
|
||||
...item,
|
||||
label: t(item.label),
|
||||
disabled: props.isDefault,
|
||||
label: localeMenuLabels.value[item.action],
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -56,7 +77,7 @@ const handleAction = ({ action, value }) => {
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ locale }} ({{ localeCode }})
|
||||
{{ localeLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
@@ -64,6 +85,12 @@ const handleAction = ({ action, value }) => {
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isDraft"
|
||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-slate-11 px-2 py-0.5"
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DRAFT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -86,6 +113,7 @@ const handleAction = ({ action, value }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="localeMenuItems.length"
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
|
||||
+37
-37
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
@@ -27,58 +27,50 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits([
|
||||
'saveArticle',
|
||||
'saveArticleAsync',
|
||||
'goBack',
|
||||
'setAuthor',
|
||||
'setCategory',
|
||||
'previewArticle',
|
||||
'createArticle',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isNewArticle = computed(() => !props.article?.id);
|
||||
|
||||
const saveAndSync = value => {
|
||||
emit('saveArticle', value);
|
||||
};
|
||||
const localTitle = ref(props.article?.title ?? '');
|
||||
const localContent = ref(props.article?.content ?? '');
|
||||
|
||||
// this will only send the data to the backend
|
||||
// but will not update the local state preventing unnecessary re-renders
|
||||
// since the data is already saved and we keep the editor text as the source of truth
|
||||
const quickSave = debounce(
|
||||
value => emit('saveArticleAsync', value),
|
||||
400,
|
||||
false
|
||||
// Sync local state when navigating to a different article or on initial fetch
|
||||
watch(
|
||||
() => props.article?.id,
|
||||
newId => {
|
||||
if (newId) {
|
||||
localTitle.value = props.article?.title ?? '';
|
||||
localContent.value = props.article?.content ?? '';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
|
||||
// so we can save the data to the backend and retrieve the updated data
|
||||
// this will update the local state with response data
|
||||
// Only use to save for existing articles
|
||||
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
|
||||
|
||||
// Debounced save for new articles
|
||||
const quickSaveNewArticle = debounce(saveAndSync, 400, false);
|
||||
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
|
||||
|
||||
const handleSave = value => {
|
||||
if (isNewArticle.value) {
|
||||
quickSaveNewArticle(value);
|
||||
} else {
|
||||
quickSave(value);
|
||||
saveAndSyncDebounced(value);
|
||||
}
|
||||
if (isNewArticle.value) return;
|
||||
debouncedSave(value);
|
||||
};
|
||||
|
||||
const articleTitle = computed({
|
||||
get: () => props.article.title,
|
||||
get: () => localTitle.value,
|
||||
set: value => {
|
||||
localTitle.value = value;
|
||||
handleSave({ title: value });
|
||||
},
|
||||
});
|
||||
|
||||
const articleContent = computed({
|
||||
get: () => props.article.content,
|
||||
get: () => localContent.value,
|
||||
set: content => {
|
||||
localContent.value = content;
|
||||
handleSave({ content });
|
||||
},
|
||||
});
|
||||
@@ -98,6 +90,14 @@ const setCategoryId = categoryId => {
|
||||
const previewArticle = () => {
|
||||
emit('previewArticle');
|
||||
};
|
||||
|
||||
const handleCreateArticle = event => {
|
||||
if (!isNewArticle.value) return;
|
||||
const title = event?.target?.value || '';
|
||||
if (title.trim()) {
|
||||
emit('createArticle', { title, content: localContent.value });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,10 +122,11 @@ const previewArticle = () => {
|
||||
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
|
||||
placeholder="Title"
|
||||
autofocus
|
||||
@blur="handleCreateArticle"
|
||||
/>
|
||||
<ArticleEditorControls
|
||||
:article="article"
|
||||
@save-article="saveAndSync"
|
||||
@save-article="values => emit('saveArticle', values)"
|
||||
@set-author="setAuthorId"
|
||||
@set-category="setCategoryId"
|
||||
/>
|
||||
@@ -160,8 +161,12 @@ const previewArticle = () => {
|
||||
}
|
||||
|
||||
.editor-root .has-selection {
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
@apply h-8 rounded-lg !px-2 z-50 bg-n-solid-3 items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
display: flex;
|
||||
top: var(--selection-top, auto) !important;
|
||||
left: var(--selection-left, 0) !important;
|
||||
@@ -169,15 +174,10 @@ const previewArticle = () => {
|
||||
position: absolute !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply mr-0;
|
||||
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0 mt-0 !mr-0;
|
||||
|
||||
svg {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-8
@@ -58,18 +58,22 @@ const openArticle = id => {
|
||||
}
|
||||
};
|
||||
|
||||
const onReorder = reorderedGroup => {
|
||||
store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
const onReorder = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Reuse existing positions to maintain order within the current group
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedArticlePositions = localArticles.value
|
||||
.map(article => article.position)
|
||||
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
|
||||
.map((article, index) => article.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const orderedArticles = localArticles.value.map(article => article.id);
|
||||
|
||||
|
||||
+12
@@ -98,6 +98,17 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
||||
deleteCategory(categoryData);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderCategories = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('categories/reorder', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
reorderedGroup,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,6 +133,7 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
||||
:categories="categories"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
@reorder="reorderCategories"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
|
||||
+60
-16
@@ -1,14 +1,22 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
|
||||
const localCategories = ref(props.categories);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
return localCategories.value?.length > 1;
|
||||
});
|
||||
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
@@ -17,21 +25,57 @@ const handleClick = slug => {
|
||||
const handleAction = ({ action, value, id }, category) => {
|
||||
emit('action', { action, value, id, category });
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedPositions = localCategories.value
|
||||
.map((category, index) => category.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const reorderedGroup = localCategories.value.reduce(
|
||||
(obj, category, index) => {
|
||||
obj[category.id] = sortedPositions[index];
|
||||
return obj;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
emit('reorder', reorderedGroup);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.categories,
|
||||
newCategories => {
|
||||
localCategories.value = newCategories;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CategoryCard
|
||||
v-for="category in categories"
|
||||
:id="category.id"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:icon="category.icon"
|
||||
:description="category.description"
|
||||
:articles-count="category.meta.articles_count || 0"
|
||||
:slug="category.slug"
|
||||
@click="handleClick(category.slug)"
|
||||
@action="handleAction($event, category)"
|
||||
/>
|
||||
</ul>
|
||||
<Draggable
|
||||
v-model="localCategories"
|
||||
:disabled="!dragEnabled"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
role="list"
|
||||
class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li class="list-none">
|
||||
<CategoryCard
|
||||
:id="element.id"
|
||||
:title="element.name"
|
||||
:icon="element.icon"
|
||||
:description="element.description"
|
||||
:articles-count="element.meta?.articles_count || 0"
|
||||
:slug="element.slug"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@click="handleClick(element.slug)"
|
||||
@action="handleAction($event, element)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
+48
-2
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
@@ -24,12 +24,20 @@ const dialogRef = ref(null);
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const selectedLocale = ref('');
|
||||
const localeStatus = ref('published');
|
||||
|
||||
const addedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const draftedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales
|
||||
.filter(locale => locale.draft)
|
||||
.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const locales = computed(() => {
|
||||
return Object.keys(allLocales)
|
||||
.map(key => {
|
||||
@@ -41,17 +49,44 @@ const locales = computed(() => {
|
||||
.filter(locale => !addedLocales.value.includes(locale.value));
|
||||
});
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{
|
||||
value: 'published',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.LIVE'),
|
||||
},
|
||||
{
|
||||
value: 'draft',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.DRAFT'),
|
||||
},
|
||||
]);
|
||||
|
||||
const resetForm = () => {
|
||||
selectedLocale.value = '';
|
||||
localeStatus.value = 'published';
|
||||
};
|
||||
|
||||
watch(localeStatus, value => {
|
||||
if (!value) {
|
||||
localeStatus.value = 'published';
|
||||
}
|
||||
});
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!selectedLocale.value) return;
|
||||
|
||||
isUpdating.value = true;
|
||||
const updatedLocales = [...addedLocales.value, selectedLocale.value];
|
||||
const updatedDraftLocales =
|
||||
localeStatus.value === 'draft'
|
||||
? [...new Set([...draftedLocales.value, selectedLocale.value])]
|
||||
: draftedLocales.value;
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: {
|
||||
allowed_locales: updatedLocales,
|
||||
draft_locales: updatedDraftLocales,
|
||||
default_locale: props.portal?.meta?.default_locale,
|
||||
},
|
||||
});
|
||||
@@ -62,7 +97,7 @@ const onCreate = async () => {
|
||||
from: route.name,
|
||||
});
|
||||
|
||||
selectedLocale.value = '';
|
||||
resetForm();
|
||||
dialogRef.value?.close();
|
||||
useAlert(
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
|
||||
@@ -87,6 +122,7 @@ defineExpose({ dialogRef });
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
|
||||
@close="resetForm"
|
||||
@confirm="onCreate"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
@@ -98,6 +134,16 @@ defineExpose({ dialogRef });
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.LABEL') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="localeStatus"
|
||||
:options="statusOptions"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -29,6 +29,7 @@ const isLocaleDefault = code => {
|
||||
|
||||
const updatePortalLocales = async ({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey,
|
||||
}) => {
|
||||
@@ -39,6 +40,7 @@ const updatePortalLocales = async ({
|
||||
config: {
|
||||
default_locale: defaultLocale,
|
||||
allowed_locales: newAllowedLocales,
|
||||
draft_locales: newDraftLocales,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,8 +55,12 @@ const updatePortalLocales = async ({
|
||||
|
||||
const changeDefaultLocale = ({ localeCode }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const newDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: localeCode,
|
||||
messageKey: 'CHANGE_DEFAULT_LOCALE',
|
||||
});
|
||||
@@ -81,11 +87,15 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
const updatedLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode)
|
||||
.map(locale => locale.code);
|
||||
const updatedDraftLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode && locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
|
||||
const defaultLocale = props.portal.meta.default_locale;
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales: updatedLocales,
|
||||
newDraftLocales: updatedDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey: 'DELETE_LOCALE',
|
||||
});
|
||||
@@ -98,9 +108,46 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateDraftLocales = async ({ localeCode, shouldDraft, messageKey }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const currentDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
const newDraftLocales = shouldDraft
|
||||
? [...new Set([...currentDraftLocales, localeCode])]
|
||||
: currentDraftLocales.filter(locale => locale !== localeCode);
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: props.portal.meta.default_locale,
|
||||
messageKey,
|
||||
});
|
||||
};
|
||||
|
||||
const moveLocaleToDraft = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: true,
|
||||
messageKey: 'DRAFT_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const publishLocale = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: false,
|
||||
messageKey: 'PUBLISH_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = ({ action }, localeCode) => {
|
||||
if (action === 'change-default') {
|
||||
changeDefaultLocale({ localeCode: localeCode });
|
||||
} else if (action === 'move-to-draft') {
|
||||
moveLocaleToDraft({ localeCode: localeCode });
|
||||
} else if (action === 'publish-locale') {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -114,6 +161,7 @@ const handleAction = ({ action }, localeCode) => {
|
||||
:key="index"
|
||||
:locale="locale.name"
|
||||
:is-default="isLocaleDefault(locale.code)"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articlesCount || 0"
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
|
||||
+12
@@ -4,37 +4,49 @@ import LocalesPage from './LocalesPage.vue';
|
||||
const locales = [
|
||||
{
|
||||
name: 'English (en-US)',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 5,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish (es-ES)',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 20,
|
||||
categoryCount: 10,
|
||||
},
|
||||
{
|
||||
name: 'English (en-UK)',
|
||||
code: 'en_GB',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malay (ms-MY)',
|
||||
code: 'ms',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malayalam (ml-IN)',
|
||||
code: 'ml',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 10,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Hindi (hi-IN)',
|
||||
code: 'hi',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
@@ -18,22 +15,18 @@ import {
|
||||
processContactableInboxes,
|
||||
mergeInboxDetails,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
alignPosition: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
},
|
||||
contactId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
isModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
align: {
|
||||
type: String,
|
||||
default: 'end',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,23 +35,16 @@ const emit = defineEmits(['close']);
|
||||
const searchContacts = createContactSearcher();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||
|
||||
const isSmallScreen = computed(
|
||||
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
||||
);
|
||||
|
||||
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
|
||||
|
||||
const popoverRef = ref(null);
|
||||
const contacts = ref([]);
|
||||
const selectedContact = ref(null);
|
||||
const targetInbox = ref(null);
|
||||
const isCreatingContact = ref(false);
|
||||
const isFetchingInboxes = ref(false);
|
||||
const isSearching = ref(false);
|
||||
const showComposeNewConversation = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
message: '',
|
||||
@@ -95,14 +81,6 @@ const directUploadsEnabled = computed(
|
||||
|
||||
const activeContact = computed(() => contactById.value(props.contactId));
|
||||
|
||||
const composePopoverClass = computed(() => {
|
||||
if (viewInModal.value) return '';
|
||||
|
||||
return props.alignPosition === 'right'
|
||||
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
||||
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
|
||||
});
|
||||
|
||||
const onContactSearch = debounce(
|
||||
async query => {
|
||||
isSearching.value = true;
|
||||
@@ -172,7 +150,7 @@ const clearSelectedContact = () => {
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
showComposeNewConversation.value = false;
|
||||
popoverRef.value?.hide();
|
||||
if (!props.contactId) {
|
||||
// If contactId is passed as prop
|
||||
// Then don't allow to remove the selected contact
|
||||
@@ -180,7 +158,6 @@ const closeCompose = () => {
|
||||
}
|
||||
targetInbox.value = null;
|
||||
resetContacts();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const discardCompose = () => {
|
||||
@@ -213,8 +190,15 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
showComposeNewConversation.value = !showComposeNewConversation.value;
|
||||
const onPopoverShow = () => {
|
||||
// Flag to prevent triggering drag n drop,
|
||||
// When compose modal is active
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||
};
|
||||
|
||||
const onPopoverHide = () => {
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -242,64 +226,22 @@ watch(
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (!showComposeNewConversation.value) return;
|
||||
|
||||
showComposeNewConversation.value = false;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onModalBackdropClick = () => {
|
||||
if (!viewInModal.value) return;
|
||||
handleClickOutside();
|
||||
};
|
||||
|
||||
onMounted(() => resetContacts());
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
if (showComposeNewConversation.value) {
|
||||
showComposeNewConversation.value = false;
|
||||
emit('close');
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
handleClickOutside,
|
||||
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
||||
// This will prevent closing the compose conversation modal when the editor Create link popup is open
|
||||
{ ignore: ['div.ProseMirror-prompt'] },
|
||||
]"
|
||||
class="relative"
|
||||
:class="{
|
||||
'z-50': showComposeNewConversation && !viewInModal,
|
||||
}"
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
:align="align"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
<slot
|
||||
name="trigger"
|
||||
:is-open="showComposeNewConversation"
|
||||
:toggle="toggle"
|
||||
/>
|
||||
<div
|
||||
v-if="showComposeNewConversation"
|
||||
:class="{
|
||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
||||
viewInModal,
|
||||
}"
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<template #default="{ isOpen }">
|
||||
<slot name="trigger" :is-open="isOpen" />
|
||||
</template>
|
||||
<template #content>
|
||||
<ComposeNewConversationForm
|
||||
:form-state="formState"
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
@@ -321,6 +263,6 @@ useKeyboardEvents(keyboardEvents);
|
||||
@create-conversation="createConversation"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
+1
-1
@@ -220,7 +220,7 @@ useEventListener(document, 'paste', onPaste);
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
|
||||
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
|
||||
:on-click="onClickInsertEmoji"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -361,7 +361,7 @@ useKeyboardEvents({
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||
class="w-full md:w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
|
||||
<ContactSelector
|
||||
|
||||
@@ -14,6 +14,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel({ type: Boolean, default: false });
|
||||
@@ -28,7 +32,8 @@ const modelValue = defineModel({ type: Boolean, default: false });
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ header }}
|
||||
</span>
|
||||
<ToggleSwitch v-model="modelValue" />
|
||||
<div v-if="hideToggle" class="size-2" />
|
||||
<ToggleSwitch v-else v-model="modelValue" />
|
||||
</div>
|
||||
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||
{{ description }}
|
||||
|
||||
@@ -18,10 +18,18 @@ const newMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const formatMessagesForApi = () => {
|
||||
return messages.value.map(message => ({
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
}));
|
||||
return messages.value.map(message => {
|
||||
const payload = {
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
};
|
||||
|
||||
if (message.sender === 'assistant' && message.agentName) {
|
||||
payload.agent_name = message.agentName;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
};
|
||||
|
||||
const resetConversation = () => {
|
||||
@@ -62,6 +70,7 @@ const sendMessage = async () => {
|
||||
messages.value.push({
|
||||
content: data.response,
|
||||
sender: 'assistant',
|
||||
agentName: data.agent_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -71,6 +80,12 @@ const sendMessage = async () => {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,7 +119,7 @@ const sendMessage = async () => {
|
||||
v-model="newMessage"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||
@keyup.enter="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, useSlots } from 'vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -30,23 +30,40 @@ const modelValue = defineModel({
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => modelValue.value.size);
|
||||
const totalCount = computed(() => props.allItems.length);
|
||||
|
||||
const visibleItemIds = computed(() => props.allItems.map(item => item.id));
|
||||
const visibleItemCount = computed(() => visibleItemIds.value.length);
|
||||
const selectedVisibleCount = computed(
|
||||
() => visibleItemIds.value.filter(id => modelValue.value.has(id)).length
|
||||
);
|
||||
const hasSelected = computed(() => selectedCount.value > 0);
|
||||
const isIndeterminate = computed(
|
||||
() => hasSelected.value && selectedCount.value < totalCount.value
|
||||
() =>
|
||||
selectedVisibleCount.value > 0 &&
|
||||
selectedVisibleCount.value < visibleItemCount.value
|
||||
);
|
||||
const allSelected = computed(
|
||||
() => totalCount.value > 0 && selectedCount.value === totalCount.value
|
||||
() =>
|
||||
visibleItemCount.value > 0 &&
|
||||
selectedVisibleCount.value === visibleItemCount.value
|
||||
);
|
||||
|
||||
const slots = useSlots();
|
||||
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
|
||||
|
||||
const bulkCheckboxState = computed({
|
||||
get: () => allSelected.value,
|
||||
set: shouldSelectAll => {
|
||||
const newSelectedIds = shouldSelectAll
|
||||
? new Set(props.allItems.map(item => item.id))
|
||||
: new Set();
|
||||
modelValue.value = newSelectedIds;
|
||||
if (!visibleItemCount.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSelection = new Set(modelValue.value);
|
||||
if (shouldSelectAll) {
|
||||
visibleItemIds.value.forEach(id => updatedSelection.add(id));
|
||||
} else {
|
||||
visibleItemIds.value.forEach(id => updatedSelection.delete(id));
|
||||
}
|
||||
modelValue.value = updatedSelection;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -63,7 +80,7 @@ const bulkCheckboxState = computed({
|
||||
v-if="hasSelected"
|
||||
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Checkbox
|
||||
v-model="bulkCheckboxState"
|
||||
@@ -78,21 +95,23 @@ const bulkCheckboxState = computed({
|
||||
<span class="text-sm text-n-slate-10 truncate tabular-nums">
|
||||
{{ selectedCountLabel }}
|
||||
</span>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<slot name="secondary-actions" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
:label="deleteLabel"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('bulkDelete')"
|
||||
/>
|
||||
</slot>
|
||||
<slot v-if="hasSecondaryActions" name="secondary-actions" />
|
||||
<div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
:label="deleteLabel"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('bulkDelete')"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -34,14 +35,34 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSelectionControl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showMenu: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
const emit = defineEmits(['action', 'select', 'hover']);
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
const modelValue = computed({
|
||||
get: () => props.isSelected,
|
||||
set: () => emit('select', props.id),
|
||||
});
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
@@ -79,12 +100,23 @@ const handleAction = ({ action, value }) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<CardLayout
|
||||
:selectable="selectable"
|
||||
class="relative"
|
||||
@mouseenter="emit('hover', true)"
|
||||
@mouseleave="emit('hover', false)"
|
||||
>
|
||||
<div
|
||||
v-show="showSelectionControl"
|
||||
class="absolute top-7 ltr:left-3 rtl:right-3"
|
||||
>
|
||||
<Checkbox v-model="modelValue" />
|
||||
</div>
|
||||
<div class="flex gap-1 justify-between w-full">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</span>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div v-if="showMenu" class="flex gap-2 items-center">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="flex relative items-center group"
|
||||
|
||||
+11
-5
@@ -21,16 +21,22 @@ const emit = defineEmits(['deleteSuccess']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const bulkDeleteDialogRef = ref(null);
|
||||
const i18nKey = computed(() => props.type.toUpperCase());
|
||||
const i18nKey = computed(() => {
|
||||
const i18nTypeMap = {
|
||||
AssistantResponse: 'RESPONSES',
|
||||
AssistantDocument: 'DOCUMENTS',
|
||||
};
|
||||
return i18nTypeMap[props.type];
|
||||
});
|
||||
|
||||
const handleBulkDelete = async ids => {
|
||||
if (!ids) return;
|
||||
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkDelete',
|
||||
Array.from(props.bulkIds)
|
||||
);
|
||||
await store.dispatch('captainBulkActions/handleBulkDelete', {
|
||||
ids: Array.from(props.bulkIds),
|
||||
type: props.type,
|
||||
});
|
||||
|
||||
emit('deleteSuccess');
|
||||
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
|
||||
|
||||
@@ -6,6 +6,13 @@ import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||
|
||||
defineProps({
|
||||
featurePrefix: {
|
||||
type: String,
|
||||
default: 'CAPTAIN',
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
@@ -31,7 +38,7 @@ const openBilling = () => {
|
||||
>
|
||||
<BasePaywallModal
|
||||
class="mx-auto"
|
||||
feature-prefix="CAPTAIN"
|
||||
:feature-prefix="featurePrefix"
|
||||
:i18n-key="i18nKey"
|
||||
:is-super-admin="isSuperAdmin"
|
||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||
|
||||
+7
@@ -27,6 +27,7 @@ const initialState = {
|
||||
conversationFaqs: false,
|
||||
memories: false,
|
||||
citations: false,
|
||||
contactAttributes: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -59,6 +60,7 @@ const updateStateFromAssistant = assistant => {
|
||||
conversationFaqs: config.feature_faq || false,
|
||||
memories: config.feature_memory || false,
|
||||
citations: config.feature_citation || false,
|
||||
contactAttributes: config.feature_contact_attributes || false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -79,6 +81,7 @@ const handleBasicInfoUpdate = async () => {
|
||||
feature_faq: state.features.conversationFaqs,
|
||||
feature_memory: state.features.memories,
|
||||
feature_citation: state.features.citations,
|
||||
feature_contact_attributes: state.features.contactAttributes,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -138,6 +141,10 @@ watch(
|
||||
<input v-model="state.features.citations" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="state.features.contactAttributes" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONTACT_ATTRIBUTES') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+3
-6
@@ -101,12 +101,9 @@ const authTypeLabel = computed(() => {
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<span
|
||||
v-if="description"
|
||||
class="text-sm truncate text-n-slate-11 flex-1"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full gap-4 min-w-0">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span v-if="description" class="text-sm truncate text-n-slate-11">
|
||||
{{ description }}
|
||||
</span>
|
||||
<span
|
||||
|
||||
+80
-6
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
||||
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { required, maxLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import CustomToolsAPI from 'dashboard/api/captain/customTools';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
@@ -72,8 +73,12 @@ const DEFAULT_PARAM = {
|
||||
required: false,
|
||||
};
|
||||
|
||||
// OpenAI enforces a 64-char limit on function names. The backend slug is
|
||||
// "custom_" (7 chars) + parameterized title, so cap the title conservatively.
|
||||
const MAX_TOOL_NAME_LENGTH = 55;
|
||||
|
||||
const validationRules = {
|
||||
title: { required },
|
||||
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
|
||||
endpoint_url: { required },
|
||||
http_method: { required },
|
||||
auth_type: { required },
|
||||
@@ -103,9 +108,15 @@ const isLoading = computed(() =>
|
||||
);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
if (!v$.value[field].$error) return '';
|
||||
|
||||
const failedRule = v$.value[field].$errors[0]?.$validator;
|
||||
if (failedRule === 'maxLength') {
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, {
|
||||
max: MAX_TOOL_NAME_LENGTH,
|
||||
});
|
||||
}
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`);
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
@@ -140,6 +151,30 @@ const handleSubmit = async () => {
|
||||
|
||||
emit('submit', state);
|
||||
};
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testResult = ref(null);
|
||||
const isTestDisabled = computed(
|
||||
() => state.endpoint_url.includes('{{') || !!state.request_template
|
||||
);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!state.endpoint_url) return;
|
||||
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const { data } = await CustomToolsAPI.test(state);
|
||||
const isOk = data.status >= 200 && data.status < 300;
|
||||
testResult.value = { success: isOk, status: data.status };
|
||||
} catch (e) {
|
||||
const message =
|
||||
e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR');
|
||||
testResult.value = { success: false, message };
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -248,6 +283,45 @@ const handleSubmit = async () => {
|
||||
class="[&_textarea]:font-mono"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-play"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.TEST.BUTTON')"
|
||||
:is-loading="isTesting"
|
||||
:disabled="isTesting || !state.endpoint_url || isTestDisabled"
|
||||
@click="handleTest"
|
||||
/>
|
||||
<p v-if="isTestDisabled" class="text-xs text-n-slate-11">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.TEST.DISABLED_HINT') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="testResult"
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs rounded-lg"
|
||||
:class="
|
||||
testResult.success
|
||||
? 'bg-n-teal-2 text-n-teal-11'
|
||||
: 'bg-n-ruby-2 text-n-ruby-11'
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
testResult.success ? 'i-lucide-check-circle' : 'i-lucide-x-circle'
|
||||
"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
{{
|
||||
testResult.status
|
||||
? t('CAPTAIN.CUSTOM_TOOLS.TEST.SUCCESS', {
|
||||
status: testResult.status,
|
||||
})
|
||||
: testResult.message
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+12
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -10,6 +13,15 @@ const onClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://chwt.app/hc/captain-tools"
|
||||
class="mb-8"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||
|
||||
@@ -29,6 +29,12 @@ const handleInput = () => {
|
||||
nextTick(adjustHeight);
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(adjustHeight);
|
||||
});
|
||||
@@ -43,7 +49,7 @@ onMounted(() => {
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
|
||||
rows="1"
|
||||
@input="handleInput"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<button
|
||||
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||
|
||||
@@ -96,6 +96,17 @@ const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
// Only close if the close event originated from this dialog,
|
||||
// not from a child dialog (e.g. ProseMirror prompt) bubbling up.
|
||||
const handleDialogClose = e => e.target === dialogRef.value && close();
|
||||
|
||||
// Only close on click-outside if this dialog is the topmost one.
|
||||
// If another dialog (e.g. ProseMirror prompt) is open on top, ignore.
|
||||
const handleClickOutside = () => {
|
||||
const dialogs = document.querySelectorAll('dialog[open]');
|
||||
if (dialogs[dialogs.length - 1] === dialogRef.value) close();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
@@ -113,9 +124,9 @@ defineExpose({ open, close });
|
||||
positionClass,
|
||||
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
||||
]"
|
||||
@close="close"
|
||||
@close.prevent="handleDialogClose"
|
||||
>
|
||||
<OnClickOutside @trigger="close">
|
||||
<OnClickOutside @trigger="handleClickOutside">
|
||||
<form
|
||||
ref="dialogContentRef"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
|
||||
@@ -36,7 +36,13 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']);
|
||||
const emit = defineEmits([
|
||||
'enterPress',
|
||||
'escapePress',
|
||||
'input',
|
||||
'blur',
|
||||
'focus',
|
||||
]);
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: [String, Number],
|
||||
@@ -49,6 +55,10 @@ const onEnterPress = () => {
|
||||
emit('enterPress');
|
||||
};
|
||||
|
||||
const onEscapePress = () => {
|
||||
emit('escapePress');
|
||||
};
|
||||
|
||||
const handleInput = event => {
|
||||
emit('input', event.target.value);
|
||||
modelValue.value = event.target.value;
|
||||
@@ -102,6 +112,7 @@ defineExpose({
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keydown.enter.prevent="onEnterPress"
|
||||
@keydown.escape.prevent="onEscapePress"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,6 +32,7 @@ const convertToMinutes = newValue => {
|
||||
|
||||
const transformedValue = computed({
|
||||
get() {
|
||||
if (duration.value == null) return null;
|
||||
if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
|
||||
if (unit.value === DURATION_UNITS.HOURS)
|
||||
return Math.floor(duration.value / 60);
|
||||
@@ -41,6 +42,10 @@ const transformedValue = computed({
|
||||
return 0;
|
||||
},
|
||||
set(newValue) {
|
||||
if (newValue == null || newValue === '') {
|
||||
duration.value = null;
|
||||
return;
|
||||
}
|
||||
let minuteValue = convertToMinutes(newValue);
|
||||
|
||||
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
|
||||
@@ -53,6 +58,7 @@ const transformedValue = computed({
|
||||
// this might create some confusion, especially when saving
|
||||
// this watcher fixes it by rounding the duration basically, to the nearest unit value
|
||||
watch(unit, () => {
|
||||
if (duration.value == null) return;
|
||||
let adjustedValue = convertToMinutes(transformedValue.value);
|
||||
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,9 @@ const isSent = computed(() => {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
|
||||
// API inbox messages use real sent/delivered/read status values from the external system.
|
||||
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.SENT;
|
||||
|
||||
// All messages will be mark as sent for the Line channel, as there is no source ID.
|
||||
if (isALineChannel.value) return true;
|
||||
|
||||
@@ -86,8 +89,10 @@ const isDelivered = computed(() => {
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
|
||||
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||
// API inbox messages use real delivered status from the external system.
|
||||
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.DELIVERED;
|
||||
// All messages marked as delivered for the web widget inbox once they are sent.
|
||||
if (isAWebWidgetInbox.value) {
|
||||
return status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
if (isALineChannel.value) {
|
||||
|
||||
@@ -226,4 +226,10 @@ const handleSeeOriginal = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Email clients (Gmail, Outlook) hardcode dir="ltr" on wrapper elements.
|
||||
// In RTL apps this forces email content LTR regardless of actual text.
|
||||
[dir='rtl'] .letter-render [dir='ltr'] {
|
||||
direction: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
align: {
|
||||
type: String,
|
||||
default: 'end',
|
||||
validator: v => ['start', 'end'].includes(v),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['show', 'hide']);
|
||||
|
||||
const isActive = ref(false);
|
||||
const triggerRef = ref(null);
|
||||
const popoverRef = ref(null);
|
||||
const mobileContentRef = ref(null);
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||
const isMobile = breakpoints.smaller('md');
|
||||
const showPopover = computed(() => isActive.value && !isMobile.value);
|
||||
|
||||
const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||
triggerRef,
|
||||
popoverRef,
|
||||
showPopover,
|
||||
{ align: props.align }
|
||||
);
|
||||
|
||||
const show = async () => {
|
||||
isActive.value = true;
|
||||
if (!isMobile.value) {
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
}
|
||||
emit('show');
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
if (!isActive.value) return;
|
||||
isActive.value = false;
|
||||
emit('hide');
|
||||
};
|
||||
|
||||
const toggle = async () => {
|
||||
if (isActive.value) hide();
|
||||
else await show();
|
||||
};
|
||||
|
||||
// Recalculate position when switching from mobile to desktop while open
|
||||
watch(isMobile, async mobile => {
|
||||
if (!isActive.value || mobile) return;
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
});
|
||||
|
||||
const handleClickOutside = event => {
|
||||
if (triggerRef.value?.contains(event.target)) return;
|
||||
hide();
|
||||
};
|
||||
|
||||
// Selectors for teleported elements that should not trigger close
|
||||
const clickOutsideIgnore = [
|
||||
'dialog.ProseMirror-prompt-backdrop',
|
||||
'[data-popover-content]',
|
||||
];
|
||||
|
||||
useKeyboardEvents({
|
||||
Escape: {
|
||||
action: () => isActive.value && hide(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineExpose({ show, hide, toggle });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="triggerRef" class="inline-flex" @click="toggle">
|
||||
<slot :is-open="isActive" />
|
||||
</span>
|
||||
|
||||
<TeleportWithDirection to="body">
|
||||
<!-- Mobile: centered modal with backdrop -->
|
||||
<div
|
||||
v-if="isActive && isMobile"
|
||||
data-popover-backdrop
|
||||
class="fixed inset-0 z-[9999] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center bg-n-alpha-black1"
|
||||
>
|
||||
<div
|
||||
ref="mobileContentRef"
|
||||
v-on-click-outside="[
|
||||
handleClickOutside,
|
||||
{ ignore: clickOutsideIgnore },
|
||||
]"
|
||||
data-popover-content
|
||||
class="relative w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 overflow-y-auto bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
>
|
||||
<slot name="content" :hide="hide" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: fixed popover -->
|
||||
<div
|
||||
v-else-if="showPopover"
|
||||
ref="popoverRef"
|
||||
v-on-click-outside="[handleClickOutside, { ignore: clickOutsideIgnore }]"
|
||||
data-popover-content
|
||||
:class="fixedPosition.class"
|
||||
:style="fixedPosition.style"
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl overflow-y-auto max-h-[calc(100vh-2rem)]"
|
||||
>
|
||||
<slot name="content" :hide="hide" />
|
||||
</div>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
@@ -10,8 +10,6 @@ import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
@@ -184,15 +182,6 @@ const closeMobileSidebar = () => {
|
||||
emit('closeMobileSidebar');
|
||||
};
|
||||
|
||||
const onComposeOpen = toggleFn => {
|
||||
toggleFn();
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||
};
|
||||
|
||||
const onComposeClose = () => {
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
};
|
||||
|
||||
const newReportRoutes = () => [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
@@ -250,6 +239,12 @@ const menuItems = computed(() => {
|
||||
activeOn: ['conversation_through_mentions'],
|
||||
to: accountScopedRoute('conversation_mentions'),
|
||||
},
|
||||
{
|
||||
name: 'Participating',
|
||||
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
|
||||
activeOn: ['conversation_through_participating'],
|
||||
to: accountScopedRoute('conversation_participating'),
|
||||
},
|
||||
{
|
||||
name: 'Unattended',
|
||||
activeOn: ['conversation_through_unattended'],
|
||||
@@ -728,7 +723,13 @@ const menuItems = computed(() => {
|
||||
<aside
|
||||
v-on-click-outside="[
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#mobile-sidebar-launcher'] },
|
||||
{
|
||||
ignore: [
|
||||
'#mobile-sidebar-launcher',
|
||||
'[data-popover-content]',
|
||||
'[data-popover-backdrop]',
|
||||
],
|
||||
},
|
||||
]"
|
||||
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
||||
:class="[
|
||||
@@ -796,8 +797,8 @@ const menuItems = computed(() => {
|
||||
>
|
||||
<span class="i-lucide-search size-4 text-n-slate-11" />
|
||||
</RouterLink>
|
||||
<ComposeConversation align-position="right" @close="onComposeClose">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<ComposeConversation align="start">
|
||||
<template #trigger="{ isOpen }">
|
||||
<Button
|
||||
icon="i-lucide-pen-line"
|
||||
color="slate"
|
||||
@@ -809,7 +810,6 @@ const menuItems = computed(() => {
|
||||
: '!h-7 !outline-n-weak !text-n-slate-11',
|
||||
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
|
||||
]"
|
||||
@click="onComposeOpen(toggle)"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
|
||||
@@ -158,9 +158,11 @@ const activeChild = computed(() => {
|
||||
return rankedPage ?? activeOnPages[0];
|
||||
}
|
||||
|
||||
return navigableChildren.value.find(
|
||||
child => child.to && route.path.startsWith(resolvePath(child.to))
|
||||
);
|
||||
return navigableChildren.value.find(child => {
|
||||
if (!child.to) return false;
|
||||
const childPath = resolvePath(child.to);
|
||||
return route.path === childPath || route.path.startsWith(`${childPath}/`);
|
||||
});
|
||||
});
|
||||
|
||||
const hasActiveChild = computed(() => {
|
||||
|
||||
@@ -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([
|
||||
@@ -107,7 +103,8 @@ function onKeydown(view, event) {
|
||||
emit('keydown');
|
||||
|
||||
// Handle Enter key to send message (Shift+Enter for new line)
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// Skip if IME composition is active (CJK character confirmation)
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
return true; // Prevent ProseMirror's default Enter handling
|
||||
@@ -207,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"
|
||||
|
||||
@@ -313,7 +313,12 @@ const plugins = computed(() => {
|
||||
const sendWithSignature = computed(() => {
|
||||
// this is considered the source of truth, we watch this property
|
||||
// on change, we toggle the signature in the editor
|
||||
if (props.allowSignature && !props.isPrivate && props.channelType) {
|
||||
if (
|
||||
props.allowSignature &&
|
||||
!props.isPrivate &&
|
||||
props.channelType &&
|
||||
!props.disabled
|
||||
) {
|
||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||
}
|
||||
|
||||
@@ -436,6 +441,7 @@ function reloadState(content = props.modelValue) {
|
||||
}
|
||||
|
||||
function addSignature() {
|
||||
if (props.disabled) return;
|
||||
let content = props.modelValue;
|
||||
// see if the content is empty, if it is before appending the signature
|
||||
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||
@@ -454,6 +460,7 @@ function addSignature() {
|
||||
}
|
||||
|
||||
function removeSignature() {
|
||||
if (props.disabled) return;
|
||||
if (!props.signature) return;
|
||||
let content = props.modelValue;
|
||||
content = removeSignatureHelper(
|
||||
@@ -806,7 +813,7 @@ watch(
|
||||
|
||||
watch(sendWithSignature, newValue => {
|
||||
// see if the allowSignature flag is true
|
||||
if (props.allowSignature) {
|
||||
if (props.allowSignature && !props.disabled) {
|
||||
toggleSignatureInEditor(newValue);
|
||||
}
|
||||
});
|
||||
@@ -828,6 +835,8 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ focusEditorInputField });
|
||||
|
||||
// BUS Event to insert text or markdown into the editor at the
|
||||
// current cursor position.
|
||||
// Components using this
|
||||
@@ -975,14 +984,43 @@ 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 {
|
||||
@apply bg-n-alpha-black1 backdrop-blur-[4px];
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
|
||||
@apply bg-n-alpha-3 border border-n-strong p-6 shadow-xl rounded-xl w-96 !important;
|
||||
|
||||
h5 {
|
||||
@apply text-n-slate-12 mb-1.5;
|
||||
@apply text-n-slate-12 mb-3;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-buttons {
|
||||
|
||||
@@ -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: {
|
||||
@@ -79,7 +96,7 @@ export default {
|
||||
|
||||
created() {
|
||||
state = createState(
|
||||
this.modelValue,
|
||||
this.modelValue || '',
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
@@ -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);
|
||||
@@ -170,7 +307,7 @@ export default {
|
||||
},
|
||||
reloadState() {
|
||||
state = createState(
|
||||
this.modelValue,
|
||||
this.modelValue || '',
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
@@ -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"
|
||||
@@ -326,26 +472,4 @@ export default {
|
||||
max-height: 7.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
|
||||
|
||||
h5 {
|
||||
@apply text-n-slate-12 mb-1.5;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-buttons {
|
||||
button {
|
||||
@apply h-8 px-3;
|
||||
|
||||
&[type='submit'] {
|
||||
@apply bg-n-brand text-white hover:bg-n-brand/90;
|
||||
}
|
||||
|
||||
&[type='button'] {
|
||||
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -128,7 +128,6 @@ export default {
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'replaceText',
|
||||
'toggleInsertArticle',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
@@ -277,9 +276,6 @@ export default {
|
||||
toggleMessageSignature() {
|
||||
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
|
||||
},
|
||||
replaceText(text) {
|
||||
this.$emit('replaceText', text);
|
||||
},
|
||||
toggleInsertArticle() {
|
||||
this.$emit('toggleInsertArticle');
|
||||
},
|
||||
|
||||
@@ -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 PriorityMark from './PriorityMark.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"
|
||||
@@ -305,7 +144,7 @@ const deleteConversation = () => {
|
||||
>
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
|
||||
<div
|
||||
class="flex items-center gap-2 flex-shrink-0"
|
||||
class="flex items-baseline gap-2 flex-shrink-0"
|
||||
:class="{
|
||||
'flex-1 justify-between': !showInboxName,
|
||||
}"
|
||||
@@ -317,7 +156,10 @@ const deleteConversation = () => {
|
||||
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
|
||||
<CardPriorityIcon
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0 !size-3.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h4
|
||||
@@ -366,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"
|
||||
@@ -383,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>
|
||||
|
||||
@@ -45,6 +45,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"
|
||||
@@ -78,7 +73,7 @@ const onSend = () => {
|
||||
<div
|
||||
v-else-if="isGeneratingContent"
|
||||
key="loading-state"
|
||||
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
|
||||
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full mb-4 p-4 flex items-start"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<CaptainLoader class="text-n-iris-10 size-4" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<script>
|
||||
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'PriorityMark',
|
||||
props: {
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
validate: value =>
|
||||
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
CONVERSATION_PRIORITY,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
tooltipText() {
|
||||
return this.$t(
|
||||
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
|
||||
);
|
||||
},
|
||||
isUrgent() {
|
||||
return this.priority === CONVERSATION_PRIORITY.URGENT;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<span
|
||||
v-if="priority"
|
||||
v-tooltip="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1500, hide: 0 },
|
||||
}"
|
||||
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
|
||||
:class="{
|
||||
'bg-n-ruby-4 text-n-ruby-10': isUrgent,
|
||||
'bg-n-slate-4 text-n-slate-11': !isUrgent,
|
||||
}"
|
||||
>
|
||||
<fluent-icon
|
||||
:icon="`priority-${priority.toLowerCase()}`"
|
||||
:size="isUrgent ? 12 : 14"
|
||||
class="flex-shrink-0"
|
||||
view-box="0 0 14 14"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
@@ -27,7 +27,6 @@ import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||
import {
|
||||
getMessageVariables,
|
||||
getUndefinedVariablesInMessage,
|
||||
replaceVariablesInMessage,
|
||||
} from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||
@@ -82,13 +81,7 @@ export default {
|
||||
CopilotReplyBottomPanel,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
popOutReplyBox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:popOutReplyBox'],
|
||||
emits: ['toggleEditorSize'],
|
||||
setup() {
|
||||
const {
|
||||
uiSettings,
|
||||
@@ -99,6 +92,7 @@ export default {
|
||||
} = useUISettings();
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
const messageEditor = useTemplateRef('messageEditor');
|
||||
const copilot = useCopilotReply();
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
|
||||
@@ -109,6 +103,7 @@ export default {
|
||||
setQuotedReplyFlagForInbox,
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
replyEditor,
|
||||
messageEditor,
|
||||
copilot,
|
||||
shortcutKey,
|
||||
};
|
||||
@@ -162,7 +157,8 @@ export default {
|
||||
this.inReplyTo?.id &&
|
||||
!this.isPrivate &&
|
||||
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
|
||||
!this.is360DialogWhatsAppChannel
|
||||
!this.is360DialogWhatsAppChannel &&
|
||||
!this.copilot.isActive.value
|
||||
);
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
@@ -250,6 +246,9 @@ export default {
|
||||
if (this.isAnInstagramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
||||
}
|
||||
if (this.isATelegramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TELEGRAM;
|
||||
}
|
||||
if (this.isATiktokChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TIKTOK;
|
||||
}
|
||||
@@ -506,7 +505,7 @@ export default {
|
||||
);
|
||||
|
||||
this.fetchAndSetReplyTo();
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
||||
|
||||
// A hacky fix to solve the drag and drop
|
||||
// Is showing on top of new conversation modal drag and drop
|
||||
@@ -521,7 +520,7 @@ export default {
|
||||
unmounted() {
|
||||
document.removeEventListener('paste', this.onPaste);
|
||||
document.removeEventListener('keydown', this.handleKeyEvents);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
||||
emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
|
||||
emitter.off(
|
||||
BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
||||
@@ -542,7 +541,10 @@ export default {
|
||||
},
|
||||
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
this.copilotAcceptedMessages[key] = trimContent(message || '');
|
||||
this.copilotAcceptedMessages[key] = trimContent(
|
||||
message || '',
|
||||
this.maxLength
|
||||
);
|
||||
},
|
||||
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
@@ -600,7 +602,7 @@ export default {
|
||||
saveDraft(conversationId, replyType) {
|
||||
if (this.message || this.message === '') {
|
||||
const key = this.getDraftKey(conversationId, replyType);
|
||||
const draftToSave = trimContent(this.message || '');
|
||||
const draftToSave = trimContent(this.message || '', this.maxLength);
|
||||
|
||||
this.$store.dispatch('draftMessages/set', {
|
||||
key,
|
||||
@@ -627,10 +629,17 @@ export default {
|
||||
return message;
|
||||
}
|
||||
|
||||
// Even when editor is disabled (e.g. WhatsApp/API can't reply), we must
|
||||
// still normalize stale signatures out of drafts when signature is off.
|
||||
if (this.isEditorDisabled && this.sendWithSignature) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.messageSignature, effectiveChannelType)
|
||||
: removeSignature(message, this.messageSignature, effectiveChannelType);
|
||||
@@ -782,7 +791,6 @@ export default {
|
||||
|
||||
this.clearMessage();
|
||||
this.hideEmojiPicker();
|
||||
this.$emit('update:popOutReplyBox', false);
|
||||
}
|
||||
},
|
||||
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
||||
@@ -902,32 +910,6 @@ export default {
|
||||
});
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
replaceText(message) {
|
||||
if (this.sendWithSignature && !this.private) {
|
||||
// if signature is enabled, append it to the message
|
||||
// appendSignature ensures that the signature is not duplicated
|
||||
// so we don't need to check if the signature is already present
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
message,
|
||||
variables: this.messageVariables,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
useTrack(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
this.message = updatedMessage;
|
||||
}, 100);
|
||||
},
|
||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||
// Clear attachments when switching between private note and reply modes
|
||||
// This is to prevent from breaking the upload rules
|
||||
@@ -1190,6 +1172,15 @@ export default {
|
||||
return false;
|
||||
});
|
||||
},
|
||||
onReplyToMessage() {
|
||||
this.fetchAndSetReplyTo();
|
||||
if (this.inReplyTo) {
|
||||
this.$nextTick(() => {
|
||||
const pos = this.isSignatureEnabledForInbox ? 'start' : 'end';
|
||||
this.messageEditor?.focusEditorInputField(pos);
|
||||
});
|
||||
}
|
||||
},
|
||||
resetReplyToMessage() {
|
||||
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
|
||||
@@ -1219,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();
|
||||
@@ -1246,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"
|
||||
/>
|
||||
@@ -1277,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"
|
||||
/>
|
||||
@@ -1301,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"
|
||||
@@ -1312,6 +1302,7 @@ export default {
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else-if="!showAudioRecorderEditor"
|
||||
ref="messageEditor"
|
||||
v-model="message"
|
||||
:conversation-id="conversationId"
|
||||
:editor-id="editorStateId"
|
||||
@@ -1416,7 +1407,6 @@ export default {
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@toggle-quoted-reply="toggleQuotedReply"
|
||||
/>
|
||||
@@ -1464,7 +1454,7 @@ export default {
|
||||
}
|
||||
|
||||
.reply-box__top {
|
||||
@apply relative py-0 px-4 -mt-px;
|
||||
@apply relative py-0 px-3 -mt-px;
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
|
||||
@@ -14,7 +14,7 @@ const emit = defineEmits(['dismiss']);
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
|
||||
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5"
|
||||
>
|
||||
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
|
||||
<div class="flex-grow gap-1 mt-px text-xs truncate">
|
||||
@@ -32,6 +32,7 @@ const emit = defineEmits(['dismiss']);
|
||||
xs
|
||||
slate
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
@click.stop="emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
+2
@@ -100,6 +100,8 @@ export default {
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomTimeSnoozeModal = true;
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
this.updateConversations('snoozed', snoozeType);
|
||||
} else {
|
||||
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
agents,
|
||||
teams,
|
||||
labels,
|
||||
booleanFilterOptions,
|
||||
statusFilterOptions,
|
||||
messageTypeOptions,
|
||||
priorityOptions,
|
||||
@@ -38,7 +39,7 @@ describe('useAutomation', () => {
|
||||
});
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
'agents/getAgents': agents,
|
||||
'agents/getVerifiedAgents': agents,
|
||||
'campaigns/getAllCampaigns': campaigns,
|
||||
'contacts/getContacts': contacts,
|
||||
'inboxes/getInboxes': inboxes,
|
||||
@@ -73,6 +74,8 @@ describe('useAutomation', () => {
|
||||
return countries;
|
||||
case 'message_type':
|
||||
return messageTypeOptions;
|
||||
case 'private_note':
|
||||
return booleanFilterOptions;
|
||||
case 'priority':
|
||||
return priorityOptions;
|
||||
default:
|
||||
@@ -89,7 +92,9 @@ describe('useAutomation', () => {
|
||||
case 'assign_team':
|
||||
return teams;
|
||||
case 'assign_agent':
|
||||
return agents;
|
||||
return options.addNoneToListFn
|
||||
? options.addNoneToListFn(options.agents)
|
||||
: options.agents;
|
||||
case 'send_email_to_team':
|
||||
return teams;
|
||||
case 'send_message':
|
||||
@@ -226,6 +231,9 @@ describe('useAutomation', () => {
|
||||
expect(getConditionDropdownValues('message_type')).toEqual(
|
||||
messageTypeOptions
|
||||
);
|
||||
expect(getConditionDropdownValues('private_note')).toEqual(
|
||||
booleanFilterOptions
|
||||
);
|
||||
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
|
||||
});
|
||||
|
||||
@@ -234,7 +242,11 @@ describe('useAutomation', () => {
|
||||
|
||||
expect(getActionDropdownValues('add_label')).toEqual(labels);
|
||||
expect(getActionDropdownValues('assign_team')).toEqual(teams);
|
||||
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
|
||||
expect(getActionDropdownValues('assign_agent')).toEqual([
|
||||
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
|
||||
{ id: 'last_responding_agent', name: 'AUTOMATION.LAST_RESPONDING_AGENT' },
|
||||
...agents,
|
||||
]);
|
||||
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
|
||||
expect(getActionDropdownValues('send_message')).toEqual([]);
|
||||
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { ref } from 'vue';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
|
||||
// Mock @vueuse/core — return reactive refs we can control per test
|
||||
const mockBounding = () => ({
|
||||
top: ref(0),
|
||||
bottom: ref(0),
|
||||
left: ref(0),
|
||||
right: ref(0),
|
||||
width: ref(0),
|
||||
height: ref(0),
|
||||
update: vi.fn(),
|
||||
});
|
||||
|
||||
const triggerBounds = mockBounding();
|
||||
const dropdownBounds = mockBounding();
|
||||
const winWidth = ref(1024);
|
||||
const winHeight = ref(768);
|
||||
|
||||
vi.mock('@vueuse/core', () => {
|
||||
let callCount = 0;
|
||||
return {
|
||||
// First call = trigger, second = dropdown, third = container (if any)
|
||||
useElementBounding: () => {
|
||||
callCount += 1;
|
||||
return callCount % 3 === 1 ? triggerBounds : dropdownBounds;
|
||||
},
|
||||
useWindowSize: () => ({ width: winWidth, height: winHeight }),
|
||||
};
|
||||
});
|
||||
|
||||
const setTrigger = ({ top, bottom, left, right }) => {
|
||||
triggerBounds.top.value = top;
|
||||
triggerBounds.bottom.value = bottom;
|
||||
triggerBounds.left.value = left ?? 100;
|
||||
triggerBounds.right.value = right ?? 200;
|
||||
};
|
||||
|
||||
const setDropdown = ({ width, height }) => {
|
||||
dropdownBounds.width.value = width ?? 200;
|
||||
dropdownBounds.height.value = height;
|
||||
};
|
||||
|
||||
describe('useDropdownPosition', () => {
|
||||
beforeEach(() => {
|
||||
winWidth.value = 1024;
|
||||
winHeight.value = 768;
|
||||
document.body.innerHTML = '<div id="app" dir="ltr"></div>';
|
||||
});
|
||||
|
||||
describe('verticalClass (relative mode)', () => {
|
||||
it('places below when enough space', () => {
|
||||
// Trigger at y=100, dropdown 200px tall
|
||||
// Space below = 768 - 140 = 628 → fits (628 > 216)
|
||||
setTrigger({ top: 100, bottom: 140 });
|
||||
setDropdown({ height: 200 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('top-full mt-2');
|
||||
});
|
||||
|
||||
it('places above when not enough space below but enough above', () => {
|
||||
// Trigger near bottom at y=600
|
||||
// Space below = 128 → doesn't fit. Space above = 600 → fits
|
||||
setTrigger({ top: 600, bottom: 640 });
|
||||
setDropdown({ height: 200 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('bottom-full mb-2');
|
||||
});
|
||||
|
||||
it('picks the side with more space when dropdown fits neither', () => {
|
||||
// Dropdown 500px tall, won't fit above (300) or below (428)
|
||||
// Below has more room → stays below
|
||||
setTrigger({ top: 300, bottom: 340 });
|
||||
setDropdown({ height: 500 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('top-full mt-2');
|
||||
});
|
||||
|
||||
it('picks above when above has more space and neither fits', () => {
|
||||
// Dropdown 600px tall, won't fit above (500) or below (228)
|
||||
// Above has more room → flips above
|
||||
setTrigger({ top: 500, bottom: 540 });
|
||||
setDropdown({ height: 600 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('bottom-full mb-2');
|
||||
});
|
||||
|
||||
it('returns default when disabled', () => {
|
||||
setTrigger({ top: 700, bottom: 740 });
|
||||
setDropdown({ height: 200 });
|
||||
|
||||
const { position } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(false)
|
||||
);
|
||||
expect(position.value.class).toBe('top-full mt-2');
|
||||
expect(position.value.style).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixedPosition', () => {
|
||||
it('places below with correct top and maxHeight', () => {
|
||||
// Trigger at y=140, space below = 628
|
||||
// top = 140 + 8(gap) = 148
|
||||
// maxHeight = 628 - 8(gap) - 16(margin) = 604
|
||||
setTrigger({ top: 100, bottom: 140 });
|
||||
setDropdown({ height: 200, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.top).toBe('148px');
|
||||
expect(fixedPosition.value.style.bottom).toBeUndefined();
|
||||
expect(fixedPosition.value.style.maxHeight).toBe('604px');
|
||||
});
|
||||
|
||||
it('flips above with correct bottom and maxHeight', () => {
|
||||
// Trigger near bottom at y=650, space below = 78 → doesn't fit
|
||||
// Flips above: bottom = 768 - 650 + 8 = 126
|
||||
// maxHeight = 650 - 8 - 16 = 626
|
||||
setTrigger({ top: 650, bottom: 690 });
|
||||
setDropdown({ height: 200, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.bottom).toBe('126px');
|
||||
expect(fixedPosition.value.style.top).toBeUndefined();
|
||||
expect(fixedPosition.value.style.maxHeight).toBe('626px');
|
||||
});
|
||||
|
||||
it('constrains maxHeight to available space on short viewports', () => {
|
||||
// Short viewport (400px), trigger in the middle, dropdown 500px tall
|
||||
// Neither side fits → above (200) > below (160) → places above
|
||||
// maxHeight capped to 200 - 8 - 16 = 176
|
||||
winHeight.value = 400;
|
||||
setTrigger({ top: 200, bottom: 240 });
|
||||
setDropdown({ height: 500, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.bottom).toBeDefined();
|
||||
expect(fixedPosition.value.style.maxHeight).toBe('176px');
|
||||
});
|
||||
|
||||
it('returns defaults when disabled', () => {
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(false)
|
||||
);
|
||||
expect(fixedPosition.value.class).toBe('fixed z-[9999]');
|
||||
expect(fixedPosition.value.style).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('horizontal positioning (fixedPosition)', () => {
|
||||
it('anchors to the right edge by default (align=end, LTR)', () => {
|
||||
// align=end + LTR → anchorLeft=false → uses style.right
|
||||
// right = 1024 - 900 = 124
|
||||
setTrigger({ top: 100, bottom: 140, left: 800, right: 900 });
|
||||
setDropdown({ height: 100, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.right).toBe('124px');
|
||||
});
|
||||
|
||||
it('anchors to the left edge when align=start (LTR)', () => {
|
||||
// align=start + LTR → anchorLeft=true → uses style.left
|
||||
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
|
||||
setDropdown({ height: 100, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true),
|
||||
{ align: 'start' }
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.left).toBe('100px');
|
||||
});
|
||||
|
||||
it('shifts left when dropdown overflows right edge', () => {
|
||||
// Trigger at x=900, dropdown 300px wide → 900+300=1200 > 1024
|
||||
// Falls back to right: 16px (margin)
|
||||
setTrigger({ top: 100, bottom: 140, left: 900, right: 1000 });
|
||||
setDropdown({ height: 100, width: 300 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true),
|
||||
{ align: 'start' }
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.right).toBe('16px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RTL', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app" dir="rtl"></div>';
|
||||
});
|
||||
|
||||
it('flips anchor direction in RTL (align=end anchors left)', () => {
|
||||
// align=end + RTL → anchorLeft=true → uses style.left
|
||||
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
|
||||
setDropdown({ height: 100, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.left).toBe('100px');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEditableAutomation } from '../useEditableAutomation';
|
||||
import useAutomationValues from '../useAutomationValues';
|
||||
|
||||
vi.mock('../useAutomationValues');
|
||||
|
||||
describe('useEditableAutomation', () => {
|
||||
beforeEach(() => {
|
||||
useAutomationValues.mockReturnValue({
|
||||
getConditionDropdownValues: vi.fn(attributeKey => {
|
||||
if (attributeKey === 'private_note') {
|
||||
return [
|
||||
{ id: true, name: 'True' },
|
||||
{ id: false, name: 'False' },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}),
|
||||
getActionDropdownValues: vi.fn(actionName => {
|
||||
if (actionName === 'assign_agent') {
|
||||
return [
|
||||
{ id: 'nil', name: 'None' },
|
||||
{
|
||||
id: 'last_responding_agent',
|
||||
name: 'Last Responding Agent',
|
||||
},
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rehydrates boolean conditions as a single selected option', () => {
|
||||
const automation = {
|
||||
event_name: 'message_created',
|
||||
conditions: [
|
||||
{
|
||||
attribute_key: 'private_note',
|
||||
filter_operator: 'equal_to',
|
||||
values: [false],
|
||||
query_operator: null,
|
||||
},
|
||||
],
|
||||
actions: [],
|
||||
};
|
||||
const automationTypes = {
|
||||
message_created: {
|
||||
conditions: [{ key: 'private_note', inputType: 'search_select' }],
|
||||
},
|
||||
};
|
||||
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
const result = formatAutomation(automation, [], automationTypes, []);
|
||||
|
||||
expect(result.conditions).toEqual([
|
||||
{
|
||||
attribute_key: 'private_note',
|
||||
filter_operator: 'equal_to',
|
||||
values: { id: false, name: 'False' },
|
||||
query_operator: 'and',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rehydrates last responding agent as a selected action option', () => {
|
||||
const automation = {
|
||||
event_name: 'conversation_created',
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action_name: 'assign_agent',
|
||||
action_params: ['last_responding_agent'],
|
||||
},
|
||||
],
|
||||
};
|
||||
const automationActionTypes = [
|
||||
{ key: 'assign_agent', inputType: 'search_select' },
|
||||
];
|
||||
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
const result = formatAutomation(automation, [], {}, automationActionTypes);
|
||||
|
||||
expect(result.actions).toEqual([
|
||||
{
|
||||
action_name: 'assign_agent',
|
||||
action_params: [
|
||||
{
|
||||
id: 'last_responding_agent',
|
||||
name: 'Last Responding Agent',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: mockLabels },
|
||||
'teams/getTeams': { value: mockTeams },
|
||||
'agents/getAgents': { value: mockAgents },
|
||||
'agents/getVerifiedAgents': { value: mockAgents },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,24 +119,30 @@ describe('useMacros', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
|
||||
expect(getMacroDropdownValues('assign_team')).toHaveLength(
|
||||
mockTeams.length
|
||||
);
|
||||
mockTeams.length + 1
|
||||
); // +1 for "None"
|
||||
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
|
||||
mockAgents.length + 1
|
||||
); // +1 for "Self"
|
||||
mockAgents.length + 2
|
||||
); // +2 for "None" and "Self"
|
||||
});
|
||||
|
||||
it('returns teams for assign_team and send_email_to_team types', () => {
|
||||
it('returns teams with "None" option for assign_team and teams only for send_email_to_team', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
|
||||
const assignTeamResult = getMacroDropdownValues('assign_team');
|
||||
expect(assignTeamResult[0]).toEqual({
|
||||
id: 'nil',
|
||||
name: 'AUTOMATION.NONE_OPTION',
|
||||
});
|
||||
expect(assignTeamResult.slice(1)).toEqual(mockTeams);
|
||||
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
|
||||
});
|
||||
|
||||
it('returns agents with "Self" option for assign_agent type', () => {
|
||||
it('returns agents with "None" and "Self" options for assign_agent type', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
const result = getMacroDropdownValues('assign_agent');
|
||||
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
|
||||
expect(result.slice(1)).toEqual(mockAgents);
|
||||
expect(result[0]).toEqual({ id: 'nil', name: 'AUTOMATION.NONE_OPTION' });
|
||||
expect(result[1]).toEqual({ id: 'self', name: 'Self' });
|
||||
expect(result.slice(2)).toEqual(mockAgents);
|
||||
});
|
||||
|
||||
it('returns formatted labels for add_label and remove_label types', () => {
|
||||
@@ -167,13 +173,16 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: [] },
|
||||
'teams/getTeams': { value: [] },
|
||||
'agents/getAgents': { value: [] },
|
||||
'agents/getVerifiedAgents': { value: [] },
|
||||
});
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('add_label')).toEqual([]);
|
||||
expect(getMacroDropdownValues('assign_team')).toEqual([]);
|
||||
expect(getMacroDropdownValues('assign_team')).toEqual([
|
||||
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
|
||||
]);
|
||||
expect(getMacroDropdownValues('assign_agent')).toEqual([
|
||||
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
|
||||
{ id: 'self', name: 'Self' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
export default function useAutomationValues() {
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const agents = useMapGetter('agents/getVerifiedAgents');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const contacts = useMapGetter('contacts/getContacts');
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
@@ -121,8 +121,19 @@ export default function useAutomationValues() {
|
||||
* @returns {Array} An array of action dropdown values.
|
||||
*/
|
||||
const getActionDropdownValues = type => {
|
||||
let agentsList = agents.value;
|
||||
if (type === 'assign_agent') {
|
||||
agentsList = [
|
||||
{
|
||||
id: 'last_responding_agent',
|
||||
name: t('AUTOMATION.LAST_RESPONDING_AGENT'),
|
||||
},
|
||||
...agentsList,
|
||||
];
|
||||
}
|
||||
|
||||
return getActionOptions({
|
||||
agents: agents.value,
|
||||
agents: agentsList,
|
||||
labels: labels.value,
|
||||
teams: teams.value,
|
||||
slaPolicies: slaPolicies.value,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { computed, unref, watch } from 'vue';
|
||||
import { useElementBounding, useWindowSize } from '@vueuse/core';
|
||||
|
||||
const FALLBACK_SIZE = 200;
|
||||
const SAFE_MARGIN = 16;
|
||||
const GAP = 8;
|
||||
|
||||
/**
|
||||
* Auto-position a floating element based on available viewport space.
|
||||
*
|
||||
* @param {Ref} triggerRef - Trigger element ref
|
||||
* @param {Ref} dropdownRef - Dropdown/popover element ref
|
||||
* @param {Ref} enabled - Whether to calculate position
|
||||
* @param {Object} options
|
||||
* @param {Ref} [options.container] - Constraining container ref
|
||||
* @param {number} [options.margin=16] - Min distance from viewport/container edges
|
||||
* @param {string} [options.align='end'] - 'start' or 'end' (flips automatically for RTL)
|
||||
*/
|
||||
export function useDropdownPosition(
|
||||
triggerRef,
|
||||
dropdownRef,
|
||||
enabled,
|
||||
{ container = null, margin = SAFE_MARGIN, align = 'end' } = {}
|
||||
) {
|
||||
const trigger = useElementBounding(triggerRef);
|
||||
const dropdown = useElementBounding(dropdownRef);
|
||||
const bounds = useElementBounding(container);
|
||||
const { width: winWidth, height: winHeight } = useWindowSize();
|
||||
|
||||
const isRTL = computed(
|
||||
() => document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl'
|
||||
);
|
||||
|
||||
// Whether to anchor to the left edge of the trigger
|
||||
const anchorLeft = computed(() => (align === 'start') !== isRTL.value);
|
||||
|
||||
const verticalClass = computed(() => {
|
||||
if (!unref(enabled)) return 'top-full mt-2';
|
||||
const dh = dropdown.height.value || FALLBACK_SIZE;
|
||||
const spaceBelow = winHeight.value - trigger.bottom.value;
|
||||
const spaceAbove = trigger.top.value;
|
||||
// Only flip above if it fits there; otherwise stay below (more room or equal)
|
||||
if (spaceBelow >= dh + margin) return 'top-full mt-2';
|
||||
if (spaceAbove >= dh + margin) return 'bottom-full mb-2';
|
||||
return spaceBelow >= spaceAbove ? 'top-full mt-2' : 'bottom-full mb-2';
|
||||
});
|
||||
|
||||
// Relative mode: Tailwind class + style for absolute-in-parent dropdowns
|
||||
const position = computed(() => {
|
||||
if (!unref(enabled)) return { class: 'top-full mt-2', style: {} };
|
||||
|
||||
const dw = dropdown.width.value || FALLBACK_SIZE;
|
||||
const leftBound = container ? bounds.left.value : 0;
|
||||
const rightBound = container ? bounds.right.value : winWidth.value;
|
||||
const style = {};
|
||||
|
||||
if (anchorLeft.value) {
|
||||
const available = rightBound - trigger.left.value;
|
||||
const overflow = dw - available;
|
||||
style.left = overflow > 0 ? `-${overflow}px` : '0px';
|
||||
} else {
|
||||
const available = trigger.right.value - leftBound;
|
||||
const overflow = dw - available;
|
||||
style.right = overflow > 0 ? `-${overflow}px` : '0px';
|
||||
}
|
||||
|
||||
return { class: verticalClass.value, style };
|
||||
});
|
||||
|
||||
// Fixed mode: styles for teleported popovers
|
||||
const fixedPosition = computed(() => {
|
||||
if (!unref(enabled)) return { class: 'fixed z-[9999]', style: {} };
|
||||
|
||||
const dh = dropdown.height.value || FALLBACK_SIZE;
|
||||
const dw = dropdown.width.value || FALLBACK_SIZE;
|
||||
const spaceBelow = winHeight.value - trigger.bottom.value;
|
||||
const style = {};
|
||||
|
||||
// Vertical: prefer below, flip above only if it fits, else pick the larger side
|
||||
const spaceAbove = trigger.top.value;
|
||||
const placeAbove =
|
||||
spaceBelow < dh + margin &&
|
||||
(spaceAbove >= dh + margin || spaceAbove > spaceBelow);
|
||||
|
||||
if (placeAbove) {
|
||||
style.bottom = `${winHeight.value - trigger.top.value + GAP}px`;
|
||||
style.maxHeight = `${spaceAbove - GAP - margin}px`;
|
||||
} else {
|
||||
style.top = `${trigger.bottom.value + GAP}px`;
|
||||
style.maxHeight = `${spaceBelow - GAP - margin}px`;
|
||||
}
|
||||
|
||||
// Horizontal
|
||||
if (anchorLeft.value) {
|
||||
const left = trigger.left.value;
|
||||
if (left + dw > winWidth.value - margin) {
|
||||
style.right = `${margin}px`;
|
||||
} else {
|
||||
style.left = `${Math.max(margin, left)}px`;
|
||||
}
|
||||
} else {
|
||||
const right = winWidth.value - trigger.right.value;
|
||||
if (trigger.right.value - dw < margin) {
|
||||
style.left = `${margin}px`;
|
||||
} else {
|
||||
style.right = `${right}px`;
|
||||
}
|
||||
}
|
||||
|
||||
return { class: 'fixed z-[9999]', style };
|
||||
});
|
||||
|
||||
const updatePosition = () => {
|
||||
trigger.update();
|
||||
dropdown.update();
|
||||
if (container) bounds.update();
|
||||
};
|
||||
|
||||
// Update position when dropdown opens to ensure RTL state is current
|
||||
watch(
|
||||
() => unref(enabled),
|
||||
isEnabled => {
|
||||
if (isEnabled) updatePosition();
|
||||
}
|
||||
);
|
||||
|
||||
return { position, fixedPosition, updatePosition };
|
||||
}
|
||||
@@ -46,11 +46,26 @@ export function useEditableAutomation() {
|
||||
if (inputType === 'comma_separated_plain_text') {
|
||||
return { ...condition, values: condition.values.join(',') };
|
||||
}
|
||||
const dropdownValues = getConditionDropdownValues(
|
||||
condition.attribute_key
|
||||
);
|
||||
const hasBooleanOptions =
|
||||
inputType === 'search_select' &&
|
||||
dropdownValues.length &&
|
||||
dropdownValues.every(item => typeof item.id === 'boolean');
|
||||
|
||||
if (hasBooleanOptions) {
|
||||
return {
|
||||
...condition,
|
||||
query_operator: condition.query_operator || 'and',
|
||||
values: dropdownValues.find(item => item.id === condition.values[0]),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...condition,
|
||||
query_operator: condition.query_operator || 'and',
|
||||
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
|
||||
item => [...condition.values].includes(item.id)
|
||||
values: [...dropdownValues].filter(item =>
|
||||
[...condition.values].includes(item.id)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -12,11 +12,14 @@ import { useKeyboardEvents } from './useKeyboardEvents';
|
||||
|
||||
/**
|
||||
* Wrap the action in a function that calls the action and prevents the default event behavior.
|
||||
* Only prevents default when items are available to navigate.
|
||||
* @param {Function} action - The action to be called.
|
||||
* @param {import('vue').Ref<Array>} items - A ref to the array of selectable items.
|
||||
* @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input.
|
||||
*/
|
||||
const createAction = action => ({
|
||||
const createAction = (action, items) => ({
|
||||
action: e => {
|
||||
if (!items.value?.length) return;
|
||||
action();
|
||||
e.preventDefault();
|
||||
},
|
||||
@@ -38,15 +41,14 @@ const createKeyboardEvents = (
|
||||
items
|
||||
) => {
|
||||
const events = {
|
||||
ArrowUp: createAction(moveSelectionUp),
|
||||
'Control+KeyP': createAction(moveSelectionUp),
|
||||
ArrowDown: createAction(moveSelectionDown),
|
||||
'Control+KeyN': createAction(moveSelectionDown),
|
||||
ArrowUp: createAction(moveSelectionUp, items),
|
||||
'Control+KeyP': createAction(moveSelectionUp, items),
|
||||
ArrowDown: createAction(moveSelectionDown, items),
|
||||
'Control+KeyN': createAction(moveSelectionDown, items),
|
||||
};
|
||||
|
||||
// Adds an event handler for the Enter key if the onSelect function is provided.
|
||||
if (typeof onSelect === 'function') {
|
||||
events.Enter = createAction(() => items.value?.length > 0 && onSelect());
|
||||
events.Enter = createAction(onSelect, items);
|
||||
}
|
||||
|
||||
return events;
|
||||
|
||||
@@ -13,7 +13,12 @@ export const useMacros = () => {
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
const teams = computed(() => getters['teams/getTeams'].value);
|
||||
const agents = computed(() => getters['agents/getAgents'].value);
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
|
||||
const withNoneOption = options => [
|
||||
{ id: 'nil', name: t('AUTOMATION.NONE_OPTION') },
|
||||
...(options || []),
|
||||
];
|
||||
|
||||
/**
|
||||
* Get dropdown values based on the specified type
|
||||
@@ -23,10 +28,15 @@ export const useMacros = () => {
|
||||
const getMacroDropdownValues = type => {
|
||||
switch (type) {
|
||||
case 'assign_team':
|
||||
return withNoneOption(teams.value);
|
||||
case 'send_email_to_team':
|
||||
return teams.value;
|
||||
case 'assign_agent':
|
||||
return [{ id: 'self', name: 'Self' }, ...agents.value];
|
||||
return [
|
||||
...withNoneOption(),
|
||||
{ id: 'self', name: 'Self' },
|
||||
...agents.value,
|
||||
];
|
||||
case 'add_label':
|
||||
case 'remove_label':
|
||||
return labels.value.map(i => ({
|
||||
|
||||
@@ -172,6 +172,7 @@ export const FORMATTING = {
|
||||
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'strike',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
@@ -182,6 +183,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'h3',
|
||||
'imageUpload',
|
||||
'code',
|
||||
'insertTable',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,11 @@ export default {
|
||||
SNOOZED: 'snoozed',
|
||||
ALL: 'all',
|
||||
},
|
||||
CONVERSATION_TYPE: {
|
||||
MENTION: 'mention',
|
||||
PARTICIPATING: 'participating',
|
||||
UNATTENDED: 'unattended',
|
||||
},
|
||||
SORT_BY_TYPE: {
|
||||
LAST_ACTIVITY_AT_ASC: 'last_activity_at_asc',
|
||||
LAST_ACTIVITY_AT_DESC: 'last_activity_at_desc',
|
||||
@@ -40,6 +45,7 @@ export default {
|
||||
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
|
||||
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
|
||||
SMALL_SCREEN_BREAKPOINT: 768,
|
||||
LARGE_SCREEN_BREAKPOINT: 1024,
|
||||
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
|
||||
SNOOZE_OPTIONS: {
|
||||
UNTIL_NEXT_REPLY: 'until_next_reply',
|
||||
|
||||
@@ -37,6 +37,7 @@ export const FEATURE_FLAGS = {
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CAPTAIN_TASKS: 'captain_tasks',
|
||||
SAML: 'saml',
|
||||
@@ -49,6 +50,7 @@ export const FEATURE_FLAGS = {
|
||||
export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.SLA,
|
||||
FEATURE_FLAGS.CAPTAIN,
|
||||
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
|
||||
FEATURE_FLAGS.CUSTOM_ROLES,
|
||||
FEATURE_FLAGS.AUDIT_LOGS,
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
|
||||
@@ -119,6 +119,10 @@ export const COPILOT_EVENTS = Object.freeze({
|
||||
USE_CAPTAIN_RESPONSE: 'Copilot: Used captain response',
|
||||
});
|
||||
|
||||
export const SNOOZE_EVENTS = Object.freeze({
|
||||
NLP_SNOOZE_APPLIED: 'Applied snooze via text-to-date input',
|
||||
});
|
||||
|
||||
export const GENERAL_EVENTS = Object.freeze({
|
||||
COMMAND_BAR: 'Used commandbar',
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ export const conversationListPageURL = ({
|
||||
} else if (conversationType) {
|
||||
const urlMap = {
|
||||
mention: 'mentions/conversations',
|
||||
participating: 'participating/conversations',
|
||||
unattended: 'unattended/conversations',
|
||||
};
|
||||
url = `accounts/${accountId}/${urlMap[conversationType]}`;
|
||||
|
||||
@@ -150,6 +150,7 @@ export const getConditionOptions = ({
|
||||
conversation_language: languages,
|
||||
country_code: countries,
|
||||
message_type: messageTypeOptions,
|
||||
private_note: booleanFilterOptions,
|
||||
priority: priorityOptions,
|
||||
labels: generateConditionOptions(labels, 'title'),
|
||||
};
|
||||
|
||||
@@ -32,6 +32,25 @@ export function extractTextFromMarkdown(markdown) {
|
||||
.trim(); // Trim any extra space
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes inline base64 markdown images from signature content.
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {{ sanitizedContent: string, hasInlineImages: boolean }}
|
||||
*/
|
||||
export function stripInlineBase64Images(content) {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { sanitizedContent: content || '', hasInlineImages: false };
|
||||
}
|
||||
|
||||
const markdownInlineBase64ImageRegex =
|
||||
/!\[[^\]]*]\(\s*data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\s*\)/gi;
|
||||
const sanitizedContent = content.replace(markdownInlineBase64ImageRegex, '');
|
||||
const hasInlineImages = sanitizedContent !== content;
|
||||
|
||||
return { sanitizedContent, hasInlineImages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unsupported markdown formatting based on channel capabilities.
|
||||
* Uses MARKDOWN_PATTERNS from editor constants.
|
||||
|
||||
@@ -133,20 +133,55 @@ export const ARTICLE_TABS_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const LOCALE_MENU_ITEMS = [
|
||||
{
|
||||
export const LOCALE_MENU_ITEMS = {
|
||||
makeDefault: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT',
|
||||
action: 'change-default',
|
||||
value: 'default',
|
||||
icon: 'i-lucide-star',
|
||||
},
|
||||
{
|
||||
moveToDraft: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT',
|
||||
action: 'move-to-draft',
|
||||
value: 'draft',
|
||||
icon: 'i-lucide-eye-off',
|
||||
},
|
||||
publishLocale: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE',
|
||||
action: 'publish-locale',
|
||||
value: 'publish',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
value: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const disableLocaleMenuItems = menuItems =>
|
||||
menuItems.map(item => ({ ...item, disabled: true }));
|
||||
|
||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
if (isDefault) {
|
||||
return disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||
}
|
||||
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
export const ARTICLE_EDITOR_STATUS_OPTIONS = {
|
||||
published: ['archive', 'draft'],
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* snoozeDateParser — Natural language date/time parser for snooze.
|
||||
*
|
||||
* Barrel re-export from submodules:
|
||||
* - parser.js: core parsing engine (parseDateFromText)
|
||||
* - localization.js: multilingual suggestion generator (generateDateSuggestions)
|
||||
* - suggestions.js: compositional suggestion engine
|
||||
* - tokenMaps.js: shared token maps and utility functions
|
||||
*/
|
||||
|
||||
export { parseDateFromText } from './parser';
|
||||
export { generateDateSuggestions } from './localization';
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Handles non-English input and generates the final suggestion list.
|
||||
* Translates localized words to English before parsing, then converts
|
||||
* suggestion labels back to the user's language for display.
|
||||
*/
|
||||
|
||||
import {
|
||||
WEEKDAY_MAP,
|
||||
MONTH_MAP,
|
||||
UNIT_MAP,
|
||||
WORD_NUMBER_MAP,
|
||||
RELATIVE_DAY_MAP,
|
||||
TIME_OF_DAY_MAP,
|
||||
sanitize,
|
||||
stripNoise,
|
||||
normalizeDigits,
|
||||
} from './tokenMaps';
|
||||
|
||||
import { parseDateFromText } from './parser';
|
||||
import { buildSuggestionCandidates, MAX_SUGGESTIONS } from './suggestions';
|
||||
|
||||
// ─── English Reference Data ─────────────────────────────────────────────────
|
||||
|
||||
const EN_WEEKDAYS_LIST = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
];
|
||||
|
||||
const EN_MONTHS_LIST = [
|
||||
'january',
|
||||
'february',
|
||||
'march',
|
||||
'april',
|
||||
'may',
|
||||
'june',
|
||||
'july',
|
||||
'august',
|
||||
'september',
|
||||
'october',
|
||||
'november',
|
||||
'december',
|
||||
];
|
||||
|
||||
const EN_DEFAULTS = {
|
||||
UNITS: {
|
||||
MINUTE: 'minute',
|
||||
MINUTES: 'minutes',
|
||||
HOUR: 'hour',
|
||||
HOURS: 'hours',
|
||||
DAY: 'day',
|
||||
DAYS: 'days',
|
||||
WEEK: 'week',
|
||||
WEEKS: 'weeks',
|
||||
MONTH: 'month',
|
||||
MONTHS: 'months',
|
||||
YEAR: 'year',
|
||||
YEARS: 'years',
|
||||
},
|
||||
RELATIVE: {
|
||||
TOMORROW: 'tomorrow',
|
||||
DAY_AFTER_TOMORROW: 'day after tomorrow',
|
||||
NEXT_WEEK: 'next week',
|
||||
NEXT_MONTH: 'next month',
|
||||
THIS_WEEKEND: 'this weekend',
|
||||
NEXT_WEEKEND: 'next weekend',
|
||||
},
|
||||
TIME_OF_DAY: {
|
||||
MORNING: 'morning',
|
||||
AFTERNOON: 'afternoon',
|
||||
EVENING: 'evening',
|
||||
NIGHT: 'night',
|
||||
NOON: 'noon',
|
||||
MIDNIGHT: 'midnight',
|
||||
},
|
||||
WORD_NUMBERS: {
|
||||
ONE: 'one',
|
||||
TWO: 'two',
|
||||
THREE: 'three',
|
||||
FOUR: 'four',
|
||||
FIVE: 'five',
|
||||
SIX: 'six',
|
||||
SEVEN: 'seven',
|
||||
EIGHT: 'eight',
|
||||
NINE: 'nine',
|
||||
TEN: 'ten',
|
||||
TWELVE: 'twelve',
|
||||
FIFTEEN: 'fifteen',
|
||||
TWENTY: 'twenty',
|
||||
THIRTY: 'thirty',
|
||||
},
|
||||
ORDINALS: {
|
||||
FIRST: 'first',
|
||||
SECOND: 'second',
|
||||
THIRD: 'third',
|
||||
FOURTH: 'fourth',
|
||||
FIFTH: 'fifth',
|
||||
},
|
||||
MERIDIEM: { AM: 'am', PM: 'pm' },
|
||||
HALF: 'half',
|
||||
NEXT: 'next',
|
||||
THIS: 'this',
|
||||
AT: 'at',
|
||||
IN: 'in',
|
||||
OF: 'of',
|
||||
AFTER: 'after',
|
||||
WEEK: 'week',
|
||||
DAY: 'day',
|
||||
FROM_NOW: 'from now',
|
||||
NEXT_YEAR: 'next year',
|
||||
};
|
||||
|
||||
const STRUCTURAL_WORDS = [
|
||||
'at',
|
||||
'in',
|
||||
'next',
|
||||
'this',
|
||||
'from',
|
||||
'now',
|
||||
'after',
|
||||
'half',
|
||||
'same',
|
||||
'time',
|
||||
'weekend',
|
||||
'end',
|
||||
'of',
|
||||
'the',
|
||||
'eod',
|
||||
'am',
|
||||
'pm',
|
||||
'week',
|
||||
'day',
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
'fourth',
|
||||
'fifth',
|
||||
];
|
||||
|
||||
const ENGLISH_VOCAB = new Set([
|
||||
...Object.keys(WEEKDAY_MAP),
|
||||
...Object.keys(MONTH_MAP),
|
||||
...Object.keys(UNIT_MAP),
|
||||
...Object.keys(WORD_NUMBER_MAP),
|
||||
...Object.keys(RELATIVE_DAY_MAP),
|
||||
...Object.keys(TIME_OF_DAY_MAP),
|
||||
...EN_WEEKDAYS_LIST,
|
||||
...EN_MONTHS_LIST,
|
||||
...STRUCTURAL_WORDS,
|
||||
]);
|
||||
|
||||
// ─── Regex for token replacement ────────────────────────────────────────────
|
||||
|
||||
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
|
||||
const MONTH_NAME_RE = new RegExp(`\\b(?:${MONTH_NAMES})\\b`, 'i');
|
||||
const NUM_TOD_RE =
|
||||
/\b(\d{1,2}(?::\d{2})?)\s+(morning|noon|afternoon|evening|night)\b/g;
|
||||
const TOD_TO_MERIDIEM = {
|
||||
morning: 'am',
|
||||
noon: 'pm',
|
||||
afternoon: 'pm',
|
||||
evening: 'pm',
|
||||
night: 'pm',
|
||||
};
|
||||
const CJK_CHAR_RE =
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
||||
|
||||
// ─── Translation Cache ──────────────────────────────────────────────────────
|
||||
|
||||
const safeString = v => (v == null ? '' : String(v));
|
||||
const MAX_PAIRS_CACHE = 20;
|
||||
const pairsCache = new Map();
|
||||
const CACHE_SECTIONS = [
|
||||
'UNITS',
|
||||
'RELATIVE',
|
||||
'TIME_OF_DAY',
|
||||
'WORD_NUMBERS',
|
||||
'ORDINALS',
|
||||
'MERIDIEM',
|
||||
];
|
||||
const SINGLE_KEYS = [
|
||||
'HALF',
|
||||
'NEXT',
|
||||
'THIS',
|
||||
'AT',
|
||||
'IN',
|
||||
'OF',
|
||||
'AFTER',
|
||||
'WEEK',
|
||||
'DAY',
|
||||
'FROM_NOW',
|
||||
'NEXT_YEAR',
|
||||
];
|
||||
|
||||
/** Create a string key from translations so we can cache results. */
|
||||
const translationSignature = translations => {
|
||||
if (!translations || typeof translations !== 'object') return 'none';
|
||||
return [
|
||||
...CACHE_SECTIONS.flatMap(section => {
|
||||
const values = translations[section] || {};
|
||||
return Object.keys(values)
|
||||
.sort()
|
||||
.map(k => `${section}.${k}:${safeString(values[k]).toLowerCase()}`);
|
||||
}),
|
||||
...SINGLE_KEYS.map(
|
||||
k => `${k}:${safeString(translations[k]).toLowerCase()}`
|
||||
),
|
||||
].join('|');
|
||||
};
|
||||
|
||||
/** Build a list of [localWord, englishWord] pairs from the translations and browser locale. */
|
||||
const buildReplacementPairsUncached = (translations, locale) => {
|
||||
const pairs = [];
|
||||
const seen = new Set();
|
||||
const t = translations || {};
|
||||
|
||||
const addPair = (local, en) => {
|
||||
const l = sanitize(safeString(local));
|
||||
const e = safeString(en).toLowerCase();
|
||||
const key = `${l}\0${e}`;
|
||||
if (l && e && l !== e && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
pairs.push([l, e]);
|
||||
}
|
||||
};
|
||||
|
||||
CACHE_SECTIONS.forEach(section => {
|
||||
const localSection = t[section] || {};
|
||||
const enSection = EN_DEFAULTS[section] || {};
|
||||
Object.keys(enSection).forEach(key => {
|
||||
addPair(localSection[key], enSection[key]);
|
||||
});
|
||||
});
|
||||
|
||||
SINGLE_KEYS.forEach(key => addPair(t[key], EN_DEFAULTS[key]));
|
||||
|
||||
try {
|
||||
const wdFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' });
|
||||
// Jan 1, 2024 is a Monday — aligns with EN_WEEKDAYS_LIST[0]='monday'
|
||||
EN_WEEKDAYS_LIST.forEach((en, i) => {
|
||||
addPair(wdFmt.format(new Date(2024, 0, i + 1)), en);
|
||||
});
|
||||
} catch {
|
||||
/* locale not supported */
|
||||
}
|
||||
|
||||
try {
|
||||
const moFmt = new Intl.DateTimeFormat(locale, { month: 'long' });
|
||||
EN_MONTHS_LIST.forEach((en, i) => {
|
||||
addPair(moFmt.format(new Date(2024, i, 1)), en);
|
||||
});
|
||||
} catch {
|
||||
/* locale not supported */
|
||||
}
|
||||
|
||||
pairs.sort((a, b) => b[0].length - a[0].length);
|
||||
return pairs;
|
||||
};
|
||||
|
||||
/** Same as above but cached. Keeps up to 20 entries to avoid rebuilding every call. */
|
||||
const buildReplacementPairs = (translations, locale) => {
|
||||
const cacheKey = `${locale || ''}:${translationSignature(translations)}`;
|
||||
if (pairsCache.has(cacheKey)) return pairsCache.get(cacheKey);
|
||||
const pairs = buildReplacementPairsUncached(translations, locale);
|
||||
if (pairsCache.size >= MAX_PAIRS_CACHE)
|
||||
pairsCache.delete(pairsCache.keys().next().value);
|
||||
pairsCache.set(cacheKey, pairs);
|
||||
return pairs;
|
||||
};
|
||||
|
||||
// ─── Token Replacement ──────────────────────────────────────────────────────
|
||||
|
||||
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/** Swap localized words for their English versions in the text. */
|
||||
const substituteLocalTokens = (text, pairs) => {
|
||||
let r = text;
|
||||
pairs.forEach(([local, en]) => {
|
||||
if (CJK_CHAR_RE.test(local)) {
|
||||
const re = new RegExp(escapeRegex(local), 'g');
|
||||
r = r.replace(re, ` ${en} `);
|
||||
} else {
|
||||
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
||||
r = r.replace(re, en);
|
||||
}
|
||||
});
|
||||
return r;
|
||||
};
|
||||
|
||||
/** Drop any words the parser wouldn't understand (keeps English words and numbers). */
|
||||
const filterToEnglishVocab = text =>
|
||||
normalizeDigits(text)
|
||||
.replace(/(\d+)h\b/g, '$1:00')
|
||||
.split(/\s+/)
|
||||
.filter(w => /[\d:]/.test(w) || ENGLISH_VOCAB.has(w.toLowerCase()))
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** Move "next year" to the right spot so the parser can read it (after the month, before time). */
|
||||
const repositionNextYear = text => {
|
||||
if (!MONTH_NAME_RE.test(text)) return text;
|
||||
let r = text.replace(/\b(?:next\s+)?year\b/i, m =>
|
||||
/next/i.test(m) ? m : 'next year'
|
||||
);
|
||||
if (!/\bnext\s+year\b/i.test(r)) return r;
|
||||
const withoutNY = r.replace(/\bnext\s+year\b/i, '').trim();
|
||||
const timeRe = /(?:(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s*$/i;
|
||||
const timePart = withoutNY.match(timeRe);
|
||||
if (timePart) {
|
||||
const beforeTime = withoutNY.slice(0, timePart.index).trim();
|
||||
r = `${beforeTime} next year ${timePart[0].trim()}`;
|
||||
} else {
|
||||
r = `${withoutNY} next year`;
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
/** Run the full translation pipeline: swap tokens, filter, fix am/pm, reposition "next year". */
|
||||
const replaceTokens = (text, pairs) => {
|
||||
const substituted = substituteLocalTokens(text, pairs);
|
||||
const filtered = filterToEnglishVocab(substituted);
|
||||
const fixed = filtered.replace(
|
||||
NUM_TOD_RE,
|
||||
(_, t, tod) => `${t}${TOD_TO_MERIDIEM[tod]}`
|
||||
);
|
||||
return stripNoise(repositionNextYear(fixed));
|
||||
};
|
||||
|
||||
/** Convert English words back to the user's language for display. */
|
||||
const reverseTokens = (text, pairs) =>
|
||||
pairs.reduce(
|
||||
(r, [local, en]) =>
|
||||
r.replace(
|
||||
new RegExp(`(?<=^|\\s)${escapeRegex(en)}(?=\\s|$)`, 'g'),
|
||||
local
|
||||
),
|
||||
text
|
||||
);
|
||||
|
||||
// ─── Main Suggestion Generator ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate snooze suggestions from what the user has typed so far.
|
||||
* Works with any language if translations are provided. Returns up to 5
|
||||
* unique results, each with a label, date, and unix timestamp.
|
||||
*
|
||||
* @param {string} text - what the user typed
|
||||
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
|
||||
* @param {{ translations?: object, locale?: string }} [options] - i18n config
|
||||
* @returns {Array<{ label: string, date: Date, unix: number }>}
|
||||
*/
|
||||
export const generateDateSuggestions = (
|
||||
text,
|
||||
referenceDate = new Date(),
|
||||
{ translations, locale } = {}
|
||||
) => {
|
||||
if (!text || typeof text !== 'string') return [];
|
||||
const normalized = sanitize(text);
|
||||
if (!normalized) return [];
|
||||
|
||||
const stripped = stripNoise(normalized);
|
||||
const pairs =
|
||||
locale && locale !== 'en'
|
||||
? buildReplacementPairs(translations, locale)
|
||||
: [];
|
||||
|
||||
// Try English parse first, then translated parse if we have locale pairs.
|
||||
// This avoids the problem where a single overlapping word (e.g. "in" in German)
|
||||
// would skip token translation entirely.
|
||||
const directParse = parseDateFromText(stripped, referenceDate);
|
||||
|
||||
const translated = pairs.length ? replaceTokens(normalized, pairs) : null;
|
||||
const translatedParse =
|
||||
translated && translated !== stripped
|
||||
? parseDateFromText(translated, referenceDate)
|
||||
: null;
|
||||
|
||||
// Prefer direct English parse; fall back to translated parse
|
||||
const useTranslated = !directParse && !!translatedParse;
|
||||
const englishInput = useTranslated ? translated : stripped;
|
||||
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
const exact = directParse || translatedParse;
|
||||
if (exact) {
|
||||
seen.add(exact.unix);
|
||||
const exactLabel =
|
||||
useTranslated && pairs.length
|
||||
? reverseTokens(englishInput, pairs)
|
||||
: englishInput;
|
||||
results.push({ label: exactLabel, query: englishInput, ...exact });
|
||||
}
|
||||
|
||||
buildSuggestionCandidates(englishInput).some(candidate => {
|
||||
if (results.length >= MAX_SUGGESTIONS) return true;
|
||||
const result = parseDateFromText(candidate, referenceDate);
|
||||
if (result && !seen.has(result.unix)) {
|
||||
seen.add(result.unix);
|
||||
const label =
|
||||
useTranslated && pairs.length
|
||||
? reverseTokens(candidate, pairs)
|
||||
: candidate;
|
||||
results.push({ label, query: candidate, ...result });
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
@@ -0,0 +1,820 @@
|
||||
/**
|
||||
* Parses natural language text into a future date.
|
||||
*
|
||||
* Flow: clean the input → try each matcher in order → return the first future date.
|
||||
* The MATCHERS order matters — see the comment above the array.
|
||||
*/
|
||||
|
||||
import {
|
||||
add,
|
||||
startOfDay,
|
||||
getDay,
|
||||
isSaturday,
|
||||
isSunday,
|
||||
nextFriday,
|
||||
nextSaturday,
|
||||
getUnixTime,
|
||||
isValid,
|
||||
startOfWeek,
|
||||
addWeeks,
|
||||
isAfter,
|
||||
isBefore,
|
||||
endOfMonth,
|
||||
} from 'date-fns';
|
||||
|
||||
import {
|
||||
WEEKDAY_MAP,
|
||||
MONTH_MAP,
|
||||
RELATIVE_DAY_MAP,
|
||||
UNIT_MAP,
|
||||
WORD_NUMBER_MAP,
|
||||
NEXT_WEEKDAY_FN,
|
||||
TIME_OF_DAY_MAP,
|
||||
TOD_HOUR_RANGE,
|
||||
HALF_UNIT_DURATIONS,
|
||||
sanitize,
|
||||
stripNoise,
|
||||
parseNumber,
|
||||
parseTimeString,
|
||||
applyTimeToDate,
|
||||
applyTimeOrDefault,
|
||||
strictDate,
|
||||
futureOrNextYear,
|
||||
ensureFutureOrNextDay,
|
||||
inferHoursFromTOD,
|
||||
addFractionalSafe,
|
||||
} from './tokenMaps';
|
||||
|
||||
// ─── Regex Fragments (derived from maps) ────────────────────────────────────
|
||||
|
||||
const WEEKDAY_NAMES = Object.keys(WEEKDAY_MAP).join('|');
|
||||
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
|
||||
const UNIT_NAMES = Object.keys(UNIT_MAP).join('|');
|
||||
const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|');
|
||||
const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|');
|
||||
const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight';
|
||||
|
||||
const NUM_RE = `(\\d+(?:\\.5)?|${WORD_NUMBERS})`;
|
||||
const UNIT_RE = `(${UNIT_NAMES})`;
|
||||
const TIME_SUFFIX_RE =
|
||||
'(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?';
|
||||
|
||||
const ORDINAL_MAP = {
|
||||
first: 1,
|
||||
second: 2,
|
||||
third: 3,
|
||||
fourth: 4,
|
||||
fifth: 5,
|
||||
sixth: 6,
|
||||
seventh: 7,
|
||||
eighth: 8,
|
||||
ninth: 9,
|
||||
tenth: 10,
|
||||
};
|
||||
const parseOrdinal = str => {
|
||||
if (ORDINAL_MAP[str]) return ORDINAL_MAP[str];
|
||||
return parseInt(str.replace(/(?:st|nd|rd|th)$/, ''), 10) || null;
|
||||
};
|
||||
const ORDINAL_WORDS = Object.keys(ORDINAL_MAP).join('|');
|
||||
const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
|
||||
|
||||
// ─── Pre-compiled Regexes ───────────────────────────────────────────────────
|
||||
|
||||
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
|
||||
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
|
||||
const RELATIVE_DURATION_AFTER_RE = new RegExp(
|
||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+after$`
|
||||
);
|
||||
const DURATION_FROM_NOW_RE = new RegExp(
|
||||
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
|
||||
);
|
||||
const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
|
||||
const RELATIVE_DAY_TOD_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const RELATIVE_DAY_MERIDIEM_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(am|pm)$`
|
||||
);
|
||||
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||
);
|
||||
const RELATIVE_DAY_AT_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?` +
|
||||
'(\\d{1,2}(?::\\d{2})?\\s*' +
|
||||
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$'
|
||||
);
|
||||
const RELATIVE_DAY_SAME_TIME_RE = new RegExp(
|
||||
`^(?:(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)|(?:same\\s+time|this\\s+time)\\s+(${RELATIVE_DAYS}))$`
|
||||
);
|
||||
const NEXT_UNIT_RE = new RegExp(
|
||||
`^next\\s+(hour|minute|week|month|year)${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const NEXT_MONTH_RE = new RegExp(`^next\\s+(${MONTH_NAMES})${TIME_SUFFIX_RE}$`);
|
||||
const NEXT_WEEKDAY_TOD_RE = new RegExp(
|
||||
`^next\\s+(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const NEXT_WEEKDAY_RE = new RegExp(
|
||||
`^(?:(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week` +
|
||||
`|next\\s+week\\s+(${WEEKDAY_NAMES})` +
|
||||
`|next\\s+(${WEEKDAY_NAMES}))${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const SAME_TIME_WEEKDAY_RE = new RegExp(
|
||||
`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`
|
||||
);
|
||||
const WEEKDAY_TOD_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?` +
|
||||
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const WEEKDAY_TOD_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?` +
|
||||
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||
);
|
||||
const WEEKDAY_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const TIME_ONLY_MERIDIEM_RE =
|
||||
/^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/;
|
||||
const TIME_ONLY_24H_RE = /^(?:at\s+)?(\d{1,2}:\d{2})$/;
|
||||
const TOD_WITH_TIME_RE = new RegExp(
|
||||
`^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+` +
|
||||
'(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*' +
|
||||
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
|
||||
);
|
||||
const TOD_PLAIN_RE = new RegExp(
|
||||
'(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?' +
|
||||
`(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$`
|
||||
);
|
||||
const ABSOLUTE_DATE_RE = new RegExp(
|
||||
`^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?` +
|
||||
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const ABSOLUTE_DATE_REVERSED_RE = new RegExp(
|
||||
`^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})` +
|
||||
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const MONTH_YEAR_RE = new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`);
|
||||
// "april first week", "first week of april", "march 2nd day", "5th day of jan"
|
||||
const MONTH_ORDINAL_RE = new RegExp(
|
||||
`^(?:(${MONTH_NAMES})\\s+${ORDINAL_RE}\\s+(week|day)|${ORDINAL_RE}\\s+(week|day)\\s+of\\s+(${MONTH_NAMES}))${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
const DAY_AFTER_TOMORROW_RE = new RegExp(
|
||||
`^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$`
|
||||
);
|
||||
|
||||
const COMPOUND_DURATION_RE = new RegExp(
|
||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+(?:and\\s+)?${NUM_RE}\\s+${UNIT_RE}$`
|
||||
);
|
||||
const DURATION_AT_TIME_RE = new RegExp(
|
||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+at\\s+` +
|
||||
'(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
|
||||
);
|
||||
const END_OF_RE = /^end\s+of\s+(?:the\s+)?(week|month|day)$/;
|
||||
const END_OF_NEXT_RE = /^end\s+of\s+(?:the\s+)?next\s+(week|month)$/;
|
||||
const START_OF_NEXT_RE =
|
||||
/^(?:beginning|start)\s+of\s+(?:the\s+)?next\s+(week|month)$/;
|
||||
const LATER_TODAY_RE = /^later\s+(?:today|this\s+(?:afternoon|evening))$/;
|
||||
const EARLY_LATE_TOD_RE = new RegExp(
|
||||
`^(early|late)\\s+(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const ONE_AND_HALF_RE = new RegExp(
|
||||
`^(?:in\\s+)?(?:one\\s+and\\s+(?:a\\s+)?half|an?\\s+hour\\s+and\\s+(?:a\\s+)?half)(?:\\s+${UNIT_RE})?$`
|
||||
);
|
||||
const NEXT_BUSINESS_DAY_RE = /^next\s+(?:business|working)\s+day$/;
|
||||
|
||||
const TIME_SUFFIX_COMPILED = new RegExp(`${TIME_SUFFIX_RE}$`);
|
||||
const ISO_DATE_RE = new RegExp(
|
||||
`^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const SLASH_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const DASH_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
const DOT_DATE_RE = new RegExp(
|
||||
`^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX_COMPILED.source}`
|
||||
);
|
||||
|
||||
// ─── Pattern Matchers ───────────────────────────────────────────────────────
|
||||
|
||||
/** Read amount and unit from a regex match, then add to now. */
|
||||
const parseDuration = (match, now) => {
|
||||
if (!match) return null;
|
||||
const amount = parseNumber(match[1]);
|
||||
const unit = UNIT_MAP[match[2]];
|
||||
if (amount == null || !unit) return null;
|
||||
return addFractionalSafe(now, unit, amount);
|
||||
};
|
||||
|
||||
/** Handle "in 2 hours", "half day", "3h30m", "5 min from now". */
|
||||
const matchDuration = (text, now) => {
|
||||
const half = text.match(HALF_UNIT_RE);
|
||||
if (half) {
|
||||
return HALF_UNIT_DURATIONS[half[1]]
|
||||
? add(now, HALF_UNIT_DURATIONS[half[1]])
|
||||
: null;
|
||||
}
|
||||
|
||||
// "one and a half hours", "an hour and a half"
|
||||
const oneHalf = text.match(ONE_AND_HALF_RE);
|
||||
if (oneHalf) {
|
||||
const unit = UNIT_MAP[oneHalf[1]] || 'hours';
|
||||
return addFractionalSafe(now, unit, 1.5);
|
||||
}
|
||||
|
||||
const compound = text.match(COMPOUND_DURATION_RE);
|
||||
if (compound) {
|
||||
const a1 = parseNumber(compound[1]);
|
||||
const u1 = UNIT_MAP[compound[2]];
|
||||
const a2 = parseNumber(compound[3]);
|
||||
const u2 = UNIT_MAP[compound[4]];
|
||||
if (a1 == null || !u1 || a2 == null || !u2) {
|
||||
return null;
|
||||
}
|
||||
return add(add(now, { [u1]: a1 }), { [u2]: a2 });
|
||||
}
|
||||
|
||||
const atTime = text.match(DURATION_AT_TIME_RE);
|
||||
if (atTime) {
|
||||
const amount = parseNumber(atTime[1]);
|
||||
const unit = UNIT_MAP[atTime[2]];
|
||||
const time = parseTimeString(atTime[3]);
|
||||
if (amount == null || !unit || !time) {
|
||||
return null;
|
||||
}
|
||||
return applyTimeToDate(
|
||||
add(now, { [unit]: amount }),
|
||||
time.hours,
|
||||
time.minutes
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
|
||||
parseDuration(text.match(RELATIVE_DURATION_AFTER_RE), now) ||
|
||||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
|
||||
);
|
||||
};
|
||||
|
||||
/** Set time on a day offset. If the result is already past, move to the next day. */
|
||||
const applyTimeWithRollover = (offset, hours, minutes, now) => {
|
||||
const base = add(startOfDay(now), { days: offset });
|
||||
const date = applyTimeToDate(base, hours, minutes);
|
||||
if (isAfter(date, now)) return date;
|
||||
return applyTimeToDate(add(base, { days: 1 }), hours, minutes);
|
||||
};
|
||||
|
||||
/** Handle "today", "tonight", "tomorrow" with optional time. */
|
||||
const matchRelativeDay = (text, now) => {
|
||||
const dayOnlyMatch = text.match(RELATIVE_DAY_ONLY_RE);
|
||||
if (dayOnlyMatch) {
|
||||
const key = dayOnlyMatch[1];
|
||||
const offset = RELATIVE_DAY_MAP[key];
|
||||
if (key === 'tonight' || key === 'tonite') {
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(add(startOfDay(now), { days: offset }), 20, 0),
|
||||
now
|
||||
);
|
||||
}
|
||||
if (offset === 1) {
|
||||
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 9, 0);
|
||||
}
|
||||
return add(now, { hours: 1 });
|
||||
}
|
||||
|
||||
const dayTodTimeMatch = text.match(RELATIVE_DAY_TOD_TIME_RE);
|
||||
if (dayTodTimeMatch) {
|
||||
const timeParts = dayTodTimeMatch[3].split(':');
|
||||
const time = inferHoursFromTOD(
|
||||
dayTodTimeMatch[2],
|
||||
timeParts[0],
|
||||
timeParts[1]
|
||||
);
|
||||
if (!time) return null;
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayTodTimeMatch[1]],
|
||||
time.hours,
|
||||
time.minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const dayTodMatch = text.match(RELATIVE_DAY_TOD_RE);
|
||||
if (dayTodMatch) {
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[dayTodMatch[2]];
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayTodMatch[1]],
|
||||
hours,
|
||||
minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const dayMeridiemMatch = text.match(RELATIVE_DAY_MERIDIEM_RE);
|
||||
if (dayMeridiemMatch) {
|
||||
const [, dayKey, meridiem] = dayMeridiemMatch;
|
||||
const hours = meridiem === 'am' ? 9 : 14;
|
||||
return applyTimeWithRollover(RELATIVE_DAY_MAP[dayKey], hours, 0, now);
|
||||
}
|
||||
|
||||
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
|
||||
if (dayAtTimeMatch) {
|
||||
const [, dayKey, timeRaw] = dayAtTimeMatch;
|
||||
const bare = /^(tonight|tonite)$/.test(dayKey) && !/[ap]m/i.test(timeRaw);
|
||||
const time = bare
|
||||
? inferHoursFromTOD('tonight', ...timeRaw.split(':'))
|
||||
: parseTimeString(timeRaw);
|
||||
if (!time) return null;
|
||||
return applyTimeWithRollover(
|
||||
RELATIVE_DAY_MAP[dayKey],
|
||||
time.hours,
|
||||
time.minutes,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE);
|
||||
if (sameTimeMatch) {
|
||||
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1] || sameTimeMatch[2]];
|
||||
if (offset <= 0) return null;
|
||||
return applyTimeToDate(
|
||||
add(startOfDay(now), { days: offset }),
|
||||
now.getHours(),
|
||||
now.getMinutes()
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Find the given weekday in next week (not this week). */
|
||||
const nextWeekdayInNextWeek = (dayIndex, now) => {
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const date = fn(now);
|
||||
const sameWeek =
|
||||
startOfWeek(now, { weekStartsOn: 1 }).getTime() ===
|
||||
startOfWeek(date, { weekStartsOn: 1 }).getTime();
|
||||
return sameWeek ? fn(date) : date;
|
||||
};
|
||||
|
||||
/** Handle "next friday", "next week", "next month", "next january", etc. */
|
||||
const matchNextPattern = (text, now) => {
|
||||
const nextUnitMatch = text.match(NEXT_UNIT_RE);
|
||||
if (nextUnitMatch) {
|
||||
const unit = nextUnitMatch[1];
|
||||
if (unit === 'hour') return add(now, { hours: 1 });
|
||||
if (unit === 'minute') return add(now, { minutes: 1 });
|
||||
if (unit === 'week') {
|
||||
const base = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
|
||||
return applyTimeOrDefault(base, nextUnitMatch[2]);
|
||||
}
|
||||
const base = add(startOfDay(now), { [`${unit}s`]: 1 });
|
||||
return applyTimeOrDefault(base, nextUnitMatch[2]);
|
||||
}
|
||||
|
||||
const nextMonthMatch = text.match(NEXT_MONTH_RE);
|
||||
if (nextMonthMatch) {
|
||||
const monthIdx = MONTH_MAP[nextMonthMatch[1]];
|
||||
let year = now.getFullYear();
|
||||
if (monthIdx <= now.getMonth()) year += 1;
|
||||
const base = new Date(year, monthIdx, 1);
|
||||
return applyTimeOrDefault(base, nextMonthMatch[2]);
|
||||
}
|
||||
|
||||
// "next monday morning", "next friday midnight" — weekday + time-of-day
|
||||
const nextTodMatch = text.match(NEXT_WEEKDAY_TOD_RE);
|
||||
if (nextTodMatch) {
|
||||
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[nextTodMatch[1]], now);
|
||||
if (!date) return null;
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[nextTodMatch[2]];
|
||||
return applyTimeToDate(date, hours, minutes);
|
||||
}
|
||||
|
||||
// "monday of next week", "next week monday", "next friday" — all with optional time
|
||||
const weekdayMatch = text.match(NEXT_WEEKDAY_RE);
|
||||
if (weekdayMatch) {
|
||||
const dayName = weekdayMatch[1] || weekdayMatch[2] || weekdayMatch[3];
|
||||
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[dayName], now);
|
||||
if (!date) return null;
|
||||
return applyTimeOrDefault(date, weekdayMatch[4]);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Find the next occurrence of a weekday, with optional time. */
|
||||
const resolveWeekdayDate = (dayIndex, timeStr, now) => {
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
let adjusted = timeStr;
|
||||
if (timeStr && /^\d{1,2}$/.test(timeStr.trim())) {
|
||||
const h = parseInt(timeStr, 10);
|
||||
if (h >= 1 && h <= 7) adjusted = `${h}pm`;
|
||||
}
|
||||
|
||||
if (getDay(now) === dayIndex) {
|
||||
const todayDate = applyTimeOrDefault(now, adjusted);
|
||||
if (todayDate && isAfter(todayDate, now)) return todayDate;
|
||||
}
|
||||
|
||||
return applyTimeOrDefault(fn(now), adjusted);
|
||||
};
|
||||
|
||||
/** Handle "friday", "monday 3pm", "wed morning", "same time friday". */
|
||||
const matchWeekday = (text, now) => {
|
||||
const sameTimeWeekday = text.match(SAME_TIME_WEEKDAY_RE);
|
||||
if (sameTimeWeekday) {
|
||||
const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const target = fn(now);
|
||||
return applyTimeToDate(target, now.getHours(), now.getMinutes());
|
||||
}
|
||||
|
||||
// "monday morning 6", "friday evening 7" — weekday + tod + bare number
|
||||
const todTimeMatch = text.match(WEEKDAY_TOD_TIME_RE);
|
||||
if (todTimeMatch) {
|
||||
const dayIndex = WEEKDAY_MAP[todTimeMatch[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const timeParts = todTimeMatch[3].split(':');
|
||||
const time = inferHoursFromTOD(todTimeMatch[2], timeParts[0], timeParts[1]);
|
||||
if (!time) return null;
|
||||
const target =
|
||||
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
|
||||
const date = applyTimeToDate(target, time.hours, time.minutes);
|
||||
return isAfter(date, now)
|
||||
? date
|
||||
: applyTimeToDate(fn(now), time.hours, time.minutes);
|
||||
}
|
||||
|
||||
// "monday morning", "friday midnight", "wednesday evening", etc.
|
||||
const todMatch = text.match(WEEKDAY_TOD_RE);
|
||||
if (todMatch) {
|
||||
const dayIndex = WEEKDAY_MAP[todMatch[1]];
|
||||
const fn = NEXT_WEEKDAY_FN[dayIndex];
|
||||
if (!fn) return null;
|
||||
const { hours, minutes } = TIME_OF_DAY_MAP[todMatch[2]];
|
||||
const target =
|
||||
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
|
||||
const date = applyTimeToDate(target, hours, minutes);
|
||||
return isAfter(date, now) ? date : applyTimeToDate(fn(now), hours, minutes);
|
||||
}
|
||||
|
||||
const match = text.match(WEEKDAY_TIME_RE);
|
||||
if (!match) return null;
|
||||
|
||||
return resolveWeekdayDate(WEEKDAY_MAP[match[1]], match[2], now);
|
||||
};
|
||||
|
||||
/** Handle a standalone time like "3pm", "14:30", "at 9am". */
|
||||
const matchTimeOnly = (text, now) => {
|
||||
const match =
|
||||
text.match(TIME_ONLY_MERIDIEM_RE) || text.match(TIME_ONLY_24H_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const time = parseTimeString(match[1]);
|
||||
if (!time) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, time.hours, time.minutes),
|
||||
now
|
||||
);
|
||||
};
|
||||
|
||||
/** Handle "morning", "evening 6pm", "eod", "this afternoon". */
|
||||
const matchTimeOfDay = (text, now) => {
|
||||
const todWithTime = text.match(TOD_WITH_TIME_RE);
|
||||
if (todWithTime) {
|
||||
const rawTime = todWithTime[2].trim();
|
||||
const hasMeridiem = /(?:am|pm|a\.m|p\.m)/i.test(rawTime);
|
||||
let time;
|
||||
if (hasMeridiem) {
|
||||
time = parseTimeString(rawTime);
|
||||
const range = TOD_HOUR_RANGE[todWithTime[1]];
|
||||
if (!time) return null;
|
||||
if (range) {
|
||||
const h = time.hours === 0 ? 24 : time.hours;
|
||||
if (h < range[0] || h >= range[1]) return null;
|
||||
}
|
||||
} else {
|
||||
const parts = rawTime.split(':');
|
||||
time = inferHoursFromTOD(todWithTime[1], parts[0], parts[1]);
|
||||
}
|
||||
if (!time) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, time.hours, time.minutes),
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
// "early morning" → 7am, "late evening" → 21:00, "late night" → 23:00
|
||||
const earlyLate = text.match(EARLY_LATE_TOD_RE);
|
||||
if (earlyLate) {
|
||||
const tod = TIME_OF_DAY_MAP[earlyLate[2]];
|
||||
if (!tod) return null;
|
||||
const shift = earlyLate[1] === 'early' ? -1 : 2;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, tod.hours + shift, 0),
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const match = text.match(TOD_PLAIN_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const key = text
|
||||
.replace(/^(?:later|in)\s+/, '')
|
||||
.replace(/^(?:this|the)\s+/, '')
|
||||
.trim();
|
||||
const tod = TIME_OF_DAY_MAP[key];
|
||||
if (!tod) return null;
|
||||
return ensureFutureOrNextDay(
|
||||
applyTimeToDate(now, tod.hours, tod.minutes),
|
||||
now
|
||||
);
|
||||
};
|
||||
|
||||
/** Turn month + day + optional year into a future date. */
|
||||
const resolveAbsoluteDate = (month, day, yearStr, timeStr, now) => {
|
||||
let year = now.getFullYear();
|
||||
if (yearStr && /next\s+year/i.test(yearStr)) {
|
||||
year += 1;
|
||||
} else if (yearStr) {
|
||||
year = parseInt(yearStr, 10);
|
||||
}
|
||||
if (yearStr) {
|
||||
const base = strictDate(year, month, day);
|
||||
if (!base) return null;
|
||||
const date = applyTimeOrDefault(base, timeStr);
|
||||
return date && isAfter(date, now) ? date : null;
|
||||
}
|
||||
return futureOrNextYear(year, month, day, timeStr, now);
|
||||
};
|
||||
|
||||
/** Handle "jan 15", "15 march", "december 2025". */
|
||||
const matchNamedDate = (text, now) => {
|
||||
const abs = text.match(ABSOLUTE_DATE_RE);
|
||||
if (abs) {
|
||||
return resolveAbsoluteDate(
|
||||
MONTH_MAP[abs[1]],
|
||||
parseInt(abs[2], 10),
|
||||
abs[3],
|
||||
abs[4],
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const rev = text.match(ABSOLUTE_DATE_REVERSED_RE);
|
||||
if (rev) {
|
||||
return resolveAbsoluteDate(
|
||||
MONTH_MAP[rev[2]],
|
||||
parseInt(rev[1], 10),
|
||||
rev[3],
|
||||
rev[4],
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const my = text.match(MONTH_YEAR_RE);
|
||||
if (my) {
|
||||
const date = new Date(parseInt(my[2], 10), MONTH_MAP[my[1]], 1);
|
||||
if (!isValid(date)) return null;
|
||||
const result = applyTimeToDate(date, 9, 0);
|
||||
return isAfter(result, now) ? result : null;
|
||||
}
|
||||
|
||||
// "april first week", "first week of april", "march 2nd day", etc.
|
||||
const mo = text.match(MONTH_ORDINAL_RE);
|
||||
if (mo) {
|
||||
// Groups: (1)month-A (2)ordinal-A (3)unit-A | (4)ordinal-B (5)unit-B (6)month-B (7)time
|
||||
const monthIdx = MONTH_MAP[mo[1] || mo[6]];
|
||||
const num = parseOrdinal(mo[2] || mo[4]);
|
||||
const unit = mo[3] || mo[5];
|
||||
const timeStr = mo[7];
|
||||
|
||||
if (!num || num < 1) return null;
|
||||
|
||||
if (unit === 'day') {
|
||||
if (num > 31) return null;
|
||||
return resolveAbsoluteDate(monthIdx, num, null, timeStr, now);
|
||||
}
|
||||
|
||||
// unit === 'week'
|
||||
if (num > 5) return null;
|
||||
const weekStartDay = (num - 1) * 7 + 1;
|
||||
let year = now.getFullYear();
|
||||
if (
|
||||
monthIdx < now.getMonth() ||
|
||||
(monthIdx === now.getMonth() && now.getDate() > weekStartDay)
|
||||
) {
|
||||
year += 1;
|
||||
}
|
||||
// Reject if weekStartDay overflows the month (e.g. feb fifth week = day 29 in non-leap)
|
||||
const daysInMonth = new Date(year, monthIdx + 1, 0).getDate();
|
||||
if (weekStartDay > daysInMonth) return null;
|
||||
const d = new Date(year, monthIdx, weekStartDay);
|
||||
if (!isValid(d)) return null;
|
||||
const result = applyTimeOrDefault(d, timeStr);
|
||||
return result && isAfter(result, now) ? result : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Build a date from year/month/day numbers, with optional time. */
|
||||
const buildDateWithOptionalTime = (year, month, day, timeStr) => {
|
||||
const date = strictDate(year, month, day);
|
||||
if (!date) return null;
|
||||
return applyTimeOrDefault(date, timeStr);
|
||||
};
|
||||
|
||||
// When both values are ≤ 12 (ambiguous), dayFirst controls the fallback:
|
||||
// dayFirst=false (slash M/D/Y) → month first
|
||||
// dayFirst=true (dash/dot D-M-Y, D.M.Y) → day first
|
||||
const disambiguateDayMonth = (a, b, dayFirst = false) => {
|
||||
if (a > 12) return { day: a, month: b - 1 };
|
||||
if (b > 12) return { month: a - 1, day: b };
|
||||
return dayFirst ? { day: a, month: b - 1 } : { month: a - 1, day: b };
|
||||
};
|
||||
|
||||
/** Handle formal dates: "2025-01-15", "1/15/2025", "15.01.2025". */
|
||||
const matchFormalDate = (text, now) => {
|
||||
const ensureFuture = date => (date && isAfter(date, now) ? date : null);
|
||||
|
||||
const isoMatch = text.match(ISO_DATE_RE);
|
||||
if (isoMatch) {
|
||||
return ensureFuture(
|
||||
buildDateWithOptionalTime(
|
||||
parseInt(isoMatch[1], 10),
|
||||
parseInt(isoMatch[2], 10) - 1,
|
||||
parseInt(isoMatch[3], 10),
|
||||
isoMatch[4]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Slash = M/D/Y (US), Dash/Dot = D-M-Y / D.M.Y (European)
|
||||
const formats = [
|
||||
{ re: SLASH_DATE_RE, dayFirst: false },
|
||||
{ re: DASH_DATE_RE, dayFirst: true },
|
||||
{ re: DOT_DATE_RE, dayFirst: true },
|
||||
];
|
||||
let result = null;
|
||||
formats.some(({ re, dayFirst }) => {
|
||||
const m = text.match(re);
|
||||
if (!m) return false;
|
||||
const { month, day } = disambiguateDayMonth(
|
||||
parseInt(m[1], 10),
|
||||
parseInt(m[2], 10),
|
||||
dayFirst
|
||||
);
|
||||
result = ensureFuture(
|
||||
buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4])
|
||||
);
|
||||
return true;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Handle "day after tomorrow", "end of week", "this weekend", "later today". */
|
||||
const matchSpecial = (text, now) => {
|
||||
const dat = text.match(DAY_AFTER_TOMORROW_RE);
|
||||
if (dat) return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), dat[1]);
|
||||
|
||||
const eof = text.match(END_OF_RE);
|
||||
if (eof) {
|
||||
if (eof[1] === 'day') return applyTimeToDate(now, 17, 0);
|
||||
if (eof[1] === 'week') {
|
||||
const fri = applyTimeToDate(now, 17, 0);
|
||||
if (getDay(now) === 5 && isAfter(fri, now)) return fri;
|
||||
return applyTimeToDate(nextFriday(now), 17, 0);
|
||||
}
|
||||
if (eof[1] === 'month') {
|
||||
const eom = applyTimeToDate(endOfMonth(now), 17, 0);
|
||||
if (isAfter(eom, now)) return eom;
|
||||
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// "end of next week", "end of next month"
|
||||
const eofNext = text.match(END_OF_NEXT_RE);
|
||||
if (eofNext) {
|
||||
if (eofNext[1] === 'week') {
|
||||
const nextWeekStart = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
|
||||
return applyTimeToDate(add(nextWeekStart, { days: 4 }), 17, 0);
|
||||
}
|
||||
if (eofNext[1] === 'month') {
|
||||
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// "beginning of next week", "start of next month"
|
||||
const sofNext = text.match(START_OF_NEXT_RE);
|
||||
if (sofNext) {
|
||||
if (sofNext[1] === 'week') {
|
||||
return applyTimeToDate(
|
||||
startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 }),
|
||||
9,
|
||||
0
|
||||
);
|
||||
}
|
||||
if (sofNext[1] === 'month') {
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return applyTimeToDate(nextMonth, 9, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// "next business day", "next working day"
|
||||
if (NEXT_BUSINESS_DAY_RE.test(text)) {
|
||||
let d = add(startOfDay(now), { days: 1 });
|
||||
while (isSaturday(d) || isSunday(d)) d = add(d, { days: 1 });
|
||||
return applyTimeToDate(d, 9, 0);
|
||||
}
|
||||
|
||||
if (LATER_TODAY_RE.test(text)) return add(now, { hours: 3 });
|
||||
|
||||
const weekendMatch = text.match(
|
||||
/^(this weekend|weekend|next weekend)(?:\s+(?:at\s+)?(.+))?$/
|
||||
);
|
||||
if (weekendMatch) {
|
||||
const isNext = weekendMatch[1] === 'next weekend';
|
||||
const timeStr = weekendMatch[2];
|
||||
|
||||
if (isNext) {
|
||||
const sat = nextSaturday(now);
|
||||
const d = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 });
|
||||
return applyTimeOrDefault(d, timeStr);
|
||||
}
|
||||
|
||||
if (isSaturday(now)) {
|
||||
if (!timeStr) {
|
||||
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
|
||||
if (now.getHours() < 18) return add(now, { hours: 2 });
|
||||
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 10, 0);
|
||||
}
|
||||
const today = applyTimeOrDefault(now, timeStr);
|
||||
if (today && isAfter(today, now)) return today;
|
||||
return applyTimeOrDefault(add(startOfDay(now), { days: 1 }), timeStr);
|
||||
}
|
||||
if (isSunday(now)) {
|
||||
if (!timeStr) {
|
||||
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
|
||||
return add(now, { hours: 2 });
|
||||
}
|
||||
const today = applyTimeOrDefault(now, timeStr);
|
||||
if (today && isAfter(today, now)) return today;
|
||||
}
|
||||
return applyTimeOrDefault(nextSaturday(now), timeStr);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// ─── Main Parser ────────────────────────────────────────────────────────────
|
||||
|
||||
// Order matters — first match wins. Common patterns go first.
|
||||
// Do not reorder without running the spec.
|
||||
const MATCHERS = [
|
||||
matchDuration, // "in 2 hours", "half day", "3h30m"
|
||||
matchSpecial, // "end of week", "later today", "this weekend"
|
||||
matchRelativeDay, // "tomorrow 3pm", "tonight", "today morning"
|
||||
matchNextPattern, // "next friday", "next week", "next month"
|
||||
matchTimeOfDay, // "morning", "evening 6pm", "eod"
|
||||
matchWeekday, // "friday", "monday 3pm", "wed morning"
|
||||
matchTimeOnly, // "3pm", "14:30" (must be after weekday to avoid conflicts)
|
||||
matchNamedDate, // "jan 15", "march 20 next year"
|
||||
matchFormalDate, // "2025-01-15", "1/15/2025" (least common, last)
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse free-form text into a future date.
|
||||
* Returns { date, unix } or null. Only returns dates after referenceDate.
|
||||
*
|
||||
* @param {string} text - user input like "in 2 hours" or "next friday 3pm"
|
||||
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
|
||||
* @returns {{ date: Date, unix: number } | null}
|
||||
*/
|
||||
export const parseDateFromText = (text, referenceDate = new Date()) => {
|
||||
if (!text || typeof text !== 'string') return null;
|
||||
|
||||
const normalized = stripNoise(sanitize(text));
|
||||
if (!normalized) return null;
|
||||
|
||||
const maxDate = add(referenceDate, { years: 999 });
|
||||
|
||||
const isValidFuture = d =>
|
||||
d && isValid(d) && isAfter(d, referenceDate) && !isBefore(maxDate, d);
|
||||
|
||||
let result = null;
|
||||
MATCHERS.some(matcher => {
|
||||
const d = matcher(normalized, referenceDate);
|
||||
if (isValidFuture(d)) {
|
||||
result = { date: d, unix: getUnixTime(d) };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Builds autocomplete suggestions as the user types a snooze date.
|
||||
* Matches partial input against known phrases and ranks them by closeness.
|
||||
*/
|
||||
|
||||
import {
|
||||
UNIT_MAP,
|
||||
WEEKDAY_MAP,
|
||||
TIME_OF_DAY_MAP,
|
||||
RELATIVE_DAY_MAP,
|
||||
WORD_NUMBER_MAP,
|
||||
MONTH_MAP,
|
||||
HALF_UNIT_DURATIONS,
|
||||
} from './tokenMaps';
|
||||
|
||||
const SUGGESTION_UNITS = [...new Set(Object.values(UNIT_MAP))].filter(
|
||||
u => u !== 'seconds'
|
||||
);
|
||||
|
||||
const FULL_WEEKDAYS = Object.keys(WEEKDAY_MAP).filter(k => k.length > 3);
|
||||
const TOD_NAMES = Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' '));
|
||||
const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3);
|
||||
|
||||
const ALL_SUGGESTION_PHRASES = [
|
||||
...Object.keys(RELATIVE_DAY_MAP),
|
||||
...FULL_WEEKDAYS,
|
||||
...TOD_NAMES,
|
||||
'next week',
|
||||
'next month',
|
||||
'this weekend',
|
||||
'next weekend',
|
||||
'day after tomorrow',
|
||||
'later today',
|
||||
'end of day',
|
||||
'end of week',
|
||||
'end of month',
|
||||
...['morning', 'afternoon', 'evening'].map(tod => `tomorrow ${tod}`),
|
||||
...FULL_WEEKDAYS.map(wd => `next ${wd}`),
|
||||
...FULL_WEEKDAYS.map(wd => `this ${wd}`),
|
||||
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `${wd} ${tod}`)),
|
||||
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `next ${wd} ${tod}`)),
|
||||
...MONTH_NAMES_LONG.map(m => `${m} 1`),
|
||||
];
|
||||
|
||||
/** Check how closely the input matches a candidate. -1 = no match, 0 = exact prefix, N = extra words needed. */
|
||||
const prefixMatchScore = (candidate, input) => {
|
||||
if (candidate === input) return -1;
|
||||
if (candidate.startsWith(input)) return 0;
|
||||
const inputWords = input.split(' ');
|
||||
const candidateWords = candidate.split(' ');
|
||||
const lastIdx = inputWords.reduce((prev, iw) => {
|
||||
if (prev === -2) return -2;
|
||||
const idx = candidateWords.findIndex(
|
||||
(cw, ci) => ci > prev && cw.startsWith(iw)
|
||||
);
|
||||
return idx === -1 ? -2 : idx;
|
||||
}, -1);
|
||||
if (lastIdx === -2) return -1;
|
||||
return candidateWords.length - inputWords.length;
|
||||
};
|
||||
|
||||
export const MAX_SUGGESTIONS = 5;
|
||||
|
||||
/** Turn user input into a ranked list of suggestion strings to try parsing. */
|
||||
export const buildSuggestionCandidates = text => {
|
||||
if (!text) return [];
|
||||
|
||||
if (/^\d/.test(text)) {
|
||||
const num = text.match(/^\d+(?:\.5)?/)[0];
|
||||
const candidates = SUGGESTION_UNITS.map(u => `${num} ${u}`);
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim();
|
||||
const spaced = trimmed.replace(/(\d)([a-z])/i, '$1 $2');
|
||||
return spaced.length > num.length
|
||||
? candidates.filter(c => c.startsWith(spaced))
|
||||
: candidates;
|
||||
}
|
||||
|
||||
if (text.length >= 2 && 'half'.startsWith(text)) {
|
||||
return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`);
|
||||
}
|
||||
|
||||
const wordNum = WORD_NUMBER_MAP[text];
|
||||
if (wordNum != null && wordNum >= 1) {
|
||||
return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`);
|
||||
}
|
||||
|
||||
const scored = ALL_SUGGESTION_PHRASES.reduce((acc, candidate) => {
|
||||
const score = prefixMatchScore(candidate, text);
|
||||
if (score >= 0) acc.push({ candidate, score });
|
||||
return acc;
|
||||
}, []);
|
||||
scored.sort((a, b) => a.score - b.score);
|
||||
const seen = new Set();
|
||||
return scored.reduce((acc, { candidate }) => {
|
||||
if (acc.length < MAX_SUGGESTIONS * 3 && !seen.has(candidate)) {
|
||||
seen.add(candidate);
|
||||
acc.push(candidate);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Shared lookup tables and helper functions used by the parser,
|
||||
* suggestions, and localization modules.
|
||||
*/
|
||||
|
||||
import {
|
||||
add,
|
||||
set,
|
||||
isValid,
|
||||
isAfter,
|
||||
nextMonday,
|
||||
nextTuesday,
|
||||
nextWednesday,
|
||||
nextThursday,
|
||||
nextFriday,
|
||||
nextSaturday,
|
||||
nextSunday,
|
||||
} from 'date-fns';
|
||||
|
||||
// ─── Token Maps ──────────────────────────────────────────────────────────────
|
||||
// All keys are lowercase. Short forms and full names both work.
|
||||
|
||||
/** Weekday name or short form → day index (0 = Sunday). */
|
||||
export const WEEKDAY_MAP = {
|
||||
sunday: 0,
|
||||
sun: 0,
|
||||
monday: 1,
|
||||
mon: 1,
|
||||
tuesday: 2,
|
||||
tue: 2,
|
||||
tues: 2,
|
||||
wednesday: 3,
|
||||
wed: 3,
|
||||
thursday: 4,
|
||||
thu: 4,
|
||||
thur: 4,
|
||||
thurs: 4,
|
||||
friday: 5,
|
||||
fri: 5,
|
||||
saturday: 6,
|
||||
sat: 6,
|
||||
};
|
||||
|
||||
/** Month name or short form → month index (0 = January). */
|
||||
export const MONTH_MAP = {
|
||||
january: 0,
|
||||
jan: 0,
|
||||
february: 1,
|
||||
feb: 1,
|
||||
march: 2,
|
||||
mar: 2,
|
||||
april: 3,
|
||||
apr: 3,
|
||||
may: 4,
|
||||
june: 5,
|
||||
jun: 5,
|
||||
july: 6,
|
||||
jul: 6,
|
||||
august: 7,
|
||||
aug: 7,
|
||||
september: 8,
|
||||
sep: 8,
|
||||
sept: 8,
|
||||
october: 9,
|
||||
oct: 9,
|
||||
november: 10,
|
||||
nov: 10,
|
||||
december: 11,
|
||||
dec: 11,
|
||||
};
|
||||
|
||||
/** Words like "today" or "tomorrow" → how many days from now. */
|
||||
export const RELATIVE_DAY_MAP = {
|
||||
today: 0,
|
||||
tonight: 0,
|
||||
tonite: 0,
|
||||
tomorrow: 1,
|
||||
tmr: 1,
|
||||
tmrw: 1,
|
||||
};
|
||||
|
||||
/** Unit shorthand → full unit name used by date-fns. */
|
||||
export const UNIT_MAP = {
|
||||
m: 'minutes',
|
||||
min: 'minutes',
|
||||
mins: 'minutes',
|
||||
minute: 'minutes',
|
||||
minutes: 'minutes',
|
||||
h: 'hours',
|
||||
hr: 'hours',
|
||||
hrs: 'hours',
|
||||
hour: 'hours',
|
||||
hours: 'hours',
|
||||
d: 'days',
|
||||
day: 'days',
|
||||
days: 'days',
|
||||
w: 'weeks',
|
||||
wk: 'weeks',
|
||||
wks: 'weeks',
|
||||
week: 'weeks',
|
||||
weeks: 'weeks',
|
||||
mo: 'months',
|
||||
month: 'months',
|
||||
months: 'months',
|
||||
y: 'years',
|
||||
yr: 'years',
|
||||
yrs: 'years',
|
||||
year: 'years',
|
||||
years: 'years',
|
||||
};
|
||||
|
||||
/** English number words → their numeric value. */
|
||||
export const WORD_NUMBER_MAP = {
|
||||
a: 1,
|
||||
an: 1,
|
||||
one: 1,
|
||||
couple: 2,
|
||||
few: 3,
|
||||
two: 2,
|
||||
three: 3,
|
||||
four: 4,
|
||||
five: 5,
|
||||
six: 6,
|
||||
seven: 7,
|
||||
eight: 8,
|
||||
nine: 9,
|
||||
ten: 10,
|
||||
eleven: 11,
|
||||
twelve: 12,
|
||||
thirteen: 13,
|
||||
fourteen: 14,
|
||||
fifteen: 15,
|
||||
sixteen: 16,
|
||||
seventeen: 17,
|
||||
eighteen: 18,
|
||||
nineteen: 19,
|
||||
twenty: 20,
|
||||
thirty: 30,
|
||||
forty: 40,
|
||||
fifty: 50,
|
||||
sixty: 60,
|
||||
ninety: 90,
|
||||
half: 0.5,
|
||||
};
|
||||
|
||||
/** Day index → the date-fns function that finds the next occurrence. */
|
||||
export const NEXT_WEEKDAY_FN = {
|
||||
0: nextSunday,
|
||||
1: nextMonday,
|
||||
2: nextTuesday,
|
||||
3: nextWednesday,
|
||||
4: nextThursday,
|
||||
5: nextFriday,
|
||||
6: nextSaturday,
|
||||
};
|
||||
|
||||
/** Time-of-day label → default hour and minute. */
|
||||
export const TIME_OF_DAY_MAP = {
|
||||
morning: { hours: 9, minutes: 0 },
|
||||
noon: { hours: 12, minutes: 0 },
|
||||
afternoon: { hours: 14, minutes: 0 },
|
||||
evening: { hours: 18, minutes: 0 },
|
||||
night: { hours: 20, minutes: 0 },
|
||||
tonight: { hours: 20, minutes: 0 },
|
||||
midnight: { hours: 0, minutes: 0 },
|
||||
eod: { hours: 17, minutes: 0 },
|
||||
'end of day': { hours: 17, minutes: 0 },
|
||||
'end of the day': { hours: 17, minutes: 0 },
|
||||
};
|
||||
|
||||
/** Allowed hour range per label — used to pick am or pm when not specified. */
|
||||
export const TOD_HOUR_RANGE = {
|
||||
morning: [4, 12],
|
||||
noon: [11, 13],
|
||||
afternoon: [12, 18],
|
||||
evening: [16, 22],
|
||||
night: [18, 24],
|
||||
tonight: [18, 24],
|
||||
midnight: [23, 25],
|
||||
};
|
||||
|
||||
/** What "half hour", "half day", etc. actually mean in date-fns terms. */
|
||||
export const HALF_UNIT_DURATIONS = {
|
||||
hour: { minutes: 30 },
|
||||
day: { hours: 12 },
|
||||
week: { days: 3, hours: 12 },
|
||||
month: { days: 15 },
|
||||
year: { months: 6 },
|
||||
};
|
||||
|
||||
const FRACTIONAL_CONVERT = {
|
||||
hours: { unit: 'minutes', factor: 60 },
|
||||
days: { unit: 'hours', factor: 24 },
|
||||
weeks: { unit: 'days', factor: 7 },
|
||||
months: { unit: 'days', factor: 30 },
|
||||
years: { unit: 'months', factor: 12 },
|
||||
};
|
||||
|
||||
// ─── Unicode / Normalization ────────────────────────────────────────────────
|
||||
// Turn non-ASCII digits and punctuation into plain ASCII so the
|
||||
// parser only has to deal with standard characters.
|
||||
|
||||
const UNICODE_DIGIT_RANGES = [
|
||||
[0x30, 0x39],
|
||||
[0x660, 0x669], // Arabic-Indic
|
||||
[0x6f0, 0x6f9], // Eastern Arabic-Indic
|
||||
[0x966, 0x96f], // Devanagari
|
||||
[0x9e6, 0x9ef], // Bengali
|
||||
[0xa66, 0xa6f], // Gurmukhi
|
||||
[0xae6, 0xaef], // Gujarati
|
||||
[0xb66, 0xb6f], // Oriya
|
||||
[0xbe6, 0xbef], // Tamil
|
||||
[0xc66, 0xc6f], // Telugu
|
||||
[0xce6, 0xcef], // Kannada
|
||||
[0xd66, 0xd6f], // Malayalam
|
||||
];
|
||||
|
||||
const toAsciiDigit = char => {
|
||||
const code = char.codePointAt(0);
|
||||
const range = UNICODE_DIGIT_RANGES.find(
|
||||
([start, end]) => code >= start && code <= end
|
||||
);
|
||||
if (!range) return char;
|
||||
return String(code - range[0]);
|
||||
};
|
||||
|
||||
/** Turn non-ASCII digits (Arabic, Devanagari, etc.) into 0-9. */
|
||||
export const normalizeDigits = text => text.replace(/\p{Nd}/gu, toAsciiDigit);
|
||||
|
||||
const ARABIC_PUNCT_MAP = {
|
||||
'\u061f': '?',
|
||||
'\u060c': ',',
|
||||
'\u061b': ';',
|
||||
'\u066b': '.',
|
||||
};
|
||||
|
||||
const NOISE_RE =
|
||||
/^(?:(?:can|could|will|would)\s+you\s+)?(?:(?:please|pls|plz|kindly)\s+)?(?:(?:snooze|remind(?:\s+me)?|set(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze|timer))?|add(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze))?|schedule|postpone|defer|delay|push)(?:\s+(?:it|this))?\s+)?(?:(?:on|to|for|at|until|till|by|from|after|within)\s+)?/;
|
||||
|
||||
const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/;
|
||||
|
||||
/** Clean up raw input: lowercase, remove punctuation, collapse spaces. */
|
||||
export const sanitize = text =>
|
||||
normalizeDigits(
|
||||
text
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[\u200f\u200e\u066c\u0640]/g, '')
|
||||
.replace(/[\u064b-\u065f]/g, '')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/[\u061f\u060c\u061b\u066b]/g, c => ARABIC_PUNCT_MAP[c])
|
||||
)
|
||||
.replace(/[,!?;]+/g, ' ')
|
||||
.replace(/\.+$/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** Strip filler words like "please snooze for" and fix typos like "tommorow". */
|
||||
export const stripNoise = text => {
|
||||
let r = text
|
||||
.replace(/\ba\s+fortnight\b/g, '2 weeks')
|
||||
.replace(/\bfortnight\b/g, '2 weeks')
|
||||
.replace(NOISE_RE, '')
|
||||
.replace(APPROX_RE, '')
|
||||
.replace(/^the\s+/, '')
|
||||
.replace(/\bnxt\b/g, 'next')
|
||||
.replace(/\ba\s+couple\s+of\b/g, 'couple')
|
||||
.replace(/\bcouple\s+of\b/g, 'couple')
|
||||
.replace(/\ba\s+couple\b/g, 'couple')
|
||||
.replace(/\ba\s+few\b/g, 'few')
|
||||
.replace(
|
||||
/\b(\d+)\s*(?:h|hr|hours?)[\s]*(\d+)\s*(?:m|min|minutes?)\b/g,
|
||||
(_, h, m) =>
|
||||
`${h} ${h === '1' ? 'hour' : 'hours'} ${m} ${m === '1' ? 'minute' : 'minutes'}`
|
||||
)
|
||||
.replace(/\b(\d+)h\b/g, (_, h) => `${h} ${h === '1' ? 'hour' : 'hours'}`)
|
||||
.replace(
|
||||
/\b(\d+)m\b/g,
|
||||
(_, m) => `${m} ${m === '1' ? 'minute' : 'minutes'}`
|
||||
)
|
||||
.replace(/\btomm?orow\b/g, 'tomorrow')
|
||||
.replace(/\s+later$/, '')
|
||||
.trim();
|
||||
// bare unit without number: "month later" → "1 month", "week" stays
|
||||
r = r.replace(/^(minutes?|hours?|days?|weeks?|months?|years?)$/, '1 $1');
|
||||
return r;
|
||||
};
|
||||
|
||||
// ─── Utility Functions ──────────────────────────────────────────────────────
|
||||
|
||||
/** Turn a string into a number. Works with digits ("5") and words ("five"). */
|
||||
export const parseNumber = str => {
|
||||
if (!str) return null;
|
||||
const lower = normalizeDigits(str.toLowerCase().trim());
|
||||
if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower];
|
||||
const num = Number(lower);
|
||||
return Number.isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
/** Set the time on a date, clearing seconds and milliseconds. */
|
||||
export const applyTimeToDate = (date, hours, minutes = 0) =>
|
||||
set(date, { hours, minutes, seconds: 0, milliseconds: 0 });
|
||||
|
||||
/** Parse "3pm", "14:30", or "2:00am" into { hours, minutes }. Returns null if invalid. */
|
||||
export const parseTimeString = timeStr => {
|
||||
if (!timeStr) return null;
|
||||
const match = timeStr
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/);
|
||||
if (!match) return null;
|
||||
|
||||
const raw = parseInt(match[1], 10);
|
||||
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
||||
const meridiem = match[3]?.replace(/\./g, '');
|
||||
if (meridiem && (raw < 1 || raw > 12)) return null;
|
||||
|
||||
const toHours = (h, m) => {
|
||||
if (m === 'pm' && h < 12) return h + 12;
|
||||
if (m === 'am' && h === 12) return 0;
|
||||
return h;
|
||||
};
|
||||
const hours = toHours(raw, meridiem);
|
||||
if (hours > 23 || minutes > 59) return null;
|
||||
return { hours, minutes };
|
||||
};
|
||||
|
||||
/** Apply a time string to a date. Falls back to 9 AM if no time is given. */
|
||||
export const applyTimeOrDefault = (date, timeStr, defaultHours = 9) => {
|
||||
if (timeStr) {
|
||||
const time = parseTimeString(timeStr);
|
||||
if (!time) return null;
|
||||
return applyTimeToDate(date, time.hours, time.minutes);
|
||||
}
|
||||
return applyTimeToDate(date, defaultHours, 0);
|
||||
};
|
||||
|
||||
/** Build a Date only if the day actually exists (e.g. rejects Feb 30). */
|
||||
export const strictDate = (year, month, day) => {
|
||||
const date = new Date(year, month, day);
|
||||
if (
|
||||
!isValid(date) ||
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month ||
|
||||
date.getDate() !== day
|
||||
)
|
||||
return null;
|
||||
return date;
|
||||
};
|
||||
|
||||
/** Try up to 8 years ahead to find a valid future date (handles Feb 29 leap years). */
|
||||
export const futureOrNextYear = (year, month, day, timeStr, now) => {
|
||||
for (let i = 0; i < 9; i += 1) {
|
||||
const base = strictDate(year + i, month, day);
|
||||
if (base) {
|
||||
const date = applyTimeOrDefault(base, timeStr);
|
||||
if (!date) return null;
|
||||
if (isAfter(date, now)) return date;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** If the date is already past, push it to the next day. */
|
||||
export const ensureFutureOrNextDay = (date, now) =>
|
||||
isAfter(date, now) ? date : add(date, { days: 1 });
|
||||
|
||||
/** Figure out am/pm from context: "morning 6" → 6am, "evening 6" → 6pm. */
|
||||
export const inferHoursFromTOD = (todLabel, rawHour, rawMinutes) => {
|
||||
const h = parseInt(rawHour, 10);
|
||||
const m = rawMinutes ? parseInt(rawMinutes, 10) : 0;
|
||||
if (Number.isNaN(h) || h < 1 || h > 12 || m > 59) return null;
|
||||
const range = TOD_HOUR_RANGE[todLabel];
|
||||
if (!range) return { hours: h, minutes: m };
|
||||
// Try both am and pm interpretations, pick the one in range
|
||||
const am = h === 12 ? 0 : h;
|
||||
const pm = h === 12 ? 12 : h + 12;
|
||||
const inRange = v => v >= range[0] && v < range[1];
|
||||
if (inRange(am)) return { hours: am, minutes: m };
|
||||
if (inRange(pm)) return { hours: pm, minutes: m };
|
||||
const mid = (range[0] + range[1]) / 2;
|
||||
return {
|
||||
hours: Math.abs(am - mid) <= Math.abs(pm - mid) ? am : pm,
|
||||
minutes: m,
|
||||
};
|
||||
};
|
||||
|
||||
/** Add a duration that might be fractional, e.g. 1.5 hours becomes 90 minutes. */
|
||||
export const addFractionalSafe = (date, unit, amount) => {
|
||||
if (Number.isInteger(amount)) return add(date, { [unit]: amount });
|
||||
if (amount % 1 !== 0.5) return null;
|
||||
const conv = FRACTIONAL_CONVERT[unit];
|
||||
if (conv) return add(date, { [conv.unit]: Math.round(amount * conv.factor) });
|
||||
return add(date, { [unit]: Math.round(amount) });
|
||||
};
|
||||
@@ -7,11 +7,17 @@ import {
|
||||
startOfMonth,
|
||||
isMonday,
|
||||
isToday,
|
||||
isSameYear,
|
||||
setHours,
|
||||
setMinutes,
|
||||
setSeconds,
|
||||
} from 'date-fns';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
generateDateSuggestions,
|
||||
parseDateFromText,
|
||||
} from 'dashboard/helper/snoozeDateParser';
|
||||
import { UNIT_MAP } from 'dashboard/helper/snoozeDateParser/tokenMaps';
|
||||
|
||||
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
||||
|
||||
@@ -33,65 +39,113 @@ export const findStartOfNextMonth = currentDate => {
|
||||
});
|
||||
};
|
||||
|
||||
export const findNextDay = currentDate => {
|
||||
return add(currentDate, { days: 1 });
|
||||
};
|
||||
export const findNextDay = currentDate => add(currentDate, { days: 1 });
|
||||
|
||||
export const setHoursToNine = date => {
|
||||
return setSeconds(setMinutes(setHours(date, 9), 0), 0);
|
||||
export const setHoursToNine = date =>
|
||||
setSeconds(setMinutes(setHours(date, 9), 0), 0);
|
||||
|
||||
const SNOOZE_RESOLVERS = {
|
||||
[SNOOZE_OPTIONS.AN_HOUR_FROM_NOW]: d => add(d, { hours: 1 }),
|
||||
[SNOOZE_OPTIONS.UNTIL_TOMORROW]: d => setHoursToNine(findNextDay(d)),
|
||||
[SNOOZE_OPTIONS.UNTIL_NEXT_WEEK]: d => setHoursToNine(findStartOfNextWeek(d)),
|
||||
[SNOOZE_OPTIONS.UNTIL_NEXT_MONTH]: d =>
|
||||
setHoursToNine(findStartOfNextMonth(d)),
|
||||
};
|
||||
|
||||
export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
|
||||
let parsedDate = null;
|
||||
if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) {
|
||||
parsedDate = add(currentDate, { hours: 1 });
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) {
|
||||
parsedDate = setHoursToNine(findNextDay(currentDate));
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) {
|
||||
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
|
||||
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
|
||||
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
|
||||
}
|
||||
|
||||
return parsedDate ? getUnixTime(parsedDate) : null;
|
||||
const resolve = SNOOZE_RESOLVERS[snoozeType];
|
||||
return resolve ? getUnixTime(resolve(currentDate)) : null;
|
||||
};
|
||||
|
||||
export const snoozedReopenTime = snoozedUntil => {
|
||||
if (!snoozedUntil) {
|
||||
return null;
|
||||
}
|
||||
if (!snoozedUntil) return null;
|
||||
const date = new Date(snoozedUntil);
|
||||
if (isToday(date)) return format(date, 'h.mmaaa');
|
||||
if (!isSameYear(date, new Date())) return format(date, 'd MMM yyyy, h.mmaaa');
|
||||
return format(date, 'd MMM, h.mmaaa');
|
||||
};
|
||||
|
||||
if (isToday(date)) {
|
||||
return format(date, 'h.mmaaa');
|
||||
export const snoozedReopenTimeToTimestamp = snoozedUntil =>
|
||||
snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
|
||||
|
||||
const formatSnoozeDate = (snoozeDate, currentDate, locale = 'en') => {
|
||||
const sameYear = isSameYear(snoozeDate, currentDate);
|
||||
try {
|
||||
const opts = {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
...(sameYear ? {} : { year: 'numeric' }),
|
||||
};
|
||||
return new Intl.DateTimeFormat(locale, opts).format(snoozeDate);
|
||||
} catch {
|
||||
return sameYear
|
||||
? format(snoozeDate, 'EEE, d MMM, h:mm a')
|
||||
: format(snoozeDate, 'EEE, d MMM yyyy, h:mm a');
|
||||
}
|
||||
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
|
||||
};
|
||||
|
||||
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
|
||||
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
|
||||
const expandUnit = (num, abbr) => {
|
||||
const full = UNIT_MAP[abbr];
|
||||
if (!full) return `${num} ${abbr}`;
|
||||
return parseFloat(num) === 1
|
||||
? `${num} ${full.replace(/s$/, '')}`
|
||||
: `${num} ${full}`;
|
||||
};
|
||||
|
||||
const capitalizeLabel = text => {
|
||||
const expanded = text
|
||||
.replace(
|
||||
/^(\d+)h(\d+)m(?:in)?$/i,
|
||||
(_, h, m) => `${expandUnit(h, 'h')} ${expandUnit(m, 'm')}`
|
||||
)
|
||||
.replace(/^(\d+(?:\.5)?)\s*([a-z]+)$/i, (_, n, u) =>
|
||||
UNIT_MAP[u.toLowerCase()] ? expandUnit(n, u.toLowerCase()) : `${n} ${u}`
|
||||
);
|
||||
return expanded.replace(/^\w/, c => c.toUpperCase());
|
||||
};
|
||||
|
||||
export const generateSnoozeSuggestions = (
|
||||
searchText,
|
||||
currentDate = new Date(),
|
||||
{ translations, locale } = {}
|
||||
) => {
|
||||
const suggestions = generateDateSuggestions(searchText, currentDate, {
|
||||
translations,
|
||||
locale,
|
||||
});
|
||||
return suggestions.map(s => ({
|
||||
date: s.date,
|
||||
unixTime: s.unix,
|
||||
query: s.query,
|
||||
label: capitalizeLabel(s.label),
|
||||
formattedDate: formatSnoozeDate(s.date, currentDate, locale),
|
||||
resolve: () => parseDateFromText(s.query)?.unix ?? s.unix,
|
||||
}));
|
||||
};
|
||||
|
||||
const UNIT_SHORT = {
|
||||
minute: 'm',
|
||||
minutes: 'm',
|
||||
hour: 'h',
|
||||
hours: 'h',
|
||||
day: 'd',
|
||||
days: 'd',
|
||||
month: 'mo',
|
||||
months: 'mo',
|
||||
year: 'y',
|
||||
years: 'y',
|
||||
};
|
||||
|
||||
export const shortenSnoozeTime = snoozedUntil => {
|
||||
if (!snoozedUntil) {
|
||||
return null;
|
||||
}
|
||||
const unitMap = {
|
||||
minutes: 'm',
|
||||
minute: 'm',
|
||||
hours: 'h',
|
||||
hour: 'h',
|
||||
days: 'd',
|
||||
day: 'd',
|
||||
months: 'mo',
|
||||
month: 'mo',
|
||||
years: 'y',
|
||||
year: 'y',
|
||||
};
|
||||
const shortenTime = snoozedUntil
|
||||
if (!snoozedUntil) return null;
|
||||
return snoozedUntil
|
||||
.replace(/^in\s+/i, '')
|
||||
.replace(
|
||||
/\s(minute|hour|day|month|year)s?\b/gi,
|
||||
(match, unit) => unitMap[unit.toLowerCase()] || match
|
||||
(match, unit) => UNIT_SHORT[unit.toLowerCase()] || match
|
||||
);
|
||||
|
||||
return shortenTime;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user