Files
mail/src/components/MessageList.vue
2026-04-25 21:49:49 -04:00

509 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'
// 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]
toggleSelection: [message: EntityObject]
activateSelectionMode: [message: EntityObject]
toggleSelectAll: [value: boolean]
clearSelection: []
moveSelection: []
deleteSelection: []
}>()
const longPressTimer = ref<number | null>(null)
const longPressActivated = ref(false)
const suppressNextClick = ref(false)
const LONG_PRESS_MS = 450
const selectedIdSet = computed(() => new Set(props.selectionList))
const isOpened = (message: EntityObject): boolean => {
if (!props.selectedMessage) return false
return (
message.provider === props.selectedMessage.provider &&
message.service === props.selectedMessage.service &&
message.collection === props.selectedMessage.collection &&
message.identifier === props.selectedMessage.identifier
)
}
const isSelected = (message: EntityObject): boolean => {
return selectedIdSet.value.has(
`${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier,
)
}
// Check if message is unread
const isUnread = (message: EntityObject): boolean => {
return !message.properties.flags?.read
}
// Check if message is flagged
const isFlagged = (message: EntityObject): boolean => {
return message.properties.flags?.flagged || false
}
const currentMessages = computed(() => props.messages ?? [])
const selectionCount = computed(() => props.selectionList.length)
const hasSelection = computed(() => selectionCount.value > 0)
const allCurrentMessagesSelected = computed(() => {
return currentMessages.value.length > 0 && currentMessages.value.every(message => isSelected(message))
})
// Format date for display
const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return ''
const messageDate = new Date(date)
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'
})
}
// Truncate text
const truncate = (text: string | null | undefined, length: number = 100): string => {
if (!text) return ''
return text.length > length ? text.substring(0, length) + '...' : text
}
const handleSelectionToggle = (message: EntityObject) => {
emit('toggleSelection', message)
}
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
if (event.shiftKey && !props.selectionMode) {
event.preventDefault()
event.stopPropagation()
emit('activateSelectionMode', message)
return
}
if (longPressActivated.value) {
longPressActivated.value = false
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (props.selectionMode) {
emit('toggleSelection', message)
return
}
emit('open', message)
}
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
if (!event.shiftKey || props.selectionMode) {
return
}
event.preventDefault()
event.stopPropagation()
suppressNextClick.value = true
emit('activateSelectionMode', message)
}
const clearLongPressTimer = () => {
if (longPressTimer.value !== null) {
window.clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
}
const handleTouchStart = (message: EntityObject) => {
clearLongPressTimer()
longPressActivated.value = false
longPressTimer.value = window.setTimeout(() => {
if (!props.selectionMode) {
emit('activateSelectionMode', message)
} else {
emit('toggleSelection', message)
}
longPressActivated.value = true
clearLongPressTimer()
}, LONG_PRESS_MS)
}
const handleTouchEnd = () => {
clearLongPressTimer()
}
const handleTouchMove = () => {
clearLongPressTimer()
}
onBeforeUnmount(() => {
clearLongPressTimer()
})
const handleSelectAllToggle = (value: boolean | null) => {
emit('toggleSelectAll', value === true)
}
// Sorted messages (newest first)
const sortedMessages = computed(() => {
return [...currentMessages.value].sort((a, b) => {
const dateA = a.properties.date ? new Date(a.properties.date).getTime() : 0
const dateB = b.properties.date ? new Date(b.properties.date).getTime() : 0
return dateB - dateA
})
})
// Read/Unread counts from collection properties
const unreadCount = computed(() => {
return props.selectedCollection?.properties.unread ?? 0
})
const totalCount = computed(() => {
return props.selectedCollection?.properties.total ?? 0
})
// True only when the collection explicitly provides total/unread counts
const hasCountData = computed(() => {
return props.selectedCollection?.properties.total != null
})
</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="hasCountData">
<span class="unread-count">{{ unreadCount }}</span>
<span class="mx-1">/</span>
<span>{{ totalCount }}</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="allCurrentMessagesSelected"
:indeterminate="hasSelection && !allCurrentMessagesSelected"
density="compact"
hide-details
@update:model-value="handleSelectAllToggle"
/>
<span class="text-caption text-medium-emphasis">
{{ selectionCount > 0 ? `${selectionCount} selected` : 'Select all loaded' }}
</span>
</div>
<div class="selection-actions">
<v-btn
size="small"
icon="mdi-folder-move-outline"
variant="text"
:disabled="!hasSelection"
@click="emit('moveSelection')"
>
<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="!hasSelection"
@click="emit('deleteSelection')"
>
<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-close"
variant="text"
:disabled="!hasSelection"
@click="emit('clearSelection')"
>
<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="currentMessages.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.provider}:${message.service}:${message.collection}:${message.identifier}`"
class="message-item"
:class="{
'opened': isOpened(message),
'selected': isSelected(message),
'selection-mode': selectionMode,
'unread': isUnread(message)
}"
@mousedown="handleMessageMouseDown($event, message)"
@click="handleMessageMouseClick($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="handleSelectionToggle(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">
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }}
</span>
<span class="text-caption text-medium-emphasis ml-2">
{{ formatDate(message.properties.date) }}
</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">
{{ truncate(message.properties.snippet, 80) }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="d-flex flex-column align-center">
<v-icon
v-if="isFlagged(message)"
size="small"
color="warning"
class="mb-1"
>
mdi-star
</v-icon>
<v-icon
v-if="message.properties.attachments && message.properties.attachments.length > 0"
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;
}
}
@media (max-width: 960px) {
.selection-summary {
flex-direction: column;
align-items: flex-start;
}
}
</style>