Initial commit
This commit is contained in:
283
src/components/MessageList.vue
Normal file
283
src/components/MessageList.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { EntityInterface } from '@MailManager/types/entity'
|
||||
import type { MessageInterface } from '@MailManager/types/message'
|
||||
import type { CollectionObject } from '@MailManager/models/collection'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
messages: EntityInterface<MessageInterface>[]
|
||||
selectedMessage?: EntityInterface<MessageInterface> | null
|
||||
selectedCollection?: CollectionObject | null
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
select: [message: EntityInterface<MessageInterface>]
|
||||
}>()
|
||||
|
||||
// Check if message is selected
|
||||
const isSelected = (message: EntityInterface<MessageInterface>): 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
|
||||
)
|
||||
}
|
||||
|
||||
// Check if message is unread
|
||||
const isUnread = (message: EntityInterface<MessageInterface>): boolean => {
|
||||
return !message.properties.flags?.read
|
||||
}
|
||||
|
||||
// Check if message is flagged
|
||||
const isFlagged = (message: EntityInterface<MessageInterface>): boolean => {
|
||||
return message.properties.flags?.flagged || false
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Handle message click
|
||||
const handleMessageClick = (message: EntityInterface<MessageInterface>) => {
|
||||
emit('select', message)
|
||||
}
|
||||
|
||||
// Sorted messages (newest first)
|
||||
const sortedMessages = computed(() => {
|
||||
return [...props.messages].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 readCount = computed(() => {
|
||||
const total = props.selectedCollection?.properties.total ?? 0
|
||||
const unread = props.selectedCollection?.properties.unread ?? 0
|
||||
return total - unread
|
||||
})
|
||||
|
||||
const totalCount = computed(() => {
|
||||
return props.selectedCollection?.properties.total ?? 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-list">
|
||||
<!-- Header with folder name and counts -->
|
||||
<div v-if="selectedCollection" class="message-list-header">
|
||||
<h2 class="text-h6">{{ selectedCollection.properties.label || 'Folder' }}</h2>
|
||||
<div class="folder-counts text-caption text-medium-emphasis">
|
||||
<span v-if="totalCount > 0">
|
||||
<span class="unread-count">{{ unreadCount }}</span>
|
||||
<span class="mx-1">/</span>
|
||||
<span>{{ totalCount }}</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
Empty
|
||||
</span>
|
||||
</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.provider}-${message.service}-${message.collection}-${message.identifier}`"
|
||||
class="message-item"
|
||||
:class="{
|
||||
'selected': isSelected(message),
|
||||
'unread': isUnread(message)
|
||||
}"
|
||||
@click="handleMessageClick(message)"
|
||||
lines="three"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<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>
|
||||
</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;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.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:hover {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
.message-item.selected {
|
||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||
border-left-color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.message-item.unread {
|
||||
:deep(.v-list-item-title),
|
||||
:deep(.v-list-item-subtitle:first-of-type) {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user