Merge pull request 'feat: attachment preview' (#64) from feat/attachment-preview into main
Reviewed-on: #64
This commit was merged in pull request #64.
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useUser } from '@KTXC'
|
import { useUser } from '@KTXC'
|
||||||
|
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||||
import type { EntityObject, MessageObject } from '@MailManager/models'
|
import type { EntityObject, MessageObject } from '@MailManager/models'
|
||||||
import { SecurityLevel } from '@/utile/emailSanitizer'
|
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 ReaderEmpty from './reader/ReaderEmpty.vue'
|
||||||
import ReaderToolbar from './reader/ReaderToolbar.vue'
|
import ReaderToolbar from './reader/ReaderToolbar.vue'
|
||||||
import ReaderHeader from './reader/ReaderHeader.vue'
|
import ReaderHeader from './reader/ReaderHeader.vue'
|
||||||
@@ -28,10 +30,14 @@ const emit = defineEmits<{
|
|||||||
const { getSetting } = useUser()
|
const { getSetting } = useUser()
|
||||||
const mailUiStore = useMailUiStore()
|
const mailUiStore = useMailUiStore()
|
||||||
|
|
||||||
// Per-message overrides
|
// Security overrides
|
||||||
const allowImages = ref(false)
|
const allowImages = ref(false)
|
||||||
const overrideSecurityLevel = ref<SecurityLevel | null>(null)
|
const overrideSecurityLevel = ref<SecurityLevel | null>(null)
|
||||||
|
|
||||||
|
// Viewer state
|
||||||
|
const showViewer = ref(false)
|
||||||
|
const viewerIndex = ref(0)
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const message = computed<MessageObject | null>(() => {
|
const message = computed<MessageObject | null>(() => {
|
||||||
return props.entity?.properties ?? null
|
return props.entity?.properties ?? null
|
||||||
@@ -55,12 +61,35 @@ const effectiveSecurityLevel = computed(() => {
|
|||||||
return overrideSecurityLevel.value ?? securityLevel.value
|
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
|
// Reset overrides when message changes
|
||||||
watch(() => props.entity, () => {
|
watch(() => props.entity, () => {
|
||||||
allowImages.value = allowImagesDefault.value
|
allowImages.value = allowImagesDefault.value
|
||||||
overrideSecurityLevel.value = null
|
overrideSecurityLevel.value = null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Close the viewer when switching messages.
|
||||||
|
watch(() => props.entity, () => {
|
||||||
|
showViewer.value = false
|
||||||
|
})
|
||||||
|
|
||||||
// Toggle images for current message only
|
// Toggle images for current message only
|
||||||
const toggleImages = () => {
|
const toggleImages = () => {
|
||||||
allowImages.value = !allowImages.value
|
allowImages.value = !allowImages.value
|
||||||
@@ -113,6 +142,22 @@ const handleFlag = () => {
|
|||||||
const handleCompose = () => {
|
const handleCompose = () => {
|
||||||
emit('compose')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -135,7 +180,7 @@ const handleCompose = () => {
|
|||||||
@move="handleMove"
|
@move="handleMove"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@flag="handleFlag"
|
@flag="handleFlag"
|
||||||
@download="handleDownload()"
|
@download="handleDownload"
|
||||||
@toggle-images="toggleImages"
|
@toggle-images="toggleImages"
|
||||||
@set-security-level="setSecurityLevel"
|
@set-security-level="setSecurityLevel"
|
||||||
/>
|
/>
|
||||||
@@ -144,7 +189,8 @@ const handleCompose = () => {
|
|||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<ReaderHeader
|
<ReaderHeader
|
||||||
:entity="props.entity"
|
:entity="props.entity"
|
||||||
@download-attachment="handleDownload"
|
@download="handleDownload"
|
||||||
|
@preview="handlePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
@@ -155,6 +201,19 @@ const handleCompose = () => {
|
|||||||
:allow-images="allowImages"
|
:allow-images="allowImages"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,18 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import RecipientDetails from '@/components/common/RecipientDetails.vue'
|
import RecipientDetails from '@/components/common/RecipientDetails.vue'
|
||||||
import { formatFileSize } from '@/utile/format'
|
import { formatFileSize } from '@/utile/format'
|
||||||
|
import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer'
|
||||||
|
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||||
import type { EntityObject } from '@MailManager/models';
|
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 {
|
interface Props {
|
||||||
entity: EntityObject | null
|
entity: EntityObject | null
|
||||||
@@ -11,9 +22,13 @@ interface Props {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
downloadAttachment: [index: number]
|
download: [index: number]
|
||||||
|
preview: [index: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const mailUiStore = useMailUiStore()
|
||||||
|
const previewPopover = computed(() => mediaViewerComponent('popover'))
|
||||||
|
|
||||||
const message = computed(() => {
|
const message = computed(() => {
|
||||||
return props.entity?.properties ?? null
|
return props.entity?.properties ?? null
|
||||||
})
|
})
|
||||||
@@ -22,6 +37,33 @@ const randomKey = computed(() => {
|
|||||||
return Math.random().toString(36).substring(2, 15)
|
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
|
// Format date for display
|
||||||
const formatDate = (date: Date | string | null | undefined): string => {
|
const formatDate = (date: Date | string | null | undefined): string => {
|
||||||
if (!date) return ''
|
if (!date) return ''
|
||||||
@@ -37,10 +79,6 @@ const formatDate = (date: Date | string | null | undefined): string => {
|
|||||||
hour12: true
|
hour12: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const download = async (index: number): Promise<void> => {
|
|
||||||
emit('downloadAttachment', index)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -101,21 +139,31 @@ const download = async (index: number): Promise<void> => {
|
|||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="(attachment, index) in message?.attachments"
|
v-for="(attachment, index) in message?.attachments"
|
||||||
:key="randomKey"
|
:key="attachment.partId || attachment.blobId || attachment.cid || index"
|
||||||
class="attachment-item"
|
class="attachment-item"
|
||||||
>
|
>
|
||||||
<v-chip
|
<v-chip
|
||||||
prepend-icon="mdi-paperclip"
|
:prepend-icon="isPreviewable(attachment) ? 'mdi-eye-outline' : 'mdi-paperclip'"
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
class="attachment-chip"
|
class="attachment-chip"
|
||||||
@click="download(index)"
|
:title="isPreviewable(attachment) ? 'Preview' : 'Download'"
|
||||||
|
@click="handleAttachmentClick(attachment, index)"
|
||||||
>
|
>
|
||||||
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
|
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
|
||||||
<span v-if="attachment.size != null" class="text-caption text-medium-emphasis ml-1">
|
<span v-if="attachment.size != null" class="text-caption text-medium-emphasis ml-1">
|
||||||
({{ formatFileSize(attachment.size) }})
|
({{ formatFileSize(attachment.size) }})
|
||||||
</span>
|
</span>
|
||||||
</v-chip>
|
</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>
|
</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 { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||||
import { useMailSync } from '@MailManager/composables/useMailSync'
|
import { useMailSync } from '@MailManager/composables/useMailSync'
|
||||||
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
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 type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
|
||||||
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
|
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
|
||||||
import { CollectionPropertiesObject } from '@MailManager/models/collection'
|
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 ───────────────────────────────────────────────────────────────
|
// ── Exports ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -607,6 +621,7 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
deleteFolder,
|
deleteFolder,
|
||||||
moveMessages,
|
moveMessages,
|
||||||
downloadMessage,
|
downloadMessage,
|
||||||
|
attachmentBlob,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
isServiceFolderLoading,
|
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 {
|
return {
|
||||||
sidebarVisible,
|
sidebarVisible,
|
||||||
settingsDialogVisible,
|
settingsDialogVisible,
|
||||||
@@ -758,6 +762,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
|
|||||||
flagMessage,
|
flagMessage,
|
||||||
moveMessages,
|
moveMessages,
|
||||||
downloadMessage,
|
downloadMessage,
|
||||||
|
attachmentBlob,
|
||||||
messageSelectionModeActivate,
|
messageSelectionModeActivate,
|
||||||
messageSelectionModeDeactivate,
|
messageSelectionModeDeactivate,
|
||||||
messageSelectionToggleOne,
|
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