diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f7e94..4697e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/components/OfflineTransactionSync.tsx b/components/OfflineTransactionSync.tsx index ebb1259..b2be35a 100644 --- a/components/OfflineTransactionSync.tsx +++ b/components/OfflineTransactionSync.tsx @@ -3,7 +3,9 @@ import { useEffect, useState } from 'react'; import { useWallet } from '@/contexts/WalletContext'; import { + OFFLINE_QUEUE_STORAGE_KEY, flushQueuedTransactions, + queuedDraftsLabel, queuedTransactions, startOnlineSync, stopOnlineSync, @@ -11,6 +13,7 @@ import { /** * #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(); @@ -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 (
{showRetryPrompt ? ( Connection restored. Retrying {queuedCount} queued transaction{queuedCount === 1 ? '' : 's'}… ) : ( - {queuedCount} transaction{queuedCount === 1 ? '' : 's'} queued for retry + <> + {' '} + + )}
); diff --git a/components/__tests__/OfflineTransactionSync.test.tsx b/components/__tests__/OfflineTransactionSync.test.tsx index e740813..8b29fc2 100644 --- a/components/__tests__/OfflineTransactionSync.test.tsx +++ b/components/__tests__/OfflineTransactionSync.test.tsx @@ -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('@/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 { @@ -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 () => { @@ -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); + }); +}); diff --git a/lib/__tests__/offline-transactions-badge.test.ts b/lib/__tests__/offline-transactions-badge.test.ts new file mode 100644 index 0000000..0001985 --- /dev/null +++ b/lib/__tests__/offline-transactions-badge.test.ts @@ -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'); + }); +}); diff --git a/lib/offline-transactions.ts b/lib/offline-transactions.ts index 29a55fa..4c37e9d 100644 --- a/lib/offline-transactions.ts +++ b/lib/offline-transactions.ts @@ -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[] {