feat: move and delete

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-04-16 20:36:25 -04:00
parent 509fbc2480
commit 31a9ab419c
5 changed files with 274 additions and 285 deletions

View File

@@ -23,7 +23,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
select: [folder: CollectionObject] select: [target: CollectionObject]
cancel: [] cancel: []
}>() }>()
@@ -50,16 +50,13 @@ interface ServiceGroup {
} }
const serviceGroups = computed<ServiceGroup[]>(() => { const serviceGroups = computed<ServiceGroup[]>(() => {
const moveCandidate = mailStore.moveMessageCandidates[0] const context = mailStore.moveDialogService
if (!moveCandidate) { if (!context) {
return [] return []
} }
const service = servicesStore.services.find(entry => const service = servicesStore.serviceByIdentifier(mailStore.moveDialogService)
entry.provider === moveCandidate.provider &&
String(entry.identifier) === String(moveCandidate.service),
)
if (!service) { if (!service) {
return [] return []
@@ -111,7 +108,7 @@ const canConfirm = computed(() => {
}) })
watch( watch(
() => [props.modelValue, mailStore.moveMessageCandidates], () => [props.modelValue, mailStore.moveDialogService],
([isOpen]) => { ([isOpen]) => {
if (!isOpen) { if (!isOpen) {
return return

View File

@@ -1,40 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import type { EntityIdentifier } from '@MailManager/types/common' import type { EntityIdentifier } from '@MailManager/types/common'
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' import type { CollectionObject } from '@MailManager/models/collection'
// Props // Props
interface Props { interface Props {
messages: EntityInterface<MessageInterface>[] messages: EntityObject[]
selectedMessage?: EntityInterface<MessageInterface> | null selectedMessage?: EntityObject | null
selectedMessageIds?: EntityIdentifier[] selectionList?: EntityIdentifier[]
selectionModeActive?: boolean selectionMode?: 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: () => [], selectionList: () => [],
selectionModeActive: false, selectionMode: false,
selectionCount: 0,
hasSelection: false,
allCurrentMessagesSelected: false,
}) })
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
open: [message: EntityInterface<MessageInterface>] open: [message: EntityObject]
toggleSelection: [message: EntityInterface<MessageInterface>] toggleSelection: [message: EntityObject]
activateSelectionMode: [message: EntityInterface<MessageInterface>] activateSelectionMode: [message: EntityObject]
toggleSelectAll: [value: boolean] toggleSelectAll: [value: boolean]
clearSelection: [] clearSelection: []
moveSelection: [] moveSelection: []
deleteSelection: []
}>() }>()
const longPressTimer = ref<number | null>(null) const longPressTimer = ref<number | null>(null)
@@ -42,19 +36,19 @@ const longPressActivated = ref(false)
const suppressNextClick = ref(false) const suppressNextClick = ref(false)
const LONG_PRESS_MS = 450 const LONG_PRESS_MS = 450
const selectedIdSet = computed(() => new Set(props.selectedMessageIds)) const selectedIdSet = computed(() => new Set(props.selectionList))
const isOpened = (message: EntityInterface<MessageInterface>): boolean => { const isOpened = (message: EntityObject): boolean => {
if (!props.selectedMessage) return false if (!props.selectedMessage) return false
return ( return (
message.provider === selectedMessage.value.provider && message.provider === props.selectedMessage.provider &&
message.service === selectedMessage.value.service && message.service === props.selectedMessage.service &&
message.collection === selectedMessage.value.collection && message.collection === props.selectedMessage.collection &&
message.identifier === selectedMessage.value.identifier message.identifier === props.selectedMessage.identifier
) )
} }
const isSelected = (message: EntityInterface<MessageInterface>): boolean => { const isSelected = (message: EntityObject): boolean => {
return selectedIdSet.value.has( return selectedIdSet.value.has(
`${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier, `${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier,
) )
@@ -70,6 +64,16 @@ const isFlagged = (message: EntityObject): boolean => {
return message.properties.flags?.flagged || false 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 // Format date for display
const formatDate = (date: Date | string | null | undefined): string => { const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return '' if (!date) return ''
@@ -101,6 +105,7 @@ const formatDate = (date: Date | string | null | undefined): string => {
day: 'numeric' day: 'numeric'
}) })
} }
// Other years - show full date // Other years - show full date
return messageDate.toLocaleDateString('en-US', { return messageDate.toLocaleDateString('en-US', {
@@ -116,8 +121,18 @@ const truncate = (text: string | null | undefined, length: number = 100): string
return text.length > length ? text.substring(0, length) + '...' : text return text.length > length ? text.substring(0, length) + '...' : text
} }
// Handle message click const handleSelectionToggle = (message: EntityObject) => {
const handleMessageClick = (message: EntityInterface<MessageInterface>) => { 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) { if (longPressActivated.value) {
longPressActivated.value = false longPressActivated.value = false
return return
@@ -128,7 +143,7 @@ const handleMessageClick = (message: EntityInterface<MessageInterface>) => {
return return
} }
if (props.selectionModeActive) { if (props.selectionMode) {
emit('toggleSelection', message) emit('toggleSelection', message)
return return
} }
@@ -136,23 +151,8 @@ const handleMessageClick = (message: EntityInterface<MessageInterface>) => {
emit('open', message) emit('open', message)
} }
const handleSelectionToggle = (message: EntityInterface<MessageInterface>) => { const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
emit('toggleSelection', message) if (!event.shiftKey || props.selectionMode) {
}
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 return
} }
@@ -169,12 +169,12 @@ const clearLongPressTimer = () => {
} }
} }
const handleTouchStart = (message: EntityInterface<MessageInterface>) => { const handleTouchStart = (message: EntityObject) => {
clearLongPressTimer() clearLongPressTimer()
longPressActivated.value = false longPressActivated.value = false
longPressTimer.value = window.setTimeout(() => { longPressTimer.value = window.setTimeout(() => {
if (!props.selectionModeActive) { if (!props.selectionMode) {
emit('activateSelectionMode', message) emit('activateSelectionMode', message)
} else { } else {
emit('toggleSelection', message) emit('toggleSelection', message)
@@ -216,12 +216,12 @@ const unreadCount = computed(() => {
}) })
const totalCount = computed(() => { const totalCount = computed(() => {
return selectedFolder.value?.properties.total ?? 0 return props.selectedCollection?.properties.total ?? 0
}) })
// True only when the collection explicitly provides total/unread counts // True only when the collection explicitly provides total/unread counts
const hasCountData = computed(() => { const hasCountData = computed(() => {
return selectedFolder.value?.properties.total != null return props.selectedCollection?.properties.total != null
}) })
</script> </script>
@@ -243,7 +243,7 @@ const hasCountData = computed(() => {
</div> </div>
</div> </div>
<div v-if="selectionModeActive && messages.length > 0" class="selection-summary"> <div v-if="selectionMode && messages.length > 0" class="selection-summary">
<div class="selection-controls"> <div class="selection-controls">
<v-checkbox-btn <v-checkbox-btn
:model-value="allCurrentMessagesSelected" :model-value="allCurrentMessagesSelected"
@@ -267,6 +267,15 @@ const hasCountData = computed(() => {
> >
Move Move
</v-btn> </v-btn>
<v-btn
size="small"
variant="text"
prepend-icon="mdi-delete-outline"
:disabled="!hasSelection"
@click="emit('deleteSelection')"
>
Delete
</v-btn>
<v-btn <v-btn
size="small" size="small"
variant="text" variant="text"
@@ -307,12 +316,12 @@ const hasCountData = computed(() => {
> >
<template v-slot:default="{ item: message }"> <template v-slot:default="{ item: message }">
<v-list-item <v-list-item
: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), 'opened': isOpened(message),
'selected': isSelected(message), 'selected': isSelected(message),
'selection-mode': selectionModeActive, 'selection-mode': selectionMode,
'unread': isUnread(message) 'unread': isUnread(message)
}" }"
@mousedown="handleMessageMouseDown($event, message)" @mousedown="handleMessageMouseDown($event, message)"
@@ -326,7 +335,7 @@ const hasCountData = computed(() => {
<template v-slot:prepend> <template v-slot:prepend>
<div class="message-item-prepend"> <div class="message-item-prepend">
<v-checkbox-btn <v-checkbox-btn
v-if="selectionModeActive || isSelected(message)" v-if="selectionMode || isSelected(message)"
:model-value="isSelected(message)" :model-value="isSelected(message)"
density="compact" density="compact"
hide-details hide-details

View File

@@ -2,14 +2,14 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useServicesStore } from '@MailManager/stores/servicesStore' import { useServicesStore } from '@MailManager/stores/servicesStore'
import { AddAccountDialog, EditAccountDialog } from '@MailManager/main' import { AddAccountDialog, EditAccountDialog } from '@MailManager/main'
import type { ServiceObject } from '@MailManager/models'
const servicesStore = useServicesStore() const servicesStore = useServicesStore()
// Dialog state // Dialog state
const showAddDialog = ref(false) const showAddDialog = ref(false)
const showEditDialog = ref(false) const showEditDialog = ref(false)
const editServiceProvider = ref<string>('') const editService = ref<ServiceObject>()
const editServiceIdentifier = ref<string | number>('')
// Load services on mount // Load services on mount
onMounted(async () => { onMounted(async () => {
@@ -22,11 +22,8 @@ const handleAddAccount = () => {
showAddDialog.value = true showAddDialog.value = true
} }
const handleConfigureAccount = (serviceKey: string) => { const handleConfigureAccount = (service: ServiceObject) => {
// Service key is in format "provider:identifier" editService.value = service
const [provider, identifier] = serviceKey.split(':')
editServiceProvider.value = provider
editServiceIdentifier.value = identifier
showEditDialog.value = true showEditDialog.value = true
} }
@@ -58,7 +55,7 @@ const handleAccountSaved = async () => {
icon="mdi-cog" icon="mdi-cog"
variant="text" variant="text"
size="small" size="small"
@click="handleConfigureAccount(`${service.provider}:${service.identifier}`)" @click="handleConfigureAccount(service)"
/> />
</template> </template>
</v-list-item> </v-list-item>
@@ -87,10 +84,10 @@ const handleAccountSaved = async () => {
<!-- Edit Account Dialog --> <!-- Edit Account Dialog -->
<EditAccountDialog <EditAccountDialog
v-if="editServiceProvider && editServiceIdentifier" v-if="editService"
v-model="showEditDialog" v-model="showEditDialog"
:service-provider="editServiceProvider" :service-provider="editService.provider"
:service-identifier="editServiceIdentifier" :service-identifier="editService.identifier"
@saved="handleAccountSaved" @saved="handleAccountSaved"
/> />
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC' import { useModuleStore } from '@KTXC'
import { useMailStore } from '@/stores/mailStore' import { useMailStore } from '@/stores/mailStore'
import type { CollectionObject, EntityObject } from '@MailManager/models' import type { CollectionObject, EntityObject } from '@MailManager/models'
import type { EntityIdentifier } from '@MailManager/types/common'
import FolderTree from '@/components/FolderTree.vue' import FolderTree from '@/components/FolderTree.vue'
import MessageList from '@/components/MessageList.vue' import MessageList from '@/components/MessageList.vue'
import MessageReader from '@/components/MessageReader.vue' import MessageReader from '@/components/MessageReader.vue'
@@ -32,19 +33,23 @@ const {
loading, loading,
selectedFolder, selectedFolder,
selectedMessage, selectedMessage,
selectedMessageIds, selectionList,
selectionModeActive, selectionMode,
composeMode, composeMode,
composeReplyTo, composeReplyTo,
currentMessages,
moveDialogVisible, moveDialogVisible,
selectionCount, moveDialogCandidates,
hasSelection,
allCurrentMessagesSelected,
} = storeToRefs(mailStore) } = storeToRefs(mailStore)
// Complex store/composable objects accessed directly (not simple refs) // Complex store/composable objects accessed directly (not simple refs)
const { mailSync, entitiesStore } = mailStore const { mailSync, entitiesStore } = mailStore
const lastSyncLabel = computed(() => {
if (!mailSync.lastSync.value) return ''
return `(Last: ${new Date(mailSync.lastSync.value).toLocaleTimeString()})`
})
// Initialize // Initialize
onMounted(async () => { onMounted(async () => {
if (!isMailManagerAvailable.value) return if (!isMailManagerAvailable.value) return
@@ -71,23 +76,24 @@ const handleSelectAllToggle = (value: boolean) => {
const handleSelectionClear = () => mailStore.deactivateSelectionMode() const handleSelectionClear = () => mailStore.deactivateSelectionMode()
const handleSelectionMove = () => mailStore.openMoveDialogForSelection() const handleSelectionMove = () => mailStore.openMoveDialog()
const handleCompose = (replyTo?: EntityObject) => mailStore.openCompose(replyTo) const handleSelectionDelete = () => mailStore.deleteMessages([...selectionList.value])
const handleCompose = (message?: EntityObject) => mailStore.openCompose(message)
const handleComposeClose = () => mailStore.closeCompose() const handleComposeClose = () => mailStore.closeCompose()
const handleComposeSent = () => mailStore.afterSent() const handleComposeSent = () => mailStore.afterSent()
const handleReply = (message: EntityObject) => mailStore.openCompose(message) const handleDelete = (message: EntityObject) => {
const id = `${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier
const handleDelete = (message: EntityObject) => mailStore.deleteMessage(message) mailStore.deleteMessages([id])
}
const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message) const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message)
const handleMoveConfirm = async (folder: CollectionObject) => { const handleMoveConfirm = async (target: CollectionObject) => { await mailStore.moveMessages(target, moveDialogCandidates.value ?? []) }
await mailStore.moveMessages(folder)
}
const handleMoveCancel = () => mailStore.closeMoveDialog() const handleMoveCancel = () => mailStore.closeMoveDialog()
@@ -150,7 +156,7 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
> >
<v-icon>mdi-refresh</v-icon> <v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="bottom"> <v-tooltip activator="parent" location="bottom">
Refresh {{ mailSync.lastSync ? `(Last: ${new Date(mailSync.lastSync).toLocaleTimeString()})` : '' }} Refresh {{ lastSyncLabel }}
</v-tooltip> </v-tooltip>
</v-btn> </v-btn>
@@ -201,13 +207,10 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
<div class="mail-list-panel"> <div class="mail-list-panel">
<MessageList <MessageList
:messages="currentMessages" :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" :selected-collection="selectedFolder"
:selected-message="selectedMessage"
:selection-list="selectionList"
:selection-mode="selectionMode"
:loading="loading" :loading="loading"
@open="handleMessageOpen" @open="handleMessageOpen"
@toggle-selection="handleMessageSelectionToggle" @toggle-selection="handleMessageSelectionToggle"
@@ -215,6 +218,7 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
@toggle-select-all="handleSelectAllToggle" @toggle-select-all="handleSelectAllToggle"
@clear-selection="handleSelectionClear" @clear-selection="handleSelectionClear"
@move-selection="handleSelectionMove" @move-selection="handleSelectionMove"
@delete-selection="handleSelectionDelete"
/> />
</div> </div>
@@ -235,7 +239,6 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
@reply="handleCompose" @reply="handleCompose"
@move="handleMove" @move="handleMove"
@delete="handleDelete" @delete="handleDelete"
@compose="handleCompose()"
/> />
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
import { useServicesStore } from '@MailManager/stores/servicesStore' 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 { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
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', () => {
@@ -41,8 +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 selectionMode = ref(false)
const selectionModeActive = ref(false) const selectionList = ref<EntityIdentifier[]>([])
// ── Compose State ───────────────────────────────────────────────────────── // ── Compose State ─────────────────────────────────────────────────────────
const composeMode = ref(false) const composeMode = ref(false)
@@ -50,7 +50,8 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Move State ──────────────────────────────────────────────────────────── // ── Move State ────────────────────────────────────────────────────────────
const moveDialogVisible = ref(false) const moveDialogVisible = ref(false)
const moveMessageCandidates = shallowRef<EntityObject[]>([]) const moveDialogService = ref<ServiceIdentifier | null>(null)
const moveDialogCandidates = ref<EntityIdentifier[] | null>(null)
// ── Computed ────────────────────────────────────────────────────────────── // ── Computed ──────────────────────────────────────────────────────────────
const currentMessages = computed(() => { const currentMessages = computed(() => {
@@ -65,31 +66,6 @@ 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() {
@@ -162,36 +138,36 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Helpers ────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────
function _serviceKey(provider: string, service: string | number) { function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier {
return `${provider}:${String(service)}` return `${item.provider}:${String(item.service)}` as ServiceIdentifier
} }
function _collectionIdentifier(collection: CollectionObject): CollectionIdentifier { function _collectionIdentifier(item: CollectionObject | EntityObject): CollectionIdentifier {
return `${collection.provider}:${String(collection.service)}:${String(collection.identifier)}` as CollectionIdentifier return `${item.provider}:${String(item.service)}:${String(item.identifier)}` as CollectionIdentifier
} }
function _entityIdentifier(entity: EntityObject): EntityIdentifier { function _entityIdentifier(item: EntityObject): EntityIdentifier {
return `${entity.provider}:${String(entity.service)}:${String(entity.collection)}:${String(entity.identifier)}` as EntityIdentifier return `${item.provider}:${String(item.service)}:${String(item.collection)}:${String(item.identifier)}` as EntityIdentifier
} }
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) { function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
serviceFolderLoadingState.value = { serviceFolderLoadingState.value = {
...serviceFolderLoadingState.value, ...serviceFolderLoadingState.value,
[_serviceKey(provider, service)]: loadingState, [_serviceIdentifier({ provider, service })]: loadingState,
} }
} }
function _setServiceFolderLoaded(provider: string, service: string | number, loaded: boolean) { function _setServiceFolderLoaded(provider: string, service: string | number, loaded: boolean) {
serviceFolderLoadedState.value = { serviceFolderLoadedState.value = {
...serviceFolderLoadedState.value, ...serviceFolderLoadedState.value,
[_serviceKey(provider, service)]: loaded, [_serviceIdentifier({ provider, service })]: loaded,
} }
} }
function _setServiceFolderError(provider: string, service: string | number, error: string | null) { function _setServiceFolderError(provider: string, service: string | number, error: string | null) {
serviceFolderErrorState.value = { serviceFolderErrorState.value = {
...serviceFolderErrorState.value, ...serviceFolderErrorState.value,
[_serviceKey(provider, service)]: error, [_serviceIdentifier({ provider, service })]: error,
} }
} }
@@ -231,22 +207,15 @@ export const useMailStore = defineStore('mailStore', () => {
} }
function isServiceFolderLoading(provider: string, service: string | number) { function isServiceFolderLoading(provider: string, service: string | number) {
return serviceFolderLoadingState.value[_serviceKey(provider, service)] === true return serviceFolderLoadingState.value[_serviceIdentifier({ provider, service })] === true
} }
function hasServiceFoldersLoaded(provider: string, service: string | number) { function hasServiceFoldersLoaded(provider: string, service: string | number) {
return serviceFolderLoadedState.value[_serviceKey(provider, service)] === true return serviceFolderLoadedState.value[_serviceIdentifier({ provider, service })] === true
} }
function getServiceFolderError(provider: string, service: string | number) { function getServiceFolderError(provider: string, service: string | number) {
return serviceFolderErrorState.value[_serviceKey(provider, service)] ?? null return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
}
function _serviceFor(provider: string, serviceIdentifier: string | number) {
return servicesStore.services.find(service =>
service.provider === provider &&
String(service.identifier) === String(serviceIdentifier),
) ?? null
} }
function _reloadFolderMessages(folder: CollectionObject) { function _reloadFolderMessages(folder: CollectionObject) {
@@ -259,17 +228,12 @@ export const useMailStore = defineStore('mailStore', () => {
}) })
} }
function _setSelectedMessageIds(nextIds: EntityIdentifier[]) { function _setSelectionList(nextIds: EntityIdentifier[]) {
selectedMessageIds.value = Array.from(new Set(nextIds)) selectionList.value = Array.from(new Set(nextIds))
}
function _removeSelection(sourceIdentifiers: EntityIdentifier[]) { if (selectionList.value.length === 0) {
if (sourceIdentifiers.length === 0 || selectedMessageIds.value.length === 0) { selectionMode.value = false
return
} }
const removedIds = new Set(sourceIdentifiers)
selectedMessageIds.value = selectedMessageIds.value.filter(identifier => !removedIds.has(identifier))
} }
function _reconcileSelection() { function _reconcileSelection() {
@@ -280,10 +244,10 @@ export const useMailStore = defineStore('mailStore', () => {
} }
const currentMessageIdentifiers = new Set(currentMessages.value.map(message => _entityIdentifier(message))) const currentMessageIdentifiers = new Set(currentMessages.value.map(message => _entityIdentifier(message)))
const nextSelectedIds = selectedMessageIds.value.filter(identifier => currentMessageIdentifiers.has(identifier)) const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
if (nextSelectedIds.length !== selectedMessageIds.value.length) { if (nextSelectedIds.length !== selectionList.value.length) {
selectedMessageIds.value = nextSelectedIds _setSelectionList(nextSelectedIds)
} }
if (selectedMessage.value && !currentMessageIdentifiers.has(_entityIdentifier(selectedMessage.value))) { if (selectedMessage.value && !currentMessageIdentifiers.has(_entityIdentifier(selectedMessage.value))) {
@@ -321,7 +285,7 @@ export const useMailStore = defineStore('mailStore', () => {
selectedFolder.value = folder selectedFolder.value = folder
selectedMessage.value = null selectedMessage.value = null
clearSelection() clearSelection()
selectionModeActive.value = false selectionMode.value = false
composeMode.value = false composeMode.value = false
try { try {
@@ -347,82 +311,7 @@ export const useMailStore = defineStore('mailStore', () => {
composeReplyTo.value = replyTo ?? null composeReplyTo.value = replyTo ?? null
selectedMessage.value = null selectedMessage.value = null
} }
function isMessageSelected(message: EntityObject) {
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
}
function openMoveDialogForSelection() {
if (selectedMessages.value.length === 0) {
return
}
openMoveDialog(selectedMessages.value)
}
function closeMoveDialog() {
moveDialogVisible.value = false
moveMessageCandidates.value = []
}
function closeCompose() { function closeCompose() {
composeMode.value = false composeMode.value = false
composeReplyTo.value = null composeReplyTo.value = null
@@ -438,20 +327,91 @@ export const useMailStore = defineStore('mailStore', () => {
} }
} }
async function moveMessages(targetFolder: CollectionObject) { function isMessageSelected(message: EntityObject) {
const candidates = moveMessageCandidates.value return selectionList.value.includes(_entityIdentifier(message))
}
function toggleMessageSelection(message: EntityObject) {
const identifier = _entityIdentifier(message)
selectionMode.value = true
if (selectionList.value.includes(identifier)) {
_setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier))
if (candidates.length === 0) {
return return
} }
const movableCandidates = candidates.filter(message => !( _setSelectionList([...selectionList.value, identifier])
targetFolder.provider === message.provider && }
String(targetFolder.service) === String(message.service) &&
String(targetFolder.identifier) === String(message.collection)
))
if (movableCandidates.length === 0) { function selectAllCurrentMessages() {
selectionMode.value = true
_setSelectionList(currentMessages.value.map(message => _entityIdentifier(message)))
}
function activateSelectionMode(message?: EntityObject) {
selectionMode.value = true
if (message) {
const identifier = _entityIdentifier(message)
if (!selectionList.value.includes(identifier)) {
_setSelectionList([...selectionList.value, identifier])
}
}
}
function deactivateSelectionMode() {
selectionMode.value = false
clearSelection()
}
function clearSelection() {
_setSelectionList([])
}
function openMoveDialog(entities?: EntityObject | EntityObject[]) {
moveDialogCandidates.value = []
if (entities) {
if (Array.isArray(entities)) {
moveDialogCandidates.value = entities.map(entity => _entityIdentifier(entity))
moveDialogService.value = _serviceIdentifier(entities[0])
} else {
moveDialogCandidates.value = [_entityIdentifier(entities)]
moveDialogService.value = _serviceIdentifier(entities)
}
} else {
moveDialogCandidates.value = selectionList.value
moveDialogService.value = _serviceIdentifier(selectedFolder.value)
}
moveDialogVisible.value = true
}
function closeMoveDialog() {
moveDialogVisible.value = false
moveDialogService.value = null
moveDialogCandidates.value = null
}
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
const movableIdentifiers = entityIdentifiers.filter(identifier => {
const entity = entitiesStore.entityByIdentifier(identifier)
if (!entity) {
return false
}
// Only allow moving messages within the same service and disallow moving into the same folder
return entity.provider === target.provider &&
String(entity.service) === String(target.service) &&
String(entity.collection) !== String(target.identifier)
})
if (movableIdentifiers.length === 0) {
closeMoveDialog() closeMoveDialog()
return return
} }
@@ -459,55 +419,31 @@ export const useMailStore = defineStore('mailStore', () => {
loading.value = true loading.value = true
try { try {
const sourceIdentifiers = movableCandidates.map(message => _entityIdentifier(message)) const response = await entitiesStore.move(_collectionIdentifier(target), movableIdentifiers)
const response = await entitiesStore.move(_collectionIdentifier(targetFolder), sourceIdentifiers) const operationSucceeded: EntityIdentifier[] = []
const successfulMoves: EntityIdentifier[] = [] const operationFailures: EntityIdentifier[] = []
const failedMoves: string[] = []
Object.entries(response).forEach(([sourceIdentifier, result]) => { Object.entries(response).forEach(([sourceIdentifier, result]) => {
if (result.success) { if (result.success) {
successfulMoves.push(sourceIdentifier as EntityIdentifier) operationSucceeded.push(sourceIdentifier as EntityIdentifier)
return return
} }
failedMoves.push(result.error) operationFailures.push(sourceIdentifier as EntityIdentifier)
}) })
if (successfulMoves.length === 0) { if (operationSucceeded.length === 0) {
throw new Error(failedMoves[0] ?? 'Failed to move messages') throw new Error(operationFailures[0] ?? 'Failed to move messages')
} }
_removeSelection(successfulMoves) if (selectedMessage.value && operationSucceeded.includes(_entityIdentifier(selectedMessage.value))) {
if (selectedMessage.value && successfulMoves.includes(_entityIdentifier(selectedMessage.value))) {
selectedMessage.value = null selectedMessage.value = null
} }
if (selectedMessageIds.value.length === 0) { clearSelection()
selectionModeActive.value = false
}
closeMoveDialog() closeMoveDialog()
const servicesToRefresh = new Map<string, ServiceObject>() const notification = _formatMoveNotification(operationSucceeded.length, operationFailures.length, target)
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) notify(notification.message, notification.color)
} catch (error) { } catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move messages' const messageText = error instanceof Error ? error.message : 'Failed to move messages'
@@ -519,9 +455,61 @@ export const useMailStore = defineStore('mailStore', () => {
} }
} }
async function deleteMessage(message: EntityObject) { async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
// TODO: implement delete via entity / collection store if (entityIdentifiers.length === 0) {
console.log('[Mail] Delete message:', message.identifier) return
}
loading.value = true
try {
const response = await entitiesStore.delete(entityIdentifiers)
const operationSucceeded: EntityIdentifier[] = []
const operationFailures: EntityIdentifier[] = []
Object.entries(response).forEach(([sourceIdentifier, result]) => {
if (result.success) {
operationSucceeded.push(sourceIdentifier as EntityIdentifier)
return
}
operationFailures.push(sourceIdentifier as EntityIdentifier)
})
if (operationSucceeded.length === 0) {
throw new Error(operationFailures[0] ?? 'Failed to delete messages')
}
if (selectedMessage.value && operationSucceeded.includes(_entityIdentifier(selectedMessage.value))) {
selectedMessage.value = null
}
clearSelection()
const successCount = operationSucceeded.length
const failureCount = operationFailures.length
if (failureCount === 0) {
notify(
successCount === 1 ? 'Message deleted' : `${successCount} messages deleted`,
'success',
)
} else {
notify(
successCount === 0
? `Delete failed for ${failureCount === 1 ? '1 message' : `${failureCount} messages`}`
: `Deleted ${successCount} ${successCount === 1 ? 'message' : 'messages'}. ${failureCount} failed.`,
successCount === 0 ? 'error' : 'warning',
)
}
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to delete messages'
console.error('[Mail] Failed to delete messages:', error)
notify(messageText, 'error')
throw error
} finally {
loading.value = false
}
} }
function toggleSidebar() { function toggleSidebar() {
@@ -551,23 +539,19 @@ export const useMailStore = defineStore('mailStore', () => {
loading, loading,
selectedFolder, selectedFolder,
selectedMessage, selectedMessage,
selectedMessageIds, selectionList,
selectionModeActive, selectionMode,
composeMode, composeMode,
composeReplyTo, composeReplyTo,
moveDialogVisible, moveDialogVisible,
moveMessageCandidates, moveDialogService,
moveDialogCandidates,
serviceFolderLoadingState, serviceFolderLoadingState,
serviceFolderLoadedState, serviceFolderLoadedState,
serviceFolderErrorState, serviceFolderErrorState,
// Computed // Computed
currentMessages, currentMessages,
selectedMessageMap,
selectedMessages,
selectionCount,
hasSelection,
allCurrentMessagesSelected,
// Actions // Actions
selectFolder, selectFolder,
@@ -580,11 +564,10 @@ export const useMailStore = defineStore('mailStore', () => {
clearSelection, clearSelection,
openCompose, openCompose,
openMoveDialog, openMoveDialog,
openMoveDialogForSelection,
closeMoveDialog, closeMoveDialog,
closeCompose, closeCompose,
afterSent, afterSent,
deleteMessage, deleteMessages,
moveMessages, moveMessages,
toggleSidebar, toggleSidebar,
openSettings, openSettings,