aboutsummaryrefslogtreecommitdiffstats
path: root/src/zap-animation.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-18 23:12:19 -0700
committerAdam Soltys <[email protected]>2026-09-18 23:12:19 -0700
commite07023af899f6682e088aee9ff3329b16b8f467d (patch)
tree7b3f6926d476ebb3c41ad2f325a1029bf58d82aa /src/zap-animation.js
parent703ee6e1281e72ec00f65768d7de7a015c242abb (diff)
downloadcoinosv3-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.js83
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);
+}