diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 828a1c9..709b3f4 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1089,6 +1089,8 @@ export default { 'No muted users': 'No muted users', 'No muted threads': 'No muted threads', 'Muted words apply to all your accounts.': 'Muted words apply to all your accounts.', - 'Reveal muted thread': 'Reveal muted thread' + 'Reveal muted thread': 'Reveal muted thread', + 'Waiting for signer approval...': 'Waiting for signer approval...', + 'Signer did not respond in time': 'Signer did not respond in time' } } diff --git a/src/lib/__tests__/signer-approval.spec.ts b/src/lib/__tests__/signer-approval.spec.ts new file mode 100644 index 0000000..8d2ec87 --- /dev/null +++ b/src/lib/__tests__/signer-approval.spec.ts @@ -0,0 +1,51 @@ +import { kinds } from 'nostr-tools' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { withSignerApproval } from '../signer-approval' + +// signer-approval imports sonner (for the waiting toast) and @/i18n (for copy). +// Mock both so the module is testable in the node env and we can assert whether +// the toast was scheduled. +vi.mock('sonner', () => ({ toast: { loading: vi.fn(), dismiss: vi.fn() } })) +vi.mock('@/i18n', () => ({ default: { t: (key: string) => key } })) + +describe('withSignerApproval', () => { + afterEach(() => { + vi.mocked(toast.loading).mockClear() + vi.mocked(toast.dismiss).mockClear() + }) + + it('passes NIP-42 relay AUTH (kind 22242) straight through with no toast', async () => { + const result = await withSignerApproval(Promise.resolve('signed'), kinds.ClientAuth) + expect(result).toBe('signed') + expect(toast.loading).not.toHaveBeenCalled() + }) + + it('passes NIP-98 HTTP AUTH (kind 27235) straight through with no toast', async () => { + const result = await withSignerApproval(Promise.resolve('signed'), kinds.HTTPAuth) + expect(result).toBe('signed') + expect(toast.loading).not.toHaveBeenCalled() + }) + + it('does not apply the approval timeout to background AUTH signs', async () => { + // A never-resolving AUTH sign must not be rejected by the timeout: it is + // returned verbatim. Race it against a short sentinel — the sentinel wins, + // proving withSignerApproval did not reject it at the (tiny) timeout. + const never = new Promise(() => {}) + const sentinel = new Promise((resolve) => setTimeout(() => resolve('sentinel'), 30)) + const winner = await Promise.race([withSignerApproval(never, kinds.ClientAuth, 5), sentinel]) + expect(winner).toBe('sentinel') + }) + + it('still enforces the timeout for a normal user-initiated sign (kind 1)', async () => { + const never = new Promise(() => {}) + await expect(withSignerApproval(never, 1, 20)).rejects.toThrow('Signer did not respond in time') + }) + + it('resolves a normal sign and shows then dismisses nothing for an instant resolve', async () => { + const result = await withSignerApproval(Promise.resolve('ok'), 1) + expect(result).toBe('ok') + // Instant resolve beats the 1s show delay, so the loading toast never fires. + expect(toast.loading).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/signer-approval.ts b/src/lib/signer-approval.ts new file mode 100644 index 0000000..82df7f0 --- /dev/null +++ b/src/lib/signer-approval.ts @@ -0,0 +1,90 @@ +import i18n from '@/i18n' +import { kinds } from 'nostr-tools' +import { toast } from 'sonner' + +// Signers that require manual approval — NIP-46 remote signers (bunker / +// nostr-connect) and NIP-07 browser extensions configured to prompt — forward +// a sign request to the user's signer and wait. Without any feedback the user +// has no idea a signature is pending and may forget to approve it. +// +// We show a deliberately low-key hint: after a short delay (so instant +// auto-approvals stay silent), a small loading toast appears in the corner and +// auto-dismisses once the signature comes back. Concurrent sign requests are +// reference-counted into a single toast so frequent signing never stacks up. +// +// We also bound the wait with a timeout: if the signer never responds (offline +// bunker, closed extension popup, etc.) the request rejects instead of hanging +// forever. The window is generous so a user manually approving still makes it. + +const SHOW_DELAY_MS = 1000 +const TIMEOUT_MS = 30_000 +const TOAST_ID = 'signer-approval-waiting' + +// NIP-42 relay AUTH (kind 22242) and NIP-98 HTTP AUTH (kind 27235) are signed +// automatically in the background — relay-connection AUTH, media-upload and +// translation HTTP auth — never as a user-initiated action. They must not surface +// an approval-wait toast or be bounded by the user-approval timeout: an AUTH-gated +// relay the user never manually approves would otherwise spam the toast and reject +// every background AUTH at the 30s mark. +const BACKGROUND_SIGN_KINDS = new Set([kinds.ClientAuth, kinds.HTTPAuth]) + +let pending = 0 +let timer: ReturnType | null = null +let shown = false + +function scheduleShow() { + timer = setTimeout(() => { + timer = null + if (pending > 0) { + shown = true + toast.loading(i18n.t('Waiting for signer approval...'), { + id: TOAST_ID, + duration: Infinity + }) + } + }, SHOW_DELAY_MS) +} + +function hide() { + if (timer) { + clearTimeout(timer) + timer = null + } + if (shown) { + shown = false + toast.dismiss(TOAST_ID) + } +} + +export async function withSignerApproval( + promise: Promise, + kind?: number, + timeout = TIMEOUT_MS +): Promise { + // Background auth signs pass straight through — no toast, no timeout. + if (kind !== undefined && BACKGROUND_SIGN_KINDS.has(kind)) { + return promise + } + if (pending === 0) { + scheduleShow() + } + pending++ + + let timeoutTimer: ReturnType | undefined + const timeoutPromise = new Promise((_, reject) => { + timeoutTimer = setTimeout( + () => reject(new Error(i18n.t('Signer did not respond in time'))), + timeout + ) + }) + + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + clearTimeout(timeoutTimer) + pending-- + if (pending === 0) { + hide() + } + } +} diff --git a/src/providers/NostrProvider/bunker.signer.ts b/src/providers/NostrProvider/bunker.signer.ts index e8c52cb..6c72ec6 100644 --- a/src/providers/NostrProvider/bunker.signer.ts +++ b/src/providers/NostrProvider/bunker.signer.ts @@ -1,3 +1,4 @@ +import { withSignerApproval } from '@/lib/signer-approval' import { ISigner, TDraftEvent } from '@/types' import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import { base64 } from '@scure/base' @@ -118,7 +119,7 @@ export class BunkerSigner implements ISigner { if (!this.signer) { throw new Error('Not logged in') } - return this.signer.signEvent(draftEvent) + return withSignerApproval(this.signer.signEvent(draftEvent), draftEvent.kind) } async nip04Encrypt(pubkey: string, plainText: string) { diff --git a/src/providers/NostrProvider/nip-07.signer.ts b/src/providers/NostrProvider/nip-07.signer.ts index f6bbc48..8a7c5e2 100644 --- a/src/providers/NostrProvider/nip-07.signer.ts +++ b/src/providers/NostrProvider/nip-07.signer.ts @@ -1,3 +1,4 @@ +import { withSignerApproval } from '@/lib/signer-approval' import { ISigner, TDraftEvent, TNip07 } from '@/types' export class Nip07Signer implements ISigner { @@ -35,7 +36,7 @@ export class Nip07Signer implements ISigner { if (!this.signer) { throw new Error('Should call init() first') } - return await this.signer.signEvent(draftEvent) + return await withSignerApproval(this.signer.signEvent(draftEvent), draftEvent.kind) } async nip04Encrypt(pubkey: string, plainText: string) {