Compare commits

..

6 Commits

Author SHA1 Message Date
b24b751e4a Merge pull request 'recator: improve logic' (#13) from recator/improve-logic2 into main
Reviewed-on: #13
2026-03-30 18:49:53 +00:00
36321a7584 recator: improve logic
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-03-30 14:49:36 -04:00
ec673984f7 Merge pull request 'refactor: improve logic' (#12) from recator/improve-logic into main
Reviewed-on: #12
2026-03-30 18:44:43 +00:00
11de31397d refactor: improve logic
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-03-30 14:43:26 -04:00
4ff377c6ac Merge pull request 'feat: multi select' (#11) from feat/multiselect into main
Reviewed-on: #11
2026-03-29 21:02:28 +00:00
69f3c430cc feat: multi select
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-03-29 17:02:00 -04:00
4 changed files with 525 additions and 84 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,26 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import { storeToRefs } from 'pinia' import type { EntityIdentifier } from '@MailManager/types/common'
import { useMailStore } from '@/stores/mailStore' import type { EntityInterface } from '@MailManager/types/entity'
import type { EntityObject } from '@MailManager/models' import type { MessageInterface } from '@MailManager/types/message'
import type { CollectionObject } from '@MailManager/models/collection'
withDefaults(defineProps<{ // Props
interface Props {
messages: EntityInterface<MessageInterface>[]
selectedMessage?: EntityInterface<MessageInterface> | null
selectedMessageIds?: EntityIdentifier[]
selectionModeActive?: boolean
selectionCount?: number
hasSelection?: boolean
allCurrentMessagesSelected?: boolean
selectedCollection?: CollectionObject | null
loading?: boolean loading?: boolean
}>(), { }
loading: false
const props = withDefaults(defineProps<Props>(), {
loading: false,
selectedMessageIds: () => [],
selectionModeActive: false,
selectionCount: 0,
hasSelection: false,
allCurrentMessagesSelected: false,
}) })
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
select: [message: EntityObject] open: [message: EntityInterface<MessageInterface>]
toggleSelection: [message: EntityInterface<MessageInterface>]
activateSelectionMode: [message: EntityInterface<MessageInterface>]
toggleSelectAll: [value: boolean]
clearSelection: []
moveSelection: []
}>() }>()
const mailStore = useMailStore() const longPressTimer = ref<number | null>(null)
const { currentMessages, selectedFolder, selectedMessage } = storeToRefs(mailStore) const longPressActivated = ref(false)
const suppressNextClick = ref(false)
const LONG_PRESS_MS = 450
// Check if message is selected const selectedIdSet = computed(() => new Set(props.selectedMessageIds))
const isSelected = (message: EntityObject): boolean => {
if (!selectedMessage.value) return false const isOpened = (message: EntityInterface<MessageInterface>): boolean => {
if (!props.selectedMessage) return false
return ( return (
message.provider === selectedMessage.value.provider && message.provider === selectedMessage.value.provider &&
message.service === selectedMessage.value.service && message.service === selectedMessage.value.service &&
@@ -29,6 +54,12 @@ const isSelected = (message: EntityObject): 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: EntityObject): boolean => { const isUnread = (message: EntityObject): boolean => {
return !message.properties.flags?.read return !message.properties.flags?.read
@@ -86,8 +117,88 @@ const truncate = (text: string | null | undefined, length: number = 100): string
} }
// Handle message click // Handle message click
const handleMessageClick = (message: EntityObject) => { 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)
@@ -101,7 +212,7 @@ const sortedMessages = computed(() => {
// Read/Unread counts from collection properties // Read/Unread counts from collection properties
const unreadCount = computed(() => { const unreadCount = computed(() => {
return selectedFolder.value?.properties.unread ?? 0 return props.selectedCollection?.properties.unread ?? 0
}) })
const totalCount = computed(() => { const totalCount = computed(() => {
@@ -117,20 +228,57 @@ const hasCountData = computed(() => {
<template> <template>
<div class="message-list"> <div class="message-list">
<!-- Header with folder name and counts --> <!-- Header with folder name and counts -->
<div v-if="selectedFolder" class="message-list-header"> <div v-if="selectedCollection" class="message-list-header">
<h2 class="text-h6">{{ selectedFolder.properties.label || 'Folder' }}</h2> <div class="message-list-heading">
<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">
<span class="unread-count">{{ unreadCount }}</span> <span class="unread-count">{{ unreadCount }}</span>
<span class="mx-1">/</span> <span class="mx-1">/</span>
<span>{{ totalCount }}</span> <span>{{ totalCount }}</span>
</span> </span>
<span v-else-if="currentMessages.length > 0"> <span v-else-if="messages.length > 0">
{{ currentMessages.length }} loaded {{ messages.length }} loaded
</span> </span>
</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
@@ -162,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">
@@ -232,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;
@@ -261,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,9 +32,14 @@ const {
loading, loading,
selectedFolder, selectedFolder,
selectedMessage, selectedMessage,
selectedMessageIds,
selectionModeActive,
composeMode, composeMode,
composeReplyTo, composeReplyTo,
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)
@@ -49,7 +54,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)
@@ -64,7 +86,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()
@@ -178,8 +200,21 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
<!-- Message list panel --> <!-- Message list panel -->
<div class="mail-list-panel"> <div class="mail-list-panel">
<MessageList <MessageList
:messages="currentMessages"
: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"
: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'
@@ -41,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)
@@ -48,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(() => {
@@ -63,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() {
@@ -215,34 +242,90 @@ export const useMailStore = defineStore('mailStore', () => {
return serviceFolderErrorState.value[_serviceKey(provider, service)] ?? null return serviceFolderErrorState.value[_serviceKey(provider, service)] ?? null
} }
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)
} }
@@ -265,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() {
@@ -290,19 +438,20 @@ export const useMailStore = defineStore('mailStore', () => {
} }
} }
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
} }
@@ -310,32 +459,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 {
@@ -375,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,