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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes are documented here. Format based on [Keep a Changelog](http
## [Unreleased]

### Added
- `OfflineTransactionSync` — floating badge shows `N drafts queued offline` (count pill, black and white,
full sentence as the status region's accessible name) and refreshes when another tab queues or
flushes a draft, via `storage` events on the queue key; label from the new
`queuedDraftsLabel()` in `lib/offline-transactions.ts` (#795)
- `lib/i18n/` — i18next/react-i18next bootstrap and a `dashboard` message namespace;
`/dashboard` is fully externalized as the proof-of-concept page for the string-externalization
migration (#558)
Expand Down
28 changes: 26 additions & 2 deletions components/OfflineTransactionSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
import { useEffect, useState } from 'react';
import { useWallet } from '@/contexts/WalletContext';
import {
OFFLINE_QUEUE_STORAGE_KEY,
flushQueuedTransactions,
queuedDraftsLabel,
queuedTransactions,
startOnlineSync,
stopOnlineSync,
} from '@/lib/offline-transactions';

/**
* #739, #809 — Queue failed transactions, retry on reconnect, and notify user.
* #795 — Show how many drafts are queued offline, kept in sync across tabs.
*/
export function OfflineTransactionSync() {
const { publicKey, signTx } = useWallet();
Expand Down Expand Up @@ -39,29 +42,50 @@ export function OfflineTransactionSync() {
setTimeout(() => setShowRetryPrompt(false), 3000);
};

// Another tab queuing or flushing a draft only reaches this tab as a
// `storage` event on the queue key (#795); a null key means storage was
// cleared.
const onStorage = (event: StorageEvent) => {
if (event.key === null || event.key === OFFLINE_QUEUE_STORAGE_KEY) updateCount();
};

window.addEventListener('online', flush);
window.addEventListener('offline-transactions-changed', updateCount);
window.addEventListener('storage', onStorage);
updateCount();

return () => {
stopOnlineSync();
window.removeEventListener('online', flush);
window.removeEventListener('offline-transactions-changed', updateCount);
window.removeEventListener('storage', onStorage);
};
}, [publicKey, signTx]);

if (queuedCount === 0 && !showRetryPrompt) return null;

const label = queuedDraftsLabel(queuedCount);
const words = label.slice(label.indexOf(' ') + 1);

return (
<div
className="fixed top-4 right-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200 text-xs px-3 py-2 rounded shadow-lg z-50"
className="fixed top-4 right-4 z-50 flex items-center gap-2 rounded border border-black bg-white px-3 py-2 text-xs text-black shadow-lg dark:border-white dark:bg-black dark:text-white"
role="status"
aria-live="polite"
aria-label={showRetryPrompt ? undefined : label}
>
{showRetryPrompt ? (
<span>Connection restored. Retrying {queuedCount} queued transaction{queuedCount === 1 ? '' : 's'}…</span>
) : (
<span>{queuedCount} transaction{queuedCount === 1 ? '' : 's'} queued for retry</span>
<>
<span
aria-hidden="true"
className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-black px-1.5 font-semibold tabular-nums text-white dark:bg-white dark:text-black"
>
{queuedCount}
</span>{' '}
<span aria-hidden="true">{words}</span>
</>
)}
</div>
);
Expand Down
70 changes: 65 additions & 5 deletions components/__tests__/OfflineTransactionSync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@ 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),
}));
let queued: { id: string }[] = [];

vi.mock('@/lib/offline-transactions', async () => {
const actual = await vi.importActual<typeof import('@/lib/offline-transactions')>('@/lib/offline-transactions');
return {
startOnlineSync: vi.fn(),
stopOnlineSync: vi.fn(),
flushQueuedTransactions: vi.fn().mockResolvedValue(undefined),
queuedTransactions: vi.fn(() => queued),
queuedDraftsLabel: actual.queuedDraftsLabel,
OFFLINE_QUEUE_STORAGE_KEY: actual.OFFLINE_QUEUE_STORAGE_KEY,
};
});

