diff --git a/src/components/FolderSelectionDialog.vue b/src/components/FolderSelectionDialog.vue new file mode 100644 index 0000000..eeaf8d2 --- /dev/null +++ b/src/components/FolderSelectionDialog.vue @@ -0,0 +1,283 @@ + + + + + + + {{ title }} + + + + + + + + + + + + + + + + + + + Loading folders + + + + + + + Folders unavailable + {{ group.error }} + + + + + + + + + + + + + + + + + + + + + + + Cancel + + + + {{ confirmText }} + + + + + + + diff --git a/src/components/FolderSelectionTreeNode.vue b/src/components/FolderSelectionTreeNode.vue new file mode 100644 index 0000000..0a0aeb7 --- /dev/null +++ b/src/components/FolderSelectionTreeNode.vue @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/MessageReader.vue b/src/components/MessageReader.vue index 90c490c..90427c8 100644 --- a/src/components/MessageReader.vue +++ b/src/components/MessageReader.vue @@ -24,6 +24,7 @@ const { getSetting } = useUser() const emit = defineEmits<{ reply: [message: EntityInterface] forward: [message: EntityInterface] + move: [message: EntityInterface] delete: [message: EntityInterface] flag: [message: EntityInterface] compose: [] @@ -105,6 +106,12 @@ const handleDelete = () => { } } +const handleMove = () => { + if (props.message) { + emit('move', props.message) + } +} + const handleFlag = () => { if (props.message) { emit('flag', props.message) @@ -134,6 +141,7 @@ const handleCompose = () => { :is-security-overridden="overrideSecurityLevel !== null" @reply="handleReply" @forward="handleForward" + @move="handleMove" @delete="handleDelete" @flag="handleFlag" @toggle-images="toggleImages" diff --git a/src/components/reader/ReaderToolbar.vue b/src/components/reader/ReaderToolbar.vue index 01f9111..6ffbcd0 100644 --- a/src/components/reader/ReaderToolbar.vue +++ b/src/components/reader/ReaderToolbar.vue @@ -17,6 +17,7 @@ const props = defineProps() const emit = defineEmits<{ reply: [] forward: [] + move: [] delete: [] flag: [] toggleImages: [] @@ -146,13 +147,31 @@ const currentSecurityLevel = computed(() => { - - mdi-dots-vertical - More Actions - + + + + mdi-dots-vertical + More Actions + + + + + + + + diff --git a/src/pages/MailPage.vue b/src/pages/MailPage.vue index 188a244..bbc3d8f 100644 --- a/src/pages/MailPage.vue +++ b/src/pages/MailPage.vue @@ -4,14 +4,13 @@ import { storeToRefs } from 'pinia' import { useDisplay } from 'vuetify' import { useModuleStore } from '@KTXC' import { useMailStore } from '@/stores/mailStore' +import type { CollectionObject, EntityObject } from '@MailManager/models' import FolderTree from '@/components/FolderTree.vue' import MessageList from '@/components/MessageList.vue' import MessageReader from '@/components/MessageReader.vue' import MessageComposer from '@/components/MessageComposer.vue' +import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue' import SettingsDialog from '@/components/settings/SettingsDialog.vue' -import type { EntityInterface } from '@MailManager/types/entity' -import type { MessageInterface } from '@MailManager/types/message' -import type { CollectionObject } from '@MailManager/models' // Vuetify display for responsive behavior const display = useDisplay() @@ -36,6 +35,7 @@ const { composeMode, composeReplyTo, currentMessages, + moveDialogVisible, } = storeToRefs(mailStore) // Complex store/composable objects accessed directly (not simple refs) @@ -50,17 +50,25 @@ onMounted(async () => { // Handlers — thin wrappers that delegate to the store const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder) -const handleMessageSelect = (message: EntityInterface) => mailStore.selectMessage(message, isMobile.value) +const handleMessageSelect = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value) -const handleCompose = (replyTo?: EntityInterface) => mailStore.openCompose(replyTo) +const handleCompose = (replyTo?: EntityObject) => mailStore.openCompose(replyTo) const handleComposeClose = () => mailStore.closeCompose() const handleComposeSent = () => mailStore.afterSent() -const handleReply = (message: EntityInterface) => mailStore.openCompose(message) +const handleReply = (message: EntityObject) => mailStore.openCompose(message) -const handleDelete = (message: EntityInterface) => mailStore.deleteMessage(message) +const handleDelete = (message: EntityObject) => mailStore.deleteMessage(message) + +const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message) + +const handleMoveConfirm = async (folder: CollectionObject) => { + await mailStore.moveMessage(folder) +} + +const handleMoveCancel = () => mailStore.closeMoveDialog() const toggleSidebar = () => mailStore.toggleSidebar() @@ -193,6 +201,7 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold v-else :message="selectedMessage" @reply="handleReply" + @move="handleMove" @delete="handleDelete" @compose="handleCompose()" /> @@ -203,6 +212,16 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold + + diff --git a/src/stores/mailStore.ts b/src/stores/mailStore.ts index bd94464..834fe16 100644 --- a/src/stores/mailStore.ts +++ b/src/stores/mailStore.ts @@ -5,10 +5,9 @@ import { useEntitiesStore } from '@MailManager/stores/entitiesStore' import { useServicesStore } from '@MailManager/stores/servicesStore' import { useMailSync } from '@MailManager/composables/useMailSync' import { useSnackbar } from '@KTXC' -import type { CollectionObject } from '@MailManager/models/collection' -import type { EntityInterface } from '@MailManager/types/entity' +import type { CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { MessageInterface } from '@MailManager/types/message' -import type { ServiceObject } from '@MailManager/models' +import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models' export const useMailStore = defineStore('mailStore', () => { const collectionsStore = useCollectionsStore() @@ -42,11 +41,15 @@ export const useMailStore = defineStore('mailStore', () => { // ── Selection State ─────────────────────────────────────────────────────── const selectedFolder = shallowRef(null) - const selectedMessage = shallowRef | null>(null) + const selectedMessage = shallowRef(null) // ── Compose State ───────────────────────────────────────────────────────── const composeMode = ref(false) - const composeReplyTo = shallowRef | null>(null) + const composeReplyTo = shallowRef(null) + + // ── Move State ──────────────────────────────────────────────────────────── + const moveDialogVisible = ref(false) + const moveMessageCandidate = shallowRef(null) // ── Computed ────────────────────────────────────────────────────────────── const currentMessages = computed(() => { @@ -205,6 +208,27 @@ export const useMailStore = defineStore('mailStore', () => { return serviceFolderErrorState.value[_serviceKey(provider, service)] ?? null } + function _entityIdentifier(entity: EntityObject): EntityIdentifier { + return `${entity.provider}:${String(entity.service)}:${String(entity.collection)}:${String(entity.identifier)}` as EntityIdentifier + } + + function _collectionIdentifier(collection: CollectionObject): CollectionIdentifier { + return `${collection.provider}:${String(collection.service)}:${String(collection.identifier)}` as CollectionIdentifier + } + + function _isSameMessage(left: EntityObject | null, right: EntityObject): boolean { + if (!left) { + return false + } + + return ( + left.provider === right.provider && + String(left.service) === String(right.service) && + String(left.collection) === String(right.collection) && + String(left.identifier) === String(right.identifier) + ) + } + // ── Actions ─────────────────────────────────────────────────────────────── async function selectFolder(folder: CollectionObject) { @@ -227,7 +251,7 @@ export const useMailStore = defineStore('mailStore', () => { _updateSyncSources() } - function selectMessage(message: EntityInterface, closeSidebar = false) { + function selectMessage(message: EntityObject, closeSidebar = false) { selectedMessage.value = message composeMode.value = false @@ -236,12 +260,22 @@ export const useMailStore = defineStore('mailStore', () => { } } - function openCompose(replyTo?: EntityInterface) { + function openCompose(replyTo?: EntityObject) { composeMode.value = true composeReplyTo.value = replyTo ?? null selectedMessage.value = null } + function openMoveDialog(message: EntityObject) { + moveMessageCandidate.value = message + moveDialogVisible.value = true + } + + function closeMoveDialog() { + moveDialogVisible.value = false + moveMessageCandidate.value = null + } + function closeCompose() { composeMode.value = false composeReplyTo.value = null @@ -257,11 +291,64 @@ export const useMailStore = defineStore('mailStore', () => { } } - async function deleteMessage(message: EntityInterface) { + async function deleteMessage(message: EntityObject) { // TODO: implement delete via entity / collection store console.log('[Mail] Delete message:', message.identifier) } + async function moveMessage(targetFolder: CollectionObject) { + const message = moveMessageCandidate.value + + if (!message) { + return + } + + const isSameCollection = + targetFolder.provider === message.provider && + String(targetFolder.service) === String(message.service) && + String(targetFolder.identifier) === String(message.collection) + + if (isSameCollection) { + closeMoveDialog() + return + } + + loading.value = true + + try { + const sourceIdentifier = _entityIdentifier(message) + const response = await entitiesStore.move(_collectionIdentifier(targetFolder), [sourceIdentifier]) + const result = response[sourceIdentifier] + + if (!result || !result.success) { + throw new Error(result && 'error' in result ? result.error : 'Failed to move message') + } + + if (_isSameMessage(selectedMessage.value, message)) { + selectedMessage.value = null + } + + const service = servicesStore.services.find(entry => + entry.provider === message.provider && + String(entry.identifier) === String(message.service), + ) + + if (service) { + void loadFoldersForService(service) + } + + notify(`Message moved to "${targetFolder.properties.label || targetFolder.identifier}"`, 'success') + closeMoveDialog() + } catch (error) { + const messageText = error instanceof Error ? error.message : 'Failed to move message' + console.error('[Mail] Failed to move message:', error) + notify(messageText, 'error') + throw error + } finally { + loading.value = false + } + } + function toggleSidebar() { sidebarVisible.value = !sidebarVisible.value } @@ -291,6 +378,8 @@ export const useMailStore = defineStore('mailStore', () => { selectedMessage, composeMode, composeReplyTo, + moveDialogVisible, + moveMessageCandidate, serviceFolderLoadingState, serviceFolderLoadedState, serviceFolderErrorState, @@ -302,9 +391,12 @@ export const useMailStore = defineStore('mailStore', () => { selectFolder, selectMessage, openCompose, + openMoveDialog, + closeMoveDialog, closeCompose, afterSent, deleteMessage, + moveMessage, toggleSidebar, openSettings, notify,