feat: attachment preview
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useUser } from '@KTXC'
|
||||
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||
import type { EntityObject, MessageObject } from '@MailManager/models'
|
||||
import { SecurityLevel } from '@/utile/emailSanitizer'
|
||||
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||
import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer'
|
||||
import type { MediaViewerItem } from '@/types/mediaViewer'
|
||||
import ReaderEmpty from './reader/ReaderEmpty.vue'
|
||||
import ReaderToolbar from './reader/ReaderToolbar.vue'
|
||||
import ReaderHeader from './reader/ReaderHeader.vue'
|
||||
@@ -28,10 +30,14 @@ const emit = defineEmits<{
|
||||
const { getSetting } = useUser()
|
||||
const mailUiStore = useMailUiStore()
|
||||
|
||||
// Per-message overrides
|
||||
// Security overrides
|
||||
const allowImages = ref(false)
|
||||
const overrideSecurityLevel = ref<SecurityLevel | null>(null)
|
||||
|
||||
// Viewer state
|
||||
const showViewer = ref(false)
|
||||
const viewerIndex = ref(0)
|
||||
|
||||
// Computed
|
||||
const message = computed<MessageObject | null>(() => {
|
||||
return props.entity?.properties ?? null
|
||||
@@ -55,12 +61,35 @@ const effectiveSecurityLevel = computed(() => {
|
||||
return overrideSecurityLevel.value ?? securityLevel.value
|
||||
})
|
||||
|
||||
// Preview state
|
||||
const previewDialog = computed(() => mediaViewerComponent('dialog'))
|
||||
const previewItems = computed<MediaViewerItem[]>(() => {
|
||||
const attachments = message.value?.attachments ?? []
|
||||
const items: MediaViewerItem[] = []
|
||||
attachments.forEach((attachment, index) => {
|
||||
if (!mediaViewerCanDisplay(attachment.type)) return
|
||||
items.push({
|
||||
id: attachment.partId || attachment.blobId || attachment.cid || `att-${index}`,
|
||||
title: attachment.name || 'Attachment',
|
||||
mime: attachment.type || '',
|
||||
size: attachment.size ?? null,
|
||||
meta: { index, attachment },
|
||||
})
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
// Reset overrides when message changes
|
||||
watch(() => props.entity, () => {
|
||||
allowImages.value = allowImagesDefault.value
|
||||
overrideSecurityLevel.value = null
|
||||
})
|
||||
|
||||
// Close the viewer when switching messages.
|
||||
watch(() => props.entity, () => {
|
||||
showViewer.value = false
|
||||
})
|
||||
|
||||
// Toggle images for current message only
|
||||
const toggleImages = () => {
|
||||
allowImages.value = !allowImages.value
|
||||
@@ -113,6 +142,22 @@ const handleFlag = () => {
|
||||
const handleCompose = () => {
|
||||
emit('compose')
|
||||
}
|
||||
|
||||
const handlePreview = (index: number) => {
|
||||
const pos = previewItems.value.findIndex(item => item.meta.index === index)
|
||||
if (pos < 0) return
|
||||
viewerIndex.value = pos
|
||||
showViewer.value = true
|
||||
}
|
||||
|
||||
const handleViewerRetrieve = (item: MediaViewerItem): Promise<Blob> => {
|
||||
if (!props.entity) return Promise.reject(new Error('No message selected'))
|
||||
return mailUiStore.attachmentBlob(props.entity, item.meta.attachment)
|
||||
}
|
||||
|
||||
const handleViewerDownload = (item: MediaViewerItem) => {
|
||||
if (props.entity) mailUiStore.downloadMessage(props.entity, item.meta.index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -135,7 +180,7 @@ const handleCompose = () => {
|
||||
@move="handleMove"
|
||||
@delete="handleDelete"
|
||||
@flag="handleFlag"
|
||||
@download="handleDownload()"
|
||||
@download="handleDownload"
|
||||
@toggle-images="toggleImages"
|
||||
@set-security-level="setSecurityLevel"
|
||||
/>
|
||||
@@ -144,7 +189,8 @@ const handleCompose = () => {
|
||||
<div class="message-content">
|
||||
<ReaderHeader
|
||||
:entity="props.entity"
|
||||
@download-attachment="handleDownload"
|
||||
@download="handleDownload"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
|
||||
<v-divider />
|
||||
@@ -155,6 +201,19 @@ const handleCompose = () => {
|
||||
:allow-images="allowImages"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Inline attachment viewer (provided by the media_viewer module) -->
|
||||
<component
|
||||
:is="previewDialog"
|
||||
v-if="previewDialog"
|
||||
v-model="showViewer"
|
||||
:items="previewItems"
|
||||
:index="viewerIndex"
|
||||
:source="handleViewerRetrieve"
|
||||
:download="handleViewerDownload"
|
||||
:navigation="true"
|
||||
@update:index="viewerIndex = $event"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,18 @@
|
||||
import { computed } from 'vue'
|
||||
import RecipientDetails from '@/components/common/RecipientDetails.vue'
|
||||
import { formatFileSize } from '@/utile/format'
|
||||
import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer'
|
||||
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||
import type { EntityObject } from '@MailManager/models';
|
||||
import type { MessagePartInterface } from '@MailManager/types/message';
|
||||
|
||||
interface AttachmentItem {
|
||||
id: string
|
||||
title: string
|
||||
mime: string
|
||||
size?: number | null
|
||||
meta: { index: number; attachment: MessagePartInterface }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entity: EntityObject | null
|
||||
@@ -11,9 +22,13 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
downloadAttachment: [index: number]
|
||||
download: [index: number]
|
||||
preview: [index: number]
|
||||
}>()
|
||||
|
||||
const mailUiStore = useMailUiStore()
|
||||
const previewPopover = computed(() => mediaViewerComponent('popover'))
|
||||
|
||||
const message = computed(() => {
|
||||
return props.entity?.properties ?? null
|
||||
})
|
||||
@@ -22,6 +37,33 @@ const randomKey = computed(() => {
|
||||
return Math.random().toString(36).substring(2, 15)
|
||||
})
|
||||
|
||||
const attachmentItems = computed<AttachmentItem[]>(() =>
|
||||
(message.value?.attachments ?? []).map((attachment, index) => ({
|
||||
id: attachment.partId || attachment.blobId || attachment.cid || `att-${index}`,
|
||||
title: attachment.name || 'Attachment',
|
||||
mime: attachment.type || '',
|
||||
size: attachment.size ?? null,
|
||||
meta: { index, attachment },
|
||||
})),
|
||||
)
|
||||
|
||||
const isPreviewable = (attachment: MessagePartInterface): boolean => {
|
||||
return mediaViewerCanDisplay(attachment.type)
|
||||
}
|
||||
|
||||
const handleAttachmentClick = (attachment: MessagePartInterface, index: number): void => {
|
||||
if (isPreviewable(attachment)) {
|
||||
emit('preview', index)
|
||||
} else {
|
||||
emit('download', index)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewerRetrieve = (item: AttachmentItem): Promise<Blob> => {
|
||||
if (!props.entity) return Promise.reject(new Error('No message selected'))
|
||||
return mailUiStore.attachmentBlob(props.entity, item.meta.attachment)
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (date: Date | string | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
@@ -37,10 +79,6 @@ const formatDate = (date: Date | string | null | undefined): string => {
|
||||
hour12: true
|
||||
})
|
||||
}
|
||||
|
||||
const download = async (index: number): Promise<void> => {
|
||||
emit('downloadAttachment', index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -101,21 +139,31 @@ const download = async (index: number): Promise<void> => {
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(attachment, index) in message?.attachments"
|
||||
:key="randomKey"
|
||||
:key="attachment.partId || attachment.blobId || attachment.cid || index"
|
||||
class="attachment-item"
|
||||
>
|
||||
<v-chip
|
||||
prepend-icon="mdi-paperclip"
|
||||
:prepend-icon="isPreviewable(attachment) ? 'mdi-eye-outline' : 'mdi-paperclip'"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="attachment-chip"
|
||||
@click="download(index)"
|
||||
:title="isPreviewable(attachment) ? 'Preview' : 'Download'"
|
||||
@click="handleAttachmentClick(attachment, index)"
|
||||
>
|
||||
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
|
||||
<span v-if="attachment.size != null" class="text-caption text-medium-emphasis ml-1">
|
||||
({{ formatFileSize(attachment.size) }})
|
||||
</span>
|
||||
</v-chip>
|
||||
|
||||
<!-- Hover preview (provided by the media_viewer module) -->
|
||||
<component
|
||||
:is="previewPopover"
|
||||
v-if="previewPopover && isPreviewable(attachment)"
|
||||
:item="attachmentItems[index]"
|
||||
:source="handleViewerRetrieve"
|
||||
@expand="emit('preview', index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* mediaViewer — bridges the Mail UI to the standalone media_viewer module
|
||||
*/
|
||||
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { useIntegrationStore } from '@KTXC'
|
||||
|
||||
const MEDIA_VIEWER_FORMAT_POINT = 'media_viewer_format'
|
||||
const MEDIA_VIEWER_POINT = 'media_viewer'
|
||||
|
||||
function mimeMatchesPattern(mime: string, pattern: string): boolean {
|
||||
if (pattern.endsWith('/*')) {
|
||||
return mime.startsWith(pattern.slice(0, -1))
|
||||
}
|
||||
return mime === pattern
|
||||
}
|
||||
|
||||
function mimeMatchesViewer(mime: string, mimeTypes?: string[], mimePatterns?: string[]): boolean {
|
||||
if (mimeTypes?.includes(mime)) return true
|
||||
if (mimePatterns) {
|
||||
for (const pattern of mimePatterns) {
|
||||
if (mimeMatchesPattern(mime, pattern)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** True if a leaf viewer is registered for this MIME type. */
|
||||
export function mediaViewerCanDisplay(mime: string | null | undefined): boolean {
|
||||
if (!mime) return false
|
||||
const viewers = useIntegrationStore().getItems(MEDIA_VIEWER_FORMAT_POINT)
|
||||
return viewers.some(viewer =>
|
||||
mimeMatchesViewer(
|
||||
mime,
|
||||
viewer.meta?.mimeTypes as string[] | undefined,
|
||||
viewer.meta?.mimePatterns as string[] | undefined,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function mediaViewerComponent(type: 'dialog' | 'popover' = 'dialog') {
|
||||
// The integration store prefixes ids with the module handle
|
||||
// (e.g. 'media_viewer.dialog'), so match on the suffix.
|
||||
const entry = useIntegrationStore().getItems(MEDIA_VIEWER_POINT).find(i => i.id.endsWith(`.${type}`))
|
||||
return entry?.component
|
||||
? defineAsyncComponent(entry.component as () => Promise<unknown>)
|
||||
: null
|
||||
}
|
||||
+16
-1
@@ -5,7 +5,7 @@ import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
|
||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||
import { useMailSync } from '@MailManager/composables/useMailSync'
|
||||
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
||||
import type { EntityTransmitRequest } from '@MailManager/types/entity'
|
||||
import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity'
|
||||
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
|
||||
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
|
||||
import { CollectionPropertiesObject } from '@MailManager/models/collection'
|
||||
@@ -571,6 +571,20 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function attachmentBlob(entity: EntityObject, part: MessagePartInterface): Promise<Blob> {
|
||||
const selector: EntityBlobSelector = {
|
||||
blobId: part.blobId ?? undefined,
|
||||
partId: part.partId ?? undefined,
|
||||
cid: part.cid ?? undefined,
|
||||
}
|
||||
const results = await entitiesStore.blobs(entity.identifier, [selector])
|
||||
const blob = results[0]?.blob
|
||||
if (!blob) {
|
||||
throw new Error('Attachment content unavailable')
|
||||
}
|
||||
return blob
|
||||
}
|
||||
|
||||
// ── Exports ───────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
@@ -607,6 +621,7 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
deleteFolder,
|
||||
moveMessages,
|
||||
downloadMessage,
|
||||
attachmentBlob,
|
||||
moveFolder,
|
||||
renameFolder,
|
||||
isServiceFolderLoading,
|
||||
|
||||
@@ -710,6 +710,10 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function attachmentBlob(entity: EntityObject, part: Parameters<typeof mailStore.attachmentBlob>[1]) {
|
||||
return mailStore.attachmentBlob(entity, part)
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarVisible,
|
||||
settingsDialogVisible,
|
||||
@@ -758,6 +762,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
flagMessage,
|
||||
moveMessages,
|
||||
downloadMessage,
|
||||
attachmentBlob,
|
||||
messageSelectionModeActivate,
|
||||
messageSelectionModeDeactivate,
|
||||
messageSelectionToggleOne,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MessagePartInterface } from "@MailManager/types/message"
|
||||
|
||||
export interface MediaViewerItem {
|
||||
id: string
|
||||
title: string
|
||||
mime: string
|
||||
size?: number | null
|
||||
meta: { index: number; attachment: MessagePartInterface }
|
||||
}
|
||||
Reference in New Issue
Block a user