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
+ <>
+
+ {queuedCount}
+ {' '}
+ {words}
+ >
)}
);
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[] {