Files
indee-demo/src/composables/useAccounts.ts
Dorian d1ac281ad9 feat: enhance webhook event handling and improve profile fetching logic
- Updated the WebhooksService to differentiate between 'InvoiceSettled' and 'InvoicePaymentSettled' events, ensuring accurate payment confirmation and logging.
- Enhanced the useAccounts composable to improve display name handling by skipping generic placeholder names and ensuring the most recent profile metadata is fetched from multiple relays.
- Modified the IndeehubApiService to handle JWT token refresh failures gracefully, allowing public endpoints to function without authentication.
- Updated content store logic to fetch all published projects from the public API, ensuring backstage content is visible to all users regardless of their active content source.

These changes improve the reliability of payment processing, enhance user profile representation, and ensure content visibility across the application.
2026-02-14 12:05:32 +00:00

303 lines
9.2 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())
})
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,
}
}