import { OfflineTransactionSync } from '../OfflineTransactionSync';
import {
Expand Down Expand Up @@ -43,6 +51,7 @@ describe('OfflineTransactionSync (#809)', () => {
beforeEach(() => {
vi.clearAllMocks();
currentPublicKey = 'GTESTPUBLICKEY';
queued = [];
});

it('binds the online listener and flushes once when a wallet is connected', async () => {
Expand Down Expand Up @@ -71,3 +80,54 @@ describe('OfflineTransactionSync (#809)', () => {
cleanup(root, container);
});
});

describe('OfflineTransactionSync queue badge (#795)', () => {
beforeEach(() => {
vi.clearAllMocks();
currentPublicKey = 'GTESTPUBLICKEY';
queued = [];
});

it('renders nothing while the queue is empty', () => {
const { container, root } = render();
expect(container.textContent).toBe('');
cleanup(root, container);
});

it('shows how many drafts are queued offline', () => {
queued = [{ id: 'a' }, { id: 'b' }];
const { container, root } = render();
expect(container.textContent).toContain('2 drafts queued offline');
expect(container.querySelector('[role="status"]')?.getAttribute('aria-label')).toBe('2 drafts queued offline');
cleanup(root, container);
});

it('uses the singular for one draft', () => {
queued = [{ id: 'a' }];
const { container, root } = render();
expect(container.textContent).toContain('1 draft queued offline');
cleanup(root, container);
});

it('updates the count when the queue changes', () => {
const { container, root } = render();
expect(container.textContent).toBe('');
queued = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
act(() => { window.dispatchEvent(new Event('offline-transactions-changed')); });
expect(container.textContent).toContain('3 drafts queued offline');
queued = [];
act(() => { window.dispatchEvent(new Event('offline-transactions-changed')); });
expect(container.textContent).toBe('');
cleanup(root, container);
});

it('updates the count when another tab changes the queue', () => {
const { container, root } = render();
queued = [{ id: 'a' }];
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: 'conduit-offline-transactions' }));
});
expect(container.textContent).toContain('1 draft queued offline');
cleanup(root, container);
});
});
22 changes: 22 additions & 0 deletions lib/__tests__/offline-transactions-badge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { queuedDraftsLabel, OFFLINE_QUEUE_STORAGE_KEY } from '../offline-transactions';

describe('queuedDraftsLabel (#795)', () => {
it('uses the singular for one draft', () => {
expect(queuedDraftsLabel(1)).toBe('1 draft queued offline');
});

it('uses the plural for several drafts', () => {
expect(queuedDraftsLabel(2)).toBe('2 drafts queued offline');
expect(queuedDraftsLabel(12)).toBe('12 drafts queued offline');
});

it('treats invalid counts as zero', () => {
expect(queuedDraftsLabel(-3)).toBe('0 drafts queued offline');
expect(queuedDraftsLabel(Number.NaN)).toBe('0 drafts queued offline');
});

it('exposes the storage key the queue lives under', () => {
expect(OFFLINE_QUEUE_STORAGE_KEY).toBe('conduit-offline-transactions');
});
});
16 changes: 16 additions & 0 deletions lib/offline-transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ type NewOfflineTransaction =

const KEY = 'conduit-offline-transactions';
const DEAD_LETTER_KEY = `${KEY}:dead-letter`;

/**
* localStorage key the offline queue lives under. Other tabs that queue or
* flush transactions announce it through `storage` events on this key, so
* the queue badge can stay in sync across tabs (#795).
*/
export const OFFLINE_QUEUE_STORAGE_KEY = KEY;

/**
* Text for the offline queue badge (#795): "1 draft queued offline",
* "3 drafts queued offline". Negative or non-finite counts read as zero.
*/
export function queuedDraftsLabel(count: number): string {
const n = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
return `${n} draft${n === 1 ? '' : 's'} queued offline`;
}
export const MAX_SYNC_ATTEMPTS = 3;

function read(): OfflineTransaction[] {
Expand Down