Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion src/components/NewMessageModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
<template>
<Modal
v-if="showMessageComposer"
:class="{ 'composer-fly-up': flying, 'composer-fly-in': composerFlyIn }"
:size="modalSize"
:name="modalTitle"
:additional-trap-elements="additionalTrapElements"
@animationend.native="onComposerAnimationEnd"
@close="$event.type === 'click' ? onClose() : onMinimize()">
<div class="modal-content">
<div class="left-pane">
Expand Down Expand Up @@ -146,6 +148,9 @@ import useOutboxStore from '../store/outboxStore.js'
import { messageBodyToTextInstance } from '../util/message.js'
import { toPlain } from '../util/text.js'

// Duration of the "fly up" send animation, matches var(--animation-slow) (300ms)
const SEND_ANIMATION_DURATION = 300

export default {
name: 'NewMessageModal',
components: {
Expand Down Expand Up @@ -182,6 +187,7 @@ export default {
draftSaved: false,
uploadingAttachments: false,
sending: false,
flying: false,
error: undefined,
warning: undefined,
modalFirstOpen: true,
Expand All @@ -200,7 +206,7 @@ export default {

computed: {
...mapStores(useOutboxStore, useMainStore),
...mapState(useMainStore, ['showMessageComposer']),
...mapState(useMainStore, ['showMessageComposer', 'composerFlyIn']),
...mapActions(useMainStore, ['getPreference']),
composerDataBodyAsTextInstance() {
return messageBodyToTextInstance(this.composerData)
Expand Down Expand Up @@ -281,6 +287,37 @@ export default {
this.isLargeScreen = window.innerWidth >= 1024
},

// Clear the fly-in flag once its animation has finished, so it only plays for the
// "Undo send" reopen and not on the next normal open
onComposerAnimationEnd(event) {
if (String(event.animationName).includes('composer-fly-in')) {
this.mainStore.composerFlyIn = false
}
},

// Trigger the fly-up animation and resolve once it has finished. Returns null when
// reduced motion is preferred. Called early in onSend so it plays immediately.
playSendAnimation() {
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) {
return null
}
this.flying = true
return this.$nextTick().then(() => new Promise((resolve) => {
const container = this.$el?.querySelector?.('.modal-container')
let done = false
const finish = () => {
if (done) {
return
}
done = true
resolve()
}
container?.addEventListener('animationend', finish, { once: true })
// Fallback so sending never hangs if the animation doesn't fire
setTimeout(finish, SEND_ANIMATION_DURATION + 100)
}))
},

async openModalSize() {
try {
const sizePreference = this.mainStore.getPreference('modalSize')
Expand Down Expand Up @@ -461,6 +498,10 @@ export default {
}
}

// Start the fly-up animation right away so it feels immediate, overlapping the
// draft save / outbox enqueue below instead of waiting for those network calls
const flyUp = this.playSendAnimation()

if (!this.composerData.id) {
// This is a new message
const { id } = await saveDraft(dataForServer)
Expand All @@ -486,6 +527,10 @@ export default {
})
}

// Ensure the fly-up animation has finished before the composer is unmounted
if (flyUp) {
await flyUp
}
if (!data.sendAt || data.sendAt < Math.floor((now + UNDO_DELAY) / 1000)) {
// Awaiting here would keep the modal open for a long time and thus block the user
this.outboxStore.sendMessageWithUndo({ id: dataForServer.id }).catch((error) => {
Expand Down Expand Up @@ -526,6 +571,7 @@ export default {
})
} finally {
this.sending = false
this.flying = false
}

// Sync sent mailbox when it's currently open
Expand Down Expand Up @@ -650,6 +696,56 @@ export default {
<style lang="scss" scoped>
@use '../../css/variables.scss';

// Apple Mail style "whoosh": tiny anticipation dip, then the composer shoots up out of view
@keyframes composer-fly-up {
0% {
transform: translateY(0);
opacity: 1;
}
15% {
transform: translateY(calc(var(--default-grid-baseline) * 2));
}
100% {
transform: translateY(-100vh);
opacity: 0;
}
}

// Fade the dark backdrop out together with the modal instead of letting it vanish instantly
@keyframes composer-backdrop-fade-out {
to {
opacity: 0;
}
}

.composer-fly-up :deep(.modal-container) {
animation: composer-fly-up var(--animation-slow) ease-in forwards;
}

.modal-mask.composer-fly-up {
animation: composer-backdrop-fade-out var(--animation-slow) ease-in forwards;
}

// Reverse of the send animation: the composer drops in from the top with a tiny overshoot
// (the backdrop fades back in through NcModal's own transition)
@keyframes composer-fly-in {
0% {
transform: translateY(-100vh);
opacity: 0;
}
85% {
transform: translateY(calc(var(--default-grid-baseline) * 2));
opacity: 1;
}
100% {
transform: translateY(0);
}
}

.composer-fly-in :deep(.modal-container) {
animation: composer-fly-in var(--animation-slow) ease-out;
}

@media only screen and (max-width: 600px) {
:deep(.modal-container) {
max-width: 80%;
Expand Down
2 changes: 2 additions & 0 deletions src/store/mainStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export default defineStore('main', {
messages: {},
newMessage: undefined,
showMessageComposer: false,
// Set when the composer is reopened via "Undo send" so it can fly back in from the top
composerFlyIn: false,
composerMessageIsSaved: false,
composerSessionId: undefined,
nextComposerSessionId: 1,
Expand Down
1 change: 1 addition & 0 deletions src/store/mainStore/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -2034,6 +2034,7 @@ export default function mainStoreActions() {
this.composerSessionId = undefined
this.newMessage = undefined
this.showMessageComposer = false
this.composerFlyIn = false
},
/**
* Show composer modal if there is an ongoing session.
Expand Down
3 changes: 3 additions & 0 deletions src/store/outboxStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ export default defineStore('outbox', {
logger.info('Attempting to stop sending message ' + message.id)
const stopped = await this.stopMessage({ message })
logger.info('Message ' + message.id + ' stopped', { message: stopped })
if (!window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) {
this.mainStore.composerFlyIn = true
}
await this.mainStore.startComposerSession({
type: 'outbox',
data: { ...message },
Expand Down
107 changes: 107 additions & 0 deletions src/tests/unit/components/NewMessageModal.vue.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createLocalVue, shallowMount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NewMessageModal from '../../../components/NewMessageModal.vue'
import Nextcloud from '../../../mixins/Nextcloud.js'
import useMainStore from '../../../store/mainStore.js'
import useOutboxStore from '../../../store/outboxStore.js'

vi.mock('../../../service/DraftService.js', () => ({
saveDraft: vi.fn().mockResolvedValue({ id: 42 }),
updateDraft: vi.fn().mockResolvedValue(undefined),
deleteDraft: vi.fn().mockResolvedValue(undefined),
}))

const localVue = createLocalVue()
localVue.mixin(Nextcloud)

// Render the NcModal stub's default slot so .modal-content is present in the DOM
const modalStub = {
template: '<div class="modal-stub"><slot /></div>',
}

describe('NewMessageModal', () => {
let store
let outboxStore

const mountModal = () => shallowMount(NewMessageModal, {
localVue,
propsData: { accounts: [] },
stubs: { Modal: modalStub },
})

beforeEach(() => {
vi.useFakeTimers()
setActivePinia(createPinia())

store = useMainStore()
store.newMessage = {
type: 'outbox',
data: { id: 5, type: 0, accountId: 1, to: [], cc: [], bcc: [], attachments: [], isHtml: false, bodyPlain: 'x' },
}
store.showMessageComposer = true
store.getPreference = vi.fn().mockReturnValue('normal')
store.stopComposerSession = vi.fn().mockResolvedValue(undefined)

outboxStore = useOutboxStore()
outboxStore.updateMessage = vi.fn().mockResolvedValue(undefined)
outboxStore.enqueueFromDraft = vi.fn().mockResolvedValue(undefined)
outboxStore.sendMessageWithUndo = vi.fn().mockResolvedValue(undefined)
})

afterEach(() => {
vi.useRealTimers()
})

it('adds the fly-in class on the modal while composerFlyIn is set (Undo send reopen)', async () => {
const wrapper = mountModal()

store.composerFlyIn = true
await wrapper.vm.$nextTick()

expect(wrapper.find('.modal-stub').classes()).toContain('composer-fly-in')
})

it('adds the fly-up class on the modal while flying (send)', async () => {
const wrapper = mountModal()

await wrapper.setData({ flying: true })

expect(wrapper.find('.modal-stub').classes()).toContain('composer-fly-up')
})

it('onComposerAnimationEnd clears the flag only for the fly-in animation', () => {
const wrapper = mountModal()
store.composerFlyIn = true

wrapper.vm.onComposerAnimationEnd({ animationName: 'composer-fly-up-abc' })
expect(store.composerFlyIn).toBe(true)

wrapper.vm.onComposerAnimationEnd({ animationName: 'composer-fly-in-abc' })
expect(store.composerFlyIn).toBe(false)
})

it('plays the fly-up animation before hiding the composer on send', async () => {
const wrapper = mountModal()
let flyingWhenSent
outboxStore.sendMessageWithUndo = vi.fn(() => {
flyingWhenSent = wrapper.vm.flying
return Promise.resolve()
})

const data = { id: 5, type: 0, accountId: 1, to: [], cc: [], bcc: [], attachments: [], subject: 'Hi', isHtml: false, bodyPlain: 'body', sendAt: 0 }
const promise = wrapper.vm.onSend(data)
await vi.runAllTimersAsync()
await promise

expect(outboxStore.sendMessageWithUndo).toHaveBeenCalled()
expect(flyingWhenSent).toBe(true)
expect(store.stopComposerSession).toHaveBeenCalled()
expect(wrapper.vm.flying).toBe(false)
})
})
Loading