Merge pull request 'feat: multi select' (#11) from feat/multiselect into main

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-03-29 21:02:28 +00:00
4 changed files with 508 additions and 80 deletions

View File

@@ -50,7 +50,7 @@ interface ServiceGroup {
} }
const serviceGroups = computed<ServiceGroup[]>(() => { const serviceGroups = computed<ServiceGroup[]>(() => {
const moveCandidate = mailStore.moveMessageCandidate const moveCandidate = mailStore.moveMessageCandidates[0]
if (!moveCandidate) { if (!moveCandidate) {
return [] return []
@@ -111,7 +111,7 @@ const canConfirm = computed(() => {
}) })
watch( watch(
() => [props.modelValue, mailStore.moveMessageCandidate], () => [props.modelValue, mailStore.moveMessageCandidates],
([isOpen]) => { ([isOpen]) => {
if (!isOpen) { if (!isOpen) {
return return

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <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 { EntityInterface } from '@MailManager/types/entity'
import type { MessageInterface } from '@MailManager/types/message' import type { MessageInterface } from '@MailManager/types/message'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
@@ -8,21 +9,42 @@ import type { CollectionObject } from '@MailManager/models/collection'
interface Props { interface Props {
messages: EntityInterface<MessageInterface>[] messages: EntityInterface<MessageInterface>[]
selectedMessage?: EntityInterface<MessageInterface> | null selectedMessage?: EntityInterface<MessageInterface> | null
selectedMessageIds?: EntityIdentifier[]
selectionModeActive?: boolean
selectionCount?: number
hasSelection?: boolean
allCurrentMessagesSelected?: boolean
selectedCollection?: CollectionObject | null selectedCollection?: CollectionObject | null
loading?: boolean loading?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
loading: false loading: false,
selectedMessageIds: () => [],
selectionModeActive: false,
selectionCount: 0,
hasSelection: false,
allCurrentMessagesSelected: false,
}) })
// Emits // Emits
const emit = defineEmits<{ 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 longPressTimer = ref<number | null>(null)
const isSelected = (message: EntityInterface<MessageInterface>): boolean => { 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 if (!props.selectedMessage) return false
return ( return (
message.provider === props.selectedMessage.provider && 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 // Check if message is unread
const isUnread = (message: EntityInterface<MessageInterface>): boolean => { const isUnread = (message: EntityInterface<MessageInterface>): boolean => {
return !message.properties.flags?.read return !message.properties.flags?.read
@@ -90,7 +118,87 @@ const truncate = (text: string | null | undefined, length: number = 100): string
// Handle message click // Handle message click
const handleMessageClick = (message: EntityInterface<MessageInterface>) => { 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) // Sorted messages (newest first)
@@ -107,12 +215,6 @@ const unreadCount = computed(() => {
return props.selectedCollection?.properties.unread ?? 0 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(() => { const totalCount = computed(() => {
return props.selectedCollection?.properties.total ?? 0 return props.selectedCollection?.properties.total ?? 0
}) })
@@ -127,6 +229,7 @@ const hasCountData = computed(() => {
<div class="message-list"> <div class="message-list">
<!-- Header with folder name and counts --> <!-- Header with folder name and counts -->
<div v-if="selectedCollection" class="message-list-header"> <div v-if="selectedCollection" class="message-list-header">
<div class="message-list-heading">
<h2 class="text-h6">{{ selectedCollection.properties.label || 'Folder' }}</h2> <h2 class="text-h6">{{ selectedCollection.properties.label || 'Folder' }}</h2>
<div class="folder-counts text-caption text-medium-emphasis"> <div class="folder-counts text-caption text-medium-emphasis">
<span v-if="hasCountData"> <span v-if="hasCountData">
@@ -140,6 +243,42 @@ const hasCountData = computed(() => {
</div> </div>
</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>
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="pa-4"> <div v-if="loading" class="pa-4">
<v-skeleton-loader <v-skeleton-loader
@@ -171,18 +310,36 @@ const hasCountData = computed(() => {
:key="`${message.provider}-${message.service}-${message.collection}-${message.identifier}`" :key="`${message.provider}-${message.service}-${message.collection}-${message.identifier}`"
class="message-item" class="message-item"
:class="{ :class="{
'opened': isOpened(message),
'selected': isSelected(message), 'selected': isSelected(message),
'selection-mode': selectionModeActive,
'unread': isUnread(message) '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" lines="three"
> >
<template v-slot:prepend> <template v-slot:prepend>
<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"> <v-avatar size="40" color="primary">
<span class="text-white text-body-1"> <span class="text-white text-body-1">
{{ (message.properties.from?.label || message.properties.from?.address || 'U')[0].toUpperCase() }} {{ (message.properties.from?.label || message.properties.from?.address || 'U')[0].toUpperCase() }}
</span> </span>
</v-avatar> </v-avatar>
</div>
</template> </template>
<v-list-item-title class="d-flex align-center"> <v-list-item-title class="d-flex align-center">
@@ -241,14 +398,42 @@ const hasCountData = computed(() => {
background-color: rgb(var(--v-theme-surface)); background-color: rgb(var(--v-theme-surface));
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 12px; gap: 12px;
.message-list-heading {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 { h2 {
margin: 0; 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 { .folder-counts {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -270,19 +455,41 @@ const hasCountData = computed(() => {
border-left: 3px solid transparent; 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 { .message-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.04); 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); background-color: rgba(var(--v-theme-primary), 0.12);
border-left-color: rgb(var(--v-theme-primary)); 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 { .message-item.unread {
:deep(.v-list-item-title), :deep(.v-list-item-title),
:deep(.v-list-item-subtitle:first-of-type) { :deep(.v-list-item-subtitle:first-of-type) {
font-weight: 600; font-weight: 600;
} }
} }
@media (max-width: 960px) {
.selection-summary {
flex-direction: column;
align-items: flex-start;
}
}
</style> </style>

View File

@@ -32,10 +32,15 @@ const {
loading, loading,
selectedFolder, selectedFolder,
selectedMessage, selectedMessage,
selectedMessageIds,
selectionModeActive,
composeMode, composeMode,
composeReplyTo, composeReplyTo,
currentMessages, currentMessages,
moveDialogVisible, moveDialogVisible,
selectionCount,
hasSelection,
allCurrentMessagesSelected,
} = storeToRefs(mailStore) } = storeToRefs(mailStore)
// Complex store/composable objects accessed directly (not simple refs) // Complex store/composable objects accessed directly (not simple refs)
@@ -50,7 +55,24 @@ onMounted(async () => {
// Handlers — thin wrappers that delegate to the store // Handlers — thin wrappers that delegate to the store
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder) const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder)
const handleMessageSelect = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value) const handleMessageOpen = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value)
const handleMessageSelectionToggle = (message: EntityObject) => mailStore.toggleMessageSelection(message)
const handleSelectionModeActivate = (message: EntityObject) => mailStore.activateSelectionMode(message)
const handleSelectAllToggle = (value: boolean) => {
if (value) {
mailStore.selectAllCurrentMessages()
return
}
mailStore.clearSelection()
}
const handleSelectionClear = () => mailStore.deactivateSelectionMode()
const handleSelectionMove = () => mailStore.openMoveDialogForSelection()
const handleCompose = (replyTo?: EntityObject) => mailStore.openCompose(replyTo) const handleCompose = (replyTo?: EntityObject) => mailStore.openCompose(replyTo)
@@ -65,7 +87,7 @@ const handleDelete = (message: EntityObject) => mailStore.deleteMessage(message)
const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message) const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message)
const handleMoveConfirm = async (folder: CollectionObject) => { const handleMoveConfirm = async (folder: CollectionObject) => {
await mailStore.moveMessage(folder) await mailStore.moveMessages(folder)
} }
const handleMoveCancel = () => mailStore.closeMoveDialog() const handleMoveCancel = () => mailStore.closeMoveDialog()
@@ -181,9 +203,19 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
<MessageList <MessageList
:messages="currentMessages" :messages="currentMessages"
:selected-message="selectedMessage" :selected-message="selectedMessage"
:selected-message-ids="selectedMessageIds"
:selection-mode-active="selectionModeActive"
:selection-count="selectionCount"
:has-selection="hasSelection"
:all-current-messages-selected="allCurrentMessagesSelected"
:selected-collection="selectedFolder" :selected-collection="selectedFolder"
:loading="loading" :loading="loading"
@select="handleMessageSelect" @open="handleMessageOpen"
@toggle-selection="handleMessageSelectionToggle"
@activate-selection-mode="handleSelectionModeActivate"
@toggle-select-all="handleSelectAllToggle"
@clear-selection="handleSelectionClear"
@move-selection="handleSelectionMove"
/> />
</div> </div>

View File

@@ -1,4 +1,4 @@
import { ref, computed, shallowRef } from 'vue' import { ref, computed, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore' import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore' import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
@@ -6,7 +6,6 @@ import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync' import { useMailSync } from '@MailManager/composables/useMailSync'
import { useSnackbar } from '@KTXC' import { useSnackbar } from '@KTXC'
import type { CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { MessageInterface } from '@MailManager/types/message'
import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models' import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models'
export const useMailStore = defineStore('mailStore', () => { export const useMailStore = defineStore('mailStore', () => {
@@ -42,6 +41,8 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Selection State ─────────────────────────────────────────────────────── // ── Selection State ───────────────────────────────────────────────────────
const selectedFolder = shallowRef<CollectionObject | null>(null) const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null) const selectedMessage = shallowRef<EntityObject | null>(null)
const selectedMessageIds = ref<EntityIdentifier[]>([])
const selectionModeActive = ref(false)
// ── Compose State ───────────────────────────────────────────────────────── // ── Compose State ─────────────────────────────────────────────────────────
const composeMode = ref(false) const composeMode = ref(false)
@@ -49,7 +50,7 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Move State ──────────────────────────────────────────────────────────── // ── Move State ────────────────────────────────────────────────────────────
const moveDialogVisible = ref(false) const moveDialogVisible = ref(false)
const moveMessageCandidate = shallowRef<EntityObject | null>(null) const moveMessageCandidates = shallowRef<EntityObject[]>([])
// ── Computed ────────────────────────────────────────────────────────────── // ── Computed ──────────────────────────────────────────────────────────────
const currentMessages = computed(() => { const currentMessages = computed(() => {
@@ -64,6 +65,31 @@ export const useMailStore = defineStore('mailStore', () => {
) )
}) })
const selectedMessageIdSet = computed(() => new Set(selectedMessageIds.value))
const selectedMessageMap = computed(() => {
const messageMap = new Map<EntityIdentifier, EntityObject>()
currentMessages.value.forEach(message => {
const identifier = _entityIdentifier(message)
if (selectedMessageIdSet.value.has(identifier)) {
messageMap.set(identifier, message)
}
})
return messageMap
})
const selectedMessages = computed(() => Array.from(selectedMessageMap.value.values()))
const selectionCount = computed(() => selectedMessageIds.value.length)
const hasSelection = computed(() => selectionCount.value > 0)
const allCurrentMessagesSelected = computed(() => {
return currentMessages.value.length > 0 && currentMessages.value.every(message => isMessageSelected(message))
})
// ── Initialization ──────────────────────────────────────────────────────── // ── Initialization ────────────────────────────────────────────────────────
async function initialize() { async function initialize() {
@@ -216,34 +242,90 @@ export const useMailStore = defineStore('mailStore', () => {
return `${collection.provider}:${String(collection.service)}:${String(collection.identifier)}` as CollectionIdentifier return `${collection.provider}:${String(collection.service)}:${String(collection.identifier)}` as CollectionIdentifier
} }
function _isSameMessage(left: EntityObject | null, right: EntityObject): boolean { function _serviceFor(provider: string, serviceIdentifier: string | number) {
if (!left) { return servicesStore.services.find(service =>
return false service.provider === provider &&
String(service.identifier) === String(serviceIdentifier),
) ?? null
} }
return ( function _reloadFolderMessages(folder: CollectionObject) {
left.provider === right.provider && return entitiesStore.list({
String(left.service) === String(right.service) && [folder.provider]: {
String(left.collection) === String(right.collection) && [String(folder.service)]: {
String(left.identifier) === String(right.identifier) [String(folder.identifier)]: true,
) },
},
})
} }
function _setSelectedMessageIds(nextIds: EntityIdentifier[]) {
selectedMessageIds.value = Array.from(new Set(nextIds))
}
function _removeSelection(sourceIdentifiers: EntityIdentifier[]) {
if (sourceIdentifiers.length === 0 || selectedMessageIds.value.length === 0) {
return
}
const removedIds = new Set(sourceIdentifiers)
selectedMessageIds.value = selectedMessageIds.value.filter(identifier => !removedIds.has(identifier))
}
function _reconcileSelection() {
if (!selectedFolder.value) {
clearSelection()
selectedMessage.value = null
return
}
const currentMessageIdentifiers = new Set(currentMessages.value.map(message => _entityIdentifier(message)))
const nextSelectedIds = selectedMessageIds.value.filter(identifier => currentMessageIdentifiers.has(identifier))
if (nextSelectedIds.length !== selectedMessageIds.value.length) {
selectedMessageIds.value = nextSelectedIds
}
if (selectedMessage.value && !currentMessageIdentifiers.has(_entityIdentifier(selectedMessage.value))) {
selectedMessage.value = null
}
}
function _formatMoveNotification(successCount: number, failureCount: number, targetFolder: CollectionObject) {
const folderLabel = targetFolder.properties.label || String(targetFolder.identifier)
if (failureCount === 0) {
return {
message: successCount === 1
? `Message moved to "${folderLabel}"`
: `${successCount} messages moved to "${folderLabel}"`,
color: 'success' as const,
}
}
return {
message: successCount === 0
? `Move failed for ${failureCount === 1 ? '1 message' : `${failureCount} messages`}`
: `Moved ${successCount} ${successCount === 1 ? 'message' : 'messages'} to "${folderLabel}". ${failureCount} failed.`,
color: successCount === 0 ? 'error' as const : 'warning' as const,
}
}
watch(currentMessages, () => {
_reconcileSelection()
})
// ── Actions ─────────────────────────────────────────────────────────────── // ── Actions ───────────────────────────────────────────────────────────────
async function selectFolder(folder: CollectionObject) { async function selectFolder(folder: CollectionObject) {
selectedFolder.value = folder selectedFolder.value = folder
selectedMessage.value = null selectedMessage.value = null
clearSelection()
selectionModeActive.value = false
composeMode.value = false composeMode.value = false
try { try {
await entitiesStore.list({ await _reloadFolderMessages(folder)
[folder.provider]: {
[folder.service]: {
[folder.identifier]: true,
},
},
})
} catch (error) { } catch (error) {
console.error('[Mail] Failed to load messages:', error) console.error('[Mail] Failed to load messages:', error)
} }
@@ -266,14 +348,79 @@ export const useMailStore = defineStore('mailStore', () => {
selectedMessage.value = null selectedMessage.value = null
} }
function openMoveDialog(message: EntityObject) { function isMessageSelected(message: EntityObject) {
moveMessageCandidate.value = message return selectedMessageIdSet.value.has(_entityIdentifier(message))
}
function toggleMessageSelection(message: EntityObject) {
const identifier = _entityIdentifier(message)
selectionModeActive.value = true
if (selectedMessageIdSet.value.has(identifier)) {
selectedMessageIds.value = selectedMessageIds.value.filter(selectedId => selectedId !== identifier)
if (selectedMessageIds.value.length === 0) {
selectionModeActive.value = false
}
return
}
_setSelectedMessageIds([...selectedMessageIds.value, identifier])
}
function selectAllCurrentMessages() {
selectionModeActive.value = true
_setSelectedMessageIds(currentMessages.value.map(message => _entityIdentifier(message)))
}
function activateSelectionMode(message?: EntityObject) {
selectionModeActive.value = true
if (message) {
const identifier = _entityIdentifier(message)
if (!selectedMessageIdSet.value.has(identifier)) {
_setSelectedMessageIds([...selectedMessageIds.value, identifier])
}
}
}
function deactivateSelectionMode() {
selectionModeActive.value = false
clearSelection()
}
function clearSelection() {
selectedMessageIds.value = []
}
function openMoveDialog(messages: EntityObject | EntityObject[]) {
const nextCandidates = Array.isArray(messages) ? messages : [messages]
moveMessageCandidates.value = Array.from(
new Map(nextCandidates.map(candidate => [_entityIdentifier(candidate), candidate])).values(),
)
if (moveMessageCandidates.value.length === 0) {
return
}
moveDialogVisible.value = true moveDialogVisible.value = true
} }
function openMoveDialogForSelection() {
if (selectedMessages.value.length === 0) {
return
}
openMoveDialog(selectedMessages.value)
}
function closeMoveDialog() { function closeMoveDialog() {
moveDialogVisible.value = false moveDialogVisible.value = false
moveMessageCandidate.value = null moveMessageCandidates.value = []
} }
function closeCompose() { function closeCompose() {
@@ -296,19 +443,20 @@ export const useMailStore = defineStore('mailStore', () => {
console.log('[Mail] Delete message:', message.identifier) console.log('[Mail] Delete message:', message.identifier)
} }
async function moveMessage(targetFolder: CollectionObject) { async function moveMessages(targetFolder: CollectionObject) {
const message = moveMessageCandidate.value const candidates = moveMessageCandidates.value
if (!message) { if (candidates.length === 0) {
return return
} }
const isSameCollection = const movableCandidates = candidates.filter(message => !(
targetFolder.provider === message.provider && targetFolder.provider === message.provider &&
String(targetFolder.service) === String(message.service) && String(targetFolder.service) === String(message.service) &&
String(targetFolder.identifier) === String(message.collection) String(targetFolder.identifier) === String(message.collection)
))
if (isSameCollection) { if (movableCandidates.length === 0) {
closeMoveDialog() closeMoveDialog()
return return
} }
@@ -316,32 +464,59 @@ export const useMailStore = defineStore('mailStore', () => {
loading.value = true loading.value = true
try { try {
const sourceIdentifier = _entityIdentifier(message) const sourceIdentifiers = movableCandidates.map(message => _entityIdentifier(message))
const response = await entitiesStore.move(_collectionIdentifier(targetFolder), [sourceIdentifier]) const response = await entitiesStore.move(_collectionIdentifier(targetFolder), sourceIdentifiers)
const result = response[sourceIdentifier] const successfulMoves: EntityIdentifier[] = []
const failedMoves: string[] = []
if (!result || !result.success) { Object.entries(response).forEach(([sourceIdentifier, result]) => {
throw new Error(result && 'error' in result ? result.error : 'Failed to move message') if (result.success) {
successfulMoves.push(sourceIdentifier as EntityIdentifier)
return
} }
if (_isSameMessage(selectedMessage.value, message)) { failedMoves.push(result.error)
})
if (successfulMoves.length === 0) {
throw new Error(failedMoves[0] ?? 'Failed to move messages')
}
_removeSelection(successfulMoves)
if (selectedMessage.value && successfulMoves.includes(_entityIdentifier(selectedMessage.value))) {
selectedMessage.value = null selectedMessage.value = null
} }
const service = servicesStore.services.find(entry => if (selectedMessageIds.value.length === 0) {
entry.provider === message.provider && selectionModeActive.value = false
String(entry.identifier) === String(message.service),
)
if (service) {
void loadFoldersForService(service)
} }
notify(`Message moved to "${targetFolder.properties.label || targetFolder.identifier}"`, 'success')
closeMoveDialog() closeMoveDialog()
const servicesToRefresh = new Map<string, ServiceObject>()
movableCandidates.forEach(message => {
const service = _serviceFor(message.provider, message.service)
if (service && service.identifier !== null) {
servicesToRefresh.set(`${service.provider}:${String(service.identifier)}`, service)
}
})
const targetService = _serviceFor(targetFolder.provider, targetFolder.service)
if (targetService && targetService.identifier !== null) {
servicesToRefresh.set(`${targetService.provider}:${String(targetService.identifier)}`, targetService)
}
await Promise.allSettled([
...Array.from(servicesToRefresh.values()).map(service => loadFoldersForService(service)),
...(selectedFolder.value ? [_reloadFolderMessages(selectedFolder.value)] : []),
])
const notification = _formatMoveNotification(successfulMoves.length, failedMoves.length, targetFolder)
notify(notification.message, notification.color)
} catch (error) { } catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move message' const messageText = error instanceof Error ? error.message : 'Failed to move messages'
console.error('[Mail] Failed to move message:', error) console.error('[Mail] Failed to move messages:', error)
notify(messageText, 'error') notify(messageText, 'error')
throw error throw error
} finally { } finally {
@@ -376,27 +551,41 @@ export const useMailStore = defineStore('mailStore', () => {
loading, loading,
selectedFolder, selectedFolder,
selectedMessage, selectedMessage,
selectedMessageIds,
selectionModeActive,
composeMode, composeMode,
composeReplyTo, composeReplyTo,
moveDialogVisible, moveDialogVisible,
moveMessageCandidate, moveMessageCandidates,
serviceFolderLoadingState, serviceFolderLoadingState,
serviceFolderLoadedState, serviceFolderLoadedState,
serviceFolderErrorState, serviceFolderErrorState,
// Computed // Computed
currentMessages, currentMessages,
selectedMessageMap,
selectedMessages,
selectionCount,
hasSelection,
allCurrentMessagesSelected,
// Actions // Actions
selectFolder, selectFolder,
selectMessage, selectMessage,
isMessageSelected,
activateSelectionMode,
deactivateSelectionMode,
toggleMessageSelection,
selectAllCurrentMessages,
clearSelection,
openCompose, openCompose,
openMoveDialog, openMoveDialog,
openMoveDialogForSelection,
closeMoveDialog, closeMoveDialog,
closeCompose, closeCompose,
afterSent, afterSent,
deleteMessage, deleteMessage,
moveMessage, moveMessages,
toggleSidebar, toggleSidebar,
openSettings, openSettings,
notify, notify,