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) })