Compare commits

...

6 Commits

Author SHA1 Message Date
b24b751e4a Merge pull request 'recator: improve logic' (#13) from recator/improve-logic2 into main
Reviewed-on: #13
2026-03-30 18:49:53 +00:00
36321a7584 recator: improve logic
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-03-30 14:49:36 -04:00
ec673984f7 Merge pull request 'refactor: improve logic' (#12) from recator/improve-logic into main
Reviewed-on: #12
2026-03-30 18:44:43 +00:00
11de31397d refactor: improve logic
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-03-30 14:43:26 -04:00
4ff377c6ac Merge pull request 'feat: multi select' (#11) from feat/multiselect into main
Reviewed-on: #11
2026-03-29 21:02:28 +00:00
69f3c430cc feat: multi select
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-03-29 17:02:00 -04:00
6 changed files with 538 additions and 113 deletions

View File

@@ -50,7 +50,7 @@ interface ServiceGroup {
} }
const serviceGroups = computed<ServiceGroup[]>(() => { const serviceGroups = computed<ServiceGroup[]>(() => {
const moveCandidate = mailStore.moveMessageCandidate const moveCandidate = mailStore.moveMessageCandidates[0]
if (!moveCandidate) { if (!moveCandidate) {
return [] return []
@@ -111,7 +111,7 @@ const canConfirm = computed(() => {
}) })
watch( watch(
() => [props.modelValue, mailStore.moveMessageCandidate], () => [props.modelValue, mailStore.moveMessageCandidates],
([isOpen]) => { ([isOpen]) => {
if (!isOpen) { if (!isOpen) {
return return

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import type { EntityIdentifier } from '@MailManager/types/common'
import type { EntityInterface } from '@MailManager/types/entity' import type { EntityInterface } from '@MailManager/types/entity'
import type { MessageInterface } from '@MailManager/types/message' import type { MessageInterface } from '@MailManager/types/message'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
@@ -8,37 +9,64 @@ import type { CollectionObject } from '@MailManager/models/collection'
interface Props { interface Props {
messages: EntityInterface<MessageInterface>[] messages: EntityInterface<MessageInterface>[]
selectedMessage?: EntityInterface<MessageInterface> | null selectedMessage?: EntityInterface<MessageInterface> | null
selectedMessageIds?: EntityIdentifier[]
selectionModeActive?: boolean
selectionCount?: number
hasSelection?: boolean
allCurrentMessagesSelected?: boolean
selectedCollection?: CollectionObject | null selectedCollection?: CollectionObject | null
loading?: boolean loading?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
loading: false loading: false,
selectedMessageIds: () => [],
selectionModeActive: false,
selectionCount: 0,
hasSelection: false,
allCurrentMessagesSelected: false,
}) })
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
select: [message: EntityInterface<MessageInterface>] open: [message: EntityInterface<MessageInterface>]
toggleSelection: [message: EntityInterface<MessageInterface>]
activateSelectionMode: [message: EntityInterface<MessageInterface>]
toggleSelectAll: [value: boolean]
clearSelection: []
moveSelection: []
}>() }>()
// Check if message is selected const longPressTimer = ref<number | null>(null)
const isSelected = (message: EntityInterface<MessageInterface>): boolean => { const longPressActivated = ref(false)
const suppressNextClick = ref(false)
const LONG_PRESS_MS = 450
const selectedIdSet = computed(() => new Set(props.selectedMessageIds))
const isOpened = (message: EntityInterface<MessageInterface>): boolean => {
if (!props.selectedMessage) return false if (!props.selectedMessage) return false
return ( return (
message.provider === props.selectedMessage.provider && message.provider === selectedMessage.value.provider &&
message.service === props.selectedMessage.service && message.service === selectedMessage.value.service &&
message.collection === props.selectedMessage.collection && message.collection === selectedMessage.value.collection &&
message.identifier === props.selectedMessage.identifier message.identifier === selectedMessage.value.identifier
)
}
const isSelected = (message: EntityInterface<MessageInterface>): boolean => {
return selectedIdSet.value.has(
`${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier,
) )
} }
// Check if message is unread // Check if message is unread
const isUnread = (message: EntityInterface<MessageInterface>): boolean => { const isUnread = (message: EntityObject): boolean => {
return !message.properties.flags?.read return !message.properties.flags?.read
} }
// Check if message is flagged // Check if message is flagged
const isFlagged = (message: EntityInterface<MessageInterface>): boolean => { const isFlagged = (message: EntityObject): boolean => {
return message.properties.flags?.flagged || false return message.properties.flags?.flagged || false
} }
@@ -90,12 +118,92 @@ const truncate = (text: string | null | undefined, length: number = 100): string
// Handle message click // Handle message click
const handleMessageClick = (message: EntityInterface<MessageInterface>) => { const handleMessageClick = (message: EntityInterface<MessageInterface>) => {
emit('select', message) if (longPressActivated.value) {
longPressActivated.value = false
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (props.selectionModeActive) {
emit('toggleSelection', message)
return
}
emit('open', message)
}
const handleSelectionToggle = (message: EntityInterface<MessageInterface>) => {
emit('toggleSelection', message)
}
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityInterface<MessageInterface>) => {
if (event.shiftKey && !props.selectionModeActive) {
event.preventDefault()
event.stopPropagation()
emit('activateSelectionMode', message)
return
}
handleMessageClick(message)
}
const handleMessageMouseDown = (event: MouseEvent, message: EntityInterface<MessageInterface>) => {
if (!event.shiftKey || props.selectionModeActive) {
return
}
event.preventDefault()
event.stopPropagation()
suppressNextClick.value = true
emit('activateSelectionMode', message)
}
const clearLongPressTimer = () => {
if (longPressTimer.value !== null) {
window.clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
}
const handleTouchStart = (message: EntityInterface<MessageInterface>) => {
clearLongPressTimer()
longPressActivated.value = false
longPressTimer.value = window.setTimeout(() => {
if (!props.selectionModeActive) {
emit('activateSelectionMode', message)
} else {
emit('toggleSelection', message)
}
longPressActivated.value = true
clearLongPressTimer()
}, LONG_PRESS_MS)
}
const handleTouchEnd = () => {
clearLongPressTimer()
}
const handleTouchMove = () => {
clearLongPressTimer()
}
onBeforeUnmount(() => {
clearLongPressTimer()
})
const handleSelectAllToggle = (value: boolean | null) => {
emit('toggleSelectAll', value === true)
} }
// Sorted messages (newest first) // Sorted messages (newest first)
const sortedMessages = computed(() => { const sortedMessages = computed(() => {
return [...props.messages].sort((a, b) => { return [...currentMessages.value].sort((a, b) => {
const dateA = a.properties.date ? new Date(a.properties.date).getTime() : 0 const dateA = a.properties.date ? new Date(a.properties.date).getTime() : 0
const dateB = b.properties.date ? new Date(b.properties.date).getTime() : 0 const dateB = b.properties.date ? new Date(b.properties.date).getTime() : 0
return dateB - dateA return dateB - dateA
@@ -107,19 +215,13 @@ const unreadCount = computed(() => {
return props.selectedCollection?.properties.unread ?? 0 return props.selectedCollection?.properties.unread ?? 0
}) })
const readCount = computed(() => {
const total = props.selectedCollection?.properties.total ?? 0
const unread = props.selectedCollection?.properties.unread ?? 0
return total - unread
})
const totalCount = computed(() => { const totalCount = computed(() => {
return props.selectedCollection?.properties.total ?? 0 return selectedFolder.value?.properties.total ?? 0
}) })
// True only when the collection explicitly provides total/unread counts // True only when the collection explicitly provides total/unread counts
const hasCountData = computed(() => { const hasCountData = computed(() => {
return props.selectedCollection?.properties.total != null return selectedFolder.value?.properties.total != null
}) })
</script> </script>
@@ -127,16 +229,53 @@ const hasCountData = computed(() => {
<div class="message-list"> <div class="message-list">
<!-- Header with folder name and counts --> <!-- Header with folder name and counts -->
<div v-if="selectedCollection" class="message-list-header"> <div v-if="selectedCollection" class="message-list-header">
<h2 class="text-h6">{{ selectedCollection.properties.label || 'Folder' }}</h2> <div class="message-list-heading">
<div class="folder-counts text-caption text-medium-emphasis"> <h2 class="text-h6">{{ selectedCollection.properties.label || 'Folder' }}</h2>
<span v-if="hasCountData"> <div class="folder-counts text-caption text-medium-emphasis">
<span class="unread-count">{{ unreadCount }}</span> <span v-if="hasCountData">
<span class="mx-1">/</span> <span class="unread-count">{{ unreadCount }}</span>
<span>{{ totalCount }}</span> <span class="mx-1">/</span>
</span> <span>{{ totalCount }}</span>
<span v-else-if="messages.length > 0"> </span>
{{ messages.length }} loaded <span v-else-if="messages.length > 0">
</span> {{ messages.length }} loaded
</span>
</div>
</div>
<div v-if="selectionModeActive && messages.length > 0" class="selection-summary">
<div class="selection-controls">
<v-checkbox-btn
:model-value="allCurrentMessagesSelected"
:indeterminate="hasSelection && !allCurrentMessagesSelected"
density="compact"
hide-details
@update:model-value="handleSelectAllToggle"
/>
<span class="text-caption text-medium-emphasis">
{{ selectionCount > 0 ? `${selectionCount} selected` : 'Select all loaded' }}
</span>
</div>
<div class="selection-actions">
<v-btn
size="small"
variant="text"
prepend-icon="mdi-folder-move-outline"
:disabled="!hasSelection"
@click="emit('moveSelection')"
>
Move
</v-btn>
<v-btn
size="small"
variant="text"
:disabled="!hasSelection"
@click="emit('clearSelection')"
>
Clear
</v-btn>
</div>
</div> </div>
</div> </div>
@@ -151,7 +290,7 @@ const hasCountData = computed(() => {
</div> </div>
<!-- Empty state --> <!-- Empty state -->
<div v-else-if="messages.length === 0" class="pa-8 text-center"> <div v-else-if="currentMessages.length === 0" class="pa-8 text-center">
<v-icon size="64" color="grey-lighten-1">mdi-email-outline</v-icon> <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-h6 mt-4 text-medium-emphasis">No messages</div>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
@@ -171,18 +310,36 @@ const hasCountData = computed(() => {
:key="`${message.provider}-${message.service}-${message.collection}-${message.identifier}`" :key="`${message.provider}-${message.service}-${message.collection}-${message.identifier}`"
class="message-item" class="message-item"
:class="{ :class="{
'opened': isOpened(message),
'selected': isSelected(message), 'selected': isSelected(message),
'selection-mode': selectionModeActive,
'unread': isUnread(message) 'unread': isUnread(message)
}" }"
@click="handleMessageClick(message)" @mousedown="handleMessageMouseDown($event, message)"
@click="handleMessageMouseClick($event, message)"
@touchstart.passive="handleTouchStart(message)"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
@touchmove="handleTouchMove"
lines="three" lines="three"
> >
<template v-slot:prepend> <template v-slot:prepend>
<v-avatar size="40" color="primary"> <div class="message-item-prepend">
<span class="text-white text-body-1"> <v-checkbox-btn
{{ (message.properties.from?.label || message.properties.from?.address || 'U')[0].toUpperCase() }} v-if="selectionModeActive || isSelected(message)"
</span> :model-value="isSelected(message)"
</v-avatar> density="compact"
hide-details
@click.stop
@update:model-value="handleSelectionToggle(message)"
/>
<v-avatar size="40" color="primary">
<span class="text-white text-body-1">
{{ (message.properties.from?.label || message.properties.from?.address || 'U')[0].toUpperCase() }}
</span>
</v-avatar>
</div>
</template> </template>
<v-list-item-title class="d-flex align-center"> <v-list-item-title class="d-flex align-center">
@@ -241,14 +398,42 @@ const hasCountData = computed(() => {
background-color: rgb(var(--v-theme-surface)); background-color: rgb(var(--v-theme-surface));
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 12px; gap: 12px;
.message-list-heading {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 { h2 {
margin: 0; margin: 0;
} }
} }
.selection-summary {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.selection-controls {
display: flex;
align-items: center;
gap: 4px;
}
.selection-actions {
display: flex;
align-items: center;
gap: 4px;
}
.folder-counts { .folder-counts {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -270,19 +455,41 @@ const hasCountData = computed(() => {
border-left: 3px solid transparent; border-left: 3px solid transparent;
} }
.message-item.selection-mode {
cursor: default;
}
.message-item-prepend {
display: flex;
align-items: center;
gap: 8px;
min-width: 72px;
}
.message-item:hover { .message-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.04); background-color: rgba(var(--v-theme-on-surface), 0.04);
} }
.message-item.selected { .message-item.opened {
background-color: rgba(var(--v-theme-primary), 0.12); background-color: rgba(var(--v-theme-primary), 0.12);
border-left-color: rgb(var(--v-theme-primary)); border-left-color: rgb(var(--v-theme-primary));
} }
.message-item.selected:not(.opened) {
background-color: rgba(var(--v-theme-primary), 0.08);
}
.message-item.unread { .message-item.unread {
:deep(.v-list-item-title), :deep(.v-list-item-title),
:deep(.v-list-item-subtitle:first-of-type) { :deep(.v-list-item-subtitle:first-of-type) {
font-weight: 600; font-weight: 600;
} }
} }
@media (max-width: 960px) {
.selection-summary {
flex-direction: column;
align-items: flex-start;
}
}
</style> </style>

View File

@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useUser } from '@KTXC' import { useUser } from '@KTXC'
import type { EntityInterface } from '@MailManager/types/entity' import type { EntityObject } from '@MailManager/models'
import type { MessageInterface } from '@MailManager/types/message'
import { MessageObject } from '@MailManager/models/message' import { MessageObject } from '@MailManager/models/message'
import { SecurityLevel } from '@/utile/emailSanitizer' import { SecurityLevel } from '@/utile/emailSanitizer'
import ReaderEmpty from './reader/ReaderEmpty.vue' import ReaderEmpty from './reader/ReaderEmpty.vue'
@@ -12,7 +11,7 @@ import ReaderBody from './reader/ReaderBody.vue'
// Props // Props
interface Props { interface Props {
message?: EntityInterface<MessageInterface> | null message?: EntityObject | null
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -22,11 +21,11 @@ const { getSetting } = useUser()
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
reply: [message: EntityInterface<MessageInterface>] reply: [message: EntityObject]
forward: [message: EntityInterface<MessageInterface>] forward: [message: EntityObject]
move: [message: EntityInterface<MessageInterface>] move: [message: EntityObject]
delete: [message: EntityInterface<MessageInterface>] delete: [message: EntityObject]
flag: [message: EntityInterface<MessageInterface>] flag: [message: EntityObject]
compose: [] compose: []
}>() }>()

View File

@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { EntityInterface } from '@MailManager/types/entity' import type { EntityObject } from '@MailManager/models'
import type { MessageInterface } from '@MailManager/types/message'
import { SecurityLevel } from '@/utile/emailSanitizer' import { SecurityLevel } from '@/utile/emailSanitizer'
interface Props { interface Props {
message: EntityInterface<MessageInterface> message: EntityObject
isHtml: boolean isHtml: boolean
allowImages: boolean allowImages: boolean
securityLevel: SecurityLevel securityLevel: SecurityLevel

View File

@@ -32,10 +32,14 @@ const {
loading, loading,
selectedFolder, selectedFolder,
selectedMessage, selectedMessage,
selectedMessageIds,
selectionModeActive,
composeMode, composeMode,
composeReplyTo, composeReplyTo,
currentMessages,
moveDialogVisible, moveDialogVisible,
selectionCount,
hasSelection,
allCurrentMessagesSelected,
} = storeToRefs(mailStore) } = storeToRefs(mailStore)
// Complex store/composable objects accessed directly (not simple refs) // Complex store/composable objects accessed directly (not simple refs)
@@ -50,7 +54,24 @@ onMounted(async () => {
// Handlers — thin wrappers that delegate to the store // Handlers — thin wrappers that delegate to the store
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder) const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder)
const handleMessageSelect = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value) const handleMessageOpen = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value)
const handleMessageSelectionToggle = (message: EntityObject) => mailStore.toggleMessageSelection(message)
const handleSelectionModeActivate = (message: EntityObject) => mailStore.activateSelectionMode(message)
const handleSelectAllToggle = (value: boolean) => {
if (value) {
mailStore.selectAllCurrentMessages()
return
}
mailStore.clearSelection()
}
const handleSelectionClear = () => mailStore.deactivateSelectionMode()
const handleSelectionMove = () => mailStore.openMoveDialogForSelection()
const handleCompose = (replyTo?: EntityObject) => mailStore.openCompose(replyTo) const handleCompose = (replyTo?: EntityObject) => mailStore.openCompose(replyTo)
@@ -65,7 +86,7 @@ const handleDelete = (message: EntityObject) => mailStore.deleteMessage(message)
const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message) const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message)
const handleMoveConfirm = async (folder: CollectionObject) => { const handleMoveConfirm = async (folder: CollectionObject) => {
await mailStore.moveMessage(folder) await mailStore.moveMessages(folder)
} }
const handleMoveCancel = () => mailStore.closeMoveDialog() const handleMoveCancel = () => mailStore.closeMoveDialog()
@@ -181,9 +202,19 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
<MessageList <MessageList
:messages="currentMessages" :messages="currentMessages"
:selected-message="selectedMessage" :selected-message="selectedMessage"
:selected-message-ids="selectedMessageIds"
:selection-mode-active="selectionModeActive"
:selection-count="selectionCount"
:has-selection="hasSelection"
:all-current-messages-selected="allCurrentMessagesSelected"
:selected-collection="selectedFolder" :selected-collection="selectedFolder"
:loading="loading" :loading="loading"
@select="handleMessageSelect" @open="handleMessageOpen"
@toggle-selection="handleMessageSelectionToggle"
@activate-selection-mode="handleSelectionModeActivate"
@toggle-select-all="handleSelectAllToggle"
@clear-selection="handleSelectionClear"
@move-selection="handleSelectionMove"
/> />
</div> </div>

View File

@@ -1,4 +1,4 @@
import { ref, computed, shallowRef } from 'vue' import { ref, computed, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore' import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore' import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
@@ -6,13 +6,12 @@ import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync' import { useMailSync } from '@MailManager/composables/useMailSync'
import { useSnackbar } from '@KTXC' import { useSnackbar } from '@KTXC'
import type { CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { MessageInterface } from '@MailManager/types/message'
import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models' import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models'
export const useMailStore = defineStore('mailStore', () => { export const useMailStore = defineStore('mailStore', () => {
const servicesStore = useServicesStore()
const collectionsStore = useCollectionsStore() const collectionsStore = useCollectionsStore()
const entitiesStore = useEntitiesStore() const entitiesStore = useEntitiesStore()
const servicesStore = useServicesStore()
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
// Background mail sync // Background mail sync
@@ -42,6 +41,8 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Selection State ─────────────────────────────────────────────────────── // ── Selection State ───────────────────────────────────────────────────────
const selectedFolder = shallowRef<CollectionObject | null>(null) const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null) const selectedMessage = shallowRef<EntityObject | null>(null)
const selectedMessageIds = ref<EntityIdentifier[]>([])
const selectionModeActive = ref(false)
// ── Compose State ───────────────────────────────────────────────────────── // ── Compose State ─────────────────────────────────────────────────────────
const composeMode = ref(false) const composeMode = ref(false)
@@ -49,7 +50,7 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Move State ──────────────────────────────────────────────────────────── // ── Move State ────────────────────────────────────────────────────────────
const moveDialogVisible = ref(false) const moveDialogVisible = ref(false)
const moveMessageCandidate = shallowRef<EntityObject | null>(null) const moveMessageCandidates = shallowRef<EntityObject[]>([])
// ── Computed ────────────────────────────────────────────────────────────── // ── Computed ──────────────────────────────────────────────────────────────
const currentMessages = computed(() => { const currentMessages = computed(() => {
@@ -64,6 +65,31 @@ export const useMailStore = defineStore('mailStore', () => {
) )
}) })
const selectedMessageIdSet = computed(() => new Set(selectedMessageIds.value))
const selectedMessageMap = computed(() => {
const messageMap = new Map<EntityIdentifier, EntityObject>()
currentMessages.value.forEach(message => {
const identifier = _entityIdentifier(message)
if (selectedMessageIdSet.value.has(identifier)) {
messageMap.set(identifier, message)
}
})
return messageMap
})
const selectedMessages = computed(() => Array.from(selectedMessageMap.value.values()))
const selectionCount = computed(() => selectedMessageIds.value.length)
const hasSelection = computed(() => selectionCount.value > 0)
const allCurrentMessagesSelected = computed(() => {
return currentMessages.value.length > 0 && currentMessages.value.every(message => isMessageSelected(message))
})
// ── Initialization ──────────────────────────────────────────────────────── // ── Initialization ────────────────────────────────────────────────────────
async function initialize() { async function initialize() {
@@ -134,12 +160,20 @@ export const useMailStore = defineStore('mailStore', () => {
} }
} }
// ── Sync Helpers ────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────
function _serviceKey(provider: string, service: string | number) { function _serviceKey(provider: string, service: string | number) {
return `${provider}:${String(service)}` return `${provider}:${String(service)}`
} }
function _collectionIdentifier(collection: CollectionObject): CollectionIdentifier {
return `${collection.provider}:${String(collection.service)}:${String(collection.identifier)}` as CollectionIdentifier
}
function _entityIdentifier(entity: EntityObject): EntityIdentifier {
return `${entity.provider}:${String(entity.service)}:${String(entity.collection)}:${String(entity.identifier)}` as EntityIdentifier
}
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) { function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
serviceFolderLoadingState.value = { serviceFolderLoadingState.value = {
...serviceFolderLoadingState.value, ...serviceFolderLoadingState.value,
@@ -208,42 +242,90 @@ export const useMailStore = defineStore('mailStore', () => {
return serviceFolderErrorState.value[_serviceKey(provider, service)] ?? null return serviceFolderErrorState.value[_serviceKey(provider, service)] ?? null
} }
function _entityIdentifier(entity: EntityObject): EntityIdentifier { function _serviceFor(provider: string, serviceIdentifier: string | number) {
return `${entity.provider}:${String(entity.service)}:${String(entity.collection)}:${String(entity.identifier)}` as EntityIdentifier return servicesStore.services.find(service =>
service.provider === provider &&
String(service.identifier) === String(serviceIdentifier),
) ?? null
} }
function _collectionIdentifier(collection: CollectionObject): CollectionIdentifier { function _reloadFolderMessages(folder: CollectionObject) {
return `${collection.provider}:${String(collection.service)}:${String(collection.identifier)}` as CollectionIdentifier return entitiesStore.list({
[folder.provider]: {
[String(folder.service)]: {
[String(folder.identifier)]: true,
},
},
})
} }
function _isSameMessage(left: EntityObject | null, right: EntityObject): boolean { function _setSelectedMessageIds(nextIds: EntityIdentifier[]) {
if (!left) { selectedMessageIds.value = Array.from(new Set(nextIds))
return false }
function _removeSelection(sourceIdentifiers: EntityIdentifier[]) {
if (sourceIdentifiers.length === 0 || selectedMessageIds.value.length === 0) {
return
} }
return ( const removedIds = new Set(sourceIdentifiers)
left.provider === right.provider && selectedMessageIds.value = selectedMessageIds.value.filter(identifier => !removedIds.has(identifier))
String(left.service) === String(right.service) &&
String(left.collection) === String(right.collection) &&
String(left.identifier) === String(right.identifier)
)
} }
function _reconcileSelection() {
if (!selectedFolder.value) {
clearSelection()
selectedMessage.value = null
return
}
const currentMessageIdentifiers = new Set(currentMessages.value.map(message => _entityIdentifier(message)))
const nextSelectedIds = selectedMessageIds.value.filter(identifier => currentMessageIdentifiers.has(identifier))
if (nextSelectedIds.length !== selectedMessageIds.value.length) {
selectedMessageIds.value = nextSelectedIds
}
if (selectedMessage.value && !currentMessageIdentifiers.has(_entityIdentifier(selectedMessage.value))) {
selectedMessage.value = 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,
}
}
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,
}
}
watch(currentMessages, () => {
_reconcileSelection()
})
// ── Actions ─────────────────────────────────────────────────────────────── // ── Actions ───────────────────────────────────────────────────────────────
async function selectFolder(folder: CollectionObject) { async function selectFolder(folder: CollectionObject) {
selectedFolder.value = folder selectedFolder.value = folder
selectedMessage.value = null selectedMessage.value = null
clearSelection()
selectionModeActive.value = false
composeMode.value = false composeMode.value = false
try { try {
await entitiesStore.list({ await _reloadFolderMessages(folder)
[folder.provider]: {
[folder.service]: {
[folder.identifier]: true,
},
},
})
} catch (error) { } catch (error) {
console.error('[Mail] Failed to load messages:', error) console.error('[Mail] Failed to load messages:', error)
} }
@@ -266,14 +348,79 @@ export const useMailStore = defineStore('mailStore', () => {
selectedMessage.value = null selectedMessage.value = null
} }
function openMoveDialog(message: EntityObject) { function isMessageSelected(message: EntityObject) {
moveMessageCandidate.value = message return selectedMessageIdSet.value.has(_entityIdentifier(message))
}
function toggleMessageSelection(message: EntityObject) {
const identifier = _entityIdentifier(message)
selectionModeActive.value = true
if (selectedMessageIdSet.value.has(identifier)) {
selectedMessageIds.value = selectedMessageIds.value.filter(selectedId => selectedId !== identifier)
if (selectedMessageIds.value.length === 0) {
selectionModeActive.value = false
}
return
}
_setSelectedMessageIds([...selectedMessageIds.value, identifier])
}
function selectAllCurrentMessages() {
selectionModeActive.value = true
_setSelectedMessageIds(currentMessages.value.map(message => _entityIdentifier(message)))
}
function activateSelectionMode(message?: EntityObject) {
selectionModeActive.value = true
if (message) {
const identifier = _entityIdentifier(message)
if (!selectedMessageIdSet.value.has(identifier)) {
_setSelectedMessageIds([...selectedMessageIds.value, identifier])
}
}
}
function deactivateSelectionMode() {
selectionModeActive.value = false
clearSelection()
}
function clearSelection() {
selectedMessageIds.value = []
}
function openMoveDialog(messages: EntityObject | EntityObject[]) {
const nextCandidates = Array.isArray(messages) ? messages : [messages]
moveMessageCandidates.value = Array.from(
new Map(nextCandidates.map(candidate => [_entityIdentifier(candidate), candidate])).values(),
)
if (moveMessageCandidates.value.length === 0) {
return
}
moveDialogVisible.value = true moveDialogVisible.value = true
} }
function openMoveDialogForSelection() {
if (selectedMessages.value.length === 0) {
return
}
openMoveDialog(selectedMessages.value)
}
function closeMoveDialog() { function closeMoveDialog() {
moveDialogVisible.value = false moveDialogVisible.value = false
moveMessageCandidate.value = null moveMessageCandidates.value = []
} }
function closeCompose() { function closeCompose() {
@@ -291,24 +438,20 @@ export const useMailStore = defineStore('mailStore', () => {
} }
} }
async function deleteMessage(message: EntityObject) { async function moveMessages(targetFolder: CollectionObject) {
// TODO: implement delete via entity / collection store const candidates = moveMessageCandidates.value
console.log('[Mail] Delete message:', message.identifier)
}
async function moveMessage(targetFolder: CollectionObject) { if (candidates.length === 0) {
const message = moveMessageCandidate.value
if (!message) {
return return
} }
const isSameCollection = const movableCandidates = candidates.filter(message => !(
targetFolder.provider === message.provider && targetFolder.provider === message.provider &&
String(targetFolder.service) === String(message.service) && String(targetFolder.service) === String(message.service) &&
String(targetFolder.identifier) === String(message.collection) String(targetFolder.identifier) === String(message.collection)
))
if (isSameCollection) { if (movableCandidates.length === 0) {
closeMoveDialog() closeMoveDialog()
return return
} }
@@ -316,32 +459,59 @@ export const useMailStore = defineStore('mailStore', () => {
loading.value = true loading.value = true
try { try {
const sourceIdentifier = _entityIdentifier(message) const sourceIdentifiers = movableCandidates.map(message => _entityIdentifier(message))
const response = await entitiesStore.move(_collectionIdentifier(targetFolder), [sourceIdentifier]) const response = await entitiesStore.move(_collectionIdentifier(targetFolder), sourceIdentifiers)
const result = response[sourceIdentifier] const successfulMoves: EntityIdentifier[] = []
const failedMoves: string[] = []
if (!result || !result.success) { Object.entries(response).forEach(([sourceIdentifier, result]) => {
throw new Error(result && 'error' in result ? result.error : 'Failed to move message') if (result.success) {
successfulMoves.push(sourceIdentifier as EntityIdentifier)
return
}
failedMoves.push(result.error)
})
if (successfulMoves.length === 0) {
throw new Error(failedMoves[0] ?? 'Failed to move messages')
} }
if (_isSameMessage(selectedMessage.value, message)) { _removeSelection(successfulMoves)
if (selectedMessage.value && successfulMoves.includes(_entityIdentifier(selectedMessage.value))) {
selectedMessage.value = null selectedMessage.value = null
} }
const service = servicesStore.services.find(entry => if (selectedMessageIds.value.length === 0) {
entry.provider === message.provider && selectionModeActive.value = false
String(entry.identifier) === String(message.service),
)
if (service) {
void loadFoldersForService(service)
} }
notify(`Message moved to "${targetFolder.properties.label || targetFolder.identifier}"`, 'success')
closeMoveDialog() closeMoveDialog()
const servicesToRefresh = new Map<string, ServiceObject>()
movableCandidates.forEach(message => {
const service = _serviceFor(message.provider, message.service)
if (service && service.identifier !== null) {
servicesToRefresh.set(`${service.provider}:${String(service.identifier)}`, service)
}
})
const targetService = _serviceFor(targetFolder.provider, targetFolder.service)
if (targetService && targetService.identifier !== null) {
servicesToRefresh.set(`${targetService.provider}:${String(targetService.identifier)}`, targetService)
}
await Promise.allSettled([
...Array.from(servicesToRefresh.values()).map(service => loadFoldersForService(service)),
...(selectedFolder.value ? [_reloadFolderMessages(selectedFolder.value)] : []),
])
const notification = _formatMoveNotification(successfulMoves.length, failedMoves.length, targetFolder)
notify(notification.message, notification.color)
} catch (error) { } catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move message' const messageText = error instanceof Error ? error.message : 'Failed to move messages'
console.error('[Mail] Failed to move message:', error) console.error('[Mail] Failed to move messages:', error)
notify(messageText, 'error') notify(messageText, 'error')
throw error throw error
} finally { } finally {
@@ -349,6 +519,11 @@ export const useMailStore = defineStore('mailStore', () => {
} }
} }
async function deleteMessage(message: EntityObject) {
// TODO: implement delete via entity / collection store
console.log('[Mail] Delete message:', message.identifier)
}
function toggleSidebar() { function toggleSidebar() {
sidebarVisible.value = !sidebarVisible.value sidebarVisible.value = !sidebarVisible.value
} }
@@ -376,27 +551,41 @@ export const useMailStore = defineStore('mailStore', () => {
loading, loading,
selectedFolder, selectedFolder,
selectedMessage, selectedMessage,
selectedMessageIds,
selectionModeActive,
composeMode, composeMode,
composeReplyTo, composeReplyTo,
moveDialogVisible, moveDialogVisible,
moveMessageCandidate, moveMessageCandidates,
serviceFolderLoadingState, serviceFolderLoadingState,
serviceFolderLoadedState, serviceFolderLoadedState,
serviceFolderErrorState, serviceFolderErrorState,
// Computed // Computed
currentMessages, currentMessages,
selectedMessageMap,
selectedMessages,
selectionCount,
hasSelection,
allCurrentMessagesSelected,
// Actions // Actions
selectFolder, selectFolder,
selectMessage, selectMessage,
isMessageSelected,
activateSelectionMode,
deactivateSelectionMode,
toggleMessageSelection,
selectAllCurrentMessages,
clearSelection,
openCompose, openCompose,
openMoveDialog, openMoveDialog,
openMoveDialogForSelection,
closeMoveDialog, closeMoveDialog,
closeCompose, closeCompose,
afterSent, afterSent,
deleteMessage, deleteMessage,
moveMessage, moveMessages,
toggleSidebar, toggleSidebar,
openSettings, openSettings,
notify, notify,