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'
146148import { messageBodyToTextInstance } from ' ../util/message.js'
147149import { 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+
149154export 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 % ;
0 commit comments