Compare commits
60 Commits
284a8e1de8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f379e40de4 | |||
| 19464beba1 | |||
| 6e7592d39d | |||
| 37d723cbdb | |||
| 461cd5dd92 | |||
| 805863f084 | |||
| 7a3d90d0cd | |||
| d24e6110b9 | |||
| ec776bbb78 | |||
| 4f9a6cc5bd | |||
| 09da7b4770 | |||
| 41c6c82546 | |||
| 967dadf864 | |||
| c1c334fe22 | |||
| 5a355c1536 | |||
| 3a3cf77da1 | |||
| 800d6bce83 | |||
| 6745361a5c | |||
| 9a98042fa0 | |||
| 42c24642ce | |||
| b66ebbd078 | |||
| 4b9a3b7945 | |||
| cdff4d0d3f | |||
| bafb294e1a | |||
| f1d0511cbb | |||
| 897a03578e | |||
| 4367fcfe9a | |||
| b7fcc0a368 | |||
| 0db23271e5 | |||
| 3f4aeb99c7 | |||
| 7e544d16f4 | |||
| 8ba40a971e | |||
| c3c41f7ba0 | |||
| 5a58c3c7ac | |||
| 7853a21288 | |||
| f1823a246c | |||
| 820c2c812f | |||
| 6e76bec190 | |||
| c304ab6b6e | |||
| 12e0c7b428 | |||
| 7c366fdd96 | |||
| 55e18bf35e | |||
| 1d62412407 | |||
| d76166addd | |||
| 99766ddb79 | |||
| ad76488705 | |||
| 8c9b746a1c | |||
| 55c87096b9 | |||
| c69763e57d | |||
| 232f588225 | |||
| b1cdaea6af | |||
| fe9f4d9656 | |||
| d069fb36a7 | |||
| 5fef85aa85 | |||
| 46632d2454 | |||
| f007d5a514 | |||
| a5f33e5f7b | |||
| 603c0caf17 | |||
| 49a067e4ff | |||
| 85a7f23889 |
2391
package-lock.json
generated
2391
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,14 +22,14 @@
|
||||
"@tiptap/extension-underline": "^3.0.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^5.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vuetify": "^3.5.0"
|
||||
"pinia": "^3.0.0",
|
||||
"vuetify": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"typescript": "^6.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vue-tsc": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||
import { CollectionPropertiesObject } from '@MailManager/models/collection'
|
||||
import type { CollectionObject } from '@MailManager/models/collection'
|
||||
import type { ServiceObject } from '@MailManager/models'
|
||||
|
||||
@@ -9,28 +7,27 @@ import type { ServiceObject } from '@MailManager/models'
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
service: ServiceObject
|
||||
parentFolder?: CollectionObject | null
|
||||
allFolders?: CollectionObject[]
|
||||
parentFolderLabel?: string
|
||||
validateName?: (name: string) => string[]
|
||||
loading?: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
parentFolder: null,
|
||||
allFolders: () => []
|
||||
parentFolderLabel: 'Root',
|
||||
validateName: () => [],
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'created': [folder: CollectionObject]
|
||||
confirm: [folderName: string]
|
||||
}>()
|
||||
|
||||
// Store
|
||||
const collectionsStore = useCollectionsStore()
|
||||
|
||||
// Form state
|
||||
const folderName = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const validationErrors = ref<string[]>([])
|
||||
|
||||
// Computed
|
||||
@@ -39,67 +36,13 @@ const dialogValue = computed({
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const parentFolderLabel = computed(() => {
|
||||
if (!props.parentFolder) return 'Root'
|
||||
return props.parentFolder.properties.label
|
||||
})
|
||||
|
||||
const isValid = computed(() => {
|
||||
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
|
||||
})
|
||||
|
||||
// Validation functions
|
||||
const validateFolderName = (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)')
|
||||
}
|
||||
|
||||
// No special characters that might cause issues
|
||||
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
|
||||
errors.push('Folder name contains invalid characters')
|
||||
}
|
||||
|
||||
// Provider-specific rules
|
||||
if (props.service.provider === 'imap' && /[\/\\]/.test(name)) {
|
||||
errors.push('IMAP folder names cannot contain / or \\')
|
||||
}
|
||||
|
||||
// Leading/trailing spaces
|
||||
if (name !== name.trim()) {
|
||||
errors.push('Folder name cannot have leading or trailing spaces')
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const checkDuplicateName = (name: string): boolean => {
|
||||
const parentId = props.parentFolder?.identifier ?? null
|
||||
|
||||
return props.allFolders.some(f =>
|
||||
f.properties.label === name &&
|
||||
String(f.collection) === String(parentId) &&
|
||||
String(f.service) === String(props.service.identifier)
|
||||
)
|
||||
}
|
||||
|
||||
// Watch folder name for validation
|
||||
watch(folderName, (newName) => {
|
||||
errorMessage.value = ''
|
||||
validationErrors.value = validateFolderName(newName)
|
||||
|
||||
// Check for duplicates only if no other validation errors
|
||||
if (validationErrors.value.length === 0 && newName.trim().length > 0) {
|
||||
if (checkDuplicateName(newName)) {
|
||||
validationErrors.value.push('A folder with this name already exists in this location')
|
||||
}
|
||||
}
|
||||
validationErrors.value = props.validateName(newName)
|
||||
})
|
||||
|
||||
// Reset form when dialog opens/closes
|
||||
@@ -111,59 +54,25 @@ watch(dialogValue, (isOpen) => {
|
||||
|
||||
const resetForm = () => {
|
||||
folderName.value = ''
|
||||
errorMessage.value = ''
|
||||
validationErrors.value = []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
// Final validation
|
||||
const errors = validateFolderName(folderName.value)
|
||||
const errors = props.validateName(folderName.value)
|
||||
|
||||
if (errors.length > 0) {
|
||||
validationErrors.value = errors
|
||||
return
|
||||
}
|
||||
|
||||
if (checkDuplicateName(folderName.value)) {
|
||||
validationErrors.value = ['A folder with this name already exists in this location']
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// Create properties object
|
||||
const properties = new CollectionPropertiesObject()
|
||||
properties.label = folderName.value.trim()
|
||||
properties.rank = 0
|
||||
properties.subscribed = true
|
||||
|
||||
// Create the collection
|
||||
const newFolder = await collectionsStore.create(
|
||||
props.service.provider,
|
||||
props.service.identifier as string | number,
|
||||
props.parentFolder?.identifier ?? null,
|
||||
properties
|
||||
)
|
||||
|
||||
// Success!
|
||||
emit('created', newFolder)
|
||||
dialogValue.value = false
|
||||
resetForm()
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[CreateFolderDialog] Failed to create folder:', error)
|
||||
errorMessage.value = error.message || 'Failed to create folder. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
emit('confirm', folderName.value.trim())
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogValue.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -8,20 +8,22 @@ interface Props {
|
||||
modelValue: boolean
|
||||
service: ServiceObject
|
||||
folder: CollectionObject
|
||||
loading?: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
deleted: [folder: CollectionObject]
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const collectionsStore = useCollectionsStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const dialogValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
@@ -33,37 +35,17 @@ const hasChildren = computed(() => {
|
||||
return collectionsStore.hasChildrenInCollection(props.folder.provider, props.folder.service, props.folder.identifier)
|
||||
})
|
||||
|
||||
const resetState = () => {
|
||||
loading.value = false
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
watch(dialogValue, isOpen => {
|
||||
if (isOpen) {
|
||||
resetState()
|
||||
}
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await collectionsStore.delete(props.folder.provider, props.folder.service, props.folder.identifier)
|
||||
emit('deleted', props.folder)
|
||||
dialogValue.value = false
|
||||
resetState()
|
||||
} catch (error: any) {
|
||||
console.error('[DeleteFolderDialog] Failed to delete folder:', error)
|
||||
errorMessage.value = error.message || 'Failed to delete folder. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
const handleDelete = () => {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogValue.value = false
|
||||
resetState()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
|
||||
const emit = defineEmits<{
|
||||
select: [folder: CollectionObject]
|
||||
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
|
||||
editFolder: [service: ServiceObject, folder: CollectionObject]
|
||||
moveFolder: [service: ServiceObject, folder: CollectionObject]
|
||||
deleteFolder: [service: ServiceObject, folder: CollectionObject]
|
||||
editFolder: [folder: CollectionObject]
|
||||
moveFolder: [folder: CollectionObject]
|
||||
deleteFolder: [folder: CollectionObject]
|
||||
}>()
|
||||
|
||||
// Page-based navigation state per service account
|
||||
@@ -283,7 +283,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
prepend-icon="mdi-pencil"
|
||||
@click="emit('editFolder', group.service, folder)"
|
||||
@click="emit('editFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||
</v-list-item>
|
||||
@@ -295,7 +295,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-folder-move"
|
||||
@click="emit('moveFolder', group.service, folder)"
|
||||
@click="emit('moveFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Move Folder</v-list-item-title>
|
||||
</v-list-item>
|
||||
@@ -303,7 +303,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
||||
v-if="canDeleteFolder(folder)"
|
||||
prepend-icon="mdi-delete"
|
||||
base-color="error"
|
||||
@click="emit('deleteFolder', group.service, folder)"
|
||||
@click="emit('deleteFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||
</v-list-item>
|
||||
@@ -446,7 +446,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
prepend-icon="mdi-pencil"
|
||||
@click="emit('editFolder', group.service, folder)"
|
||||
@click="emit('editFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||
</v-list-item>
|
||||
@@ -458,7 +458,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-folder-move"
|
||||
@click="emit('moveFolder', group.service, folder)"
|
||||
@click="emit('moveFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Move Folder</v-list-item-title>
|
||||
</v-list-item>
|
||||
@@ -466,7 +466,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
||||
v-if="canDeleteFolder(folder)"
|
||||
prepend-icon="mdi-delete"
|
||||
base-color="error"
|
||||
@click="emit('deleteFolder', group.service, folder)"
|
||||
@click="emit('deleteFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||
import type { ServiceObject, CollectionObject } from '@MailManager/models'
|
||||
import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue'
|
||||
|
||||
@@ -30,8 +30,8 @@ const emit = defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const collectionsStore = useCollectionsStore()
|
||||
const mailStore = useMailStore()
|
||||
const collectionsStore = useCollectionsStore()
|
||||
|
||||
const selectedFolderKey = ref<string | null>(null)
|
||||
|
||||
@@ -40,10 +40,6 @@ const dialogValue = computed({
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const folderKeyFor = (folder: CollectionObject): string => {
|
||||
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
|
||||
}
|
||||
|
||||
interface ServiceGroup {
|
||||
service: ServiceObject
|
||||
loading: boolean
|
||||
@@ -52,8 +48,7 @@ interface ServiceGroup {
|
||||
}
|
||||
|
||||
const serviceGroups = computed<ServiceGroup[]>(() => {
|
||||
const service = props.service ?? mailStore.moveDialogService
|
||||
|
||||
const service = props.service
|
||||
if (!service) {
|
||||
return []
|
||||
}
|
||||
@@ -70,6 +65,34 @@ const serviceGroups = computed<ServiceGroup[]>(() => {
|
||||
}]
|
||||
})
|
||||
|
||||
const selectedFolder = computed(() => {
|
||||
if (!selectedFolderKey.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const group = serviceGroups.value[0]
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getServiceFolders(group.service).find(folder => folder.identifier === selectedFolderKey.value) ?? null
|
||||
})
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
return selectedFolder.value !== null && !props.loading
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedFolderKey.value = null
|
||||
},
|
||||
)
|
||||
|
||||
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
|
||||
if (service.identifier === null) {
|
||||
return []
|
||||
@@ -86,36 +109,8 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
||||
return collectionsStore.collectionsForService(service.provider, service.identifier)
|
||||
}
|
||||
|
||||
const selectedFolder = computed(() => {
|
||||
if (!selectedFolderKey.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const group = serviceGroups.value[0]
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getServiceFolders(group.service).find(folder => folderKeyFor(folder) === selectedFolderKey.value) ?? null
|
||||
})
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
return selectedFolder.value !== null && !props.loading
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, mailStore.moveDialogService],
|
||||
([isOpen]) => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedFolderKey.value = null
|
||||
},
|
||||
)
|
||||
|
||||
const handleSelect = (folder: CollectionObject) => {
|
||||
selectedFolderKey.value = folderKeyFor(folder)
|
||||
selectedFolderKey.value = folder.identifier
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -165,7 +160,7 @@ const handleConfirm = () => {
|
||||
|
||||
<FolderSelectionTreeNode
|
||||
v-for="folder in getRootFolders(group.service)"
|
||||
:key="folderKeyFor(folder)"
|
||||
:key="folder.identifier"
|
||||
:folder="folder"
|
||||
:service="group.service"
|
||||
:selected-folder-key="selectedFolderKey"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||
import type { CollectionObject } from '@MailManager/models/collection'
|
||||
import type { ServiceObject } from '@MailManager/models'
|
||||
import type { CollectionIdentifier } from '@MailManager/types/common'
|
||||
|
||||
interface Props {
|
||||
folder: CollectionObject
|
||||
@@ -20,9 +21,7 @@ const emit = defineEmits<{
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
const folderKeyFor = (folder: CollectionObject): string => {
|
||||
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
|
||||
}
|
||||
const folderKeyFor = (folder: CollectionObject): string => String(folder.identifier)
|
||||
|
||||
const folderLabelFor = (folder: CollectionObject): string => {
|
||||
return folder.properties.label || String(folder.identifier)
|
||||
@@ -76,7 +75,11 @@ const childFolders = computed(() => {
|
||||
return []
|
||||
}
|
||||
|
||||
return collectionsStore.collectionsInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
|
||||
return collectionsStore.collectionsInCollection(
|
||||
props.service.provider,
|
||||
serviceIdentifier,
|
||||
props.folder.identifier as CollectionIdentifier,
|
||||
)
|
||||
})
|
||||
|
||||
const hasChildren = computed(() => {
|
||||
@@ -86,7 +89,7 @@ const hasChildren = computed(() => {
|
||||
return false
|
||||
}
|
||||
|
||||
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
|
||||
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier as CollectionIdentifier)
|
||||
})
|
||||
const isSelected = computed(() => props.selectedFolderKey === key.value)
|
||||
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import { useUser } from '@KTXC'
|
||||
import FolderTreeView from './FolderTreeView.vue'
|
||||
import FolderPageView from './FolderPageView.vue'
|
||||
import CreateFolderDialog from './CreateFolderDialog.vue'
|
||||
import DeleteFolderDialog from './DeleteFolderDialog.vue'
|
||||
import FolderSelectionDialog from './FolderSelectionDialog.vue'
|
||||
import RenameFolderDialog from './RenameFolderDialog.vue'
|
||||
import type { CollectionObject } from '@MailManager/models/collection'
|
||||
import type { ServiceObject } from '@MailManager/models'
|
||||
|
||||
type FolderViewMode = 'tree' | 'page'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedFolder?: CollectionObject | null
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
select: [folder: CollectionObject]
|
||||
folderCreated: [folder: CollectionObject]
|
||||
}>()
|
||||
|
||||
// Stores
|
||||
const collectionsStore = useCollectionsStore()
|
||||
const servicesStore = useServicesStore()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
// User settings
|
||||
const { settings } = useUser()
|
||||
|
||||
// Folder view mode from user settings
|
||||
const folderViewMode = computed(() => {
|
||||
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
|
||||
})
|
||||
|
||||
// Create folder dialog state
|
||||
const createDialogVisible = ref(false)
|
||||
const createDialogService = ref<ServiceObject | null>(null)
|
||||
const createDialogParent = ref<CollectionObject | null>(null)
|
||||
const renameDialogVisible = ref(false)
|
||||
const renameDialogService = ref<ServiceObject | null>(null)
|
||||
const renameDialogFolder = ref<CollectionObject | null>(null)
|
||||
const moveDialogVisible = ref(false)
|
||||
const moveDialogService = ref<ServiceObject | null>(null)
|
||||
const moveDialogFolder = ref<CollectionObject | null>(null)
|
||||
const deleteDialogVisible = ref(false)
|
||||
const deleteDialogService = ref<ServiceObject | null>(null)
|
||||
const deleteDialogFolder = ref<CollectionObject | null>(null)
|
||||
|
||||
// Handle create folder event from child components
|
||||
const handleCreateFolder = (service: ServiceObject, parentFolder: CollectionObject | null = null) => {
|
||||
createDialogService.value = service
|
||||
createDialogParent.value = parentFolder
|
||||
createDialogVisible.value = true
|
||||
}
|
||||
|
||||
// Handle folder created
|
||||
const handleFolderCreated = (newFolder: CollectionObject) => {
|
||||
emit('folderCreated', newFolder)
|
||||
emit('select', newFolder)
|
||||
}
|
||||
|
||||
const handleEditFolder = (service: ServiceObject, folder: CollectionObject) => {
|
||||
renameDialogService.value = service
|
||||
renameDialogFolder.value = folder
|
||||
renameDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleMoveFolder = (service: ServiceObject, folder: CollectionObject) => {
|
||||
moveDialogService.value = service
|
||||
moveDialogFolder.value = folder
|
||||
moveDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDeleteFolder = (service: ServiceObject, folder: CollectionObject) => {
|
||||
deleteDialogService.value = service
|
||||
deleteDialogFolder.value = folder
|
||||
deleteDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleFolderRenamed = (updatedFolder: CollectionObject) => {
|
||||
emit('select', updatedFolder)
|
||||
}
|
||||
|
||||
const folderKeyFor = (folder: CollectionObject): string => {
|
||||
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
|
||||
}
|
||||
|
||||
const moveDialogInvalidFolderKeys = computed(() => {
|
||||
const sourceFolder = moveDialogFolder.value
|
||||
|
||||
if (!sourceFolder) {
|
||||
return []
|
||||
}
|
||||
|
||||
const invalidKeys = new Set<string>()
|
||||
const queue: CollectionObject[] = [sourceFolder]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentFolder = queue.shift()
|
||||
|
||||
if (!currentFolder) {
|
||||
continue
|
||||
}
|
||||
|
||||
invalidKeys.add(folderKeyFor(currentFolder))
|
||||
|
||||
collectionsStore
|
||||
.collectionsInCollection(currentFolder.provider, currentFolder.service, currentFolder.identifier)
|
||||
.forEach(childFolder => {
|
||||
queue.push(childFolder)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(invalidKeys)
|
||||
})
|
||||
|
||||
const isSameFolder = (left: CollectionObject | null | undefined, right: CollectionObject | null | undefined) => {
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
|
||||
return left.provider === right.provider &&
|
||||
String(left.service) === String(right.service) &&
|
||||
String(left.identifier) === String(right.identifier)
|
||||
}
|
||||
|
||||
const handleFolderDeleted = (deletedFolder: CollectionObject) => {
|
||||
if (isSameFolder(props.selectedFolder, deletedFolder)) {
|
||||
mailStore.clearSelectedFolder()
|
||||
}
|
||||
|
||||
mailStore.notify(`Folder "${deletedFolder.properties.label || String(deletedFolder.identifier)}" deleted`, 'success')
|
||||
}
|
||||
|
||||
const handleMoveDialogCancel = () => {
|
||||
moveDialogVisible.value = false
|
||||
}
|
||||
|
||||
const handleFolderMove = async (targetFolder: CollectionObject) => {
|
||||
const sourceFolder = moveDialogFolder.value
|
||||
|
||||
if (!sourceFolder) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const movedFolder = await collectionsStore.move(folderKeyFor(targetFolder), folderKeyFor(sourceFolder))
|
||||
moveDialogVisible.value = false
|
||||
|
||||
if (isSameFolder(props.selectedFolder, sourceFolder)) {
|
||||
emit('select', movedFolder)
|
||||
}
|
||||
|
||||
mailStore.notify(
|
||||
`Folder "${sourceFolder.properties.label || String(sourceFolder.identifier)}" moved to "${targetFolder.properties.label || String(targetFolder.identifier)}"`,
|
||||
'success',
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
console.error('[FolderTree] Failed to move folder:', error)
|
||||
mailStore.notify(error instanceof Error ? error.message : 'Failed to move folder', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// Computed: all folders for validation
|
||||
const allFolders = computed(() =>
|
||||
servicesStore.servicesEnabled.flatMap(service =>
|
||||
collectionsStore.collectionsForService(service.provider, service.identifier),
|
||||
)
|
||||
)
|
||||
|
||||
interface ServiceGroup {
|
||||
service: ServiceObject
|
||||
loading: boolean
|
||||
loaded: boolean
|
||||
error: string | null
|
||||
}
|
||||
const serviceGroups = computed(() => {
|
||||
const groups: ServiceGroup[] = []
|
||||
|
||||
servicesStore.servicesEnabled.forEach(service => {
|
||||
groups.push({
|
||||
service,
|
||||
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
|
||||
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
|
||||
error: mailStore.getServiceFolderError(service.provider, service.identifier),
|
||||
})
|
||||
})
|
||||
|
||||
return groups
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-list density="compact" nav>
|
||||
<!-- Tree View -->
|
||||
<FolderTreeView
|
||||
v-if="folderViewMode === 'tree'"
|
||||
:selected-folder="selectedFolder"
|
||||
:service-groups="serviceGroups"
|
||||
@select="emit('select', $event)"
|
||||
@create-folder="handleCreateFolder"
|
||||
@edit-folder="handleEditFolder"
|
||||
@move-folder="handleMoveFolder"
|
||||
@delete-folder="handleDeleteFolder"
|
||||
/>
|
||||
|
||||
<!-- Page-based View -->
|
||||
<FolderPageView
|
||||
v-else
|
||||
:selected-folder="selectedFolder"
|
||||
:service-groups="serviceGroups"
|
||||
@select="emit('select', $event)"
|
||||
@create-folder="handleCreateFolder"
|
||||
@edit-folder="handleEditFolder"
|
||||
@move-folder="handleMoveFolder"
|
||||
@delete-folder="handleDeleteFolder"
|
||||
/>
|
||||
|
||||
<!-- Empty state -->
|
||||
<v-list-item v-if="servicesStore.servicesEnabled.length === 0">
|
||||
<v-list-item-title class="text-center text-medium-emphasis">
|
||||
No mail accounts configured
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<!-- Create Folder Dialog -->
|
||||
<CreateFolderDialog
|
||||
v-if="createDialogService"
|
||||
v-model="createDialogVisible"
|
||||
:service="createDialogService"
|
||||
:parent-folder="createDialogParent"
|
||||
:all-folders="allFolders"
|
||||
@created="handleFolderCreated"
|
||||
/>
|
||||
|
||||
<RenameFolderDialog
|
||||
v-if="renameDialogService && renameDialogFolder"
|
||||
v-model="renameDialogVisible"
|
||||
:service="renameDialogService"
|
||||
:folder="renameDialogFolder"
|
||||
:all-folders="allFolders"
|
||||
@updated="handleFolderRenamed"
|
||||
/>
|
||||
|
||||
<FolderSelectionDialog
|
||||
v-if="moveDialogService && moveDialogFolder"
|
||||
v-model="moveDialogVisible"
|
||||
:service="moveDialogService"
|
||||
:loading="collectionsStore.transceiving"
|
||||
title="Move Folder"
|
||||
confirm-text="Move Folder"
|
||||
empty-text="No valid target folders are available."
|
||||
:disabled-folder-keys="moveDialogInvalidFolderKeys"
|
||||
@cancel="handleMoveDialogCancel"
|
||||
@select="handleFolderMove"
|
||||
/>
|
||||
|
||||
<DeleteFolderDialog
|
||||
v-if="deleteDialogService && deleteDialogFolder"
|
||||
v-model="deleteDialogVisible"
|
||||
:service="deleteDialogService"
|
||||
:folder="deleteDialogFolder"
|
||||
@deleted="handleFolderDeleted"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-list-item--active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -17,17 +17,17 @@ const expanded = ref(false)
|
||||
const emit = defineEmits<{
|
||||
select: [folder: CollectionObject]
|
||||
createSubfolder: [service: ServiceObject, parentFolder: CollectionObject]
|
||||
editFolder: [service: ServiceObject, folder: CollectionObject]
|
||||
moveFolder: [service: ServiceObject, folder: CollectionObject]
|
||||
deleteFolder: [service: ServiceObject, folder: CollectionObject]
|
||||
editFolder: [folder: CollectionObject]
|
||||
moveFolder: [folder: CollectionObject]
|
||||
deleteFolder: [folder: CollectionObject]
|
||||
}>()
|
||||
|
||||
const childFolders = computed(() => {
|
||||
return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier, props.folder.identifier)
|
||||
return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier)
|
||||
})
|
||||
|
||||
const hasChildren = computed(() => {
|
||||
return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier, props.folder.identifier)
|
||||
return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier)
|
||||
})
|
||||
|
||||
const canDeleteFolder = computed(() => !props.folder.properties.role)
|
||||
@@ -131,7 +131,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
prepend-icon="mdi-pencil"
|
||||
@click="emit('editFolder', service, folder)"
|
||||
@click="emit('editFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||
</v-list-item>
|
||||
@@ -143,7 +143,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-folder-move"
|
||||
@click="emit('moveFolder', service, folder)"
|
||||
@click="emit('moveFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Move Folder</v-list-item-title>
|
||||
</v-list-item>
|
||||
@@ -151,7 +151,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
||||
v-if="canDeleteFolder"
|
||||
prepend-icon="mdi-delete"
|
||||
base-color="error"
|
||||
@click="emit('deleteFolder', service, folder)"
|
||||
@click="emit('deleteFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||
</v-list-item>
|
||||
@@ -170,9 +170,9 @@ const isSelected = (folder: CollectionObject): boolean => {
|
||||
:selected-folder="selectedFolder"
|
||||
@select="emit('select', $event)"
|
||||
@create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)"
|
||||
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
|
||||
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
|
||||
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
|
||||
@edit-folder="(folder) => emit('editFolder', folder)"
|
||||
@move-folder="(folder) => emit('moveFolder', folder)"
|
||||
@delete-folder="(folder) => emit('deleteFolder', folder)"
|
||||
/>
|
||||
</div>
|
||||
</v-list-group>
|
||||
@@ -217,7 +217,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
prepend-icon="mdi-pencil"
|
||||
@click="emit('editFolder', service, folder)"
|
||||
@click="emit('editFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||
</v-list-item>
|
||||
@@ -229,7 +229,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-folder-move"
|
||||
@click="emit('moveFolder', service, folder)"
|
||||
@click="emit('moveFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Move Folder</v-list-item-title>
|
||||
</v-list-item>
|
||||
@@ -237,7 +237,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
||||
v-if="canDeleteFolder"
|
||||
prepend-icon="mdi-delete"
|
||||
base-color="error"
|
||||
@click="emit('deleteFolder', service, folder)"
|
||||
@click="emit('deleteFolder', folder)"
|
||||
>
|
||||
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
|
||||
const emit = defineEmits<{
|
||||
select: [folder: CollectionObject]
|
||||
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
|
||||
editFolder: [service: ServiceObject, folder: CollectionObject]
|
||||
moveFolder: [service: ServiceObject, folder: CollectionObject]
|
||||
deleteFolder: [service: ServiceObject, folder: CollectionObject]
|
||||
editFolder: [folder: CollectionObject]
|
||||
moveFolder: [folder: CollectionObject]
|
||||
deleteFolder: [folder: CollectionObject]
|
||||
}>()
|
||||
|
||||
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
|
||||
@@ -75,9 +75,9 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
||||
:selected-folder="selectedFolder"
|
||||
@select="emit('select', $event)"
|
||||
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
|
||||
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
|
||||
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
|
||||
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
|
||||
@edit-folder="(folder) => emit('editFolder', folder)"
|
||||
@move-folder="(folder) => emit('moveFolder', folder)"
|
||||
@delete-folder="(folder) => emit('deleteFolder', folder)"
|
||||
/>
|
||||
|
||||
<v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item">
|
||||
|
||||
113
src/components/FolderView.vue
Normal file
113
src/components/FolderView.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
|
||||
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||
import FolderTreeView from './FolderTreeView.vue'
|
||||
import FolderPageView from './FolderPageView.vue'
|
||||
import type { CollectionObject } from '@MailManager/models/collection'
|
||||
import type { ServiceObject } from '@MailManager/models'
|
||||
|
||||
interface ServiceGroup {
|
||||
service: ServiceObject
|
||||
loading: boolean
|
||||
loaded: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
selectedFolder?: CollectionObject | null
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
select: [folder: CollectionObject]
|
||||
}>()
|
||||
|
||||
// Stores
|
||||
const servicesStore = useServicesStore()
|
||||
const mailStore = useMailStore()
|
||||
const mailUiStore = useMailUiStore()
|
||||
const mailSettingsStore = useMailSettingsStore()
|
||||
const { folderViewMode } = storeToRefs(mailSettingsStore)
|
||||
|
||||
const serviceGroups = computed(() => {
|
||||
const groups: ServiceGroup[] = []
|
||||
|
||||
servicesStore.servicesEnabled.forEach(service => {
|
||||
if (service.identifier === null) {
|
||||
return
|
||||
}
|
||||
|
||||
groups.push({
|
||||
service,
|
||||
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
|
||||
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
|
||||
error: mailStore.getServiceFolderError(service.provider, service.identifier),
|
||||
})
|
||||
})
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// Handlers
|
||||
const handleFolderCreate = (service: ServiceObject, parentFolder: CollectionObject | null = null) => {
|
||||
mailUiStore.openCreateFolderDialog(service, parentFolder)
|
||||
}
|
||||
|
||||
const handleFolderEdit = (folder: CollectionObject) => {
|
||||
mailUiStore.openRenameFolderDialog(folder)
|
||||
}
|
||||
|
||||
const handleFolderDelete = (folder: CollectionObject) => {
|
||||
mailUiStore.openDeleteFolderDialog(folder)
|
||||
}
|
||||
|
||||
const handleFolderMove = (folder: CollectionObject) => {
|
||||
mailUiStore.openMoveFolderDialog(folder)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-list density="compact" nav>
|
||||
<!-- Tree View -->
|
||||
<FolderTreeView
|
||||
v-if="folderViewMode === 'tree'"
|
||||
:selected-folder="selectedFolder"
|
||||
:service-groups="serviceGroups"
|
||||
@select="emit('select', $event)"
|
||||
@create-folder="handleFolderCreate"
|
||||
@edit-folder="handleFolderEdit"
|
||||
@move-folder="handleFolderMove"
|
||||
@delete-folder="handleFolderDelete"
|
||||
/>
|
||||
|
||||
<!-- Page-based View -->
|
||||
<FolderPageView
|
||||
v-else
|
||||
:selected-folder="selectedFolder"
|
||||
:service-groups="serviceGroups"
|
||||
@select="emit('select', $event)"
|
||||
@create-folder="handleFolderCreate"
|
||||
@edit-folder="handleFolderEdit"
|
||||
@move-folder="handleFolderMove"
|
||||
@delete-folder="handleFolderDelete"
|
||||
/>
|
||||
|
||||
<!-- Empty state -->
|
||||
<v-list-item v-if="servicesStore.servicesEnabled.length === 0">
|
||||
<v-list-item-title class="text-center text-medium-emphasis">
|
||||
No mail accounts configured
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-list-item--active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useEditor } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { entityService } from '@MailManager/services'
|
||||
import type { EntityInterface } from '@MailManager/types/entity'
|
||||
import type { MessageInterface } from '@MailManager/types/message'
|
||||
import type { CollectionInterface } from '@MailManager/types/collection'
|
||||
import { MessageObject } from '@MailManager/models/message'
|
||||
import { EntityObject } from '@MailManager/models/entity'
|
||||
import type { CollectionObject } from '@MailManager/models'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import type { MessageAddressInterface } from '@MailManager/types/message'
|
||||
import { ComposerMode } from '@/types/composer'
|
||||
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
|
||||
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
|
||||
import ComposerEditor from '@/components/composer/ComposerEditor.vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
replyTo?: EntityInterface<MessageInterface> | null
|
||||
folder?: CollectionInterface | null
|
||||
mode: ComposerMode
|
||||
source?: EntityObject | MessageAddressInterface | null
|
||||
folder?: CollectionObject | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -24,9 +28,15 @@ const props = defineProps<Props>()
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
sent: []
|
||||
}>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const {
|
||||
composerSending: sending,
|
||||
composerSaving: saving,
|
||||
composerLastSaved: lastSaved,
|
||||
} = storeToRefs(mailStore)
|
||||
|
||||
// State
|
||||
const to = ref<string[]>([])
|
||||
const cc = ref<string[]>([])
|
||||
@@ -34,10 +44,6 @@ const bcc = ref<string[]>([])
|
||||
const subject = ref('')
|
||||
const showCc = ref(false)
|
||||
const showBcc = ref(false)
|
||||
const sending = ref(false)
|
||||
const saving = ref(false)
|
||||
const lastSaved = ref<Date | null>(null)
|
||||
const draftId = ref<string | null>(null)
|
||||
|
||||
// Auto-save timer
|
||||
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -65,25 +71,73 @@ const editor = useEditor({
|
||||
},
|
||||
})
|
||||
|
||||
// Initialize from reply-to message
|
||||
if (props.replyTo) {
|
||||
const replyMessage = new MessageObject(props.replyTo.properties)
|
||||
|
||||
const fromEmail = replyMessage.from?.address
|
||||
to.value = fromEmail ? [fromEmail] : []
|
||||
|
||||
const originalSubject = replyMessage.subject || ''
|
||||
subject.value = originalSubject.startsWith('Re:')
|
||||
? originalSubject
|
||||
: `Re: ${originalSubject}`
|
||||
|
||||
// Add quoted reply - prefer HTML content, fallback to text
|
||||
const originalBody = replyMessage.getHtmlContent() || replyMessage.getTextContent() || ''
|
||||
const senderName = replyMessage.from?.label || replyMessage.from?.address || 'Unknown'
|
||||
const quotedReply = `<p><br></p><p>On ${new Date(replyMessage.date || '').toLocaleString()}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`
|
||||
editor.value?.commands.setContent(quotedReply)
|
||||
function resetComposerFields() {
|
||||
to.value = []
|
||||
cc.value = []
|
||||
bcc.value = []
|
||||
subject.value = ''
|
||||
showCc.value = false
|
||||
showBcc.value = false
|
||||
editor.value?.commands.setContent('')
|
||||
}
|
||||
|
||||
function initializeComposerFromProps() {
|
||||
mailStore.resetComposerState()
|
||||
resetComposerFields()
|
||||
|
||||
if (props.mode === ComposerMode.Fresh) {
|
||||
if (props.source && 'address' in props.source) {
|
||||
// If source is an email address, pre-fill the "To" field
|
||||
to.value = [props.source.address]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.source instanceof EntityObject == false) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceMessage = props.source.properties
|
||||
const originalSubject = sourceMessage.subject || ''
|
||||
const originalBody = sourceMessage.getHtmlContent() || sourceMessage.getTextContent() || ''
|
||||
const senderName = sourceMessage.from?.label || sourceMessage.from?.address || 'Unknown'
|
||||
const sentAt = sourceMessage.sent || props.source.created || ''
|
||||
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
|
||||
|
||||
if (props.mode === ComposerMode.Reply) {
|
||||
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address
|
||||
to.value = fromEmail ? [fromEmail] : []
|
||||
subject.value = /^Re:/i.test(originalSubject)
|
||||
? originalSubject
|
||||
: `Re: ${originalSubject}`
|
||||
editor.value?.commands.setContent(
|
||||
`<p><br></p><p>---------- Original message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.mode === ComposerMode.Forward) {
|
||||
subject.value = /^Fwd:/i.test(originalSubject)
|
||||
? originalSubject
|
||||
: `Fwd: ${originalSubject}`
|
||||
editor.value?.commands.setContent(
|
||||
`<p><br></p><p>---------- Forwarded message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.mode, () => props.source, () => editor.value],
|
||||
([, , currentEditor]) => {
|
||||
if (!currentEditor) {
|
||||
return
|
||||
}
|
||||
|
||||
initializeComposerFromProps()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Computed
|
||||
const canSend = computed(() => {
|
||||
return to.value.length > 0 && subject.value.trim().length > 0
|
||||
@@ -110,10 +164,8 @@ const saveDraft = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const draftData = {
|
||||
await mailStore.saveComposerDraft(props.folder, {
|
||||
to: to.value,
|
||||
cc: cc.value,
|
||||
bcc: bcc.value,
|
||||
@@ -122,27 +174,9 @@ const saveDraft = async () => {
|
||||
html: editor.value?.getHTML() || '',
|
||||
text: editor.value?.getText() || '',
|
||||
},
|
||||
}
|
||||
|
||||
// Find drafts folder for this service
|
||||
// For now, we'll use the current folder's service
|
||||
// In a real implementation, you'd find the actual Drafts folder
|
||||
const response = await entityService.create({
|
||||
provider: props.folder.provider,
|
||||
service: props.folder.service,
|
||||
collection: props.folder.identifier, // Should be drafts folder ID
|
||||
properties: draftData,
|
||||
})
|
||||
|
||||
if (response) {
|
||||
draftId.value = String(response.identifier)
|
||||
}
|
||||
|
||||
lastSaved.value = new Date()
|
||||
} catch (error) {
|
||||
console.error('[MessageComposer] Failed to save draft:', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
console.error('[Mail][Composer] Failed to save draft:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,53 +207,32 @@ onBeforeUnmount(() => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
}
|
||||
mailStore.resetComposerState()
|
||||
editor.value?.destroy()
|
||||
})
|
||||
|
||||
// Handlers
|
||||
const handleClose = () => {
|
||||
mailStore.resetComposerState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!canSend.value || sending.value) return
|
||||
|
||||
sending.value = true
|
||||
|
||||
|
||||
try {
|
||||
await entityService.transmit({
|
||||
message: {
|
||||
to: to.value,
|
||||
cc: cc.value.length > 0 ? cc.value : undefined,
|
||||
bcc: bcc.value.length > 0 ? bcc.value : undefined,
|
||||
subject: subject.value,
|
||||
body: {
|
||||
html: editor.value?.getHTML() || '',
|
||||
text: editor.value?.getText() || '',
|
||||
},
|
||||
await mailStore.sendComposerMessage({
|
||||
to: to.value,
|
||||
cc: cc.value,
|
||||
bcc: bcc.value,
|
||||
subject: subject.value,
|
||||
body: {
|
||||
html: editor.value?.getHTML() || '',
|
||||
text: editor.value?.getText() || '',
|
||||
},
|
||||
})
|
||||
|
||||
// Delete draft if it was saved
|
||||
if (draftId.value && props.folder) {
|
||||
try {
|
||||
await entityService.delete({
|
||||
provider: props.folder.provider,
|
||||
service: props.folder.service,
|
||||
collection: props.folder.identifier,
|
||||
identifier: draftId.value,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[MessageComposer] Failed to delete draft:', error)
|
||||
}
|
||||
}
|
||||
|
||||
emit('sent')
|
||||
} catch (error) {
|
||||
console.error('[MessageComposer] Failed to send message:', error)
|
||||
alert('Failed to send message. Please try again.')
|
||||
} finally {
|
||||
sending.value = false
|
||||
console.error('[Mail][Composer] Failed to send message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,192 +261,61 @@ const removeLink = () => editor.value?.chain().focus().unsetLink().run()
|
||||
const isActive = (name: string, attrs?: any) => {
|
||||
return editor.value?.isActive(name, attrs) || false
|
||||
}
|
||||
|
||||
const toggleLink = () => {
|
||||
if (isActive('link')) {
|
||||
removeLink()
|
||||
return
|
||||
}
|
||||
|
||||
setLink()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-composer">
|
||||
<!-- Toolbar -->
|
||||
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="handleClose"
|
||||
icon="mdi-close"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
|
||||
</v-btn>
|
||||
<ComposerToolbar
|
||||
:mode="mode"
|
||||
:save-status="saveStatus"
|
||||
:can-send="canSend"
|
||||
:sending="sending"
|
||||
@close="handleClose"
|
||||
@send="handleSend"
|
||||
/>
|
||||
|
||||
<v-toolbar-title>
|
||||
{{ replyTo ? 'Reply' : 'New Message' }}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
|
||||
{{ saveStatus }}
|
||||
</span>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
:disabled="!canSend"
|
||||
:loading="sending"
|
||||
@click="handleSend"
|
||||
prepend-icon="mdi-send"
|
||||
>
|
||||
Send
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<!-- Composer content -->
|
||||
<div class="composer-content">
|
||||
<!-- Recipients -->
|
||||
<div class="composer-fields pa-4">
|
||||
<v-combobox
|
||||
v-model="to"
|
||||
label="To"
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="toggleCc"
|
||||
class="mr-1"
|
||||
>
|
||||
Cc
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="toggleBcc"
|
||||
>
|
||||
Bcc
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-combobox>
|
||||
|
||||
<v-combobox
|
||||
v-if="showCc"
|
||||
v-model="cc"
|
||||
label="Cc"
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<v-combobox
|
||||
v-if="showBcc"
|
||||
v-model="bcc"
|
||||
label="Bcc"
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="subject"
|
||||
label="Subject"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</div>
|
||||
<ComposerRecipients
|
||||
:to="to"
|
||||
:cc="cc"
|
||||
:bcc="bcc"
|
||||
:subject="subject"
|
||||
:show-cc="showCc"
|
||||
:show-bcc="showBcc"
|
||||
@update:to="to = $event"
|
||||
@update:cc="cc = $event"
|
||||
@update:bcc="bcc = $event"
|
||||
@update:subject="subject = $event"
|
||||
@toggle:cc="toggleCc"
|
||||
@toggle:bcc="toggleBcc"
|
||||
/>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- Editor toolbar -->
|
||||
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isActive('bold') }"
|
||||
@click="toggleBold"
|
||||
>
|
||||
<v-icon>mdi-format-bold</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isActive('italic') }"
|
||||
@click="toggleItalic"
|
||||
>
|
||||
<v-icon>mdi-format-italic</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isActive('underline') }"
|
||||
@click="toggleUnderline"
|
||||
>
|
||||
<v-icon>mdi-format-underline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-divider vertical class="mx-2" />
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isActive('bulletList') }"
|
||||
@click="toggleBulletList"
|
||||
>
|
||||
<v-icon>mdi-format-list-bulleted</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isActive('orderedList') }"
|
||||
@click="toggleOrderedList"
|
||||
>
|
||||
<v-icon>mdi-format-list-numbered</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-divider vertical class="mx-2" />
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isActive('link') }"
|
||||
@click="isActive('link') ? removeLink() : setLink()"
|
||||
>
|
||||
<v-icon>mdi-link</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
>
|
||||
<v-icon>mdi-paperclip</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="editor-container">
|
||||
<editor-content :editor="editor" />
|
||||
</div>
|
||||
<ComposerEditor
|
||||
:editor="editor"
|
||||
:is-bold-active="isActive('bold')"
|
||||
:is-italic-active="isActive('italic')"
|
||||
:is-underline-active="isActive('underline')"
|
||||
:is-bullet-list-active="isActive('bulletList')"
|
||||
:is-ordered-list-active="isActive('orderedList')"
|
||||
:is-link-active="isActive('link')"
|
||||
@bold="toggleBold"
|
||||
@italic="toggleItalic"
|
||||
@underline="toggleUnderline"
|
||||
@bullet-list="toggleBulletList"
|
||||
@ordered-list="toggleOrderedList"
|
||||
@link="toggleLink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -457,65 +339,4 @@ const isActive = (name: string, attrs?: any) => {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.composer-fields {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background-color: rgb(var(--v-theme-background));
|
||||
}
|
||||
|
||||
.v-btn--active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
:deep(.tiptap-editor) {
|
||||
outline: none;
|
||||
min-height: 300px;
|
||||
|
||||
p.is-editor-empty:first-child::before {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.3;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-left: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid rgb(var(--v-border-color));
|
||||
padding-left: 1em;
|
||||
margin-left: 0;
|
||||
margin-bottom: 0.5em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import type { EntityIdentifier } from '@MailManager/types/common'
|
||||
import type { EntityObject } from '@MailManager/models'
|
||||
import type { CollectionObject } from '@MailManager/models/collection'
|
||||
import RecipientDetails from '@/components/common/RecipientDetails.vue'
|
||||
import MessageListItemMenu from '@/components/MessageListItemMenu.vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
@@ -23,62 +25,70 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
open: [message: EntityObject]
|
||||
toggleSelection: [message: EntityObject]
|
||||
activateSelectionMode: [message: EntityObject]
|
||||
toggleSelectAll: [value: boolean]
|
||||
clearSelection: []
|
||||
moveSelection: []
|
||||
deleteSelection: []
|
||||
reply: [message: EntityObject]
|
||||
forward: [message: EntityObject]
|
||||
move: [message: EntityObject]
|
||||
delete: [message: EntityObject]
|
||||
flag: [message: EntityObject, flag: string, value: boolean]
|
||||
selectionMode: [message: EntityObject]
|
||||
selectionToggleOne: [message: EntityObject]
|
||||
selectionToggleAll: [value: boolean]
|
||||
selectionClear: []
|
||||
selectionMove: []
|
||||
selectionDelete: []
|
||||
selectionFlag: [flag: string, value: boolean]
|
||||
}>()
|
||||
|
||||
const longPressTimer = ref<number | null>(null)
|
||||
const longPressActivated = ref(false)
|
||||
const suppressNextClick = ref(false)
|
||||
const LONG_PRESS_MS = 450
|
||||
|
||||
const contextMenuVisible = ref(false)
|
||||
const contextMenuTarget = ref<[number, number] | undefined>(undefined)
|
||||
const contextMenuMessage = ref<EntityObject | null>(null)
|
||||
const LONG_PRESS_MS = 400
|
||||
const selectedIdSet = computed(() => new Set(props.selectionList))
|
||||
const selectionCount = computed(() => props.selectionList.length ?? 0)
|
||||
|
||||
// Sorted messages (newest first)
|
||||
const sortedMessages = computed(() => {
|
||||
return [...props.messages].sort((a, b) => {
|
||||
const dateA = timeStamp(a) ?? 0
|
||||
const dateB = timeStamp(b) ?? 0
|
||||
return dateB - dateA
|
||||
})
|
||||
})
|
||||
|
||||
const isOpened = (message: EntityObject): boolean => {
|
||||
if (!props.selectedMessage) return false
|
||||
return (
|
||||
message.provider === props.selectedMessage.provider &&
|
||||
message.service === props.selectedMessage.service &&
|
||||
message.collection === props.selectedMessage.collection &&
|
||||
message.identifier === props.selectedMessage.identifier
|
||||
)
|
||||
return (message.identifier === props.selectedMessage.identifier)
|
||||
}
|
||||
|
||||
const isSelected = (message: EntityObject): boolean => {
|
||||
return selectedIdSet.value.has(
|
||||
`${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier,
|
||||
)
|
||||
return selectedIdSet.value.has(message.identifier)
|
||||
}
|
||||
|
||||
// Check if message is unread
|
||||
const isUnread = (message: EntityObject): boolean => {
|
||||
return !message.properties.flags?.read
|
||||
const timeStamp = (message: EntityObject): number | null => {
|
||||
const timestamp = message.properties.received
|
||||
|| message.properties.sent
|
||||
|| message.modified
|
||||
|| message.created
|
||||
|| null
|
||||
|
||||
if (!timestamp) {
|
||||
return null
|
||||
}
|
||||
|
||||
const timeValue = new Date(timestamp).getTime()
|
||||
return Number.isNaN(timeValue) ? null : timeValue
|
||||
}
|
||||
|
||||
// Check if message is flagged
|
||||
const isFlagged = (message: EntityObject): boolean => {
|
||||
return message.properties.flags?.flagged || false
|
||||
}
|
||||
|
||||
const currentMessages = computed(() => props.messages ?? [])
|
||||
|
||||
const selectionCount = computed(() => props.selectionList.length)
|
||||
|
||||
const hasSelection = computed(() => selectionCount.value > 0)
|
||||
|
||||
const allCurrentMessagesSelected = computed(() => {
|
||||
return currentMessages.value.length > 0 && currentMessages.value.every(message => isSelected(message))
|
||||
})
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (date: Date | string | null | undefined): string => {
|
||||
const formatDate = (date: Date | string | number | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
|
||||
const messageDate = new Date(date)
|
||||
if (Number.isNaN(messageDate.getTime())) return ''
|
||||
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const yesterday = new Date(today)
|
||||
@@ -115,26 +125,20 @@ const formatDate = (date: Date | string | null | undefined): string => {
|
||||
})
|
||||
}
|
||||
|
||||
// Truncate text
|
||||
const truncate = (text: string | null | undefined, length: number = 100): string => {
|
||||
if (!text) return ''
|
||||
return text.length > length ? text.substring(0, length) + '...' : text
|
||||
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
|
||||
return event.target instanceof Element && event.target.closest('.message-selection-checkbox, .message-item-menu-trigger, .message-item-menu-content') !== null
|
||||
}
|
||||
|
||||
const handleSelectionToggle = (message: EntityObject) => {
|
||||
emit('toggleSelection', message)
|
||||
const handleSelectionToggleOne = (message: EntityObject) => {
|
||||
emit('selectionToggleOne', message)
|
||||
}
|
||||
|
||||
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
|
||||
if (event.shiftKey && !props.selectionMode) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('activateSelectionMode', message)
|
||||
return
|
||||
}
|
||||
const handleSelectionToggleAll = (value: boolean | null) => {
|
||||
emit('selectionToggleAll', value === true)
|
||||
}
|
||||
|
||||
if (longPressActivated.value) {
|
||||
longPressActivated.value = false
|
||||
const handleMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
|
||||
if (isSelectionControlClick(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -143,15 +147,31 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey && !props.selectionMode) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('selectionMode', message)
|
||||
return
|
||||
}
|
||||
|
||||
if (longPressActivated.value) {
|
||||
longPressActivated.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (props.selectionMode) {
|
||||
emit('toggleSelection', message)
|
||||
emit('selectionToggleOne', message)
|
||||
return
|
||||
}
|
||||
|
||||
emit('open', message)
|
||||
}
|
||||
|
||||
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
|
||||
const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
|
||||
if (isSelectionControlClick(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.shiftKey || props.selectionMode) {
|
||||
return
|
||||
}
|
||||
@@ -159,14 +179,44 @@ const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
suppressNextClick.value = true
|
||||
emit('activateSelectionMode', message)
|
||||
emit('selectionMode', message)
|
||||
}
|
||||
|
||||
const clearLongPressTimer = () => {
|
||||
if (longPressTimer.value !== null) {
|
||||
window.clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
const openContextMenu = (event: MouseEvent, message: EntityObject) => {
|
||||
if (isSelectionControlClick(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
contextMenuMessage.value = message
|
||||
contextMenuTarget.value = [event.clientX, event.clientY]
|
||||
contextMenuVisible.value = true
|
||||
}
|
||||
|
||||
const handleContextMenuReply = (message: EntityObject) => {
|
||||
emit('reply', message)
|
||||
}
|
||||
|
||||
const handleContextMenuForward = (message: EntityObject) => {
|
||||
emit('forward', message)
|
||||
}
|
||||
|
||||
const handleContextMenuMove = (message: EntityObject) => {
|
||||
emit('move', message)
|
||||
}
|
||||
|
||||
const handleContextMenuDelete = (message: EntityObject) => {
|
||||
emit('delete', message)
|
||||
}
|
||||
|
||||
const handleContextMenuFlag = (message: EntityObject, flag: string, value: boolean) => {
|
||||
emit('flag', message, flag, value)
|
||||
}
|
||||
|
||||
const getContextMenuMessage = (): EntityObject => {
|
||||
return contextMenuMessage.value as EntityObject
|
||||
}
|
||||
|
||||
const handleTouchStart = (message: EntityObject) => {
|
||||
@@ -175,9 +225,9 @@ const handleTouchStart = (message: EntityObject) => {
|
||||
|
||||
longPressTimer.value = window.setTimeout(() => {
|
||||
if (!props.selectionMode) {
|
||||
emit('activateSelectionMode', message)
|
||||
emit('selectionMode', message)
|
||||
} else {
|
||||
emit('toggleSelection', message)
|
||||
emit('selectionToggleOne', message)
|
||||
}
|
||||
|
||||
longPressActivated.value = true
|
||||
@@ -193,35 +243,31 @@ const handleTouchMove = () => {
|
||||
clearLongPressTimer()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearLongPressTimer()
|
||||
})
|
||||
|
||||
const handleSelectAllToggle = (value: boolean | null) => {
|
||||
emit('toggleSelectAll', value === true)
|
||||
const clearLongPressTimer = () => {
|
||||
if (longPressTimer.value !== null) {
|
||||
window.clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Sorted messages (newest first)
|
||||
const sortedMessages = computed(() => {
|
||||
return [...currentMessages.value].sort((a, b) => {
|
||||
const dateA = a.properties.date ? new Date(a.properties.date).getTime() : 0
|
||||
const dateB = b.properties.date ? new Date(b.properties.date).getTime() : 0
|
||||
return dateB - dateA
|
||||
})
|
||||
})
|
||||
const handleFlag = (flag: string, value: boolean) => {
|
||||
if (props.selectionMode && selectionCount.value > 0) {
|
||||
emit('selectionFlag', flag, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Read/Unread counts from collection properties
|
||||
const unreadCount = computed(() => {
|
||||
return props.selectedCollection?.properties.unread ?? 0
|
||||
})
|
||||
const handleRecipientClick = (message: EntityObject) => {
|
||||
if (props.selectionMode) {
|
||||
emit('selectionToggleOne', message)
|
||||
return
|
||||
}
|
||||
|
||||
const totalCount = computed(() => {
|
||||
return props.selectedCollection?.properties.total ?? 0
|
||||
})
|
||||
emit('open', message)
|
||||
}
|
||||
|
||||
// True only when the collection explicitly provides total/unread counts
|
||||
const hasCountData = computed(() => {
|
||||
return props.selectedCollection?.properties.total != null
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearLongPressTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -230,12 +276,12 @@ const hasCountData = computed(() => {
|
||||
<!-- Header with folder name and counts -->
|
||||
<div v-if="selectedCollection" class="message-list-header">
|
||||
<div class="message-list-heading">
|
||||
<h2 class="text-h6">{{ selectedCollection.properties.label || 'Folder' }}</h2>
|
||||
<h2 class="text-h6">{{ selectedCollection?.properties.label || 'Folder' }}</h2>
|
||||
<div class="folder-counts text-caption text-medium-emphasis">
|
||||
<span v-if="hasCountData">
|
||||
<span class="unread-count">{{ unreadCount }}</span>
|
||||
<span v-if="selectedCollection?.properties.total != null">
|
||||
<span class="unread-count">{{ selectedCollection?.properties.unread ?? 0 }}</span>
|
||||
<span class="mx-1">/</span>
|
||||
<span>{{ totalCount }}</span>
|
||||
<span>{{ selectedCollection?.properties.total ?? 0 }}</span>
|
||||
</span>
|
||||
<span v-else-if="messages.length > 0">
|
||||
{{ messages.length }} loaded
|
||||
@@ -246,15 +292,12 @@ const hasCountData = computed(() => {
|
||||
<div v-if="selectionMode && messages.length > 0" class="selection-summary">
|
||||
<div class="selection-controls">
|
||||
<v-checkbox-btn
|
||||
:model-value="allCurrentMessagesSelected"
|
||||
:indeterminate="hasSelection && !allCurrentMessagesSelected"
|
||||
:model-value="selectionCount !== 0"
|
||||
:indeterminate="selectionCount > 0 && selectionCount !== messages.length"
|
||||
density="compact"
|
||||
hide-details
|
||||
@update:model-value="handleSelectAllToggle"
|
||||
@update:model-value="handleSelectionToggleAll"
|
||||
/>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ selectionCount > 0 ? `${selectionCount} selected` : 'Select all loaded' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="selection-actions">
|
||||
@@ -262,8 +305,8 @@ const hasCountData = computed(() => {
|
||||
size="small"
|
||||
icon="mdi-folder-move-outline"
|
||||
variant="text"
|
||||
:disabled="!hasSelection"
|
||||
@click="emit('moveSelection')"
|
||||
:disabled="selectionCount === 0"
|
||||
@click="emit('selectionMove')"
|
||||
>
|
||||
<v-icon>mdi-folder-move-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Move Selected</v-tooltip>
|
||||
@@ -272,18 +315,37 @@ const hasCountData = computed(() => {
|
||||
size="small"
|
||||
icon="mdi-delete-outline"
|
||||
variant="text"
|
||||
:disabled="!hasSelection"
|
||||
@click="emit('deleteSelection')"
|
||||
:disabled="selectionCount === 0"
|
||||
@click="emit('selectionDelete')"
|
||||
>
|
||||
<v-icon>mdi-delete-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Delete Selected</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
icon="mdi-email-open-outline"
|
||||
variant="text"
|
||||
:disabled="selectionCount === 0"
|
||||
@click="handleFlag('read', true)"
|
||||
>
|
||||
<v-icon>mdi-email-open-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Mark as Read</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
icon="mdi-email-outline"
|
||||
variant="text"
|
||||
:disabled="selectionCount === 0"
|
||||
@click="handleFlag('read', false)"
|
||||
>
|
||||
<v-icon>mdi-email-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Mark as Unread</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
:disabled="!hasSelection"
|
||||
@click="emit('clearSelection')"
|
||||
@click="emit('selectionClear')"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Clear Selected</v-tooltip>
|
||||
@@ -303,7 +365,7 @@ const hasCountData = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="currentMessages.length === 0" class="pa-8 text-center">
|
||||
<div v-else-if="messages.length === 0" class="pa-8 text-center">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-email-outline</v-icon>
|
||||
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
@@ -320,16 +382,17 @@ const hasCountData = computed(() => {
|
||||
>
|
||||
<template v-slot:default="{ item: message }">
|
||||
<v-list-item
|
||||
:key="`${message.provider}:${message.service}:${message.collection}:${message.identifier}`"
|
||||
:key="message.identifier"
|
||||
class="message-item"
|
||||
:class="{
|
||||
'opened': isOpened(message),
|
||||
'selected': isSelected(message),
|
||||
'selection-mode': selectionMode,
|
||||
'unread': isUnread(message)
|
||||
'unread': !message.properties.isRead
|
||||
}"
|
||||
@mousedown="handleMessageMouseDown($event, message)"
|
||||
@click="handleMessageMouseClick($event, message)"
|
||||
@mousedown="handleMouseDown($event, message)"
|
||||
@click="handleMouseClick($event, message)"
|
||||
@contextmenu="openContextMenu($event, message)"
|
||||
@touchstart.passive="handleTouchStart(message)"
|
||||
@touchend="handleTouchEnd"
|
||||
@touchcancel="handleTouchEnd"
|
||||
@@ -344,7 +407,7 @@ const hasCountData = computed(() => {
|
||||
density="compact"
|
||||
hide-details
|
||||
@click.stop
|
||||
@update:model-value="handleSelectionToggle(message)"
|
||||
@update:model-value="handleSelectionToggleOne(message)"
|
||||
/>
|
||||
|
||||
<v-avatar size="40" color="primary">
|
||||
@@ -357,10 +420,17 @@ const hasCountData = computed(() => {
|
||||
|
||||
<v-list-item-title class="d-flex align-center">
|
||||
<span class="flex-grow-1 text-truncate">
|
||||
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }}
|
||||
<RecipientDetails
|
||||
:address="message.properties.from"
|
||||
@clicked="handleRecipientClick(message)"
|
||||
>
|
||||
<template #default="{ label }">
|
||||
<span class="message-person-link text-truncate">{{ label }}</span>
|
||||
</template>
|
||||
</RecipientDetails>
|
||||
</span>
|
||||
<span class="text-caption text-medium-emphasis ml-2">
|
||||
{{ formatDate(message.properties.date) }}
|
||||
{{ formatDate(timeStamp(message)) }}
|
||||
</span>
|
||||
</v-list-item-title>
|
||||
|
||||
@@ -369,32 +439,56 @@ const hasCountData = computed(() => {
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<v-list-item-subtitle class="text-caption text-truncate">
|
||||
{{ truncate(message.properties.snippet, 80) }}
|
||||
{{ '' }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template v-slot:append>
|
||||
<div class="d-flex flex-column align-center">
|
||||
<v-icon
|
||||
v-if="isFlagged(message)"
|
||||
size="small"
|
||||
color="warning"
|
||||
class="mb-1"
|
||||
>
|
||||
mdi-star
|
||||
</v-icon>
|
||||
<v-icon
|
||||
v-if="message.properties.attachments && message.properties.attachments.length > 0"
|
||||
size="small"
|
||||
color="grey"
|
||||
>
|
||||
mdi-paperclip
|
||||
</v-icon>
|
||||
<div class="message-item-append">
|
||||
<div class="d-flex flex-column align-center">
|
||||
<v-icon
|
||||
v-if="message.properties.isFlagged"
|
||||
size="small"
|
||||
color="warning"
|
||||
class="mb-1"
|
||||
>
|
||||
mdi-star
|
||||
</v-icon>
|
||||
<v-icon
|
||||
v-if="message.properties.hasAttachments"
|
||||
size="small"
|
||||
color="grey"
|
||||
>
|
||||
mdi-paperclip
|
||||
</v-icon>
|
||||
</div>
|
||||
|
||||
<MessageListItemMenu
|
||||
:message="message"
|
||||
@reply="handleContextMenuReply"
|
||||
@forward="handleContextMenuForward"
|
||||
@move="handleContextMenuMove"
|
||||
@delete="handleContextMenuDelete"
|
||||
@flag="handleContextMenuFlag"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
|
||||
<MessageListItemMenu
|
||||
v-if="contextMenuMessage"
|
||||
v-model="contextMenuVisible"
|
||||
:message="getContextMenuMessage()"
|
||||
:target="contextMenuTarget"
|
||||
:show-activator="false"
|
||||
@reply="handleContextMenuReply"
|
||||
@forward="handleContextMenuForward"
|
||||
@move="handleContextMenuMove"
|
||||
@delete="handleContextMenuDelete"
|
||||
@flag="handleContextMenuFlag"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -479,6 +573,31 @@ const hasCountData = computed(() => {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.message-item-append {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.message-item :deep(.message-item-menu-trigger) {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
|
||||
.message-item:hover :deep(.message-item-menu-trigger),
|
||||
.message-item:focus-within :deep(.message-item-menu-trigger),
|
||||
.message-item.opened :deep(.message-item-menu-trigger),
|
||||
.message-item.selected :deep(.message-item-menu-trigger),
|
||||
.message-item :deep(.message-item-menu-trigger[aria-expanded='true']) {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.message-item:hover {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
@@ -497,6 +616,33 @@ const hasCountData = computed(() => {
|
||||
:deep(.v-list-item-subtitle:first-of-type) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.v-list-item-title),
|
||||
:deep(.v-list-item-subtitle:first-of-type),
|
||||
:deep(.v-list-item-title .text-caption) {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
}
|
||||
|
||||
.message-item.unread:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.message-item.unread.selected:not(.opened) {
|
||||
background-color: rgba(var(--v-theme-primary), 0.14);
|
||||
}
|
||||
|
||||
.message-person-link {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
margin: -1px -4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.message-person-link:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
|
||||
138
src/components/MessageListItemMenu.vue
Normal file
138
src/components/MessageListItemMenu.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, ref } from 'vue'
|
||||
import type { EntityObject } from '@MailManager/models'
|
||||
|
||||
interface Props {
|
||||
message: EntityObject
|
||||
modelValue?: boolean
|
||||
target?: [number, number] | undefined
|
||||
showActivator?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
target: undefined,
|
||||
showActivator: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
reply: [message: EntityObject]
|
||||
forward: [message: EntityObject]
|
||||
move: [message: EntityObject]
|
||||
delete: [message: EntityObject]
|
||||
flag: [message: EntityObject, flag: string, value: boolean]
|
||||
}>()
|
||||
|
||||
const isRead = computed(() => props.message.properties.isRead === true)
|
||||
const isFlagged = computed(() => props.message.properties.isFlagged === true)
|
||||
const localIsOpen = ref(false)
|
||||
const instance = getCurrentInstance()
|
||||
const isControlled = computed(() => {
|
||||
const vnodeProps = instance?.vnode.props ?? {}
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(vnodeProps, 'modelValue')
|
||||
|| Object.prototype.hasOwnProperty.call(vnodeProps, 'onUpdate:modelValue')
|
||||
})
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => (isControlled.value ? props.modelValue ?? false : localIsOpen.value),
|
||||
set: (value: boolean) => {
|
||||
if (!isControlled.value) {
|
||||
localIsOpen.value = value
|
||||
}
|
||||
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const handleFlag = (flag: string, value: boolean) => {
|
||||
emit('flag', props.message, flag, value)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleReply = () => {
|
||||
emit('reply', props.message)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleForward = () => {
|
||||
emit('forward', props.message)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleMove = () => {
|
||||
emit('move', props.message)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.message)
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-menu
|
||||
v-model="isOpen"
|
||||
:target="target"
|
||||
:absolute="target !== undefined"
|
||||
:close-on-content-click="true"
|
||||
location="bottom end"
|
||||
content-class="message-item-menu-content"
|
||||
>
|
||||
<template v-if="showActivator" #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
class="message-item-menu-trigger"
|
||||
icon="mdi-dots-vertical"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
>
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">More Actions</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list class="message-item-menu" density="compact" min-width="220">
|
||||
<v-list-item
|
||||
:prepend-icon="isRead ? 'mdi-email-outline' : 'mdi-email-open-outline'"
|
||||
@click="handleFlag('read', !isRead)"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ isRead ? 'Mark as unread' : 'Mark as read' }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
:prepend-icon="isFlagged ? 'mdi-star' : 'mdi-star-outline'"
|
||||
@click="handleFlag('flagged', !isFlagged)"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ isFlagged ? 'Unstar' : 'Star' }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider class="my-1" />
|
||||
|
||||
<v-list-item prepend-icon="mdi-folder-move-outline" @click="handleMove">
|
||||
<v-list-item-title>Move to...</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item prepend-icon="mdi-reply" @click="handleReply">
|
||||
<v-list-item-title>Reply</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item prepend-icon="mdi-share" @click="handleForward">
|
||||
<v-list-item-title>Forward</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider class="my-1" />
|
||||
|
||||
<v-list-item prepend-icon="mdi-delete-outline" @click="handleDelete">
|
||||
<v-list-item-title>Delete</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useUser } from '@KTXC'
|
||||
import type { EntityObject, MessageObject } from '@MailManager/models'
|
||||
import { SecurityLevel } from '@/utile/emailSanitizer'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import ReaderEmpty from './reader/ReaderEmpty.vue'
|
||||
import ReaderToolbar from './reader/ReaderToolbar.vue'
|
||||
import ReaderHeader from './reader/ReaderHeader.vue'
|
||||
@@ -25,6 +26,7 @@ const emit = defineEmits<{
|
||||
|
||||
// User settings
|
||||
const { getSetting } = useUser()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
// Per-message overrides
|
||||
const allowImages = ref(false)
|
||||
@@ -94,6 +96,14 @@ const handleMove = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (partIndex?: number) => {
|
||||
if (!props.entity) {
|
||||
return
|
||||
}
|
||||
|
||||
await mailStore.downloadMessage(props.entity, partIndex)
|
||||
}
|
||||
|
||||
const handleFlag = () => {
|
||||
if (props.entity) {
|
||||
emit('flag', props.entity)
|
||||
@@ -125,13 +135,17 @@ const handleCompose = () => {
|
||||
@move="handleMove"
|
||||
@delete="handleDelete"
|
||||
@flag="handleFlag"
|
||||
@download="handleDownload()"
|
||||
@toggle-images="toggleImages"
|
||||
@set-security-level="setSecurityLevel"
|
||||
/>
|
||||
|
||||
<!-- Message content -->
|
||||
<div class="message-content">
|
||||
<ReaderHeader :message="message!" />
|
||||
<ReaderHeader
|
||||
:entity="props.entity"
|
||||
@download-attachment="handleDownload"
|
||||
/>
|
||||
|
||||
<v-divider />
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||
import { CollectionPropertiesObject } from '@MailManager/models/collection'
|
||||
import type { CollectionObject } from '@MailManager/models/collection'
|
||||
import type { ServiceObject } from '@MailManager/models'
|
||||
|
||||
@@ -9,23 +7,25 @@ interface Props {
|
||||
modelValue: boolean
|
||||
service: ServiceObject
|
||||
folder: CollectionObject
|
||||
allFolders?: CollectionObject[]
|
||||
parentFolderLabel?: string
|
||||
validateName?: (name: string) => string[]
|
||||
loading?: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
allFolders: () => []
|
||||
parentFolderLabel: 'Root',
|
||||
validateName: () => [],
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
updated: [folder: CollectionObject]
|
||||
confirm: [folderName: string]
|
||||
}>()
|
||||
|
||||
const collectionsStore = useCollectionsStore()
|
||||
|
||||
const folderName = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const validationErrors = ref<string[]>([])
|
||||
|
||||
const dialogValue = computed({
|
||||
@@ -37,74 +37,13 @@ const isValid = computed(() => {
|
||||
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
|
||||
})
|
||||
|
||||
const parentFolderLabel = computed(() => {
|
||||
const parentId = props.folder.collection
|
||||
if (parentId === null || parentId === undefined) return 'Root'
|
||||
|
||||
const parent = props.allFolders.find(
|
||||
f =>
|
||||
String(f.identifier) === String(parentId) &&
|
||||
f.provider === props.folder.provider &&
|
||||
String(f.service) === String(props.folder.service)
|
||||
)
|
||||
|
||||
return parent?.properties.label || 'Root'
|
||||
})
|
||||
|
||||
const validateFolderName = (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 (props.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
|
||||
}
|
||||
|
||||
const checkDuplicateName = (name: string): boolean => {
|
||||
const parentId = props.folder.collection ?? null
|
||||
return props.allFolders.some(f => {
|
||||
if (String(f.identifier) === String(props.folder.identifier)) return false
|
||||
return (
|
||||
f.properties.label === name &&
|
||||
String(f.collection) === String(parentId) &&
|
||||
f.provider === props.folder.provider &&
|
||||
String(f.service) === String(props.folder.service)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
watch(folderName, (newName) => {
|
||||
errorMessage.value = ''
|
||||
validationErrors.value = validateFolderName(newName)
|
||||
|
||||
if (validationErrors.value.length === 0 && newName.trim().length > 0 && checkDuplicateName(newName)) {
|
||||
validationErrors.value.push('A folder with this name already exists in this location')
|
||||
}
|
||||
validationErrors.value = props.validateName(newName)
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
folderName.value = props.folder.properties.label || ''
|
||||
errorMessage.value = ''
|
||||
validationErrors.value = []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
watch(dialogValue, (isOpen) => {
|
||||
@@ -114,54 +53,27 @@ watch(dialogValue, (isOpen) => {
|
||||
}, { immediate: true })
|
||||
|
||||
const handleRename = async () => {
|
||||
const errors = validateFolderName(folderName.value)
|
||||
const errors = props.validateName(folderName.value)
|
||||
|
||||
if (errors.length > 0) {
|
||||
validationErrors.value = errors
|
||||
return
|
||||
}
|
||||
|
||||
if (checkDuplicateName(folderName.value)) {
|
||||
validationErrors.value = ['A folder with this name already exists in this location']
|
||||
return
|
||||
}
|
||||
|
||||
const newName = folderName.value.trim()
|
||||
if (newName === props.folder.properties.label) {
|
||||
dialogValue.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const properties = new CollectionPropertiesObject()
|
||||
properties.label = newName
|
||||
properties.rank = props.folder.properties.rank ?? 0
|
||||
properties.subscribed = props.folder.properties.subscribed ?? true
|
||||
|
||||
const updatedFolder = await collectionsStore.update(
|
||||
props.folder.provider,
|
||||
props.folder.service,
|
||||
props.folder.identifier,
|
||||
properties
|
||||
)
|
||||
|
||||
emit('updated', updatedFolder)
|
||||
dialogValue.value = false
|
||||
resetForm()
|
||||
} catch (error: any) {
|
||||
console.error('[RenameFolderDialog] Failed to rename folder:', error)
|
||||
errorMessage.value = error.message || 'Failed to rename folder. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
emit('confirm', newName)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogValue.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
192
src/components/common/RecipientDetails.vue
Normal file
192
src/components/common/RecipientDetails.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useSnackbar } from '@KTXC'
|
||||
import type { MessageAddressInterface } from '@MailManager/types/message'
|
||||
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||
import { ComposerMode } from '@/types/composer'
|
||||
|
||||
interface Props {
|
||||
address?: MessageAddressInterface | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
clicked: []
|
||||
}>()
|
||||
|
||||
const mailUiStore = useMailUiStore()
|
||||
const { showSnackbar } = useSnackbar()
|
||||
|
||||
const recipientLabel = computed(() => props.address?.label?.trim() || '')
|
||||
const recipientAddress = computed(() => props.address?.address?.trim() || '')
|
||||
const displayLabel = computed(() => recipientLabel.value || recipientAddress.value || 'Unknown Sender')
|
||||
const formattedAddress = computed(() => {
|
||||
if (recipientLabel.value && recipientAddress.value && recipientLabel.value !== recipientAddress.value) {
|
||||
return `${recipientLabel.value} <${recipientAddress.value}>`
|
||||
}
|
||||
|
||||
return recipientAddress.value || recipientLabel.value
|
||||
})
|
||||
const hasEmail = computed(() => recipientAddress.value.length > 0)
|
||||
|
||||
const showCopyResult = (message: string, color: 'success' | 'error') => {
|
||||
showSnackbar({ message, color })
|
||||
}
|
||||
|
||||
const copy = async (value: string, label: string) => {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
showCopyResult(`${label} copied`, 'success')
|
||||
} catch (error) {
|
||||
console.error('[Mail][RecipientDetails] Failed to copy text:', error)
|
||||
showCopyResult(`Unable to copy ${label.toLowerCase()}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompose = () => {
|
||||
if (!props.address) {
|
||||
return
|
||||
}
|
||||
|
||||
mailUiStore.openComposer(props.address, ComposerMode.Fresh)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
emit('clicked')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-menu
|
||||
:open-on-hover="true"
|
||||
:open-on-click="false"
|
||||
:open-delay="1000"
|
||||
location="bottom start"
|
||||
transition="slide-y-transition"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<span
|
||||
v-bind="activatorProps"
|
||||
class="address-activator"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot
|
||||
:label="displayLabel"
|
||||
:name="recipientLabel"
|
||||
:email="recipientAddress"
|
||||
>
|
||||
{{ displayLabel }}
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<v-card class="address-card" elevation="8" rounded="lg">
|
||||
<div class="address-card-header">
|
||||
<v-avatar size="40" color="primary">
|
||||
<span class="text-white text-subtitle-2">
|
||||
{{ displayLabel[0]?.toUpperCase() || 'U' }}
|
||||
</span>
|
||||
</v-avatar>
|
||||
|
||||
<div class="address-card-meta">
|
||||
<div class="text-subtitle-2 font-weight-medium">{{ displayLabel }}</div>
|
||||
<div v-if="recipientAddress" class="text-body-2 text-medium-emphasis">{{ recipientAddress }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
|
||||
<div class="address-card-actions">
|
||||
<v-btn
|
||||
class="address-action-button"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-pencil"
|
||||
:disabled="!hasEmail"
|
||||
@click="handleCompose"
|
||||
>
|
||||
Send Email
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="address-action-button"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="!hasEmail"
|
||||
@click="copy(recipientAddress, 'Email address')"
|
||||
>
|
||||
<v-icon>mdi-content-copy</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Copy Email</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="address-action-button"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="!formattedAddress"
|
||||
@click="copy(formattedAddress, 'Address')"
|
||||
>
|
||||
<v-icon>mdi-card-account-details-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Copy Address</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="recipientLabel"
|
||||
class="address-action-button"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="copy(recipientLabel, 'Name')"
|
||||
>
|
||||
<v-icon>mdi-account-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Copy Name</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.address-activator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.address-card {
|
||||
width: min(320px, calc(100vw - 32px));
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.address-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.address-card-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.address-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.address-card-actions :deep(.address-action-button) {
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.address-card-actions :deep(.address-action-button:first-child) {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
</style>
|
||||
184
src/components/composer/ComposerEditor.vue
Normal file
184
src/components/composer/ComposerEditor.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import { EditorContent, type Editor } from '@tiptap/vue-3'
|
||||
|
||||
defineProps({
|
||||
editor: {
|
||||
type: Object as PropType<Editor | null>,
|
||||
default: null,
|
||||
},
|
||||
isBoldActive: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isItalicActive: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isUnderlineActive: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isBulletListActive: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isOrderedListActive: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isLinkActive: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
bold: []
|
||||
italic: []
|
||||
underline: []
|
||||
bulletList: []
|
||||
orderedList: []
|
||||
link: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isBoldActive }"
|
||||
@click="$emit('bold')"
|
||||
>
|
||||
<v-icon>mdi-format-bold</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isItalicActive }"
|
||||
@click="$emit('italic')"
|
||||
>
|
||||
<v-icon>mdi-format-italic</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isUnderlineActive }"
|
||||
@click="$emit('underline')"
|
||||
>
|
||||
<v-icon>mdi-format-underline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-divider vertical class="mx-2" />
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isBulletListActive }"
|
||||
@click="$emit('bulletList')"
|
||||
>
|
||||
<v-icon>mdi-format-list-bulleted</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isOrderedListActive }"
|
||||
@click="$emit('orderedList')"
|
||||
>
|
||||
<v-icon>mdi-format-list-numbered</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-divider vertical class="mx-2" />
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
:class="{ 'v-btn--active': isLinkActive }"
|
||||
@click="$emit('link')"
|
||||
>
|
||||
<v-icon>mdi-link</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-btn icon size="small">
|
||||
<v-icon>mdi-paperclip</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div class="editor-container">
|
||||
<EditorContent :editor="editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.editor-toolbar {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background-color: rgb(var(--v-theme-background));
|
||||
}
|
||||
|
||||
.v-btn--active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
:deep(.tiptap-editor) {
|
||||
outline: none;
|
||||
min-height: 300px;
|
||||
|
||||
p.is-editor-empty:first-child::before {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.3;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-left: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid rgb(var(--v-border-color));
|
||||
padding-left: 1em;
|
||||
margin-left: 0;
|
||||
margin-bottom: 0.5em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
95
src/components/composer/ComposerRecipients.vue
Normal file
95
src/components/composer/ComposerRecipients.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
to: string[]
|
||||
cc: string[]
|
||||
bcc: string[]
|
||||
subject: string
|
||||
showCc: boolean
|
||||
showBcc: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'update:to': [value: string[]]
|
||||
'update:cc': [value: string[]]
|
||||
'update:bcc': [value: string[]]
|
||||
'update:subject': [value: string]
|
||||
'toggle:cc': []
|
||||
'toggle:bcc': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer-fields pa-4">
|
||||
<v-combobox
|
||||
:model-value="to"
|
||||
label="To"
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
@update:model-value="$emit('update:to', $event)"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="mr-1"
|
||||
@click="$emit('toggle:cc')"
|
||||
>
|
||||
Cc
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="$emit('toggle:bcc')"
|
||||
>
|
||||
Bcc
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-combobox>
|
||||
|
||||
<v-combobox
|
||||
v-if="showCc"
|
||||
:model-value="cc"
|
||||
label="Cc"
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
@update:model-value="$emit('update:cc', $event)"
|
||||
/>
|
||||
|
||||
<v-combobox
|
||||
v-if="showBcc"
|
||||
:model-value="bcc"
|
||||
label="Bcc"
|
||||
chips
|
||||
multiple
|
||||
closable-chips
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
@update:model-value="$emit('update:bcc', $event)"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
:model-value="subject"
|
||||
label="Subject"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
@update:model-value="$emit('update:subject', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.composer-fields {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
57
src/components/composer/ComposerToolbar.vue
Normal file
57
src/components/composer/ComposerToolbar.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ComposerMode } from '@/types/composer'
|
||||
|
||||
interface Props {
|
||||
mode: ComposerMode
|
||||
saveStatus: string
|
||||
canSend: boolean
|
||||
sending: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
send: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
|
||||
<v-btn
|
||||
variant="text"
|
||||
icon="mdi-close"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-toolbar-title>
|
||||
{{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
|
||||
{{ saveStatus }}
|
||||
</span>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
:disabled="!canSend"
|
||||
:loading="sending"
|
||||
prepend-icon="mdi-send"
|
||||
@click="$emit('send')"
|
||||
>
|
||||
Send
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.composer-toolbar {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { MessageObject } from '@MailManager/models/message'
|
||||
import { computed } from 'vue'
|
||||
import RecipientDetails from '@/components/common/RecipientDetails.vue'
|
||||
import type { EntityObject } from '@MailManager/models';
|
||||
|
||||
interface Props {
|
||||
message: MessageObject
|
||||
entity: EntityObject | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
downloadAttachment: [index: number]
|
||||
}>()
|
||||
|
||||
const message = computed(() => {
|
||||
return props.entity?.properties ?? null
|
||||
})
|
||||
|
||||
const randomKey = computed(() => {
|
||||
return Math.random().toString(36).substring(2, 15)
|
||||
})
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (date: Date | string | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
@@ -31,6 +45,10 @@ const formatFileSize = (bytes: number | undefined): string => {
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const download = async (index: number): Promise<void> => {
|
||||
emit('downloadAttachment', index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,10 +64,14 @@ const formatFileSize = (bytes: number | undefined): string => {
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-body-1 font-weight-medium">
|
||||
{{ message?.from?.label || message?.from?.address || 'Unknown Sender' }}
|
||||
<RecipientDetails :address="message?.from">
|
||||
<template #default="{ label }">
|
||||
<span class="contact-link">{{ label }}</span>
|
||||
</template>
|
||||
</RecipientDetails>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ formatDate(message?.date) }}
|
||||
{{ formatDate(message?.received || message?.sent) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,12 +79,26 @@ const formatFileSize = (bytes: number | undefined): string => {
|
||||
<!-- Recipients -->
|
||||
<div v-if="message?.to && message?.to.length > 0" class="text-body-2 mb-1">
|
||||
<span class="text-medium-emphasis">To:</span>
|
||||
{{ message?.to.map(t => t.label || t.address).join(', ') }}
|
||||
<template v-for="(recipient, index) in message.to" :key="randomKey">
|
||||
<RecipientDetails :address="recipient">
|
||||
<template #default="{ label }">
|
||||
<span class="contact-link">{{ label }}</span>
|
||||
</template>
|
||||
</RecipientDetails>
|
||||
<span v-if="index < message.to.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="message?.cc && message?.cc.length > 0" class="text-body-2 mb-1">
|
||||
<span class="text-medium-emphasis">Cc:</span>
|
||||
{{ message?.cc.map(c => c.label || c.address).join(', ') }}
|
||||
<template v-for="(recipient, index) in message.cc" :key="randomKey">
|
||||
<RecipientDetails :address="recipient">
|
||||
<template #default="{ label }">
|
||||
<span class="contact-link">{{ label }}</span>
|
||||
</template>
|
||||
</RecipientDetails>
|
||||
<span v-if="index < message.cc.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
@@ -71,19 +107,24 @@ const formatFileSize = (bytes: number | undefined): string => {
|
||||
Attachments ({{ message?.attachments.length }})
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
<div
|
||||
v-for="(attachment, index) in message?.attachments"
|
||||
:key="index"
|
||||
prepend-icon="mdi-paperclip"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="attachment-chip"
|
||||
:key="randomKey"
|
||||
class="attachment-item"
|
||||
>
|
||||
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
|
||||
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1">
|
||||
({{ formatFileSize(attachment.size) }})
|
||||
</span>
|
||||
</v-chip>
|
||||
<v-chip
|
||||
prepend-icon="mdi-paperclip"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="attachment-chip"
|
||||
@click="download(index)"
|
||||
>
|
||||
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
|
||||
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1">
|
||||
({{ formatFileSize(attachment.size ?? undefined) }})
|
||||
</span>
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,8 +139,15 @@ const formatFileSize = (bytes: number | undefined): string => {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.attachment-chip {
|
||||
max-width: 300px;
|
||||
cursor: pointer;
|
||||
|
||||
.attachment-name {
|
||||
overflow: hidden;
|
||||
@@ -107,4 +155,21 @@ const formatFileSize = (bytes: number | undefined): string => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-error {
|
||||
color: rgb(var(--v-theme-error));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.contact-link {
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
margin: -1px -4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.contact-link:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,7 @@ const emit = defineEmits<{
|
||||
move: []
|
||||
delete: []
|
||||
flag: []
|
||||
download: []
|
||||
toggleImages: []
|
||||
setSecurityLevel: [level: SecurityLevel]
|
||||
}>()
|
||||
@@ -161,6 +162,28 @@ const currentSecurityLevel = computed(() => {
|
||||
<v-icon>mdi-delete-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Delete</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-menu>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon="mdi-dots-vertical"
|
||||
variant="text"
|
||||
>
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">More Actions</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
prepend-icon="mdi-download"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<v-list-item-title>Download</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
|
||||
44
src/components/settings/BehaviorSettings.vue
Normal file
44
src/components/settings/BehaviorSettings.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { messageReadDelayOptions, useMailSettingsStore } from '@/stores/mailSettingsStore'
|
||||
|
||||
const mailSettingsStore = useMailSettingsStore()
|
||||
const { messageReadEnabled, messageReadDelay } = storeToRefs(mailSettingsStore)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<h3 class="text-h6 mb-4">Behaviours</h3>
|
||||
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-list-item-title>Mark messages as read automatically</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
Mark a message as read after it stays open for the configured delay
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-switch v-model="messageReadEnabled" color="primary" hide-details />
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item>
|
||||
<v-list-item-title>Read delay</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
Choose how long a message must stay open before it is marked as read
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-select
|
||||
v-model="messageReadDelay"
|
||||
:items="messageReadDelayOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
:disabled="!messageReadEnabled"
|
||||
style="width: 180px"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,67 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useUser } from '@KTXC'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { folderViewModeOptions, useMailSettingsStore } from '@/stores/mailSettingsStore'
|
||||
|
||||
type FolderViewMode = 'tree' | 'page'
|
||||
|
||||
const { settings, setSetting } = useUser()
|
||||
|
||||
const theme = ref('Auto')
|
||||
const showPreview = ref(true)
|
||||
const compactMode = ref(false)
|
||||
|
||||
const folderViewMode = computed({
|
||||
get: () => {
|
||||
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
|
||||
},
|
||||
set: (value: FolderViewMode) => setSetting('mail.folderViewMode', value)
|
||||
})
|
||||
const mailSettingsStore = useMailSettingsStore()
|
||||
const { folderViewMode } = storeToRefs(mailSettingsStore)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<h3 class="text-h6 mb-4">Display Settings</h3>
|
||||
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-list-item-title>Theme</v-list-item-title>
|
||||
<v-list-item-subtitle>Choose your preferred theme</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-select
|
||||
v-model="theme"
|
||||
:items="['Light', 'Dark', 'Auto']"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
style="width: 150px"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item>
|
||||
<v-list-item-title>Message preview</v-list-item-title>
|
||||
<v-list-item-subtitle>Show message preview in list</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-switch v-model="showPreview" color="primary" hide-details />
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item>
|
||||
<v-list-item-title>Compact mode</v-list-item-title>
|
||||
<v-list-item-subtitle>Use compact message list layout</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-switch v-model="compactMode" color="primary" hide-details />
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item>
|
||||
<v-list-item-title>Folder navigation style</v-list-item-title>
|
||||
<v-list-item-subtitle>Choose how folders are displayed</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-select
|
||||
v-model="folderViewMode"
|
||||
:items="[
|
||||
{ value: 'tree', title: 'Tree' },
|
||||
{ value: 'page', title: 'Page' }
|
||||
]"
|
||||
:items="folderViewModeOptions"
|
||||
item-value="value"
|
||||
item-title="title"
|
||||
density="compact"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import DisplaySettings from './DisplaySettings.vue'
|
||||
import AccountsSettings from './AccountsSettings.vue'
|
||||
import BehaviorSettings from './BehaviorSettings.vue'
|
||||
import SecuritySettings from './SecuritySettings.vue'
|
||||
|
||||
interface Props {
|
||||
@@ -51,6 +52,10 @@ const handleClose = () => {
|
||||
<v-icon start>mdi-palette</v-icon>
|
||||
Display
|
||||
</v-tab>
|
||||
<v-tab value="behaviour">
|
||||
<v-icon start>mdi-timer-cog-outline</v-icon>
|
||||
Behaviours
|
||||
</v-tab>
|
||||
<v-tab value="security">
|
||||
<v-icon start>mdi-shield-account</v-icon>
|
||||
Security
|
||||
@@ -68,6 +73,10 @@ const handleClose = () => {
|
||||
<DisplaySettings />
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="behaviour">
|
||||
<BehaviorSettings />
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="security">
|
||||
<SecuritySettings />
|
||||
</v-window-item>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, unref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useModuleStore } from '@KTXC'
|
||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||
import type { CollectionObject, EntityObject } from '@MailManager/models'
|
||||
import type { EntityIdentifier } from '@MailManager/types/common'
|
||||
import FolderTree from '@/components/FolderTree.vue'
|
||||
import { ComposerMode } from '@/types/composer'
|
||||
import MessageList from '@/components/MessageList.vue'
|
||||
import MessageReader from '@/components/MessageReader.vue'
|
||||
import MessageComposer from '@/components/MessageComposer.vue'
|
||||
import CreateFolderDialog from '@/components/CreateFolderDialog.vue'
|
||||
import DeleteFolderDialog from '@/components/DeleteFolderDialog.vue'
|
||||
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
|
||||
import RenameFolderDialog from '@/components/RenameFolderDialog.vue'
|
||||
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
|
||||
import FolderView from '@/components/FolderView.vue'
|
||||
|
||||
// Vuetify display for responsive behavior
|
||||
const display = useDisplay()
|
||||
@@ -19,94 +24,173 @@ const isMobile = computed(() => display.mdAndDown.value)
|
||||
|
||||
// Check if mail manager is available
|
||||
const moduleStore = useModuleStore()
|
||||
const isMailManagerAvailable = computed(() => {
|
||||
const isManagerAvailable = computed(() => {
|
||||
return moduleStore.has('mail_manager') || moduleStore.has('MailManager')
|
||||
})
|
||||
const collectionsStore = useCollectionsStore()
|
||||
|
||||
// Mail module store
|
||||
const mailStore = useMailStore()
|
||||
const mailUiStore = useMailUiStore()
|
||||
|
||||
// storeToRefs preserves reactivity for state and computed properties
|
||||
const {
|
||||
loading,
|
||||
selectedMessage,
|
||||
currentMessages,
|
||||
} = storeToRefs(mailStore)
|
||||
|
||||
const {
|
||||
sidebarVisible,
|
||||
settingsDialogVisible,
|
||||
loading,
|
||||
selectedFolder,
|
||||
selectedMessage,
|
||||
composerMode,
|
||||
composerSource,
|
||||
composerVisible,
|
||||
selectionList,
|
||||
selectionMode,
|
||||
composeMode,
|
||||
composeReplyTo,
|
||||
currentMessages,
|
||||
moveDialogVisible,
|
||||
moveDialogCandidates,
|
||||
} = storeToRefs(mailStore)
|
||||
moveMessagesDialogVisible,
|
||||
moveMessagesDialogService,
|
||||
createFolderDialogVisible,
|
||||
createFolderDialogService,
|
||||
createFolderDialogLoading,
|
||||
createFolderDialogError,
|
||||
renameFolderDialogVisible,
|
||||
renameFolderDialogService,
|
||||
renameFolderDialogFolder,
|
||||
renameFolderDialogLoading,
|
||||
renameFolderDialogError,
|
||||
moveFolderDialogVisible,
|
||||
moveFolderDialogService,
|
||||
deleteFolderDialogVisible,
|
||||
deleteFolderDialogService,
|
||||
deleteFolderDialogFolder,
|
||||
deleteFolderDialogLoading,
|
||||
deleteFolderDialogError,
|
||||
} = storeToRefs(mailUiStore)
|
||||
|
||||
// Complex store/composable objects accessed directly (not simple refs)
|
||||
const { mailSync, entitiesStore } = mailStore
|
||||
|
||||
const lastSyncLabel = computed(() => {
|
||||
if (!mailSync.lastSync) return ''
|
||||
return `(Last: ${new Date(mailSync.lastSync).toLocaleTimeString()})`
|
||||
const lastSync = unref(unref(mailSync.lastSync))
|
||||
|
||||
if (!(lastSync instanceof Date)) return ''
|
||||
return `(Last: ${lastSync.toLocaleTimeString()})`
|
||||
})
|
||||
|
||||
// Initialize
|
||||
onMounted(async () => {
|
||||
if (!isMailManagerAvailable.value) return
|
||||
await mailStore.initialize()
|
||||
if (!isManagerAvailable.value) return
|
||||
await mailUiStore.initialize()
|
||||
})
|
||||
|
||||
// Handlers — thin wrappers that delegate to the store
|
||||
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder)
|
||||
const {
|
||||
validateCreateFolderName,
|
||||
validateRenameFolderName,
|
||||
} = mailUiStore
|
||||
|
||||
const handleMessageOpen = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value)
|
||||
const sidebarToggle = () => mailUiStore.sidebarToggle()
|
||||
|
||||
const handleMessageSelectionToggle = (message: EntityObject) => mailStore.toggleMessageSelection(message)
|
||||
const handleSettingsOpen = () => mailUiStore.settingsOpen()
|
||||
|
||||
const handleSelectionModeActivate = (message: EntityObject) => mailStore.activateSelectionMode(message)
|
||||
const handleFolderSelect = (folder: CollectionObject) => mailUiStore.selectFolder(folder)
|
||||
|
||||
const handleSelectAllToggle = (value: boolean) => {
|
||||
if (value) {
|
||||
mailStore.selectAllCurrentMessages()
|
||||
return
|
||||
const handleFolderCreateConfirm = async (folderName: string) => {
|
||||
try {
|
||||
const mutatedFolder = await mailUiStore.confirmCreateFolder(folderName)
|
||||
|
||||
if (mutatedFolder) {
|
||||
handleFolderSelect(mutatedFolder)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('[MailPage] Failed to create folder:', error)
|
||||
}
|
||||
|
||||
mailStore.clearSelection()
|
||||
}
|
||||
|
||||
const handleSelectionClear = () => mailStore.deactivateSelectionMode()
|
||||
const handleFolderEditConfirm = async (folderName: string) => {
|
||||
try {
|
||||
const mutatedFolder = await mailUiStore.confirmRenameFolder(folderName)
|
||||
|
||||
const handleSelectionMove = () => mailStore.openMoveDialog()
|
||||
|
||||
const handleSelectionDelete = () => mailStore.deleteMessages([...selectionList.value])
|
||||
|
||||
const handleCompose = (message?: EntityObject) => mailStore.openCompose(message)
|
||||
|
||||
const handleComposeClose = () => mailStore.closeCompose()
|
||||
|
||||
const handleComposeSent = () => mailStore.afterSent()
|
||||
|
||||
const handleDelete = (message: EntityObject) => {
|
||||
const id = `${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier
|
||||
mailStore.deleteMessages([id])
|
||||
if (mutatedFolder) {
|
||||
handleFolderSelect(mutatedFolder)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('[MailPage] Failed to rename folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message)
|
||||
const handleFolderDeleteConfirm = async () => {
|
||||
try {
|
||||
await mailUiStore.confirmDeleteFolder()
|
||||
} catch (error: unknown) {
|
||||
console.error('[MailPage] Failed to delete folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMoveConfirm = async (target: CollectionObject) => { await mailStore.moveMessages(target, moveDialogCandidates.value ?? []) }
|
||||
const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
|
||||
try {
|
||||
await mailUiStore.confirmMoveFolder(targetFolder)
|
||||
} catch (error: unknown) {
|
||||
console.error('[MailPage] Failed to move folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMoveCancel = () => mailStore.closeMoveDialog()
|
||||
const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog()
|
||||
|
||||
const toggleSidebar = () => mailStore.toggleSidebar()
|
||||
|
||||
const handleSettingsOpen = () => mailStore.openSettings()
|
||||
const handleMessageOpen = (message: EntityObject) => {
|
||||
mailStore.selectMessage(message)
|
||||
|
||||
if (isMobile.value) {
|
||||
mailUiStore.sidebarHide()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMessageComposeFresh = () => mailUiStore.openComposer()
|
||||
|
||||
const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Reply)
|
||||
|
||||
const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Forward)
|
||||
|
||||
const handleMessageComposeClose = () => mailUiStore.closeComposer()
|
||||
|
||||
const handleMessageFlag = (message: EntityObject, flag: string, value: boolean) => {
|
||||
mailStore.flagMessages([message.identifier], { [flag]: value })
|
||||
}
|
||||
|
||||
const handleMessageDelete = (message: EntityObject) => {
|
||||
mailStore.deleteMessages([message.identifier])
|
||||
}
|
||||
|
||||
const handleMessageMove = (message: EntityObject) => mailUiStore.openMoveMessagesDialog(message)
|
||||
|
||||
const handleMessageMoveConfirm = async (target: CollectionObject) => { await mailUiStore.confirmMoveMessages(target) }
|
||||
|
||||
const handleMessageMoveCancel = () => mailUiStore.closeMoveMessagesDialog()
|
||||
|
||||
const handleMessageSelectionMode = (message: EntityObject) => mailUiStore.messageSelectionModeActivate(message)
|
||||
|
||||
const handleMessageSelectionToggleOne = (message: EntityObject) => mailUiStore.messageSelectionToggleOne(message)
|
||||
|
||||
const handleMessageSelectionToggleAll = (value: boolean) => {
|
||||
mailUiStore.messageSelectionToggleAll(value)
|
||||
}
|
||||
|
||||
const handleMessageSelectionClear = () => mailUiStore.messageSelectionModeDeactivate()
|
||||
|
||||
const handleMessageSelectionMove = () => mailUiStore.openMoveMessagesDialog()
|
||||
|
||||
const handleMessageSelectionFlag = (flag: string, value: boolean) => mailUiStore.flagSelectedMessages(flag, value)
|
||||
|
||||
const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||
|
||||
const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Folder "${folder.properties.label}" created`, 'success')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Manager Unavailable -->
|
||||
<div v-if="!isMailManagerAvailable" class="mail-unavailable">
|
||||
<div v-if="!isManagerAvailable" class="mail-unavailable">
|
||||
<v-alert
|
||||
type="warning"
|
||||
variant="outlined"
|
||||
@@ -131,7 +215,7 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
|
||||
<v-app-bar class="mail-toolbar" elevation="0" density="compact">
|
||||
<v-app-bar-nav-icon
|
||||
v-if="isMobile"
|
||||
@click="toggleSidebar"
|
||||
@click="sidebarToggle"
|
||||
/>
|
||||
|
||||
<v-app-bar-title>Mail</v-app-bar-title>
|
||||
@@ -140,7 +224,7 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
|
||||
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
@click="handleCompose()"
|
||||
@click="handleMessageComposeFresh()"
|
||||
color="primary"
|
||||
variant="text"
|
||||
>
|
||||
@@ -180,10 +264,10 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
|
||||
width="280"
|
||||
class="mail-sidebar"
|
||||
>
|
||||
<FolderTree
|
||||
|
||||
<FolderView
|
||||
:selected-folder="selectedFolder"
|
||||
@select="handleFolderSelect"
|
||||
@folder-created="handleFolderCreated"
|
||||
/>
|
||||
|
||||
<template #append>
|
||||
@@ -213,32 +297,39 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
|
||||
:selection-mode="selectionMode"
|
||||
:loading="loading"
|
||||
@open="handleMessageOpen"
|
||||
@toggle-selection="handleMessageSelectionToggle"
|
||||
@activate-selection-mode="handleSelectionModeActivate"
|
||||
@toggle-select-all="handleSelectAllToggle"
|
||||
@clear-selection="handleSelectionClear"
|
||||
@move-selection="handleSelectionMove"
|
||||
@delete-selection="handleSelectionDelete"
|
||||
@reply="handleMessageComposeReply"
|
||||
@forward="handleMessageComposeForward"
|
||||
@move="handleMessageMove"
|
||||
@delete="handleMessageDelete"
|
||||
@flag="handleMessageFlag"
|
||||
@selection-mode="handleMessageSelectionMode"
|
||||
@selection-toggle-one="handleMessageSelectionToggleOne"
|
||||
@selection-toggle-all="handleMessageSelectionToggleAll"
|
||||
@selection-clear="handleMessageSelectionClear"
|
||||
@selection-flag="handleMessageSelectionFlag"
|
||||
@selection-move="handleMessageSelectionMove"
|
||||
@selection-delete="handleMessageSelectionDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Reader/Composer panel -->
|
||||
<div class="mail-reader-panel">
|
||||
<MessageComposer
|
||||
v-if="composeMode"
|
||||
:reply-to="composeReplyTo"
|
||||
v-if="composerVisible"
|
||||
:mode="composerMode"
|
||||
:source="composerSource"
|
||||
:folder="selectedFolder"
|
||||
@close="handleComposeClose"
|
||||
@sent="handleComposeSent"
|
||||
@close="handleMessageComposeClose"
|
||||
/>
|
||||
|
||||
<MessageReader
|
||||
v-else
|
||||
:entity="selectedMessage"
|
||||
@compose="handleCompose"
|
||||
@reply="handleCompose"
|
||||
@move="handleMove"
|
||||
@delete="handleDelete"
|
||||
@compose="handleMessageComposeFresh"
|
||||
@reply="handleMessageComposeReply"
|
||||
@forward="handleMessageComposeForward"
|
||||
@move="handleMessageMove"
|
||||
@delete="handleMessageDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,13 +340,61 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
|
||||
<SettingsDialog v-model="settingsDialogVisible" />
|
||||
|
||||
<FolderSelectionDialog
|
||||
v-model="moveDialogVisible"
|
||||
v-if="moveMessagesDialogService"
|
||||
v-model="moveMessagesDialogVisible"
|
||||
:service="moveMessagesDialogService"
|
||||
:loading="loading"
|
||||
title="Move To"
|
||||
title="Move Messages To"
|
||||
confirm-text="Move"
|
||||
empty-text="No other folders are available in this account."
|
||||
@select="handleMoveConfirm"
|
||||
@cancel="handleMoveCancel"
|
||||
@select="handleMessageMoveConfirm"
|
||||
@cancel="handleMessageMoveCancel"
|
||||
/>
|
||||
|
||||
<FolderSelectionDialog
|
||||
v-if="moveFolderDialogService"
|
||||
v-model="moveFolderDialogVisible"
|
||||
:service="moveFolderDialogService"
|
||||
:loading="collectionsStore.transceiving"
|
||||
title="Move Folder To"
|
||||
confirm-text="Move"
|
||||
empty-text="No other folders are available in this account."
|
||||
:disabled-folder-keys="mailUiStore.moveFolderDialogInvalidFolderKeys"
|
||||
@select="handleFolderMoveConfirm"
|
||||
@cancel="handleFolderMoveCancel"
|
||||
/>
|
||||
|
||||
<CreateFolderDialog
|
||||
v-if="createFolderDialogService"
|
||||
v-model="createFolderDialogVisible"
|
||||
:service="createFolderDialogService"
|
||||
:parent-folder-label="mailUiStore.createFolderDialogParentLabel"
|
||||
:validate-name="validateCreateFolderName"
|
||||
:loading="createFolderDialogLoading"
|
||||
:error-message="createFolderDialogError"
|
||||
@confirm="handleFolderCreateConfirm"
|
||||
/>
|
||||
|
||||
<RenameFolderDialog
|
||||
v-if="renameFolderDialogService && renameFolderDialogFolder"
|
||||
v-model="renameFolderDialogVisible"
|
||||
:service="renameFolderDialogService"
|
||||
:folder="renameFolderDialogFolder"
|
||||
:parent-folder-label="mailUiStore.renameFolderDialogParentLabel"
|
||||
:validate-name="validateRenameFolderName"
|
||||
:loading="renameFolderDialogLoading"
|
||||
:error-message="renameFolderDialogError"
|
||||
@confirm="handleFolderEditConfirm"
|
||||
/>
|
||||
|
||||
<DeleteFolderDialog
|
||||
v-if="deleteFolderDialogService && deleteFolderDialogFolder"
|
||||
v-model="deleteFolderDialogVisible"
|
||||
:service="deleteFolderDialogService"
|
||||
:folder="deleteFolderDialogFolder"
|
||||
:loading="deleteFolderDialogLoading"
|
||||
:error-message="deleteFolderDialogError"
|
||||
@confirm="handleFolderDeleteConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
74
src/stores/mailSettingsStore.ts
Normal file
74
src/stores/mailSettingsStore.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useUserStore } from '@KTXC'
|
||||
|
||||
const MESSAGE_READ_ENABLED_KEY = 'mail.behaviour.messageReadEnabled'
|
||||
const MESSAGE_READ_DELAY_KEY = 'mail.behaviour.messageReadDelay'
|
||||
const FOLDER_VIEW_MODE_KEY = 'mail.folderViewMode'
|
||||
|
||||
const DEFAULT_MESSAGE_READ_ENABLED = false
|
||||
const DEFAULT_MESSAGE_READ_DELAY = 5
|
||||
const DEFAULT_FOLDER_VIEW_MODE = 'tree'
|
||||
|
||||
export type FolderViewMode = 'tree' | 'page'
|
||||
|
||||
export const messageReadDelayOptions = [
|
||||
{ value: 2, title: '2 seconds' },
|
||||
{ value: 5, title: '5 seconds' },
|
||||
{ value: 10, title: '10 seconds' },
|
||||
{ value: 30, title: '30 seconds' },
|
||||
]
|
||||
|
||||
export const folderViewModeOptions = [
|
||||
{ value: 'tree', title: 'Tree' },
|
||||
{ value: 'page', title: 'Page' },
|
||||
]
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
const normalized = Number(value)
|
||||
|
||||
return Number.isFinite(normalized) && normalized > 0 ? normalized : fallback
|
||||
}
|
||||
|
||||
function normalizeFolderViewMode(value: unknown, fallback: FolderViewMode): FolderViewMode {
|
||||
return value === 'tree' || value === 'page' ? value : fallback
|
||||
}
|
||||
|
||||
export const useMailSettingsStore = defineStore('mailSettingsStore', () => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
const messageReadEnabled = computed({
|
||||
get: () => normalizeBoolean(userStore.getSetting(MESSAGE_READ_ENABLED_KEY), DEFAULT_MESSAGE_READ_ENABLED),
|
||||
set: (value: boolean) => userStore.setSetting(MESSAGE_READ_ENABLED_KEY, value),
|
||||
})
|
||||
|
||||
const messageReadDelay = computed({
|
||||
get: () => normalizePositiveNumber(userStore.getSetting(MESSAGE_READ_DELAY_KEY), DEFAULT_MESSAGE_READ_DELAY),
|
||||
set: (value: number) => userStore.setSetting(
|
||||
MESSAGE_READ_DELAY_KEY,
|
||||
normalizePositiveNumber(value, DEFAULT_MESSAGE_READ_DELAY),
|
||||
),
|
||||
})
|
||||
|
||||
const folderViewMode = computed({
|
||||
get: () => normalizeFolderViewMode(userStore.getSetting(FOLDER_VIEW_MODE_KEY), DEFAULT_FOLDER_VIEW_MODE),
|
||||
set: (value: FolderViewMode) => userStore.setSetting(
|
||||
FOLDER_VIEW_MODE_KEY,
|
||||
normalizeFolderViewMode(value, DEFAULT_FOLDER_VIEW_MODE),
|
||||
),
|
||||
})
|
||||
|
||||
return {
|
||||
folderViewMode,
|
||||
messageReadEnabled,
|
||||
messageReadDelay,
|
||||
}
|
||||
})
|
||||
@@ -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(() => {
|
||||
@@ -74,20 +78,15 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
await servicesStore.list()
|
||||
|
||||
const services = [...servicesStore.servicesEnabled]
|
||||
services.forEach(service => {
|
||||
void loadFoldersForService(service,{ selectInbox: true })
|
||||
})
|
||||
await Promise.all(services.map(service => loadFoldersForService(service)))
|
||||
} catch (error) {
|
||||
console.error('[Mail] Failed to initialize:', error)
|
||||
console.error('[Mail][Operations] Failed to initialize:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFoldersForService(
|
||||
service: ServiceObject,
|
||||
options: { selectInbox?: boolean } = {},
|
||||
) {
|
||||
async function loadFoldersForService(service: ServiceObject) {
|
||||
|
||||
if (service.identifier === null) {
|
||||
return
|
||||
@@ -98,37 +97,15 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
|
||||
try {
|
||||
// retrieve folders for service
|
||||
const collections = await collectionsStore.list({
|
||||
[service.provider]: {
|
||||
[String(service.identifier)]: true,
|
||||
},
|
||||
})
|
||||
await collectionsStore.collectionsForService(service.provider, service.identifier, true)
|
||||
|
||||
_setServiceFolderLoaded(service.provider, service.identifier, true)
|
||||
|
||||
if (options.selectInbox && !selectedFolder.value) {
|
||||
const inbox = Object.values(collections).find(
|
||||
folder =>
|
||||
folder.provider === service.provider &&
|
||||
String(folder.service) === String(service.identifier) &&
|
||||
(folder.properties.role === 'inbox' ||
|
||||
String(folder.identifier).toLowerCase() === 'inbox'),
|
||||
)
|
||||
|
||||
if (inbox) {
|
||||
await selectFolder(inbox)
|
||||
}
|
||||
}
|
||||
|
||||
_updateSyncSources()
|
||||
return collections
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to load folders'
|
||||
_setServiceFolderError(service.provider, service.identifier, message)
|
||||
console.error(
|
||||
`[Mail] Failed to load folders for ${service.provider}:${String(service.identifier)}:`,
|
||||
error,
|
||||
)
|
||||
console.error(`[Mail][Operations] Failed to load folders for ${service.provider}:${String(service.identifier)}:`, error)
|
||||
_updateSyncSources()
|
||||
return {}
|
||||
} finally {
|
||||
@@ -139,15 +116,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 +158,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 +178,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,254 +203,289 @@ 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 _setSelectionList(nextIds: EntityIdentifier[]) {
|
||||
selectionList.value = Array.from(new Set(nextIds))
|
||||
function _toMessageAddresses(addresses: string[]): MessageAddressInterface[] | undefined {
|
||||
const normalized = addresses
|
||||
.map(address => address.trim())
|
||||
.filter(address => address.length > 0)
|
||||
|
||||
if (selectionList.value.length === 0) {
|
||||
selectionMode.value = false
|
||||
if (normalized.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return normalized.map(address => ({ address }))
|
||||
}
|
||||
|
||||
function _reconcileSelection() {
|
||||
if (!selectedFolder.value) {
|
||||
clearSelection()
|
||||
selectedMessage.value = null
|
||||
return
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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 (html.length > 0) {
|
||||
parts.push({
|
||||
type: 'text/html',
|
||||
content: html,
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedMessage.value && !currentMessageIdentifiers.has(_entityIdentifier(selectedMessage.value))) {
|
||||
selectedMessage.value = null
|
||||
if (parts.length === 0) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function _formatMoveNotification(successCount: number, failureCount: number, targetFolder: CollectionObject) {
|
||||
const folderLabel = targetFolder.properties.label || String(targetFolder.identifier)
|
||||
|
||||
if (failureCount === 0) {
|
||||
return {
|
||||
message: successCount === 1
|
||||
? `Message moved to "${folderLabel}"`
|
||||
: `${successCount} messages moved to "${folderLabel}"`,
|
||||
color: 'success' as const,
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
return {
|
||||
message: successCount === 0
|
||||
? `Move failed for ${failureCount === 1 ? '1 message' : `${failureCount} messages`}`
|
||||
: `Moved ${successCount} ${successCount === 1 ? 'message' : 'messages'} to "${folderLabel}". ${failureCount} failed.`,
|
||||
color: successCount === 0 ? 'error' as const : 'warning' as const,
|
||||
type: 'multipart/alternative',
|
||||
subParts: parts,
|
||||
}
|
||||
}
|
||||
|
||||
watch(currentMessages, () => {
|
||||
_reconcileSelection()
|
||||
})
|
||||
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 resetComposerState() {
|
||||
composerSaving.value = false
|
||||
composerSending.value = false
|
||||
composerLastSaved.value = null
|
||||
composerDraftIdentifier.value = null
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function selectFolder(folder: CollectionObject) {
|
||||
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][Operations] Failed to retrieve service ${identifier}:`, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!service) {
|
||||
const message = `Service ${identifier} not found`
|
||||
console.error(`[Mail][Operations] ${message}`)
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
async function selectFolder(folder: CollectionObject | null) {
|
||||
selectedFolder.value = folder
|
||||
selectedMessage.value = null
|
||||
clearSelection()
|
||||
selectionMode.value = false
|
||||
composeMode.value = false
|
||||
|
||||
try {
|
||||
await _reloadFolderMessages(folder)
|
||||
} catch (error) {
|
||||
console.error('[Mail] Failed to load messages:', error)
|
||||
if (folder) {
|
||||
try {
|
||||
await entitiesStore.list([folder.identifier])
|
||||
} catch (error) {
|
||||
console.error('[Mail][Operations] Failed to load messages:', error)
|
||||
}
|
||||
}
|
||||
|
||||
_updateSyncSources()
|
||||
}
|
||||
|
||||
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 | null) {
|
||||
selectedMessage.value = entity
|
||||
composeMode.value = false
|
||||
|
||||
if (closeSidebar) {
|
||||
sidebarVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCompose(replyTo?: EntityObject) {
|
||||
composeMode.value = true
|
||||
composeReplyTo.value = replyTo ?? null
|
||||
selectedMessage.value = null
|
||||
}
|
||||
|
||||
function closeCompose() {
|
||||
composeMode.value = false
|
||||
composeReplyTo.value = null
|
||||
}
|
||||
|
||||
async function afterSent() {
|
||||
composeMode.value = false
|
||||
composeReplyTo.value = null
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
function toggleMessageSelection(message: EntityObject) {
|
||||
const identifier = _entityIdentifier(message)
|
||||
|
||||
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(currentMessages.value.map(message => _entityIdentifier(message)))
|
||||
}
|
||||
|
||||
function activateSelectionMode(message?: EntityObject) {
|
||||
selectionMode.value = true
|
||||
|
||||
if (message) {
|
||||
const identifier = _entityIdentifier(message)
|
||||
|
||||
if (!selectionList.value.includes(identifier)) {
|
||||
_setSelectionList([...selectionList.value, identifier])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deactivateSelectionMode() {
|
||||
selectionMode.value = false
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
_setSelectionList([])
|
||||
}
|
||||
|
||||
function openMoveDialog(entities?: EntityObject | EntityObject[]) {
|
||||
|
||||
moveDialogCandidates.value = []
|
||||
|
||||
if (entities) {
|
||||
if (Array.isArray(entities)) {
|
||||
moveDialogCandidates.value = entities.map(entity => _entityIdentifier(entity))
|
||||
moveDialogService.value = _serviceIdentifier(entities[0])
|
||||
} else {
|
||||
moveDialogCandidates.value = [_entityIdentifier(entities)]
|
||||
moveDialogService.value = _serviceIdentifier(entities)
|
||||
}
|
||||
} else {
|
||||
moveDialogCandidates.value = selectionList.value
|
||||
moveDialogService.value = _serviceIdentifier(selectedFolder.value)
|
||||
}
|
||||
|
||||
moveDialogVisible.value = true
|
||||
}
|
||||
|
||||
function closeMoveDialog() {
|
||||
moveDialogVisible.value = false
|
||||
moveDialogService.value = null
|
||||
moveDialogCandidates.value = null
|
||||
}
|
||||
|
||||
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
|
||||
const movableIdentifiers = entityIdentifiers.filter(identifier => {
|
||||
const entity = entitiesStore.entityByIdentifier(identifier)
|
||||
|
||||
if (!entity) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only allow moving messages within the same service and disallow moving into the same folder
|
||||
return entity.provider === target.provider &&
|
||||
String(entity.service) === String(target.service) &&
|
||||
String(entity.collection) !== String(target.identifier)
|
||||
})
|
||||
|
||||
if (movableIdentifiers.length === 0) {
|
||||
closeMoveDialog()
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) {
|
||||
composerSaving.value = true
|
||||
|
||||
try {
|
||||
const response = await entitiesStore.move(_collectionIdentifier(target), movableIdentifiers)
|
||||
const operationSucceeded: EntityIdentifier[] = []
|
||||
const operationFailures: EntityIdentifier[] = []
|
||||
const targetFolder = _findDraftFolder(folder)
|
||||
const properties = _toDraftProperties(message)
|
||||
const draft = composerDraftIdentifier.value
|
||||
? await entitiesStore.update(composerDraftIdentifier.value, properties)
|
||||
: await entitiesStore.create(targetFolder.identifier, properties)
|
||||
|
||||
Object.entries(response).forEach(([sourceIdentifier, result]) => {
|
||||
if (result.success) {
|
||||
operationSucceeded.push(sourceIdentifier as EntityIdentifier)
|
||||
return
|
||||
}
|
||||
composerDraftIdentifier.value = draft.identifier
|
||||
composerLastSaved.value = new Date()
|
||||
|
||||
operationFailures.push(sourceIdentifier as EntityIdentifier)
|
||||
})
|
||||
|
||||
if (operationSucceeded.length === 0) {
|
||||
throw new Error(operationFailures[0] ?? 'Failed to move messages')
|
||||
}
|
||||
|
||||
if (selectedMessage.value && operationSucceeded.includes(_entityIdentifier(selectedMessage.value))) {
|
||||
selectedMessage.value = null
|
||||
}
|
||||
|
||||
clearSelection()
|
||||
closeMoveDialog()
|
||||
|
||||
const notification = _formatMoveNotification(operationSucceeded.length, operationFailures.length, target)
|
||||
notify(notification.message, notification.color)
|
||||
return draft
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
|
||||
console.error('[Mail] Failed to move messages:', error)
|
||||
console.error('[Mail][Operations] Failed to save draft:', error)
|
||||
throw error
|
||||
} finally {
|
||||
composerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function findFoldersByRole(role: string): CollectionObject[] {
|
||||
const normalizedRole = role.toLowerCase()
|
||||
|
||||
return servicesStore.servicesEnabled.flatMap(service => {
|
||||
if (service.identifier === null) {
|
||||
return []
|
||||
}
|
||||
|
||||
return collectionsStore.collectionsForService(service.provider, service.identifier).filter(
|
||||
folder =>
|
||||
folder.provider === service.provider &&
|
||||
String(folder.service) === String(service.identifier) &&
|
||||
(folder.properties.role === normalizedRole ||
|
||||
String(folder.identifier).toLowerCase() === normalizedRole),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function sendComposerMessage(message: ComposerMessageInput) {
|
||||
composerSending.value = true
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (transmitRequest.message.cc?.length === 0) {
|
||||
delete transmitRequest.message.cc
|
||||
}
|
||||
|
||||
if (transmitRequest.message.bcc?.length === 0) {
|
||||
delete transmitRequest.message.bcc
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await entitiesStore.transmit(transmitRequest)
|
||||
|
||||
if (composerDraftIdentifier.value) {
|
||||
try {
|
||||
await entitiesStore.delete([composerDraftIdentifier.value])
|
||||
} catch (error) {
|
||||
console.error('[Mail][Operations] 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][Operations] Failed to send message:', error)
|
||||
notify(messageText, 'error')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
composerSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)) {
|
||||
await selectFolder(null)
|
||||
}
|
||||
|
||||
notify(
|
||||
`Folder "${folder.properties.label || String(folder.identifier)}" deleted`,
|
||||
'success',
|
||||
)
|
||||
|
||||
return deletedFolder
|
||||
}
|
||||
|
||||
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
|
||||
if (entityIdentifiers.length === 0) {
|
||||
return
|
||||
@@ -474,48 +494,26 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await entitiesStore.delete(entityIdentifiers)
|
||||
const operationSucceeded: EntityIdentifier[] = []
|
||||
const operationFailures: EntityIdentifier[] = []
|
||||
const { successes, failures } = await entitiesStore.delete(entityIdentifiers)
|
||||
|
||||
Object.entries(response).forEach(([sourceIdentifier, result]) => {
|
||||
if (result.success) {
|
||||
operationSucceeded.push(sourceIdentifier as EntityIdentifier)
|
||||
return
|
||||
}
|
||||
|
||||
operationFailures.push(sourceIdentifier as EntityIdentifier)
|
||||
})
|
||||
|
||||
if (operationSucceeded.length === 0) {
|
||||
throw new Error(operationFailures[0] ?? 'Failed to delete messages')
|
||||
}
|
||||
|
||||
if (selectedMessage.value && operationSucceeded.includes(_entityIdentifier(selectedMessage.value))) {
|
||||
selectedMessage.value = null
|
||||
}
|
||||
|
||||
clearSelection()
|
||||
|
||||
const successCount = operationSucceeded.length
|
||||
const failureCount = operationFailures.length
|
||||
|
||||
if (failureCount === 0) {
|
||||
if (failures.length === 0) {
|
||||
notify(
|
||||
successCount === 1 ? 'Message deleted' : `${successCount} messages deleted`,
|
||||
successes.length === 1 ? 'Message deleted' : `${successes.length} messages deleted`,
|
||||
'success',
|
||||
)
|
||||
} else {
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
notify(
|
||||
successCount === 0
|
||||
? `Delete failed for ${failureCount === 1 ? '1 message' : `${failureCount} messages`}`
|
||||
: `Deleted ${successCount} ${successCount === 1 ? 'message' : 'messages'}. ${failureCount} failed.`,
|
||||
successCount === 0 ? 'error' : 'warning',
|
||||
successes.length === 0
|
||||
? `Delete failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
|
||||
: `Deleted ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
|
||||
successes.length === 0 ? 'error' : 'warning',
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : 'Failed to delete messages'
|
||||
console.error('[Mail] Failed to delete messages:', error)
|
||||
console.error('[Mail][Operations] Failed to delete messages:', error)
|
||||
notify(messageText, 'error')
|
||||
throw error
|
||||
} finally {
|
||||
@@ -523,12 +521,139 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarVisible.value = !sidebarVisible.value
|
||||
async function flagMessages(entityIdentifiers: EntityIdentifier[], flags: Partial<MessageInterface['flags']>, options: { notify?: boolean } = {}) {
|
||||
if (entityIdentifiers.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const shouldNotify = options.notify ?? true
|
||||
|
||||
try {
|
||||
const patch = entitiesStore.fresh().properties
|
||||
patch.flags = flags
|
||||
|
||||
const { successes, failures } = await entitiesStore.patch(patch, entityIdentifiers)
|
||||
|
||||
if (shouldNotify && successes.length > 0) {
|
||||
notify(
|
||||
successes.length === 1 ? 'Message updated' : `${successes.length} messages updated`,
|
||||
'success',
|
||||
)
|
||||
}
|
||||
|
||||
if (shouldNotify && failures.length > 0) {
|
||||
notify(
|
||||
successes.length === 0
|
||||
? `Update failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
|
||||
: `Updated ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
|
||||
successes.length === 0 ? 'error' : 'warning',
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : 'Failed to update messages'
|
||||
console.error('[Mail][Operations] Failed to update messages:', error)
|
||||
notify(messageText, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
settingsDialogVisible.value = true
|
||||
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
|
||||
const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
|
||||
(accumulator, identifier) => {
|
||||
const entity = entitiesStore.entity(identifier)
|
||||
|
||||
if (!entity) {
|
||||
return accumulator
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
|
||||
|
||||
if (failures.length === 0) {
|
||||
notify(
|
||||
successes.length === 1 ? 'Message moved' : `${successes.length} messages moved`,
|
||||
'success',
|
||||
)
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
notify(
|
||||
successes.length === 0
|
||||
? `Move failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
|
||||
: `Moved ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
|
||||
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][Operations] Failed to move messages:', error)
|
||||
notify(messageText, 'error')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadMessage(entity: EntityObject, index?: number) {
|
||||
const target = entity.identifier
|
||||
let part = null
|
||||
|
||||
if (index !== undefined) {
|
||||
part = entity.properties.attachments?.[Number(index)] ?? null
|
||||
}
|
||||
|
||||
try {
|
||||
await entitiesStore.download(target, part)
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error
|
||||
? error.message
|
||||
: index === undefined
|
||||
? 'Failed to download message'
|
||||
: 'Failed to download attachment'
|
||||
console.error(
|
||||
index === undefined
|
||||
? '[Mail][Operations] Failed to download message:'
|
||||
: '[Mail][Operations] Failed to download attachment:',
|
||||
error,
|
||||
)
|
||||
notify(messageText, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
|
||||
@@ -545,18 +670,12 @@ 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,
|
||||
@@ -565,28 +684,25 @@ export const useMailStore = defineStore('mailStore', () => {
|
||||
currentMessages,
|
||||
|
||||
// Actions
|
||||
retrieveService,
|
||||
selectFolder,
|
||||
clearSelectedFolder,
|
||||
selectMessage,
|
||||
isMessageSelected,
|
||||
activateSelectionMode,
|
||||
deactivateSelectionMode,
|
||||
toggleMessageSelection,
|
||||
selectAllCurrentMessages,
|
||||
clearSelection,
|
||||
openCompose,
|
||||
openMoveDialog,
|
||||
closeMoveDialog,
|
||||
closeCompose,
|
||||
afterSent,
|
||||
createFolder,
|
||||
saveComposerDraft,
|
||||
sendComposerMessage,
|
||||
resetComposerState,
|
||||
flagMessages,
|
||||
deleteMessages,
|
||||
deleteFolder,
|
||||
moveMessages,
|
||||
toggleSidebar,
|
||||
openSettings,
|
||||
downloadMessage,
|
||||
moveFolder,
|
||||
renameFolder,
|
||||
notify,
|
||||
isServiceFolderLoading,
|
||||
hasServiceFoldersLoaded,
|
||||
getServiceFolderError,
|
||||
findFoldersByRole,
|
||||
loadFoldersForService,
|
||||
initialize,
|
||||
}
|
||||
|
||||
641
src/stores/mailUiStore.ts
Normal file
641
src/stores/mailUiStore.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
5
src/types/composer.ts
Normal file
5
src/types/composer.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum ComposerMode {
|
||||
Fresh,
|
||||
Reply,
|
||||
Forward,
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["src/**/*", "src/**/*.vue", "../../core/src/**/*.ts"],
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"../../core/src/**/*.ts",
|
||||
"../mail_manager/src/**/*.ts",
|
||||
"../mail_manager/src/**/*.vue"
|
||||
],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
Reference in New Issue
Block a user