Skip to content

Commit 089c855

Browse files
committed
feat(composer): animate composer flying up on send
Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Jan C. Borchardt <925062+jancborchardt@users.noreply.github.com>
1 parent 39fd543 commit 089c855

5 files changed

Lines changed: 210 additions & 1 deletion

File tree

src/components/NewMessageModal.vue

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
<template>
66
<Modal
77
v-if="showMessageComposer"
8+
:class="{ 'composer-fly-up': flying, 'composer-fly-in': composerFlyIn }"
89
:size="modalSize"
910
:name="modalTitle"
1011
:additional-trap-elements="additionalTrapElements"
12+
@animationend.native="onComposerAnimationEnd"
1113
@close="$event.type === 'click' ? onClose() : onMinimize()">
1214
<div class="modal-content">
1315
<div class="left-pane">
@@ -146,6 +148,9 @@ import useOutboxStore from '../store/outboxStore.js'
146148
import { messageBodyToTextInstance } from '../util/message.js'
147149
import { toPlain } from '../util/text.js'
148150
151+
// Duration of the "fly up" send animation, matches var(--animation-slow) (300ms)
152+
const SEND_ANIMATION_DURATION = 300
153+
149154
export default {
150155
name: 'NewMessageModal',
151156
components: {
@@ -182,6 +187,7 @@ export default {
182187
draftSaved: false,
183188
uploadingAttachments: false,
184189
sending: false,
190+
flying: false,
185191
error: undefined,
186192
warning: undefined,
187193
modalFirstOpen: true,
@@ -200,7 +206,7 @@ export default {
200206
201207
computed: {
202208
...mapStores(useOutboxStore, useMainStore),
203-
...mapState(useMainStore, ['showMessageComposer']),
209+
...mapState(useMainStore, ['showMessageComposer', 'composerFlyIn']),
204210
...mapActions(useMainStore, ['getPreference']),
205211
composerDataBodyAsTextInstance() {
206212
return messageBodyToTextInstance(this.composerData)
@@ -281,6 +287,37 @@ export default {
281287
this.isLargeScreen = window.innerWidth >= 1024
282288
},
283289
290+
// Clear the fly-in flag once its animation has finished, so it only plays for the
291+
// "Undo send" reopen and not on the next normal open
292+
onComposerAnimationEnd(event) {
293+
if (String(event.animationName).includes('composer-fly-in')) {
294+
this.mainStore.composerFlyIn = false
295+
}
296+
},
297+
298+
// Trigger the fly-up animation and resolve once it has finished. Returns null when
299+
// reduced motion is preferred. Called early in onSend so it plays immediately.
300+
playSendAnimation() {
301+
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) {
302+
return null
303+
}
304+
this.flying = true
305+
return this.$nextTick().then(() => new Promise((resolve) => {
306+
const container = this.$el?.querySelector?.('.modal-container')
307+
let done = false
308+
const finish = () => {
309+
if (done) {
310+
return
311+
}
312+
done = true
313+
resolve()
314+
}
315+
container?.addEventListener('animationend', finish, { once: true })
316+
// Fallback so sending never hangs if the animation doesn't fire
317+
setTimeout(finish, SEND_ANIMATION_DURATION + 100)
318+
}))
319+
},
320+
284321
async openModalSize() {
285322
try {
286323
const sizePreference = this.mainStore.getPreference('modalSize')
@@ -461,6 +498,10 @@ export default {
461498
}
462499
}
463500
501+
// Start the fly-up animation right away so it feels immediate, overlapping the
502+
// draft save / outbox enqueue below instead of waiting for those network calls
503+
const flyUp = this.playSendAnimation()
504+
464505
if (!this.composerData.id) {
465506
// This is a new message
466507
const { id } = await saveDraft(dataForServer)
@@ -486,6 +527,10 @@ export default {
486527
})
487528
}
488529
530+
// Ensure the fly-up animation has finished before the composer is unmounted
531+
if (flyUp) {
532+
await flyUp
533+
}
489534
if (!data.sendAt || data.sendAt < Math.floor((now + UNDO_DELAY) / 1000)) {
490535
// Awaiting here would keep the modal open for a long time and thus block the user
491536
this.outboxStore.sendMessageWithUndo({ id: dataForServer.id }).catch((error) => {
@@ -526,6 +571,7 @@ export default {
526571
})
527572
} finally {
528573
this.sending = false
574+
this.flying = false
529575
}
530576
531577
// Sync sent mailbox when it's currently open
@@ -650,6 +696,56 @@ export default {
650696
<style lang="scss" scoped>
651697
@use '../../css/variables.scss';
652698
699+
// Apple Mail style "whoosh": tiny anticipation dip, then the composer shoots up out of view
700+
@keyframes composer-fly-up {
701+
0% {
702+
transform: translateY(0);
703+
opacity: 1;
704+
}
705+
15% {
706+
transform: translateY(calc(var(--default-grid-baseline) * 2));
707+
}
708+
100% {
709+
transform: translateY(-100vh);
710+
opacity: 0;
711+
}
712+
}
713+
714+
// Fade the dark backdrop out together with the modal instead of letting it vanish instantly
715+
@keyframes composer-backdrop-fade-out {
716+
to {
717+
opacity: 0;
718+
}
719+
}
720+
721+
.composer-fly-up :deep(.modal-container) {
722+
animation: composer-fly-up var(--animation-slow) ease-in forwards;
723+
}
724+
725+
.modal-mask.composer-fly-up {
726+
animation: composer-backdrop-fade-out var(--animation-slow) ease-in forwards;
727+
}
728+
729+
// Reverse of the send animation: the composer drops in from the top with a tiny overshoot
730+
// (the backdrop fades back in through NcModal's own transition)
731+
@keyframes composer-fly-in {
732+
0% {
733+
transform: translateY(-100vh);
734+
opacity: 0;
735+
}
736+
85% {
737+
transform: translateY(calc(var(--default-grid-baseline) * 2));
738+
opacity: 1;
739+
}
740+
100% {
741+
transform: translateY(0);
742+
}
743+
}
744+
745+
.composer-fly-in :deep(.modal-container) {
746+
animation: composer-fly-in var(--animation-slow) ease-out;
747+
}
748+
653749
@media only screen and (max-width: 600px) {
654750
:deep(.modal-container) {
655751
max-width: 80%;

src/store/mainStore.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export default defineStore('main', {
8383
messages: {},
8484
newMessage: undefined,
8585
showMessageComposer: false,
86+
// Set when the composer is reopened via "Undo send" so it can fly back in from the top
87+
composerFlyIn: false,
8688
composerMessageIsSaved: false,
8789
composerSessionId: undefined,
8890
nextComposerSessionId: 1,

src/store/mainStore/actions.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2034,6 +2034,7 @@ export default function mainStoreActions() {
20342034
this.composerSessionId = undefined
20352035
this.newMessage = undefined
20362036
this.showMessageComposer = false
2037+
this.composerFlyIn = false
20372038
},
20382039
/**
20392040
* Show composer modal if there is an ongoing session.

src/store/outboxStore.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@ export default defineStore('outbox', {
184184
logger.info('Attempting to stop sending message ' + message.id)
185185
const stopped = await this.stopMessage({ message })
186186
logger.info('Message ' + message.id + ' stopped', { message: stopped })
187+
if (!window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) {
188+
this.mainStore.composerFlyIn = true
189+
}
187190
await this.mainStore.startComposerSession({
188191
type: 'outbox',
189192
data: { ...message },
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { createLocalVue, shallowMount } from '@vue/test-utils'
7+
import { createPinia, setActivePinia } from 'pinia'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
import NewMessageModal from '../../../components/NewMessageModal.vue'
10+
import Nextcloud from '../../../mixins/Nextcloud.js'
11+
import useMainStore from '../../../store/mainStore.js'
12+
import useOutboxStore from '../../../store/outboxStore.js'
13+
14+
vi.mock('../../../service/DraftService.js', () => ({
15+
saveDraft: vi.fn().mockResolvedValue({ id: 42 }),
16+
updateDraft: vi.fn().mockResolvedValue(undefined),
17+
deleteDraft: vi.fn().mockResolvedValue(undefined),
18+
}))
19+
20+
const localVue = createLocalVue()
21+
localVue.mixin(Nextcloud)
22+
23+
// Render the NcModal stub's default slot so .modal-content is present in the DOM
24+
const modalStub = {
25+
template: '<div class="modal-stub"><slot /></div>',
26+
}
27+
28+
describe('NewMessageModal', () => {
29+
let store
30+
let outboxStore
31+
32+
const mountModal = () => shallowMount(NewMessageModal, {
33+
localVue,
34+
propsData: { accounts: [] },
35+
stubs: { Modal: modalStub },
36+
})
37+
38+
beforeEach(() => {
39+
vi.useFakeTimers()
40+
setActivePinia(createPinia())
41+
42+
store = useMainStore()
43+
store.newMessage = {
44+
type: 'outbox',
45+
data: { id: 5, type: 0, accountId: 1, to: [], cc: [], bcc: [], attachments: [], isHtml: false, bodyPlain: 'x' },
46+
}
47+
store.showMessageComposer = true
48+
store.getPreference = vi.fn().mockReturnValue('normal')
49+
store.stopComposerSession = vi.fn().mockResolvedValue(undefined)
50+
51+
outboxStore = useOutboxStore()
52+
outboxStore.updateMessage = vi.fn().mockResolvedValue(undefined)
53+
outboxStore.enqueueFromDraft = vi.fn().mockResolvedValue(undefined)
54+
outboxStore.sendMessageWithUndo = vi.fn().mockResolvedValue(undefined)
55+
})
56+
57+
afterEach(() => {
58+
vi.useRealTimers()
59+
})
60+
61+
it('adds the fly-in class on the modal while composerFlyIn is set (Undo send reopen)', async () => {
62+
const wrapper = mountModal()
63+
64+
store.composerFlyIn = true
65+
await wrapper.vm.$nextTick()
66+
67+
expect(wrapper.find('.modal-stub').classes()).toContain('composer-fly-in')
68+
})
69+
70+
it('adds the fly-up class on the modal while flying (send)', async () => {
71+
const wrapper = mountModal()
72+
73+
await wrapper.setData({ flying: true })
74+
75+
expect(wrapper.find('.modal-stub').classes()).toContain('composer-fly-up')
76+
})
77+
78+
it('onComposerAnimationEnd clears the flag only for the fly-in animation', () => {
79+
const wrapper = mountModal()
80+
store.composerFlyIn = true
81+
82+
wrapper.vm.onComposerAnimationEnd({ animationName: 'composer-fly-up-abc' })
83+
expect(store.composerFlyIn).toBe(true)
84+
85+
wrapper.vm.onComposerAnimationEnd({ animationName: 'composer-fly-in-abc' })
86+
expect(store.composerFlyIn).toBe(false)
87+
})
88+
89+
it('plays the fly-up animation before hiding the composer on send', async () => {
90+
const wrapper = mountModal()
91+
let flyingWhenSent
92+
outboxStore.sendMessageWithUndo = vi.fn(() => {
93+
flyingWhenSent = wrapper.vm.flying
94+
return Promise.resolve()
95+
})
96+
97+
const data = { id: 5, type: 0, accountId: 1, to: [], cc: [], bcc: [], attachments: [], subject: 'Hi', isHtml: false, bodyPlain: 'body', sendAt: 0 }
98+
const promise = wrapper.vm.onSend(data)
99+
await vi.runAllTimersAsync()
100+
await promise
101+
102+
expect(outboxStore.sendMessageWithUndo).toHaveBeenCalled()
103+
expect(flyingWhenSent).toBe(true)
104+
expect(store.stopComposerSession).toHaveBeenCalled()
105+
expect(wrapper.vm.flying).toBe(false)
106+
})
107+
})

0 commit comments

Comments
 (0)