refactor: split stores and use events
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ref, computed, shallowRef, watch } from 'vue'
|
||||
import { ref, computed, shallowRef } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
|
||||
@@ -6,7 +6,21 @@ import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||
import { useMailSync } from '@MailManager/composables/useMailSync'
|
||||
import { useSnackbar } from '@KTXC'
|
||||
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
||||
import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models'
|
||||
import type { EntityTransmitRequest } from '@MailManager/types/entity'
|
||||
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
|
||||
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
|
||||
import { CollectionPropertiesObject } from '@MailManager/models/collection'
|
||||
|
||||
interface ComposerMessageInput {
|
||||
to: string[]
|
||||
cc: string[]
|
||||
bcc: string[]
|
||||
subject: string
|
||||
body: {
|
||||
html: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
export const useMailStore = defineStore('mailStore', () => {
|
||||
const servicesStore = useServicesStore()
|
||||
@@ -31,27 +45,17 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
}
|
||||
|
||||
// ── General State ─────────────────-───────────────────────────────────────
|
||||
const sidebarVisible = ref(true)
|
||||
const settingsDialogVisible = ref(false)
|
||||
const loading = ref(false)
|
||||
const serviceFolderLoadingState = ref<Record<string, boolean>>({})
|
||||
const serviceFolderLoadedState = ref<Record<string, boolean>>({})
|
||||
const serviceFolderErrorState = ref<Record<string, string | null>>({})
|
||||
|
||||
// ── Selection State ───────────────────────────────────────────────────────
|
||||
const selectedFolder = shallowRef<CollectionObject | null>(null)
|
||||
const selectedMessage = shallowRef<EntityObject | null>(null)
|
||||
const selectionMode = ref(false)
|
||||
const selectionList = ref<EntityIdentifier[]>([])
|
||||
|
||||
// ── Compose State ─────────────────────────────────────────────────────────
|
||||
const composeMode = ref(false)
|
||||
const composeReplyTo = shallowRef<EntityObject | null>(null)
|
||||
|
||||
// ── Move State ────────────────────────────────────────────────────────────
|
||||
const moveDialogVisible = ref(false)
|
||||
const moveDialogService = ref<ServiceIdentifier | null>(null)
|
||||
const moveDialogCandidates = ref<EntityIdentifier[] | null>(null)
|
||||
const composerSaving = ref(false)
|
||||
const composerSending = ref(false)
|
||||
const composerLastSaved = ref<Date | null>(null)
|
||||
const composerDraftIdentifier = ref<EntityIdentifier | null>(null)
|
||||
|
||||
// ── Computed ──────────────────────────────────────────────────────────────
|
||||
const currentMessages = computed(() => {
|
||||
@@ -98,11 +102,7 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
|
||||
try {
|
||||
// retrieve folders for service
|
||||
const collections = await collectionsStore.list({
|
||||
[service.provider]: {
|
||||
[String(service.identifier)]: true,
|
||||
},
|
||||
})
|
||||
const collections = await collectionsStore.collectionsForService(service.provider, service.identifier, true)
|
||||
|
||||
_setServiceFolderLoaded(service.provider, service.identifier, true)
|
||||
|
||||
@@ -121,7 +121,6 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
}
|
||||
|
||||
_updateSyncSources()
|
||||
return collections
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to load folders'
|
||||
_setServiceFolderError(service.provider, service.identifier, message)
|
||||
@@ -139,15 +138,20 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier {
|
||||
if (item instanceof ServiceObject) {
|
||||
return `${item.provider}:${String(item.identifier)}` as ServiceIdentifier
|
||||
}
|
||||
return `${item.provider}:${String(item.service)}` as ServiceIdentifier
|
||||
}
|
||||
|
||||
function _collectionIdentifier(item: CollectionObject | EntityObject): CollectionIdentifier {
|
||||
return `${item.provider}:${String(item.service)}:${String(item.identifier)}` as CollectionIdentifier
|
||||
}
|
||||
function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
|
||||
function _entityIdentifier(item: EntityObject): EntityIdentifier {
|
||||
return `${item.provider}:${String(item.service)}:${String(item.collection)}:${String(item.identifier)}` as EntityIdentifier
|
||||
return left.provider === right.provider &&
|
||||
String(left.service) === String(right.service) &&
|
||||
String(left.identifier) === String(right.identifier)
|
||||
}
|
||||
|
||||
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
|
||||
@@ -176,15 +180,18 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
|
||||
// Track the currently selected folder
|
||||
if (selectedFolder.value) {
|
||||
mailSyncController.addSource({
|
||||
provider: selectedFolder.value.provider,
|
||||
service: selectedFolder.value.service,
|
||||
collections: [selectedFolder.value.identifier],
|
||||
})
|
||||
//mailSyncController.addSource({
|
||||
// provider: selectedFolder.value.provider,
|
||||
// service: selectedFolder.value.service,
|
||||
// collections: [selectedFolder.value.identifier],
|
||||
//})
|
||||
}
|
||||
|
||||
// Always track inboxes for each account (for new-mail notifications)
|
||||
servicesStore.servicesEnabled.forEach(service => {
|
||||
if (service.identifier === null) {
|
||||
return
|
||||
}
|
||||
const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter(
|
||||
c =>
|
||||
String(c.service) === String(service.identifier) &&
|
||||
@@ -193,11 +200,11 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
)
|
||||
|
||||
if (inboxes.length > 0) {
|
||||
mailSyncController.addSource({
|
||||
provider: service.provider,
|
||||
service: service.identifier as string | number,
|
||||
collections: inboxes.map(inbox => inbox.identifier),
|
||||
})
|
||||
//mailSyncController.addSource({
|
||||
// provider: service.provider,
|
||||
// service: service.identifier as string | number,
|
||||
// collections: inboxes.map(inbox => inbox.identifier),
|
||||
//})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -218,58 +225,113 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
|
||||
}
|
||||
|
||||
function _reloadFolderMessages(folder: CollectionObject) {
|
||||
return entitiesStore.list({
|
||||
[folder.provider]: {
|
||||
[String(folder.service)]: {
|
||||
[String(folder.identifier)]: true,
|
||||
},
|
||||
function _findDraftFolder(folder: CollectionObject): CollectionObject {
|
||||
return collectionsStore.collectionsForService(folder.provider, folder.service).find(
|
||||
candidate =>
|
||||
candidate.provider === folder.provider &&
|
||||
String(candidate.service) === String(folder.service) &&
|
||||
(candidate.properties.role === 'drafts' ||
|
||||
String(candidate.identifier).toLowerCase() === 'drafts' ||
|
||||
candidate.properties.label.toLowerCase() === 'drafts'),
|
||||
) ?? folder
|
||||
}
|
||||
|
||||
function _toMessageAddresses(addresses: string[]): MessageAddressInterface[] | undefined {
|
||||
const normalized = addresses
|
||||
.map(address => address.trim())
|
||||
.filter(address => address.length > 0)
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return normalized.map(address => ({ address }))
|
||||
}
|
||||
|
||||
function _toDraftBody(body: ComposerMessageInput['body']): MessagePartInterface | null {
|
||||
const parts: MessagePartInterface[] = []
|
||||
const text = body.text.trim()
|
||||
const html = body.html.trim()
|
||||
|
||||
if (text.length > 0) {
|
||||
parts.push({
|
||||
type: 'text/plain',
|
||||
content: text,
|
||||
})
|
||||
}
|
||||
|
||||
if (html.length > 0) {
|
||||
parts.push({
|
||||
type: 'text/html',
|
||||
content: html,
|
||||
})
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'multipart/alternative',
|
||||
subParts: parts,
|
||||
}
|
||||
}
|
||||
|
||||
function _toDraftProperties(message: ComposerMessageInput): MessageInterface {
|
||||
return {
|
||||
'@type': 'mail:message',
|
||||
to: _toMessageAddresses(message.to),
|
||||
cc: _toMessageAddresses(message.cc),
|
||||
bcc: _toMessageAddresses(message.bcc),
|
||||
subject: message.subject.trim() || null,
|
||||
body: _toDraftBody(message.body),
|
||||
flags: {
|
||||
draft: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function _setSelectionList(nextIds: EntityIdentifier[]) {
|
||||
selectionList.value = Array.from(new Set(nextIds))
|
||||
|
||||
if (selectionList.value.length === 0) {
|
||||
selectionMode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function _reconcileSelection() {
|
||||
if (!selectedFolder.value) {
|
||||
clearSelection()
|
||||
selectedMessage.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const currentMessageIdentifiers = new Set(currentMessages.value.map(message => _entityIdentifier(message)))
|
||||
const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
|
||||
|
||||
if (nextSelectedIds.length !== selectionList.value.length) {
|
||||
_setSelectionList(nextSelectedIds)
|
||||
}
|
||||
|
||||
if (selectedMessage.value && !currentMessageIdentifiers.has(_entityIdentifier(selectedMessage.value))) {
|
||||
selectedMessage.value = null
|
||||
}
|
||||
function resetComposerState() {
|
||||
composerSaving.value = false
|
||||
composerSending.value = false
|
||||
composerLastSaved.value = null
|
||||
composerDraftIdentifier.value = null
|
||||
}
|
||||
|
||||
watch(currentMessages, () => {
|
||||
_reconcileSelection()
|
||||
})
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function retrieveService(identifier: ServiceIdentifier, force: boolean = false): Promise<ServiceObject | null> {
|
||||
let service = servicesStore.serviceByIdentifier(identifier)
|
||||
if (service && !force) {
|
||||
return service
|
||||
}
|
||||
|
||||
try {
|
||||
service = await servicesStore.serviceByIdentifier(identifier, true)
|
||||
} catch (error) {
|
||||
console.error(`[Mail] Failed to retrieve service ${identifier}:`, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!service) {
|
||||
const message = `Service ${identifier} not found`
|
||||
console.error(`[Mail] ${message}`)
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
async function selectFolder(folder: CollectionObject) {
|
||||
selectedFolder.value = folder
|
||||
selectedMessage.value = null
|
||||
clearSelection()
|
||||
selectionMode.value = false
|
||||
composeMode.value = false
|
||||
|
||||
try {
|
||||
await _reloadFolderMessages(folder)
|
||||
await entitiesStore.list([folder.identifier])
|
||||
} catch (error) {
|
||||
console.error('[Mail] Failed to load messages:', error)
|
||||
}
|
||||
@@ -280,140 +342,216 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
function clearSelectedFolder() {
|
||||
selectedFolder.value = null
|
||||
selectedMessage.value = null
|
||||
clearSelection()
|
||||
selectionMode.value = false
|
||||
composeMode.value = false
|
||||
composeReplyTo.value = null
|
||||
|
||||
_updateSyncSources()
|
||||
}
|
||||
|
||||
function selectMessage(entity: EntityObject, closeSidebar = false) {
|
||||
function selectMessage(entity: EntityObject) {
|
||||
selectedMessage.value = entity
|
||||
composeMode.value = false
|
||||
|
||||
if (closeSidebar) {
|
||||
sidebarVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCompose(replyTo?: EntityObject) {
|
||||
composeMode.value = true
|
||||
composeReplyTo.value = replyTo ?? null
|
||||
function clearSelectedMessage() {
|
||||
selectedMessage.value = null
|
||||
}
|
||||
|
||||
function closeCompose() {
|
||||
composeMode.value = false
|
||||
composeReplyTo.value = null
|
||||
}
|
||||
|
||||
async function afterSent() {
|
||||
composeMode.value = false
|
||||
composeReplyTo.value = null
|
||||
|
||||
async function reloadSelectedFolder() {
|
||||
// Reload the current folder so the sent message appears in Sent
|
||||
if (selectedFolder.value) {
|
||||
await selectFolder(selectedFolder.value)
|
||||
}
|
||||
}
|
||||
|
||||
function isMessageSelected(message: EntityObject) {
|
||||
return selectionList.value.includes(_entityIdentifier(message))
|
||||
async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) {
|
||||
composerSaving.value = true
|
||||
|
||||
try {
|
||||
const targetFolder = _findDraftFolder(folder)
|
||||
const properties = _toDraftProperties(message)
|
||||
const draft = composerDraftIdentifier.value
|
||||
? await entitiesStore.update(composerDraftIdentifier.value, properties)
|
||||
: await entitiesStore.create(targetFolder.identifier, properties)
|
||||
|
||||
composerDraftIdentifier.value = draft.identifier
|
||||
composerLastSaved.value = new Date()
|
||||
|
||||
return draft
|
||||
} catch (error) {
|
||||
console.error('[Mail] Failed to save draft:', error)
|
||||
throw error
|
||||
} finally {
|
||||
composerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMessageSelection(message: EntityObject) {
|
||||
const identifier = _entityIdentifier(message)
|
||||
async function sendComposerMessage(message: ComposerMessageInput) {
|
||||
composerSending.value = true
|
||||
|
||||
selectionMode.value = true
|
||||
|
||||
if (selectionList.value.includes(identifier)) {
|
||||
_setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier))
|
||||
|
||||
return
|
||||
const transmitRequest: EntityTransmitRequest = {
|
||||
message: {
|
||||
to: message.to.map(address => address.trim()).filter(address => address.length > 0),
|
||||
cc: message.cc.map(address => address.trim()).filter(address => address.length > 0),
|
||||
bcc: message.bcc.map(address => address.trim()).filter(address => address.length > 0),
|
||||
subject: message.subject,
|
||||
body: {
|
||||
html: message.body.html,
|
||||
text: message.body.text,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_setSelectionList([...selectionList.value, identifier])
|
||||
}
|
||||
if (transmitRequest.message.cc?.length === 0) {
|
||||
delete transmitRequest.message.cc
|
||||
}
|
||||
|
||||
function selectAllCurrentMessages() {
|
||||
selectionMode.value = true
|
||||
_setSelectionList(currentMessages.value.map(message => _entityIdentifier(message)))
|
||||
}
|
||||
if (transmitRequest.message.bcc?.length === 0) {
|
||||
delete transmitRequest.message.bcc
|
||||
}
|
||||
|
||||
function activateSelectionMode(message?: EntityObject) {
|
||||
selectionMode.value = true
|
||||
try {
|
||||
const response = await entitiesStore.transmit(transmitRequest)
|
||||
|
||||
if (message) {
|
||||
const identifier = _entityIdentifier(message)
|
||||
|
||||
if (!selectionList.value.includes(identifier)) {
|
||||
_setSelectionList([...selectionList.value, identifier])
|
||||
if (composerDraftIdentifier.value) {
|
||||
try {
|
||||
await entitiesStore.delete([composerDraftIdentifier.value])
|
||||
} catch (error) {
|
||||
console.error('[Mail] Failed to delete draft after send:', error)
|
||||
}
|
||||
}
|
||||
|
||||
notify('Message sent', 'success')
|
||||
resetComposerState()
|
||||
return response
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : 'Failed to send message'
|
||||
console.error('[Mail] Failed to send message:', error)
|
||||
notify(messageText, 'error')
|
||||
throw error
|
||||
} finally {
|
||||
composerSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
async function createFolder(
|
||||
service: ServiceObject,
|
||||
label: string,
|
||||
parentFolder: CollectionObject | null = null,
|
||||
): Promise<CollectionObject> {
|
||||
if (service.identifier === null) {
|
||||
throw new Error('Cannot create folder for a service without an identifier')
|
||||
}
|
||||
|
||||
moveDialogVisible.value = true
|
||||
const properties = new CollectionPropertiesObject()
|
||||
properties.label = label.trim()
|
||||
properties.rank = 0
|
||||
properties.subscribed = true
|
||||
|
||||
const newFolder = await collectionsStore.create(
|
||||
service.provider,
|
||||
service.identifier,
|
||||
properties,
|
||||
parentFolder?.identifier,
|
||||
)
|
||||
|
||||
notify(
|
||||
`Folder "${newFolder.properties.label || properties.label}" created`,
|
||||
'success',
|
||||
)
|
||||
|
||||
return newFolder
|
||||
}
|
||||
|
||||
function closeMoveDialog() {
|
||||
moveDialogVisible.value = false
|
||||
moveDialogService.value = null
|
||||
moveDialogCandidates.value = null
|
||||
async function renameFolder(folder: CollectionObject, label: string): Promise<CollectionObject> {
|
||||
const properties = new CollectionPropertiesObject()
|
||||
properties.label = label.trim()
|
||||
properties.rank = folder.properties.rank ?? 0
|
||||
properties.subscribed = folder.properties.subscribed ?? true
|
||||
|
||||
const updatedFolder = await collectionsStore.update(folder.identifier, properties)
|
||||
|
||||
if (_sameCollection(selectedFolder.value, folder)) {
|
||||
selectedFolder.value = updatedFolder
|
||||
}
|
||||
|
||||
notify(
|
||||
`Folder "${folder.properties.label || String(folder.identifier)}" renamed to "${updatedFolder.properties.label || properties.label}"`,
|
||||
'success',
|
||||
)
|
||||
|
||||
return updatedFolder
|
||||
}
|
||||
|
||||
async function moveFolder(source: CollectionObject, target: CollectionObject): Promise<CollectionObject> {
|
||||
const movedFolder = await collectionsStore.move(target.identifier, source.identifier)
|
||||
|
||||
if (_sameCollection(selectedFolder.value, source)) {
|
||||
selectedFolder.value = movedFolder
|
||||
}
|
||||
|
||||
notify(
|
||||
`Folder "${source.properties.label || String(source.identifier)}" moved to "${target.properties.label || String(target.identifier)}"`,
|
||||
'success',
|
||||
)
|
||||
|
||||
return movedFolder
|
||||
}
|
||||
|
||||
async function deleteFolder(folder: CollectionObject): Promise<CollectionObject | boolean> {
|
||||
const deletedFolder = await collectionsStore.delete(folder.identifier)
|
||||
|
||||
if (_sameCollection(selectedFolder.value, folder)) {
|
||||
clearSelectedFolder()
|
||||
}
|
||||
|
||||
notify(
|
||||
`Folder "${folder.properties.label || String(folder.identifier)}" deleted`,
|
||||
'success',
|
||||
)
|
||||
|
||||
return deletedFolder
|
||||
}
|
||||
|
||||
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
|
||||
const movableIdentifiers = entityIdentifiers.filter(identifier => {
|
||||
const entity = entitiesStore.entityByIdentifier(identifier)
|
||||
const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
|
||||
(accumulator, identifier) => {
|
||||
const entity = entitiesStore.entity(identifier)
|
||||
|
||||
if (!entity) {
|
||||
return false
|
||||
}
|
||||
if (!entity) {
|
||||
return accumulator
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
// Only allow moving messages within the same service and disallow moving into the same folder
|
||||
const canMove = entity.provider === target.provider &&
|
||||
String(entity.service) === String(target.service) &&
|
||||
String(entity.collection) !== String(target.identifier)
|
||||
|
||||
if (!canMove) {
|
||||
return accumulator
|
||||
}
|
||||
|
||||
accumulator.movableIdentifiers.push(identifier)
|
||||
|
||||
if (!accumulator.sourceCollections.some(
|
||||
collection => String(collection) === String(entity.collection),
|
||||
)) {
|
||||
accumulator.sourceCollections.push(entity.collection)
|
||||
}
|
||||
|
||||
return accumulator
|
||||
},
|
||||
{
|
||||
movableIdentifiers: [] as EntityIdentifier[],
|
||||
sourceCollections: [] as CollectionIdentifier[],
|
||||
},
|
||||
)
|
||||
|
||||
if (movableIdentifiers.length === 0) {
|
||||
closeMoveDialog()
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const [successes, failures] = await entitiesStore.move(_collectionIdentifier(target), movableIdentifiers)
|
||||
|
||||
clearSelection()
|
||||
closeMoveDialog()
|
||||
const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
|
||||
|
||||
if (failures.length === 0) {
|
||||
notify(
|
||||
@@ -430,6 +568,10 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
successes.length === 0 ? 'error' : 'warning',
|
||||
)
|
||||
}
|
||||
|
||||
// update source collections to reflect moved messages
|
||||
sourceCollections.push(target.identifier)
|
||||
await collectionsStore.fetch(sourceCollections)
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
|
||||
console.error('[Mail] Failed to move messages:', error)
|
||||
@@ -448,9 +590,7 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const [successes, failures] = await entitiesStore.delete(entityIdentifiers)
|
||||
|
||||
clearSelection()
|
||||
const { successes, failures } = await entitiesStore.delete(entityIdentifiers)
|
||||
|
||||
if (failures.length === 0) {
|
||||
notify(
|
||||
@@ -477,14 +617,6 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarVisible.value = !sidebarVisible.value
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
settingsDialogVisible.value = true
|
||||
}
|
||||
|
||||
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
|
||||
showSnackbar({ message, color })
|
||||
}
|
||||
@@ -499,18 +631,13 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
mailSync,
|
||||
|
||||
// State
|
||||
sidebarVisible,
|
||||
settingsDialogVisible,
|
||||
loading,
|
||||
selectedFolder,
|
||||
selectedMessage,
|
||||
selectionList,
|
||||
selectionMode,
|
||||
composeMode,
|
||||
composeReplyTo,
|
||||
moveDialogVisible,
|
||||
moveDialogService,
|
||||
moveDialogCandidates,
|
||||
composerSaving,
|
||||
composerSending,
|
||||
composerLastSaved,
|
||||
composerDraftIdentifier,
|
||||
serviceFolderLoadingState,
|
||||
serviceFolderLoadedState,
|
||||
serviceFolderErrorState,
|
||||
@@ -519,24 +646,21 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
currentMessages,
|
||||
|
||||
// Actions
|
||||
retrieveService,
|
||||
selectFolder,
|
||||
clearSelectedFolder,
|
||||
selectMessage,
|
||||
isMessageSelected,
|
||||
activateSelectionMode,
|
||||
deactivateSelectionMode,
|
||||
toggleMessageSelection,
|
||||
selectAllCurrentMessages,
|
||||
clearSelection,
|
||||
openCompose,
|
||||
openMoveDialog,
|
||||
closeMoveDialog,
|
||||
closeCompose,
|
||||
afterSent,
|
||||
clearSelectedMessage,
|
||||
createFolder,
|
||||
reloadSelectedFolder,
|
||||
saveComposerDraft,
|
||||
sendComposerMessage,
|
||||
resetComposerState,
|
||||
deleteMessages,
|
||||
deleteFolder,
|
||||
moveMessages,
|
||||
toggleSidebar,
|
||||
openSettings,
|
||||
moveFolder,
|
||||
renameFolder,
|
||||
notify,
|
||||
isServiceFolderLoading,
|
||||
hasServiceFoldersLoaded,
|
||||
|
||||
534
src/stores/mailUiStore.ts
Normal file
534
src/stores/mailUiStore.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
||||
import type { EntityObject, ServiceObject } from '@MailManager/models'
|
||||
import type { CollectionObject } from '@MailManager/models/collection'
|
||||
|
||||
export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||
const collectionsStore = useCollectionsStore()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
const sidebarVisible = ref(true)
|
||||
const settingsDialogVisible = ref(false)
|
||||
const composeMode = ref<'new' | 'reply' | 'forward'>('new')
|
||||
const composeSource = shallowRef<EntityObject | null>(null)
|
||||
const composeVisible = 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 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.selectedFolder,
|
||||
() => {
|
||||
closeCompose()
|
||||
deactivateSelectionMode()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => mailStore.selectedMessage,
|
||||
selectedMessage => {
|
||||
if (selectedMessage) {
|
||||
closeCompose()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => mailStore.currentMessages,
|
||||
() => {
|
||||
reconcileSelection()
|
||||
},
|
||||
)
|
||||
|
||||
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 openCompose(source?: EntityObject, mode: 'reply' | 'forward' = 'reply') {
|
||||
mailStore.clearSelectedMessage()
|
||||
composeSource.value = source ?? null
|
||||
composeMode.value = mode
|
||||
composeVisible.value = true
|
||||
}
|
||||
|
||||
function closeCompose() {
|
||||
composeMode.value = 'new'
|
||||
composeSource.value = null
|
||||
composeVisible.value = false
|
||||
}
|
||||
|
||||
async function afterSent() {
|
||||
closeCompose()
|
||||
await mailStore.reloadSelectedFolder()
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setSelectionList([])
|
||||
}
|
||||
|
||||
function activateSelectionMode(message?: EntityObject) {
|
||||
selectionMode.value = true
|
||||
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
||||
const identifier = message.identifier
|
||||
|
||||
if (!selectionList.value.includes(identifier)) {
|
||||
setSelectionList([...selectionList.value, identifier])
|
||||
}
|
||||
}
|
||||
|
||||
function deactivateSelectionMode() {
|
||||
selectionMode.value = false
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
function toggleMessageSelection(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 selectAllCurrentMessages() {
|
||||
selectionMode.value = true
|
||||
setSelectionList(mailStore.currentMessages.map(message => message.identifier))
|
||||
}
|
||||
|
||||
function reconcileSelection() {
|
||||
if (!mailStore.selectedFolder) {
|
||||
clearSelection()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarVisible.value = !sidebarVisible.value
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
sidebarVisible.value = false
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
settingsDialogVisible.value = true
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
settingsDialogVisible.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 = mailStore.selectedFolder?.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 ?? [])
|
||||
deactivateSelectionMode()
|
||||
closeMoveMessagesDialog()
|
||||
}
|
||||
|
||||
async function deleteSelectedMessages() {
|
||||
await mailStore.deleteMessages([...selectionList.value])
|
||||
deactivateSelectionMode()
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarVisible,
|
||||
settingsDialogVisible,
|
||||
composeMode,
|
||||
composeSource,
|
||||
composeVisible,
|
||||
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,
|
||||
toggleSidebar,
|
||||
closeSidebar,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
openCompose,
|
||||
closeCompose,
|
||||
afterSent,
|
||||
activateSelectionMode,
|
||||
deactivateSelectionMode,
|
||||
toggleMessageSelection,
|
||||
selectAllCurrentMessages,
|
||||
clearSelection,
|
||||
validateCreateFolderName,
|
||||
validateRenameFolderName,
|
||||
openMoveMessagesDialog,
|
||||
closeMoveMessagesDialog,
|
||||
confirmMoveMessages,
|
||||
deleteSelectedMessages,
|
||||
openCreateFolderDialog,
|
||||
closeCreateFolderDialog,
|
||||
confirmCreateFolder,
|
||||
openRenameFolderDialog,
|
||||
closeRenameFolderDialog,
|
||||
confirmRenameFolder,
|
||||
openMoveFolderDialog,
|
||||
closeMoveFolderDialog,
|
||||
confirmMoveFolder,
|
||||
openDeleteFolderDialog,
|
||||
closeDeleteFolderDialog,
|
||||
confirmDeleteFolder,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user