559 lines
14 KiB
Vue
559 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
|
import type { EntityIdentifier } from '@MailManager/types/common'
|
|
import type { EntityObject } from '@MailManager/models'
|
|
import type { CollectionObject } from '@MailManager/models/collection'
|
|
import RecipientDetails from '@/components/common/RecipientDetails.vue'
|
|
|
|
// Props
|
|
interface Props {
|
|
messages: EntityObject[]
|
|
selectedMessage?: EntityObject | null
|
|
selectionList?: EntityIdentifier[]
|
|
selectionMode?: boolean
|
|
selectedCollection?: CollectionObject | null
|
|
loading?: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
loading: false,
|
|
selectionList: () => [],
|
|
selectionMode: false,
|
|
})
|
|
|
|
// Emits
|
|
const emit = defineEmits<{
|
|
open: [message: EntityObject]
|
|
selectionMode: [message: EntityObject]
|
|
selectionToggleOne: [message: EntityObject]
|
|
selectionToggleAll: [value: boolean]
|
|
selectionClear: []
|
|
selectionMove: []
|
|
selectionDelete: []
|
|
selectionFlag: [flag: string, value: boolean]
|
|
}>()
|
|
|
|
const longPressTimer = ref<number | null>(null)
|
|
const longPressActivated = ref(false)
|
|
const suppressNextClick = ref(false)
|
|
const LONG_PRESS_MS = 400
|
|
const selectedIdSet = computed(() => new Set(props.selectionList))
|
|
const selectionCount = computed(() => props.selectionList.length ?? 0)
|
|
|
|
// Sorted messages (newest first)
|
|
const sortedMessages = computed(() => {
|
|
return [...props.messages].sort((a, b) => {
|
|
const dateA = timeStamp(a) ?? 0
|
|
const dateB = timeStamp(b) ?? 0
|
|
return dateB - dateA
|
|
})
|
|
})
|
|
|
|
const isOpened = (message: EntityObject): boolean => {
|
|
if (!props.selectedMessage) return false
|
|
return (message.identifier === props.selectedMessage.identifier)
|
|
}
|
|
|
|
const isSelected = (message: EntityObject): boolean => {
|
|
return selectedIdSet.value.has(message.identifier)
|
|
}
|
|
|
|
const timeStamp = (message: EntityObject): number | null => {
|
|
const timestamp = message.properties.received
|
|
|| message.properties.sent
|
|
|| message.modified
|
|
|| message.created
|
|
|| null
|
|
|
|
if (!timestamp) {
|
|
return null
|
|
}
|
|
|
|
const timeValue = new Date(timestamp).getTime()
|
|
return Number.isNaN(timeValue) ? null : timeValue
|
|
}
|
|
|
|
// Format date for display
|
|
const formatDate = (date: Date | string | number | null | undefined): string => {
|
|
if (!date) return ''
|
|
|
|
const messageDate = new Date(date)
|
|
if (Number.isNaN(messageDate.getTime())) return ''
|
|
|
|
const now = new Date()
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
const yesterday = new Date(today)
|
|
yesterday.setDate(yesterday.getDate() - 1)
|
|
|
|
// Today - show time
|
|
if (messageDate >= today) {
|
|
return messageDate.toLocaleTimeString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
})
|
|
}
|
|
|
|
// Yesterday
|
|
if (messageDate >= yesterday) {
|
|
return 'Yesterday'
|
|
}
|
|
|
|
// This year - show month and day
|
|
if (messageDate.getFullYear() === now.getFullYear()) {
|
|
return messageDate.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric'
|
|
})
|
|
}
|
|
|
|
|
|
// Other years - show full date
|
|
return messageDate.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
})
|
|
}
|
|
|
|
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
|
|
return event.target instanceof Element && event.target.closest('.message-selection-checkbox') !== null
|
|
}
|
|
|
|
const handleSelectionToggleOne = (message: EntityObject) => {
|
|
emit('selectionToggleOne', message)
|
|
}
|
|
|
|
const handleSelectionToggleAll = (value: boolean | null) => {
|
|
emit('selectionToggleAll', value === true)
|
|
}
|
|
|
|
const handleMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
|
|
if (isSelectionControlClick(event)) {
|
|
return
|
|
}
|
|
|
|
if (suppressNextClick.value) {
|
|
suppressNextClick.value = false
|
|
return
|
|
}
|
|
|
|
if (event.shiftKey && !props.selectionMode) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
emit('selectionMode', message)
|
|
return
|
|
}
|
|
|
|
if (longPressActivated.value) {
|
|
longPressActivated.value = false
|
|
return
|
|
}
|
|
|
|
if (props.selectionMode) {
|
|
emit('selectionToggleOne', message)
|
|
return
|
|
}
|
|
|
|
emit('open', message)
|
|
}
|
|
|
|
const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
|
|
if (isSelectionControlClick(event)) {
|
|
return
|
|
}
|
|
|
|
if (!event.shiftKey || props.selectionMode) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
suppressNextClick.value = true
|
|
emit('selectionMode', message)
|
|
}
|
|
|
|
const handleTouchStart = (message: EntityObject) => {
|
|
clearLongPressTimer()
|
|
longPressActivated.value = false
|
|
|
|
longPressTimer.value = window.setTimeout(() => {
|
|
if (!props.selectionMode) {
|
|
emit('selectionMode', message)
|
|
} else {
|
|
emit('selectionToggleOne', message)
|
|
}
|
|
|
|
longPressActivated.value = true
|
|
clearLongPressTimer()
|
|
}, LONG_PRESS_MS)
|
|
}
|
|
|
|
const handleTouchEnd = () => {
|
|
clearLongPressTimer()
|
|
}
|
|
|
|
const handleTouchMove = () => {
|
|
clearLongPressTimer()
|
|
}
|
|
|
|
const clearLongPressTimer = () => {
|
|
if (longPressTimer.value !== null) {
|
|
window.clearTimeout(longPressTimer.value)
|
|
longPressTimer.value = null
|
|
}
|
|
}
|
|
|
|
const handleFlag = (flag: string, value: boolean) => {
|
|
if (props.selectionMode && selectionCount.value > 0) {
|
|
emit('selectionFlag', flag, value)
|
|
}
|
|
}
|
|
|
|
const handleRecipientClick = (message: EntityObject) => {
|
|
if (props.selectionMode) {
|
|
emit('selectionToggleOne', message)
|
|
return
|
|
}
|
|
|
|
emit('open', message)
|
|
}
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
clearLongPressTimer()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="message-list">
|
|
<!-- Header with folder name and counts -->
|
|
<div v-if="selectedCollection" class="message-list-header">
|
|
<div class="message-list-heading">
|
|
<h2 class="text-h6">{{ selectedCollection?.properties.label || 'Folder' }}</h2>
|
|
<div class="folder-counts text-caption text-medium-emphasis">
|
|
<span v-if="selectedCollection?.properties.total != null">
|
|
<span class="unread-count">{{ selectedCollection?.properties.unread ?? 0 }}</span>
|
|
<span class="mx-1">/</span>
|
|
<span>{{ selectedCollection?.properties.total ?? 0 }}</span>
|
|
</span>
|
|
<span v-else-if="messages.length > 0">
|
|
{{ messages.length }} loaded
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="selectionMode && messages.length > 0" class="selection-summary">
|
|
<div class="selection-controls">
|
|
<v-checkbox-btn
|
|
:model-value="selectionCount !== 0"
|
|
:indeterminate="selectionCount > 0 && selectionCount !== messages.length"
|
|
density="compact"
|
|
hide-details
|
|
@update:model-value="handleSelectionToggleAll"
|
|
/>
|
|
</div>
|
|
|
|
<div class="selection-actions">
|
|
<v-btn
|
|
size="small"
|
|
icon="mdi-folder-move-outline"
|
|
variant="text"
|
|
:disabled="selectionCount === 0"
|
|
@click="emit('selectionMove')"
|
|
>
|
|
<v-icon>mdi-folder-move-outline</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Move Selected</v-tooltip>
|
|
</v-btn>
|
|
<v-btn
|
|
size="small"
|
|
icon="mdi-delete-outline"
|
|
variant="text"
|
|
:disabled="selectionCount === 0"
|
|
@click="emit('selectionDelete')"
|
|
>
|
|
<v-icon>mdi-delete-outline</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Delete Selected</v-tooltip>
|
|
</v-btn>
|
|
<v-btn
|
|
size="small"
|
|
icon="mdi-email-open-outline"
|
|
variant="text"
|
|
:disabled="selectionCount === 0"
|
|
@click="handleFlag('read', true)"
|
|
>
|
|
<v-icon>mdi-email-open-outline</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Mark as Read</v-tooltip>
|
|
</v-btn>
|
|
<v-btn
|
|
size="small"
|
|
icon="mdi-email-outline"
|
|
variant="text"
|
|
:disabled="selectionCount === 0"
|
|
@click="handleFlag('read', false)"
|
|
>
|
|
<v-icon>mdi-email-outline</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Mark as Unread</v-tooltip>
|
|
</v-btn>
|
|
<v-btn
|
|
size="small"
|
|
icon="mdi-close"
|
|
variant="text"
|
|
@click="emit('selectionClear')"
|
|
>
|
|
<v-icon>mdi-close</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Clear Selected</v-tooltip>
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading state -->
|
|
<div v-if="loading" class="pa-4">
|
|
<v-skeleton-loader
|
|
v-for="i in 5"
|
|
:key="i"
|
|
type="list-item-three-line"
|
|
class="mb-2"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div v-else-if="messages.length === 0" class="pa-8 text-center">
|
|
<v-icon size="64" color="grey-lighten-1">mdi-email-outline</v-icon>
|
|
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
|
|
<div class="text-body-2 text-medium-emphasis">
|
|
This folder is empty
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Message list with virtual scroll -->
|
|
<v-virtual-scroll
|
|
v-else
|
|
:items="sortedMessages"
|
|
:item-height="80"
|
|
class="message-virtual-scroll"
|
|
>
|
|
<template v-slot:default="{ item: message }">
|
|
<v-list-item
|
|
:key="message.identifier"
|
|
class="message-item"
|
|
:class="{
|
|
'opened': isOpened(message),
|
|
'selected': isSelected(message),
|
|
'selection-mode': selectionMode,
|
|
'unread': !message.properties.isRead
|
|
}"
|
|
@mousedown="handleMouseDown($event, message)"
|
|
@click="handleMouseClick($event, message)"
|
|
@touchstart.passive="handleTouchStart(message)"
|
|
@touchend="handleTouchEnd"
|
|
@touchcancel="handleTouchEnd"
|
|
@touchmove="handleTouchMove"
|
|
lines="three"
|
|
>
|
|
<template v-slot:prepend>
|
|
<div class="message-item-prepend">
|
|
<v-checkbox-btn
|
|
v-if="selectionMode || isSelected(message)"
|
|
:model-value="isSelected(message)"
|
|
density="compact"
|
|
hide-details
|
|
@click.stop
|
|
@update:model-value="handleSelectionToggleOne(message)"
|
|
/>
|
|
|
|
<v-avatar size="40" color="primary">
|
|
<span class="text-white text-body-1">
|
|
{{ (message.properties.from?.label || message.properties.from?.address || 'U')[0].toUpperCase() }}
|
|
</span>
|
|
</v-avatar>
|
|
</div>
|
|
</template>
|
|
|
|
<v-list-item-title class="d-flex align-center">
|
|
<span class="flex-grow-1 text-truncate">
|
|
<RecipientDetails
|
|
:address="message.properties.from"
|
|
@clicked="handleRecipientClick(message)"
|
|
>
|
|
<template #default="{ label }">
|
|
<span class="message-person-link text-truncate">{{ label }}</span>
|
|
</template>
|
|
</RecipientDetails>
|
|
</span>
|
|
<span class="text-caption text-medium-emphasis ml-2">
|
|
{{ formatDate(timeStamp(message)) }}
|
|
</span>
|
|
</v-list-item-title>
|
|
|
|
<v-list-item-subtitle class="text-truncate">
|
|
{{ message.properties.subject || '(No subject)' }}
|
|
</v-list-item-subtitle>
|
|
|
|
<v-list-item-subtitle class="text-caption text-truncate">
|
|
{{ '' }}
|
|
</v-list-item-subtitle>
|
|
|
|
<template v-slot:append>
|
|
<div class="d-flex flex-column align-center">
|
|
<v-icon
|
|
v-if="message.properties.isFlagged"
|
|
size="small"
|
|
color="warning"
|
|
class="mb-1"
|
|
>
|
|
mdi-star
|
|
</v-icon>
|
|
<v-icon
|
|
v-if="message.properties.hasAttachments"
|
|
size="small"
|
|
color="grey"
|
|
>
|
|
mdi-paperclip
|
|
</v-icon>
|
|
</div>
|
|
</template>
|
|
</v-list-item>
|
|
<v-divider />
|
|
</template>
|
|
</v-virtual-scroll>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.message-list {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.message-list-header {
|
|
padding: 16px;
|
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
|
background-color: rgb(var(--v-theme-surface));
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
|
|
.message-list-heading {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
h2 {
|
|
margin: 0;
|
|
}
|
|
}
|
|
|
|
.selection-summary {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.selection-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.selection-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.folder-counts {
|
|
display: flex;
|
|
align-items: center;
|
|
|
|
.unread-count {
|
|
font-weight: 600;
|
|
color: rgb(var(--v-theme-primary));
|
|
}
|
|
}
|
|
|
|
.message-virtual-scroll {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.message-item {
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
border-left: 3px solid transparent;
|
|
}
|
|
|
|
.message-item.selection-mode {
|
|
cursor: default;
|
|
}
|
|
|
|
.message-item-prepend {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 72px;
|
|
}
|
|
|
|
.message-item:hover {
|
|
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
|
}
|
|
|
|
.message-item.opened {
|
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
|
border-left-color: rgb(var(--v-theme-primary));
|
|
}
|
|
|
|
.message-item.selected:not(.opened) {
|
|
background-color: rgba(var(--v-theme-primary), 0.08);
|
|
}
|
|
|
|
.message-item.unread {
|
|
:deep(.v-list-item-title),
|
|
:deep(.v-list-item-subtitle:first-of-type) {
|
|
font-weight: 600;
|
|
}
|
|
|
|
:deep(.v-list-item-title),
|
|
:deep(.v-list-item-subtitle:first-of-type),
|
|
:deep(.v-list-item-title .text-caption) {
|
|
color: rgb(var(--v-theme-on-surface));
|
|
}
|
|
}
|
|
|
|
.message-item.unread:hover {
|
|
background-color: rgba(var(--v-theme-primary), 0.1);
|
|
}
|
|
|
|
.message-item.unread.selected:not(.opened) {
|
|
background-color: rgba(var(--v-theme-primary), 0.14);
|
|
}
|
|
|
|
.message-person-link {
|
|
display: inline-block;
|
|
max-width: 100%;
|
|
border-radius: 4px;
|
|
padding: 1px 4px;
|
|
margin: -1px -4px;
|
|
transition: background-color 0.2s ease;
|
|
}
|
|
|
|
.message-person-link:hover {
|
|
background-color: rgba(var(--v-theme-primary), 0.08);
|
|
}
|
|
|
|
@media (max-width: 960px) {
|
|
.selection-summary {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
}
|
|
</style>
|