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
11 changes: 11 additions & 0 deletions app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { useWallet } from '@/contexts/WalletContext';
import { createStream, isMock } from '@/lib/factory';
import { TOKENS_TESTNET, tokenLogoUrl } from '@/lib/tokens';
import { CopyHashButton } from '@/components/ui/CopyHashButton';
import { FirstStreamNotificationPrompt } from '@/components/FirstStreamNotificationPrompt';
import { hasCreatedStream, markFirstStreamCreated } from '@/lib/notifications';
import { checkRecipientBalance, checkRecipientExists } from '@/lib/soroban';
import { checkContractHasWithdraw } from '@/lib/contract-recipient-probe';
import { queryClient, refreshStreamData } from '@/lib/queryClient';
Expand Down Expand Up @@ -140,6 +142,9 @@ export default function CreatePage() {
const [pending, setPending] = useState(false);
const [txHash, setTxHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// #810 — only offer the notification opt-in after the very first successful
// stream creation.
const [firstStreamCreated, setFirstStreamCreated] = useState(false);

// Tracks the SEP-41 allowance pre-flight (#218) so the button label can
// tell the user which on-chain step they're currently waiting on — an
Expand Down Expand Up @@ -499,6 +504,11 @@ export default function CreatePage() {
await invalidateTokenBalance(queryClient, publicKey, tokenAddr);

setTxHash(hash);
// #810 — the notification prompt belongs after this moment of value, not
// on the landing page. Only surface it while the flag is still unset.
const isFirstStream = !hasCreatedStream();
markFirstStreamCreated();
if (isFirstStream) setFirstStreamCreated(true);
try { localStorage.removeItem(DRAFT_KEY); } catch { /* storage is optional */ }
// #362 — createStream now decodes the confirmed transaction's return
// value, so we can deep-link straight to the new stream instead of
Expand Down Expand Up @@ -528,6 +538,7 @@ export default function CreatePage() {
<p className="font-mono text-xs text-gray-400 dark:text-gray-500 break-all">{txHash}</p>
<CopyHashButton hash={txHash} className={`shrink-0 ${styles.shrink0}`} />
</div>
<FirstStreamNotificationPrompt firstStream={firstStreamCreated} />
</div>
</div>
);
Expand Down
77 changes: 77 additions & 0 deletions components/FirstStreamNotificationPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client';

import { useEffect, useState } from 'react';
import {
dismissNotificationPermissionPrompt,
requestNotificationPermissionAfterFirstStream,
shouldRequestNotificationPermission,
} from '@/lib/notifications';

interface FirstStreamNotificationPromptProps {
/** True when this render follows the creation of the user's first stream. */
firstStream: boolean;
className?: string;
}

/**
* Contextual notification opt-in shown after a stream is created (#810).
* Asking here — rather than on first page load — means the request arrives
* right after the user has seen value, and the browser prompt is triggered by
* an explicit click instead of a cold landing-page visit.
*/
export function FirstStreamNotificationPrompt({
firstStream,
className = '',
}: FirstStreamNotificationPromptProps) {
const [visible, setVisible] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
setVisible(firstStream && shouldRequestNotificationPermission());
}, [firstStream]);

if (!visible) return null;

const handleEnable = async () => {
setVisible(false);
setError(null);
const result = await requestNotificationPermissionAfterFirstStream();
if (result === 'denied') {
setError('Notifications are blocked in your browser settings.');
}
};

const handleDismiss = () => {
dismissNotificationPermissionPrompt();
setVisible(false);
};

return (
<div
role="status"
className={`border border-gray-200 dark:border-gray-700 rounded px-3 py-3 mt-6 text-left ${className}`}
>
<p className="text-sm font-semibold dark:text-white">Get notified when your stream pays out</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
We can let you know when funds are ready to withdraw and when a claim confirms.
</p>
{error && (
<p role="alert" className="text-xs text-red-600 mt-2">
{error}
</p>
)}
<div className="flex items-center gap-2 mt-3">
<button type="button" onClick={() => void handleEnable()} className="btn-primary text-xs">
Enable notifications
</button>
<button
type="button"
onClick={handleDismiss}
className="text-xs underline text-gray-400 hover:text-black dark:hover:text-white"
>
Not now
</button>
</div>
</div>
);
}
26 changes: 17 additions & 9 deletions components/OfflineTransactionSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

import { useEffect, useState } from 'react';
import { useWallet } from '@/contexts/WalletContext';
import { flushQueuedTransactions, queuedTransactions } from '@/lib/offline-transactions';
import {
flushQueuedTransactions,
queuedTransactions,
startOnlineSync,
stopOnlineSync,
} from '@/lib/offline-transactions';

/**
* #739 — Queue failed transactions and prompt retry upon reconnect.
*
* When a transaction fails due to network issues, it's queued and the user
* is prompted to retry when the connection is restored. Also tracks queued
* count and shows a notification when transactions are flushed.
* #739, #809 — Queue failed transactions, retry on reconnect, and notify user.
*/
export function OfflineTransactionSync() {
const { publicKey, signTx } = useWallet();
Expand All @@ -19,16 +20,22 @@ export function OfflineTransactionSync() {
useEffect(() => {
if (!publicKey) return;

const updateCount = () => setQueuedCount(queuedTransactions().length);
startOnlineSync(signTx);
void flushQueuedTransactions(signTx);

const updateCount = () => {
if (typeof queuedTransactions === 'function') {
setQueuedCount(queuedTransactions()?.length ?? 0);
}
};

const flush = async () => {
const pending = queuedTransactions().length;
const pending = typeof queuedTransactions === 'function' ? queuedTransactions()?.length ?? 0 : 0;
if (pending > 0) {
setShowRetryPrompt(true);
}
await flushQueuedTransactions(signTx);
updateCount();
// Hide prompt after successful flush
setTimeout(() => setShowRetryPrompt(false), 3000);
};

Expand All @@ -37,6 +44,7 @@ export function OfflineTransactionSync() {
updateCount();

return () => {
stopOnlineSync();
window.removeEventListener('online', flush);
window.removeEventListener('offline-transactions-changed', updateCount);
};
Expand Down
116 changes: 116 additions & 0 deletions components/__tests__/FirstStreamNotificationPrompt.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { FirstStreamNotificationPrompt } from '../FirstStreamNotificationPrompt';
import { markFirstStreamCreated } from '@/lib/notifications';

const PROMPT_SHOWN_KEY = 'conduit:notification-prompt-shown';

interface NotificationMock {
permission: NotificationPermission;
requestPermission: ReturnType<typeof vi.fn>;
}

function installNotification(permission: NotificationPermission, result?: NotificationPermission): NotificationMock {
const requestPermission = vi.fn(async () => {
const next = result ?? permission;
MockNotification.permission = next;
return next;
});
class MockNotification {
static permission: NotificationPermission = permission;
static requestPermission = requestPermission;
constructor(_title: string, _options?: NotificationOptions) {}
}
(globalThis as unknown as { Notification: unknown }).Notification = MockNotification;
(window as unknown as { Notification: unknown }).Notification = MockNotification;
return { permission, requestPermission };
}

function uninstallNotification() {
delete (globalThis as unknown as { Notification?: unknown }).Notification;
delete (window as unknown as { Notification?: unknown }).Notification;
}

function render(firstStream: boolean) {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(React.createElement(FirstStreamNotificationPrompt, { firstStream }));
});
return { container, root };
}

function cleanup(root: ReturnType<typeof createRoot>, container: HTMLElement) {
act(() => root.unmount());
document.body.removeChild(container);
}

describe('FirstStreamNotificationPrompt (#810)', () => {
beforeEach(() => {
window.localStorage.clear();
markFirstStreamCreated();
});

afterEach(() => {
uninstallNotification();
window.localStorage.clear();
});

it('renders nothing when this was not the first stream', () => {
installNotification('default');
const { container, root } = render(false);
expect(container.textContent).toBe('');
cleanup(root, container);
});

it('renders nothing when the browser does not support notifications', () => {
const { container, root } = render(true);
expect(container.textContent).toBe('');
cleanup(root, container);
});

it('offers the opt-in after the first stream while permission is undecided', () => {
installNotification('default');
const { container, root } = render(true);
expect(container.textContent).toContain('Get notified when your stream pays out');
cleanup(root, container);
});

it('requests permission when the user opts in', async () => {
const notification = installNotification('default', 'granted');
const { container, root } = render(true);

const enable = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('Enable notifications'),
) as HTMLButtonElement;

await act(async () => {
enable.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});

expect(notification.requestPermission).toHaveBeenCalledTimes(1);
cleanup(root, container);
});

it('dismissing marks the prompt as shown and hides it', async () => {
const notification = installNotification('default');
const { container, root } = render(true);

const dismiss = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('Not now'),
) as HTMLButtonElement;

await act(async () => {
dismiss.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});

expect(notification.requestPermission).not.toHaveBeenCalled();
expect(window.localStorage.getItem(PROMPT_SHOWN_KEY)).toBe('true');
expect(container.textContent).toBe('');
cleanup(root, container);
});
});
73 changes: 73 additions & 0 deletions components/__tests__/OfflineTransactionSync.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';

const mockSignTx = vi.fn();
let currentPublicKey: string | null = 'GTESTPUBLICKEY';

vi.mock('@/contexts/WalletContext', () => ({
useWallet: () => ({ publicKey: currentPublicKey, signTx: mockSignTx }),
}));

vi.mock('@/lib/offline-transactions', () => ({
startOnlineSync: vi.fn(),
stopOnlineSync: vi.fn(),
flushQueuedTransactions: vi.fn().mockResolvedValue(undefined),
}));

import { OfflineTransactionSync } from '../OfflineTransactionSync';
import {
startOnlineSync,
stopOnlineSync,
flushQueuedTransactions,
} from '@/lib/offline-transactions';

function render() {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(React.createElement(OfflineTransactionSync));
});
return { container, root };
}

function cleanup(root: ReturnType<typeof createRoot>, container: HTMLElement) {
act(() => {
root.unmount();
});
document.body.removeChild(container);
}

describe('OfflineTransactionSync (#809)', () => {
beforeEach(() => {
vi.clearAllMocks();
currentPublicKey = 'GTESTPUBLICKEY';
});

it('binds the online listener and flushes once when a wallet is connected', async () => {
const { container, root } = render();
await act(async () => { await Promise.resolve(); });

expect(startOnlineSync).toHaveBeenCalledWith(mockSignTx);
expect(flushQueuedTransactions).toHaveBeenCalledWith(mockSignTx);

cleanup(root, container);
});

it('detaches the listener on unmount', () => {
const { container, root } = render();
cleanup(root, container);
expect(stopOnlineSync).toHaveBeenCalledTimes(1);
});

it('does nothing for a disconnected wallet', () => {
currentPublicKey = null;
const { container, root } = render();

expect(startOnlineSync).not.toHaveBeenCalled();
expect(flushQueuedTransactions).not.toHaveBeenCalled();

cleanup(root, container);
});
});
Loading
Loading