feat: add media view for contacts (#14393)
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user