Merge branch 'develop' into feat/app-store-reviews
This commit is contained in:
@@ -106,4 +106,10 @@ export default {
|
||||
const urlData = endPoints('resetAccessToken');
|
||||
return axios.post(urlData.url);
|
||||
},
|
||||
getSessions() {
|
||||
return axios.get('/api/v1/profile/sessions');
|
||||
},
|
||||
revokeSession(id) {
|
||||
return axios.delete(`/api/v1/profile/sessions/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,6 +40,12 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${contactId}/conversations`, { params });
|
||||
}
|
||||
|
||||
getAttachments(contactId, page = 1) {
|
||||
return axios.get(`${this.url}/${contactId}/attachments`, {
|
||||
params: { page },
|
||||
});
|
||||
}
|
||||
|
||||
getContactableInboxes(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
|
||||
}
|
||||
|
||||
@@ -128,9 +128,14 @@ const closeMobileSidebar = () => {
|
||||
<!-- Desktop sidebar -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar container -->
|
||||
@@ -179,9 +184,14 @@ const closeMobileSidebar = () => {
|
||||
<div
|
||||
v-if="isContactSidebarOpen"
|
||||
id="contact-sidebar-content"
|
||||
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
||||
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
|
||||
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6">
|
||||
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
|
||||
<ContactCustomAttributeItem
|
||||
v-for="attribute in usedAttributes"
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ const contactConversations = computed(() =>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="contactConversations.length > 0"
|
||||
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||
>
|
||||
<ConversationCard
|
||||
v-for="conversation in contactConversations"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import {
|
||||
MEDIA_TYPES,
|
||||
NON_FILE_TYPES,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
|
||||
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const MEDIA_PEEK_LIMIT = 12;
|
||||
const FILES_PEEK_LIMIT = 6;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const attachmentsByContact = useMapGetter('contacts/getContactAttachments');
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const attachments = computed(() =>
|
||||
attachmentsByContact.value(route.params.contactId)
|
||||
);
|
||||
const isFetching = computed(() => uiFlags.value.isFetchingAttachments);
|
||||
|
||||
const hasContent = computed(() =>
|
||||
attachments.value.some(
|
||||
a => a.data_url && !NON_FILE_TYPES.includes(a.file_type)
|
||||
)
|
||||
);
|
||||
|
||||
const mediaAttachments = computed(() =>
|
||||
attachments.value
|
||||
.filter(a => MEDIA_TYPES.includes(a.file_type) && a.data_url)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
);
|
||||
|
||||
const showGallery = ref(false);
|
||||
const selectedAttachment = ref(null);
|
||||
|
||||
const onMediaSelect = attachment => {
|
||||
selectedAttachment.value = attachment;
|
||||
showGallery.value = true;
|
||||
};
|
||||
|
||||
const onFileSelect = attachment => {
|
||||
if (attachment.data_url) {
|
||||
window.open(attachment.data_url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const onJumpToMessage = attachment => {
|
||||
if (!attachment.conversation_id || !attachment.message_id) return;
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
conversation_id: attachment.conversation_id,
|
||||
},
|
||||
query: { messageId: attachment.message_id },
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('contacts/fetchAttachments', route.params.contactId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-6">
|
||||
<div v-if="isFetching" class="flex justify-center p-3">
|
||||
<Spinner class="size-5" />
|
||||
</div>
|
||||
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
|
||||
</p>
|
||||
<div v-else class="flex flex-col gap-5">
|
||||
<Media
|
||||
:attachments="attachments"
|
||||
:peek-limit="MEDIA_PEEK_LIMIT"
|
||||
show-jump-to-message
|
||||
@select="onMediaSelect"
|
||||
@jump-to-message="onJumpToMessage"
|
||||
/>
|
||||
<Files
|
||||
:attachments="attachments"
|
||||
:peek-limit="FILES_PEEK_LIMIT"
|
||||
show-jump-to-message
|
||||
@select="onFileSelect"
|
||||
@jump-to-message="onJumpToMessage"
|
||||
/>
|
||||
</div>
|
||||
<GalleryView
|
||||
v-if="showGallery && selectedAttachment"
|
||||
v-model:show="showGallery"
|
||||
:attachment="selectedAttachment"
|
||||
:all-attachments="mediaAttachments"
|
||||
auto-play
|
||||
@close="showGallery = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -103,7 +103,7 @@ const onMergeContacts = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-8 px-6 py-6">
|
||||
<div class="flex flex-col gap-8 px-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h4 class="text-base text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
|
||||
|
||||
@@ -55,7 +55,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 py-6">
|
||||
<div class="flex flex-col gap-6">
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
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';
|
||||
@@ -131,7 +132,7 @@ const statusText = computed(() => {
|
||||
|
||||
const categoryName = computed(() => {
|
||||
if (props.category?.slug) {
|
||||
return `${props.category.icon} ${props.category.name}`;
|
||||
return props.category.name;
|
||||
}
|
||||
return t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
|
||||
@@ -222,7 +223,15 @@ const handleClick = id => {
|
||||
{{ authorName || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="block text-sm whitespace-nowrap text-n-slate-11">
|
||||
<span
|
||||
class="flex items-center gap-1 text-sm whitespace-nowrap text-n-slate-11"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="category?.icon"
|
||||
:value="category.icon"
|
||||
:color="category.icon_color"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
{{ categoryName }}
|
||||
</span>
|
||||
<div
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToggle } from '@vueuse/core';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -20,6 +21,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -55,10 +60,6 @@ const categoryMenuItems = [
|
||||
},
|
||||
];
|
||||
|
||||
const categoryTitleWithIcon = computed(() => {
|
||||
return `${props.icon} ${props.title}`;
|
||||
});
|
||||
|
||||
const description = computed(() => {
|
||||
return props.description ? props.description : 'No description added';
|
||||
});
|
||||
@@ -83,10 +84,16 @@ const handleAction = ({ action, value }) => {
|
||||
<div class="flex justify-between w-full gap-2">
|
||||
<div class="flex items-center justify-start w-full min-w-0 gap-2">
|
||||
<span
|
||||
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
|
||||
class="flex items-center gap-1.5 text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
|
||||
@click="handleClick(slug)"
|
||||
>
|
||||
{{ categoryTitleWithIcon }}
|
||||
<EmojiIcon
|
||||
v-if="icon"
|
||||
:value="icon"
|
||||
:color="iconColor"
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate">{{ title }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
|
||||
|
||||
+16
-10
@@ -8,6 +8,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -107,10 +108,11 @@ const selectedCategory = computed(() => {
|
||||
const categoryList = computed(() => {
|
||||
return (
|
||||
categories.value
|
||||
.map(({ name, id, icon }) => ({
|
||||
.map(({ name, id, icon, icon_color: iconColor }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
emoji: icon,
|
||||
iconColor,
|
||||
isSelected: isNewArticle.value
|
||||
? id === (selectedCategoryId.value || selectedCategory.value?.id)
|
||||
: id === props.article?.category?.id,
|
||||
@@ -202,10 +204,6 @@ onMounted(() => {
|
||||
<div class="relative">
|
||||
<OnClickOutside @trigger="openCategoryList = false">
|
||||
<Button
|
||||
:label="
|
||||
selectedCategory?.name ||
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
|
||||
"
|
||||
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
@@ -213,12 +211,20 @@ onMounted(() => {
|
||||
@click="openCategoryList = !openCategoryList"
|
||||
>
|
||||
<span
|
||||
v-if="selectedCategory"
|
||||
class="text-sm text-n-slate-12 hover:text-n-slate-11"
|
||||
class="flex items-center gap-1.5 min-w-0 text-sm text-n-slate-12 hover:text-n-slate-11"
|
||||
>
|
||||
{{
|
||||
`${selectedCategory.icon || ''} ${selectedCategory.name || t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')}`
|
||||
}}
|
||||
<EmojiIcon
|
||||
v-if="selectedCategory?.icon"
|
||||
:value="selectedCategory.icon"
|
||||
:color="selectedCategory.icon_color"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate">
|
||||
{{
|
||||
selectedCategory?.name ||
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
|
||||
+21
-13
@@ -13,6 +13,7 @@ import {
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
@@ -63,18 +64,15 @@ const activeTabIndex = computed(() => {
|
||||
return tabs.value.findIndex(tab => tab.value === tabParam);
|
||||
});
|
||||
|
||||
const activeCategoryName = computed(() => {
|
||||
const activeCategory = props.categories.find(
|
||||
category => category.slug === route.params.categorySlug
|
||||
);
|
||||
const activeCategory = computed(() =>
|
||||
props.categories.find(category => category.slug === route.params.categorySlug)
|
||||
);
|
||||
|
||||
if (activeCategory) {
|
||||
const { icon, name } = activeCategory;
|
||||
return `${icon} ${name}`;
|
||||
}
|
||||
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL');
|
||||
});
|
||||
const activeCategoryName = computed(
|
||||
() =>
|
||||
activeCategory.value?.name ||
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL')
|
||||
);
|
||||
|
||||
const activeLocaleName = computed(() => {
|
||||
return props.allowedLocales.find(
|
||||
@@ -94,6 +92,7 @@ const categoryMenuItems = computed(() => {
|
||||
value: category.slug,
|
||||
action: 'filter',
|
||||
emoji: category.icon,
|
||||
iconColor: category.icon_color,
|
||||
}));
|
||||
|
||||
const hasCategorySlug = !!route.params.categorySlug;
|
||||
@@ -167,14 +166,23 @@ const handleTabChange = value => {
|
||||
<div v-if="hasCategoryMenuItems" class="relative group">
|
||||
<OnClickOutside @trigger="isCategoryMenuOpen = false">
|
||||
<Button
|
||||
:label="activeCategoryName"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
|
||||
/>
|
||||
>
|
||||
<span class="flex items-center gap-1.5 min-w-0">
|
||||
<EmojiIcon
|
||||
v-if="activeCategory?.icon"
|
||||
:value="activeCategory.icon"
|
||||
:color="activeCategory.icon_color"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate">{{ activeCategoryName }}</span>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isCategoryMenuOpen"
|
||||
|
||||
@@ -230,6 +230,7 @@ const categoryMenuItems = computed(() =>
|
||||
value: category.id,
|
||||
action: 'move',
|
||||
emoji: category.icon,
|
||||
iconColor: category.icon_color,
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const handleCategory = async formData => {
|
||||
const { id, name, slug, icon, description, locale } = formData;
|
||||
const categoryData = { name, icon, slug, description };
|
||||
const { id, name, slug, icon, iconColor, description, locale } = formData;
|
||||
const categoryData = { name, icon, icon_color: iconColor, slug, description };
|
||||
|
||||
if (props.mode === 'create') {
|
||||
categoryData.locale = locale;
|
||||
|
||||
+44
-13
@@ -18,6 +18,7 @@ import { convertToCategorySlug } from 'dashboard/helper/commons.js';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@@ -49,8 +50,9 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const EmojiInput = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiInput.vue')
|
||||
const EmojiIconPicker = defineAsyncComponent(
|
||||
() =>
|
||||
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -72,6 +74,7 @@ const state = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
icon: '',
|
||||
iconColor: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
locale: '',
|
||||
@@ -109,8 +112,19 @@ const slugHelpText = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
state.icon = emoji;
|
||||
const onSelectIcon = ({ type, value, color }) => {
|
||||
state.icon = value;
|
||||
state.iconColor = type === 'icon' ? color : '';
|
||||
isEmojiPickerOpen.value = false;
|
||||
};
|
||||
|
||||
const onColorChange = color => {
|
||||
state.iconColor = color;
|
||||
};
|
||||
|
||||
const onRemoveIcon = () => {
|
||||
state.icon = '';
|
||||
state.iconColor = '';
|
||||
isEmojiPickerOpen.value = false;
|
||||
};
|
||||
|
||||
@@ -139,7 +153,14 @@ watch(
|
||||
newCategory => {
|
||||
if (props.mode === 'edit' && newCategory) {
|
||||
const { id, name, icon, slug, description } = newCategory;
|
||||
Object.assign(state, { id, name, icon, slug, description });
|
||||
Object.assign(state, {
|
||||
id,
|
||||
name,
|
||||
icon,
|
||||
iconColor: newCategory.icon_color || '',
|
||||
slug,
|
||||
description,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -197,19 +218,29 @@ defineExpose({ state, isSubmitDisabled });
|
||||
<template #prefix>
|
||||
<OnClickOutside @trigger="isEmojiPickerOpen = false">
|
||||
<Button
|
||||
:label="state.icon"
|
||||
color="slate"
|
||||
size="sm"
|
||||
type="button"
|
||||
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
|
||||
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
class="!h-[2.38rem] !w-[2.375rem] !p-0 absolute top-[2rem] start-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
||||
/>
|
||||
<EmojiInput
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="state.icon"
|
||||
:value="state.icon"
|
||||
:color="state.iconColor"
|
||||
class="size-5 text-xl !leading-5"
|
||||
/>
|
||||
<span v-else class="i-lucide-smile-plus size-4" />
|
||||
</Button>
|
||||
<EmojiIconPicker
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="left-0 top-16"
|
||||
:value="state.icon"
|
||||
:color="state.iconColor"
|
||||
show-remove-button
|
||||
:on-click="onClickInsertEmoji"
|
||||
@select="onSelectIcon"
|
||||
@color-change="onColorChange"
|
||||
@remove="onRemoveIcon"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</template>
|
||||
|
||||
+1
@@ -106,6 +106,7 @@ const breadcrumbItems = computed(() => {
|
||||
}
|
||||
),
|
||||
emoji: selectedCategoryEmoji.value,
|
||||
iconColor: selectedCategory.value?.icon_color,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
|
||||
+1
@@ -68,6 +68,7 @@ watch(
|
||||
:id="element.id"
|
||||
:title="element.name"
|
||||
:icon="element.icon"
|
||||
:icon-color="element.icon_color"
|
||||
:description="element.description"
|
||||
:articles-count="element.meta?.articles_count || 0"
|
||||
:slug="element.slug"
|
||||
|
||||
+2
-2
@@ -52,8 +52,8 @@ const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
|
||||
const onUpdateCategory = async () => {
|
||||
if (!categoryFormRef.value) return;
|
||||
const { state } = categoryFormRef.value;
|
||||
const { id, name, slug, icon, description } = state;
|
||||
const categoryData = { name, icon, slug, description };
|
||||
const { id, name, slug, icon, iconColor, description } = state;
|
||||
const categoryData = { name, icon, icon_color: iconColor, slug, description };
|
||||
categoryData.id = id;
|
||||
|
||||
try {
|
||||
|
||||
+6
-4
@@ -53,8 +53,9 @@ const generateUid = () => {
|
||||
const uploadAttachment = ref(null);
|
||||
const isEmojiPickerOpen = ref(false);
|
||||
|
||||
const EmojiInput = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiInput.vue')
|
||||
const EmojiIconPicker = defineAsyncComponent(
|
||||
() =>
|
||||
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -215,10 +216,11 @@ useEventListener(document, 'paste', onPaste);
|
||||
class="!w-10"
|
||||
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
||||
/>
|
||||
<EmojiInput
|
||||
<EmojiIconPicker
|
||||
v-if="isEmojiPickerOpen"
|
||||
mode="emoji"
|
||||
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
|
||||
:on-click="onClickInsertEmoji"
|
||||
@select="onClickInsertEmoji($event.value)"
|
||||
/>
|
||||
</div>
|
||||
<FileUpload
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { formatBytes } from 'shared/helpers/FileHelper';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
import {
|
||||
MEDIA_TYPES,
|
||||
NON_FILE_TYPES,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
|
||||
import FileIcon from 'next/icon/FileIcon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachments: { type: Array, default: () => [] },
|
||||
peekLimit: { type: Number, default: 0 },
|
||||
showJumpToMessage: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'jumpToMessage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const fileAttachments = computed(() =>
|
||||
[...props.attachments]
|
||||
.filter(
|
||||
a =>
|
||||
a.data_url &&
|
||||
!MEDIA_TYPES.includes(a.file_type) &&
|
||||
!NON_FILE_TYPES.includes(a.file_type)
|
||||
)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
);
|
||||
|
||||
const showAll = ref(false);
|
||||
const downloadingId = ref(null);
|
||||
|
||||
const isPeekable = computed(() => props.peekLimit > 0);
|
||||
|
||||
const visibleFiles = computed(() => {
|
||||
if (!isPeekable.value || showAll.value) return fileAttachments.value;
|
||||
return fileAttachments.value.slice(0, props.peekLimit);
|
||||
});
|
||||
|
||||
const fileNameFromUrl = url => {
|
||||
if (!url) return '';
|
||||
const name = url.split('/').pop();
|
||||
return name ? decodeURIComponent(name) : '';
|
||||
};
|
||||
|
||||
const displayName = attachment =>
|
||||
fileNameFromUrl(attachment.data_url) ||
|
||||
t('CONVERSATION_SIDEBAR.SHARED_FILES.UNTITLED_FILE');
|
||||
|
||||
const displaySize = attachment => {
|
||||
if (attachment.file_size) return formatBytes(attachment.file_size);
|
||||
if (attachment.extension) return attachment.extension.toUpperCase();
|
||||
return '—';
|
||||
};
|
||||
|
||||
const displayTime = attachment => {
|
||||
if (!attachment.created_at) return '';
|
||||
return shortTimestamp(dynamicTime(attachment.created_at), true);
|
||||
};
|
||||
|
||||
const onActivate = attachment => emit('select', attachment);
|
||||
|
||||
const onDownloadFile = async attachment => {
|
||||
const { id, file_type: type, data_url: url, extension } = attachment;
|
||||
try {
|
||||
downloadingId.value = id;
|
||||
await downloadFile({ url, type, extension });
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
|
||||
} finally {
|
||||
downloadingId.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="fileAttachments.length" class="flex flex-col gap-2.5">
|
||||
<header class="flex items-center justify-between px-0.5">
|
||||
<h4
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
|
||||
<span
|
||||
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
|
||||
>
|
||||
{{ fileAttachments.length }}
|
||||
</span>
|
||||
</h4>
|
||||
<NextButton
|
||||
v-if="isPeekable && fileAttachments.length > peekLimit"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
trailing-icon
|
||||
:icon="showAll ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'"
|
||||
:label="
|
||||
showAll
|
||||
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
|
||||
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
|
||||
"
|
||||
@click="showAll = !showAll"
|
||||
/>
|
||||
</header>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
<li
|
||||
v-for="attachment in visibleFiles"
|
||||
:key="attachment.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="flex items-center gap-3 px-2 py-2 transition-colors rounded-lg cursor-pointer hover:bg-n-slate-3 group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
|
||||
@click="onActivate(attachment)"
|
||||
@keydown.enter="onActivate(attachment)"
|
||||
@keydown.space.prevent="onActivate(attachment)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-gradient-to-br from-n-slate-3 to-n-slate-4 ring-1 ring-inset ring-n-slate-4/40"
|
||||
>
|
||||
<FileIcon
|
||||
:file-type="attachment.extension?.toLowerCase() || ''"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate text-n-slate-12 mb-1">
|
||||
{{ displayName(attachment) }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ displaySize(attachment) }}
|
||||
<template v-if="displayTime(attachment)">
|
||||
· {{ displayTime(attachment) }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<NextButton
|
||||
v-if="showJumpToMessage && attachment.message_id"
|
||||
v-tooltip.top="{
|
||||
content: t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE'),
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-external-link"
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE')"
|
||||
@click.stop="emit('jumpToMessage', attachment)"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-download"
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
:is-loading="downloadingId === attachment.id"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
|
||||
@click.stop="onDownloadFile(attachment)"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import {
|
||||
dynamicTime,
|
||||
formatDuration,
|
||||
shortTimestamp,
|
||||
} from 'shared/helpers/timeHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
import {
|
||||
ATTACHMENT_TYPES,
|
||||
MEDIA_TYPES,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachments: { type: Array, default: () => [] },
|
||||
peekLimit: { type: Number, default: 0 },
|
||||
showJumpToMessage: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'jumpToMessage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const mediaAttachments = computed(() =>
|
||||
[...props.attachments]
|
||||
.filter(a => a.data_url && MEDIA_TYPES.includes(a.file_type))
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
);
|
||||
|
||||
const showAll = ref(false);
|
||||
const failedThumbs = ref(new Set());
|
||||
const failedPreviews = ref(new Set());
|
||||
const durations = ref({});
|
||||
const downloadingId = ref(null);
|
||||
|
||||
const isPeekable = computed(() => props.peekLimit > 0);
|
||||
|
||||
const visibleMedia = computed(() => {
|
||||
if (!isPeekable.value || showAll.value) return mediaAttachments.value;
|
||||
return mediaAttachments.value.slice(0, props.peekLimit);
|
||||
});
|
||||
|
||||
const overflow = computed(() => {
|
||||
if (!isPeekable.value) return 0;
|
||||
const total = mediaAttachments.value.length;
|
||||
return total > props.peekLimit ? total - (props.peekLimit - 1) : 0;
|
||||
});
|
||||
|
||||
const fileNameFromUrl = url => {
|
||||
if (!url) return '';
|
||||
const name = url.split('/').pop();
|
||||
return name ? decodeURIComponent(name) : '';
|
||||
};
|
||||
|
||||
const isVideoType = type =>
|
||||
[ATTACHMENT_TYPES.VIDEO, ATTACHMENT_TYPES.IG_REEL].includes(type);
|
||||
const isAudioType = type => type === ATTACHMENT_TYPES.AUDIO;
|
||||
const isPlayableType = type => isVideoType(type) || isAudioType(type);
|
||||
|
||||
const imagePreviewSrc = ({
|
||||
id,
|
||||
file_type: type,
|
||||
thumb_url: thumbUrl,
|
||||
data_url: dataUrl,
|
||||
}) => {
|
||||
const canUseThumb = thumbUrl && !failedThumbs.value.has(id);
|
||||
if (type === ATTACHMENT_TYPES.IMAGE) return canUseThumb ? thumbUrl : dataUrl;
|
||||
if (isVideoType(type)) return canUseThumb ? thumbUrl : null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const onPreviewError = ({
|
||||
id,
|
||||
file_type: type,
|
||||
thumb_url: thumbUrl,
|
||||
data_url: dataUrl,
|
||||
}) => {
|
||||
const canRetryWithFull = thumbUrl && !failedThumbs.value.has(id) && dataUrl;
|
||||
if (
|
||||
canRetryWithFull &&
|
||||
(type === ATTACHMENT_TYPES.IMAGE || isVideoType(type))
|
||||
) {
|
||||
failedThumbs.value.add(id);
|
||||
return;
|
||||
}
|
||||
failedPreviews.value.add(id);
|
||||
};
|
||||
|
||||
const hasPreview = attachment =>
|
||||
!!imagePreviewSrc(attachment) && !failedPreviews.value.has(attachment.id);
|
||||
|
||||
const hasVideoPreview = attachment =>
|
||||
isVideoType(attachment.file_type) &&
|
||||
attachment.data_url &&
|
||||
!failedPreviews.value.has(attachment.id);
|
||||
|
||||
const fallbackIcon = type => {
|
||||
if (isAudioType(type)) return 'i-lucide-music';
|
||||
if (isVideoType(type)) return 'i-lucide-video';
|
||||
return 'i-lucide-image';
|
||||
};
|
||||
|
||||
const onLoadedMetadata = (attachment, event) => {
|
||||
const seconds = event.target?.duration;
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
durations.value[attachment.id] = seconds;
|
||||
}
|
||||
};
|
||||
|
||||
const displayDuration = attachment => {
|
||||
const seconds = durations.value[attachment.id];
|
||||
return seconds ? formatDuration(Math.round(seconds)) : '';
|
||||
};
|
||||
|
||||
const displayTime = attachment => {
|
||||
if (!attachment.created_at) return '';
|
||||
return shortTimestamp(dynamicTime(attachment.created_at), true);
|
||||
};
|
||||
|
||||
const isOverflowTile = index =>
|
||||
isPeekable.value &&
|
||||
!showAll.value &&
|
||||
overflow.value > 0 &&
|
||||
index === props.peekLimit - 1;
|
||||
|
||||
const onTileActivate = (attachment, index) => {
|
||||
if (isOverflowTile(index)) {
|
||||
showAll.value = true;
|
||||
return;
|
||||
}
|
||||
emit('select', attachment);
|
||||
};
|
||||
|
||||
const onDownloadFile = async attachment => {
|
||||
const { id, file_type: type, data_url: url, extension } = attachment;
|
||||
try {
|
||||
downloadingId.value = id;
|
||||
await downloadFile({ url, type, extension });
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
|
||||
} finally {
|
||||
downloadingId.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="mediaAttachments.length" class="flex flex-col gap-2.5">
|
||||
<header class="flex items-center justify-between px-0.5">
|
||||
<h4
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
|
||||
<span
|
||||
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
|
||||
>
|
||||
{{ mediaAttachments.length }}
|
||||
</span>
|
||||
</h4>
|
||||
<NextButton
|
||||
v-if="overflow > 0"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
trailing-icon
|
||||
:icon="showAll ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'"
|
||||
:label="
|
||||
showAll
|
||||
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
|
||||
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
|
||||
"
|
||||
@click="showAll = !showAll"
|
||||
/>
|
||||
</header>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="(attachment, index) in visibleMedia"
|
||||
:key="attachment.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="relative w-full overflow-hidden transition-all duration-200 rounded-lg cursor-pointer aspect-square bg-n-slate-3 shadow-sm hover:shadow-md hover:-translate-y-px group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
|
||||
@click="onTileActivate(attachment, index)"
|
||||
@keydown.enter="onTileActivate(attachment, index)"
|
||||
@keydown.space.prevent="onTileActivate(attachment, index)"
|
||||
>
|
||||
<template v-if="!isOverflowTile(index)">
|
||||
<img
|
||||
v-if="hasPreview(attachment)"
|
||||
:src="imagePreviewSrc(attachment)"
|
||||
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
:alt="fileNameFromUrl(attachment.data_url)"
|
||||
@error="onPreviewError(attachment)"
|
||||
/>
|
||||
<video
|
||||
v-else-if="hasVideoPreview(attachment)"
|
||||
:src="`${attachment.data_url}#t=0.1`"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110 pointer-events-none"
|
||||
@loadedmetadata="onLoadedMetadata(attachment, $event)"
|
||||
@error="onPreviewError(attachment)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full h-full bg-gradient-to-br from-n-slate-3 to-n-slate-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="fallbackIcon(attachment.file_type)"
|
||||
class="size-6 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
v-if="isAudioType(attachment.file_type) && attachment.data_url"
|
||||
:src="attachment.data_url"
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
@loadedmetadata="onLoadedMetadata(attachment, $event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 transition-opacity duration-200 opacity-0 pointer-events-none group-hover:opacity-100 bg-gradient-to-t from-black/40 via-transparent to-transparent"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="hasVideoPreview(attachment)"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none bg-gradient-to-t from-black/30 via-transparent to-transparent"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full size-7 bg-white/95 shadow-md"
|
||||
>
|
||||
<Icon icon="i-lucide-play" class="ms-0.5 size-3.5 text-n-black" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="
|
||||
isPlayableType(attachment.file_type) &&
|
||||
displayDuration(attachment)
|
||||
"
|
||||
class="absolute text-xxs font-medium tabular-nums transition-opacity bottom-1.5 ltr:right-1.5 rtl:left-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-0"
|
||||
>
|
||||
{{ displayDuration(attachment) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="displayTime(attachment)"
|
||||
class="absolute text-xxs font-medium transition-opacity opacity-0 bottom-1.5 ltr:left-1.5 rtl:right-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-100"
|
||||
>
|
||||
{{ displayTime(attachment) }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 bottom-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white disabled:opacity-50"
|
||||
:disabled="downloadingId === attachment.id"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
|
||||
@click.stop="onDownloadFile(attachment)"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
<Icon icon="i-lucide-download" class="size-3 text-n-black" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showJumpToMessage && attachment.message_id"
|
||||
v-tooltip.top="{
|
||||
content: t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE'),
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
type="button"
|
||||
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 top-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE')"
|
||||
@click.stop="emit('jumpToMessage', attachment)"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
<Icon icon="i-lucide-external-link" class="size-3 text-n-black" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="isOverflowTile(index)"
|
||||
class="absolute inset-0 flex items-center justify-center bg-n-slate-5"
|
||||
>
|
||||
<span class="text-base font-semibold text-n-slate-12">
|
||||
{{
|
||||
t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
|
||||
count: overflow,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
@@ -47,8 +48,17 @@ const onClick = (item, index) => {
|
||||
</button>
|
||||
|
||||
<!-- The last breadcrumb item is plain text -->
|
||||
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
|
||||
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1 text-sm truncate min-w-0"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="item.emoji"
|
||||
:value="item.emoji"
|
||||
:color="item.iconColor"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate text-n-slate-12">{{ item.label }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
@@ -162,7 +163,7 @@ onMounted(() => {
|
||||
>
|
||||
<p
|
||||
v-if="section.title"
|
||||
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm"
|
||||
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm truncate min-w-0"
|
||||
>
|
||||
{{ section.title }}
|
||||
</p>
|
||||
@@ -207,9 +208,12 @@ onMounted(() => {
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
</slot>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{
|
||||
item.emoji
|
||||
}}</span>
|
||||
<EmojiIcon
|
||||
v-if="item.emoji"
|
||||
:value="item.emoji"
|
||||
:color="item.iconColor"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<slot name="label" :item="item">
|
||||
<span
|
||||
v-if="item.label"
|
||||
@@ -260,7 +264,12 @@ onMounted(() => {
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
</slot>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<EmojiIcon
|
||||
v-if="item.emoji"
|
||||
:value="item.emoji"
|
||||
:color="item.iconColor"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<slot name="label" :item="item">
|
||||
<span
|
||||
v-if="item.label"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ICON_COLORS } from './constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
const selectedColor = defineModel({ type: String, default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-1 px-0.5 mx-2 py-1 mb-px">
|
||||
<button
|
||||
v-for="color in ICON_COLORS"
|
||||
:key="color.value"
|
||||
v-tooltip.top="t(`EMOJI_ICON_PICKER.COLORS.${color.name}`)"
|
||||
type="button"
|
||||
class="flex items-center justify-center flex-shrink-0 transition-all rounded-full size-5 ring-offset-2 ring-offset-n-surface-2 hover:ring-2"
|
||||
:class="{ 'ring-2': selectedColor === color.value }"
|
||||
:style="{ backgroundColor: color.value, '--tw-ring-color': color.value }"
|
||||
@click="selectedColor = color.value"
|
||||
>
|
||||
<span
|
||||
v-if="selectedColor === color.value"
|
||||
class="text-white i-lucide-check size-3"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { isIconValue, iconClassFor } from './constants';
|
||||
|
||||
// Renders a saved picker value: a color-tinted icon or an emoji.
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const isIcon = computed(() => isIconValue(props.value));
|
||||
const iconClass = computed(() => iconClassFor(props.value));
|
||||
const iconStyle = computed(() =>
|
||||
props.color ? { color: props.color } : undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center justify-center leading-none">
|
||||
<Icon
|
||||
v-if="isIcon"
|
||||
:icon="iconClass"
|
||||
class="size-full"
|
||||
:style="iconStyle"
|
||||
/>
|
||||
<span v-else v-dompurify-html="value" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ColorPalette from './ColorPalette.vue';
|
||||
import { CURATED_ICONS } from './icons';
|
||||
import {
|
||||
DEFAULT_ICON_COLOR,
|
||||
ICON_STYLE,
|
||||
PICKER_MODE,
|
||||
PICKER_TAB,
|
||||
isIconValue,
|
||||
} from './constants';
|
||||
import {
|
||||
buildEmojiSections,
|
||||
getEmojiTint,
|
||||
getRecentEmojis,
|
||||
addRecentEmoji,
|
||||
} from 'shared/components/emoji/pickerHelper';
|
||||
|
||||
const props = defineProps({
|
||||
// 'both' shows Icons + Emojis tabs; 'emoji' shows only the emoji panel.
|
||||
mode: {
|
||||
type: String,
|
||||
default: PICKER_MODE.BOTH,
|
||||
validator: value => Object.values(PICKER_MODE).includes(value),
|
||||
},
|
||||
// Saved value: an emoji char or an icon name (e.g. "rocket-line").
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// Current stored icon color (hex), used to preselect a swatch.
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showRemoveButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'remove', 'colorChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showTabs = computed(() => props.mode === PICKER_MODE.BOTH);
|
||||
const showHeader = computed(
|
||||
() => showTabs.value || (props.showRemoveButton && props.value)
|
||||
);
|
||||
|
||||
const activeTab = ref(
|
||||
props.value && !isIconValue(props.value)
|
||||
? PICKER_TAB.EMOJIS
|
||||
: PICKER_TAB.ICONS
|
||||
);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
id: PICKER_TAB.ICONS,
|
||||
label: t('EMOJI_ICON_PICKER.TABS.ICONS'),
|
||||
icon: 'i-lucide-shapes',
|
||||
},
|
||||
{
|
||||
id: PICKER_TAB.EMOJIS,
|
||||
label: t('EMOJI_ICON_PICKER.TABS.EMOJIS'),
|
||||
icon: 'i-lucide-smile',
|
||||
},
|
||||
]);
|
||||
|
||||
// Icons
|
||||
const iconSearch = ref('');
|
||||
const selectedColor = ref(props.color || DEFAULT_ICON_COLOR);
|
||||
// Preselect the outline/filled toggle from the saved icon's style suffix.
|
||||
const iconStyle = ref(
|
||||
props.value.endsWith(`-${ICON_STYLE.FILL}`)
|
||||
? ICON_STYLE.FILL
|
||||
: ICON_STYLE.LINE
|
||||
);
|
||||
|
||||
const filteredIcons = computed(() => {
|
||||
const term = iconSearch.value.trim().toLowerCase();
|
||||
if (!term) return CURATED_ICONS;
|
||||
return CURATED_ICONS.filter(
|
||||
icon => icon.name.includes(term) || icon.keywords.includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleIconStyle = () => {
|
||||
iconStyle.value =
|
||||
iconStyle.value === ICON_STYLE.LINE ? ICON_STYLE.FILL : ICON_STYLE.LINE;
|
||||
};
|
||||
|
||||
// Each icon button tints its hover with the selected color (low opacity).
|
||||
const iconHoverStyle = computed(() => ({
|
||||
'--ep-tint': `${selectedColor.value}24`,
|
||||
}));
|
||||
|
||||
const selectIcon = icon => {
|
||||
// Store the name with its style suffix, e.g. "rocket-fill".
|
||||
const value = `${icon.name}-${iconStyle.value}`;
|
||||
emit('select', {
|
||||
type: 'icon',
|
||||
value,
|
||||
icon: value,
|
||||
color: selectedColor.value,
|
||||
});
|
||||
};
|
||||
|
||||
watch(selectedColor, color => {
|
||||
if (isIconValue(props.value)) emit('colorChange', color);
|
||||
});
|
||||
|
||||
// Emojis
|
||||
const emojiSearch = ref('');
|
||||
const recentEmojis = ref([]);
|
||||
|
||||
onMounted(() => {
|
||||
recentEmojis.value = getRecentEmojis();
|
||||
});
|
||||
|
||||
const emojiSections = computed(() =>
|
||||
buildEmojiSections(
|
||||
emojiSearch.value,
|
||||
recentEmojis.value,
|
||||
t('EMOJI_ICON_PICKER.FREQUENTLY_USED')
|
||||
)
|
||||
);
|
||||
|
||||
// Tints an emoji button with a light shade of the emoji's own color on hover.
|
||||
const applyEmojiTint = (event, emoji) => {
|
||||
event.currentTarget.style.setProperty('--ep-tint', getEmojiTint(emoji));
|
||||
};
|
||||
|
||||
const selectEmoji = emoji => {
|
||||
recentEmojis.value = addRecentEmoji(emoji);
|
||||
emit('select', { type: 'emoji', value: emoji.emoji, emoji: emoji.emoji });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="dialog"
|
||||
class="absolute z-20 flex flex-col overflow-hidden shadow-xl w-[22rem] bg-n-surface-2 backdrop-blur-[100px] rounded-2xl outline outline-1 outline-n-weak dark:outline-n-strong/50"
|
||||
>
|
||||
<div v-if="showHeader" class="flex items-center justify-between gap-2 p-2">
|
||||
<div v-if="showTabs" class="flex gap-0.5 p-0.5 rounded-lg bg-n-alpha-1">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
v-tooltip.top="tab.label"
|
||||
type="button"
|
||||
class="flex items-center justify-center !p-0 transition-all rounded-md size-7 active:scale-[0.92]"
|
||||
:class="
|
||||
activeTab === tab.id
|
||||
? 'bg-n-surface-1 shadow-sm text-n-slate-12'
|
||||
: 'text-n-slate-10 hover:text-n-slate-12'
|
||||
"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<span :class="tab.icon" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
v-if="showRemoveButton && value"
|
||||
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-eraser"
|
||||
class="flex-shrink-0"
|
||||
@click="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Icons panel -->
|
||||
<div
|
||||
v-if="showTabs && activeTab === PICKER_TAB.ICONS"
|
||||
class="flex flex-col gap-1.5"
|
||||
:style="iconHoverStyle"
|
||||
>
|
||||
<ColorPalette v-model="selectedColor" />
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<Input
|
||||
v-model="iconSearch"
|
||||
size="md"
|
||||
class="flex-1"
|
||||
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_ICON')"
|
||||
custom-input-class="!ps-9 !bg-transparent"
|
||||
autofocus
|
||||
>
|
||||
<template #prefix>
|
||||
<span
|
||||
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
<button
|
||||
v-tooltip.top="
|
||||
iconStyle === ICON_STYLE.FILL
|
||||
? t('EMOJI_ICON_PICKER.STYLE.FILLED')
|
||||
: t('EMOJI_ICON_PICKER.STYLE.OUTLINE')
|
||||
"
|
||||
type="button"
|
||||
class="flex items-center justify-center flex-shrink-0 transition-all rounded-lg !p-2 size-9 text-n-slate-11 hover:bg-[var(--ep-tint)] active:scale-[0.92]"
|
||||
@click="toggleIconStyle"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
iconStyle === ICON_STYLE.FILL
|
||||
? 'i-ri-contrast-2-fill'
|
||||
: 'i-ri-contrast-2-line'
|
||||
"
|
||||
class="size-[1.2rem]"
|
||||
:style="{ color: selectedColor }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredIcons.length"
|
||||
class="grid grid-cols-10 h-52 gap-0.5 overflow-y-auto no-scrollbar content-start px-2 pb-2"
|
||||
>
|
||||
<button
|
||||
v-for="icon in filteredIcons"
|
||||
:key="icon.name"
|
||||
type="button"
|
||||
:title="icon.name"
|
||||
class="flex items-center justify-center !p-0 size-8 transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
|
||||
@click="selectIcon(icon)"
|
||||
>
|
||||
<span
|
||||
:class="icon[iconStyle]"
|
||||
class="size-[1.125rem]"
|
||||
:style="{ color: selectedColor }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center gap-2 h-52 text-n-slate-10"
|
||||
>
|
||||
<span class="i-lucide-search-x size-7" />
|
||||
<span class="text-sm font-medium">
|
||||
{{ t('EMOJI_ICON_PICKER.NO_ICON') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emojis panel -->
|
||||
<div
|
||||
v-if="!showTabs || activeTab === PICKER_TAB.EMOJIS"
|
||||
class="flex flex-col gap-1.5"
|
||||
:class="{ 'pt-2': !showHeader }"
|
||||
>
|
||||
<div class="px-2">
|
||||
<Input
|
||||
v-model="emojiSearch"
|
||||
size="md"
|
||||
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_EMOJI')"
|
||||
custom-input-class="!ps-9 !bg-transparent"
|
||||
autofocus
|
||||
>
|
||||
<template #prefix>
|
||||
<span
|
||||
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div
|
||||
v-if="emojiSections.length"
|
||||
class="h-60 overflow-y-auto px-2 no-scrollbar pb-2"
|
||||
>
|
||||
<Virtualizer v-slot="{ item: section }" :data="emojiSections">
|
||||
<h5
|
||||
class="px-1 pt-2 pb-1 m-0 text-xs font-medium tracking-wide uppercase text-n-slate-10"
|
||||
>
|
||||
{{ section.name }}
|
||||
</h5>
|
||||
<div class="grid grid-cols-10 gap-0.5">
|
||||
<button
|
||||
v-for="emoji in section.emojis"
|
||||
:key="`${section.name}-${emoji.slug}`"
|
||||
type="button"
|
||||
:title="emoji.name"
|
||||
class="flex items-center justify-center !p-0 size-8 w-full max-w-[2rem] text-xl transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
|
||||
@mouseenter="applyEmojiTint($event, emoji.emoji)"
|
||||
@click="selectEmoji(emoji)"
|
||||
>
|
||||
{{ emoji.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</Virtualizer>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center gap-2 h-60 text-n-slate-10"
|
||||
>
|
||||
<span class="i-lucide-smile-plus size-7" />
|
||||
<span class="text-sm font-medium">
|
||||
{{ t('EMOJI_ICON_PICKER.NO_EMOJI') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
// Prefix that turns a stored icon name (e.g. "rocket-line") into a class.
|
||||
// Swapping icon libraries means changing this and the curated set only.
|
||||
export const ICON_PREFIX = 'i-ri-';
|
||||
|
||||
export const ICON_STYLE = {
|
||||
LINE: 'line',
|
||||
FILL: 'fill',
|
||||
};
|
||||
|
||||
// Icon values are ascii names (e.g. "rocket-line"); emoji are non-ascii.
|
||||
export const isIconValue = value =>
|
||||
typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value);
|
||||
|
||||
export const iconClassFor = value =>
|
||||
value.startsWith(ICON_PREFIX) ? value : `${ICON_PREFIX}${value}`;
|
||||
|
||||
export const ICON_COLORS = [
|
||||
{ name: 'SLATE', value: '#64748B' },
|
||||
{ name: 'RED', value: '#EF4444' },
|
||||
{ name: 'ORANGE', value: '#F97316' },
|
||||
{ name: 'AMBER', value: '#F59E0B' },
|
||||
{ name: 'GREEN', value: '#22C55E' },
|
||||
{ name: 'TEAL', value: '#14B8A6' },
|
||||
{ name: 'BLUE', value: '#3B82F6' },
|
||||
{ name: 'INDIGO', value: '#6366F1' },
|
||||
{ name: 'VIOLET', value: '#8B5CF6' },
|
||||
{ name: 'PINK', value: '#EC4899' },
|
||||
];
|
||||
|
||||
export const DEFAULT_ICON_COLOR = '#3B82F6';
|
||||
|
||||
export const PICKER_MODE = {
|
||||
BOTH: 'both',
|
||||
EMOJI: 'emoji',
|
||||
};
|
||||
|
||||
export const PICKER_TAB = {
|
||||
ICONS: 'icons',
|
||||
EMOJIS: 'emojis',
|
||||
};
|
||||
@@ -0,0 +1,983 @@
|
||||
// Curated Remix (ri) icons, each with a `line` and `fill` class. The class
|
||||
// literals are kept here so Tailwind generates them. { name, keywords, line, fill }
|
||||
|
||||
export const CURATED_ICONS = [
|
||||
{
|
||||
name: 'home',
|
||||
keywords: 'home house main',
|
||||
line: 'i-ri-home-line',
|
||||
fill: 'i-ri-home-fill',
|
||||
},
|
||||
{
|
||||
name: 'star',
|
||||
keywords: 'favorite star',
|
||||
line: 'i-ri-star-line',
|
||||
fill: 'i-ri-star-fill',
|
||||
},
|
||||
{
|
||||
name: 'heart',
|
||||
keywords: 'like love',
|
||||
line: 'i-ri-heart-line',
|
||||
fill: 'i-ri-heart-fill',
|
||||
},
|
||||
{
|
||||
name: 'bookmark',
|
||||
keywords: 'save',
|
||||
line: 'i-ri-bookmark-line',
|
||||
fill: 'i-ri-bookmark-fill',
|
||||
},
|
||||
{
|
||||
name: 'flag-2',
|
||||
keywords: 'flag report',
|
||||
line: 'i-ri-flag-2-line',
|
||||
fill: 'i-ri-flag-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'price-tag-3',
|
||||
keywords: 'tag label',
|
||||
line: 'i-ri-price-tag-3-line',
|
||||
fill: 'i-ri-price-tag-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'map-pin',
|
||||
keywords: 'location place',
|
||||
line: 'i-ri-map-pin-line',
|
||||
fill: 'i-ri-map-pin-fill',
|
||||
},
|
||||
{
|
||||
name: 'sparkling-2',
|
||||
keywords: 'magic ai shine sparkle',
|
||||
line: 'i-ri-sparkling-2-line',
|
||||
fill: 'i-ri-sparkling-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'fire',
|
||||
keywords: 'flame hot trending',
|
||||
line: 'i-ri-fire-line',
|
||||
fill: 'i-ri-fire-fill',
|
||||
},
|
||||
{
|
||||
name: 'lightbulb',
|
||||
keywords: 'idea tip',
|
||||
line: 'i-ri-lightbulb-line',
|
||||
fill: 'i-ri-lightbulb-fill',
|
||||
},
|
||||
{
|
||||
name: 'rocket',
|
||||
keywords: 'launch startup ship',
|
||||
line: 'i-ri-rocket-line',
|
||||
fill: 'i-ri-rocket-fill',
|
||||
},
|
||||
{
|
||||
name: 'focus-3',
|
||||
keywords: 'target goal aim',
|
||||
line: 'i-ri-focus-3-line',
|
||||
fill: 'i-ri-focus-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'award',
|
||||
keywords: 'prize badge medal',
|
||||
line: 'i-ri-award-line',
|
||||
fill: 'i-ri-award-fill',
|
||||
},
|
||||
{
|
||||
name: 'trophy',
|
||||
keywords: 'win achievement',
|
||||
line: 'i-ri-trophy-line',
|
||||
fill: 'i-ri-trophy-fill',
|
||||
},
|
||||
{
|
||||
name: 'vip-crown-2',
|
||||
keywords: 'crown premium vip',
|
||||
line: 'i-ri-vip-crown-2-line',
|
||||
fill: 'i-ri-vip-crown-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'vip-diamond',
|
||||
keywords: 'gem diamond premium',
|
||||
line: 'i-ri-vip-diamond-line',
|
||||
fill: 'i-ri-vip-diamond-fill',
|
||||
},
|
||||
{
|
||||
name: 'gift',
|
||||
keywords: 'present reward',
|
||||
line: 'i-ri-gift-line',
|
||||
fill: 'i-ri-gift-fill',
|
||||
},
|
||||
{
|
||||
name: 'notification-3',
|
||||
keywords: 'bell alert',
|
||||
line: 'i-ri-notification-3-line',
|
||||
fill: 'i-ri-notification-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'global',
|
||||
keywords: 'globe world web',
|
||||
line: 'i-ri-global-line',
|
||||
fill: 'i-ri-global-fill',
|
||||
},
|
||||
{
|
||||
name: 'compass-3',
|
||||
keywords: 'explore discover',
|
||||
line: 'i-ri-compass-3-line',
|
||||
fill: 'i-ri-compass-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'puzzle-2',
|
||||
keywords: 'extension integration',
|
||||
line: 'i-ri-puzzle-2-line',
|
||||
fill: 'i-ri-puzzle-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'ticket-2',
|
||||
keywords: 'support coupon',
|
||||
line: 'i-ri-ticket-2-line',
|
||||
fill: 'i-ri-ticket-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'megaphone',
|
||||
keywords: 'announce broadcast marketing',
|
||||
line: 'i-ri-megaphone-line',
|
||||
fill: 'i-ri-megaphone-fill',
|
||||
},
|
||||
{
|
||||
name: 'briefcase-4',
|
||||
keywords: 'work job business',
|
||||
line: 'i-ri-briefcase-4-line',
|
||||
fill: 'i-ri-briefcase-4-fill',
|
||||
},
|
||||
{
|
||||
name: 'building-2',
|
||||
keywords: 'company office org',
|
||||
line: 'i-ri-building-2-line',
|
||||
fill: 'i-ri-building-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'store-2',
|
||||
keywords: 'shop commerce',
|
||||
line: 'i-ri-store-2-line',
|
||||
fill: 'i-ri-store-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'community',
|
||||
keywords: 'group team',
|
||||
line: 'i-ri-community-line',
|
||||
fill: 'i-ri-community-fill',
|
||||
},
|
||||
{
|
||||
name: 'folder',
|
||||
keywords: 'directory',
|
||||
line: 'i-ri-folder-line',
|
||||
fill: 'i-ri-folder-fill',
|
||||
},
|
||||
{
|
||||
name: 'folder-open',
|
||||
keywords: 'directory open',
|
||||
line: 'i-ri-folder-open-line',
|
||||
fill: 'i-ri-folder-open-fill',
|
||||
},
|
||||
{
|
||||
name: 'file',
|
||||
keywords: 'document',
|
||||
line: 'i-ri-file-line',
|
||||
fill: 'i-ri-file-fill',
|
||||
},
|
||||
{
|
||||
name: 'file-text',
|
||||
keywords: 'document doc',
|
||||
line: 'i-ri-file-text-line',
|
||||
fill: 'i-ri-file-text-fill',
|
||||
},
|
||||
{
|
||||
name: 'file-copy',
|
||||
keywords: 'documents files',
|
||||
line: 'i-ri-file-copy-line',
|
||||
fill: 'i-ri-file-copy-fill',
|
||||
},
|
||||
{
|
||||
name: 'clipboard',
|
||||
keywords: 'copy tasks',
|
||||
line: 'i-ri-clipboard-line',
|
||||
fill: 'i-ri-clipboard-fill',
|
||||
},
|
||||
{
|
||||
name: 'book-2',
|
||||
keywords: 'read guide book',
|
||||
line: 'i-ri-book-2-line',
|
||||
fill: 'i-ri-book-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'book-open',
|
||||
keywords: 'read guide docs',
|
||||
line: 'i-ri-book-open-line',
|
||||
fill: 'i-ri-book-open-fill',
|
||||
},
|
||||
{
|
||||
name: 'graduation-cap',
|
||||
keywords: 'learn course education',
|
||||
line: 'i-ri-graduation-cap-line',
|
||||
fill: 'i-ri-graduation-cap-fill',
|
||||
},
|
||||
{
|
||||
name: 'newspaper',
|
||||
keywords: 'news article blog',
|
||||
line: 'i-ri-newspaper-line',
|
||||
fill: 'i-ri-newspaper-fill',
|
||||
},
|
||||
{
|
||||
name: 'sticky-note',
|
||||
keywords: 'note memo',
|
||||
line: 'i-ri-sticky-note-line',
|
||||
fill: 'i-ri-sticky-note-fill',
|
||||
},
|
||||
{
|
||||
name: 'pencil',
|
||||
keywords: 'write edit',
|
||||
line: 'i-ri-pencil-line',
|
||||
fill: 'i-ri-pencil-fill',
|
||||
},
|
||||
{
|
||||
name: 'quill-pen',
|
||||
keywords: 'write author',
|
||||
line: 'i-ri-quill-pen-line',
|
||||
fill: 'i-ri-quill-pen-fill',
|
||||
},
|
||||
{
|
||||
name: 'mark-pen',
|
||||
keywords: 'highlight',
|
||||
line: 'i-ri-mark-pen-line',
|
||||
fill: 'i-ri-mark-pen-fill',
|
||||
},
|
||||
{
|
||||
name: 'calendar-2',
|
||||
keywords: 'date schedule calendar',
|
||||
line: 'i-ri-calendar-2-line',
|
||||
fill: 'i-ri-calendar-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'calendar-event',
|
||||
keywords: 'schedule event',
|
||||
line: 'i-ri-calendar-event-line',
|
||||
fill: 'i-ri-calendar-event-fill',
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
keywords: 'clock time',
|
||||
line: 'i-ri-time-line',
|
||||
fill: 'i-ri-time-fill',
|
||||
},
|
||||
{
|
||||
name: 'timer',
|
||||
keywords: 'countdown',
|
||||
line: 'i-ri-timer-line',
|
||||
fill: 'i-ri-timer-fill',
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
keywords: 'recent past',
|
||||
line: 'i-ri-history-line',
|
||||
fill: 'i-ri-history-fill',
|
||||
},
|
||||
{
|
||||
name: 'hourglass',
|
||||
keywords: 'wait time',
|
||||
line: 'i-ri-hourglass-line',
|
||||
fill: 'i-ri-hourglass-fill',
|
||||
},
|
||||
{
|
||||
name: 'shopping-cart-2',
|
||||
keywords: 'buy ecommerce cart',
|
||||
line: 'i-ri-shopping-cart-2-line',
|
||||
fill: 'i-ri-shopping-cart-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'shopping-bag-3',
|
||||
keywords: 'buy purchase bag',
|
||||
line: 'i-ri-shopping-bag-3-line',
|
||||
fill: 'i-ri-shopping-bag-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'box-3',
|
||||
keywords: 'package box shipping',
|
||||
line: 'i-ri-box-3-line',
|
||||
fill: 'i-ri-box-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'archive',
|
||||
keywords: 'store box archive',
|
||||
line: 'i-ri-archive-line',
|
||||
fill: 'i-ri-archive-fill',
|
||||
},
|
||||
{
|
||||
name: 'bank-card',
|
||||
keywords: 'pay billing card credit',
|
||||
line: 'i-ri-bank-card-line',
|
||||
fill: 'i-ri-bank-card-fill',
|
||||
},
|
||||
{
|
||||
name: 'wallet-3',
|
||||
keywords: 'money pay wallet',
|
||||
line: 'i-ri-wallet-3-line',
|
||||
fill: 'i-ri-wallet-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'money-dollar-circle',
|
||||
keywords: 'money price usd',
|
||||
line: 'i-ri-money-dollar-circle-line',
|
||||
fill: 'i-ri-money-dollar-circle-fill',
|
||||
},
|
||||
{
|
||||
name: 'copper-coin',
|
||||
keywords: 'coins money',
|
||||
line: 'i-ri-copper-coin-line',
|
||||
fill: 'i-ri-copper-coin-fill',
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
keywords: 'money exchange',
|
||||
line: 'i-ri-currency-line',
|
||||
fill: 'i-ri-currency-fill',
|
||||
},
|
||||
{
|
||||
name: 'receipt',
|
||||
keywords: 'invoice bill',
|
||||
line: 'i-ri-receipt-line',
|
||||
fill: 'i-ri-receipt-fill',
|
||||
},
|
||||
{
|
||||
name: 'percent',
|
||||
keywords: 'discount sale',
|
||||
line: 'i-ri-percent-line',
|
||||
fill: 'i-ri-percent-fill',
|
||||
},
|
||||
{
|
||||
name: 'calculator',
|
||||
keywords: 'math',
|
||||
line: 'i-ri-calculator-line',
|
||||
fill: 'i-ri-calculator-fill',
|
||||
},
|
||||
{
|
||||
name: 'mail',
|
||||
keywords: 'email message',
|
||||
line: 'i-ri-mail-line',
|
||||
fill: 'i-ri-mail-fill',
|
||||
},
|
||||
{
|
||||
name: 'send-plane',
|
||||
keywords: 'send submit',
|
||||
line: 'i-ri-send-plane-line',
|
||||
fill: 'i-ri-send-plane-fill',
|
||||
},
|
||||
{
|
||||
name: 'chat-3',
|
||||
keywords: 'chat talk message',
|
||||
line: 'i-ri-chat-3-line',
|
||||
fill: 'i-ri-chat-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'message-2',
|
||||
keywords: 'chat comment',
|
||||
line: 'i-ri-message-2-line',
|
||||
fill: 'i-ri-message-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'question-answer',
|
||||
keywords: 'chat conversation faq',
|
||||
line: 'i-ri-question-answer-line',
|
||||
fill: 'i-ri-question-answer-fill',
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
keywords: 'call',
|
||||
line: 'i-ri-phone-line',
|
||||
fill: 'i-ri-phone-fill',
|
||||
},
|
||||
{
|
||||
name: 'vidicon',
|
||||
keywords: 'video call meeting',
|
||||
line: 'i-ri-vidicon-line',
|
||||
fill: 'i-ri-vidicon-fill',
|
||||
},
|
||||
{
|
||||
name: 'mic',
|
||||
keywords: 'record voice audio',
|
||||
line: 'i-ri-mic-line',
|
||||
fill: 'i-ri-mic-fill',
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
keywords: 'team people users',
|
||||
line: 'i-ri-group-line',
|
||||
fill: 'i-ri-group-fill',
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
keywords: 'person account',
|
||||
line: 'i-ri-user-line',
|
||||
fill: 'i-ri-user-fill',
|
||||
},
|
||||
{
|
||||
name: 'user-add',
|
||||
keywords: 'add member',
|
||||
line: 'i-ri-user-add-line',
|
||||
fill: 'i-ri-user-add-fill',
|
||||
},
|
||||
{
|
||||
name: 'contacts',
|
||||
keywords: 'person card contact',
|
||||
line: 'i-ri-contacts-line',
|
||||
fill: 'i-ri-contacts-fill',
|
||||
},
|
||||
{
|
||||
name: 'emotion-happy',
|
||||
keywords: 'happy smile emoji',
|
||||
line: 'i-ri-emotion-happy-line',
|
||||
fill: 'i-ri-emotion-happy-fill',
|
||||
},
|
||||
{
|
||||
name: 'thumb-up',
|
||||
keywords: 'like approve',
|
||||
line: 'i-ri-thumb-up-line',
|
||||
fill: 'i-ri-thumb-up-fill',
|
||||
},
|
||||
{
|
||||
name: 'hand-heart',
|
||||
keywords: 'care support',
|
||||
line: 'i-ri-hand-heart-line',
|
||||
fill: 'i-ri-hand-heart-fill',
|
||||
},
|
||||
{
|
||||
name: 'at',
|
||||
keywords: 'mention email at',
|
||||
line: 'i-ri-at-line',
|
||||
fill: 'i-ri-at-fill',
|
||||
},
|
||||
{
|
||||
name: 'image-2',
|
||||
keywords: 'picture photo image',
|
||||
line: 'i-ri-image-2-line',
|
||||
fill: 'i-ri-image-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'camera-3',
|
||||
keywords: 'photo camera',
|
||||
line: 'i-ri-camera-3-line',
|
||||
fill: 'i-ri-camera-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'film',
|
||||
keywords: 'movie video',
|
||||
line: 'i-ri-film-line',
|
||||
fill: 'i-ri-film-fill',
|
||||
},
|
||||
{
|
||||
name: 'music-2',
|
||||
keywords: 'audio song music',
|
||||
line: 'i-ri-music-2-line',
|
||||
fill: 'i-ri-music-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'headphone',
|
||||
keywords: 'audio support',
|
||||
line: 'i-ri-headphone-line',
|
||||
fill: 'i-ri-headphone-fill',
|
||||
},
|
||||
{
|
||||
name: 'palette',
|
||||
keywords: 'color design art',
|
||||
line: 'i-ri-palette-line',
|
||||
fill: 'i-ri-palette-fill',
|
||||
},
|
||||
{
|
||||
name: 'brush-3',
|
||||
keywords: 'paint design brush',
|
||||
line: 'i-ri-brush-3-line',
|
||||
fill: 'i-ri-brush-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'scissors-2',
|
||||
keywords: 'cut',
|
||||
line: 'i-ri-scissors-2-line',
|
||||
fill: 'i-ri-scissors-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'code-s-slash',
|
||||
keywords: 'develop code programming',
|
||||
line: 'i-ri-code-s-slash-line',
|
||||
fill: 'i-ri-code-s-slash-fill',
|
||||
},
|
||||
{
|
||||
name: 'terminal-box',
|
||||
keywords: 'console cli terminal',
|
||||
line: 'i-ri-terminal-box-line',
|
||||
fill: 'i-ri-terminal-box-fill',
|
||||
},
|
||||
{
|
||||
name: 'git-branch',
|
||||
keywords: 'version code git',
|
||||
line: 'i-ri-git-branch-line',
|
||||
fill: 'i-ri-git-branch-fill',
|
||||
},
|
||||
{
|
||||
name: 'github',
|
||||
keywords: 'git code',
|
||||
line: 'i-ri-github-line',
|
||||
fill: 'i-ri-github-fill',
|
||||
},
|
||||
{
|
||||
name: 'cpu',
|
||||
keywords: 'chip processor',
|
||||
line: 'i-ri-cpu-line',
|
||||
fill: 'i-ri-cpu-fill',
|
||||
},
|
||||
{
|
||||
name: 'database-2',
|
||||
keywords: 'storage data',
|
||||
line: 'i-ri-database-2-line',
|
||||
fill: 'i-ri-database-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'server',
|
||||
keywords: 'host backend',
|
||||
line: 'i-ri-server-line',
|
||||
fill: 'i-ri-server-fill',
|
||||
},
|
||||
{
|
||||
name: 'cloud',
|
||||
keywords: 'storage cloud',
|
||||
line: 'i-ri-cloud-line',
|
||||
fill: 'i-ri-cloud-fill',
|
||||
},
|
||||
{
|
||||
name: 'wifi',
|
||||
keywords: 'network internet',
|
||||
line: 'i-ri-wifi-line',
|
||||
fill: 'i-ri-wifi-fill',
|
||||
},
|
||||
{
|
||||
name: 'computer',
|
||||
keywords: 'screen desktop monitor',
|
||||
line: 'i-ri-computer-line',
|
||||
fill: 'i-ri-computer-fill',
|
||||
},
|
||||
{
|
||||
name: 'smartphone',
|
||||
keywords: 'mobile phone',
|
||||
line: 'i-ri-smartphone-line',
|
||||
fill: 'i-ri-smartphone-fill',
|
||||
},
|
||||
{
|
||||
name: 'macbook',
|
||||
keywords: 'laptop computer',
|
||||
line: 'i-ri-macbook-line',
|
||||
fill: 'i-ri-macbook-fill',
|
||||
},
|
||||
{
|
||||
name: 'bug',
|
||||
keywords: 'issue error bug',
|
||||
line: 'i-ri-bug-line',
|
||||
fill: 'i-ri-bug-fill',
|
||||
},
|
||||
{
|
||||
name: 'command',
|
||||
keywords: 'keyboard shortcut',
|
||||
line: 'i-ri-command-line',
|
||||
fill: 'i-ri-command-fill',
|
||||
},
|
||||
{
|
||||
name: 'settings-3',
|
||||
keywords: 'config gear options settings',
|
||||
line: 'i-ri-settings-3-line',
|
||||
fill: 'i-ri-settings-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'equalizer',
|
||||
keywords: 'controls adjust sliders filter',
|
||||
line: 'i-ri-equalizer-line',
|
||||
fill: 'i-ri-equalizer-fill',
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
keywords: 'fix tools config',
|
||||
line: 'i-ri-tools-line',
|
||||
fill: 'i-ri-tools-fill',
|
||||
},
|
||||
{
|
||||
name: 'hammer',
|
||||
keywords: 'build fix hammer',
|
||||
line: 'i-ri-hammer-line',
|
||||
fill: 'i-ri-hammer-fill',
|
||||
},
|
||||
{
|
||||
name: 'key-2',
|
||||
keywords: 'password access key',
|
||||
line: 'i-ri-key-2-line',
|
||||
fill: 'i-ri-key-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'lock',
|
||||
keywords: 'secure private lock',
|
||||
line: 'i-ri-lock-line',
|
||||
fill: 'i-ri-lock-fill',
|
||||
},
|
||||
{
|
||||
name: 'lock-unlock',
|
||||
keywords: 'open access',
|
||||
line: 'i-ri-lock-unlock-line',
|
||||
fill: 'i-ri-lock-unlock-fill',
|
||||
},
|
||||
{
|
||||
name: 'shield',
|
||||
keywords: 'security protect',
|
||||
line: 'i-ri-shield-line',
|
||||
fill: 'i-ri-shield-fill',
|
||||
},
|
||||
{
|
||||
name: 'shield-check',
|
||||
keywords: 'secure verified',
|
||||
line: 'i-ri-shield-check-line',
|
||||
fill: 'i-ri-shield-check-fill',
|
||||
},
|
||||
{
|
||||
name: 'eye',
|
||||
keywords: 'view visible preview',
|
||||
line: 'i-ri-eye-line',
|
||||
fill: 'i-ri-eye-fill',
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
keywords: 'find lookup search',
|
||||
line: 'i-ri-search-line',
|
||||
fill: 'i-ri-search-fill',
|
||||
},
|
||||
{
|
||||
name: 'filter-3',
|
||||
keywords: 'sort refine filter',
|
||||
line: 'i-ri-filter-3-line',
|
||||
fill: 'i-ri-filter-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'links',
|
||||
keywords: 'url chain link',
|
||||
line: 'i-ri-links-line',
|
||||
fill: 'i-ri-links-fill',
|
||||
},
|
||||
{
|
||||
name: 'share-forward',
|
||||
keywords: 'share send distribute',
|
||||
line: 'i-ri-share-forward-line',
|
||||
fill: 'i-ri-share-forward-fill',
|
||||
},
|
||||
{
|
||||
name: 'plug',
|
||||
keywords: 'connect integration',
|
||||
line: 'i-ri-plug-line',
|
||||
fill: 'i-ri-plug-fill',
|
||||
},
|
||||
{
|
||||
name: 'shut-down',
|
||||
keywords: 'power on off',
|
||||
line: 'i-ri-shut-down-line',
|
||||
fill: 'i-ri-shut-down-fill',
|
||||
},
|
||||
{
|
||||
name: 'qr-scan-2',
|
||||
keywords: 'qr scan',
|
||||
line: 'i-ri-qr-scan-2-line',
|
||||
fill: 'i-ri-qr-scan-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'fingerprint',
|
||||
keywords: 'identity auth fingerprint',
|
||||
line: 'i-ri-fingerprint-line',
|
||||
fill: 'i-ri-fingerprint-fill',
|
||||
},
|
||||
{
|
||||
name: 'bar-chart',
|
||||
keywords: 'analytics stats graph bar',
|
||||
line: 'i-ri-bar-chart-line',
|
||||
fill: 'i-ri-bar-chart-fill',
|
||||
},
|
||||
{
|
||||
name: 'pie-chart',
|
||||
keywords: 'analytics stats pie',
|
||||
line: 'i-ri-pie-chart-line',
|
||||
fill: 'i-ri-pie-chart-fill',
|
||||
},
|
||||
{
|
||||
name: 'line-chart',
|
||||
keywords: 'analytics trend graph line',
|
||||
line: 'i-ri-line-chart-line',
|
||||
fill: 'i-ri-line-chart-fill',
|
||||
},
|
||||
{
|
||||
name: 'stock',
|
||||
keywords: 'growth increase trending',
|
||||
line: 'i-ri-stock-line',
|
||||
fill: 'i-ri-stock-fill',
|
||||
},
|
||||
{
|
||||
name: 'pulse',
|
||||
keywords: 'activity health monitor',
|
||||
line: 'i-ri-pulse-line',
|
||||
fill: 'i-ri-pulse-fill',
|
||||
},
|
||||
{
|
||||
name: 'dashboard-3',
|
||||
keywords: 'speed performance dashboard gauge',
|
||||
line: 'i-ri-dashboard-3-line',
|
||||
fill: 'i-ri-dashboard-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'check',
|
||||
keywords: 'done complete ok check',
|
||||
line: 'i-ri-check-line',
|
||||
fill: 'i-ri-check-fill',
|
||||
},
|
||||
{
|
||||
name: 'checkbox-circle',
|
||||
keywords: 'done success check',
|
||||
line: 'i-ri-checkbox-circle-line',
|
||||
fill: 'i-ri-checkbox-circle-fill',
|
||||
},
|
||||
{
|
||||
name: 'close-circle',
|
||||
keywords: 'error fail close',
|
||||
line: 'i-ri-close-circle-line',
|
||||
fill: 'i-ri-close-circle-fill',
|
||||
},
|
||||
{
|
||||
name: 'information',
|
||||
keywords: 'info information',
|
||||
line: 'i-ri-information-line',
|
||||
fill: 'i-ri-information-fill',
|
||||
},
|
||||
{
|
||||
name: 'error-warning',
|
||||
keywords: 'warning alert',
|
||||
line: 'i-ri-error-warning-line',
|
||||
fill: 'i-ri-error-warning-fill',
|
||||
},
|
||||
{
|
||||
name: 'alert',
|
||||
keywords: 'warning caution',
|
||||
line: 'i-ri-alert-line',
|
||||
fill: 'i-ri-alert-fill',
|
||||
},
|
||||
{
|
||||
name: 'question',
|
||||
keywords: 'support faq help',
|
||||
line: 'i-ri-question-line',
|
||||
fill: 'i-ri-question-fill',
|
||||
},
|
||||
{
|
||||
name: 'layout-grid',
|
||||
keywords: 'dashboard apps grid',
|
||||
line: 'i-ri-layout-grid-line',
|
||||
fill: 'i-ri-layout-grid-fill',
|
||||
},
|
||||
{
|
||||
name: 'table',
|
||||
keywords: 'grid data table',
|
||||
line: 'i-ri-table-line',
|
||||
fill: 'i-ri-table-fill',
|
||||
},
|
||||
{
|
||||
name: 'function',
|
||||
keywords: 'kanban board',
|
||||
line: 'i-ri-function-line',
|
||||
fill: 'i-ri-function-fill',
|
||||
},
|
||||
{
|
||||
name: 'map-2',
|
||||
keywords: 'location navigate map',
|
||||
line: 'i-ri-map-2-line',
|
||||
fill: 'i-ri-map-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'navigation',
|
||||
keywords: 'direction gps',
|
||||
line: 'i-ri-navigation-line',
|
||||
fill: 'i-ri-navigation-fill',
|
||||
},
|
||||
{
|
||||
name: 'car',
|
||||
keywords: 'drive vehicle car',
|
||||
line: 'i-ri-car-line',
|
||||
fill: 'i-ri-car-fill',
|
||||
},
|
||||
{
|
||||
name: 'truck',
|
||||
keywords: 'delivery shipping',
|
||||
line: 'i-ri-truck-line',
|
||||
fill: 'i-ri-truck-fill',
|
||||
},
|
||||
{
|
||||
name: 'plane',
|
||||
keywords: 'travel flight plane',
|
||||
line: 'i-ri-plane-line',
|
||||
fill: 'i-ri-plane-fill',
|
||||
},
|
||||
{
|
||||
name: 'bike',
|
||||
keywords: 'cycle bike',
|
||||
line: 'i-ri-bike-line',
|
||||
fill: 'i-ri-bike-fill',
|
||||
},
|
||||
{
|
||||
name: 'anchor',
|
||||
keywords: 'ship marine anchor',
|
||||
line: 'i-ri-anchor-line',
|
||||
fill: 'i-ri-anchor-fill',
|
||||
},
|
||||
{
|
||||
name: 'leaf',
|
||||
keywords: 'eco nature green leaf',
|
||||
line: 'i-ri-leaf-line',
|
||||
fill: 'i-ri-leaf-fill',
|
||||
},
|
||||
{
|
||||
name: 'plant',
|
||||
keywords: 'nature plant grow',
|
||||
line: 'i-ri-plant-line',
|
||||
fill: 'i-ri-plant-fill',
|
||||
},
|
||||
{
|
||||
name: 'sun',
|
||||
keywords: 'day light weather sun',
|
||||
line: 'i-ri-sun-line',
|
||||
fill: 'i-ri-sun-fill',
|
||||
},
|
||||
{
|
||||
name: 'moon',
|
||||
keywords: 'night dark moon',
|
||||
line: 'i-ri-moon-line',
|
||||
fill: 'i-ri-moon-fill',
|
||||
},
|
||||
{
|
||||
name: 'rainy',
|
||||
keywords: 'weather rain',
|
||||
line: 'i-ri-rainy-line',
|
||||
fill: 'i-ri-rainy-fill',
|
||||
},
|
||||
{
|
||||
name: 'umbrella',
|
||||
keywords: 'rain protect umbrella',
|
||||
line: 'i-ri-umbrella-line',
|
||||
fill: 'i-ri-umbrella-fill',
|
||||
},
|
||||
{
|
||||
name: 'landscape',
|
||||
keywords: 'outdoor hike mountain',
|
||||
line: 'i-ri-landscape-line',
|
||||
fill: 'i-ri-landscape-fill',
|
||||
},
|
||||
{
|
||||
name: 'drop',
|
||||
keywords: 'water drop',
|
||||
line: 'i-ri-drop-line',
|
||||
fill: 'i-ri-drop-fill',
|
||||
},
|
||||
{
|
||||
name: 'cup',
|
||||
keywords: 'coffee drink cup',
|
||||
line: 'i-ri-cup-line',
|
||||
fill: 'i-ri-cup-fill',
|
||||
},
|
||||
{
|
||||
name: 'restaurant',
|
||||
keywords: 'food eat restaurant',
|
||||
line: 'i-ri-restaurant-line',
|
||||
fill: 'i-ri-restaurant-fill',
|
||||
},
|
||||
{
|
||||
name: 'cake-3',
|
||||
keywords: 'birthday celebrate cake',
|
||||
line: 'i-ri-cake-3-line',
|
||||
fill: 'i-ri-cake-3-fill',
|
||||
},
|
||||
{
|
||||
name: 'goblet',
|
||||
keywords: 'drink wine bar',
|
||||
line: 'i-ri-goblet-line',
|
||||
fill: 'i-ri-goblet-fill',
|
||||
},
|
||||
{
|
||||
name: 'heart-pulse',
|
||||
keywords: 'health medical',
|
||||
line: 'i-ri-heart-pulse-line',
|
||||
fill: 'i-ri-heart-pulse-fill',
|
||||
},
|
||||
{
|
||||
name: 'stethoscope',
|
||||
keywords: 'health doctor medical',
|
||||
line: 'i-ri-stethoscope-line',
|
||||
fill: 'i-ri-stethoscope-fill',
|
||||
},
|
||||
{
|
||||
name: 'capsule',
|
||||
keywords: 'medicine pill health',
|
||||
line: 'i-ri-capsule-line',
|
||||
fill: 'i-ri-capsule-fill',
|
||||
},
|
||||
{
|
||||
name: 'add',
|
||||
keywords: 'add new create plus',
|
||||
line: 'i-ri-add-line',
|
||||
fill: 'i-ri-add-fill',
|
||||
},
|
||||
{
|
||||
name: 'delete-bin',
|
||||
keywords: 'delete remove trash',
|
||||
line: 'i-ri-delete-bin-line',
|
||||
fill: 'i-ri-delete-bin-fill',
|
||||
},
|
||||
{
|
||||
name: 'download',
|
||||
keywords: 'save export download',
|
||||
line: 'i-ri-download-line',
|
||||
fill: 'i-ri-download-fill',
|
||||
},
|
||||
{
|
||||
name: 'upload',
|
||||
keywords: 'import upload',
|
||||
line: 'i-ri-upload-line',
|
||||
fill: 'i-ri-upload-fill',
|
||||
},
|
||||
{
|
||||
name: 'refresh',
|
||||
keywords: 'reload sync update refresh',
|
||||
line: 'i-ri-refresh-line',
|
||||
fill: 'i-ri-refresh-fill',
|
||||
},
|
||||
{
|
||||
name: 'external-link',
|
||||
keywords: 'open new external',
|
||||
line: 'i-ri-external-link-line',
|
||||
fill: 'i-ri-external-link-fill',
|
||||
},
|
||||
{
|
||||
name: 'logout-box',
|
||||
keywords: 'exit signout logout',
|
||||
line: 'i-ri-logout-box-line',
|
||||
fill: 'i-ri-logout-box-fill',
|
||||
},
|
||||
{
|
||||
name: 'fullscreen',
|
||||
keywords: 'expand maximize fullscreen',
|
||||
line: 'i-ri-fullscreen-line',
|
||||
fill: 'i-ri-fullscreen-fill',
|
||||
},
|
||||
{
|
||||
name: 'drag-move',
|
||||
keywords: 'drag reorder move',
|
||||
line: 'i-ri-drag-move-line',
|
||||
fill: 'i-ri-drag-move-fill',
|
||||
},
|
||||
{
|
||||
name: 'paint-brush',
|
||||
keywords: 'brush paint',
|
||||
line: 'i-ri-paint-brush-line',
|
||||
fill: 'i-ri-paint-brush-fill',
|
||||
},
|
||||
];
|
||||
@@ -78,6 +78,12 @@ export const MEDIA_TYPES = [
|
||||
ATTACHMENT_TYPES.IG_REEL,
|
||||
];
|
||||
|
||||
export const NON_FILE_TYPES = [
|
||||
ATTACHMENT_TYPES.LOCATION,
|
||||
ATTACHMENT_TYPES.FALLBACK,
|
||||
ATTACHMENT_TYPES.CONTACT,
|
||||
];
|
||||
|
||||
export const VOICE_CALL_STATUS = {
|
||||
IN_PROGRESS: 'in-progress',
|
||||
RINGING: 'ringing',
|
||||
|
||||
@@ -21,6 +21,12 @@ import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import {
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
getSidebarSortOptions,
|
||||
resolveSidebarSort,
|
||||
sortSidebarItems,
|
||||
} from 'dashboard/helper/sidebarSort';
|
||||
|
||||
const props = defineProps({
|
||||
isMobileSidebarOpen: {
|
||||
@@ -50,6 +56,7 @@ const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUserId = useMapGetter('getCurrentUserID');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
@@ -79,6 +86,11 @@ const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
|
||||
store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
const fetchSidebarSortPreferences = ([currentAccountId, userId]) => {
|
||||
if (!currentAccountId || !userId) return;
|
||||
store.dispatch('sidebarSortPreferences/initialize');
|
||||
};
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -192,6 +204,9 @@ const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
'customViews/getConversationCustomViews'
|
||||
);
|
||||
const getSidebarSectionSort = useMapGetter(
|
||||
'sidebarSortPreferences/getSectionSort'
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('labels/get');
|
||||
@@ -207,44 +222,62 @@ watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const normalizeUnreadCount = count => {
|
||||
const unreadCount = Number(count);
|
||||
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
|
||||
};
|
||||
watch([accountId, currentUserId], fetchSidebarSortPreferences, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const sortByUnreadCount = (items, labelKey, unreadCountKey) =>
|
||||
items.slice().sort((a, b) => {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(b)) -
|
||||
normalizeUnreadCount(unreadCountKey(a));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
|
||||
return labelKey(a).localeCompare(labelKey(b));
|
||||
const getSortOptionsForSection = section =>
|
||||
getSidebarSortOptions(section, {
|
||||
hasUnreadCounts: hasConversationUnreadCounts.value,
|
||||
});
|
||||
|
||||
const getSortForSection = section =>
|
||||
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
|
||||
hasUnreadCounts: hasConversationUnreadCounts.value,
|
||||
});
|
||||
|
||||
const updateSortPreference = (section, sortBy) => {
|
||||
store.dispatch('sidebarSortPreferences/setSectionSort', {
|
||||
section,
|
||||
sortBy,
|
||||
});
|
||||
};
|
||||
|
||||
const buildSortConfig = section => ({
|
||||
sortOptions: getSortOptionsForSection(section),
|
||||
activeSort: getSortForSection(section),
|
||||
onSortChange: sortBy => updateSortPreference(section, sortBy),
|
||||
});
|
||||
|
||||
const sortedFolders = computed(() =>
|
||||
sortSidebarItems(conversationCustomViews.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
|
||||
labelKey: view => view.name,
|
||||
})
|
||||
);
|
||||
|
||||
const sortedTeams = computed(() =>
|
||||
sortByUnreadCount(
|
||||
teams.value,
|
||||
team => team.name,
|
||||
team => getTeamUnreadCount.value(team.id)
|
||||
)
|
||||
sortSidebarItems(teams.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.TEAMS),
|
||||
labelKey: team => team.name,
|
||||
unreadCountKey: team => getTeamUnreadCount.value(team.id),
|
||||
})
|
||||
);
|
||||
|
||||
const sortedInboxes = computed(() =>
|
||||
sortByUnreadCount(
|
||||
inboxes.value,
|
||||
inbox => inbox.name,
|
||||
inbox => getInboxUnreadCount.value(inbox.id)
|
||||
)
|
||||
sortSidebarItems(inboxes.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.CHANNELS),
|
||||
labelKey: inbox => inbox.name,
|
||||
unreadCountKey: inbox => getInboxUnreadCount.value(inbox.id),
|
||||
})
|
||||
);
|
||||
|
||||
const sortedLabels = computed(() =>
|
||||
sortByUnreadCount(
|
||||
labels.value,
|
||||
label => label.title,
|
||||
label => getLabelUnreadCount.value(label.id)
|
||||
)
|
||||
sortSidebarItems(labels.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.LABELS),
|
||||
labelKey: label => label.title,
|
||||
unreadCountKey: label => getLabelUnreadCount.value(label.id),
|
||||
})
|
||||
);
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
@@ -300,6 +333,7 @@ const menuItems = computed(() => {
|
||||
{
|
||||
name: 'All',
|
||||
label: t('SIDEBAR.ALL_CONVERSATIONS'),
|
||||
icon: 'i-lucide-inbox',
|
||||
badgeCount: allUnreadCount.value,
|
||||
activeOn: ['inbox_conversation'],
|
||||
to: accountScopedRoute('home'),
|
||||
@@ -307,12 +341,14 @@ const menuItems = computed(() => {
|
||||
{
|
||||
name: 'Mentions',
|
||||
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
|
||||
icon: 'i-lucide-at-sign',
|
||||
activeOn: ['conversation_through_mentions'],
|
||||
to: accountScopedRoute('conversation_mentions'),
|
||||
},
|
||||
{
|
||||
name: 'Participating',
|
||||
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
|
||||
icon: 'i-lucide-user-round-check',
|
||||
activeOn: ['conversation_through_participating'],
|
||||
to: accountScopedRoute('conversation_participating'),
|
||||
},
|
||||
@@ -320,6 +356,7 @@ const menuItems = computed(() => {
|
||||
name: 'Unattended',
|
||||
activeOn: ['conversation_through_unattended'],
|
||||
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
|
||||
icon: 'i-lucide-clock-alert',
|
||||
to: accountScopedRoute('conversation_unattended'),
|
||||
},
|
||||
{
|
||||
@@ -327,7 +364,10 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
|
||||
icon: 'i-lucide-folder',
|
||||
activeOn: ['conversations_through_folders'],
|
||||
children: conversationCustomViews.value.map(view => ({
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.FOLDERS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedFolders.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
to: accountScopedRoute('folder_conversations', { id: view.id }),
|
||||
@@ -338,6 +378,9 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: ['conversations_through_team'],
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.TEAMS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedTeams.value.map(team => ({
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
@@ -350,6 +393,9 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CHANNELS'),
|
||||
icon: 'i-lucide-mailbox',
|
||||
activeOn: ['conversation_through_inbox'],
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.CHANNELS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
@@ -370,6 +416,9 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tag',
|
||||
activeOn: ['conversations_through_label'],
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.LABELS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedLabels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
@@ -481,6 +530,8 @@ const menuItems = computed(() => {
|
||||
name: 'Segments',
|
||||
icon: 'i-lucide-group',
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: contactCustomViews.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
@@ -499,6 +550,8 @@ const menuItems = computed(() => {
|
||||
name: 'Tagged With',
|
||||
icon: 'i-lucide-tag',
|
||||
label: t('SIDEBAR.TAGGED_WITH'),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: labels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
import SidebarSortMenu from './SidebarSortMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
@@ -14,7 +16,7 @@ const props = defineProps({
|
||||
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
|
||||
const emit = defineEmits(['close', 'mouseenter', 'mouseleave', 'sortToggle']);
|
||||
|
||||
const router = useRouter();
|
||||
const { isAllowed, sidebarWidth } = useSidebarContext();
|
||||
@@ -29,6 +31,14 @@ const toggleSubGroup = name => {
|
||||
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
|
||||
};
|
||||
|
||||
const handleSortToggle = isSortOpen => {
|
||||
emit('sortToggle', isSortOpen);
|
||||
};
|
||||
|
||||
onClickOutside(popoverRef, () => emit('close'), {
|
||||
ignore: ['[data-popover-content]'],
|
||||
});
|
||||
|
||||
const navigateAndClose = to => {
|
||||
router.push(to);
|
||||
emit('close');
|
||||
@@ -123,24 +133,44 @@ onMounted(async () => {
|
||||
>
|
||||
<template v-for="child in accessibleChildren" :key="child.name">
|
||||
<!-- SubGroup with children -->
|
||||
<li v-if="child.children" class="py-0.5">
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
|
||||
@click="toggleSubGroup(child.name)"
|
||||
<li v-if="child.children" class="group/sidebar-section py-0.5">
|
||||
<div
|
||||
class="flex items-center rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out"
|
||||
>
|
||||
<Icon
|
||||
v-if="child.icon"
|
||||
:icon="child.icon"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
|
||||
<span
|
||||
class="size-3 transition-transform i-lucide-chevron-down"
|
||||
:class="{
|
||||
'rotate-180': expandedSubGroup === child.name,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="flex flex-1 min-w-0 items-center gap-2 ps-2 py-1.5 text-left rtl:text-right"
|
||||
@click="toggleSubGroup(child.name)"
|
||||
>
|
||||
<Icon
|
||||
v-if="child.icon"
|
||||
:icon="child.icon"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
|
||||
</button>
|
||||
<div class="flex flex-shrink-0 items-center gap-1 pe-2">
|
||||
<SidebarSortMenu
|
||||
v-if="child.sortOptions?.length"
|
||||
:active-sort="child.activeSort"
|
||||
:options="child.sortOptions"
|
||||
:open-on-hover="false"
|
||||
@sort="child.onSortChange"
|
||||
@toggle="handleSortToggle"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-11 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
|
||||
@click.stop="toggleSubGroup(child.name)"
|
||||
>
|
||||
<span
|
||||
class="size-4 flex-shrink-0 transition-transform i-lucide-chevron-down"
|
||||
:class="{
|
||||
'rotate-180': expandedSubGroup === child.name,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Transition v-bind="transition">
|
||||
<ul
|
||||
v-if="expandedSubGroup === child.name"
|
||||
|
||||
@@ -55,6 +55,9 @@ const hasChildren = computed(
|
||||
const isPopoverOpen = computed(() => activePopover.value === props.name);
|
||||
const triggerRef = ref(null);
|
||||
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
|
||||
// The sort dropdown teleports outside the popover; keep the popover open while
|
||||
// it is showing so moving the cursor onto it does not close everything.
|
||||
const isSortMenuOpen = ref(false);
|
||||
|
||||
const openPopover = () => {
|
||||
if (triggerRef.value) {
|
||||
@@ -82,7 +85,7 @@ const handleMouseEnter = () => {
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!hasChildren.value) return;
|
||||
if (!hasChildren.value || isSortMenuOpen.value) return;
|
||||
scheduleClose(200);
|
||||
};
|
||||
|
||||
@@ -91,28 +94,53 @@ const handlePopoverMouseEnter = () => {
|
||||
};
|
||||
|
||||
const handlePopoverMouseLeave = () => {
|
||||
if (isSortMenuOpen.value) return;
|
||||
scheduleClose(100);
|
||||
};
|
||||
|
||||
const handleSortToggle = isOpen => {
|
||||
isSortMenuOpen.value = isOpen;
|
||||
cancelClose();
|
||||
};
|
||||
|
||||
// Close popover when mouse leaves the window
|
||||
const handleWindowBlur = () => {
|
||||
closeActivePopover();
|
||||
};
|
||||
|
||||
const accessibleItems = computed(() => {
|
||||
const hasAccessibleSubChildren = child => {
|
||||
return child.children?.some(
|
||||
subChild => subChild.to && isAllowed(subChild.to)
|
||||
);
|
||||
};
|
||||
|
||||
const visibleChildren = computed(() => {
|
||||
if (!hasChildren.value) return [];
|
||||
|
||||
return props.children.filter(child => {
|
||||
// If a item has no link, it means it's just a subgroup header
|
||||
// So we don't need to check for permissions here, because there's nothing to
|
||||
// access here anyway
|
||||
if (child.children) return hasAccessibleSubChildren(child);
|
||||
|
||||
return child.to && isAllowed(child.to);
|
||||
});
|
||||
});
|
||||
|
||||
const hasAccessibleChildren = computed(() => {
|
||||
return accessibleItems.value.length > 0;
|
||||
const accessibleItems = computed(() => {
|
||||
if (!hasChildren.value) return [];
|
||||
|
||||
return visibleChildren.value
|
||||
.flatMap(child => child.children || child)
|
||||
.filter(child => child.to && isAllowed(child.to));
|
||||
});
|
||||
|
||||
const hasAccessibleChildren = computed(() => {
|
||||
return visibleChildren.value.length > 0;
|
||||
});
|
||||
|
||||
const isLastVisibleChild = child => {
|
||||
const lastChild = visibleChildren.value[visibleChildren.value.length - 1];
|
||||
return lastChild === child;
|
||||
};
|
||||
|
||||
const isActive = computed(() => {
|
||||
if (props.to) {
|
||||
if (route.path === resolvePath(props.to)) return true;
|
||||
@@ -254,6 +282,7 @@ watch(
|
||||
@close="closePopover"
|
||||
@mouseenter="handlePopoverMouseEnter"
|
||||
@mouseleave="handlePopoverMouseLeave"
|
||||
@sort-toggle="handleSortToggle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -274,16 +303,23 @@ watch(
|
||||
<ul
|
||||
v-if="hasChildren"
|
||||
v-show="isExpanded || hasActiveChild"
|
||||
class="grid m-0 list-none sidebar-group-children min-w-0"
|
||||
class="grid m-0 list-none min-w-0"
|
||||
>
|
||||
<template v-for="child in children" :key="child.name">
|
||||
<template v-for="child in visibleChildren" :key="child.name">
|
||||
<SidebarSubGroup
|
||||
v-if="child.children"
|
||||
:name="`${name}:${child.name}`"
|
||||
:label="child.label"
|
||||
:icon="child.icon"
|
||||
:children="child.children"
|
||||
:collapsible="child.collapsible"
|
||||
:show-tree-line="child.showTreeLine"
|
||||
:end-tree-line="child.showTreeLine && isLastVisibleChild(child)"
|
||||
:is-expanded="isExpanded"
|
||||
:active-child="activeChild"
|
||||
:sort-options="child.sortOptions"
|
||||
:active-sort="child.activeSort"
|
||||
@update-sort="child.onSortChange"
|
||||
/>
|
||||
<SidebarGroupLeaf
|
||||
v-else-if="isAllowed(child.to)"
|
||||
@@ -299,59 +335,3 @@ watch(
|
||||
</template>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.sidebar-group-children .child-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0.125rem;
|
||||
/* 0.5px */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-group-children .child-item:first-child::before {
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
/* This selects the last child in a group */
|
||||
/* https://codepen.io/scmmishra/pen/yLmKNLW */
|
||||
.sidebar-group-children > .child-item:last-child::before,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::before {
|
||||
height: 20%;
|
||||
}
|
||||
|
||||
.sidebar-group-children > .child-item:last-child::after,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 12px;
|
||||
bottom: calc(50% - 2px);
|
||||
border-bottom-width: 0.125rem;
|
||||
border-left-width: 0.125rem;
|
||||
border-right-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 0 4px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#app[dir='rtl'] .sidebar-group-children > .child-item:last-child::after,
|
||||
#app[dir='rtl']
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
right: 0;
|
||||
border-bottom-width: 0.125rem;
|
||||
border-right-width: 0.125rem;
|
||||
border-left-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 4px 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -45,7 +45,9 @@ const count = computed(() =>
|
||||
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
|
||||
<div
|
||||
class="flex items-center gap-1.5 flex-grow justify-between min-w-0 flex-1"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
:class="{
|
||||
@@ -57,11 +59,7 @@ const count = computed(() =>
|
||||
</span>
|
||||
<span
|
||||
v-if="dynamicCount && !expandable"
|
||||
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
|
||||
:class="{
|
||||
'text-n-slate-12 outline-n-slate-6': isActive,
|
||||
'text-n-slate-11 outline-n-strong': !isActive,
|
||||
}"
|
||||
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
|
||||
@@ -12,6 +12,8 @@ const props = defineProps({
|
||||
active: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
badgeCount: { type: [Number, String], default: 0 },
|
||||
hideTreeLine: { type: Boolean, default: false },
|
||||
thinTreeLine: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
@@ -19,21 +21,30 @@ const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
const shouldRenderComponent = computed(() => {
|
||||
return typeof props.component === 'function' || isVNode(props.component);
|
||||
});
|
||||
|
||||
// Tree-line connector per leaf: vertical line (::before) + rounded elbow on the
|
||||
// last child (::after). Logical props (start / border-s / rounded-es)
|
||||
const TREE_CONNECTOR =
|
||||
"child-item before:content-[''] before:absolute before:start-0 before:w-0.5 before:h-full before:bg-n-slate-4 first:before:rounded-t last:before:h-1/5 last:after:content-[''] last:after:absolute last:after:start-0 last:after:bottom-[calc(50%_-_2px)] last:after:h-3 last:after:w-2.5 last:after:border-b-2 last:after:border-s-2 last:after:rounded-es last:after:border-n-slate-4";
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<Policy
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
class="py-0.5 ltr:pl-2 rtl:pr-2 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0 min-w-0"
|
||||
class="py-0.5 ps-2 ms-3 relative text-n-slate-11 min-w-0"
|
||||
:class="{
|
||||
[TREE_CONNECTOR]: !hideTreeLine,
|
||||
'before:!w-px last:after:!border-b last:after:!border-s':
|
||||
!hideTreeLine && thinTreeLine,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
:to="to"
|
||||
:title="label"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg ltr:hover:bg-gradient-to-r rtl:hover:bg-gradient-to-l from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2 active': active,
|
||||
}"
|
||||
|
||||
@@ -1,25 +1,104 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import SidebarSortMenu from './SidebarSortMenu.vue';
|
||||
|
||||
defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isExpanded: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: [Object, String],
|
||||
default: '',
|
||||
},
|
||||
sortOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showTreeLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
endTreeLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle', 'update-sort']);
|
||||
|
||||
const TREE_VERTICAL_LINE =
|
||||
"before:content-[''] before:absolute before:-top-1 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
|
||||
const TREE_ELBOW =
|
||||
"after:content-[''] after:absolute after:w-2.5 after:h-3 after:bottom-1/2 after:start-[-0.5rem] after:border-b-2 after:border-s-2 after:rounded-es after:border-n-slate-4";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none pointer-events-none"
|
||||
>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="relative min-w-0" :class="{ 'ms-5': collapsible }">
|
||||
<component
|
||||
:is="collapsible ? 'button' : 'div'"
|
||||
:type="collapsible ? 'button' : undefined"
|
||||
:aria-expanded="collapsible ? isExpanded : undefined"
|
||||
:title="label"
|
||||
class="relative flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-n-slate-10 select-none"
|
||||
:class="[
|
||||
showTreeLine && TREE_VERTICAL_LINE,
|
||||
showTreeLine &&
|
||||
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
|
||||
{
|
||||
'pointer-events-none': !collapsible,
|
||||
'cursor-pointer hover:bg-n-alpha-2': collapsible,
|
||||
'pe-14': collapsible && sortOptions.length,
|
||||
'pe-8': collapsible && !sortOptions.length,
|
||||
'pe-10': !collapsible && sortOptions.length,
|
||||
},
|
||||
]"
|
||||
@click.stop="collapsible ? emit('toggle') : undefined"
|
||||
>
|
||||
<div class="inline-flex min-w-0 items-center gap-2">
|
||||
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
|
||||
<span
|
||||
class="flex-grow truncate text-start text-sm font-medium leading-5"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</component>
|
||||
<div
|
||||
v-if="collapsible || sortOptions.length"
|
||||
class="absolute end-2 top-1/2 flex -translate-y-1/2 items-center gap-1"
|
||||
>
|
||||
<SidebarSortMenu
|
||||
v-if="sortOptions.length"
|
||||
:active-sort="activeSort"
|
||||
:options="sortOptions"
|
||||
@sort="sortBy => emit('update-sort', sortBy)"
|
||||
/>
|
||||
<button
|
||||
v-if="collapsible"
|
||||
type="button"
|
||||
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-10 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
|
||||
:aria-expanded="isExpanded"
|
||||
:aria-label="label"
|
||||
@click.stop="emit('toggle')"
|
||||
>
|
||||
<span
|
||||
class="size-3 flex-shrink-0"
|
||||
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import { SIDEBAR_SORT_KEYS } from 'dashboard/helper/sidebarSort';
|
||||
|
||||
const props = defineProps({
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
openOnHover: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sort', 'toggle']);
|
||||
|
||||
const SORT_OPTION_GROUPS = [
|
||||
{
|
||||
key: 'created',
|
||||
options: [SIDEBAR_SORT_KEYS.CREATED_DESC, SIDEBAR_SORT_KEYS.CREATED_ASC],
|
||||
},
|
||||
{
|
||||
key: 'alphabetical',
|
||||
options: [
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'unread_count',
|
||||
options: [
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { t } = useI18n();
|
||||
const isOpen = ref(false);
|
||||
const triggerRef = ref(null);
|
||||
const popoverRef = ref(null);
|
||||
let closeTimer;
|
||||
|
||||
const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||
triggerRef,
|
||||
popoverRef,
|
||||
isOpen,
|
||||
{ align: 'start' }
|
||||
);
|
||||
|
||||
const getSortOptionLabel = option => {
|
||||
if (option === SIDEBAR_SORT_KEYS.CREATED_DESC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.CREATED_DESC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.CREATED_ASC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.CREATED_ASC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_ASC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_DESC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_DESC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_ASC');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const getSortGroupLabel = groupKey => {
|
||||
if (groupKey === 'created') {
|
||||
return t('SIDEBAR.SORT_GROUPS.CREATED');
|
||||
}
|
||||
|
||||
if (groupKey === 'alphabetical') {
|
||||
return t('SIDEBAR.SORT_GROUPS.ALPHABETICAL');
|
||||
}
|
||||
|
||||
if (groupKey === 'unread_count') {
|
||||
return t('SIDEBAR.SORT_GROUPS.UNREAD_COUNT');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const sortMenuSections = computed(() =>
|
||||
SORT_OPTION_GROUPS.map(group => ({
|
||||
title: getSortGroupLabel(group.key),
|
||||
items: group.options
|
||||
.filter(option => props.options.includes(option))
|
||||
.map(option => ({
|
||||
label: getSortOptionLabel(option),
|
||||
value: option,
|
||||
action: 'sort',
|
||||
isActive: option === props.activeSort,
|
||||
})),
|
||||
})).filter(section => section.items.length)
|
||||
);
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openMenu = async () => {
|
||||
clearCloseTimer();
|
||||
isOpen.value = true;
|
||||
emit('toggle', true);
|
||||
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
clearCloseTimer();
|
||||
isOpen.value = false;
|
||||
emit('toggle', false);
|
||||
};
|
||||
|
||||
const scheduleClose = () => {
|
||||
clearCloseTimer();
|
||||
closeTimer = setTimeout(closeMenu, 150);
|
||||
};
|
||||
|
||||
const handleTriggerEnter = () => {
|
||||
if (props.openOnHover) openMenu();
|
||||
};
|
||||
|
||||
const handleTriggerLeave = () => {
|
||||
if (props.openOnHover) scheduleClose();
|
||||
};
|
||||
|
||||
const handleClickOutside = event => {
|
||||
if (triggerRef.value?.contains(event.target)) return;
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
const handleSortChange = ({ value }) => {
|
||||
emit('sort', value);
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
onBeforeUnmount(clearCloseTimer);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="relative invisible flex-shrink-0 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/sidebar-section:visible group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:pointer-events-auto"
|
||||
:class="{ '!visible !opacity-100 !pointer-events-auto': isOpen }"
|
||||
@mouseenter="handleTriggerEnter"
|
||||
@mouseleave="handleTriggerLeave"
|
||||
>
|
||||
<Button
|
||||
:title="t('SIDEBAR.SORT_TOOLTIP')"
|
||||
icon="i-lucide-arrow-up-down"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
class="!size-6 !text-n-slate-11 hover:!text-n-slate-12"
|
||||
:class="{ '!bg-n-alpha-2': isOpen }"
|
||||
@click.stop="openMenu"
|
||||
/>
|
||||
<TeleportWithDirection>
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
ref="popoverRef"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
data-popover-content
|
||||
:menu-sections="sortMenuSections"
|
||||
:class="fixedPosition.class"
|
||||
:style="fixedPosition.style"
|
||||
class="w-60 !fixed"
|
||||
@action="handleSortChange"
|
||||
@mouseenter="clearCloseTimer"
|
||||
@mouseleave="handleTriggerLeave"
|
||||
>
|
||||
<template #trailing-icon="{ item }">
|
||||
<span
|
||||
v-if="item.isActive"
|
||||
class="i-lucide-check ms-auto size-4 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</TeleportWithDirection>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +1,56 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
|
||||
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
label: { type: String, required: true },
|
||||
icon: { type: [Object, String], required: true },
|
||||
children: { type: Array, default: undefined },
|
||||
activeChild: { type: Object, default: undefined },
|
||||
sortOptions: { type: Array, default: () => [] },
|
||||
activeSort: { type: String, default: '' },
|
||||
collapsible: { type: Boolean, default: false },
|
||||
showTreeLine: { type: Boolean, default: false },
|
||||
endTreeLine: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update-sort']);
|
||||
|
||||
const { isAllowed } = useSidebarContext();
|
||||
const scrollableContainer = ref(null);
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const minimizedSectionsKey = LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS;
|
||||
|
||||
const getMinimizedSections = () => {
|
||||
const minimizedSections = LocalStorage.get(minimizedSectionsKey);
|
||||
return minimizedSections &&
|
||||
typeof minimizedSections === 'object' &&
|
||||
!Array.isArray(minimizedSections)
|
||||
? minimizedSections
|
||||
: {};
|
||||
};
|
||||
|
||||
const minimizedSections = ref(getMinimizedSections());
|
||||
const storageKey = computed(() =>
|
||||
accountId.value ? `${accountId.value}:${props.name}` : props.name
|
||||
);
|
||||
const isSubGroupExpanded = computed(
|
||||
() => !props.collapsible || !minimizedSections.value[storageKey.value]
|
||||
);
|
||||
const hasActiveChild = computed(() =>
|
||||
props.children.some(child => child.name === props.activeChild?.name)
|
||||
);
|
||||
|
||||
const accessibleItems = computed(() =>
|
||||
props.children.filter(child => {
|
||||
@@ -28,84 +63,120 @@ const hasAccessibleItems = computed(() => {
|
||||
});
|
||||
|
||||
const isScrollable = computed(() => {
|
||||
return accessibleItems.value.length > 7;
|
||||
return (
|
||||
props.isExpanded &&
|
||||
isSubGroupExpanded.value &&
|
||||
accessibleItems.value.length > 7
|
||||
);
|
||||
});
|
||||
|
||||
const scrollEnd = ref(false);
|
||||
|
||||
const CHILDREN_TRUNK =
|
||||
"before:content-[''] before:absolute before:top-0 before:bottom-0 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
|
||||
|
||||
const hideLeafTreeLine = computed(
|
||||
() => props.showTreeLine && !props.isExpanded
|
||||
);
|
||||
|
||||
const toggleSubGroup = () => {
|
||||
if (!props.collapsible) return;
|
||||
|
||||
if (isSubGroupExpanded.value) {
|
||||
LocalStorage.updateJsonStore(minimizedSectionsKey, storageKey.value, true);
|
||||
} else {
|
||||
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
|
||||
}
|
||||
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
};
|
||||
|
||||
const expandSubGroupOnActiveChild = () => {
|
||||
if (!props.collapsible || !hasActiveChild.value || isSubGroupExpanded.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
};
|
||||
|
||||
const shouldShowItem = child => {
|
||||
return (
|
||||
isSubGroupExpanded.value &&
|
||||
(props.isExpanded || props.activeChild?.name === child.name)
|
||||
);
|
||||
};
|
||||
|
||||
// set scrollEnd to true when the scroll reaches the end
|
||||
useEventListener(scrollableContainer, 'scroll', () => {
|
||||
const { scrollHeight, scrollTop, clientHeight } = scrollableContainer.value;
|
||||
scrollEnd.value = scrollHeight - scrollTop === clientHeight;
|
||||
});
|
||||
|
||||
useEventListener(window, 'storage', event => {
|
||||
if (event.key === minimizedSectionsKey) {
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
}
|
||||
});
|
||||
|
||||
watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroupSeparator
|
||||
v-if="hasAccessibleItems"
|
||||
v-show="isExpanded"
|
||||
:label
|
||||
:icon
|
||||
class="my-1"
|
||||
/>
|
||||
<ul
|
||||
v-if="children.length"
|
||||
class="m-0 list-none reset-base relative group min-w-0"
|
||||
>
|
||||
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
|
||||
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
|
||||
<div
|
||||
ref="scrollableContainer"
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
>
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
<li class="group/sidebar-section relative flex flex-col list-none min-w-0">
|
||||
<template v-if="hasAccessibleItems">
|
||||
<SidebarGroupSeparator
|
||||
v-show="isExpanded"
|
||||
:label
|
||||
:icon
|
||||
:collapsible
|
||||
:is-expanded="isSubGroupExpanded"
|
||||
:show-tree-line="showTreeLine"
|
||||
:end-tree-line="endTreeLine"
|
||||
:sort-options="sortOptions"
|
||||
:active-sort="activeSort"
|
||||
class="my-1"
|
||||
@toggle="toggleSubGroup"
|
||||
@update-sort="sortBy => emit('update-sort', sortBy)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
v-show="!scrollEnd"
|
||||
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="24"
|
||||
viewBox="0 0 16 24"
|
||||
fill="none"
|
||||
class="text-n-slate-9 opacity-50 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<ul
|
||||
v-if="children.length"
|
||||
class="m-0 list-none reset-base relative group min-w-0"
|
||||
:class="[
|
||||
{ 'ms-5': collapsible },
|
||||
showTreeLine && !endTreeLine && CHILDREN_TRUNK,
|
||||
]"
|
||||
>
|
||||
<path
|
||||
d="M4 4L8 8L12 4"
|
||||
stroke="currentColor"
|
||||
opacity="0.5"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 10L8 14L12 10"
|
||||
stroke="currentColor"
|
||||
opacity="0.75"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 16L8 20L12 16"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</ul>
|
||||
<div
|
||||
ref="scrollableContainer"
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'max-h-60 overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
>
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="shouldShowItem(child)"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
:hide-tree-line="hideLeafTreeLine"
|
||||
thin-tree-line
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
v-show="!scrollEnd"
|
||||
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-chevrons-down"
|
||||
class="w-4 h-6 text-n-slate-9 opacity-50 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
import SidebarSubGroup from '../SidebarSubGroup.vue';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { provideSidebarContext } from '../provider';
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useMapGetter: () => ref(1),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/usePolicy', () => ({
|
||||
usePolicy: () => ({
|
||||
shouldShow: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
resolve: () => ({ path: '/' }),
|
||||
getRoutes: () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const children = [
|
||||
{
|
||||
name: 'Sales-1',
|
||||
label: 'Sales',
|
||||
to: { name: 'team_conversations' },
|
||||
},
|
||||
];
|
||||
|
||||
const mountSubGroup = props => {
|
||||
return mount(
|
||||
{
|
||||
components: { SidebarSubGroup },
|
||||
setup() {
|
||||
provideSidebarContext({});
|
||||
},
|
||||
template: '<SidebarSubGroup v-bind="$attrs" />',
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
name: 'Conversation:Teams',
|
||||
label: 'Teams',
|
||||
icon: 'i-lucide-users',
|
||||
children,
|
||||
isExpanded: true,
|
||||
collapsible: true,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
SidebarGroupLeaf: {
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
hideTreeLine: { type: Boolean, default: false },
|
||||
thinTreeLine: { type: Boolean, default: false },
|
||||
},
|
||||
template:
|
||||
'<li class="sidebar-leaf" :data-hide-tree-line="String(hideTreeLine)" :data-thin-tree-line="String(thinTreeLine)">{{ label }}</li>',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
describe('SidebarSubGroup', () => {
|
||||
let localStorageStore;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageStore = {};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: key => localStorageStore[key] || null,
|
||||
setItem: (key, value) => {
|
||||
localStorageStore[key] = String(value);
|
||||
},
|
||||
removeItem: key => {
|
||||
delete localStorageStore[key];
|
||||
},
|
||||
clear: () => {
|
||||
localStorageStore = {};
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('keeps collapsible sections expanded by default', () => {
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the tree line on the separator, positioned relative to it', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
const button = wrapper.find('button');
|
||||
|
||||
expect(button.classes()).toContain('relative');
|
||||
expect(button.classes()).toContain('before:bg-n-slate-4');
|
||||
expect(button.classes()).toContain('before:-bottom-1');
|
||||
});
|
||||
|
||||
it('renders the end curve on the last separator', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true, endTreeLine: true });
|
||||
const button = wrapper.find('button');
|
||||
|
||||
expect(button.classes()).toContain('before:h-3');
|
||||
expect(button.classes()).toContain('after:border-b-2');
|
||||
});
|
||||
|
||||
it('draws nested item tree lines via the leaf connectors', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
|
||||
expect(
|
||||
wrapper.find('.sidebar-leaf').attributes('data-hide-tree-line')
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
it('marks nested item connectors as thin', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
|
||||
expect(
|
||||
wrapper.find('.sidebar-leaf').attributes('data-thin-tree-line')
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('minimizes the section and stores it by account and section name', async () => {
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
await wrapper.find('button').trigger('click');
|
||||
|
||||
const storedSections = JSON.parse(
|
||||
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
|
||||
);
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
|
||||
expect(storedSections).toEqual({ '1:Conversation:Teams': true });
|
||||
});
|
||||
|
||||
it('uses the stored minimized state when mounted again', () => {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
|
||||
JSON.stringify({ '1:Conversation:Teams': true })
|
||||
);
|
||||
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('expands a stored minimized section when one of its children is active', () => {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
|
||||
JSON.stringify({ '1:Conversation:Teams': true })
|
||||
);
|
||||
|
||||
const wrapper = mountSubGroup({ activeChild: children[0] });
|
||||
|
||||
const storedSections = JSON.parse(
|
||||
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
|
||||
);
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
|
||||
expect(storedSections).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,7 @@ const showDivider = index => {
|
||||
<template v-for="(tab, index) in tabs" :key="index">
|
||||
<button
|
||||
:ref="el => (tabRefs[index] = el)"
|
||||
type="button"
|
||||
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
|
||||
:class="[
|
||||
activeTab === index
|
||||
|
||||
@@ -308,6 +308,15 @@ function filterByAssigneeTab(conversations) {
|
||||
return [...conversations];
|
||||
}
|
||||
|
||||
function sortByUnreadStatus(conversations) {
|
||||
return [...conversations].sort((a, b) => {
|
||||
const unreadCountDiff = (b.unread_count || 0) - (a.unread_count || 0);
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
|
||||
return (b.last_activity_at || 0) - (a.last_activity_at || 0);
|
||||
});
|
||||
}
|
||||
|
||||
const conversationList = computed(() => {
|
||||
let localConversationList = [];
|
||||
|
||||
@@ -337,6 +346,13 @@ const conversationList = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!hasAppliedFiltersOrActiveFolders.value &&
|
||||
activeSortBy.value === wootConstants.SORT_BY_TYPE.UNREAD
|
||||
) {
|
||||
localConversationList = sortByUnreadStatus(localConversationList);
|
||||
}
|
||||
|
||||
return localConversationList;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
sessions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['revoke', 'revokeAll', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const revokingId = ref(null);
|
||||
const revokingAll = ref(false);
|
||||
|
||||
const sortedSessions = computed(() =>
|
||||
[...props.sessions].sort(
|
||||
(a, b) => new Date(b.created_at) - new Date(a.created_at)
|
||||
)
|
||||
);
|
||||
|
||||
const formatDate = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return format(parseISO(dateStr), 'MMMM d, yyyy');
|
||||
};
|
||||
|
||||
const formatTime = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return format(parseISO(dateStr), 'hh:mma');
|
||||
};
|
||||
|
||||
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
|
||||
|
||||
const sessionLabel = session => {
|
||||
const parts = [];
|
||||
if (!isUnknown(session.browser_name)) parts.push(session.browser_name);
|
||||
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
|
||||
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.sessions,
|
||||
() => {
|
||||
revokingId.value = null;
|
||||
revokingAll.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const handleRevoke = session => {
|
||||
revokingId.value = session.id;
|
||||
emit('revoke', session.id);
|
||||
};
|
||||
|
||||
const handleRevokeAll = () => {
|
||||
revokingAll.value = true;
|
||||
emit('revokeAll');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-lg mx-auto">
|
||||
<div
|
||||
class="bg-white shadow dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ $t('SESSION_LIMIT.TITLE') }}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11 mt-2">
|
||||
{{ $t('SESSION_LIMIT.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<NextButton
|
||||
type="button"
|
||||
faded
|
||||
sm
|
||||
class="flex-shrink-0 whitespace-nowrap"
|
||||
:label="$t('SESSION_LIMIT.END_ALL')"
|
||||
:is-loading="revokingAll"
|
||||
:disabled="revokingId !== null"
|
||||
@click="handleRevokeAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Session List -->
|
||||
<div class="flex flex-col gap-3 max-h-80 overflow-y-auto">
|
||||
<div
|
||||
v-for="session in sortedSessions"
|
||||
:key="session.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Icon
|
||||
icon="i-lucide-monitor"
|
||||
class="size-4 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ sessionLabel(session) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{
|
||||
`${$t('SESSION_LIMIT.STARTED')} ${formatDate(session.created_at)}, ${formatTime(session.created_at)}`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 flex-shrink-0 disabled:opacity-50"
|
||||
:disabled="revokingId !== null || revokingAll"
|
||||
@click="handleRevoke(session)"
|
||||
>
|
||||
{{ $t('SESSION_LIMIT.END') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel -->
|
||||
<div class="text-center pt-4">
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
link
|
||||
type="button"
|
||||
class="w-full hover:!no-underline"
|
||||
:label="$t('SESSION_LIMIT.CANCEL')"
|
||||
@click="() => emit('cancel')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,6 +78,10 @@ const chatSortOptions = computed(() => [
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_asc.TEXT'),
|
||||
value: 'created_at_asc',
|
||||
},
|
||||
{
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.unread.TEXT'),
|
||||
value: 'unread',
|
||||
},
|
||||
{
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc.TEXT'),
|
||||
value: 'priority_desc',
|
||||
|
||||
@@ -56,8 +56,9 @@ import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
const EmojiInput = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiInput.vue')
|
||||
const EmojiIconPicker = defineAsyncComponent(
|
||||
() =>
|
||||
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
|
||||
);
|
||||
|
||||
export default {
|
||||
@@ -66,7 +67,7 @@ export default {
|
||||
AttachmentPreview,
|
||||
AudioRecorder,
|
||||
ReplyBoxBanner,
|
||||
EmojiInput,
|
||||
EmojiIconPicker,
|
||||
MessageSignatureMissingAlert,
|
||||
ReplyBottomPanel,
|
||||
ReplyEmailHead,
|
||||
@@ -1284,13 +1285,15 @@ export default {
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
/>
|
||||
<EmojiInput
|
||||
<EmojiIconPicker
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
mode="emoji"
|
||||
class="emoji-dialog"
|
||||
:class="{
|
||||
'emoji-dialog--expanded': isOnExpandedLayout,
|
||||
}"
|
||||
:on-click="addIntoEditor"
|
||||
@select="addIntoEditor($event.value)"
|
||||
/>
|
||||
<ReplyEmailHead
|
||||
v-if="showReplyHead && isDefaultEditorMode"
|
||||
|
||||
@@ -70,7 +70,8 @@ describe('useFacebookPageConnect', () => {
|
||||
ACCOUNT_ID
|
||||
);
|
||||
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
|
||||
scope: expect.stringContaining('pages_show_list'),
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,pages_show_list,pages_read_engagement',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import ChannelApi from 'dashboard/api/channels';
|
||||
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
|
||||
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
// Page-management + messaging scopes required to list pages and create a
|
||||
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
|
||||
const FB_PAGE_SCOPES =
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
|
||||
|
||||
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
|
||||
// FB.login for page scopes, and fetch the user's pages. The caller owns the
|
||||
// page-picker UI and the channel creation, because choosing a page is an
|
||||
@@ -51,7 +47,7 @@ export function useFacebookPageConnect() {
|
||||
: null
|
||||
);
|
||||
},
|
||||
{ scope: FB_PAGE_SCOPES }
|
||||
{ scope: buildFacebookLoginScopes() }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export default {
|
||||
WAITING_SINCE_ASC: 'waiting_since_asc',
|
||||
WAITING_SINCE_DESC: 'waiting_since_desc',
|
||||
PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc',
|
||||
UNREAD: 'unread',
|
||||
},
|
||||
ARTICLE_STATUS_TYPES: {
|
||||
DRAFT: 0,
|
||||
|
||||
@@ -7,4 +7,5 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
||||
MESSAGE_REPLY_TO: 'messageReplyTo',
|
||||
RECENT_SEARCHES: 'recentSearches',
|
||||
SIDEBAR_MINIMIZED_SECTIONS: 'sidebarMinimizedSections',
|
||||
};
|
||||
|
||||
@@ -154,6 +154,11 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const SESSION_EVENTS = Object.freeze({
|
||||
LIMIT_HIT: 'Session limit reached at login',
|
||||
REVOKED_FROM_PROFILE: 'Revoked an active session',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const FACEBOOK_PAGE_SCOPES = [
|
||||
'pages_manage_metadata',
|
||||
'business_management',
|
||||
'pages_messaging',
|
||||
'pages_show_list',
|
||||
'pages_read_engagement',
|
||||
];
|
||||
|
||||
export const INSTAGRAM_SCOPES = [
|
||||
'instagram_basic',
|
||||
'instagram_manage_messages',
|
||||
];
|
||||
|
||||
export const buildFacebookLoginScopes = ({
|
||||
includeInstagramScopes = false,
|
||||
} = {}) => {
|
||||
const scopes = [...FACEBOOK_PAGE_SCOPES];
|
||||
if (includeInstagramScopes) {
|
||||
scopes.push(...INSTAGRAM_SCOPES);
|
||||
}
|
||||
return scopes.join(',');
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
export const SIDEBAR_SORT_KEYS = Object.freeze({
|
||||
CREATED_DESC: 'created_at_desc',
|
||||
CREATED_ASC: 'created_at_asc',
|
||||
ALPHABETICAL_ASC: 'alphabetical_asc',
|
||||
ALPHABETICAL_DESC: 'alphabetical_desc',
|
||||
UNREAD_COUNT_DESC: 'unread_count_desc',
|
||||
UNREAD_COUNT_ASC: 'unread_count_asc',
|
||||
});
|
||||
|
||||
export const SIDEBAR_SORT_SECTIONS = Object.freeze({
|
||||
FOLDERS: 'folders',
|
||||
TEAMS: 'teams',
|
||||
CHANNELS: 'channels',
|
||||
LABELS: 'labels',
|
||||
});
|
||||
|
||||
export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
],
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
[SIDEBAR_SORT_SECTIONS.CHANNELS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
[SIDEBAR_SORT_SECTIONS.LABELS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
});
|
||||
|
||||
const UNREAD_COUNT_SORT_OPTIONS = [
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
];
|
||||
|
||||
export const DEFAULT_SIDEBAR_SORT_PREFERENCES = Object.freeze({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.CHANNELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.LABELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
});
|
||||
|
||||
export const isValidSidebarSort = (section, sortBy) => {
|
||||
return SIDEBAR_SORT_OPTIONS_BY_SECTION[section]?.includes(sortBy);
|
||||
};
|
||||
|
||||
const isUnreadCountSort = sortBy => UNREAD_COUNT_SORT_OPTIONS.includes(sortBy);
|
||||
|
||||
export const getSidebarSortOptions = (
|
||||
section,
|
||||
{ hasUnreadCounts = true } = {}
|
||||
) => {
|
||||
const options = SIDEBAR_SORT_OPTIONS_BY_SECTION[section] || [];
|
||||
|
||||
if (hasUnreadCounts) return options;
|
||||
|
||||
return options.filter(option => !isUnreadCountSort(option));
|
||||
};
|
||||
|
||||
export const resolveSidebarSort = (
|
||||
section,
|
||||
sortBy,
|
||||
{ hasUnreadCounts = true } = {}
|
||||
) => {
|
||||
const options = getSidebarSortOptions(section, { hasUnreadCounts });
|
||||
|
||||
if (options.includes(sortBy)) return sortBy;
|
||||
if (!hasUnreadCounts && isUnreadCountSort(sortBy)) {
|
||||
return SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
|
||||
}
|
||||
|
||||
return options[0] || SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
|
||||
};
|
||||
|
||||
export const normalizeSidebarSortPreferences = (preferences = {}) => {
|
||||
const savedPreferences = preferences || {};
|
||||
|
||||
return Object.keys(DEFAULT_SIDEBAR_SORT_PREFERENCES).reduce(
|
||||
(result, section) => {
|
||||
const sortBy = savedPreferences[section];
|
||||
result[section] = isValidSidebarSort(section, sortBy)
|
||||
? sortBy
|
||||
: DEFAULT_SIDEBAR_SORT_PREFERENCES[section];
|
||||
return result;
|
||||
},
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeUnreadCount = count => {
|
||||
const unreadCount = Number(count);
|
||||
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
|
||||
};
|
||||
|
||||
const getCreatedValue = item => {
|
||||
const createdAt = item.created_at || item.createdAt;
|
||||
|
||||
if (typeof createdAt === 'number') return createdAt;
|
||||
|
||||
if (createdAt) {
|
||||
const timestamp = Date.parse(createdAt);
|
||||
if (Number.isFinite(timestamp)) return timestamp;
|
||||
}
|
||||
|
||||
const id = Number(item.id);
|
||||
return Number.isFinite(id) ? id : 0;
|
||||
};
|
||||
|
||||
const getLabelValue = (item, labelKey) => {
|
||||
return String(labelKey(item) || '');
|
||||
};
|
||||
|
||||
const compareAlphabetically = (a, b, labelKey) => {
|
||||
return getLabelValue(a, labelKey).localeCompare(
|
||||
getLabelValue(b, labelKey),
|
||||
undefined,
|
||||
{
|
||||
sensitivity: 'base',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const sortSidebarItems = (
|
||||
items,
|
||||
{ sortBy, labelKey, unreadCountKey = () => 0 }
|
||||
) => {
|
||||
return (items || []).slice().sort((a, b) => {
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_DESC) {
|
||||
const createdDiff = getCreatedValue(b) - getCreatedValue(a);
|
||||
if (createdDiff !== 0) return createdDiff;
|
||||
return compareAlphabetically(a, b, labelKey);
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_ASC) {
|
||||
const createdDiff = getCreatedValue(a) - getCreatedValue(b);
|
||||
if (createdDiff !== 0) return createdDiff;
|
||||
return compareAlphabetically(a, b, labelKey);
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
|
||||
return compareAlphabetically(b, a, labelKey);
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(b)) -
|
||||
normalizeUnreadCount(unreadCountKey(a));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(a)) -
|
||||
normalizeUnreadCount(unreadCountKey(b));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
}
|
||||
|
||||
return compareAlphabetically(a, b, labelKey);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
SIDEBAR_SORT_KEYS,
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
getSidebarSortOptions,
|
||||
normalizeSidebarSortPreferences,
|
||||
resolveSidebarSort,
|
||||
sortSidebarItems,
|
||||
} from '../sidebarSort';
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Billing',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Accounts',
|
||||
created_at: '2024-03-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Support',
|
||||
created_at: '2024-02-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
describe('#sortSidebarItems', () => {
|
||||
it('sorts by created date descending', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Accounts',
|
||||
'Support',
|
||||
'Billing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by created date ascending', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Billing',
|
||||
'Support',
|
||||
'Accounts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to id when created date is not present', () => {
|
||||
const sortedItems = sortSidebarItems(
|
||||
items.map(({ created_at: _createdAt, ...item }) => item),
|
||||
{
|
||||
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
labelKey: item => item.name,
|
||||
}
|
||||
);
|
||||
|
||||
expect(sortedItems.map(item => item.id)).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('sorts alphabetically from A to Z', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Accounts',
|
||||
'Billing',
|
||||
'Support',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts alphabetically from Z to A', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Support',
|
||||
'Billing',
|
||||
'Accounts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by unread count descending and falls back to alphabetical order', () => {
|
||||
const unreadCounts = {
|
||||
1: 3,
|
||||
2: 3,
|
||||
3: 7,
|
||||
};
|
||||
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
labelKey: item => item.name,
|
||||
unreadCountKey: item => unreadCounts[item.id],
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Accounts',
|
||||
'Billing',
|
||||
'Support',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by unread count ascending and falls back to alphabetical order', () => {
|
||||
const unreadCounts = {
|
||||
1: 3,
|
||||
2: 3,
|
||||
3: 7,
|
||||
};
|
||||
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
labelKey: item => item.name,
|
||||
unreadCountKey: item => unreadCounts[item.id],
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Billing',
|
||||
'Support',
|
||||
'Accounts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#normalizeSidebarSortPreferences', () => {
|
||||
it('keeps valid preferences', () => {
|
||||
const preferences = normalizeSidebarSortPreferences({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
});
|
||||
|
||||
expect(preferences).toEqual({
|
||||
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to defaults for unsupported preferences', () => {
|
||||
const preferences = normalizeSidebarSortPreferences({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
});
|
||||
|
||||
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
|
||||
});
|
||||
|
||||
it('falls back to defaults when stored preferences are null', () => {
|
||||
expect(normalizeSidebarSortPreferences(null)).toEqual(
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getSidebarSortOptions', () => {
|
||||
it('keeps unread count options when unread counts are enabled', () => {
|
||||
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
|
||||
hasUnreadCounts: true,
|
||||
});
|
||||
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
|
||||
});
|
||||
|
||||
it('removes unread count options when unread counts are disabled', () => {
|
||||
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
|
||||
hasUnreadCounts: false,
|
||||
});
|
||||
|
||||
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
|
||||
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolveSidebarSort', () => {
|
||||
it('keeps unread count sort when unread counts are enabled', () => {
|
||||
const sortBy = resolveSidebarSort(
|
||||
SIDEBAR_SORT_SECTIONS.TEAMS,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
{ hasUnreadCounts: true }
|
||||
);
|
||||
|
||||
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
|
||||
});
|
||||
|
||||
it('falls back to alphabetical sort when unread counts are disabled', () => {
|
||||
const sortBy = resolveSidebarSort(
|
||||
SIDEBAR_SORT_SECTIONS.TEAMS,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
{ hasUnreadCounts: false }
|
||||
);
|
||||
|
||||
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,9 @@
|
||||
},
|
||||
"priority_desc_created_at_asc": {
|
||||
"TEXT": "Priority: Highest first, Created: Oldest first"
|
||||
},
|
||||
"unread": {
|
||||
"TEXT": "Unread Count: Highest first"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -511,6 +511,7 @@
|
||||
"ATTRIBUTES": "Attributes",
|
||||
"HISTORY": "History",
|
||||
"NOTES": "Notes",
|
||||
"MEDIA": "Media",
|
||||
"MERGE": "Merge"
|
||||
},
|
||||
"HISTORY": {
|
||||
|
||||
@@ -403,7 +403,8 @@
|
||||
"VIEW_ALL": "View all",
|
||||
"SHOW_LESS": "Show less",
|
||||
"MORE_COUNT": "+{count}",
|
||||
"UNTITLED_FILE": "Untitled file"
|
||||
"UNTITLED_FILE": "Untitled file",
|
||||
"JUMP_TO_MESSAGE": "Jump to message"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
"ORDER_ID": "Order #{id}",
|
||||
|
||||
@@ -3,5 +3,33 @@
|
||||
"PLACEHOLDER": "Search emojis",
|
||||
"NOT_FOUND": "No emoji match your search",
|
||||
"REMOVE": "Remove"
|
||||
},
|
||||
"EMOJI_ICON_PICKER": {
|
||||
"TABS": {
|
||||
"ICONS": "Icons",
|
||||
"EMOJIS": "Emojis"
|
||||
},
|
||||
"SEARCH_EMOJI": "Search emoji…",
|
||||
"SEARCH_ICON": "Search icons…",
|
||||
"FREQUENTLY_USED": "Frequently used",
|
||||
"NO_EMOJI": "No emoji match your search",
|
||||
"NO_ICON": "No icons match your search",
|
||||
"REMOVE": "Remove",
|
||||
"STYLE": {
|
||||
"OUTLINE": "Outline icons",
|
||||
"FILLED": "Filled icons"
|
||||
},
|
||||
"COLORS": {
|
||||
"SLATE": "Slate",
|
||||
"RED": "Red",
|
||||
"ORANGE": "Orange",
|
||||
"AMBER": "Amber",
|
||||
"GREEN": "Green",
|
||||
"TEAL": "Teal",
|
||||
"BLUE": "Blue",
|
||||
"INDIGO": "Indigo",
|
||||
"VIOLET": "Violet",
|
||||
"PINK": "Pink"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import onboarding from './onboarding.json';
|
||||
import sessionLimit from './sessionLimit.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
@@ -85,5 +86,6 @@ export default {
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...sessionLimit,
|
||||
...yearInReview,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"SESSION_LIMIT": {
|
||||
"TITLE": "Active session limit reached",
|
||||
"DESCRIPTION": "You have reached your limit of active sessions. Please end a session before logging in.",
|
||||
"END": "End",
|
||||
"END_ALL": "End all sessions",
|
||||
"LOG_IN": "Log in",
|
||||
"CANCEL": "Back to login",
|
||||
"UNKNOWN_DEVICE": "Unknown device",
|
||||
"STARTED": "Started"
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,17 @@
|
||||
"NOTE": "Manage additional security features for your account.",
|
||||
"MFA_BUTTON": "Manage Two-Factor Authentication"
|
||||
},
|
||||
"SESSIONS_SECTION": {
|
||||
"TITLE": "Active Sessions",
|
||||
"NOTE": "These are the devices currently logged in to your account.",
|
||||
"CURRENT": "Current session",
|
||||
"REVOKE": "Revoke",
|
||||
"REVOKE_SUCCESS": "Session revoked successfully",
|
||||
"REVOKE_ERROR": "Unable to revoke session. Please try again.",
|
||||
"FETCH_ERROR": "Unable to fetch sessions. Please try again.",
|
||||
"LAST_ACTIVE": "Last active",
|
||||
"UNKNOWN_DEVICE": "Unknown device"
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"NOTE": "This token can be used if you are building an API based integration",
|
||||
@@ -379,6 +390,21 @@
|
||||
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
|
||||
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
||||
},
|
||||
"SORT_TOOLTIP": "Sort",
|
||||
"SORT_BY": "Sort by",
|
||||
"SORT_GROUPS": {
|
||||
"CREATED": "Sort by date of creation",
|
||||
"ALPHABETICAL": "Sort in alphabetical order",
|
||||
"UNREAD_COUNT": "Sort by unread count"
|
||||
},
|
||||
"SORT_OPTIONS": {
|
||||
"CREATED_DESC": "Newest first",
|
||||
"CREATED_ASC": "Oldest first",
|
||||
"ALPHABETICAL_ASC": "Ascending (A - Z)",
|
||||
"ALPHABETICAL_DESC": "Descending (Z - A)",
|
||||
"UNREAD_COUNT_DESC": "Highest first",
|
||||
"UNREAD_COUNT_ASC": "Lowest first"
|
||||
},
|
||||
"DOCS": "Read docs",
|
||||
"SECURITY": "Security",
|
||||
"CAPTAIN_AI": "Captain",
|
||||
|
||||
@@ -11,6 +11,7 @@ import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDeta
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
|
||||
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
|
||||
import ContactMedia from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue';
|
||||
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
|
||||
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
|
||||
|
||||
@@ -40,6 +41,7 @@ const CONTACT_TABS_OPTIONS = [
|
||||
{ key: 'ATTRIBUTES', value: 'attributes' },
|
||||
{ key: 'HISTORY', value: 'history' },
|
||||
{ key: 'NOTES', value: 'notes' },
|
||||
{ key: 'MEDIA', value: 'media' },
|
||||
{ key: 'MERGE', value: 'merge' },
|
||||
];
|
||||
|
||||
@@ -149,8 +151,8 @@ onMounted(() => {
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="goToContactsList"
|
||||
/>
|
||||
<template #sidebar>
|
||||
<div class="px-6">
|
||||
<template #sidebarHeader>
|
||||
<div class="px-6 pt-6 pb-3">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeTabIndex"
|
||||
@@ -158,6 +160,8 @@ onMounted(() => {
|
||||
@tab-changed="handleTabChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<div
|
||||
v-if="isFetchingItem"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
@@ -171,6 +175,7 @@ onMounted(() => {
|
||||
/>
|
||||
<ContactNotes v-if="activeTab === 'notes'" />
|
||||
<ContactHistory v-if="activeTab === 'history'" />
|
||||
<ContactMedia v-if="activeTab === 'media'" />
|
||||
<ContactMerge
|
||||
v-if="activeTab === 'merge'"
|
||||
ref="contactMergeRef"
|
||||
|
||||
@@ -2,413 +2,70 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { formatBytes } from 'shared/helpers/FileHelper';
|
||||
import {
|
||||
dynamicTime,
|
||||
formatDuration,
|
||||
shortTimestamp,
|
||||
} from 'shared/helpers/timeHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
import {
|
||||
ATTACHMENT_TYPES,
|
||||
MEDIA_TYPES,
|
||||
NON_FILE_TYPES,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import FileIcon from 'next/icon/FileIcon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
|
||||
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const MEDIA_PEEK_LIMIT = 6;
|
||||
const FILES_PEEK_LIMIT = 3;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const allAttachments = useMapGetter('getSelectedChatAttachments');
|
||||
const attachmentsLoaded = useMapGetter('getSelectedChatAttachmentsLoaded');
|
||||
|
||||
const sortedAttachments = computed(() =>
|
||||
[...allAttachments.value].sort(
|
||||
(a, b) => (b.created_at || 0) - (a.created_at || 0)
|
||||
)
|
||||
);
|
||||
const { t } = useI18n();
|
||||
|
||||
const mediaAttachments = computed(() =>
|
||||
sortedAttachments.value.filter(a => MEDIA_TYPES.includes(a.file_type))
|
||||
allAttachments.value
|
||||
.filter(a => MEDIA_TYPES.includes(a.file_type) && a.data_url)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
);
|
||||
|
||||
const fileAttachments = computed(() =>
|
||||
sortedAttachments.value.filter(
|
||||
a => !MEDIA_TYPES.includes(a.file_type) && a.data_url
|
||||
const hasContent = computed(() =>
|
||||
allAttachments.value.some(
|
||||
a => a.data_url && !NON_FILE_TYPES.includes(a.file_type)
|
||||
)
|
||||
);
|
||||
|
||||
const showAllMedia = ref(false);
|
||||
const showAllFiles = ref(false);
|
||||
|
||||
const visibleMedia = computed(() =>
|
||||
showAllMedia.value
|
||||
? mediaAttachments.value
|
||||
: mediaAttachments.value.slice(0, MEDIA_PEEK_LIMIT)
|
||||
);
|
||||
|
||||
const visibleFiles = computed(() =>
|
||||
showAllFiles.value
|
||||
? fileAttachments.value
|
||||
: fileAttachments.value.slice(0, FILES_PEEK_LIMIT)
|
||||
);
|
||||
|
||||
const mediaOverflow = computed(() => {
|
||||
const total = mediaAttachments.value.length;
|
||||
return total > MEDIA_PEEK_LIMIT ? total - (MEDIA_PEEK_LIMIT - 1) : 0;
|
||||
});
|
||||
|
||||
const showGallery = ref(false);
|
||||
const selectedAttachment = ref(null);
|
||||
const downloadingId = ref(null);
|
||||
|
||||
const fileNameFromUrl = url => {
|
||||
if (!url) return '';
|
||||
const name = url.split('/').pop();
|
||||
return name ? decodeURIComponent(name) : '';
|
||||
};
|
||||
|
||||
const onDownloadFile = async attachment => {
|
||||
const { id, file_type: type, data_url: url, extension } = attachment;
|
||||
try {
|
||||
downloadingId.value = id;
|
||||
await downloadFile({ url, type, extension });
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
|
||||
} finally {
|
||||
downloadingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const isVideoType = type =>
|
||||
[ATTACHMENT_TYPES.VIDEO, ATTACHMENT_TYPES.IG_REEL].includes(type);
|
||||
|
||||
const isAudioType = type => type === ATTACHMENT_TYPES.AUDIO;
|
||||
const isPlayableType = type => isVideoType(type) || isAudioType(type);
|
||||
|
||||
const durations = ref({});
|
||||
|
||||
const onLoadedMetadata = (attachment, event) => {
|
||||
const seconds = event.target?.duration;
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
durations.value[attachment.id] = seconds;
|
||||
}
|
||||
};
|
||||
|
||||
const displayDuration = attachment => {
|
||||
const seconds = durations.value[attachment.id];
|
||||
return seconds ? formatDuration(Math.round(seconds)) : '';
|
||||
};
|
||||
|
||||
const isOverflowTile = index =>
|
||||
!showAllMedia.value &&
|
||||
mediaOverflow.value > 0 &&
|
||||
index === MEDIA_PEEK_LIMIT - 1;
|
||||
|
||||
const onTileActivate = (attachment, index) => {
|
||||
if (isOverflowTile(index)) {
|
||||
showAllMedia.value = true;
|
||||
return;
|
||||
}
|
||||
const onMediaSelect = attachment => {
|
||||
selectedAttachment.value = attachment;
|
||||
showGallery.value = true;
|
||||
};
|
||||
|
||||
const failedThumbs = ref(new Set());
|
||||
const failedPreviews = ref(new Set());
|
||||
|
||||
const imagePreviewSrc = ({
|
||||
id,
|
||||
file_type: type,
|
||||
thumb_url: thumbUrl,
|
||||
data_url: dataUrl,
|
||||
}) => {
|
||||
const canUseThumb = thumbUrl && !failedThumbs.value.has(id);
|
||||
if (type === ATTACHMENT_TYPES.IMAGE) return canUseThumb ? thumbUrl : dataUrl;
|
||||
if (isVideoType(type)) return canUseThumb ? thumbUrl : null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const onPreviewError = ({
|
||||
id,
|
||||
file_type: type,
|
||||
thumb_url: thumbUrl,
|
||||
data_url: dataUrl,
|
||||
}) => {
|
||||
const canRetryWithFull = thumbUrl && !failedThumbs.value.has(id) && dataUrl;
|
||||
if (
|
||||
canRetryWithFull &&
|
||||
(type === ATTACHMENT_TYPES.IMAGE || isVideoType(type))
|
||||
) {
|
||||
failedThumbs.value.add(id);
|
||||
return;
|
||||
const onFileSelect = attachment => {
|
||||
if (attachment.data_url) {
|
||||
window.open(attachment.data_url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
failedPreviews.value.add(id);
|
||||
};
|
||||
|
||||
const hasPreview = attachment =>
|
||||
!!imagePreviewSrc(attachment) && !failedPreviews.value.has(attachment.id);
|
||||
const hasVideoPreview = attachment =>
|
||||
isVideoType(attachment.file_type) &&
|
||||
attachment.data_url &&
|
||||
!failedPreviews.value.has(attachment.id);
|
||||
|
||||
const fallbackIcon = type => {
|
||||
if (type === ATTACHMENT_TYPES.AUDIO) return 'i-lucide-music';
|
||||
if (isVideoType(type)) return 'i-lucide-video';
|
||||
return 'i-lucide-image';
|
||||
};
|
||||
|
||||
const displayName = attachment =>
|
||||
fileNameFromUrl(attachment.data_url) ||
|
||||
t('CONVERSATION_SIDEBAR.SHARED_FILES.UNTITLED_FILE');
|
||||
|
||||
const displaySize = attachment => {
|
||||
if (attachment.file_size) return formatBytes(attachment.file_size);
|
||||
if (attachment.extension) return attachment.extension.toUpperCase();
|
||||
return '—';
|
||||
};
|
||||
|
||||
const displayTime = attachment => {
|
||||
if (!attachment.created_at) return '';
|
||||
return shortTimestamp(dynamicTime(attachment.created_at), true);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 p-2">
|
||||
<div class="p-2">
|
||||
<div v-if="!attachmentsLoaded" class="flex justify-center p-3">
|
||||
<Spinner class="size-5" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="!mediaAttachments.length && !fileAttachments.length"
|
||||
class="p-3 text-sm text-center text-n-slate-11"
|
||||
>
|
||||
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
|
||||
</p>
|
||||
|
||||
<section v-if="mediaAttachments.length" class="flex flex-col gap-2.5">
|
||||
<header class="flex items-center justify-between px-0.5">
|
||||
<h4
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
|
||||
<span
|
||||
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
|
||||
>
|
||||
{{ mediaAttachments.length }}
|
||||
</span>
|
||||
</h4>
|
||||
<NextButton
|
||||
v-if="mediaOverflow > 0"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
trailing-icon
|
||||
:icon="
|
||||
showAllMedia ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'
|
||||
"
|
||||
:label="
|
||||
showAllMedia
|
||||
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
|
||||
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
|
||||
"
|
||||
@click="showAllMedia = !showAllMedia"
|
||||
/>
|
||||
</header>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="(attachment, index) in visibleMedia"
|
||||
:key="attachment.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="relative w-full overflow-hidden transition-all duration-200 rounded-lg cursor-pointer aspect-square bg-n-slate-3 shadow-sm hover:shadow-md hover:-translate-y-px group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
|
||||
@click="onTileActivate(attachment, index)"
|
||||
@keydown.enter="onTileActivate(attachment, index)"
|
||||
@keydown.space.prevent="onTileActivate(attachment, index)"
|
||||
>
|
||||
<template v-if="!isOverflowTile(index)">
|
||||
<img
|
||||
v-if="hasPreview(attachment)"
|
||||
:src="imagePreviewSrc(attachment)"
|
||||
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
:alt="fileNameFromUrl(attachment.data_url)"
|
||||
@error="onPreviewError(attachment)"
|
||||
/>
|
||||
<video
|
||||
v-else-if="hasVideoPreview(attachment)"
|
||||
:src="`${attachment.data_url}#t=0.1`"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110 pointer-events-none"
|
||||
@loadedmetadata="onLoadedMetadata(attachment, $event)"
|
||||
@error="onPreviewError(attachment)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full h-full bg-gradient-to-br from-n-slate-3 to-n-slate-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="fallbackIcon(attachment.file_type)"
|
||||
class="size-6 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
v-if="isAudioType(attachment.file_type) && attachment.data_url"
|
||||
:src="attachment.data_url"
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
@loadedmetadata="onLoadedMetadata(attachment, $event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 transition-opacity duration-200 opacity-0 pointer-events-none group-hover:opacity-100 bg-gradient-to-t from-black/40 via-transparent to-transparent"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="hasVideoPreview(attachment)"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none bg-gradient-to-t from-black/30 via-transparent to-transparent"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full size-7 bg-white/95 shadow-md"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-play"
|
||||
class="ms-0.5 size-3.5 text-n-black"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="
|
||||
isPlayableType(attachment.file_type) &&
|
||||
displayDuration(attachment)
|
||||
"
|
||||
class="absolute text-xxs font-medium tabular-nums transition-opacity bottom-1.5 ltr:right-1.5 rtl:left-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-0"
|
||||
>
|
||||
{{ displayDuration(attachment) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="displayTime(attachment)"
|
||||
class="absolute text-xxs font-medium transition-opacity opacity-0 bottom-1.5 ltr:left-1.5 rtl:right-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-100"
|
||||
>
|
||||
{{ displayTime(attachment) }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 bottom-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white disabled:opacity-50"
|
||||
:disabled="downloadingId === attachment.id"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
|
||||
@click.stop="onDownloadFile(attachment)"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
<Icon icon="i-lucide-download" class="size-3 text-n-black" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="isOverflowTile(index)"
|
||||
class="absolute inset-0 flex items-center justify-center bg-n-slate-5"
|
||||
>
|
||||
<span class="text-base font-semibold text-n-slate-12">
|
||||
{{
|
||||
t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
|
||||
count: mediaOverflow,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="fileAttachments.length" class="flex flex-col gap-2.5">
|
||||
<header class="flex items-center justify-between px-0.5">
|
||||
<h4
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
|
||||
<span
|
||||
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
|
||||
>
|
||||
{{ fileAttachments.length }}
|
||||
</span>
|
||||
</h4>
|
||||
<NextButton
|
||||
v-if="fileAttachments.length > FILES_PEEK_LIMIT"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
trailing-icon
|
||||
:icon="
|
||||
showAllFiles ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'
|
||||
"
|
||||
:label="
|
||||
showAllFiles
|
||||
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
|
||||
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
|
||||
"
|
||||
@click="showAllFiles = !showAllFiles"
|
||||
/>
|
||||
</header>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
<li
|
||||
v-for="attachment in visibleFiles"
|
||||
:key="attachment.id"
|
||||
class="flex items-center gap-3 px-2 py-2 transition-colors rounded-lg hover:bg-n-slate-3 group"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-gradient-to-br from-n-slate-3 to-n-slate-4 ring-1 ring-inset ring-n-slate-4/40"
|
||||
>
|
||||
<FileIcon
|
||||
:file-type="attachment.extension?.toLowerCase() || ''"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
:href="attachment.data_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex-1 min-w-0"
|
||||
:title="displayName(attachment)"
|
||||
>
|
||||
<p class="text-sm font-medium truncate text-n-slate-12 mb-1">
|
||||
{{ displayName(attachment) }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ displaySize(attachment) }}
|
||||
<template v-if="displayTime(attachment)">
|
||||
· {{ displayTime(attachment) }}
|
||||
</template>
|
||||
</p>
|
||||
</a>
|
||||
<NextButton
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-download"
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
:is-loading="downloadingId === attachment.id"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
|
||||
@click="onDownloadFile(attachment)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div v-else class="flex flex-col gap-5">
|
||||
<Media
|
||||
:attachments="allAttachments"
|
||||
:peek-limit="MEDIA_PEEK_LIMIT"
|
||||
@select="onMediaSelect"
|
||||
/>
|
||||
<Files
|
||||
:attachments="allAttachments"
|
||||
:peek-limit="FILES_PEEK_LIMIT"
|
||||
@select="onFileSelect"
|
||||
/>
|
||||
</div>
|
||||
<GalleryView
|
||||
v-if="showGallery && selectedAttachment"
|
||||
v-model:show="showGallery"
|
||||
|
||||
@@ -4,6 +4,7 @@ import InboxReconnectionRequired from '../components/InboxReconnectionRequired.v
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
|
||||
export default {
|
||||
@@ -20,6 +21,11 @@ export default {
|
||||
inboxId() {
|
||||
return this.inbox.id;
|
||||
},
|
||||
facebookLoginScopes() {
|
||||
return buildFacebookLoginScopes({
|
||||
includeInstagramScopes: !!this.inbox.instagram_id,
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.fbAsyncInit = this.runFBInit;
|
||||
@@ -77,8 +83,7 @@ export default {
|
||||
}
|
||||
},
|
||||
{
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
|
||||
scope: this.facebookLoginScopes,
|
||||
auth_type: 'reauthorize',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import authAPI from 'dashboard/api/auth';
|
||||
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
|
||||
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessions = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const relativeTime = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return formatDistanceToNow(parseISO(dateStr), { addSuffix: true });
|
||||
};
|
||||
|
||||
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
|
||||
|
||||
const deviceIcon = session => {
|
||||
const name = (session.device_name || '').toLowerCase();
|
||||
if (
|
||||
name.includes('iphone') ||
|
||||
name.includes('android') ||
|
||||
name.includes('mobile')
|
||||
) {
|
||||
return 'i-lucide-smartphone';
|
||||
}
|
||||
if (name.includes('ipad') || name.includes('tablet')) {
|
||||
return 'i-lucide-tablet';
|
||||
}
|
||||
return 'i-lucide-monitor';
|
||||
};
|
||||
|
||||
const sessionLabel = session => {
|
||||
const parts = [];
|
||||
if (!isUnknown(session.browser_name)) {
|
||||
parts.push(
|
||||
session.browser_version
|
||||
? `${session.browser_name} ${session.browser_version}`
|
||||
: session.browser_name
|
||||
);
|
||||
}
|
||||
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
|
||||
return (
|
||||
parts.join(' on ') ||
|
||||
t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.UNKNOWN_DEVICE')
|
||||
);
|
||||
};
|
||||
|
||||
const locationLabel = session => {
|
||||
const parts = [];
|
||||
if (session.city) parts.push(session.city);
|
||||
if (session.country) parts.push(session.country);
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await authAPI.getSessions();
|
||||
sessions.value = data;
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.FETCH_ERROR'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const revokeSession = async session => {
|
||||
try {
|
||||
await authAPI.revokeSession(session.id);
|
||||
sessions.value = sessions.value.filter(s => s.id !== session.id);
|
||||
AnalyticsHelper.track(SESSION_EVENTS.REVOKED_FROM_PROFILE);
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchSessions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background p-4"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon
|
||||
:icon="deviceIcon(session)"
|
||||
class="size-5 mt-0.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ sessionLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.current"
|
||||
class="rounded-full bg-n-teal-3 px-2 py-0.5 text-caption text-n-teal-11"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.CURRENT') }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="locationLabel(session)"
|
||||
class="text-body-b3 text-n-slate-11"
|
||||
>
|
||||
{{ locationLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.last_activity_at"
|
||||
class="text-body-b3 text-n-slate-10"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.LAST_ACTIVE') }}
|
||||
{{ relativeTime(session.last_activity_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!session.current"
|
||||
type="button"
|
||||
faded
|
||||
xs
|
||||
:label="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE')"
|
||||
color="ruby"
|
||||
@click="revokeSession(session)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,6 +20,7 @@ import SectionLayout from '../account/components/SectionLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import AccessToken from './AccessToken.vue';
|
||||
import MfaSettingsCard from './MfaSettingsCard.vue';
|
||||
import ActiveSessions from './ActiveSessions.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ export default {
|
||||
AudioNotifications,
|
||||
AccessToken,
|
||||
MfaSettingsCard,
|
||||
ActiveSessions,
|
||||
BaseSettingsHeader,
|
||||
},
|
||||
setup() {
|
||||
@@ -307,6 +309,13 @@ export default {
|
||||
>
|
||||
<MfaSettingsCard />
|
||||
</SectionLayout>
|
||||
<SectionLayout
|
||||
with-border
|
||||
:title="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.TITLE')"
|
||||
:description="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.NOTE')"
|
||||
>
|
||||
<ActiveSessions />
|
||||
</SectionLayout>
|
||||
<Policy :permissions="audioNotificationPermissions">
|
||||
<SectionLayout
|
||||
with-border
|
||||
|
||||
@@ -44,6 +44,7 @@ import portals from './modules/helpCenterPortals';
|
||||
import reports from './modules/reports';
|
||||
import sla from './modules/sla';
|
||||
import slaReports from './modules/SLAReports';
|
||||
import sidebarSortPreferences from './modules/sidebarSortPreferences';
|
||||
import summaryReports from './modules/summaryReports';
|
||||
import teamMembers from './modules/teamMembers';
|
||||
import teams from './modules/teams';
|
||||
@@ -108,6 +109,7 @@ export default createStore({
|
||||
reports,
|
||||
sla,
|
||||
slaReports,
|
||||
sidebarSortPreferences,
|
||||
summaryReports,
|
||||
teamMembers,
|
||||
teams,
|
||||
|
||||
@@ -25,7 +25,9 @@ export const createConversationPayload = ({ params, contactId, files }) => {
|
||||
payload.append('inbox_id', inboxId);
|
||||
payload.append('contact_id', contactId);
|
||||
payload.append('source_id', sourceId);
|
||||
payload.append('additional_attributes[mail_subject]', mailSubject);
|
||||
if (mailSubject) {
|
||||
payload.append('additional_attributes[mail_subject]', mailSubject);
|
||||
}
|
||||
payload.append('assignee_id', assigneeId);
|
||||
|
||||
return payload;
|
||||
|
||||
@@ -2,12 +2,12 @@ import {
|
||||
DuplicateContactException,
|
||||
ExceptionWithMessage,
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
import types from '../../mutation-types';
|
||||
import ContactAPI from '../../../api/contacts';
|
||||
import snakecaseKeys from 'snakecase-keys';
|
||||
import AccountActionsAPI from '../../../api/accountActions';
|
||||
import ContactAPI from '../../../api/contacts';
|
||||
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
|
||||
import { CONTACTS_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import types from '../../mutation-types';
|
||||
|
||||
const buildContactFormData = contactParams => {
|
||||
const formData = new FormData();
|
||||
@@ -114,6 +114,19 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
fetchAttachments: async ({ commit }, id) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: true });
|
||||
try {
|
||||
const response = await ContactAPI.getAttachments(id);
|
||||
commit(types.SET_CONTACT_ATTACHMENTS, {
|
||||
id,
|
||||
data: response.data.payload,
|
||||
});
|
||||
} finally {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: false });
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ commit }, { id, isFormData = false, ...contactParams }) => {
|
||||
const { avatar, customAttributes, ...paramsToDecamelize } = contactParams;
|
||||
const decamelizedContactParams = {
|
||||
|
||||
@@ -24,6 +24,7 @@ export const getters = {
|
||||
stopPaths: ['custom_attributes'],
|
||||
});
|
||||
},
|
||||
getContactAttachments: $state => id => $state.records[id]?.attachments || [],
|
||||
getMeta: $state => {
|
||||
return $state.meta;
|
||||
},
|
||||
|
||||
@@ -58,7 +58,15 @@ export const mutations = {
|
||||
},
|
||||
|
||||
[types.EDIT_CONTACT]: ($state, data) => {
|
||||
$state.records[data.id] = data;
|
||||
const existingAttachments = $state.records[data.id]?.attachments;
|
||||
$state.records[data.id] = existingAttachments
|
||||
? { ...data, attachments: existingAttachments }
|
||||
: data;
|
||||
},
|
||||
|
||||
[types.SET_CONTACT_ATTACHMENTS]: ($state, { id, data }) => {
|
||||
if (!$state.records[id]) $state.records[id] = {};
|
||||
$state.records[id].attachments = data;
|
||||
},
|
||||
|
||||
[types.DELETE_CONTACT]: ($state, id) => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import {
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
isValidSidebarSort,
|
||||
normalizeSidebarSortPreferences,
|
||||
} from 'dashboard/helper/sidebarSort';
|
||||
|
||||
const STORAGE_NAME = 'chatwoot_sidebar_sort_preferences';
|
||||
export const SET_SIDEBAR_SORT_PREFERENCES = 'SET_SIDEBAR_SORT_PREFERENCES';
|
||||
|
||||
const getPreferenceScope = rootGetters => {
|
||||
const currentUserId = rootGetters.getCurrentUserID;
|
||||
const currentAccountId = rootGetters.getCurrentAccountId;
|
||||
|
||||
if (!currentUserId || !currentAccountId) return null;
|
||||
|
||||
return `${currentUserId}:${currentAccountId}`;
|
||||
};
|
||||
|
||||
export const state = {
|
||||
preferences: { ...DEFAULT_SIDEBAR_SORT_PREFERENCES },
|
||||
storageKey: null,
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getSectionSort: $state => section => {
|
||||
return (
|
||||
$state.preferences[section] || DEFAULT_SIDEBAR_SORT_PREFERENCES[section]
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
initialize({ commit, rootGetters }) {
|
||||
const storageKey = getPreferenceScope(rootGetters);
|
||||
const storedPreferences = storageKey
|
||||
? LocalStorage.getFromJsonStore(STORAGE_NAME, storageKey)
|
||||
: {};
|
||||
|
||||
commit(SET_SIDEBAR_SORT_PREFERENCES, {
|
||||
preferences: normalizeSidebarSortPreferences(storedPreferences),
|
||||
storageKey,
|
||||
});
|
||||
},
|
||||
setSectionSort({ commit, rootGetters, state: currentState }, payload = {}) {
|
||||
const { section, sortBy } = payload;
|
||||
|
||||
if (!isValidSidebarSort(section, sortBy)) return;
|
||||
|
||||
const storageKey =
|
||||
currentState.storageKey || getPreferenceScope(rootGetters);
|
||||
const preferences = {
|
||||
...currentState.preferences,
|
||||
[section]: sortBy,
|
||||
};
|
||||
|
||||
commit(SET_SIDEBAR_SORT_PREFERENCES, {
|
||||
preferences,
|
||||
storageKey,
|
||||
});
|
||||
|
||||
if (storageKey) {
|
||||
LocalStorage.updateJsonStore(STORAGE_NAME, storageKey, preferences);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
[SET_SIDEBAR_SORT_PREFERENCES]($state, payload = {}) {
|
||||
const { preferences = {}, storageKey = null } = payload;
|
||||
$state.preferences = normalizeSidebarSortPreferences(preferences);
|
||||
$state.storageKey = storageKey;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@@ -282,6 +282,24 @@ describe('createConversationPayload', () => {
|
||||
expect(payload.get('assignee_id')).toBe(options.params.assigneeId);
|
||||
expect(payload.getAll('message[attachments][]')).toEqual([]);
|
||||
});
|
||||
|
||||
it('omits mail_subject when mailSubject is undefined', () => {
|
||||
const options = {
|
||||
params: {
|
||||
inboxId: '1',
|
||||
message: {
|
||||
content: 'Test message content',
|
||||
},
|
||||
sourceId: '12',
|
||||
assigneeId: '123',
|
||||
},
|
||||
contactId: '23',
|
||||
};
|
||||
|
||||
const payload = createConversationPayload(options);
|
||||
|
||||
expect(payload.has('additional_attributes[mail_subject]')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWhatsAppConversationPayload', () => {
|
||||
|
||||
@@ -438,4 +438,48 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#fetchAttachments', () => {
|
||||
const attachments = [
|
||||
{ id: 11, message_id: 21, file_type: 'image' },
|
||||
{ id: 12, message_id: 22, file_type: 'file' },
|
||||
];
|
||||
|
||||
it('fetches and stores attachments on the contact record', async () => {
|
||||
axios.get.mockResolvedValue({ data: { payload: attachments } });
|
||||
const state = { records: { 1: { id: 1 } } };
|
||||
await actions.fetchAttachments({ commit, state }, 1);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: true }],
|
||||
[types.SET_CONTACT_ATTACHMENTS, { id: 1, data: attachments }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('refetches even when attachments are already cached', async () => {
|
||||
axios.get.mockResolvedValue({ data: { payload: attachments } });
|
||||
const state = {
|
||||
records: { 1: { id: 1, attachments: [{ id: 99 }] } },
|
||||
};
|
||||
await actions.fetchAttachments({ commit, state }, 1);
|
||||
expect(axios.get).toHaveBeenCalled();
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: true }],
|
||||
[types.SET_CONTACT_ATTACHMENTS, { id: 1, data: attachments }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears the loading flag and rethrows when the API errors', async () => {
|
||||
axios.get.mockRejectedValue(new Error('Network error'));
|
||||
const state = { records: { 1: { id: 1 } } };
|
||||
await expect(
|
||||
actions.fetchAttachments({ commit, state }, 1)
|
||||
).rejects.toThrow('Network error');
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: true }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: false }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,4 +50,22 @@ describe('#getters', () => {
|
||||
};
|
||||
expect(getters.getAppliedContactFilters(state)).toEqual(filters);
|
||||
});
|
||||
|
||||
describe('getContactAttachments', () => {
|
||||
it('returns the attachments stored on the contact record', () => {
|
||||
const data = [{ id: 11, file_type: 'image' }];
|
||||
const state = { records: { 1: { id: 1, attachments: data } } };
|
||||
expect(getters.getContactAttachments(state)(1)).toEqual(data);
|
||||
});
|
||||
|
||||
it('returns an empty array when the contact has no cached attachments', () => {
|
||||
const state = { records: { 1: { id: 1 } } };
|
||||
expect(getters.getContactAttachments(state)(1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the contact is not in the store', () => {
|
||||
const state = { records: {} };
|
||||
expect(getters.getContactAttachments(state)(99)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,21 @@ describe('#mutations', () => {
|
||||
1: { id: 1, name: 'contact2', email: 'contact2@chatwoot.com' },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a cached attachments list across edits', () => {
|
||||
const attachments = [{ id: 11, file_type: 'image' }];
|
||||
const state = {
|
||||
records: {
|
||||
1: { id: 1, name: 'contact1', attachments },
|
||||
},
|
||||
};
|
||||
mutations[types.EDIT_CONTACT](state, { id: 1, name: 'contact2' });
|
||||
expect(state.records[1]).toEqual({
|
||||
id: 1,
|
||||
name: 'contact2',
|
||||
attachments,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_CONTACT_FILTERS', () => {
|
||||
@@ -102,4 +117,33 @@ describe('#mutations', () => {
|
||||
expect(state.appliedFilters).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_CONTACT_ATTACHMENTS', () => {
|
||||
it('attaches the list to the existing contact record', () => {
|
||||
const state = { records: { 1: { id: 1, name: 'Sivin' } } };
|
||||
const data = [{ id: 11, file_type: 'image' }];
|
||||
mutations[types.SET_CONTACT_ATTACHMENTS](state, { id: 1, data });
|
||||
expect(state.records[1]).toEqual({
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
attachments: data,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a record shell when the contact is not yet loaded', () => {
|
||||
const state = { records: {} };
|
||||
const data = [{ id: 12, file_type: 'file' }];
|
||||
mutations[types.SET_CONTACT_ATTACHMENTS](state, { id: 5, data });
|
||||
expect(state.records[5]).toEqual({ attachments: data });
|
||||
});
|
||||
|
||||
it('replaces an existing attachment list', () => {
|
||||
const state = {
|
||||
records: { 1: { id: 1, attachments: [{ id: 99 }] } },
|
||||
};
|
||||
const data = [{ id: 11 }];
|
||||
mutations[types.SET_CONTACT_ATTACHMENTS](state, { id: 1, data });
|
||||
expect(state.records[1].attachments).toEqual(data);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import {
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
SIDEBAR_SORT_KEYS,
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
} from 'dashboard/helper/sidebarSort';
|
||||
import {
|
||||
SET_SIDEBAR_SORT_PREFERENCES,
|
||||
actions,
|
||||
} from '../../sidebarSortPreferences';
|
||||
|
||||
vi.mock('shared/helpers/localStorage', () => ({
|
||||
LocalStorage: {
|
||||
getFromJsonStore: vi.fn(),
|
||||
updateJsonStore: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const rootGetters = {
|
||||
getCurrentUserID: 1,
|
||||
getCurrentAccountId: 2,
|
||||
};
|
||||
|
||||
describe('#actions', () => {
|
||||
const commit = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
commit.mockClear();
|
||||
LocalStorage.getFromJsonStore.mockReset();
|
||||
LocalStorage.updateJsonStore.mockReset();
|
||||
});
|
||||
|
||||
describe('#initialize', () => {
|
||||
it('loads scoped preferences from local storage', () => {
|
||||
LocalStorage.getFromJsonStore.mockReturnValue({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
});
|
||||
|
||||
actions.initialize({ commit, rootGetters });
|
||||
|
||||
expect(LocalStorage.getFromJsonStore).toHaveBeenCalledWith(
|
||||
'chatwoot_sidebar_sort_preferences',
|
||||
'1:2'
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith(SET_SIDEBAR_SORT_PREFERENCES, {
|
||||
preferences: {
|
||||
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
},
|
||||
storageKey: '1:2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setSectionSort', () => {
|
||||
it('persists valid preferences to local storage', () => {
|
||||
const state = {
|
||||
preferences: DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
storageKey: '1:2',
|
||||
};
|
||||
|
||||
actions.setSectionSort(
|
||||
{ commit, rootGetters, state },
|
||||
{
|
||||
section: SIDEBAR_SORT_SECTIONS.LABELS,
|
||||
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
}
|
||||
);
|
||||
|
||||
const preferences = {
|
||||
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
[SIDEBAR_SORT_SECTIONS.LABELS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
};
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(SET_SIDEBAR_SORT_PREFERENCES, {
|
||||
preferences,
|
||||
storageKey: '1:2',
|
||||
});
|
||||
expect(LocalStorage.updateJsonStore).toHaveBeenCalledWith(
|
||||
'chatwoot_sidebar_sort_preferences',
|
||||
'1:2',
|
||||
preferences
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores invalid preferences', () => {
|
||||
actions.setSectionSort(
|
||||
{
|
||||
commit,
|
||||
rootGetters,
|
||||
state: {
|
||||
preferences: DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
storageKey: '1:2',
|
||||
},
|
||||
},
|
||||
{
|
||||
section: SIDEBAR_SORT_SECTIONS.FOLDERS,
|
||||
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
}
|
||||
);
|
||||
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
expect(LocalStorage.updateJsonStore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
SIDEBAR_SORT_KEYS,
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
} from 'dashboard/helper/sidebarSort';
|
||||
import { getters } from '../../sidebarSortPreferences';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('returns section sort preference', () => {
|
||||
const state = {
|
||||
preferences: {
|
||||
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
},
|
||||
};
|
||||
|
||||
expect(getters.getSectionSort(state)(SIDEBAR_SORT_SECTIONS.TEAMS)).toBe(
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to default section sort preference', () => {
|
||||
const state = {
|
||||
preferences: {},
|
||||
};
|
||||
|
||||
expect(getters.getSectionSort(state)(SIDEBAR_SORT_SECTIONS.LABELS)).toBe(
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
SIDEBAR_SORT_KEYS,
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
} from 'dashboard/helper/sidebarSort';
|
||||
import {
|
||||
SET_SIDEBAR_SORT_PREFERENCES,
|
||||
mutations,
|
||||
} from '../../sidebarSortPreferences';
|
||||
|
||||
describe('#mutations', () => {
|
||||
it('sets normalized preferences', () => {
|
||||
const state = {
|
||||
preferences: {},
|
||||
storageKey: null,
|
||||
};
|
||||
|
||||
mutations[SET_SIDEBAR_SORT_PREFERENCES](state, {
|
||||
preferences: {
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.LABELS]: 'invalid',
|
||||
},
|
||||
storageKey: '1:2',
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
preferences: {
|
||||
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
},
|
||||
storageKey: '1:2',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -139,6 +139,7 @@ export default {
|
||||
SET_CONTACT_META: 'SET_CONTACT_META',
|
||||
SET_CONTACT_UI_FLAG: 'SET_CONTACT_UI_FLAG',
|
||||
SET_CONTACT_ITEM: 'SET_CONTACT_ITEM',
|
||||
SET_CONTACT_ATTACHMENTS: 'SET_CONTACT_ATTACHMENTS',
|
||||
SET_CONTACTS: 'SET_CONTACTS',
|
||||
APPEND_CONTACTS: 'APPEND_CONTACTS',
|
||||
CLEAR_CONTACTS: 'CLEAR_CONTACTS',
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
<script>
|
||||
import emojis from './emojisGroup.json';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
const SEARCH_KEY = 'Search';
|
||||
|
||||
export default {
|
||||
components: { FluentIcon, NextButton },
|
||||
props: {
|
||||
onClick: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
showRemoveButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedKey: 'Search',
|
||||
emojis,
|
||||
search: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
categories() {
|
||||
return [...this.emojis];
|
||||
},
|
||||
filterEmojisByCategory() {
|
||||
const selectedCategoryName = this.emojis.find(category =>
|
||||
category.name === this.selectedKey ? category.name : null
|
||||
);
|
||||
return selectedCategoryName?.emojis;
|
||||
},
|
||||
filterAllEmojisBySearch() {
|
||||
return this.emojis.map(category => {
|
||||
const allEmojis = category.emojis.filter(emoji =>
|
||||
emoji.slug.replaceAll('_', ' ').includes(this.search.toLowerCase())
|
||||
);
|
||||
return allEmojis.length > 0
|
||||
? { ...category, emojis: allEmojis }
|
||||
: { ...category, emojis: [] };
|
||||
});
|
||||
},
|
||||
hasNoSearch() {
|
||||
return this.selectedKey !== SEARCH_KEY && this.search === '';
|
||||
},
|
||||
hasEmptySearchResult() {
|
||||
return this.filterAllEmojisBySearch.every(
|
||||
category => category.emojis.length === 0
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
search() {
|
||||
this.selectedKey = 'Search';
|
||||
},
|
||||
selectedKey() {
|
||||
return this.selectedKey === 'Search' ? this.focusSearchInput() : null;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.focusSearchInput();
|
||||
},
|
||||
methods: {
|
||||
changeCategory(category) {
|
||||
this.search = '';
|
||||
this.$refs.emojiItem.scrollTo({ top: 0 });
|
||||
this.selectedKey = category;
|
||||
},
|
||||
getFirstEmojiByCategoryName(categoryName) {
|
||||
const categoryItem = this.emojis.find(category =>
|
||||
category.name === categoryName ? category : null
|
||||
);
|
||||
return categoryItem ? categoryItem.emojis[0].emoji : '';
|
||||
},
|
||||
focusSearchInput() {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.searchbar.focus();
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="dialog"
|
||||
class="emoji-dialog bg-n-background shadow-lg rounded-md outline outline-1 outline-n-weak box-content h-[18.75rem] absolute right-0 -top-[95px] w-80 z-20"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-2 m-2 sticky top-2">
|
||||
<input
|
||||
ref="searchbar"
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="focus:box-shadow-blue dark:focus:box-shadow-dark !mb-0 !h-8 !text-sm"
|
||||
:placeholder="$t('EMOJI.PLACEHOLDER')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showRemoveButton"
|
||||
faded
|
||||
sm
|
||||
slate
|
||||
class="flex-shrink-0"
|
||||
:label="$t('EMOJI.REMOVE')"
|
||||
@click="onClick('')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="hasNoSearch" ref="emojiItem" class="emoji-item">
|
||||
<h5
|
||||
class="text-sm text-n-slate-12 font-medium leading-normal m-0 py-1 px-2 capitalize"
|
||||
>
|
||||
{{ selectedKey }}
|
||||
</h5>
|
||||
<div class="emoji--row">
|
||||
<button
|
||||
v-for="item in filterEmojisByCategory"
|
||||
:key="item.slug"
|
||||
v-dompurify-html="item.emoji"
|
||||
class="emoji--item"
|
||||
track-by="$index"
|
||||
@click="onClick(item.emoji)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else ref="emojiItem" class="emoji-item">
|
||||
<div v-for="category in filterAllEmojisBySearch" :key="category.slug">
|
||||
<h5
|
||||
v-if="category.emojis.length > 0"
|
||||
class="text-sm text-n-slate-12 font-medium leading-normal m-0 py-1 px-2 capitalize"
|
||||
>
|
||||
{{ category.name }}
|
||||
</h5>
|
||||
<div v-if="category.emojis.length > 0" class="emoji--row">
|
||||
<button
|
||||
v-for="item in category.emojis"
|
||||
:key="item.slug"
|
||||
v-dompurify-html="item.emoji"
|
||||
class="emoji--item"
|
||||
track-by="$index"
|
||||
@click="onClick(item.emoji)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasEmptySearchResult"
|
||||
class="items-center flex flex-col h-[13.25rem] justify-center"
|
||||
>
|
||||
<div class="text-n-slate-11 mb-2">
|
||||
<FluentIcon icon="emoji" size="48" />
|
||||
</div>
|
||||
<span class="text-n-slate-11 text-sm font-medium">
|
||||
{{ $t('EMOJI.NOT_FOUND') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="emoji-dialog--footer relative w-full py-0 rounded-b-[0.34rem] px-1 bg-n-slate-3"
|
||||
role="menu"
|
||||
>
|
||||
<ul
|
||||
class="flex relative left-[2px] rtl:left-[unset] rtl:right-[2px] list-none m-0 overflow-auto py-1 px-0"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
class="emoji--item"
|
||||
:class="{ active: selectedKey === 'Search' }"
|
||||
@click="changeCategory('Search')"
|
||||
>
|
||||
<FluentIcon icon="search" size="16" class="text-n-slate-11" />
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
v-for="category in categories"
|
||||
:key="category.slug"
|
||||
@click="changeCategory(category.name)"
|
||||
>
|
||||
<button
|
||||
v-dompurify-html="getFirstEmojiByCategoryName(category.name)"
|
||||
class="emoji--item"
|
||||
:class="{ active: selectedKey === category.name }"
|
||||
@click="changeCategory(category.name)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.box-shadow-blue {
|
||||
box-shadow:
|
||||
0 0 0 1px #1f93ff,
|
||||
0 0 1px 2px #c7e3ff;
|
||||
}
|
||||
|
||||
.box-shadow-dark {
|
||||
box-shadow:
|
||||
0 0 0 1px #212222,
|
||||
0 0 1px 2px #4c5155;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.emoji-dialog {
|
||||
&::before {
|
||||
@apply absolute -bottom-3 h-3 w-6 bg-n-slate-3 content-[""];
|
||||
clip-path: polygon(50% 100%, 0% 0%, 100% 0%);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji--item {
|
||||
@apply bg-transparent border-0 rounded cursor-pointer text-lg h-6 m-0 py-0 px-1 hover:bg-n-slate-4;
|
||||
}
|
||||
|
||||
.emoji--row {
|
||||
@apply box-border p-1;
|
||||
|
||||
.emoji--item {
|
||||
@apply h-[1.625rem] w-[1.625rem] leading-normal m-1;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
@apply h-[13.25rem] overflow-y-auto;
|
||||
}
|
||||
|
||||
.emoji-dialog--footer {
|
||||
ul {
|
||||
> li {
|
||||
@apply items-center cursor-pointer flex justify-center p-1;
|
||||
}
|
||||
|
||||
li .active {
|
||||
@apply bg-n-background;
|
||||
}
|
||||
|
||||
.emoji--item {
|
||||
@apply items-center flex text-sm;
|
||||
|
||||
&:hover {
|
||||
@apply bg-n-slate-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
|
||||
import {
|
||||
buildEmojiSections,
|
||||
getEmojiTint,
|
||||
getRecentEmojis,
|
||||
addRecentEmoji,
|
||||
} from 'shared/components/emoji/pickerHelper';
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const emojiSearch = ref('');
|
||||
const recentEmojis = ref([]);
|
||||
const searchInput = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
recentEmojis.value = getRecentEmojis();
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
|
||||
const emojiSections = computed(() =>
|
||||
buildEmojiSections(
|
||||
emojiSearch.value,
|
||||
recentEmojis.value,
|
||||
t('EMOJI_ICON_PICKER.FREQUENTLY_USED')
|
||||
)
|
||||
);
|
||||
|
||||
// Tints an emoji button with a light shade of the emoji's own color on hover.
|
||||
const applyEmojiTint = (event, emoji) => {
|
||||
event.currentTarget.style.setProperty('--ep-tint', getEmojiTint(emoji));
|
||||
};
|
||||
|
||||
const selectEmoji = emoji => {
|
||||
recentEmojis.value = addRecentEmoji(emoji);
|
||||
emit('select', { type: 'emoji', value: emoji.emoji, emoji: emoji.emoji });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="dialog"
|
||||
class="absolute z-20 flex flex-col overflow-hidden shadow-xl w-[22rem] bg-n-surface-2 backdrop-blur-[100px] rounded-2xl outline outline-1 outline-n-weak dark:outline-n-strong/50"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5 pt-2">
|
||||
<div class="relative px-2">
|
||||
<span
|
||||
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-5"
|
||||
/>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="emojiSearch"
|
||||
type="text"
|
||||
class="block w-full h-10 text-sm reset-base outline outline-1 outline-offset-[-1px] outline-n-weak focus:outline-n-brand border-none rounded-lg !ps-9 !pe-3 !py-2.5 bg-transparent text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_EMOJI')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="emojiSections.length"
|
||||
class="h-60 overflow-y-auto px-2 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<Virtualizer v-slot="{ item: section }" :data="emojiSections">
|
||||
<h5
|
||||
class="px-1 pt-2 pb-1 m-0 text-xs font-medium tracking-wide uppercase text-n-slate-10"
|
||||
>
|
||||
{{ section.name }}
|
||||
</h5>
|
||||
<div class="grid grid-cols-10 gap-0.5">
|
||||
<button
|
||||
v-for="emoji in section.emojis"
|
||||
:key="`${section.name}-${emoji.slug}`"
|
||||
type="button"
|
||||
:title="emoji.name"
|
||||
class="flex items-center justify-center !p-0 w-full max-w-[2rem] text-xl transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
|
||||
@mouseenter="applyEmojiTint($event, emoji.emoji)"
|
||||
@click="selectEmoji(emoji)"
|
||||
>
|
||||
{{ emoji.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</Virtualizer>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center gap-2 h-60 text-n-slate-10"
|
||||
>
|
||||
<span class="i-lucide-smile-plus size-7" />
|
||||
<span class="text-sm font-medium">
|
||||
{{ t('EMOJI_ICON_PICKER.NO_EMOJI') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
import emojiGroups from 'shared/components/emoji/emojisGroup.json';
|
||||
|
||||
// Recently used emojis persisted in localStorage.
|
||||
const RECENT_EMOJI_KEY = 'emoji-icon-picker.recent-emojis';
|
||||
const MAX_RECENT_EMOJIS = 16;
|
||||
|
||||
const matchesSearch = (emoji, term) =>
|
||||
emoji.slug.replaceAll('_', ' ').includes(term) ||
|
||||
emoji.name.toLowerCase().includes(term);
|
||||
|
||||
// Emoji sections for the search term; prepends "Frequently used" when idle.
|
||||
export const buildEmojiSections = (search, recentEmojis, frequentLabel) => {
|
||||
const term = search.trim().toLowerCase();
|
||||
if (term) {
|
||||
return emojiGroups
|
||||
.map(({ name, emojis }) => ({
|
||||
name,
|
||||
emojis: emojis.filter(e => matchesSearch(e, term)),
|
||||
}))
|
||||
.filter(group => group.emojis.length);
|
||||
}
|
||||
return [
|
||||
...(recentEmojis.length
|
||||
? [{ name: frequentLabel, emojis: recentEmojis }]
|
||||
: []),
|
||||
...emojiGroups,
|
||||
];
|
||||
};
|
||||
|
||||
// Samples an emoji's average color (via canvas) as a translucent hover tint. Cached per emoji.
|
||||
const emojiTintCache = new Map();
|
||||
const NEUTRAL_TINT = 'rgb(var(--slate-9) / 0.12)';
|
||||
|
||||
const sampleEmojiTint = emoji => {
|
||||
const canvas = Object.assign(document.createElement('canvas'), {
|
||||
width: 16,
|
||||
height: 16,
|
||||
});
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
ctx.font = '14px serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(emoji, 8, 9);
|
||||
|
||||
const { data } = ctx.getImageData(0, 0, 16, 16);
|
||||
const pixels = Array.from(
|
||||
{ length: data.length / 4 },
|
||||
(_, i) => i * 4
|
||||
).filter(i => data[i + 3] > 16);
|
||||
if (!pixels.length) return NEUTRAL_TINT;
|
||||
|
||||
const avg = offset =>
|
||||
Math.round(
|
||||
pixels.reduce((sum, i) => sum + data[i + offset], 0) / pixels.length
|
||||
);
|
||||
|
||||
return `rgba(${avg(0)}, ${avg(1)}, ${avg(2)}, 0.16)`;
|
||||
};
|
||||
|
||||
export const getEmojiTint = emoji => {
|
||||
if (!emojiTintCache.has(emoji)) {
|
||||
let tint = NEUTRAL_TINT;
|
||||
try {
|
||||
tint = sampleEmojiTint(emoji);
|
||||
} catch {
|
||||
/* canvas unavailable */
|
||||
}
|
||||
emojiTintCache.set(emoji, tint);
|
||||
}
|
||||
return emojiTintCache.get(emoji);
|
||||
};
|
||||
|
||||
export const getRecentEmojis = () => {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(RECENT_EMOJI_KEY) ?? '[]');
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const addRecentEmoji = emoji => {
|
||||
const updated = [
|
||||
emoji,
|
||||
...getRecentEmojis().filter(item => item.slug !== emoji.slug),
|
||||
].slice(0, MAX_RECENT_EMOJIS);
|
||||
try {
|
||||
localStorage.setItem(RECENT_EMOJI_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
/* private mode; recents are best-effort */
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
@@ -43,6 +43,15 @@ export const login = async ({
|
||||
mfaToken: error.response.data.mfa_token,
|
||||
};
|
||||
}
|
||||
if (
|
||||
error.response?.status === 409 &&
|
||||
error.response?.data?.sessions_limit_reached
|
||||
) {
|
||||
return {
|
||||
sessionsLimitReached: true,
|
||||
sessions: error.response.data.sessions,
|
||||
};
|
||||
}
|
||||
const loginError = new Error(parseAPIErrorResponse(error));
|
||||
loginError.errorCode = error.response?.data?.error_code;
|
||||
throw loginError;
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
|
||||
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
// components
|
||||
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
|
||||
@@ -17,6 +19,7 @@ import Spinner from 'shared/components/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
|
||||
import SessionLimitOverlay from 'dashboard/components/auth/SessionLimitOverlay.vue';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
|
||||
@@ -36,6 +39,7 @@ export default {
|
||||
NextButton,
|
||||
SimpleDivider,
|
||||
MfaVerification,
|
||||
SessionLimitOverlay,
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
@@ -68,6 +72,8 @@ export default {
|
||||
error: '',
|
||||
mfaRequired: false,
|
||||
mfaToken: null,
|
||||
sessionsLimitReached: false,
|
||||
limitedSessions: [],
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
@@ -182,6 +188,15 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if sessions limit reached
|
||||
if (result?.sessionsLimitReached) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.sessionsLimitReached = true;
|
||||
this.limitedSessions = result.sessions;
|
||||
AnalyticsHelper.track(SESSION_EVENTS.LIMIT_HIT);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
@@ -224,6 +239,51 @@ export default {
|
||||
this.mfaToken = null;
|
||||
this.credentials.password = '';
|
||||
},
|
||||
retryLoginWithParams(extraParams) {
|
||||
const credentials = {
|
||||
email: this.email
|
||||
? decodeURIComponent(this.email)
|
||||
: this.credentials.email,
|
||||
password: this.credentials.password,
|
||||
sso_auth_token: this.ssoAuthToken,
|
||||
ssoAccountId: this.ssoAccountId,
|
||||
ssoConversationId: this.ssoConversationId,
|
||||
...extraParams,
|
||||
};
|
||||
|
||||
this.sessionsLimitReached = false;
|
||||
this.limitedSessions = [];
|
||||
this.loginApi.showLoading = true;
|
||||
login(credentials)
|
||||
.then(result => {
|
||||
if (result?.sessionsLimitReached) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.sessionsLimitReached = true;
|
||||
this.limitedSessions = result.sessions;
|
||||
AnalyticsHelper.track(SESSION_EVENTS.LIMIT_HIT);
|
||||
return;
|
||||
}
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(response => {
|
||||
this.loginApi.hasErrored = true;
|
||||
this.showAlertMessage(
|
||||
response?.message || this.$t('LOGIN.API.UNAUTH')
|
||||
);
|
||||
});
|
||||
},
|
||||
handleSessionRevoke(sessionId) {
|
||||
this.retryLoginWithParams({ revoke_session_id: sessionId });
|
||||
},
|
||||
handleSessionRevokeAll() {
|
||||
this.retryLoginWithParams({ revoke_all_sessions: true });
|
||||
},
|
||||
handleSessionLimitCancel() {
|
||||
this.sessionsLimitReached = false;
|
||||
this.limitedSessions = [];
|
||||
this.credentials.password = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -255,8 +315,18 @@ export default {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Session Limit Section -->
|
||||
<section v-if="sessionsLimitReached" class="mt-11">
|
||||
<SessionLimitOverlay
|
||||
:sessions="limitedSessions"
|
||||
@revoke="handleSessionRevoke"
|
||||
@revoke-all="handleSessionRevokeAll"
|
||||
@cancel="handleSessionLimitCancel"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- MFA Verification Section -->
|
||||
<section v-if="mfaRequired" class="mt-11">
|
||||
<section v-else-if="mfaRequired" class="mt-11">
|
||||
<MfaVerification
|
||||
:mfa-token="mfaToken"
|
||||
@verified="handleMfaVerified"
|
||||
|
||||
@@ -272,6 +272,9 @@ blockquote {
|
||||
--gray-12: 32 32 32;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--surface-1: 254 254 254;
|
||||
--surface-2: 255 255 255;
|
||||
--surface-active: 255 255 255;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
--border-strong: 235 235 235;
|
||||
@@ -379,6 +382,9 @@ blockquote {
|
||||
--gray-12: 238 238 238;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--surface-1: 20 21 23;
|
||||
--surface-2: 22 23 26;
|
||||
--surface-active: 53 57 66;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
--solid-1: 23 23 26;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import ChatAttachmentButton from 'widget/components/ChatAttachment.vue';
|
||||
@@ -7,14 +8,16 @@ import { useAttachments } from '../composables/useAttachments';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
|
||||
import EmojiInput from 'shared/components/emoji/EmojiInput.vue';
|
||||
const EmojiPicker = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiPicker.vue')
|
||||
);
|
||||
|
||||
export default {
|
||||
name: 'ChatInputWrap',
|
||||
components: {
|
||||
ChatAttachmentButton,
|
||||
ChatSendButton,
|
||||
EmojiInput,
|
||||
EmojiPicker,
|
||||
FluentIcon,
|
||||
ResizableTextArea,
|
||||
},
|
||||
@@ -110,6 +113,9 @@ export default {
|
||||
emojiOnClick(emoji) {
|
||||
this.userInput = `${this.userInput}${emoji} `;
|
||||
},
|
||||
onSelectEmoji({ value }) {
|
||||
this.emojiOnClick(value);
|
||||
},
|
||||
onTypingOff() {
|
||||
this.toggleTyping('off');
|
||||
},
|
||||
@@ -148,7 +154,7 @@ export default {
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<div class="flex items-center ltr:pl-2 rtl:pr-2">
|
||||
<div class="relative flex items-center ltr:pl-2 rtl:pr-2">
|
||||
<ChatAttachmentButton
|
||||
v-if="showAttachment"
|
||||
class="text-n-slate-12"
|
||||
@@ -169,10 +175,11 @@ export default {
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<EmojiInput
|
||||
<EmojiPicker
|
||||
v-if="shouldShowEmojiPicker && showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:on-click="emojiOnClick"
|
||||
class="!bottom-full end-0 mb-2 max-w-[calc(100vw-3rem)]"
|
||||
@select="onSelectEmoji"
|
||||
@keydown.esc="hideEmojiPicker"
|
||||
/>
|
||||
<ChatSendButton
|
||||
@@ -185,10 +192,6 @@ export default {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.emoji-dialog {
|
||||
@apply max-w-full ltr:right-5 rtl:right-[unset] rtl:left-5 -top-[302px] before:ltr:right-2.5 before:rtl:right-[unset] before:rtl:left-2.5;
|
||||
}
|
||||
|
||||
.user-message-input {
|
||||
@apply border-none outline-none w-full placeholder:text-n-slate-10 resize-none h-8 min-h-8 max-h-60 py-1 px-0 my-2 bg-n-background text-n-slate-12 transition-all duration-200;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,11 @@
|
||||
"NOT_FOUND": "No emoji match your search",
|
||||
"ARIA_LABEL": "Emoji picker"
|
||||
},
|
||||
"EMOJI_ICON_PICKER": {
|
||||
"SEARCH_EMOJI": "Search emoji…",
|
||||
"FREQUENTLY_USED": "Frequently used",
|
||||
"NO_EMOJI": "No emoji match your search"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Rate your conversation",
|
||||
"SUBMITTED_TITLE": "Thank you for submitting the rating",
|
||||
|
||||
Reference in New Issue
Block a user