Added possibility to Drag&Drop message selection between folders.

fix #3775
This commit is contained in:
Antoine Nguyen
2025-11-25 15:22:20 +01:00
parent ab9231cc8c
commit ae1ba7149d
11 changed files with 195 additions and 58 deletions
+14 -6
View File
@@ -65,23 +65,31 @@ export default {
responseType: 'blob',
})
},
deleteSelection(mailbox, selection) {
moveSelection(source, destination, selection) {
const body = {
mailbox,
source,
destination,
selection,
}
return repository.post('/webmail/emails/move/', body)
},
deleteSelection(source, selection) {
const body = {
source,
selection,
}
return repository.post('/webmail/emails/delete/', body)
},
markSelectionAsJunk(mailbox, selection) {
markSelectionAsJunk(source, selection) {
const body = {
mailbox,
source,
selection,
}
return repository.post('/webmail/emails/mark_as_junk/', body)
},
markSelectionAsNotJunk(mailbox, selection) {
markSelectionAsNotJunk(source, selection) {
const body = {
mailbox,
source,
selection,
}
return repository.post('/webmail/emails/mark_as_not_junk/', body)
+77 -37
View File
@@ -5,6 +5,7 @@
v-model="selectAll"
class="mr-4"
hide-details
color="primary"
@update:model-value="toggleAllSelection"
/>
@@ -88,7 +89,7 @@
</v-menu>
</v-btn>
<v-spacer />
<div v-if="emails.results" class="d-flex align-center">
<div v-if="emails" class="d-flex align-center">
<div class="text-caption mr-2">
{{ emails.first_index }}-{{ emails.last_index }} {{ $gettext('on') }}
{{ emails.count }}
@@ -119,14 +120,17 @@
:key="email.imapid"
density="compact"
class="mb-2 mx-1"
draggable="true"
@dragstart="onDragStart(email)"
>
<v-card-text
class="d-flex align-center"
:class="{ 'font-weight-bold': email.style === 'unseen' }"
>
<v-checkbox
v-model="selection"
v-model="webmailStore.selection"
:value="email.imapid"
color="primary"
hide-details
/>
<v-btn
@@ -170,10 +174,10 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useGettext } from 'vue3-gettext'
import { useBusStore } from '@/stores'
import { useBusStore, useWebmailStore } from '@/stores'
import api from '@/api/webmail'
const props = defineProps({
@@ -183,18 +187,20 @@ const props = defineProps({
},
})
const { $gettext } = useGettext()
const { $gettext, $ngettext } = useGettext()
const { displayNotification, reloadMailboxCounters } = useBusStore()
const webmailStore = useWebmailStore()
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const emails = ref({})
const emails = ref([])
const emailsPage = ref({})
const page = ref(1)
const search = ref('')
const selectAll = ref(false)
const selection = ref([])
const working = ref(false)
const emailList = ref(null)
let intervalId = null
@@ -233,18 +239,18 @@ const submitSearch = () => {
const toggleAllSelection = (value) => {
if (!value) {
selection.value = []
webmailStore.selection = []
} else {
selection.value = emails.value.results.map((email) => email.imapid)
webmailStore.selection = emails.value.results.map((email) => email.imapid)
}
}
const deleteSelection = () => {
if (!selection.value.length) {
if (!webmailStore.selection.length) {
return
}
working.value = true
api.deleteSelection(currentMailbox.value, selection.value).then(() => {
api.deleteSelection(currentMailbox.value, webmailStore.selection).then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) deleted') })
fetchEmails()
@@ -253,41 +259,47 @@ const deleteSelection = () => {
}
const markSelectionAsJunk = () => {
if (!selection.value.length) {
if (!webmailStore.selection.length) {
return
}
working.value = true
api.markSelectionAsJunk(currentMailbox.value, selection.value).then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) marked as junk') })
autoRefreshContent()
})
api
.markSelectionAsJunk(currentMailbox.value, webmailStore.selection)
.then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) marked as junk') })
autoRefreshContent()
})
}
const markSelectionAsNotJunk = () => {
if (!selection.value.length) {
if (!webmailStore.selection.length) {
return
}
working.value = true
api.markSelectionAsNotJunk(currentMailbox.value, selection.value).then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) marked as not junk') })
autoRefreshContent()
})
api
.markSelectionAsNotJunk(currentMailbox.value, webmailStore.selection)
.then(() => {
working.value = false
displayNotification({ msg: $gettext('Message(s) marked as not junk') })
autoRefreshContent()
})
}
const flagSelection = (status) => {
if (!selection.value.length) {
if (!webmailStore.selection.length) {
return
}
working.value = true
api.flagSelection(currentMailbox.value, selection.value, status).then(() => {
working.value = false
selection.value = []
displayNotification({ msg: $gettext('Message(s) flagged') })
fetchEmails()
reloadMailboxCounters()
})
api
.flagSelection(currentMailbox.value, webmailStore.selection, status)
.then(() => {
working.value = false
webmailStore.selection = []
displayNotification({ msg: $gettext('Message(s) flagged') })
fetchEmails()
reloadMailboxCounters()
})
}
const emptyMailbox = () => {
@@ -305,6 +317,25 @@ const toggleFollowState = async (email) => {
email.flagged = flag === 'flagged'
}
const onDragStart = (email) => {
if (!webmailStore.selection.includes(email.imapid)) {
webmailStore.selection = [email.imapid]
}
const ghost = document.createElement('div')
const count = webmailStore.selection.length || 0
const msg = $ngettext('%{count} message', '%{count} messages', count, {
count,
})
ghost.innerHTML = `
<div class="pa-4 rounded-lg" style="background: #a9a9a9; opacity: 0.8; width: 300px">
<div style="font-weight: 600">${msg}</div>
</div>
`
document.body.appendChild(ghost)
event.dataTransfer.setDragImage(ghost, -10, -10)
setTimeout(() => ghost.remove(), 0)
}
onMounted(() => {
intervalId = setInterval(autoRefreshContent, 300 * 1000)
})
@@ -320,13 +351,22 @@ watch(
},
{ immediate: true }
)
watch(selection, () => {
if (!selection.value.length) {
selectAll.value = false
} else {
selectAll.value = true
watch(
() => webmailStore.selection,
() => {
if (!webmailStore.selection.length) {
selectAll.value = false
} else {
selectAll.value = true
}
}
})
)
watch(
() => webmailStore.listingKey,
() => {
fetchEmails()
}
)
watch(page, () => {
fetchEmails()
})
@@ -12,6 +12,10 @@
@click="updateSelection(mailbox.name)"
@mouseover="setHover(mailbox, true)"
@mouseleave="setHover(mailbox, false)"
@dragover.prevent
@dragenter="setHover(mailbox, true)"
@dragleave="setHover(mailbox, false)"
@drop="onDrop(mailbox)"
>
<v-icon :icon="iconByMailboxType[mailbox.type]" class="mr-4" />
<template v-if="mailbox.name === route.query.mailbox">
@@ -57,7 +61,8 @@
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useBusStore } from '@/stores'
import { useGettext } from 'vue3-gettext'
import { useBusStore, useWebmailStore } from '@/stores'
import api from '@/api/webmail'
const props = defineProps({
@@ -86,7 +91,9 @@ const model = defineModel()
const emit = defineEmits(['update:modelValue'])
const route = useRoute()
const { $gettext } = useGettext()
const busStore = useBusStore()
const webmailStore = useWebmailStore()
const iconByMailboxType = {
inbox: 'mdi-inbox',
@@ -152,6 +159,25 @@ function updateSelection(value) {
}
}
async function onDrop(mailbox) {
try {
busStore.displayNotification({
msg: $gettext('Moving selection...'),
type: 'info',
timeout: 0,
})
const resp = await api.moveSelection(
route.query.mailbox,
mailbox.name,
webmailStore.selection
)
webmailStore.listingKey++
webmailStore.selection = []
} finally {
busStore.hideNotification()
}
}
watch(
() => busStore.mbCounterKey,
() => {
@@ -169,4 +195,8 @@ watch(
border-radius: 5px;
}
}
.mailbox * {
pointer-events: none;
}
</style>
@@ -54,17 +54,21 @@ const props = defineProps({
const authUser = computed(() => authStore.authUser)
const notificationColor = computed(() => busStore.notificationColor)
const notificationTimeout = computed(() => busStore.notificationTimeout)
const notification = computed(() => busStore.notification)
const snackbar = ref(false)
const notificationTimeout = 2000
busStore.$onAction(({ name, after }) => {
if (name === 'displayNotification') {
after(() => {
snackbar.value = true
})
}
if (name === 'hideNotification') {
after(() => {
snackbar.value = false
})
}
}, true)
if (props.loadTheme) {
+12 -2
View File
@@ -3,14 +3,16 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useBusStore = defineStore('bus', () => {
const notification = ref({})
const notification = ref('')
const notificationColor = ref('')
const notificationTimeout = ref(2000)
const dataKey = ref(1)
const mbCounterKey = ref(1)
async function $reset() {
notification.value = {}
notification.value = ''
notificationColor.value = ''
notificationTimeout.value = 2000
dataKey.value = 1
mbCounterKey.value = 1
}
@@ -18,6 +20,13 @@ export const useBusStore = defineStore('bus', () => {
function displayNotification(options) {
notification.value = options.msg
notificationColor.value = options.type ? options.type : 'success'
if (options.timeout !== undefined) {
notificationTimeout.value = options.timeout
}
}
function hideNotification() {
notification.value = ''
}
function reloadData() {
@@ -32,6 +41,7 @@ export const useBusStore = defineStore('bus', () => {
notification,
notificationColor,
displayNotification,
hideNotification,
reloadMailboxCounters,
dataKey,
mbCounterKey,
+1
View File
@@ -9,5 +9,6 @@ export * from './domains.store'
export * from './parameters.store'
export * from './providers.store'
export * from './contacts.store'
export * from './webmail.store'
export default createPinia()
+12
View File
@@ -0,0 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useWebmailStore = defineStore('webmail', () => {
const selection = ref([])
const listingKey = ref(0)
return {
selection,
listingKey,
}
})
+5
View File
@@ -245,6 +245,11 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
"@formkit/drag-and-drop@^0.5.3":
version "0.5.3"
resolved "https://registry.yarnpkg.com/@formkit/drag-and-drop/-/drag-and-drop-0.5.3.tgz#24408c0cf8749b26d3fba3b043503efba44b4c90"
integrity sha512-VGQsKYc350OOMbz7wYt69YqDbn49x7RJ32eqiJieNke54nGZYmx18XEqTw0lBiJmH3RaiH6V45gB1u+tvI/TNw==
"@fullcalendar/core@^6.1.15":
version "6.1.19"
resolved "https://registry.yarnpkg.com/@fullcalendar/core/-/core-6.1.19.tgz#60379111ba0cfb9fd9bc6594fee47cf855fc77b2"