diff options
| author | Adam Soltys <[email protected]> | 2026-09-18 23:12:19 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-18 23:12:19 -0700 |
| commit | e07023af899f6682e088aee9ff3329b16b8f467d (patch) | |
| tree | 7b3f6926d476ebb3c41ad2f325a1029bf58d82aa /src/zap-animation.js | |
| parent | 703ee6e1281e72ec00f65768d7de7a015c242abb (diff) | |
| download | coinosv3-e07023af899f6682e088aee9ff3329b16b8f467d.tar.gz coinosv3-e07023af899f6682e088aee9ff3329b16b8f467d.zip | |
Zaps: the sound, the sender, and one action bar for every post.
Tap feedback: a bolt strike, the amount popping off the button, the post
trembling, and the zap.mp3 clip. The clip is 62KB of base64 and messages.js
is boot code, so it is its own chunk, fetched once a zappable post is on
screen and played inside the tap once loaded; the standalone build inlines
it. Ark zap receipts are signed by the wallet key, which has no profile: they
now carry the login identity as the sender (NIP-57's P tag), readers prefer
it, and our own older receipts read as us. The action bar is the same six
slots on every post: the zap button and the who-reacted chevron are always
there (inert until they have something to do), so the first zap no longer
reflows the row, and you can zap your own posts and messages. Community
rows centre their unread dot off the edge. Also in here: reactions with
custom emoji, reply-composer focus under the tapped post, profile fetch
retry, and Lightning zap failures that say so.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
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); +} |
