import { ref, computed, watch, onUnmounted } from 'vue' import { Accounts } from 'applesauce-accounts' import { onlyEvents } from 'applesauce-relay' import { accountManager, AmberClipboardSigner, AmberClipboardAccount } from '../lib/accounts' import { TEST_PERSONAS, TASTEMAKER_PERSONAS } from '../data/testPersonas' import { pool, APP_RELAYS, LOOKUP_RELAYS } from '../lib/relay' import type { Subscription } from 'rxjs' type Persona = { name: string; nsec: string; pubkey: string } /** Nostr kind 0 profile metadata */ interface NostrProfileMetadata { name?: string display_name?: string picture?: string about?: string nip05?: string lud16?: string banner?: string } /** * Vue composable for Nostr account management. * Wraps applesauce AccountManager observables as Vue reactive refs. * Provides login methods for Extension, test personas, and private keys. */ export function useAccounts() { const activeAccount = ref(null) const allAccounts = ref([]) const isLoggingIn = ref(false) const loginError = ref(null) /** Cached kind 0 profile metadata keyed by pubkey */ const profileCache = ref>(new Map()) const subscriptions: Subscription[] = [] // Subscribe to active account changes const activeSub = accountManager.active$.subscribe((account) => { activeAccount.value = account ?? null }) subscriptions.push(activeSub) // Subscribe to all accounts changes const accountsSub = accountManager.accounts$.subscribe((accounts) => { allAccounts.value = [...accounts] }) subscriptions.push(accountsSub) const isLoggedIn = computed(() => activeAccount.value !== null) const activePubkey = computed(() => activeAccount.value?.pubkey ?? null) /** The current user's kind 0 profile metadata (fetched from relays) */ const activeProfile = computed(() => { if (!activePubkey.value) return null return profileCache.value.get(activePubkey.value) ?? null }) /** * Display name: prefer Nostr profile → test persona → npub slice. * Skips generic placeholder names like "Nostr" so the user sees * a more useful identifier while they set up their profile. */ const activeName = computed(() => { if (!activeAccount.value) return null // First: check if we have kind 0 profile metadata const profile = activeProfile.value if (profile?.display_name && !/^nostr$/i.test(profile.display_name.trim())) { return profile.display_name } if (profile?.name && !/^nostr$/i.test(profile.name.trim())) { return profile.name } // Second: check test personas const allPersonas: Persona[] = [ ...(TEST_PERSONAS as unknown as Persona[]), ...(TASTEMAKER_PERSONAS as unknown as Persona[]), ] const match = allPersonas.find( (p) => p.pubkey === activeAccount.value?.pubkey, ) if (match?.name) return match.name // Fallback: truncated npub for a friendlier display const pk = activeAccount.value?.pubkey if (!pk) return 'Unknown' return `npub...${pk.slice(-6)}` }) /** * Profile picture URL: prefer Nostr profile picture → robohash fallback. */ const activeProfilePicture = computed(() => { const profile = activeProfile.value if (profile?.picture) return profile.picture if (activePubkey.value) return `https://robohash.org/${activePubkey.value}.png` return null }) /** * Fetch kind 0 profile metadata for a pubkey from relays. * Queries multiple relays and keeps the MOST RECENT event * (highest created_at) to ensure we get the latest profile. */ function fetchProfile(pubkey: string) { if (!pubkey || profileCache.value.has(pubkey)) return // Track the best (most recent) event seen so far for this fetch let bestCreatedAt = 0 const relays = [...APP_RELAYS, ...LOOKUP_RELAYS] try { const profileSub = pool .subscription(relays, [ { kinds: [0], authors: [pubkey], limit: 1 }, ]) .pipe(onlyEvents()) .subscribe({ next: (event: any) => { try { // Only accept this event if it's newer than what we already have const eventTime = event.created_at ?? 0 if (eventTime < bestCreatedAt) return const metadata: NostrProfileMetadata = JSON.parse(event.content) // Skip profiles with no meaningful data (empty or // generic placeholder names without a picture) const hasName = !!(metadata.display_name || metadata.name) const isGeneric = hasName && /^nostr$/i.test((metadata.display_name || metadata.name || '').trim()) const hasPicture = !!metadata.picture // Accept if: has a non-generic name, has a picture, or // this is the first event we've received if (bestCreatedAt === 0 || !isGeneric || hasPicture) { bestCreatedAt = eventTime const updated = new Map(profileCache.value) updated.set(pubkey, metadata) profileCache.value = updated } } catch { // Invalid JSON in profile event } }, }) // Close subscription after 8 seconds (allows slower relays to respond) setTimeout(() => profileSub.unsubscribe(), 8000) subscriptions.push(profileSub) } catch (err) { console.error(`[useAccounts] Failed to fetch profile for ${pubkey}:`, err) } } // Auto-fetch profile when active account changes watch(activePubkey, (pubkey) => { if (pubkey) fetchProfile(pubkey) }, { immediate: true }) /** * Login with a browser extension (NIP-07) */ async function loginWithExtension() { isLoggingIn.value = true loginError.value = null try { const account = await Accounts.ExtensionAccount.fromExtension() accountManager.addAccount(account) accountManager.setActive(account) } catch (err: any) { loginError.value = err.message || 'Extension login failed' console.error('Extension login error:', err) } finally { isLoggingIn.value = false } } /** * Login with a test persona (for development) */ async function loginWithPersona(persona: Persona) { isLoggingIn.value = true loginError.value = null try { const account = Accounts.PrivateKeyAccount.fromKey(persona.nsec) accountManager.addAccount(account) accountManager.setActive(account) } catch (err: any) { loginError.value = err.message || 'Persona login failed' console.error('Persona login error:', err) } finally { isLoggingIn.value = false } } /** * Login with a private key (nsec) */ async function loginWithPrivateKey(nsec: string) { isLoggingIn.value = true loginError.value = null try { const account = Accounts.PrivateKeyAccount.fromKey(nsec) accountManager.addAccount(account) accountManager.setActive(account) } catch (err: any) { loginError.value = err.message || 'Private key login failed' console.error('Private key login error:', err) } finally { isLoggingIn.value = false } } /** * Check if Amber signer is supported on this platform (Android + clipboard) */ const isAmberSupported = computed(() => !!AmberClipboardSigner.SUPPORTED) /** * Login with Amber (NIP-55 Android Signer). * Uses the AmberClipboardSigner to request the pubkey via Android intents. * The signer is retained for future event signing (comments, reactions, etc.) */ async function loginWithAmber() { isLoggingIn.value = true loginError.value = null try { const signer = new AmberClipboardSigner() const pubkey = await signer.getPublicKey() const account = new AmberClipboardAccount(pubkey, signer) accountManager.addAccount(account) accountManager.setActive(account) return pubkey } catch (err: any) { loginError.value = err.message || 'Amber login failed' console.error('Amber login error:', err) throw err } finally { isLoggingIn.value = false } } /** * Logout current account. * removeAccount already clears the active account if it's the one being removed. */ function logout() { const current = accountManager.active if (current) { try { accountManager.removeAccount(current) } catch (err) { console.debug('Account removal cleanup:', err) } } } /** * Get all available test personas */ const testPersonas = computed(() => [ ...(TEST_PERSONAS as unknown as Persona[]), ]) const tastemakerPersonas = computed(() => [ ...(TASTEMAKER_PERSONAS as unknown as Persona[]), ]) // Cleanup on unmount onUnmounted(() => { subscriptions.forEach((sub) => sub.unsubscribe()) }) return { // State activeAccount, allAccounts, isLoggedIn, isLoggingIn, loginError, activePubkey, activeName, activeProfile, activeProfilePicture, // Login methods loginWithExtension, loginWithPersona, loginWithPrivateKey, loginWithAmber, logout, // Platform checks isAmberSupported, // Personas for dev UI testPersonas, tastemakerPersonas, } }