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
26 changes: 26 additions & 0 deletions frontend/src/NotificationSettings.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { useState } from 'react';
import Header from './components/Header';
import NotificationPreferences from './components/NotificationPreferences';
import {
isSuccessChimeEnabled,
playSuccessChime,
setSuccessChimeEnabled,
} from './lib/successChime';
import './Landing.css';

export default function NotificationSettings({
Expand All @@ -14,6 +20,15 @@ export default function NotificationSettings({
onConnectWallet,
onDisconnectWallet,
}) {
const [chimeEnabled, setChimeEnabled] = useState(isSuccessChimeEnabled);

const handleChimeToggle = (event) => {
const enabled = event.target.checked;
setSuccessChimeEnabled(enabled);
setChimeEnabled(enabled);
if (enabled) playSuccessChime({ force: true });
};

return (
<div className="landing">
<Header
Expand All @@ -31,6 +46,17 @@ export default function NotificationSettings({
<main id="main-content" className="landing-main" tabIndex="-1">
<section className="section" style={{ maxWidth: 820, margin: '0 auto' }}>
<NotificationPreferences />
<div style={{ marginTop: 24 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={chimeEnabled}
onChange={handleChimeToggle}
data-testid="success-chime-toggle"
/>
Play a subtle chime when a transaction succeeds
</label>
</div>
</section>
</main>
</div>
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/components/TransactionStatus.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { RECOVERY_ACTION, RECOVERY_ACTION_META } from '../lib/errorMapping';
import { useTransactionRecovery } from '../hooks/useTransactionRecovery';
import { playSuccessChime } from '../lib/successChime';
import './TransactionStatus.css';

const VARIANT_ICON = { success: '✓', pending: '⏳', error: '✕' };
Expand Down Expand Up @@ -38,6 +39,15 @@ export default function TransactionStatus({
onTopUp,
}) {
const [copied, setCopied] = useState(false);
const chimedHashRef = useRef(null);

// Optional audio cue (#1234) when a transaction turns green. Plays once per
// hash and only if the user opted in from Notification settings.
useEffect(() => {
if (variant !== 'success' || !hash || chimedHashRef.current === hash) return;
chimedHashRef.current = hash;
playSuccessChime();
}, [variant, hash]);

const { handlers, getExplorerUrl, getReportUrl } = useTransactionRecovery({
onRetry,
Expand Down
23 changes: 22 additions & 1 deletion frontend/src/components/TransactionStatus.test.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { beforeEach, describe, it, expect, vi } from 'vitest';
import TransactionStatus from './TransactionStatus';
import { RECOVERY_ACTION } from '../lib/errorMapping';
import { playSuccessChime } from '../lib/successChime';

vi.mock('../lib/successChime', () => ({ playSuccessChime: vi.fn() }));

const HASH = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';

Expand Down Expand Up @@ -242,3 +245,21 @@ describe('TransactionStatus — callback wiring', () => {
expect(onTopUp).toHaveBeenCalledTimes(1);
});
});

describe('TransactionStatus success chime (#1234)', () => {
beforeEach(() => {
playSuccessChime.mockClear();
});

it('plays the chime once per successful hash', () => {
const { rerender } = render(<TransactionStatus hash={HASH} variant="success" />);
rerender(<TransactionStatus hash={HASH} variant="success" />);
expect(playSuccessChime).toHaveBeenCalledTimes(1);
});

it('does not chime for pending or error states', () => {
render(<TransactionStatus variant="pending" />);
render(<TransactionStatus variant="error" message="boom" />);
expect(playSuccessChime).not.toHaveBeenCalled();
});
});
73 changes: 73 additions & 0 deletions frontend/src/lib/successChime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Optional success chime (#1234).
*
* A short two-note tone synthesized with the Web Audio API, so no audio asset
* ships with the bundle. Off by default; users opt in from Notification
* settings and the choice is kept in localStorage.
*/

export const SUCCESS_CHIME_STORAGE_KEY = 'trivela:success-chime';

let audioContext = null;

export function isSuccessChimeEnabled() {
try {
return window.localStorage.getItem(SUCCESS_CHIME_STORAGE_KEY) === 'on';
} catch {
return false;
}
}

export function setSuccessChimeEnabled(enabled) {
try {
window.localStorage.setItem(SUCCESS_CHIME_STORAGE_KEY, enabled ? 'on' : 'off');
} catch {
// Storage blocked (private mode etc.) — the preference just won't persist.
}
}

/**
* Plays the chime if the user enabled it. Never throws: audio is a nicety and
* must not break the success flow.
*
* @param {{ force?: boolean }} [options] - `force` skips the preference check
* (used by the settings "preview" button).
* @returns {boolean} whether a chime was scheduled.
*/
export function playSuccessChime({ force = false } = {}) {
if (!force && !isSuccessChimeEnabled()) return false;
if (typeof window === 'undefined') return false;

const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return false;

try {
audioContext = audioContext ?? new AudioCtx();
if (audioContext.state === 'suspended') audioContext.resume?.();

const now = audioContext.currentTime;
// C6 then E6 — short, soft, and clearly "positive".
[1046.5, 1318.5].forEach((freq, i) => {
const start = now + i * 0.09;
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.08, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.25);
osc.connect(gain);
gain.connect(audioContext.destination);
osc.start(start);
osc.stop(start + 0.26);
});
return true;
} catch {
return false;
}
}

/** Test helper: drop the cached AudioContext. */
export function __resetSuccessChimeForTests() {
audioContext = null;
}
73 changes: 73 additions & 0 deletions frontend/src/lib/successChime.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SUCCESS_CHIME_STORAGE_KEY,
__resetSuccessChimeForTests,
isSuccessChimeEnabled,
playSuccessChime,
setSuccessChimeEnabled,
} from './successChime';

function makeFakeAudioContext() {
const node = () => ({
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
frequency: { setValueAtTime: vi.fn() },
gain: { setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() },
});
return vi.fn(function FakeAudioContext() {
this.state = 'running';
this.currentTime = 0;
this.destination = {};
this.createOscillator = vi.fn(node);
this.createGain = vi.fn(node);
});
}

describe('successChime', () => {
beforeEach(() => {
window.localStorage.clear();
__resetSuccessChimeForTests();
});

afterEach(() => {
delete window.AudioContext;
});

it('is disabled by default', () => {
expect(isSuccessChimeEnabled()).toBe(false);
});

it('persists the preference', () => {
setSuccessChimeEnabled(true);
expect(window.localStorage.getItem(SUCCESS_CHIME_STORAGE_KEY)).toBe('on');
expect(isSuccessChimeEnabled()).toBe(true);
setSuccessChimeEnabled(false);
expect(isSuccessChimeEnabled()).toBe(false);
});

it('does not play when disabled', () => {
window.AudioContext = makeFakeAudioContext();
expect(playSuccessChime()).toBe(false);
expect(window.AudioContext).not.toHaveBeenCalled();
});

it('plays two notes when enabled', () => {
const Ctx = makeFakeAudioContext();
window.AudioContext = Ctx;
setSuccessChimeEnabled(true);
expect(playSuccessChime()).toBe(true);
const ctx = Ctx.mock.instances[0];
expect(ctx.createOscillator).toHaveBeenCalledTimes(2);
});

it('force plays even when disabled (settings preview)', () => {
window.AudioContext = makeFakeAudioContext();
expect(playSuccessChime({ force: true })).toBe(true);
});

it('fails quietly when Web Audio is unavailable', () => {
setSuccessChimeEnabled(true);
expect(playSuccessChime()).toBe(false);
});
});
Loading