feat: message list message menu

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-05-30 18:30:43 -04:00
parent ec776bbb78
commit d24e6110b9
3 changed files with 256 additions and 17 deletions

View File

@@ -4,6 +4,7 @@ import type { EntityIdentifier } from '@MailManager/types/common'
import type { EntityObject } from '@MailManager/models' import type { EntityObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import RecipientDetails from '@/components/common/RecipientDetails.vue' import RecipientDetails from '@/components/common/RecipientDetails.vue'
import MessageListItemMenu from '@/components/MessageListItemMenu.vue'
// Props // Props
interface Props { interface Props {
@@ -24,6 +25,11 @@ const props = withDefaults(defineProps<Props>(), {
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
open: [message: EntityObject] open: [message: EntityObject]
reply: [message: EntityObject]
forward: [message: EntityObject]
move: [message: EntityObject]
delete: [message: EntityObject]
flag: [message: EntityObject, flag: string, value: boolean]
selectionMode: [message: EntityObject] selectionMode: [message: EntityObject]
selectionToggleOne: [message: EntityObject] selectionToggleOne: [message: EntityObject]
selectionToggleAll: [value: boolean] selectionToggleAll: [value: boolean]
@@ -36,6 +42,9 @@ const emit = defineEmits<{
const longPressTimer = ref<number | null>(null) const longPressTimer = ref<number | null>(null)
const longPressActivated = ref(false) const longPressActivated = ref(false)
const suppressNextClick = ref(false) const suppressNextClick = ref(false)
const contextMenuVisible = ref(false)
const contextMenuTarget = ref<[number, number] | undefined>(undefined)
const contextMenuMessage = ref<EntityObject | null>(null)
const LONG_PRESS_MS = 400 const LONG_PRESS_MS = 400
const selectedIdSet = computed(() => new Set(props.selectionList)) const selectedIdSet = computed(() => new Set(props.selectionList))
const selectionCount = computed(() => props.selectionList.length ?? 0) const selectionCount = computed(() => props.selectionList.length ?? 0)
@@ -117,7 +126,7 @@ const formatDate = (date: Date | string | number | null | undefined): string =>
} }
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => { const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
return event.target instanceof Element && event.target.closest('.message-selection-checkbox') !== null return event.target instanceof Element && event.target.closest('.message-selection-checkbox, .message-item-menu-trigger, .message-item-menu-content') !== null
} }
const handleSelectionToggleOne = (message: EntityObject) => { const handleSelectionToggleOne = (message: EntityObject) => {
@@ -173,6 +182,43 @@ const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
emit('selectionMode', message) emit('selectionMode', message)
} }
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) => { const handleTouchStart = (message: EntityObject) => {
clearLongPressTimer() clearLongPressTimer()
longPressActivated.value = false longPressActivated.value = false
@@ -346,6 +392,7 @@ onBeforeUnmount(() => {
}" }"
@mousedown="handleMouseDown($event, message)" @mousedown="handleMouseDown($event, message)"
@click="handleMouseClick($event, message)" @click="handleMouseClick($event, message)"
@contextmenu="openContextMenu($event, message)"
@touchstart.passive="handleTouchStart(message)" @touchstart.passive="handleTouchStart(message)"
@touchend="handleTouchEnd" @touchend="handleTouchEnd"
@touchcancel="handleTouchEnd" @touchcancel="handleTouchEnd"
@@ -396,6 +443,7 @@ onBeforeUnmount(() => {
</v-list-item-subtitle> </v-list-item-subtitle>
<template v-slot:append> <template v-slot:append>
<div class="message-item-append">
<div class="d-flex flex-column align-center"> <div class="d-flex flex-column align-center">
<v-icon <v-icon
v-if="message.properties.isFlagged" v-if="message.properties.isFlagged"
@@ -413,11 +461,34 @@ onBeforeUnmount(() => {
mdi-paperclip mdi-paperclip
</v-icon> </v-icon>
</div> </div>
<MessageListItemMenu
:message="message"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
</div>
</template> </template>
</v-list-item> </v-list-item>
<v-divider /> <v-divider />
</template> </template>
</v-virtual-scroll> </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> </div>
</template> </template>
@@ -502,6 +573,31 @@ onBeforeUnmount(() => {
min-width: 72px; 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 { .message-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.04); background-color: rgba(var(--v-theme-on-surface), 0.04);
} }

View 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>

View File

@@ -297,6 +297,11 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
:selection-mode="selectionMode" :selection-mode="selectionMode"
:loading="loading" :loading="loading"
@open="handleMessageOpen" @open="handleMessageOpen"
@reply="handleMessageComposeReply"
@forward="handleMessageComposeForward"
@move="handleMessageMove"
@delete="handleMessageDelete"
@flag="handleMessageFlag"
@selection-mode="handleMessageSelectionMode" @selection-mode="handleMessageSelectionMode"
@selection-toggle-one="handleMessageSelectionToggleOne" @selection-toggle-one="handleMessageSelectionToggleOne"
@selection-toggle-all="handleMessageSelectionToggleAll" @selection-toggle-all="handleMessageSelectionToggleAll"