@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import type { EntityIdentifier } from '@MailManager/types/common'
|
||||
import type { EntityInterface } from '@MailManager/types/entity'
|
||||
import type { MessageInterface } from '@MailManager/types/message'
|
||||
import type { CollectionObject } from '@MailManager/models/collection'
|
||||
@@ -8,21 +9,42 @@ import type { CollectionObject } from '@MailManager/models/collection'
|
||||
interface Props {
|
||||
messages: EntityInterface<MessageInterface>[]
|
||||
selectedMessage?: EntityInterface<MessageInterface> | null
|
||||
selectedMessageIds?: EntityIdentifier[]
|
||||
selectionModeActive?: boolean
|
||||
selectionCount?: number
|
||||
hasSelection?: boolean
|
||||
allCurrentMessagesSelected?: boolean
|
||||
selectedCollection?: CollectionObject | null
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
loading: false,
|
||||
selectedMessageIds: () => [],
|
||||
selectionModeActive: false,
|
||||
selectionCount: 0,
|
||||
hasSelection: false,
|
||||
allCurrentMessagesSelected: false,
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
select: [message: EntityInterface<MessageInterface>]
|
||||
open: [message: EntityInterface<MessageInterface>]
|
||||
toggleSelection: [message: EntityInterface<MessageInterface>]
|
||||
activateSelectionMode: [message: EntityInterface<MessageInterface>]
|
||||
toggleSelectAll: [value: boolean]
|
||||
clearSelection: []
|
||||
moveSelection: []
|
||||
}>()
|
||||
|
||||
// Check if message is selected
|
||||
const isSelected = (message: EntityInterface<MessageInterface>): boolean => {
|
||||
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.selectedMessageIds))
|
||||
|
||||
const isOpened = (message: EntityInterface<MessageInterface>): boolean => {
|
||||
if (!props.selectedMessage) return false
|
||||
return (
|
||||
message.provider === props.selectedMessage.provider &&
|
||||
@@ -32,6 +54,12 @@ const isSelected = (message: EntityInterface<MessageInterface>): boolean => {
|
||||
)
|
||||
}
|
||||
|
||||
const isSelected = (message: EntityInterface<MessageInterface>): 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: EntityInterface<MessageInterface>): boolean => {
|
||||
return !message.properties.flags?.read
|
||||
@@ -90,7 +118,87 @@ const truncate = (text: string | null | undefined, length: number = 100): string
|
||||
|
||||
// Handle message click
|
||||
const handleMessageClick = (message: EntityInterface<MessageInterface>) => {
|
||||
emit('select', message)
|
||||
if (longPressActivated.value) {
|
||||
longPressActivated.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (suppressNextClick.value) {
|
||||
suppressNextClick.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (props.selectionModeActive) {
|
||||
emit('toggleSelection', message)
|
||||
return
|
||||
}
|
||||
|
||||
emit('open', message)
|
||||
}
|
||||
|
||||
const handleSelectionToggle = (message: EntityInterface<MessageInterface>) => {
|
||||
emit('toggleSelection', message)
|
||||
}
|
||||
|
||||
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityInterface<MessageInterface>) => {
|
||||
if (event.shiftKey && !props.selectionModeActive) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('activateSelectionMode', message)
|
||||
return
|
||||
}
|
||||
|
||||
handleMessageClick(message)
|
||||
}
|
||||
|
||||
const handleMessageMouseDown = (event: MouseEvent, message: EntityInterface<MessageInterface>) => {
|
||||
if (!event.shiftKey || props.selectionModeActive) {
|
||||
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: EntityInterface<MessageInterface>) => {
|
||||
clearLongPressTimer()
|
||||
longPressActivated.value = false
|
||||
|
||||
longPressTimer.value = window.setTimeout(() => {
|
||||
if (!props.selectionModeActive) {
|
||||
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)
|
||||
@@ -107,12 +215,6 @@ 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
|
||||
})
|
||||
@@ -127,16 +229,53 @@ const hasCountData = computed(() => {
|
||||
<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="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 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="selectionModeActive && 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"
|
||||
variant="text"
|
||||
prepend-icon="mdi-folder-move-outline"
|
||||
:disabled="!hasSelection"
|
||||
@click="emit('moveSelection')"
|
||||
>
|
||||
Move
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="!hasSelection"
|
||||
@click="emit('clearSelection')"
|
||||
>
|
||||
Clear
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,18 +310,36 @@ const hasCountData = computed(() => {
|
||||
:key="`${message.provider}-${message.service}-${message.collection}-${message.identifier}`"
|
||||
class="message-item"
|
||||
:class="{
|
||||
'opened': isOpened(message),
|
||||
'selected': isSelected(message),
|
||||
'selection-mode': selectionModeActive,
|
||||
'unread': isUnread(message)
|
||||
}"
|
||||
@click="handleMessageClick(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>
|
||||
<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 class="message-item-prepend">
|
||||
<v-checkbox-btn
|
||||
v-if="selectionModeActive || 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">
|
||||
@@ -241,14 +398,42 @@ const hasCountData = computed(() => {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
@@ -270,19 +455,41 @@ const hasCountData = computed(() => {
|
||||
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.selected {
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user