feat: add media view for contacts (#14393)
This commit is contained in:
@@ -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')"
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -511,6 +511,7 @@
|
||||
"ATTRIBUTES": "Attributes",
|
||||
"HISTORY": "History",
|
||||
"NOTES": "Notes",
|
||||
"MEDIA": "Media",
|
||||
"MERGE": "Merge"
|
||||
},
|
||||
"HISTORY": {
|
||||
|
||||
@@ -401,7 +401,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}",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user