- 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.
303 lines
9.2 KiB
TypeScript
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,
|
|
}
|
|
}
|