refactor: simplify Nostr login button and improve Amber login flow

- Removed conditional rendering for the Nostr login button, ensuring it is always displayed when the extension is available.
- Enhanced the Amber login flow with clearer messaging during the waiting phases for pubkey and signature.
- Added paste fallback options for users in case clipboard functionality fails, improving user experience during the login process.

These changes streamline the authentication experience and provide better feedback to users during the login phases.
This commit is contained in:
Dorian
2026-02-14 13:07:52 +00:00
parent 11d289d793
commit e10b0a0406

View File

@@ -64,31 +64,29 @@
</div> </div>
</div> </div>
<!-- Nostr Login Button (NIP-07 Browser Extension) only shown when extension is installed --> <!-- Nostr Login Button (NIP-07 Browser Extension) -->
<template v-if="hasNostrExtension"> <button
<button @click="handleNostrLogin"
@click="handleNostrLogin" :disabled="isLoading"
:disabled="isLoading" class="nostr-login-button w-full flex items-center justify-center gap-2"
class="nostr-login-button w-full flex items-center justify-center gap-2" >
> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /> </svg>
</svg> Sign in with Nostr Extension
Sign in with Nostr Extension </button>
</button>
<!-- Divider between Nostr methods --> <!-- Divider between Nostr methods -->
<div class="relative my-4"> <div class="relative my-4">
<div class="absolute inset-0 flex items-center"> <div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-white/10"></div> <div class="w-full border-t border-white/10"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-transparent text-white/30 text-xs">or</span>
</div>
</div> </div>
</template> <div class="relative flex justify-center text-sm">
<span class="px-4 bg-transparent text-white/30 text-xs">or</span>
</div>
</div>
<!-- Amber Login (NIP-55 Android Signer) 3-phase flow --> <!-- Amber Login (NIP-55 Android Signer) -->
<button <button
v-if="amberPhase === 'idle'" v-if="amberPhase === 'idle'"
@click="handleAmberOpen" @click="handleAmberOpen"
@@ -102,54 +100,97 @@
Sign in with Amber Sign in with Amber
</button> </button>
<!-- Phase 2: Read pubkey from clipboard --> <!-- Waiting for pubkey from Amber (auto-detects on return) -->
<div v-else-if="amberPhase === 'waiting-pubkey'" class="mt-3 space-y-3"> <div v-else-if="amberPhase === 'waiting-pubkey'" class="mt-3 space-y-3">
<p class="text-sm text-white/60 text-center"> <div class="flex items-center justify-center gap-2 text-sm text-[#F7931A]">
Approved in Amber? Tap below to continue. <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
</p> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<button <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
@click="handleAmberPubkeyRead"
:disabled="isLoading"
class="amber-login-button amber-login-button--active w-full flex items-center justify-center gap-2"
>
<svg class="w-5 h-5 animate-pulse" viewBox="0 0 24 24" fill="none">
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.3" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M9 12l2 2 4-4" stroke="#F7931A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
Continue <span>Waiting for Amber...</span>
</button> </div>
<p class="text-xs text-white/40 text-center">Approve the request in Amber, then come back here</p>
<!-- Paste fallback (shown after timeout) -->
<div v-if="showPasteFallback" class="space-y-2 pt-2 border-t border-white/5">
<p class="text-xs text-white/50 text-center">Clipboard didn't work? Paste your public key:</p>
<div class="flex gap-2">
<input
v-model="amberPasteInput"
type="text"
placeholder="npub1... or hex pubkey"
class="auth-input flex-1 text-xs"
/>
<button
@click="handleAmberPasteSubmit"
:disabled="!amberPasteInput.trim()"
class="amber-login-button px-4 text-xs"
:class="{ 'opacity-40 cursor-not-allowed': !amberPasteInput.trim() }"
>
Go
</button>
</div>
</div>
<button <button
@click="amberPhase = 'idle'; amberPubkey = null; amberUnsignedEvent = null" @click="cancelAmber"
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors" class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
> >
Cancel Cancel
</button> </button>
</div> </div>
<!-- Phase 3: Read signed NIP-98 event from clipboard --> <!-- Waiting for NIP-98 signature from Amber (auto-detects on return) -->
<div v-else-if="amberPhase === 'waiting-signature'" class="mt-3 space-y-3"> <div v-else-if="amberPhase === 'waiting-signature'" class="mt-3 space-y-3">
<p class="text-sm text-white/60 text-center"> <div class="flex items-center justify-center gap-2 text-sm text-[#F7931A]">
Approve the sign-in request in Amber, then tap below. <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
</p> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<button <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
@click="handleAmberSignComplete"
:disabled="isLoading"
class="amber-login-button amber-login-button--active w-full flex items-center justify-center gap-2"
>
<svg class="w-5 h-5 animate-pulse" viewBox="0 0 24 24" fill="none">
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.3" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M9 12l2 2 4-4" stroke="#F7931A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
Complete Sign-in <span>Approve sign-in in Amber...</span>
</button> </div>
<p class="text-xs text-white/40 text-center">Approve the signing request, then come back here</p>
<!-- Paste fallback (shown after timeout) -->
<div v-if="showPasteFallback" class="space-y-2 pt-2 border-t border-white/5">
<p class="text-xs text-white/50 text-center">Clipboard didn't work? Paste the signature:</p>
<div class="flex gap-2">
<input
v-model="amberPasteInput"
type="text"
placeholder="Hex signature or signed event JSON"
class="auth-input flex-1 text-xs"
/>
<button
@click="handleAmberSignPasteSubmit"
:disabled="!amberPasteInput.trim()"
class="amber-login-button px-4 text-xs"
:class="{ 'opacity-40 cursor-not-allowed': !amberPasteInput.trim() }"
>
Go
</button>
</div>
</div>
<button <button
@click="amberPhase = 'idle'; amberPubkey = null; amberUnsignedEvent = null" @click="cancelAmber"
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors" class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
> >
Cancel Cancel
</button> </button>
</div> </div>
<!-- Completing sign-in -->
<div v-else-if="amberPhase === 'completing'" class="mt-3">
<div class="flex items-center justify-center gap-2 text-sm text-[#F7931A]">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>Completing sign-in...</span>
</div>
</div>
<!-- Toggle Mode --> <!-- Toggle Mode -->
<div class="mt-6 text-center text-sm text-white/60"> <div class="mt-6 text-center text-sm text-white/60">
{{ mode === 'register' ? 'Already have an account?' : "Don't have an account?" }} {{ mode === 'register' ? 'Already have an account?' : "Don't have an account?" }}
@@ -256,14 +297,14 @@ const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
const errorMessage = ref<string | null>(null) const errorMessage = ref<string | null>(null)
const isLoading = computed(() => authLoading.value || sovereignGenerating.value) const isLoading = computed(() => authLoading.value || sovereignGenerating.value)
// NIP-07 extension detection — hide the button entirely when no extension is installed // Amber login state
const hasNostrExtension = ref(!!(window as any).nostr) const amberPhase = ref<'idle' | 'waiting-pubkey' | 'waiting-signature' | 'completing'>('idle')
// Amber three-phase login state
const amberPhase = ref<'idle' | 'waiting-pubkey' | 'waiting-signature'>('idle')
const amberPubkey = ref<string | null>(null) const amberPubkey = ref<string | null>(null)
/** The exact unsigned NIP-98 event sent to Amber for signing (reused in Phase 3) */
const amberUnsignedEvent = ref<any>(null) const amberUnsignedEvent = ref<any>(null)
const amberPasteInput = ref('')
const showPasteFallback = ref(false)
let amberFallbackTimer: ReturnType<typeof setTimeout> | null = null
let amberVisibilityHandler: (() => void) | null = null
// ── Sovereign Identity Flow ────────────────────────────────────── // ── Sovereign Identity Flow ──────────────────────────────────────
type SovereignPhase = 'normal' | 'nah' | 'own-privacy' | 'generated' type SovereignPhase = 'normal' | 'nah' | 'own-privacy' | 'generated'
@@ -281,17 +322,14 @@ watch(() => props.isOpen, (open) => {
sovereignDismissed.value = false sovereignDismissed.value = false
generatedKeys.value = null generatedKeys.value = null
errorMessage.value = null errorMessage.value = null
amberPhase.value = 'idle' cancelAmber()
amberUnsignedEvent.value = null
} }
}) })
function closeModal() { function closeModal() {
emit('close') emit('close')
errorMessage.value = null errorMessage.value = null
amberPhase.value = 'idle' cancelAmber()
amberPubkey.value = null
amberUnsignedEvent.value = null
sovereignPhase.value = 'normal' sovereignPhase.value = 'normal'
sovereignDismissed.value = false sovereignDismissed.value = false
generatedKeys.value = null generatedKeys.value = null
@@ -434,78 +472,126 @@ async function handleNostrLogin() {
} }
/** /**
* Amber Login — Phase 1: Open Amber via Android intent to get the pubkey. * Clean up Amber flow state, timers, and event listeners.
*/
function cancelAmber() {
amberPhase.value = 'idle'
amberPubkey.value = null
amberUnsignedEvent.value = null
amberPasteInput.value = ''
showPasteFallback.value = false
if (amberFallbackTimer) {
clearTimeout(amberFallbackTimer)
amberFallbackTimer = null
}
if (amberVisibilityHandler) {
document.removeEventListener('visibilitychange', amberVisibilityHandler)
amberVisibilityHandler = null
}
}
/**
* Start listening for the user to return from Amber.
* On visibilitychange → visible, waits 500ms then tries to read
* clipboard. If clipboard read fails or is empty, shows paste fallback.
* *
* Amber copies the pubkey to the clipboard. Mobile browsers block * @param onSuccess Called with clipboard text when auto-read succeeds.
* automatic clipboard reads (navigator.clipboard.readText requires a */
* user gesture), so we move to phase 2 where the user taps a button function listenForAmberReturn(onSuccess: (text: string) => void) {
* to trigger the read inside a real user-gesture event handler. // Show paste fallback after 8 seconds regardless
amberFallbackTimer = setTimeout(() => {
showPasteFallback.value = true
}, 8000)
amberVisibilityHandler = () => {
if (document.visibilityState !== 'visible') return
// User returned to browser — try clipboard after a short delay
// (same 500ms + 200ms pattern as AmberClipboardSigner)
setTimeout(async () => {
try {
if (!navigator.clipboard?.readText) {
showPasteFallback.value = true
return
}
const text = (await navigator.clipboard.readText()).trim()
if (!text) {
showPasteFallback.value = true
return
}
// Clipboard read succeeded — clean up listener and proceed
if (amberVisibilityHandler) {
document.removeEventListener('visibilitychange', amberVisibilityHandler)
amberVisibilityHandler = null
}
if (amberFallbackTimer) {
clearTimeout(amberFallbackTimer)
amberFallbackTimer = null
}
onSuccess(text)
} catch {
// Clipboard permission denied or other error — show paste input
showPasteFallback.value = true
}
}, 600)
}
document.addEventListener('visibilitychange', amberVisibilityHandler)
}
/**
* Amber Login — Step 1: Open Amber to get the public key.
* Sets up auto-detection via visibilitychange for when the user returns.
*/ */
function handleAmberOpen() { function handleAmberOpen() {
errorMessage.value = null errorMessage.value = null
showPasteFallback.value = false
amberPasteInput.value = ''
// Check platform support
const isAndroid = navigator.userAgent.includes('Android') const isAndroid = navigator.userAgent.includes('Android')
if (!isAndroid) { if (!isAndroid) {
errorMessage.value = 'Amber is only available on Android devices. Please use a Nostr browser extension instead.' errorMessage.value = 'Amber is only available on Android devices. Please use a Nostr browser extension instead.'
return return
} }
// Open Amber via the nostrsigner intent to get pubkey // Open Amber via intent to get pubkey
const intent = 'intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end' const intent = 'intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end'
window.open(intent, '_blank') window.open(intent, '_blank')
// Switch to phase 2 — user will tap "Continue" after Amber approves
amberPhase.value = 'waiting-pubkey' amberPhase.value = 'waiting-pubkey'
// Auto-detect: when user returns, try to read pubkey from clipboard
listenForAmberReturn((clipboardText) => {
processPubkey(clipboardText)
})
} }
/** /**
* Read a pubkey from clipboard text. Handles hex, npub, and nprofile formats. * Decode a pubkey from text. Handles hex, npub, and nprofile formats.
*/ */
async function decodePubkeyFromClipboard(clipboardText: string): Promise<string> { async function decodePubkeyText(text: string): Promise<string> {
if (/^[0-9a-f]{64}$/i.test(clipboardText)) { if (/^[0-9a-f]{64}$/i.test(text)) return text
return clipboardText if (text.startsWith('npub') || text.startsWith('nprofile')) {
}
if (clipboardText.startsWith('npub') || clipboardText.startsWith('nprofile')) {
const { decodeProfilePointer } = await import('applesauce-core/helpers') const { decodeProfilePointer } = await import('applesauce-core/helpers')
const decoded = decodeProfilePointer(clipboardText) const decoded = decodeProfilePointer(text)
if (!decoded?.pubkey) throw new Error('Could not decode npub from clipboard') if (!decoded?.pubkey) throw new Error('Could not decode npub')
return decoded.pubkey return decoded.pubkey
} }
throw new Error('Clipboard does not contain a valid Nostr public key. Got: ' + clipboardText.slice(0, 20) + '...') throw new Error('Not a valid public key. Expected hex or npub format.')
} }
/** /**
* Amber Login — Phase 2: Read the pubkey from the clipboard, then * Process a pubkey (from auto-read or paste), then open Amber to sign NIP-98.
* construct the NIP-98 event and open Amber again to sign it.
*
* This runs inside a click handler (user gesture) so
* navigator.clipboard.readText() is permitted by the browser.
*/ */
async function handleAmberPubkeyRead() { async function processPubkey(text: string) {
errorMessage.value = null errorMessage.value = null
try { try {
if (!navigator.clipboard?.readText) { const pubkey = await decodePubkeyText(text)
throw new Error('Clipboard API not available')
}
const clipboardText = (await navigator.clipboard.readText()).trim()
if (!clipboardText) {
throw new Error('Clipboard is empty. Please try again — make sure you approved the request in Amber.')
}
const pubkey = await decodePubkeyFromClipboard(clipboardText)
amberPubkey.value = pubkey amberPubkey.value = pubkey
showPasteFallback.value = false
amberPasteInput.value = ''
// NOTE: We do NOT register the Amber account in accountManager here. // Build the unsigned NIP-98 event
// That happens in Phase 3 after backend login succeeds, to avoid a
// split state where accountManager.active is set but authStore
// isn't authenticated (which confuses guards and the UI).
// Build the unsigned NIP-98 event that the backend expects.
// Store it so Phase 3 can reuse the exact same event data
// (same created_at) when combining with the returned signature.
const sessionUrl = authService.getNostrSessionUrl() const sessionUrl = authService.getNostrSessionUrl()
const unsignedEvent = { const unsignedEvent = {
kind: 27235, kind: 27235,
@@ -519,94 +605,69 @@ async function handleAmberPubkeyRead() {
} }
amberUnsignedEvent.value = unsignedEvent amberUnsignedEvent.value = unsignedEvent
// Open Amber to sign this NIP-98 event // Open Amber to sign this event
const eventJson = JSON.stringify(unsignedEvent) const eventJson = JSON.stringify(unsignedEvent)
const intent = `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=event;S.type=sign_event;S.event=${encodeURIComponent(eventJson)};end` const intent = `intent:${encodeURIComponent(eventJson)}#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=sign_event;end`
window.open(intent, '_blank') window.open(intent, '_blank')
// Move to phase 3 — user will tap "Complete Sign-in" after Amber signs
amberPhase.value = 'waiting-signature' amberPhase.value = 'waiting-signature'
} catch (error: any) {
console.error('Amber pubkey read failed:', error) // Auto-detect: when user returns, try to read signature from clipboard
handleAmberError(error) listenForAmberReturn((clipboardText) => {
processSignature(clipboardText)
})
} catch (err: any) {
errorMessage.value = err.message || 'Invalid public key. Please try again.'
showPasteFallback.value = true
} }
} }
/** /**
* Amber Login — Phase 3: Read the signed NIP-98 event from the * Manual paste submit for pubkey.
* clipboard and complete authentication.
*
* This runs inside a click handler (user gesture) so
* navigator.clipboard.readText() is permitted by the browser.
*/ */
async function handleAmberSignComplete() { function handleAmberPasteSubmit() {
const text = amberPasteInput.value.trim()
if (!text) return
processPubkey(text)
}
/**
* Process a signature (from auto-read or paste) and complete login.
*/
async function processSignature(text: string) {
errorMessage.value = null errorMessage.value = null
const pubkey = amberPubkey.value
if (!pubkey) { errorMessage.value = 'Lost pubkey. Please start over.'; cancelAmber(); return }
const storedEvent = amberUnsignedEvent.value
if (!storedEvent) { errorMessage.value = 'Lost signing request. Please start over.'; cancelAmber(); return }
amberPhase.value = 'completing'
try { try {
if (!navigator.clipboard?.readText) {
throw new Error('Clipboard API not available')
}
const clipboardText = (await navigator.clipboard.readText()).trim()
if (!clipboardText) {
throw new Error('Clipboard is empty. Make sure you approved the signing request in Amber.')
}
const pubkey = amberPubkey.value
if (!pubkey) throw new Error('Lost track of the pubkey. Please start over.')
const storedEvent = amberUnsignedEvent.value
if (!storedEvent) throw new Error('Lost track of the signing request. Please start over.')
// Amber may return different formats depending on version:
// 1. Full signed event JSON (starts with '{')
// 2. Just the hex signature (64-byte Schnorr = 128 hex chars)
// 3. Hex string of varying length (some Amber versions)
let authHeader: string let authHeader: string
// Try 1: Full signed event JSON // Full signed event JSON
if (clipboardText.startsWith('{')) { if (text.startsWith('{')) {
try { const signedEvent = JSON.parse(text)
const signedEvent = JSON.parse(clipboardText) if (!signedEvent.sig) throw new Error('Signed event missing signature')
if (signedEvent.sig) { authHeader = `Nostr ${btoa(JSON.stringify(signedEvent))}`
authHeader = `Nostr ${btoa(JSON.stringify(signedEvent))}`
} else {
throw new Error('missing sig')
}
} catch {
throw new Error('Clipboard contains JSON but it is not a valid signed event. Please try again.')
}
} }
// Try 2: Hex string (signature) — combine with the stored unsigned event // Hex signature — combine with stored unsigned event
else if (/^[0-9a-f]+$/i.test(clipboardText)) { else if (/^[0-9a-f]+$/i.test(text)) {
const sig = clipboardText const event = { ...storedEvent, sig: text }
// Reconstruct the full event using the EXACT unsigned event we
// sent to Amber (same created_at, tags, etc.) + the signature
const event = {
...storedEvent,
sig,
}
// Calculate the event ID
const { getEventHash } = await import('nostr-tools/pure') const { getEventHash } = await import('nostr-tools/pure')
event.id = getEventHash(event as any) event.id = getEventHash(event as any)
authHeader = `Nostr ${btoa(JSON.stringify(event))}` authHeader = `Nostr ${btoa(JSON.stringify(event))}`
} }
// Nothing we recognise
else { else {
throw new Error( throw new Error('Unrecognised signature format from Amber.')
'Could not read the signed event from Amber. '
+ 'Please make sure you approved the request in Amber, then try again.'
)
} }
// Authenticate with the pre-signed NIP-98 header (bypasses signer.signEvent) // Authenticate with the pre-signed NIP-98 header
await loginWithNostr(pubkey, 'amber-nip55', {}, authHeader) await loginWithNostr(pubkey, 'amber-nip55', {}, authHeader)
// Backend login succeeded — NOW register the Amber account in // Register the Amber account in accountManager
// accountManager so the rest of the UI reflects the logged-in state.
try { try {
const { AmberClipboardSigner, AmberClipboardAccount } = await import('../lib/accounts') const { AmberClipboardSigner, AmberClipboardAccount } = await import('../lib/accounts')
const signer = new AmberClipboardSigner() const signer = new AmberClipboardSigner()
@@ -618,30 +679,24 @@ async function handleAmberSignComplete() {
console.warn('[Amber] Account registration after login:', accountErr) console.warn('[Amber] Account registration after login:', accountErr)
} }
amberPhase.value = 'idle' cancelAmber()
amberPubkey.value = null
amberUnsignedEvent.value = null
emit('success') emit('success')
closeModal() closeModal()
} catch (error: any) { } catch (err: any) {
console.error('Amber sign-in failed:', error) console.error('Amber sign-in failed:', err)
handleAmberError(error) errorMessage.value = err.message || 'Failed to complete Amber sign-in. Please try again.'
amberPhase.value = 'waiting-signature'
showPasteFallback.value = true
} }
} }
/** /**
* Shared error handler for Amber flow phases. * Manual paste submit for signature.
*/ */
function handleAmberError(error: any) { function handleAmberSignPasteSubmit() {
if (error.message?.includes('Clipboard is empty')) { const text = amberPasteInput.value.trim()
errorMessage.value = 'Clipboard is empty. Open Amber, approve the request, then come back and tap the button.' if (!text) return
} else if (error.message?.includes('not available')) { processSignature(text)
errorMessage.value = 'Clipboard access is not available. Try using Chrome on Android.'
} else if (error.message?.includes('permission')) {
errorMessage.value = 'Clipboard permission denied. Please allow clipboard access and try again.'
} else {
errorMessage.value = error.message || 'Failed to read from Amber. Please try again.'
}
} }
// Declare window.nostr for TypeScript // Declare window.nostr for TypeScript