Files
mail/src/stores/mailUiStore.ts
2026-05-22 11:56:29 -04:00

641 lines
19 KiB
TypeScript

import { computed, ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { ComposerMode } from '@/types/composer'
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
import { EntityObject, type ServiceObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection'
import type { MessageAddressInterface } from '@MailManager/types/message'
export const useMailUiStore = defineStore('mailUiStore', () => {
const collectionsStore = useCollectionsStore()
const mailStore = useMailStore()
const mailSettingsStore = useMailSettingsStore()
const sidebarVisible = ref(true)
const settingsDialogVisible = ref(false)
const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null)
const composerMode = ref<ComposerMode>(ComposerMode.Fresh)
const composerSource = shallowRef<EntityObject | MessageAddressInterface | null>(null)
const composerVisible = ref(false)
const selectionMode = ref(false)
const selectionList = ref<EntityIdentifier[]>([])
const moveMessagesDialogVisible = ref(false)
const moveMessagesDialogService = shallowRef<ServiceObject | null>(null)
const moveMessagesDialogCandidates = ref<EntityIdentifier[] | null>(null)
const createFolderDialogVisible = ref(false)
const createFolderDialogService = shallowRef<ServiceObject | null>(null)
const createFolderDialogParent = shallowRef<CollectionObject | null>(null)
const createFolderDialogLoading = ref(false)
const createFolderDialogError = ref('')
const renameFolderDialogVisible = ref(false)
const renameFolderDialogService = shallowRef<ServiceObject | null>(null)
const renameFolderDialogFolder = shallowRef<CollectionObject | null>(null)
const renameFolderDialogLoading = ref(false)
const renameFolderDialogError = ref('')
const moveFolderDialogVisible = ref(false)
const moveFolderDialogService = shallowRef<ServiceObject | null>(null)
const moveFolderDialogSource = shallowRef<CollectionObject | null>(null)
const deleteFolderDialogVisible = ref(false)
const deleteFolderDialogService = shallowRef<ServiceObject | null>(null)
const deleteFolderDialogFolder = shallowRef<CollectionObject | null>(null)
const deleteFolderDialogLoading = ref(false)
const deleteFolderDialogError = ref('')
const messageReadIdentifier = ref<EntityIdentifier | null>(null)
const messageReadTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const createFolderDialogParentLabel = computed(() => {
return createFolderDialogParent.value?.properties.label || 'Root'
})
const renameFolderDialogParentLabel = computed(() => {
const folder = renameFolderDialogFolder.value
if (!folder || folder.collection === null || folder.collection === undefined) {
return 'Root'
}
const parent = collectionsStore.collectionsInCollection(folder.provider, folder.service, null)
.flatMap(rootFolder => [rootFolder, ...collectionsStore.collectionsForService(folder.provider, folder.service)])
.find(candidate => String(candidate.identifier) === String(folder.collection))
return parent?.properties.label || 'Root'
})
const moveFolderDialogInvalidFolderKeys = computed(() => {
const sourceFolder = moveFolderDialogSource.value
if (!sourceFolder) {
return []
}
const invalidKeys = new Set<string>()
const queue = [sourceFolder]
while (queue.length > 0) {
const currentFolder = queue.shift()
if (!currentFolder) {
continue
}
invalidKeys.add(String(currentFolder.identifier))
collectionsStore
.collectionsInCollection(currentFolder.provider, currentFolder.service, currentFolder.identifier)
.forEach(childFolder => {
queue.push(childFolder)
})
}
return Array.from(invalidKeys)
})
watch(
() => mailStore.selectedMessage,
message => {
if (message) {
closeComposer()
}
selectMessage(message)
},
)
watch(
() => mailStore.currentMessages,
() => {
messageSelectionReconcile()
},
)
function sidebarToggle() {
sidebarVisible.value = !sidebarVisible.value
}
function sidebarHide() {
sidebarVisible.value = false
}
function settingsOpen() {
settingsDialogVisible.value = true
}
function settingsClose() {
settingsDialogVisible.value = false
}
function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
if (!left || !right) {
return false
}
return left.provider === right.provider &&
String(left.service) === String(right.service) &&
String(left.identifier) === String(right.identifier)
}
async function initialize() {
await mailStore.initialize()
if (!selectedFolder.value) {
const inbox = mailStore.findFoldersByRole('inbox')[0] ?? null
if (inbox) {
await selectFolder(inbox)
}
}
}
async function selectFolder(folder: CollectionObject | null) {
closeComposer()
messageSelectionModeDeactivate()
clearMessageReadTimer()
selectedMessage.value = null
selectedFolder.value = folder
await mailStore.selectFolder(folder)
}
async function selectMessage(message: EntityObject | null) {
messageSelectionModeDeactivate()
createMessageReadTimer(message)
selectedMessage.value = message
}
function createMessageReadTimer(entity: EntityObject | null) {
clearMessageReadTimer()
if (!entity) {
return
}
if (entity.properties.isRead || !mailSettingsStore.messageReadEnabled) {
return
}
const delayMilliseconds = mailSettingsStore.messageReadDelay * 1000
if (delayMilliseconds <= 0) {
return
}
messageReadIdentifier.value = entity.identifier
messageReadTimer.value = setTimeout(() => {
void completeMessageRead(entity.identifier)
}, delayMilliseconds)
}
function clearMessageReadTimer() {
if (messageReadTimer.value !== null) {
clearTimeout(messageReadTimer.value)
}
messageReadTimer.value = null
messageReadIdentifier.value = null
}
async function completeMessageRead(identifier: EntityIdentifier) {
try {
if (selectedMessage.value && selectedMessage.value.identifier === identifier && selectedMessage.value.properties.isRead === false) {
await mailStore.flagMessages([selectedMessage.value.identifier], { read: true }, { notify: false })
}
} catch (error) {
console.error('[Mail][UI] Failed to auto-mark message as read:', error)
} finally {
clearMessageReadTimer()
}
}
function openCreateFolderDialog(service: ServiceObject, parentFolder: CollectionObject | null = null) {
createFolderDialogService.value = service
createFolderDialogParent.value = parentFolder
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
createFolderDialogVisible.value = true
}
function closeCreateFolderDialog() {
createFolderDialogVisible.value = false
createFolderDialogService.value = null
createFolderDialogParent.value = null
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
}
async function confirmCreateFolder(label: string) {
const service = createFolderDialogService.value
if (!service) {
return null
}
createFolderDialogLoading.value = true
createFolderDialogError.value = ''
try {
const folder = await mailStore.createFolder(service, label, createFolderDialogParent.value)
closeCreateFolderDialog()
return folder
} catch (error) {
createFolderDialogError.value = error instanceof Error ? error.message : 'Failed to create folder. Please try again.'
throw error
} finally {
createFolderDialogLoading.value = false
}
}
async function openRenameFolderDialog(target: CollectionObject) {
const service = await mailStore.retrieveService(target.service)
renameFolderDialogService.value = service
renameFolderDialogFolder.value = target
renameFolderDialogError.value = ''
renameFolderDialogLoading.value = false
renameFolderDialogVisible.value = true
}
function closeRenameFolderDialog() {
renameFolderDialogVisible.value = false
renameFolderDialogService.value = null
renameFolderDialogFolder.value = null
renameFolderDialogError.value = ''
renameFolderDialogLoading.value = false
}
async function confirmRenameFolder(label: string) {
const folder = renameFolderDialogFolder.value
if (!folder) {
return null
}
renameFolderDialogLoading.value = true
renameFolderDialogError.value = ''
try {
const updatedFolder = await mailStore.renameFolder(folder, label)
if (_sameCollection(selectedFolder.value, folder)) {
selectedFolder.value = updatedFolder
}
closeRenameFolderDialog()
return updatedFolder
} catch (error) {
renameFolderDialogError.value = error instanceof Error ? error.message : 'Failed to rename folder. Please try again.'
throw error
} finally {
renameFolderDialogLoading.value = false
}
}
async function openMoveFolderDialog(source: CollectionObject) {
const service = await mailStore.retrieveService(source.service)
moveFolderDialogService.value = service
moveFolderDialogSource.value = source
moveFolderDialogVisible.value = true
}
function closeMoveFolderDialog() {
moveFolderDialogVisible.value = false
moveFolderDialogService.value = null
moveFolderDialogSource.value = null
}
async function confirmMoveFolder(target: CollectionObject) {
const source = moveFolderDialogSource.value
if (!source) {
return null
}
const movedFolder = await mailStore.moveFolder(source, target)
if (_sameCollection(selectedFolder.value, source)) {
selectedFolder.value = movedFolder
}
closeMoveFolderDialog()
return movedFolder
}
async function openDeleteFolderDialog(target: CollectionObject) {
const service = await mailStore.retrieveService(target.service)
deleteFolderDialogService.value = service
deleteFolderDialogFolder.value = target
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
deleteFolderDialogVisible.value = true
}
function closeDeleteFolderDialog() {
deleteFolderDialogVisible.value = false
deleteFolderDialogService.value = null
deleteFolderDialogFolder.value = null
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
}
async function confirmDeleteFolder() {
const folder = deleteFolderDialogFolder.value
if (!folder) {
return null
}
deleteFolderDialogLoading.value = true
deleteFolderDialogError.value = ''
try {
const deleted = await mailStore.deleteFolder(folder)
if (_sameCollection(selectedFolder.value, folder)) {
selectFolder(null)
}
closeDeleteFolderDialog()
return deleted
} catch (error) {
deleteFolderDialogError.value = error instanceof Error ? error.message : 'Failed to delete folder. Please try again.'
throw error
} finally {
deleteFolderDialogLoading.value = false
}
}
function validateFolderNameBase(service: ServiceObject, name: string): string[] {
const errors: string[] = []
if (!name || name.trim().length === 0) {
errors.push('Folder name is required')
return errors
}
if (name.length > 255) {
errors.push('Folder name too long (max 255 characters)')
}
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
errors.push('Folder name contains invalid characters')
}
if (service.provider === 'imap' && /[\/\\]/.test(name)) {
errors.push('IMAP folder names cannot contain / or \\')
}
if (name !== name.trim()) {
errors.push('Folder name cannot have leading or trailing spaces')
}
return errors
}
function validateCreateFolderName(name: string): string[] {
const service = createFolderDialogService.value
if (!service || service.identifier === null) {
return ['Folder service is unavailable']
}
const errors = validateFolderNameBase(service, name)
if (errors.length > 0) {
return errors
}
const parentIdentifier = createFolderDialogParent.value?.identifier ?? null
const duplicate = collectionsStore
.collectionsInCollection(service.provider, service.identifier, parentIdentifier)
.some(folder => folder.properties.label === name)
if (duplicate) {
errors.push('A folder with this name already exists in this location')
}
return errors
}
function validateRenameFolderName(name: string): string[] {
const service = renameFolderDialogService.value
const folder = renameFolderDialogFolder.value
if (!service || !folder || service.identifier === null) {
return ['Folder service is unavailable']
}
const errors = validateFolderNameBase(service, name)
if (errors.length > 0) {
return errors
}
const parentIdentifier = folder.collection ?? null
const duplicate = collectionsStore
.collectionsInCollection(service.provider, service.identifier, parentIdentifier)
.some(candidate =>
String(candidate.identifier) !== String(folder.identifier) &&
candidate.properties.label === name,
)
if (duplicate) {
errors.push('A folder with this name already exists in this location')
}
return errors
}
function setSelectionList(nextIds: EntityIdentifier[]) {
selectionList.value = Array.from(new Set(nextIds))
}
function openComposer(source?: EntityObject | MessageAddressInterface, mode: ComposerMode = ComposerMode.Fresh) {
mailStore.selectMessage(null)
composerSource.value = source ?? null
composerMode.value = mode
composerVisible.value = true
}
function closeComposer() {
composerMode.value = ComposerMode.Fresh
composerSource.value = null
composerVisible.value = false
}
function messageSelectionClear() {
setSelectionList([])
}
function messageSelectionModeActivate(message?: EntityObject) {
selectionMode.value = true
if (!message) {
return
}
const identifier = message.identifier
if (!selectionList.value.includes(identifier)) {
setSelectionList([...selectionList.value, identifier])
}
}
function messageSelectionModeDeactivate() {
selectionMode.value = false
messageSelectionClear()
}
function messageSelectionToggleOne(message: EntityObject) {
const identifier = message.identifier
selectionMode.value = true
if (selectionList.value.includes(identifier)) {
setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier))
return
}
setSelectionList([...selectionList.value, identifier])
}
function messageSelectionToggleAll(value: boolean) {
selectionMode.value = true
if (value) {
setSelectionList(mailStore.currentMessages.map(message => message.identifier))
} else {
setSelectionList([])
}
}
function messageSelectionReconcile() {
if (!selectedFolder.value) {
messageSelectionClear()
return
}
const currentMessageIdentifiers = new Set(mailStore.currentMessages.map(message => message.identifier))
const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
if (nextSelectedIds.length !== selectionList.value.length) {
setSelectionList(nextSelectedIds)
}
if (nextSelectedIds.length === 0) {
selectionMode.value = false
}
}
async function openMoveMessagesDialog(entities?: EntityObject | EntityObject[]) {
let moveMessagesServiceIdentifier = null as ServiceIdentifier | null
moveMessagesDialogCandidates.value = []
if (entities) {
if (Array.isArray(entities)) {
moveMessagesDialogCandidates.value = entities.map(entity => entity.identifier)
moveMessagesServiceIdentifier = entities[0]?.service as ServiceIdentifier || null
} else {
moveMessagesDialogCandidates.value = [entities.identifier]
moveMessagesServiceIdentifier = entities.service as ServiceIdentifier || null
}
} else {
moveMessagesDialogCandidates.value = [...selectionList.value]
moveMessagesServiceIdentifier = selectedFolder.value?.service as ServiceIdentifier || null
}
moveMessagesDialogService.value = await mailStore.retrieveService(moveMessagesServiceIdentifier)
moveMessagesDialogVisible.value = true
}
function closeMoveMessagesDialog() {
moveMessagesDialogVisible.value = false
moveMessagesDialogService.value = null
moveMessagesDialogCandidates.value = null
}
async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailStore.moveMessages>[0]) {
await mailStore.moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? [])
messageSelectionModeDeactivate()
closeMoveMessagesDialog()
}
async function deleteSelectedMessages() {
await mailStore.deleteMessages([...selectionList.value])
messageSelectionModeDeactivate()
}
async function flagSelectedMessages(flag: string, value: boolean) {
await mailStore.flagMessages([...selectionList.value], { [flag]: value })
messageSelectionModeDeactivate()
}
return {
sidebarVisible,
settingsDialogVisible,
selectedFolder,
selectedMessage,
composerMode,
composerSource,
composerVisible,
selectionMode,
selectionList,
moveMessagesDialogVisible,
moveMessagesDialogService,
moveMessagesDialogCandidates,
createFolderDialogParentLabel,
createFolderDialogVisible,
createFolderDialogService,
createFolderDialogParent,
createFolderDialogLoading,
createFolderDialogError,
renameFolderDialogVisible,
renameFolderDialogService,
renameFolderDialogFolder,
renameFolderDialogParentLabel,
renameFolderDialogLoading,
renameFolderDialogError,
moveFolderDialogVisible,
moveFolderDialogService,
moveFolderDialogSource,
moveFolderDialogInvalidFolderKeys,
deleteFolderDialogVisible,
deleteFolderDialogService,
deleteFolderDialogFolder,
deleteFolderDialogLoading,
deleteFolderDialogError,
sidebarToggle,
sidebarHide,
settingsOpen,
settingsClose,
initialize,
selectFolder,
openComposer,
closeComposer,
messageSelectionModeActivate,
messageSelectionModeDeactivate,
messageSelectionToggleOne,
messageSelectionToggleAll,
messageSelectionClear,
validateCreateFolderName,
validateRenameFolderName,
openMoveMessagesDialog,
closeMoveMessagesDialog,
confirmMoveMessages,
deleteSelectedMessages,
openCreateFolderDialog,
closeCreateFolderDialog,
confirmCreateFolder,
openRenameFolderDialog,
closeRenameFolderDialog,
confirmRenameFolder,
openMoveFolderDialog,
closeMoveFolderDialog,
confirmMoveFolder,
openDeleteFolderDialog,
closeDeleteFolderDialog,
confirmDeleteFolder,
flagSelectedMessages,
}
})