feat: multi select #11

Merged
Sebastian merged 1 commits from feat/multiselect into main 2026-03-29 21:02:28 +00:00
4 changed files with 508 additions and 80 deletions
Showing only changes of commit 69f3c430cc - Show all commits

View File

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

View File

@@ -1,5 +1,6 @@
<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 { MessageInterface } from '@MailManager/types/message'
import type { CollectionObject } from '@MailManager/models/collection'
@@ -8,21 +9,42 @@ import type { CollectionObject } from '@MailManager/models/collection'
interface Props {
messages: EntityInterface<MessageInterface>[]
selectedMessage?: EntityInterface<MessageInterface> | null
selectedMessageIds?: EntityIdentifier[]
selectionModeActive?: boolean
selectionCount?: number
hasSelection?: boolean
allCurrentMessagesSelected?: boolean
selectedCollection?: CollectionObject | null
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
loading: false,
selectedMessageIds: () => [],
selectionModeActive: false,
selectionCount: 0,
hasSelection: false,
allCurrentMessagesSelected: false,
})
// Emits
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 isSelected = (message: EntityInterface<MessageInterface>): boolean => {
const longPressTimer = ref<number | null>(null)
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
return (
message.provider === props.selectedMessage.provider &&
@@ -32,6 +54,12 @@ const isSelected = (message: EntityInterface<MessageInterface>): boolean => {
)
}
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
const isUnread = (message: EntityInterface<MessageInterface>): boolean => {
return !message.properties.flags?.read
@@ -90,7 +118,87 @@ const truncate = (text: string | null | undefined, length: number = 100): string
// Handle message click
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)
@@ -107,12 +215,6 @@ const unreadCount = computed(() => {
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(() => {
return props.selectedCollection?.properties.total ?? 0
})
@@ -127,16 +229,53 @@ const hasCountData = computed(() => {
<div class="message-list">
<!-- Header with folder name and counts -->
<div v-if="selectedCollection" class="message-list-header">
<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 class="mx-1">/</span>
<span>{{ totalCount }}</span>
</span>
<span v-else-if="messages.length > 0">
{{ messages.length }} loaded
</span>
<div class="message-list-heading">
<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 class="mx-1">/</span>
<span>{{ totalCount }}</span>
</span>
<span v-else-if="messages.length > 0">
{{ 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>
@@ -171,18 +310,36 @@ const hasCountData = computed(() => {
:key="`${message.provider}-${message.service}-${message.collection}-${message.identifier}`"
class="message-item"
:class="{
'opened': isOpened(message),
'selected': isSelected(message),
'selection-mode': selectionModeActive,
'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"
>
<template v-slot:prepend>
<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 class="message-item-prepend">
<v-checkbox-btn
v-if="selectionModeActive || isSelected(message)"
:model-value="isSelected(message)"
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>
<v-list-item-title class="d-flex align-center">
@@ -241,14 +398,42 @@ const hasCountData = computed(() => {
background-color: rgb(var(--v-theme-surface));
flex-shrink: 0;
display: flex;
align-items: center;
flex-direction: column;
gap: 12px;
.message-list-heading {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 {
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 {
display: flex;
align-items: center;
@@ -270,19 +455,41 @@ const hasCountData = computed(() => {
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 {
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);
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 {
:deep(.v-list-item-title),
:deep(.v-list-item-subtitle:first-of-type) {
font-weight: 600;
}
}
@media (max-width: 960px) {
.selection-summary {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -32,10 +32,15 @@ const {
loading,
selectedFolder,
selectedMessage,
selectedMessageIds,
selectionModeActive,
composeMode,
composeReplyTo,
currentMessages,
moveDialogVisible,
selectionCount,
hasSelection,
allCurrentMessagesSelected,
} = storeToRefs(mailStore)
// Complex store/composable objects accessed directly (not simple refs)
@@ -50,7 +55,24 @@ onMounted(async () => {
// Handlers — thin wrappers that delegate to the store
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)
@@ -65,7 +87,7 @@ const handleDelete = (message: EntityObject) => mailStore.deleteMessage(message)
const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message)
const handleMoveConfirm = async (folder: CollectionObject) => {
await mailStore.moveMessage(folder)
await mailStore.moveMessages(folder)
}
const handleMoveCancel = () => mailStore.closeMoveDialog()
@@ -181,9 +203,19 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
<MessageList
:messages="currentMessages"
: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"
:loading="loading"
@select="handleMessageSelect"
@open="handleMessageOpen"
@toggle-selection="handleMessageSelectionToggle"
@activate-selection-mode="handleSelectionModeActivate"
@toggle-select-all="handleSelectAllToggle"
@clear-selection="handleSelectionClear"
@move-selection="handleSelectionMove"
/>
</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 { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
@@ -6,7 +6,6 @@ import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync'
import { useSnackbar } from '@KTXC'
import type { CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { MessageInterface } from '@MailManager/types/message'
import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models'
export const useMailStore = defineStore('mailStore', () => {
@@ -42,6 +41,8 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Selection State ───────────────────────────────────────────────────────
const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null)
const selectedMessageIds = ref<EntityIdentifier[]>([])
const selectionModeActive = ref(false)
// ── Compose State ─────────────────────────────────────────────────────────
const composeMode = ref(false)
@@ -49,7 +50,7 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Move State ────────────────────────────────────────────────────────────
const moveDialogVisible = ref(false)
const moveMessageCandidate = shallowRef<EntityObject | null>(null)
const moveMessageCandidates = shallowRef<EntityObject[]>([])
// ── 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 ────────────────────────────────────────────────────────
async function initialize() {
@@ -216,34 +242,90 @@ export const useMailStore = defineStore('mailStore', () => {
return `${collection.provider}:${String(collection.service)}:${String(collection.identifier)}` as CollectionIdentifier
}
function _isSameMessage(left: EntityObject | null, right: EntityObject): boolean {
if (!left) {
return false
function _serviceFor(provider: string, serviceIdentifier: string | number) {
return servicesStore.services.find(service =>
service.provider === provider &&
String(service.identifier) === String(serviceIdentifier),
) ?? null
}
function _reloadFolderMessages(folder: CollectionObject) {
return entitiesStore.list({
[folder.provider]: {
[String(folder.service)]: {
[String(folder.identifier)]: true,
},
},
})
}
function _setSelectedMessageIds(nextIds: EntityIdentifier[]) {
selectedMessageIds.value = Array.from(new Set(nextIds))
}
function _removeSelection(sourceIdentifiers: EntityIdentifier[]) {
if (sourceIdentifiers.length === 0 || selectedMessageIds.value.length === 0) {
return
}
return (
left.provider === right.provider &&
String(left.service) === String(right.service) &&
String(left.collection) === String(right.collection) &&
String(left.identifier) === String(right.identifier)
)
const removedIds = new Set(sourceIdentifiers)
selectedMessageIds.value = selectedMessageIds.value.filter(identifier => !removedIds.has(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 ───────────────────────────────────────────────────────────────
async function selectFolder(folder: CollectionObject) {
selectedFolder.value = folder
selectedMessage.value = null
clearSelection()
selectionModeActive.value = false
composeMode.value = false
try {
await entitiesStore.list({
[folder.provider]: {
[folder.service]: {
[folder.identifier]: true,
},
},
})
await _reloadFolderMessages(folder)
} catch (error) {
console.error('[Mail] Failed to load messages:', error)
}
@@ -266,14 +348,79 @@ export const useMailStore = defineStore('mailStore', () => {
selectedMessage.value = null
}
function openMoveDialog(message: EntityObject) {
moveMessageCandidate.value = message
function isMessageSelected(message: EntityObject) {
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
}
function openMoveDialogForSelection() {
if (selectedMessages.value.length === 0) {
return
}
openMoveDialog(selectedMessages.value)
}
function closeMoveDialog() {
moveDialogVisible.value = false
moveMessageCandidate.value = null
moveMessageCandidates.value = []
}
function closeCompose() {
@@ -296,19 +443,20 @@ export const useMailStore = defineStore('mailStore', () => {
console.log('[Mail] Delete message:', message.identifier)
}
async function moveMessage(targetFolder: CollectionObject) {
const message = moveMessageCandidate.value
async function moveMessages(targetFolder: CollectionObject) {
const candidates = moveMessageCandidates.value
if (!message) {
if (candidates.length === 0) {
return
}
const isSameCollection =
const movableCandidates = candidates.filter(message => !(
targetFolder.provider === message.provider &&
String(targetFolder.service) === String(message.service) &&
String(targetFolder.identifier) === String(message.collection)
))
if (isSameCollection) {
if (movableCandidates.length === 0) {
closeMoveDialog()
return
}
@@ -316,32 +464,59 @@ export const useMailStore = defineStore('mailStore', () => {
loading.value = true
try {
const sourceIdentifier = _entityIdentifier(message)
const response = await entitiesStore.move(_collectionIdentifier(targetFolder), [sourceIdentifier])
const result = response[sourceIdentifier]
const sourceIdentifiers = movableCandidates.map(message => _entityIdentifier(message))
const response = await entitiesStore.move(_collectionIdentifier(targetFolder), sourceIdentifiers)
const successfulMoves: EntityIdentifier[] = []
const failedMoves: string[] = []
if (!result || !result.success) {
throw new Error(result && 'error' in result ? result.error : 'Failed to move message')
Object.entries(response).forEach(([sourceIdentifier, result]) => {
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
}
const service = servicesStore.services.find(entry =>
entry.provider === message.provider &&
String(entry.identifier) === String(message.service),
)
if (service) {
void loadFoldersForService(service)
if (selectedMessageIds.value.length === 0) {
selectionModeActive.value = false
}
notify(`Message moved to "${targetFolder.properties.label || targetFolder.identifier}"`, 'success')
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) {
const messageText = error instanceof Error ? error.message : 'Failed to move message'
console.error('[Mail] Failed to move message:', error)
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
console.error('[Mail] Failed to move messages:', error)
notify(messageText, 'error')
throw error
} finally {
@@ -376,27 +551,41 @@ export const useMailStore = defineStore('mailStore', () => {
loading,
selectedFolder,
selectedMessage,
selectedMessageIds,
selectionModeActive,
composeMode,
composeReplyTo,
moveDialogVisible,
moveMessageCandidate,
moveMessageCandidates,
serviceFolderLoadingState,
serviceFolderLoadedState,
serviceFolderErrorState,
// Computed
currentMessages,
selectedMessageMap,
selectedMessages,
selectionCount,
hasSelection,
allCurrentMessagesSelected,
// Actions
selectFolder,
selectMessage,
isMessageSelected,
activateSelectionMode,
deactivateSelectionMode,
toggleMessageSelection,
selectAllCurrentMessages,
clearSelection,
openCompose,
openMoveDialog,
openMoveDialogForSelection,
closeMoveDialog,
closeCompose,
afterSent,
deleteMessage,
moveMessage,
moveMessages,
toggleSidebar,
openSettings,
notify,