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-sound.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-sound.js')
| -rw-r--r-- | src/zap-sound.js | 59 |
1 files changed, 59 insertions, 0 deletions
diff --git a/src/zap-sound.js b/src/zap-sound.js new file mode 100644 index 0000000..fcf6d1f --- /dev/null +++ b/src/zap-sound.js @@ -0,0 +1,59 @@ +// The supplied zap.mp3, encoded verbatim so hosted and standalone builds +// both include it. Decode ahead of the tap; no network request is needed. +import mp3 from './assets/zap.mp3.base64.txt' with { type: 'text' }; + +let context, buffer, loading, playing, sequence = 0; + +function prepareZap() { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) return null; + if (!context || context.state === 'closed') { + context = new AudioCtx({ latencyHint: 'interactive' }); + buffer = null; + loading = null; + } + if (!loading) { + const ctx = context; + const bytes = Uint8Array.from(atob(mp3.trim()), c => c.charCodeAt(0)); + loading = ctx.decodeAudioData(bytes.buffer).then(decoded => { + if (context === ctx) buffer = decoded; + return decoded; + }); + loading.catch(() => { if (context === ctx) loading = null; }); + } + return loading; +} + +// Decoding is allowed while the audio context is suspended; the tap resumes +// playback. A browser without audio must still be able to load the wallet. +try { prepareZap(); } catch {} + +// tappedAt: when the finger landed — the caller's clock, since this module +// may still have been loading when it did. +export function playZapSound(tappedAt = performance.now()) { + try { + const ready = prepareZap(); + if (!ready) return; + const ctx = context, token = ++sequence; + if (ctx.state !== 'running') ctx.resume().catch(() => {}); + const start = decoded => { + // A very early tap may beat decoding. Never queue an old sound behind + // a later zap or surprise the user after a stalled decoder recovers. + if (token !== sequence || context !== ctx || performance.now() - tappedAt > 250) return; + if (playing) { playing.stop(); playing.disconnect(); } + const source = ctx.createBufferSource(), gain = ctx.createGain(); + source.buffer = decoded; + gain.gain.value = .65; + source.connect(gain); + gain.connect(ctx.destination); + source.onended = () => { + source.disconnect(); gain.disconnect(); + if (playing === source) playing = null; + }; + playing = source; + source.start(); + }; + if (buffer) start(buffer); + else ready.then(start).catch(() => {}); + } catch {} // Unavailable audio never interrupts a payment or its animation. +} |
