diff --git a/src/components/Envelope.vue b/src/components/Envelope.vue
index 67876fe570..22d7ef3e35 100644
--- a/src/components/Envelope.vue
+++ b/src/components/Envelope.vue
@@ -1387,11 +1387,12 @@ export default {
this.showEventModal = true
},
- onMove() {
- this.$emit('move')
+ onMove(envelopeIds) {
+ this.$emit('move', envelopeIds)
},
async moveThread(destMailboxId) {
+ this.$emit('move', [this.data.databaseId])
if (this.layoutMessageViewThreaded) {
await this.mainStore.moveThread({
envelope: this.data,
@@ -1403,7 +1404,6 @@ export default {
destMailboxId,
})
}
- this.onMove()
},
onCloseMoveModal() {
diff --git a/src/components/EnvelopeList.vue b/src/components/EnvelopeList.vue
index 71ae845f7d..811969126b 100644
--- a/src/components/EnvelopeList.vue
+++ b/src/components/EnvelopeList.vue
@@ -118,6 +118,7 @@
:account="account"
:envelopes="selectedEnvelopes"
:move-thread="true"
+ @move="(ids) => $emit('move', ids)"
@close="onCloseMoveModal" />
@@ -134,6 +135,7 @@
:selected-envelopes="selectedEnvelopes"
:compact-mode="compactMode"
@delete="$emit('delete', env.databaseId)"
+ @move="(ids) => $emit('move', ids)"
@update:selected="onEnvelopeSelectToggle(env, index, $event)"
@select-multiple="onEnvelopeSelectMultiple(env, index)"
@open:quick-actions-settings="showQuickActionsSettings = true" />
diff --git a/src/components/Mailbox.vue b/src/components/Mailbox.vue
index 861db0a881..1c34290a01 100644
--- a/src/components/Mailbox.vue
+++ b/src/components/Mailbox.vue
@@ -28,7 +28,8 @@
:loading-more="false"
:load-more-button="false"
:skip-transition="skipListTransition"
- @delete="onDelete" />
+ @delete="onDelete"
+ @move="onMove" />
@@ -62,6 +64,7 @@ import MailboxNotCachedError from '../errors/MailboxNotCachedError.js'
import { matchError } from '../errors/match.js'
import NoTrashMailboxConfiguredError
from '../errors/NoTrashMailboxConfiguredError.js'
+import dragEventBus from '../directives/drag-and-drop/util/dragEventBus.js'
import logger from '../logger.js'
import useMainStore from '../store/mainStore.js'
import { mailboxHasRights } from '../util/acl.js'
@@ -198,8 +201,10 @@ export default {
created() {
this.bus.on('load-more', this.onScroll)
this.bus.on('delete', this.onDelete)
+ this.bus.on('move', this.onMove)
this.bus.on('archive', this.onArchive)
this.bus.on('shortcut', this.handleShortcut)
+ dragEventBus.on('envelopes-dropped', this.onEnvelopesDropped)
this.loadMailboxInterval = setInterval(this.loadMailbox, 60000)
},
@@ -220,8 +225,10 @@ export default {
unmounted() {
this.bus.off('load-more', this.onScroll)
this.bus.off('delete', this.onDelete)
+ this.bus.off('move', this.onMove)
this.bus.off('archive', this.onArchive)
this.bus.off('shortcut', this.handleShortcut)
+ dragEventBus.off('envelopes-dropped', this.onEnvelopesDropped)
this.stopInterval()
},
@@ -539,18 +546,22 @@ export default {
}
},
- // onDelete(id): Load more message and navigate to other message if needed
- // id: The id of the message being delete
- onDelete(id) {
- // Get a new message
- this.mainStore.fetchNextEnvelopes({
- mailboxId: this.mailbox.databaseId,
- query: this.searchQuery,
- quantity: 1,
- })
+ onEnvelopesDropped({ envelopes }) {
+ const currentThreadId = this.$route.params.threadId
+ if (!currentThreadId) {
+ return
+ }
+ const movedIds = envelopes.map((e) => e.databaseId)
+ const wasMoved = movedIds.some((id) => String(id) === String(currentThreadId))
+ if (wasMoved) {
+ this.onMove(movedIds)
+ }
+ },
+
+ navigateToAdjacentEnvelope(id, excludeIds = []) {
const idx = findIndex(propEq(id, 'databaseId'), this.envelopes)
if (idx === -1) {
- logger.debug('envelope to delete does not exist in envelope list')
+ logger.debug('envelope does not exist in envelope list')
return
}
if (id !== this.$route.params.threadId) {
@@ -558,7 +569,15 @@ export default {
return
}
- const next = this.envelopes[idx === 0 ? 1 : idx - 1]
+ // Find the nearest envelope that is not being moved, preferring
+ // the one above (idx - 1) unless we're at the top of the list
+ let next
+ if (idx === 0) {
+ next = this.envelopes.slice(idx + 1).find((env) => !excludeIds.includes(env.databaseId))
+ } else {
+ next = this.envelopes.slice(0, idx).reverse().find((env) => !excludeIds.includes(env.databaseId))
+ || this.envelopes.slice(idx + 1).find((env) => !excludeIds.includes(env.databaseId))
+ }
if (!next) {
logger.debug('no next/previous envelope, not navigating')
return
@@ -576,6 +595,23 @@ export default {
})
},
+ onDelete(id) {
+ this.mainStore.fetchNextEnvelopes({
+ mailboxId: this.mailbox.databaseId,
+ query: this.searchQuery,
+ quantity: 1,
+ })
+ this.navigateToAdjacentEnvelope(id, [id])
+ },
+
+ onMove(ids) {
+ const currentThreadId = this.$route.params.threadId
+ const movedId = ids.find((id) => id === currentThreadId)
+ if (movedId !== undefined) {
+ this.navigateToAdjacentEnvelope(movedId, ids)
+ }
+ },
+
onScroll() {
if (this.paginate !== 'scroll') {
logger.debug('ignoring scroll pagination')
diff --git a/src/components/MailboxThread.vue b/src/components/MailboxThread.vue
index ec4a27368e..50103a1a05 100644
--- a/src/components/MailboxThread.vue
+++ b/src/components/MailboxThread.vue
@@ -190,7 +190,7 @@
-
+
@@ -497,6 +497,10 @@ export default {
this.bus.emit('delete', id)
},
+ moveMessage(ids) {
+ this.bus.emit('move', ids)
+ },
+
onScroll(event) {
logger.debug('scroll', { event })
diff --git a/src/components/MoveModal.vue b/src/components/MoveModal.vue
index cc9582e449..db8bee4ee0 100644
--- a/src/components/MoveModal.vue
+++ b/src/components/MoveModal.vue
@@ -69,6 +69,10 @@ export default {
return
}
+ // Emit before the move so the parent can navigate to the next message
+ // while the envelopes are still in the list
+ this.$emit('move', envelopes.map((envelope) => envelope.databaseId))
+
for (const envelope of envelopes) {
if (this.moveThread) {
await this.mainStore.moveThread({ envelope, destMailboxId: this.destMailboxId })
@@ -78,7 +82,6 @@ export default {
}
await this.mainStore.syncEnvelopes({ mailboxId: this.destMailboxId })
- this.$emit('move')
} catch (error) {
logger.error('could not move messages', {
error,
diff --git a/src/components/NavigationMailbox.vue b/src/components/NavigationMailbox.vue
index 622cd87bca..ce3b3a48b1 100644
--- a/src/components/NavigationMailbox.vue
+++ b/src/components/NavigationMailbox.vue
@@ -514,13 +514,11 @@ export default {
mounted() {
dragEventBus.on('drag-start', this.onDragStart)
dragEventBus.on('drag-end', this.onDragEnd)
- dragEventBus.on('envelopes-moved', this.onEnvelopesMoved)
},
beforeUnmount() {
dragEventBus.off('drag-start', this.onDragStart)
dragEventBus.off('drag-end', this.onDragEnd)
- dragEventBus.off('envelopes-moved', this.onEnvelopesMoved)
},
methods: {
@@ -763,26 +761,6 @@ export default {
this.showSubMailboxes = false
},
- onEnvelopesMoved({ mailboxId, movedEnvelopes }) {
- if (this.mailbox.databaseId !== mailboxId) {
- return
- }
- const openedMessageHasBeenMoved = movedEnvelopes.find((movedEnvelope) => {
- return movedEnvelope.envelopeId === this.$route.params.threadId
- })
- // navigate to the mailbox root
- // if the currently displayed message has been moved
- if (this.$route.name === 'message' && openedMessageHasBeenMoved) {
- this.$router.push({
- name: 'mailbox',
- params: {
- mailboxId: this.$route.params.mailboxId,
- filter: this.$route.params?.filter,
- },
- })
- }
- },
-
/**
* Delete all vanished emails that are still cached.
*
diff --git a/src/components/Thread.vue b/src/components/Thread.vue
index 2a7aa1d160..3c4ae64d4f 100644
--- a/src/components/Thread.vue
+++ b/src/components/Thread.vue
@@ -297,12 +297,8 @@ export default {
onMove(threadId) {
if (threadId === this.threadId) {
- this.$router.replace({
- name: 'mailbox',
- params: {
- mailboxId: this.$route.params.mailboxId,
- },
- })
+ // Let Mailbox.vue handle navigation to the next message
+ this.$emit('move', [threadId])
} else {
this.expandedThreads = this.expandedThreads.filter((id) => id !== threadId)
this.fetchThread()
diff --git a/src/components/ThreadEnvelope.vue b/src/components/ThreadEnvelope.vue
index 3078e2e0be..04c4fa7024 100644
--- a/src/components/ThreadEnvelope.vue
+++ b/src/components/ThreadEnvelope.vue
@@ -1044,8 +1044,8 @@ export default {
}
},
- onMove() {
- this.$emit('move')
+ onMove(envelopeIds) {
+ this.$emit('move', envelopeIds)
},
onOpenMoveModal() {
diff --git a/src/directives/drag-and-drop/droppable-mailbox/droppable-mailbox.js b/src/directives/drag-and-drop/droppable-mailbox/droppable-mailbox.js
index c95900786c..ad9ab77a23 100644
--- a/src/directives/drag-and-drop/droppable-mailbox/droppable-mailbox.js
+++ b/src/directives/drag-and-drop/droppable-mailbox/droppable-mailbox.js
@@ -130,11 +130,6 @@ export class DroppableMailbox {
await Promise.all(processedEnvelopes)
} catch (error) {
logger.error('could not process dropped messages', error)
- } finally {
- dragEventBus.emit('envelopes-moved', {
- mailboxId: this.options.mailboxId,
- movedEnvelopes: envelopesBeingDragged,
- })
}
}
diff --git a/src/tests/unit/components/Mailbox.vue.spec.js b/src/tests/unit/components/Mailbox.vue.spec.js
new file mode 100644
index 0000000000..c82baba2b9
--- /dev/null
+++ b/src/tests/unit/components/Mailbox.vue.spec.js
@@ -0,0 +1,183 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { createLocalVue, shallowMount } from '@vue/test-utils'
+import mitt from 'mitt'
+import { createPinia, setActivePinia } from 'pinia'
+import Mailbox from '../../../components/Mailbox.vue'
+import Nextcloud from '../../../mixins/Nextcloud.js'
+import useMainStore from '../../../store/mainStore.js'
+
+vi.mock('../../../directives/drag-and-drop/util/dragEventBus.js', () => ({
+ default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
+}))
+
+const localVue = createLocalVue()
+localVue.mixin(Nextcloud)
+
+const envelopes = [
+ { databaseId: 'A', mailboxId: 1 },
+ { databaseId: 'B', mailboxId: 1 },
+ { databaseId: 'C', mailboxId: 1 },
+ { databaseId: 'D', mailboxId: 1 },
+ { databaseId: 'E', mailboxId: 1 },
+]
+
+function mountMailbox({ threadId, envelopeList } = {}) {
+ const store = useMainStore()
+ store.getEnvelopes = vi.fn().mockReturnValue(envelopeList || envelopes)
+ store.fetchNextEnvelopes = vi.fn()
+
+ const $router = { push: vi.fn() }
+ const $route = {
+ params: {
+ mailboxId: 1,
+ threadId,
+ },
+ }
+
+ const wrapper = shallowMount(Mailbox, {
+ localVue,
+ mocks: { $route, $router },
+ propsData: {
+ account: { id: 1 },
+ mailbox: { databaseId: 1, accountId: 1 },
+ bus: mitt(),
+ },
+ })
+
+ return { wrapper, store, $router }
+}
+
+describe('Mailbox', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ })
+
+ describe('navigateToAdjacentEnvelope', () => {
+ it('navigates to the envelope above when in the middle', () => {
+ // User is viewing envelope C in a list of [A, B, C, D, E].
+ // C is being removed, so the method should navigate to B (the one above).
+ const { wrapper, $router } = mountMailbox({ threadId: 'C' })
+ wrapper.vm.navigateToAdjacentEnvelope('C', ['C'])
+ expect($router.push).toHaveBeenCalledWith(expect.objectContaining({
+ params: expect.objectContaining({ threadId: 'B' }),
+ }))
+ })
+
+ it('navigates to the envelope below when at the top', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: 'A' })
+ wrapper.vm.navigateToAdjacentEnvelope('A', ['A'])
+ expect($router.push).toHaveBeenCalledWith(expect.objectContaining({
+ params: expect.objectContaining({ threadId: 'B' }),
+ }))
+ })
+
+ it('navigates to the envelope above when at the bottom', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: 'E' })
+ wrapper.vm.navigateToAdjacentEnvelope('E', ['E'])
+ expect($router.push).toHaveBeenCalledWith(expect.objectContaining({
+ params: expect.objectContaining({ threadId: 'D' }),
+ }))
+ })
+
+ it('does not navigate when it is the only envelope', () => {
+ const { wrapper, $router } = mountMailbox({
+ threadId: 'A',
+ envelopeList: [{ databaseId: 'A', mailboxId: 1 }],
+ })
+ wrapper.vm.navigateToAdjacentEnvelope('A', ['A'])
+ expect($router.push).not.toHaveBeenCalled()
+ })
+
+ it('does not navigate when envelope is not in list', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: 'Z' })
+ wrapper.vm.navigateToAdjacentEnvelope('Z', ['Z'])
+ expect($router.push).not.toHaveBeenCalled()
+ })
+
+ it('does not navigate when a different message is open', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: 'D' })
+ wrapper.vm.navigateToAdjacentEnvelope('C', ['C'])
+ expect($router.push).not.toHaveBeenCalled()
+ })
+
+ it('skips excluded envelopes when bulk moving', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: 'C' })
+ wrapper.vm.navigateToAdjacentEnvelope('C', ['B', 'C', 'D'])
+ expect($router.push).toHaveBeenCalledWith(expect.objectContaining({
+ params: expect.objectContaining({ threadId: 'A' }),
+ }))
+ })
+
+ it('skips excluded envelopes at the top of the list', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: 'A' })
+ wrapper.vm.navigateToAdjacentEnvelope('A', ['A', 'B'])
+ expect($router.push).toHaveBeenCalledWith(expect.objectContaining({
+ params: expect.objectContaining({ threadId: 'C' }),
+ }))
+ })
+ })
+
+ describe('onDelete', () => {
+ it('fetches next envelopes and navigates', () => {
+ const { wrapper, store, $router } = mountMailbox({ threadId: 'C' })
+ wrapper.vm.onDelete('C')
+ expect(store.fetchNextEnvelopes).toHaveBeenCalledWith(expect.objectContaining({
+ mailboxId: 1,
+ quantity: 1,
+ }))
+ expect($router.push).toHaveBeenCalledWith(expect.objectContaining({
+ params: expect.objectContaining({ threadId: 'B' }),
+ }))
+ })
+ })
+
+ describe('onMove', () => {
+ it('navigates when moving a single envelope', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: 'C' })
+ wrapper.vm.onMove(['C'])
+ expect($router.push).toHaveBeenCalledWith(expect.objectContaining({
+ params: expect.objectContaining({ threadId: 'B' }),
+ }))
+ })
+
+ it('navigates when bulk moving envelopes', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: 'C' })
+ wrapper.vm.onMove(['B', 'C', 'D'])
+ expect($router.push).toHaveBeenCalledWith(expect.objectContaining({
+ params: expect.objectContaining({ threadId: 'A' }),
+ }))
+ })
+
+ it('does not navigate when current thread is not among moved ids', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: 'C' })
+ wrapper.vm.onMove(['A', 'B'])
+ expect($router.push).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('onEnvelopesDropped', () => {
+ it('navigates when the dropped envelope is the current thread', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: 'C' })
+ wrapper.vm.onEnvelopesDropped({ envelopes: [{ databaseId: 'C' }] })
+ expect($router.push).toHaveBeenCalledWith(expect.objectContaining({
+ params: expect.objectContaining({ threadId: 'B' }),
+ }))
+ })
+
+ it('does not navigate when dropped envelope is not the current thread', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: 'C' })
+ wrapper.vm.onEnvelopesDropped({ envelopes: [{ databaseId: 'A' }] })
+ expect($router.push).not.toHaveBeenCalled()
+ })
+
+ it('does not navigate when no message is open', () => {
+ const { wrapper, $router } = mountMailbox({ threadId: undefined })
+ wrapper.vm.onEnvelopesDropped({ envelopes: [{ databaseId: 'A' }] })
+ expect($router.push).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/src/tests/unit/util/groupedEnvelopes.spec.js b/src/tests/unit/util/groupedEnvelopes.spec.js
index 373d4dc442..ed9b7c654a 100644
--- a/src/tests/unit/util/groupedEnvelopes.spec.js
+++ b/src/tests/unit/util/groupedEnvelopes.spec.js
@@ -10,6 +10,7 @@ describe('groupEnvelopesByDate', () => {
const makeEnvelope = (date) => ({ dateInt: Math.floor(date.getTime() / 1000) })
it('groups envelopes into lastHour, yesterday, lastMonth, July, and 2024', () => {
+ const julyLabel = new Date(2025, 6, 1).toLocaleString('default', { month: 'long' })
const envelopes = [
makeEnvelope(new Date('2025-10-07T11:30:00Z')),
makeEnvelope(new Date('2025-10-06T18:00:00Z')),
@@ -24,9 +25,9 @@ describe('groupEnvelopesByDate', () => {
expect(result).toHaveLength(5)
const labels = result.map(([label]) => label)
- expect(labels).toEqual(expect.arrayContaining(['lastHour', 'yesterday', 'lastMonth', 'July', '2024']))
+ expect(labels).toEqual(expect.arrayContaining(['lastHour', 'yesterday', 'lastMonth', julyLabel, '2024']))
- result.forEach(([label, group]) => {
+ result.forEach(([, group]) => {
expect(Array.isArray(group)).toBe(true)
expect(group).toHaveLength(1)
})