feat: message list message menu #54
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user