- Introduced a new KeysModal component to display and manage nsec/npub for accounts with local private keys. - Updated AppHeader and Profile views to include a "My Keys" button, conditionally rendered based on the presence of a private key. - Enhanced the useAccounts composable to determine if the active account holds a local private key, enabling key management functionality. These changes improve user access to their private key information and enhance the overall account management experience.
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
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<any>(null)
|
|
const allAccounts = ref<any[]>([])
|
|
const isLoggingIn = ref(false)
|
|
const loginError = ref<string | null>(null)
|
|
|
|
/** Cached kind 0 profile metadata keyed by pubkey */
|
|
const profileCache = ref<Map<string, NostrProfileMetadata>>(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<NostrProfileMetadata | null>(() => {
|
|
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())
|
|
})
|
|
|
|
/**
|
|
* Whether the active account holds a local private key
|
|
* (generated sovereign identity or imported nsec).
|
|
* When true, the user can view/export their keys.
|
|
*/
|
|
const hasPrivateKey = computed(() => {
|
|
const acct = activeAccount.value
|
|
if (!acct) return false
|
|
// PrivateKeyAccount has type 'local' and stores the secret key
|
|
// in its signer. Check the account type name set by applesauce.
|
|
const typeName = acct.constructor?.name ?? acct.type ?? ''
|
|
return typeName === 'PrivateKeyAccount' || typeName === 'local'
|
|
})
|
|
|
|
/**
|
|
* Retrieve the active account's nsec and npub.
|
|
* Only works for private-key-based accounts (generated or imported nsec).
|
|
* Returns null if the active account doesn't hold a local secret key.
|
|
*/
|
|
async function getAccountKeys(): Promise<{ nsec: string; npub: string; hexPub: string } | null> {
|
|
const acct = activeAccount.value
|
|
if (!acct?.pubkey) return null
|
|
|
|
try {
|
|
const nip19 = await import('nostr-tools/nip19')
|
|
const npub = nip19.npubEncode(acct.pubkey)
|
|
|
|
// The PrivateKeyAccount stores the secret key in its signer.
|
|
// Access it through the account's serialisation (toJSON includes secrets).
|
|
const signer = acct.signer ?? acct._signer
|
|
const secretKey: Uint8Array | undefined = signer?.key ?? signer?.secretKey ?? signer?._key
|
|
|
|
if (!secretKey) return null
|
|
|
|
const nsec = nip19.nsecEncode(secretKey)
|
|
return { nsec, npub, hexPub: acct.pubkey }
|
|
} catch (err) {
|
|
console.warn('[useAccounts] Failed to extract keys:', err)
|
|
return null
|
|
}
|
|
}
|
|
|
|
return {
|
|
// State
|
|
activeAccount,
|
|
allAccounts,
|
|
isLoggedIn,
|
|
isLoggingIn,
|
|
loginError,
|
|
activePubkey,
|
|
activeName,
|
|
activeProfile,
|
|
activeProfilePicture,
|
|
|
|
// Key management
|
|
hasPrivateKey,
|
|
getAccountKeys,
|
|
|
|
// Login methods
|
|
loginWithExtension,
|
|
loginWithPersona,
|
|
loginWithPrivateKey,
|
|
loginWithAmber,
|
|
logout,
|
|
|
|
// Platform checks
|
|
isAmberSupported,
|
|
|
|
// Personas for dev UI
|
|
testPersonas,
|
|
tastemakerPersonas,
|
|
}
|
|
}
|