Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions components/wallet/pay-success-mark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Inline copy of OneSatWallet/Resources/PaySuccessMark.svg, the motion source of truth.
// Keep geometry/timing in sync with the native PaySuccessMotion and PaySuccessMark.
export const PAY_SUCCESS_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220" width="220" height="220" aria-hidden="true" focusable="false" class="pay-success-mark">
<style>
.pay-success-mark { color: var(--chart-4); overflow: visible; }
.pay-success-mark .disk { transform-origin: 110px 110px; animation: pay-disk 180ms ease-out both; }
.pay-success-mark .glow { transform-origin: 110px 110px; opacity: .12; animation: pay-glow 1000ms ease-out both; }
.pay-success-mark .check { stroke-dasharray: 1; stroke-dashoffset: 0; animation: pay-check 240ms 80ms linear both; }
.pay-success-mark .tick { transform: translateX(84px); opacity: .8; animation: pay-burst 540ms 160ms ease-out both; }
.pay-success-card .pay-success-copy { animation: pay-copy 220ms 280ms ease-out both; }
@keyframes pay-disk { from { transform: scale(.62); } to { transform: scale(1); } }
@keyframes pay-glow { 0% { opacity: 0; transform: scale(.62); } 18% { opacity: .28; transform: scale(1); } 100% { opacity: .12; transform: scale(1); } }
@keyframes pay-check { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@keyframes pay-burst { 0% { transform: translateX(58px) rotate(-8deg); opacity: 0; } 65% { transform: translateX(90px) rotate(4deg); opacity: 1; } 100% { transform: translateX(84px) rotate(0deg); opacity: .8; } }
@keyframes pay-copy { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
.pay-success-mark .disk, .pay-success-mark .glow, .pay-success-mark .check, .pay-success-mark .tick, .pay-success-card .pay-success-copy { animation: none; }
}
</style>
<circle class="glow" cx="110" cy="110" r="61" fill="currentColor" style="filter: blur(8px)"/>
<circle class="disk" cx="110" cy="110" r="48" fill="currentColor"/>
<path class="check" d="M88 110 L103 125 L133 95" pathLength="1" fill="none" stroke="white" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
<g transform="translate(110 110)" fill="none" stroke-width="4" stroke-linecap="round">
<g transform="rotate(-90)" stroke="var(--primary)"><path class="tick" d="M0 0 H10"/></g>
<g transform="rotate(-54)" stroke="var(--chart-3)"><path class="tick" d="M0 0 H10"/></g>
<g transform="rotate(-18)" stroke="var(--chart-4)"><path class="tick" d="M0 0 H10"/></g>
<g transform="rotate(18)" stroke="var(--primary)"><path class="tick" d="M0 0 H10"/></g>
<g transform="rotate(54)" stroke="var(--chart-3)"><path class="tick" d="M0 0 H10"/></g>
<g transform="rotate(90)" stroke="var(--chart-4)"><path class="tick" d="M0 0 H10"/></g>
<g transform="rotate(126)" stroke="var(--primary)"><path class="tick" d="M0 0 H10"/></g>
<g transform="rotate(162)" stroke="var(--chart-3)"><path class="tick" d="M0 0 H10"/></g>
<g transform="rotate(198)" stroke="var(--chart-4)"><path class="tick" d="M0 0 H10"/></g>
<g transform="rotate(234)" stroke="var(--primary)"><path class="tick" d="M0 0 H10"/></g>
</g>
</svg>`;

export function PaySuccessMark() {
return (
// The markup is a bundled constant, never transaction or user input.
<div
className="mx-auto size-[220px]"
dangerouslySetInnerHTML={{ __html: PAY_SUCCESS_SVG }}
/>
);
}
78 changes: 62 additions & 16 deletions components/wallet/wallet-home-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

import { sendBsv } from "@1sat/actions";
import { useQueryClient } from "@tanstack/react-query";
import { ArrowDownToLine, Check, Copy, Loader2, Send, X } from "lucide-react";
import { ArrowDownToLine, Copy, Loader2, Send, X } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
Expand All @@ -17,8 +18,10 @@ import {
SoundDialog,
} from "@/components/ui/sound-dialog";
import { useCopyWithSound } from "@/hooks/use-copy-with-sound";
import { useSound } from "@/hooks/use-sound";
import { reportDiagnostic } from "@/lib/runtime-diagnostics";
import { useWalletToolbox } from "@/providers/wallet-toolbox-provider";
import { PaySuccessMark } from "./pay-success-mark";
import styles from "./wallet-home.module.css";
import {
formatSatoshisAsBsv,
Expand All @@ -35,7 +38,7 @@ interface ReviewedSend {
type SendState =
| { status: "idle" }
| ({ status: "review" | "sending" } & ReviewedSend)
| { status: "success"; txid: string }
| { status: "success"; txid: string; satoshis: number }
| { status: "error"; message: string };

export function WalletHomeActions() {
Expand All @@ -48,12 +51,24 @@ export function WalletHomeActions() {
} = useWalletToolbox();
const queryClient = useQueryClient();
const [, copy] = useCopyWithSound();
const { play, preparePayChime } = useSound();
const playedTxid = useRef<string | null>(null);
const inFlightRef = useRef(false);
const [sendOpen, setSendOpen] = useState(false);
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
const [sendState, setSendState] = useState<SendState>({ status: "idle" });

useEffect(() => {
if (
sendState.status === "success" &&
playedTxid.current !== sendState.txid
) {
playedTxid.current = sendState.txid;
play("payChime");
}
}, [sendState, play]);

const reviewSend = () => {
const satoshis = parseBsvAmount(amount);
if (!satoshis) {
Expand Down Expand Up @@ -93,6 +108,7 @@ export function WalletHomeActions() {
return;
}
const { destination, satoshis } = sendState;
preparePayChime();
inFlightRef.current = true;
setSendState({ status: "sending", destination, satoshis });

Expand All @@ -118,6 +134,7 @@ export function WalletHomeActions() {
setSendState({
status: "success",
txid: result.txid,
satoshis,
});
refreshBalance();
void queryClient.invalidateQueries({ queryKey: ["wallet-actions"] });
Expand Down Expand Up @@ -214,20 +231,49 @@ export function WalletHomeActions() {
<Send data-icon="inline-start" /> Send
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Send BSV</DialogTitle>
<DialogDescription>
Enter a BSV address and the amount to send.
</DialogDescription>
</DialogHeader>
<DialogContent
showCloseButton={sendState.status !== "success"}
className={
sendState.status === "success"
? "pay-success-card max-h-[calc(100dvh-2rem)] overflow-y-auto rounded-3xl bg-popover p-6 text-center sm:p-8 [--primary:var(--chart-1)] [--primary-foreground:var(--foreground)] dark:[--primary:var(--ring)] dark:[--primary-foreground:var(--background)]"
: undefined
}
>
{sendState.status !== "success" && (
<DialogHeader>
<DialogTitle>Send BSV</DialogTitle>
<DialogDescription>
Enter a BSV address and the amount to send.
</DialogDescription>
</DialogHeader>
)}
{sendState.status === "success" ? (
<div className="space-y-3 py-4 text-center" role="status">
<Check className="mx-auto size-8 text-emerald-500" />
<p className="font-medium">Transaction sent</p>
<p className="break-all font-mono text-muted-foreground text-xs">
{sendState.txid}
</p>
<div className="space-y-5 text-center" role="status">
<PaySuccessMark />
<div className="pay-success-copy space-y-2">
<DialogTitle className="font-bold text-2xl text-foreground leading-tight">
Payment Successful
</DialogTitle>
<DialogDescription className="text-muted-foreground text-sm">
Your payment has been processed.
</DialogDescription>
<p className="pt-2 font-bold text-3xl text-foreground break-all">
{formatSatoshisAsBsv(sendState.satoshis)} BSV
</p>
</div>
<DialogClose asChild>
<Button className="h-14 w-full rounded-full bg-primary font-bold text-primary-foreground">
Done
</Button>
</DialogClose>
<details className="text-muted-foreground text-sm">
<summary className="cursor-pointer rounded-sm py-2 focus-visible:outline-2 focus-visible:outline-ring">
Transaction details
</summary>
<p className="mt-2 break-all font-mono text-xs">
{sendState.txid}
</p>
</details>
</div>
) : sendState.status === "review" ||
sendState.status === "sending" ? (
Expand Down
20 changes: 2 additions & 18 deletions hooks/use-sound-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ const SOUND_MUTED_KEY = "1sat_sound_muted";

function getInitialMuted(): boolean {
if (typeof window === "undefined") return false;
// Respect prefers-reduced-motion
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return true;
}
// Motion and sound are independent accessibility preferences.
return window.localStorage.getItem(SOUND_MUTED_KEY) === "1";
}

Expand All @@ -35,7 +32,7 @@ function setGlobalMuted(value: boolean) {

/**
* Hook for managing global sound mute state.
* Persists to localStorage. Respects prefers-reduced-motion.
* Persists the explicit sound preference to localStorage.
*/
export function useSoundSettings() {
// Keep the server and first client render identical; read browser preferences
Expand All @@ -51,19 +48,6 @@ export function useSoundSettings() {
};
}, []);

// Listen for prefers-reduced-motion changes
useEffect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (e: MediaQueryListEvent) => {
if (e.matches) {
setGlobalMuted(true);
}
};
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);

const toggleMuted = useCallback(() => {
setGlobalMuted(!getGlobalMuted());
}, []);
Expand Down
118 changes: 90 additions & 28 deletions hooks/use-sound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { SOUND_VOLUMES, SOUNDS, type SoundName } from "@/lib/sounds";

/**
* One module-level browser audio pool shared across all hook consumers,
* so the six sound assets are created/preloaded once instead of once per
* so the UI sound assets are created/preloaded once instead of once per
* mounted component.
*/
const sharedAudioPool = new Map<SoundName, HTMLAudioElement>();
Expand All @@ -18,19 +18,75 @@ function ensureSharedPoolPreloaded(): void {
poolPreloadStarted = true;

for (const [name, path] of Object.entries(SOUNDS)) {
if (name === "payChime") continue; // Decoded once for gesture-safe Web Audio playback.
const audio = new Audio(path);
audio.preload = "auto";
audio.volume = SOUND_VOLUMES[name as SoundName];
sharedAudioPool.set(name as SoundName, audio);
}
}

// Keep the context alive across the asynchronous send. resume() must be called
// synchronously from Confirm, before the wallet request yields user activation.
let payContext: AudioContext | undefined;
let payReady: Promise<AudioBuffer | null> | undefined;

export function preparePayChime(): void {
if (isSoundMuted() || typeof window === "undefined") return;
try {
payContext ??= new AudioContext();
const context = payContext;
const resumed = context.resume();
const decoded =
payReady ??
fetch(SOUNDS.payChime)
.then((response) => {
if (!response.ok) throw new Error("Chime unavailable");
return response.arrayBuffer();
})
.then((bytes) => context.decodeAudioData(bytes));
payReady = Promise.all([resumed, decoded])
.then(([, buffer]) => buffer)
.catch(() => {
payReady = undefined;
return null;
});
} catch {
// Audio failure must never prevent a send.
}
}

export async function playPayChime(
volume = SOUND_VOLUMES.payChime,
): Promise<void> {
if (isSoundMuted()) return;
try {
const buffer = await payReady;
// Mute can change while the send or audio decoding is pending.
if (!buffer || !payContext || isSoundMuted()) return;
const source = payContext.createBufferSource();
const gain = payContext.createGain();
source.buffer = buffer;
source.loop = false;
gain.gain.value = Math.max(0, Math.min(1, volume));
source.connect(gain);
gain.connect(payContext.destination);
source.onended = () => {
source.disconnect();
gain.disconnect();
};
source.start();
} catch {
// Silent, non-fatal; no payment data belongs in audio diagnostics.
}
}

/**
* Hook for playing UI sounds.
*
* Uses Web Audio API with HTMLAudioElement for broad compatibility.
* Uses HTMLAudioElement for UI taps and Web Audio for deferred payment audio.
* Sounds are preloaded once into a shared module-level pool for instant playback.
* Respects the global mute toggle and prefers-reduced-motion.
* Respects the global mute toggle independently of reduced motion.
*
* @example
* const { play } = useSound();
Expand All @@ -42,32 +98,38 @@ export function useSound() {
ensureSharedPoolPreloaded();
}, []);

const play = (sound: SoundName, volumeOverride?: number) => {
if (isSoundMuted()) return;
const play = playSound;

let audio = sharedAudioPool.get(sound);
if (!audio) {
// Pool not preloaded yet (e.g. first play before the mount
// effect runs); initialize now and keep playing in this same
// user gesture instead of returning silently.
ensureSharedPoolPreloaded();
audio = sharedAudioPool.get(sound);
if (!audio) return;
}
return { play, preparePayChime };
}

// Apply volume override if provided
if (volumeOverride !== undefined) {
audio.volume = Math.max(0, Math.min(1, volumeOverride));
} else {
audio.volume = SOUND_VOLUMES[sound];
}
export const playSound = (sound: SoundName, volumeOverride?: number) => {
if (isSoundMuted()) return;
if (sound === "payChime") {
void playPayChime(volumeOverride);
return;
}

// Reset and play
audio.currentTime = 0;
audio.play().catch(() => {
// Silently fail if audio can't play (user hasn't interacted, etc.)
});
};
let audio = sharedAudioPool.get(sound);
if (!audio) {
// Pool not preloaded yet (e.g. first play before the mount
// effect runs); initialize now and keep playing in this same
// user gesture instead of returning silently.
ensureSharedPoolPreloaded();
audio = sharedAudioPool.get(sound);
if (!audio) return;
}

return { play };
}
// Apply volume override if provided
if (volumeOverride !== undefined) {
audio.volume = Math.max(0, Math.min(1, volumeOverride));
} else {
audio.volume = SOUND_VOLUMES[sound];
}

// Reset and play
audio.currentTime = 0;
audio.play().catch(() => {
// Silently fail if audio can't play (user hasn't interacted, etc.)
});
};
Loading