diff options
Diffstat (limited to 'src/zap-animation.js')
| -rw-r--r-- | src/zap-animation.js | 83 |
1 files changed, 83 insertions, 0 deletions
diff --git a/src/zap-animation.js b/src/zap-animation.js new file mode 100644 index 0000000..20a6b60 --- /dev/null +++ b/src/zap-animation.js @@ -0,0 +1,83 @@ +// Outside the app's morphed tree so relay updates cannot restart the effect. +// This is tap feedback only: payment settlement never plays it again. + +// The clip is 62KB of base64 — messages.js can't be deferred (it's the boot +// shell), so the sound is its own chunk instead, fetched once a zappable +// post or chat message is on screen. By the first tap it's decoded; and if +// it isn't, the sound keeps the tap's own clock and stays silent when late. +let soundModule = null, sound = null; +export function warmZapSound() { + if (!soundModule) { + soundModule = import('./zap-sound.js') + .then((m) => (sound = m)) + .catch(() => { soundModule = null; return null; }); + } + return soundModule; +} + +const trembles = new WeakMap(); +function tremblePost(id) { + if (!id || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + for (const post of document.querySelectorAll(`[data-zap-post="${CSS.escape(id)}"]`)) { + // Web Animations survive the app's DOM morphs without adding a class + // that a balance update could remove or accidentally restart. + trembles.get(post)?.cancel(); + const animation = post.animate([ + { transform: 'none' }, + { transform: 'translate(-3px, 1px) rotate(-.3deg)' }, + { transform: 'translate(3px, -1px) rotate(.3deg)' }, + { transform: 'translate(-2.5px, 0) rotate(-.2deg)' }, + { transform: 'translate(2px, 1px) rotate(.2deg)' }, + { transform: 'translate(-1.5px, -1px)' }, + { transform: 'translate(1px, 0)' }, + { transform: 'none' }, + ], { duration: 360, easing: 'ease-out' }); + trembles.set(post, animation); + animation.onfinish = () => { if (trembles.get(post) === animation) trembles.delete(post); }; + } +} + +export function animateZap(sats, origin, postId) { + // Warm: play inside the tap itself (the audio context resumes on a + // gesture). Cold: play when the chunk lands, if that's still soon enough. + if (sound) sound.playZapSound(); + else { const tappedAt = performance.now(); warmZapSound().then((m) => { if (m) m.playZapSound(tappedAt); }); } + try { tremblePost(postId); } catch {} // Optional motion must never block a zap. + const layer = document.createElement('div'); + layer.className = 'zap-fx'; + layer.setAttribute('aria-hidden', 'true'); + const x = origin ? origin.left + origin.width / 2 : innerWidth / 2; + const y = origin ? origin.top + origin.height / 2 : innerHeight / 2; + layer.style.setProperty('--zap-x', `${x}px`); + layer.style.setProperty('--zap-y', `${Math.max(110, y)}px`); + layer.innerHTML = '<div class="zap-fx-impact">' + + '<svg class="zap-fx-bolt" viewBox="0 0 100 180"><path d="M62 0 25 65 52 59 15 122 43 110 50 180 72 91 48 100 88 35 59 43Z"/></svg>' + + '<i class="zap-fx-flash"></i><i class="zap-fx-ring"></i>' + + '<div class="zap-fx-amount"><strong></strong><span>sats</span></div></div>'; + layer.querySelector('strong').textContent = '+' + sats.toLocaleString(); + const impact = layer.firstElementChild; + for (let i = 0; i < 26; i++) { + const spark = document.createElement('i'); + const angle = i * 2.39996; + const distance = 38 + (i % 6) * 15; + spark.className = 'zap-fx-spark' + (i >= 16 ? ' ember' : ''); + spark.style.setProperty('--dx', `${Math.cos(angle) * distance}px`); + spark.style.setProperty('--dy', `${Math.sin(angle) * distance - 35}px`); + spark.style.setProperty('--angle', `${angle}rad`); + spark.style.setProperty('--delay', `${i >= 16 ? 600 + (i % 5) * 55 : 60 + (i % 4) * 20}ms`); + impact.append(spark); + } + document.body.append(layer); + // Leave room for the pop's 1.2x overshoot, even for long amounts at an edge. + const label = layer.querySelector('.zap-fx-amount'); + if (label.offsetWidth > (innerWidth - 32) / 1.2) { + const strong = label.querySelector('strong'); + strong.style.fontSize = `${parseFloat(getComputedStyle(strong).fontSize) * (innerWidth - 80) / (label.offsetWidth * 1.2)}px`; + } + const margin = label.offsetWidth * .6 + 12; + const labelX = Math.max(margin, Math.min(innerWidth - margin, x)); + layer.style.setProperty('--zap-label-x', `${labelX - x}px`); + const clean = () => { layer.remove(); window.removeEventListener('pagehide', clean); }; + window.addEventListener('pagehide', clean, { once: true }); + setTimeout(clean, 1700); +} |
