641 lines
19 KiB
TypeScript
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,
|
|
}
|
|
}) |