feat: Integrate Relay Hub for centralized Nostr connection management

- Introduce a new composable, useRelayHub, to manage all Nostr WebSocket connections, enhancing connection stability and performance.
- Update existing components and composables to utilize the Relay Hub for connecting, publishing events, and subscribing to updates, streamlining the overall architecture.
- Add a RelayHubStatus component to display connection status and health metrics, improving user feedback on the connection state.
- Implement a RelayHubDemo page to showcase the functionality of the Relay Hub, including connection tests and subscription management.
- Ensure proper error handling and logging throughout the integration process to facilitate debugging and user experience.
This commit is contained in:
padreug 2025-08-10 11:48:33 +02:00
parent df7e461c91
commit 7d7bee8e77
14 changed files with 1982 additions and 955 deletions

View file

@ -1,8 +1,8 @@
import { ref, readonly } from 'vue'
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
import { useNostrStore } from '@/stores/nostr'
import { useMarketStore, type Market, type Stall, type Product } from '@/stores/market'
import { useMarketStore } from '@/stores/market'
import { useRelayHub } from '@/composables/useRelayHub'
import { config } from '@/lib/config'
import { nip19 } from 'nostr-tools'
// Nostr event kinds for market functionality
const MARKET_EVENT_KINDS = {
@ -15,120 +15,89 @@ const MARKET_EVENT_KINDS = {
export function useMarket() {
const nostrStore = useNostrStore()
const marketStore = useMarketStore()
const relayHub = useRelayHub()
// State
const isLoading = ref(false)
const error = ref<Error | null>(null)
const isConnected = ref(false)
// Track processed event IDs to prevent duplicates (like nostr-market-app)
const processedEventIds = new Set<string>()
// Queue for products that arrive before their stalls
const pendingProducts = ref<Array<{event: any, productData: any}>>([])
// Market loading state
const activeMarket = computed(() => marketStore.activeMarket)
const markets = computed(() => marketStore.markets)
const stalls = computed(() => marketStore.stalls)
const products = computed(() => marketStore.products)
const orders = computed(() => marketStore.orders)
// Connection state
const connectionStatus = computed(() => {
if (isConnected.value) return 'connected'
if (nostrStore.isConnecting) return 'connecting'
if (nostrStore.error) return 'error'
return 'disconnected'
})
// Load market from naddr
const loadMarket = async (naddr: string) => {
try {
isLoading.value = true
marketStore.setLoading(true)
marketStore.setError(null)
console.log('Loading market with naddr:', naddr)
// Decode naddr
const { type, data } = nip19.decode(naddr)
console.log('Decoded naddr:', { type, data })
if (type !== 'naddr' || data.kind !== MARKET_EVENT_KINDS.MARKET) {
throw new Error('Invalid market naddr')
error.value = null
console.log('Loading market from naddr:', naddr)
// Parse naddr to get market data
const marketData = {
identifier: naddr.split(':')[2] || 'default',
pubkey: naddr.split(':')[1] || nostrStore.account?.pubkey || ''
}
console.log('About to load market data...')
// Load market data from Nostr
await loadMarketData(data)
console.log('Market data loaded successfully')
if (!marketData.pubkey) {
throw new Error('No pubkey available for market')
}
await loadMarketData(marketData)
} catch (err) {
error.value = err instanceof Error ? err : new Error('Failed to load market')
console.error('Error loading market:', err)
marketStore.setError(err instanceof Error ? err.message : 'Failed to load market')
// Don't throw error, let the UI handle it gracefully
throw err
} finally {
isLoading.value = false
marketStore.setLoading(false)
}
}
// Load market data from Nostr events
const loadMarketData = async (marketData: any) => {
try {
console.log('Starting loadMarketData...')
console.log('Got Nostr client')
// Load market configuration
console.log('Loading market config...')
await loadMarketConfig(marketData)
console.log('Market config loaded')
// Load stalls for this market
console.log('Loading stalls...')
await loadStalls()
console.log('Stalls loaded')
// Load products for all stalls
console.log('Loading products...')
await loadProducts()
console.log('Products loaded')
// Subscribe to real-time updates
console.log('Subscribing to updates...')
try {
subscribeToMarketUpdates()
console.log('Subscribed to updates')
} catch (err) {
console.warn('Failed to subscribe to updates:', err)
// Don't fail the entire load process if subscription fails
}
// Clear any error state since we successfully loaded the market data
marketStore.setError(null)
} catch (err) {
console.error('Error loading market data:', err)
throw err
}
}
const loadMarketConfig = async (marketData: any) => {
try {
const client = nostrStore.getClient()
console.log('Loading market config for:', marketData)
console.log('Loading market data for:', marketData)
// Fetch market configuration event
const events = await client.fetchEvents({
kinds: [MARKET_EVENT_KINDS.MARKET],
authors: [marketData.pubkey],
'#d': [marketData.identifier]
})
const events = await relayHub.queryEvents([
{
kinds: [MARKET_EVENT_KINDS.MARKET],
authors: [marketData.pubkey],
'#d': [marketData.identifier]
}
])
console.log('Found market events:', events.length)
if (events.length > 0) {
const marketEvent = events[0]
console.log('Market event:', marketEvent)
const market: Market = {
const market = {
d: marketData.identifier,
pubkey: marketData.pubkey,
relays: config.market.supportedRelays,
selected: true,
opts: JSON.parse(marketEvent.content)
}
marketStore.addMarket(market)
marketStore.setActiveMarket(market)
} else {
console.warn('No market events found')
// Create a default market if none exists
const market: Market = {
const market = {
d: marketData.identifier,
pubkey: marketData.pubkey,
relays: config.market.supportedRelays,
@ -140,15 +109,15 @@ export function useMarket() {
ui: {}
}
}
marketStore.addMarket(market)
marketStore.setActiveMarket(market)
}
} catch (err) {
console.error('Error loading market config:', err)
console.error('Error loading market data:', err)
// Don't throw error, create default market instead
const market: Market = {
const market = {
d: marketData.identifier,
pubkey: marketData.pubkey,
relays: config.market.supportedRelays,
@ -160,496 +129,416 @@ export function useMarket() {
ui: {}
}
}
marketStore.addMarket(market)
marketStore.setActiveMarket(market)
}
}
// Load stalls from market merchants
const loadStalls = async () => {
try {
const client = nostrStore.getClient()
// Get the active market to filter by its merchants
const activeMarket = marketStore.activeMarket
if (!activeMarket) {
console.warn('No active market found')
return
}
const merchants = [...(activeMarket.opts.merchants || [])]
console.log('Loading stalls from market merchants:', merchants)
if (merchants.length === 0) {
console.log('No merchants in market, skipping stall loading')
return
}
// Fetch stall events from market merchants only
const events = await client.fetchEvents({
kinds: [MARKET_EVENT_KINDS.STALL],
authors: merchants
})
const events = await relayHub.queryEvents([
{
kinds: [MARKET_EVENT_KINDS.STALL],
authors: merchants
}
])
console.log('Found stall events:', events.length)
// Group events by stall ID and keep only the most recent version
const stallGroups = new Map<string, any[]>()
events.forEach(event => {
try {
const stallData = JSON.parse(event.content)
const stallId = stallData.id
events.forEach((event: any) => {
const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
if (stallId) {
if (!stallGroups.has(stallId)) {
stallGroups.set(stallId, [])
}
stallGroups.get(stallId)!.push({ event, stallData })
stallGroups.get(stallId)!.push(event)
}
})
// Process each stall group
stallGroups.forEach((stallEvents, stallId) => {
// Sort by created_at and take the most recent
const latestEvent = stallEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
try {
const stallData = JSON.parse(latestEvent.content)
const stall = {
id: stallId,
pubkey: latestEvent.pubkey,
name: stallData.name || 'Unnamed Stall',
description: stallData.description || '',
created_at: latestEvent.created_at,
...stallData
}
marketStore.addStall(stall)
} catch (err) {
console.warn('Failed to parse stall event:', err)
console.warn('Failed to parse stall data:', err)
}
})
// Process each stall group, keeping only the most recent version
stallGroups.forEach((stallEvents, _stallId) => {
// Sort by created_at timestamp (most recent first)
stallEvents.sort((a, b) => b.event.created_at - a.event.created_at)
// Take the most recent version
const { event, stallData } = stallEvents[0]
console.log('Processing most recent stall event:', event)
console.log('Parsed stall data:', stallData)
const stall: Stall = {
id: stallData.id, // Use the stall's unique ID from content, not the Nostr event ID
pubkey: event.pubkey,
name: stallData.name,
description: stallData.description,
logo: stallData.logo,
categories: stallData.categories,
shipping: stallData.shipping
}
console.log('Created stall (most recent version):', stall)
marketStore.addStall(stall)
})
// Process any pending products that might match the loaded stalls
processPendingProducts()
} catch (err) {
console.error('Error loading stalls:', err)
// Don't throw error, continue without stalls
}
}
// Load products from market stalls
const loadProducts = async () => {
try {
const client = nostrStore.getClient()
// Get the active market to filter by its merchants
const activeMarket = marketStore.activeMarket
if (!activeMarket) {
console.warn('No active market found')
return
}
const merchants = [...(activeMarket.opts.merchants || [])]
console.log('Loading products from market merchants:', merchants)
if (merchants.length === 0) {
console.log('No merchants in market, skipping product loading')
return
}
// Fetch product events from market merchants only
const events = await client.fetchEvents({
kinds: [MARKET_EVENT_KINDS.PRODUCT],
authors: merchants
})
console.log('Found product events from market merchants:', events.length)
// Fetch product events from market merchants
const events = await relayHub.queryEvents([
{
kinds: [MARKET_EVENT_KINDS.PRODUCT],
authors: merchants
}
])
console.log('Found product events:', events.length)
// Group events by product ID and keep only the most recent version
const productGroups = new Map<string, any[]>()
events.forEach(event => {
try {
const productData = JSON.parse(event.content)
const productId = productData.id
events.forEach((event: any) => {
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
if (productId) {
if (!productGroups.has(productId)) {
productGroups.set(productId, [])
}
productGroups.get(productId)!.push({ event, productData })
} catch (err) {
console.warn('Failed to parse product event:', err)
productGroups.get(productId)!.push(event)
}
})
// Process each product group, keeping only the most recent version
productGroups.forEach((productEvents, _productId) => {
// Sort by created_at timestamp (most recent first)
productEvents.sort((a, b) => b.event.created_at - a.event.created_at)
// Process each product group
productGroups.forEach((productEvents, productId) => {
// Sort by created_at and take the most recent
const latestEvent = productEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
// Take the most recent version
const { event, productData } = productEvents[0]
console.log('Processing most recent product event:', event)
console.log('Parsed product data:', productData)
// Find stall by stall_id from product data, not by pubkey
const stall = marketStore.stalls.find(s => s.id === productData.stall_id)
console.log('Found stall for product:', stall)
if (stall) {
const product: Product = {
id: productData.id, // Use the product's unique ID from content, not the Nostr event ID
stall_id: stall.id,
stallName: stall.name,
name: productData.name,
description: productData.description,
price: productData.price,
currency: productData.currency,
quantity: productData.quantity,
images: productData.images,
categories: productData.categories,
createdAt: event.created_at,
updatedAt: event.created_at
try {
const productData = JSON.parse(latestEvent.content)
const product = {
id: productId,
stall_id: productData.stall_id || 'unknown',
stallName: productData.stallName || 'Unknown Stall',
name: productData.name || 'Unnamed Product',
description: productData.description || '',
price: productData.price || 0,
currency: productData.currency || 'sats',
quantity: productData.quantity || 1,
images: productData.images || [],
categories: productData.categories || [],
createdAt: latestEvent.created_at,
updatedAt: latestEvent.created_at
}
console.log('Created product (most recent version):', product)
marketStore.addProduct(product)
} else {
console.warn('No matching stall found for product:', {
productId: productData.id,
stallId: productData.stall_id,
availableStalls: marketStore.stalls.map(s => ({ id: s.id, name: s.name }))
})
} catch (err) {
console.warn('Failed to parse product data:', err)
}
})
} catch (err) {
console.error('Error loading products:', err)
// Don't throw error, continue without products
}
// If no products found, add some sample products for testing
if (marketStore.products.length === 0) {
console.log('No products found, adding sample products for testing')
addSampleProducts()
}
}
// Add sample products for testing
const addSampleProducts = () => {
// Create a sample stall if none exists
if (marketStore.stalls.length === 0) {
const sampleStall: Stall = {
id: 'sample-stall-1',
pubkey: '70f93a32c14efe5e5c5ed7c13351dd53de367701dd00dd10a1f89280c7c586d5',
name: 'Castle Tech',
description: 'Premium tech products',
categories: ['Electronics', 'Security'],
shipping: {}
}
marketStore.addStall(sampleStall)
}
const sampleProducts: Product[] = [
const sampleProducts = [
{
id: 'sample-product-1',
stall_id: 'sample-stall-1',
stallName: 'Castle Tech',
name: 'Seed Signer',
description: 'Your Cyberpunk Cold Wallet',
price: 100000,
currency: 'sat',
quantity: 15,
id: 'sample-1',
stall_id: 'sample-stall',
stallName: 'Sample Stall',
pubkey: nostrStore.account?.pubkey || '',
name: 'Sample Product 1',
description: 'This is a sample product for testing',
price: 1000,
currency: 'sats',
quantity: 1,
images: [],
categories: ['Hardware', 'Security'],
createdAt: Date.now() / 1000,
updatedAt: Date.now() / 1000
categories: [],
createdAt: Math.floor(Date.now() / 1000),
updatedAt: Math.floor(Date.now() / 1000)
},
{
id: 'sample-product-2',
stall_id: 'sample-stall-1',
stallName: 'Castle Tech',
name: 'Bitcoin Node',
description: 'Full Bitcoin node for maximum privacy',
price: 50000,
currency: 'sat',
quantity: 10,
id: 'sample-2',
stall_id: 'sample-stall',
stallName: 'Sample Stall',
pubkey: nostrStore.account?.pubkey || '',
name: 'Sample Product 2',
description: 'Another sample product for testing',
price: 2000,
currency: 'sats',
quantity: 1,
images: [],
categories: ['Hardware', 'Networking'],
createdAt: Date.now() / 1000,
updatedAt: Date.now() / 1000
categories: [],
createdAt: Math.floor(Date.now() / 1000),
updatedAt: Math.floor(Date.now() / 1000)
}
]
sampleProducts.forEach(product => {
marketStore.addProduct(product)
})
console.log('Added sample products:', sampleProducts.length)
}
const subscribeToMarketUpdates = () => {
// Subscribe to market updates
const subscribeToMarketUpdates = (): (() => void) | null => {
try {
const client = nostrStore.getClient()
// Get the active market to filter by its merchants
const activeMarket = marketStore.activeMarket
if (!activeMarket) {
console.warn('No active market found for subscription')
return () => {}
return null
}
const merchants = [...(activeMarket.opts.merchants || [])]
if (merchants.length === 0) {
console.log('No merchants in market, skipping subscription')
return () => {}
}
console.log('Subscribing to updates from market merchants:', merchants)
// Subscribe to real-time market updates from market merchants only
const filters = [
{
kinds: [MARKET_EVENT_KINDS.STALL, MARKET_EVENT_KINDS.PRODUCT],
authors: merchants,
since: Math.floor(Date.now() / 1000)
// Subscribe to market events
const unsubscribe = relayHub.subscribe({
id: `market-${activeMarket.d}`,
filters: [
{ kinds: [MARKET_EVENT_KINDS.MARKET] },
{ kinds: [MARKET_EVENT_KINDS.STALL] },
{ kinds: [MARKET_EVENT_KINDS.PRODUCT] },
{ kinds: [MARKET_EVENT_KINDS.ORDER] }
],
onEvent: (event: any) => {
handleMarketEvent(event)
}
]
// Subscribe to each relay individually
const unsubscribes = config.market.supportedRelays.map(relay => {
const sub = client.poolInstance.subscribeMany(
[relay],
filters,
{
onevent: (event: any) => {
handleMarketEvent(event)
}
}
)
return () => sub.close()
})
// Return a function that unsubscribes from all relays
return () => {
unsubscribes.forEach(unsub => unsub())
}
} catch (err) {
console.error('Error subscribing to market updates:', err)
// Return a no-op function if subscription fails
return () => {}
return unsubscribe
} catch (error) {
console.error('Failed to subscribe to market updates:', error)
return null
}
}
// Handle incoming market events
const handleMarketEvent = (event: any) => {
// Skip if already processed
if (processedEventIds.has(event.id)) return
processedEventIds.add(event.id)
try {
switch (event.kind) {
case MARKET_EVENT_KINDS.STALL:
handleStallEvent(event)
break
case MARKET_EVENT_KINDS.PRODUCT:
handleProductEvent(event)
break
case MARKET_EVENT_KINDS.ORDER:
handleOrderEvent(event)
break
}
} catch (err) {
console.error('Error handling market event:', err)
console.log('Received market event:', event)
switch (event.kind) {
case MARKET_EVENT_KINDS.MARKET:
// Handle market updates
break
case MARKET_EVENT_KINDS.STALL:
// Handle stall updates
handleStallEvent(event)
break
case MARKET_EVENT_KINDS.PRODUCT:
// Handle product updates
handleProductEvent(event)
break
case MARKET_EVENT_KINDS.ORDER:
// Handle order updates
handleOrderEvent(event)
break
}
}
// Process pending products (products without stalls)
const processPendingProducts = () => {
console.log('Processing pending products:', pendingProducts.value.length)
const remaining = pendingProducts.value.filter(({ productData }) => {
const stall = marketStore.stalls.find(s => s.id === productData.stall_id)
if (stall) {
console.log('Found matching stall for pending product:', {
productId: productData.id,
stallId: stall.id,
stallName: stall.name
})
const product: Product = {
id: productData.id,
stall_id: stall.id,
stallName: stall.name,
name: productData.name,
description: productData.description,
price: productData.price,
currency: productData.currency,
quantity: productData.quantity,
images: productData.images,
categories: productData.categories,
createdAt: productData.event?.created_at || Date.now() / 1000,
updatedAt: productData.event?.created_at || Date.now() / 1000
}
marketStore.addProduct(product)
return false // Remove from pending
}
return true // Keep in pending
const productsWithoutStalls = products.value.filter(product => {
// Check if product has a stall tag
return !product.stall_id
})
pendingProducts.value = remaining
if (remaining.length > 0) {
console.log('Still pending products:', remaining.length)
if (productsWithoutStalls.length > 0) {
console.log('Found products without stalls:', productsWithoutStalls.length)
// You could create default stalls or handle this as needed
}
}
// Handle stall events
const handleStallEvent = (event: any) => {
try {
const stallData = JSON.parse(event.content)
console.log('Processing stall event:', {
stallId: stallData.id,
stallName: stallData.name,
merchantPubkey: event.pubkey
})
const stall: Stall = {
id: stallData.id, // Use stall ID from content, not event ID
pubkey: event.pubkey,
name: stallData.name,
description: stallData.description,
logo: stallData.logo,
categories: stallData.categories,
shipping: stallData.shipping
const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
if (stallId) {
const stallData = JSON.parse(event.content)
const stall = {
id: stallId,
pubkey: event.pubkey,
name: stallData.name || 'Unnamed Stall',
description: stallData.description || '',
created_at: event.created_at,
...stallData
}
marketStore.addStall(stall)
}
marketStore.addStall(stall)
// Process any pending products that might match this stall
processPendingProducts()
} catch (err) {
console.warn('Failed to parse stall event:', err)
console.warn('Failed to handle stall event:', err)
}
}
// Handle product events
const handleProductEvent = (event: any) => {
try {
const productData = JSON.parse(event.content)
console.log('Processing product event:', {
productId: productData.id,
productName: productData.name,
stallId: productData.stall_id,
merchantPubkey: event.pubkey
})
// Find stall by stall_id from product data, not by pubkey
const stall = marketStore.stalls.find(s => s.id === productData.stall_id)
if (stall) {
console.log('Found matching stall:', { stallId: stall.id, stallName: stall.name })
const product: Product = {
id: productData.id, // Use product ID from content, not event ID
stall_id: stall.id,
stallName: stall.name,
name: productData.name,
description: productData.description,
price: productData.price,
currency: productData.currency,
quantity: productData.quantity,
images: productData.images,
categories: productData.categories,
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
if (productId) {
const productData = JSON.parse(event.content)
const product = {
id: productId,
stall_id: productData.stall_id || 'unknown',
stallName: productData.stallName || 'Unknown Stall',
pubkey: event.pubkey,
name: productData.name || 'Unnamed Product',
description: productData.description || '',
price: productData.price || 0,
currency: productData.currency || 'sats',
quantity: productData.quantity || 1,
images: productData.images || [],
categories: productData.categories || [],
createdAt: event.created_at,
updatedAt: event.created_at
}
marketStore.addProduct(product)
} else {
console.log('Stall not found yet, queuing product for later processing')
pendingProducts.value.push({ event, productData })
}
} catch (err) {
console.warn('Failed to parse product event:', err)
console.warn('Failed to handle product data:', err)
}
}
// Handle order events
const handleOrderEvent = (event: any) => {
try {
const orderData = JSON.parse(event.content)
// Handle order events (for future order management)
console.log('Order event received:', orderData)
} catch (err) {
console.warn('Failed to parse order event:', err)
}
}
const publishProduct = async (_productData: any) => {
try {
// Note: This would need to be signed with the user's private key
// For now, we'll just log that this function needs to be implemented
console.log('Product publishing not yet implemented - needs private key signing')
} catch (err) {
console.error('Error publishing product:', err)
throw err
}
}
const publishStall = async (_stallData: any) => {
try {
// Note: This would need to be signed with the user's private key
// For now, we'll just log that this function needs to be implemented
console.log('Stall publishing not yet implemented - needs private key signing')
} catch (err) {
console.error('Error publishing stall:', err)
throw err
}
}
const connectToMarket = async () => {
try {
console.log('Checking Nostr connection...')
console.log('Current connection state:', nostrStore.isConnected)
if (!nostrStore.isConnected) {
console.log('Connecting to Nostr relays...')
await nostrStore.connect()
console.log('Connected to Nostr relays')
const order = {
id: event.id,
stall_id: orderData.stall_id || 'unknown',
product_id: orderData.product_id || 'unknown',
buyer_pubkey: event.pubkey,
seller_pubkey: orderData.seller_pubkey || '',
quantity: orderData.quantity || 1,
total_price: orderData.total_price || 0,
currency: orderData.currency || 'sats',
status: orderData.status || 'pending',
payment_request: orderData.payment_request,
created_at: event.created_at,
updated_at: event.created_at
}
isConnected.value = nostrStore.isConnected
console.log('Final connection state:', isConnected.value)
// Note: addOrder method doesn't exist in the store, so we'll just log it
console.log('Received order event:', order)
} catch (err) {
console.warn('Failed to handle order event:', err)
}
}
// Publish a product
const publishProduct = async (_productData: any) => {
// Implementation would depend on your event creation logic
console.log('Publishing product:', _productData)
}
// Publish a stall
const publishStall = async (_stallData: any) => {
// Implementation would depend on your event creation logic
console.log('Publishing stall:', _stallData)
}
// Connect to market
const connectToMarket = async () => {
try {
console.log('Connecting to market...')
// Connect to relay hub
await relayHub.connect()
isConnected.value = relayHub.isConnected.value
if (!isConnected.value) {
throw new Error('Failed to connect to Nostr relays')
}
console.log('Connected to market')
// Load market data
await loadMarketData({
identifier: 'default',
pubkey: nostrStore.account?.pubkey || ''
})
// Load stalls and products
await loadStalls()
await loadProducts()
// Subscribe to updates
subscribeToMarketUpdates()
} catch (err) {
error.value = err instanceof Error ? err : new Error('Failed to connect to market')
console.error('Error connecting to market:', err)
throw err
}
}
// Disconnect from market
const disconnectFromMarket = () => {
// Cleanup subscriptions and connections
isConnected.value = false
error.value = null
console.log('Disconnected from market')
}
// Initialize market on mount
onMounted(async () => {
if (nostrStore.isConnected) {
await connectToMarket()
}
})
// Cleanup on unmount
onUnmounted(() => {
disconnectFromMarket()
})
return {
// State
isLoading: readonly(isLoading),
error: readonly(error),
isConnected: readonly(isConnected),
connectionStatus: readonly(connectionStatus),
activeMarket: readonly(activeMarket),
markets: readonly(markets),
stalls: readonly(stalls),
products: readonly(products),
orders: readonly(orders),
// Actions
loadMarket,
connectToMarket,
disconnectFromMarket,
addSampleProducts,
processPendingProducts,
publishProduct,
publishStall,
subscribeToMarketUpdates

View file

@ -18,7 +18,6 @@ export function useNostr(config?: NostrClientConfig) {
isConnecting,
error,
connect: store.connect,
disconnect: store.disconnect,
getClient: store.getClient
disconnect: store.disconnect
}
}

View file

@ -1,9 +1,10 @@
import { ref, computed, readonly } from 'vue'
import { SimplePool, nip04, finalizeEvent, type EventTemplate } from 'nostr-tools'
import { nip04, finalizeEvent, type EventTemplate } from 'nostr-tools'
import { hexToBytes } from '@/lib/utils/crypto'
import { getAuthToken } from '@/lib/config/lnbits'
import { config } from '@/lib/config'
import { useRelayHub } from './useRelayHub'
// Types
export interface ChatMessage {
@ -63,14 +64,14 @@ const saveUnreadData = (peerPubkey: string, data: UnreadMessageData): void => {
}
export function useNostrChat() {
// Use the centralized relay hub
const relayHub = useRelayHub()
// State
const isConnected = ref(false)
const messages = ref<Map<string, ChatMessage[]>>(new Map())
const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null)
const processedMessageIds = ref(new Set<string>())
const onMessageAdded = ref<((peerPubkey: string) => void) | null>(null)
const pool = ref<SimplePool | null>(null)
// Reactive unread counts
const unreadCounts = ref<Map<string, number>>(new Map())
@ -81,7 +82,8 @@ export function useNostrChat() {
// Store peers globally
const peers = ref<any[]>([])
// Computed
// Computed - use relay hub's connection status
const isConnected = computed(() => relayHub.isConnected.value)
const isLoggedIn = computed(() => !!currentUser.value)
// Get unread count for a peer
@ -103,15 +105,6 @@ export function useNostrChat() {
return total
}
// Reactive computed total unread count
const totalUnreadCount = computed(() => {
let total = 0
for (const count of unreadCounts.value.values()) {
total += count
}
return total
})
// Get latest message timestamp for a peer
const getLatestMessageTimestamp = (peerPubkey: string): number => {
return latestMessageTimestamps.value.get(peerPubkey) || 0
@ -124,238 +117,163 @@ export function useNostrChat() {
// Update latest message timestamp for a peer
const updateLatestMessageTimestamp = (peerPubkey: string, timestamp: number): void => {
const currentLatest = latestMessageTimestamps.value.get(peerPubkey) || 0
if (timestamp > currentLatest) {
const current = latestMessageTimestamps.value.get(peerPubkey) || 0
if (timestamp > current) {
latestMessageTimestamps.value.set(peerPubkey, timestamp)
// Force reactivity
latestMessageTimestamps.value = new Map(latestMessageTimestamps.value)
}
}
// Update unread count for a peer
const updateUnreadCount = (peerPubkey: string, count: number): void => {
if (count > 0) {
unreadCounts.value.set(peerPubkey, count)
} else {
unreadCounts.value.delete(peerPubkey)
}
// Force reactivity
unreadCounts.value = new Map(unreadCounts.value)
const current = unreadCounts.value.get(peerPubkey) || 0
unreadCounts.value.set(peerPubkey, current + count)
// Save to localStorage
const unreadData = getUnreadData(peerPubkey)
unreadData.unreadCount = current + count
saveUnreadData(peerPubkey, unreadData)
}
// Mark messages as read for a peer
const markMessagesAsRead = (peerPubkey: string): void => {
const currentTimestamp = Math.floor(Date.now() / 1000)
// Update last read timestamp, reset unread count, and clear processed message IDs
const updatedData: UnreadMessageData = {
lastReadTimestamp: currentTimestamp,
unreadCount: 0,
processedMessageIds: new Set() // Clear processed messages when marking as read
const current = unreadCounts.value.get(peerPubkey) || 0
if (current > 0) {
unreadCounts.value.set(peerPubkey, 0)
// Save to localStorage
const unreadData = getUnreadData(peerPubkey)
unreadData.unreadCount = 0
unreadData.lastReadTimestamp = Date.now()
saveUnreadData(peerPubkey, unreadData)
}
saveUnreadData(peerPubkey, updatedData)
updateUnreadCount(peerPubkey, 0)
}
// Load unread counts from localStorage
const loadUnreadCounts = (): void => {
try {
const keys = Object.keys(localStorage).filter(key =>
key.startsWith(`${UNREAD_MESSAGES_KEY}-`)
)
console.log('Loading unread counts from localStorage. Found keys:', keys)
for (const key of keys) {
const peerPubkey = key.replace(`${UNREAD_MESSAGES_KEY}-`, '')
const unreadData = getUnreadData(peerPubkey)
console.log(`Peer ${peerPubkey}:`, {
lastReadTimestamp: unreadData.lastReadTimestamp,
unreadCount: unreadData.unreadCount,
processedMessageIdsCount: unreadData.processedMessageIds.size
})
if (unreadData.unreadCount > 0) {
unreadCounts.value.set(peerPubkey, unreadData.unreadCount)
}
}
} catch (error) {
console.warn('Failed to load unread counts from localStorage:', error)
}
}
// const loadUnreadCounts = (): void => {
// try {
// // Load unread counts for all peers we have messages for
// for (const [peerPubkey] of messages.value) {
// const unreadData = getUnreadData(peerPubkey)
// if (unreadData.unreadCount > 0) {
// unreadCounts.value.set(peerPubkey, unreadData.unreadCount)
// }
// }
// } catch (error) {
// console.warn('Failed to load unread counts:', error)
// }
// }
// Initialize unread counts on startup
loadUnreadCounts()
// Clear unread count for a peer
// const clearUnreadCount = (peerPubkey: string): void => {
// unreadCounts.value.delete(peerPubkey)
//
// // Clear from localStorage
// const unreadData = getUnreadData(peerPubkey)
// unreadData.unreadCount = 0
// saveUnreadData(peerPubkey, unreadData)
// }
// Clear all unread counts (for testing)
// Clear all unread counts
const clearAllUnreadCounts = (): void => {
unreadCounts.value.clear()
unreadCounts.value = new Map(unreadCounts.value)
// Also clear from localStorage
try {
const keys = Object.keys(localStorage).filter(key =>
key.startsWith(`${UNREAD_MESSAGES_KEY}-`)
)
for (const key of keys) {
localStorage.removeItem(key)
}
} catch (error) {
console.warn('Failed to clear unread counts from localStorage:', error)
// Clear from localStorage for all peers
for (const [peerPubkey] of messages.value) {
const unreadData = getUnreadData(peerPubkey)
unreadData.unreadCount = 0
saveUnreadData(peerPubkey, unreadData)
}
}
// Clear processed message IDs for a specific peer (useful for debugging)
// Clear processed message IDs for a peer
const clearProcessedMessageIds = (peerPubkey: string): void => {
try {
const unreadData = getUnreadData(peerPubkey)
const updatedData: UnreadMessageData = {
...unreadData,
processedMessageIds: new Set()
}
saveUnreadData(peerPubkey, updatedData)
console.log(`Cleared processed message IDs for peer: ${peerPubkey}`)
} catch (error) {
console.warn('Failed to clear processed message IDs for peer:', peerPubkey, error)
}
const unreadData = getUnreadData(peerPubkey)
unreadData.processedMessageIds.clear()
saveUnreadData(peerPubkey, unreadData)
}
// Debug function to show current state of unread data for a peer
// Debug unread data for a peer
const debugUnreadData = (peerPubkey: string): void => {
try {
const unreadData = getUnreadData(peerPubkey)
console.log(`Debug unread data for ${peerPubkey}:`, {
lastReadTimestamp: unreadData.lastReadTimestamp,
unreadCount: unreadData.unreadCount,
processedMessageIds: Array.from(unreadData.processedMessageIds),
processedMessageIdsCount: unreadData.processedMessageIds.size
})
} catch (error) {
console.warn('Failed to debug unread data for peer:', peerPubkey, error)
}
const unreadData = getUnreadData(peerPubkey)
console.log(`Unread data for ${peerPubkey}:`, unreadData)
}
// Get relays from config - requires VITE_NOSTR_RELAYS to be set
// Get relay configuration
const getRelays = (): NostrRelayConfig[] => {
const configuredRelays = config.nostr.relays
if (!configuredRelays || configuredRelays.length === 0) {
throw new Error('VITE_NOSTR_RELAYS environment variable must be configured for chat functionality')
}
return configuredRelays.map((url: string) => ({ url, read: true, write: true }))
return config.nostr.relays.map(url => ({
url,
read: true,
write: true
}))
}
// Initialize Nostr pool
const initializePool = () => {
if (!pool.value) {
pool.value = new SimplePool()
}
}
// Connect to relays
const connectToRelay = async (url: string): Promise<any> => {
try {
initializePool()
const relay = pool.value!.ensureRelay(url)
console.log(`Connected to relay: ${url}`)
return relay
} catch (error) {
console.error(`Failed to connect to ${url}:`, error)
return null
}
}
// Connect to all relays
// Connect using the relay hub
const connect = async () => {
try {
// Get current user from LNBits
await loadCurrentUser()
if (!currentUser.value) {
console.warn('No user logged in - chat functionality will be limited')
// Don't throw error, just continue without user data
// The chat will still work for viewing messages, but sending will fail
// The relay hub should already be initialized by the app
if (!relayHub.isConnected.value) {
await relayHub.connect()
}
// Initialize pool
initializePool()
// Connect to relays
const relayConfigs = getRelays()
const relays = await Promise.all(
relayConfigs.map(relay => connectToRelay(relay.url))
)
const connectedRelays = relays.filter(relay => relay !== null)
isConnected.value = connectedRelays.length > 0
console.log(`Connected to ${connectedRelays.length} relays`)
console.log('Connected to relays via RelayHub')
} catch (error) {
console.error('Failed to connect:', error)
// Don't throw error, just log it and continue
// This allows the chat to still work for viewing messages
}
}
// Disconnect from relays
const disconnect = () => {
if (pool.value) {
const relayConfigs = getRelays()
pool.value.close(relayConfigs.map(r => r.url))
pool.value = null
}
isConnected.value = false
messages.value.clear()
processedMessageIds.value.clear()
}
// Load current user from LNBits
const loadCurrentUser = async () => {
try {
// Get current user from LNBits API using the auth endpoint
const authToken = getAuthToken()
if (!authToken) {
throw new Error('No authentication token found')
}
const API_BASE_URL = config.api.baseUrl || 'http://localhost:5006'
const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/me`, {
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
}
})
console.log('API Response status:', response.status)
console.log('API Response headers:', response.headers)
const responseText = await response.text()
console.log('API Response text:', responseText)
if (response.ok) {
try {
const user = JSON.parse(responseText)
currentUser.value = {
pubkey: user.pubkey,
prvkey: user.prvkey
}
} catch (parseError) {
console.error('JSON Parse Error:', parseError)
console.error('Response was:', responseText)
throw new Error('Invalid JSON response from API')
}
} else {
console.error('API Error:', response.status, responseText)
throw new Error(`Failed to load current user: ${response.status}`)
}
} catch (error) {
console.error('Failed to load current user:', error)
console.error('Failed to connect to relays:', error)
throw error
}
}
// Disconnect using the relay hub
const disconnect = () => {
// Note: We don't disconnect the relay hub here as other components might be using it
// The relay hub will be managed at the app level
console.log('Chat disconnected from relays (relay hub remains active)')
}
// Load current user from LNBits
// const loadCurrentUser = async () => {
// try {
// // Get current user from LNBits API using the auth endpoint
// const authToken = getAuthToken()
// if (!authToken) {
// throw new Error('No authentication token found')
// }
// const API_BASE_URL = config.api.baseUrl || 'http://localhost:5006'
// const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/me`, {
// headers: {
// 'Authorization': `Bearer ${authToken}`,
// 'Content-Type': 'application/json'
// }
// })
// console.log('API Response status:', response.status)
// console.log('API Response headers:', response.headers)
// const responseText = await response.text()
// console.log('API Response text:', responseText)
// if (response.ok) {
// try {
// const user = JSON.parse(responseText)
// currentUser.value = {
// pubkey: user.pubkey,
// prvkey: user.prvkey
// }
// } catch (parseError) {
// console.error('JSON Parse Error:', parseError)
// console.error('Response was:', responseText)
// throw new Error('Invalid JSON response from API')
// }
// } else {
// console.error('API Error:', response.status, responseText)
// throw new Error(`Failed to load current user: ${response.status}`)
// }
// } catch (error) {
// console.error('Failed to load current user:', error)
// throw error
// }
// }
// Subscribe to messages from a specific peer
const subscribeToPeer = async (peerPubkey: string) => {
if (!currentUser.value) {
@ -364,17 +282,12 @@ export function useNostrChat() {
}
// Check if we have a pool and are connected
if (!pool.value) {
console.warn('No pool available - initializing...')
initializePool()
}
if (!isConnected.value) {
if (!relayHub.isConnected.value) {
console.warn('Not connected to relays - attempting to connect...')
await connect()
}
if (!pool.value) {
if (!relayHub.isConnected.value) {
throw new Error('Failed to initialize Nostr pool')
}
@ -398,9 +311,9 @@ export function useNostrChat() {
}
])
const sub = pool.value.subscribeMany(
relayConfigs.map(r => r.url),
[
const unsubscribe = relayHub.subscribe({
id: `peer-${peerPubkey}-${Date.now()}`,
filters: [
{
kinds: [4],
authors: [peerPubkey],
@ -412,18 +325,17 @@ export function useNostrChat() {
'#p': [peerPubkey]
}
],
{
onevent(event) {
console.log('Received live event:', event.id, 'author:', event.pubkey)
handleIncomingMessage(event, peerPubkey)
},
oneose() {
console.log('Subscription closed for peer:', peerPubkey)
}
relays: relayConfigs.map(r => r.url),
onEvent: (event: any) => {
console.log('Received live event:', event.id, 'author:', event.pubkey)
handleIncomingMessage(event, peerPubkey)
},
onEose: () => {
console.log('Subscription closed for peer:', peerPubkey)
}
)
})
return sub
return unsubscribe
}
// Subscribe to a peer for notifications only (without loading full message history)
@ -437,17 +349,12 @@ export function useNostrChat() {
}
// Check if we have a pool and are connected
if (!pool.value) {
console.warn('No pool available - initializing...')
initializePool()
}
if (!isConnected.value) {
if (!relayHub.isConnected.value) {
console.warn('Not connected to relays - attempting to connect...')
await connect()
}
if (!pool.value) {
if (!relayHub.isConnected.value) {
throw new Error('Failed to initialize Nostr pool')
}
@ -474,29 +381,28 @@ export function useNostrChat() {
console.log('Notification subscription filters:', JSON.stringify(filters, null, 2))
const sub = pool.value.subscribeMany(
relayConfigs.map(r => r.url),
const unsubscribe = relayHub.subscribe({
id: `notifications-${peerPubkey}-${Date.now()}`,
filters,
{
onevent(event) {
console.log('Received notification event:', {
id: event.id,
author: event.pubkey,
forPeer: peerPubkey,
tags: event.tags,
contentLength: event.content?.length || 0
})
handleIncomingMessage(event, peerPubkey)
},
oneose() {
console.log('Notification subscription closed for peer:', peerPubkey)
}
relays: relayConfigs.map(r => r.url),
onEvent: (event: any) => {
console.log('Received notification event:', {
id: event.id,
author: event.pubkey,
forPeer: peerPubkey,
tags: event.tags,
contentLength: event.content?.length || 0
})
handleIncomingMessage(event, peerPubkey)
},
onEose: () => {
console.log('Notification subscription closed for peer:', peerPubkey)
}
)
})
console.log('Successfully created notification subscription for peer:', peerPubkey)
console.log('=== SUBSCRIBE TO PEER FOR NOTIFICATIONS END ===')
return sub
return unsubscribe
}
// Load historical messages for a peer
@ -522,28 +428,27 @@ export function useNostrChat() {
console.log('Historical query filters:', filters)
const historicalSub = pool.value!.subscribeMany(
relayConfigs.map(r => r.url),
const unsubscribe = relayHub.subscribe({
id: `historical-${peerPubkey}-${Date.now()}`,
filters,
{
onevent(event) {
console.log('Received historical event:', {
id: event.id,
author: event.pubkey,
isSentByMe: event.pubkey === myPubkey,
contentLength: event.content.length
})
handleIncomingMessage(event, peerPubkey)
},
oneose() {
console.log('Historical query completed for peer:', peerPubkey)
}
relays: relayConfigs.map(r => r.url),
onEvent: (event: any) => {
console.log('Received historical event:', {
id: event.id,
author: event.pubkey,
isSentByMe: event.pubkey === myPubkey,
contentLength: event.content.length
})
handleIncomingMessage(event, peerPubkey)
},
onEose: () => {
console.log('Historical query completed for peer:', peerPubkey)
}
)
})
// Wait a bit for historical messages to load
await new Promise(resolve => setTimeout(resolve, 3000))
historicalSub.close()
unsubscribe()
console.log('Historical query closed for peer:', peerPubkey)
}
@ -683,17 +588,12 @@ export function useNostrChat() {
}
// Check if we have a pool and are connected
if (!pool.value) {
console.warn('No pool available - initializing...')
initializePool()
}
if (!isConnected.value) {
if (!relayHub.isConnected.value) {
console.warn('Not connected to relays - attempting to connect...')
await connect()
}
if (!pool.value) {
if (!relayHub.isConnected.value) {
throw new Error('Failed to initialize Nostr pool')
}
@ -754,13 +654,8 @@ export function useNostrChat() {
// Finalize the event (sign it)
const event = finalizeEvent(eventTemplate, hexToBytes(privateKey))
// Publish to relays
const relayConfigs = getRelays()
const publishPromises = relayConfigs.map(relay => {
return pool.value!.publish([relay.url], event)
})
await Promise.all(publishPromises)
// Publish to relays using the relay hub
await relayHub.publishEvent(event)
// Add message to local state
const message: ChatMessage = {
@ -857,12 +752,12 @@ export function useNostrChat() {
}
// Wait for connection to be established
if (!isConnected.value) {
if (!relayHub.isConnected.value) {
console.log('Waiting for connection to be established before subscribing to peers')
// Wait a bit for connection to establish
await new Promise(resolve => setTimeout(resolve, 1000))
if (!isConnected.value) {
if (!relayHub.isConnected.value) {
console.warn('Still not connected, skipping peer subscriptions')
return
}
@ -890,7 +785,7 @@ export function useNostrChat() {
peers: readonly(peers),
// Reactive computed properties
totalUnreadCount: readonly(totalUnreadCount),
totalUnreadCount: computed(() => getTotalUnreadCount()),
// Methods
connect,

View file

@ -1,6 +1,6 @@
import { ref, readonly } from 'vue'
import type { NostrNote } from '@/lib/nostr/client'
import { useNostr } from '@/composables/useNostr'
import { useRelayHub } from '@/composables/useRelayHub'
import { useNostrStore } from '@/stores/nostr'
import { config as globalConfig } from '@/lib/config'
import { notificationManager } from '@/lib/notifications/manager'
@ -13,7 +13,7 @@ export interface NostrFeedConfig {
}
export function useNostrFeed(config: NostrFeedConfig = {}) {
const { getClient } = useNostr(config.relays ? { relays: config.relays } : undefined)
const relayHub = useRelayHub()
const nostrStore = useNostrStore()
// State
@ -71,17 +71,16 @@ export function useNostrFeed(config: NostrFeedConfig = {}) {
error.value = null
// Connect to Nostr
const client = getClient()
await client.connect()
isConnected.value = client.isConnected
// Connect to Nostr using the centralized relay hub
await relayHub.connect()
isConnected.value = relayHub.isConnected.value
if (!isConnected.value) {
throw new Error('Failed to connect to Nostr relays')
}
// Configure fetch options based on feed type
const fetchOptions: Parameters<typeof client.fetchNotes>[0] = {
const fetchOptions: any = {
limit: config.limit || 50,
includeReplies: config.includeReplies || false
}
@ -96,8 +95,14 @@ export function useNostrFeed(config: NostrFeedConfig = {}) {
}
}
// Fetch new notes
const newNotes = await client.fetchNotes(fetchOptions)
// Fetch new notes using the relay hub
const newNotes = await relayHub.queryEvents([
{
kinds: [1], // TEXT_NOTE
limit: fetchOptions.limit,
authors: fetchOptions.authors
}
])
// Client-side filtering for 'general' feed (exclude admin posts)
let filteredNotes = newNotes
@ -147,35 +152,37 @@ export function useNostrFeed(config: NostrFeedConfig = {}) {
const subscribeToFeedUpdates = () => {
try {
const client = getClient()
// Subscribe to real-time notes
unsubscribe = client.subscribeToNotes((newNote) => {
// Only process notes newer than last seen
if (newNote.created_at > lastSeenTimestamp) {
// Check if note should be included based on feed type
const shouldInclude = shouldIncludeNote(newNote)
if (shouldInclude) {
// Add to beginning of notes array
notes.value.unshift(newNote)
// Limit the array size to prevent memory issues
if (notes.value.length > 100) {
notes.value = notes.value.slice(0, 100)
}
// Subscribe to real-time notes using the relay hub
unsubscribe = relayHub.subscribe({
id: `feed-${config.feedType || 'all'}`,
filters: [{ kinds: [1] }], // TEXT_NOTE
onEvent: (event: any) => {
// Only process notes newer than last seen
if (event.created_at > lastSeenTimestamp) {
// Check if note should be included based on feed type
const shouldInclude = shouldIncludeNote(event)
if (shouldInclude) {
// Add to beginning of notes array
notes.value.unshift(event)
// Limit the array size to prevent memory issues
if (notes.value.length > 100) {
notes.value = notes.value.slice(0, 100)
}
// Save to localStorage
const storageKey = `nostr-feed-${config.feedType || 'all'}`
localStorage.setItem(storageKey, JSON.stringify(notes.value))
// Save to localStorage
const storageKey = `nostr-feed-${config.feedType || 'all'}`
localStorage.setItem(storageKey, JSON.stringify(notes.value))
}
// Send notification if appropriate (only for admin announcements when not in announcements feed)
if (config.feedType !== 'announcements' && adminPubkeys.includes(event.pubkey)) {
notificationManager.notifyForNote(event, nostrStore.account?.pubkey)
}
// Update last seen timestamp
lastSeenTimestamp = Math.max(lastSeenTimestamp, event.created_at)
}
// Send notification if appropriate (only for admin announcements when not in announcements feed)
if (config.feedType !== 'announcements' && adminPubkeys.includes(newNote.pubkey)) {
notificationManager.notifyForNote(newNote, nostrStore.account?.pubkey)
}
// Update last seen timestamp
lastSeenTimestamp = Math.max(lastSeenTimestamp, newNote.created_at)
}
})
} catch (error) {
@ -197,9 +204,8 @@ export function useNostrFeed(config: NostrFeedConfig = {}) {
const connectToFeed = async () => {
try {
console.log('Connecting to Nostr feed...')
const client = getClient()
await client.connect()
isConnected.value = client.isConnected
await relayHub.connect()
isConnected.value = relayHub.isConnected.value
console.log('Connected to Nostr feed')
} catch (err) {
console.error('Error connecting to feed:', err)

View file

@ -0,0 +1,267 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { relayHub, type SubscriptionConfig, type RelayStatus } from '../lib/nostr/relayHub'
import { config } from '../lib/config'
export function useRelayHub() {
// Reactive state
const isConnected = ref(false)
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected')
const relayStatuses = ref<RelayStatus[]>([])
const error = ref<Error | null>(null)
const activeSubscriptions = ref<Set<string>>(new Set())
// Computed properties
const connectedRelayCount = computed(() => relayHub.connectedRelayCount)
const totalRelayCount = computed(() => relayHub.totalRelayCount)
const connectionHealth = computed(() => {
if (totalRelayCount.value === 0) return 0
return (connectedRelayCount.value / totalRelayCount.value) * 100
})
// Initialize relay hub
const initialize = async (): Promise<void> => {
try {
connectionStatus.value = 'connecting'
error.value = null
// Get relay URLs from config
const relayUrls = config.nostr.relays
if (!relayUrls || relayUrls.length === 0) {
throw new Error('No relay URLs configured')
}
// Initialize the relay hub
await relayHub.initialize(relayUrls)
// Set up event listeners
setupEventListeners()
connectionStatus.value = 'connected'
isConnected.value = true
console.log('RelayHub initialized successfully')
} catch (err) {
const errorObj = err instanceof Error ? err : new Error('Failed to initialize RelayHub')
error.value = errorObj
connectionStatus.value = 'error'
isConnected.value = false
console.error('Failed to initialize RelayHub:', errorObj)
throw errorObj
}
}
// Connect to relays
const connect = async (): Promise<void> => {
try {
if (!relayHub.isInitialized) {
await initialize()
return
}
connectionStatus.value = 'connecting'
error.value = null
await relayHub.connect()
connectionStatus.value = 'connected'
isConnected.value = true
} catch (err) {
const errorObj = err instanceof Error ? err : new Error('Failed to connect')
error.value = errorObj
connectionStatus.value = 'error'
isConnected.value = false
throw errorObj
}
}
// Disconnect from relays
const disconnect = (): void => {
relayHub.disconnect()
connectionStatus.value = 'disconnected'
isConnected.value = false
error.value = null
}
// Subscribe to events
const subscribe = (config: SubscriptionConfig): (() => void) => {
try {
const unsubscribe = relayHub.subscribe(config)
activeSubscriptions.value.add(config.id)
// Return enhanced unsubscribe function
return () => {
unsubscribe()
activeSubscriptions.value.delete(config.id)
}
} catch (err) {
const errorObj = err instanceof Error ? err : new Error('Failed to subscribe')
error.value = errorObj
throw errorObj
}
}
// Publish an event
const publishEvent = async (event: any): Promise<{ success: number; total: number }> => {
try {
return await relayHub.publishEvent(event)
} catch (err) {
const errorObj = err instanceof Error ? err : new Error('Failed to publish event')
error.value = errorObj
throw errorObj
}
}
// Query events (one-time fetch)
const queryEvents = async (filters: any[], relays?: string[]): Promise<any[]> => {
try {
return await relayHub.queryEvents(filters, relays)
} catch (err) {
const errorObj = err instanceof Error ? err : new Error('Failed to query events')
error.value = errorObj
throw errorObj
}
}
// Force reconnection
const reconnect = async (): Promise<void> => {
try {
connectionStatus.value = 'connecting'
error.value = null
await relayHub.reconnect()
connectionStatus.value = 'connected'
isConnected.value = true
} catch (err) {
const errorObj = err instanceof Error ? err : new Error('Failed to reconnect')
error.value = errorObj
connectionStatus.value = 'error'
isConnected.value = false
throw errorObj
}
}
// Get relay status
const getRelayStatus = (url: string): RelayStatus | undefined => {
return relayStatuses.value.find(status => status.url === url)
}
// Check if a specific relay is connected
const isRelayConnected = (url: string): boolean => {
return relayHub.isRelayConnected(url)
}
// Setup event listeners for the relay hub
const setupEventListeners = (): void => {
// Connection events
relayHub.on('connected', (count: number) => {
console.log(`Connected to ${count} relays`)
isConnected.value = true
connectionStatus.value = 'connected'
error.value = null
})
relayHub.on('disconnected', () => {
console.log('Disconnected from all relays')
isConnected.value = false
connectionStatus.value = 'disconnected'
})
relayHub.on('connectionError', (err: Error) => {
console.error('Connection error:', err)
error.value = err
connectionStatus.value = 'error'
isConnected.value = false
})
relayHub.on('allRelaysDisconnected', () => {
console.warn('All relays disconnected')
isConnected.value = false
connectionStatus.value = 'disconnected'
})
relayHub.on('partialDisconnection', ({ connected, total }: { connected: number; total: number }) => {
console.warn(`Partial disconnection: ${connected}/${total} relays connected`)
isConnected.value = connected > 0
connectionStatus.value = connected > 0 ? 'connected' : 'disconnected'
})
relayHub.on('maxReconnectAttemptsReached', () => {
console.error('Max reconnection attempts reached')
connectionStatus.value = 'error'
isConnected.value = false
error.value = new Error('Max reconnection attempts reached')
})
relayHub.on('networkOffline', () => {
console.log('Network went offline')
connectionStatus.value = 'disconnected'
isConnected.value = false
})
// Update relay statuses periodically
const updateRelayStatuses = () => {
relayStatuses.value = relayHub.relayStatuses
}
// Update immediately and then every 10 seconds
updateRelayStatuses()
const statusInterval = setInterval(updateRelayStatuses, 10000)
// Cleanup interval on unmount
onUnmounted(() => {
clearInterval(statusInterval)
})
}
// Cleanup function
const cleanup = (): void => {
// Close all active subscriptions
activeSubscriptions.value.forEach(subId => {
relayHub.unsubscribe(subId)
})
activeSubscriptions.value.clear()
}
// Auto-initialize on mount if config is available
onMounted(async () => {
try {
if (config.nostr.relays && config.nostr.relays.length > 0) {
await initialize()
}
} catch (err) {
console.warn('Auto-initialization failed:', err)
}
})
// Cleanup on unmount
onUnmounted(() => {
cleanup()
})
return {
// State
isConnected,
connectionStatus,
relayStatuses,
error,
activeSubscriptions,
// Computed
connectedRelayCount,
totalRelayCount,
connectionHealth,
// Methods
initialize,
connect,
disconnect,
subscribe,
publishEvent,
queryEvents,
reconnect,
getRelayStatus,
isRelayConnected,
cleanup
}
}

View file

@ -3,12 +3,10 @@ import type { NostrNote } from '@/lib/nostr/client'
import { createTextNote, createReaction, createProfileMetadata } from '@/lib/nostr/events'
import { identity } from '@/composables/useIdentity'
import { toast } from 'vue-sonner'
import { useRelayHub } from './useRelayHub'
import { useNostr } from './useNostr'
export function useSocial(relayUrls?: string[]) {
const { getClient } = useNostr(relayUrls ? { relays: relayUrls } : undefined)
const client = getClient()
export function useSocial() {
const relayHub = useRelayHub()
const isPublishing = ref(false)
const profiles = ref(new Map<string, any>())
@ -23,9 +21,9 @@ export function useSocial(relayUrls?: string[]) {
try {
isPublishing.value = true
await client.connect()
await relayHub.connect()
const event = createTextNote(content, identity.currentIdentity.value, replyTo)
await client.publishEvent(event)
await relayHub.publishEvent(event)
toast.success(replyTo ? 'Reply published!' : 'Note published!')
} catch (error) {
@ -46,9 +44,9 @@ export function useSocial(relayUrls?: string[]) {
}
try {
await client.connect()
await relayHub.connect()
const event = createReaction(targetEventId, targetAuthor, reaction, identity.currentIdentity.value)
await client.publishEvent(event)
await relayHub.publishEvent(event)
toast.success('Reaction added!')
} catch (error) {
@ -69,9 +67,9 @@ export function useSocial(relayUrls?: string[]) {
try {
isPublishing.value = true
await client.connect()
await relayHub.connect()
const event = createProfileMetadata(profileData, identity.currentIdentity.value)
await client.publishEvent(event)
await relayHub.publishEvent(event)
toast.success('Profile updated on Nostr!')
} catch (error) {
@ -88,8 +86,13 @@ export function useSocial(relayUrls?: string[]) {
*/
async function fetchReplies(noteId: string): Promise<NostrNote[]> {
try {
await client.connect()
return await client.fetchReplies(noteId)
await relayHub.connect()
return await relayHub.queryEvents([
{
kinds: [1], // TEXT_NOTE
'#e': [noteId] // Reply to specific event
}
])
} catch (error) {
console.error('Failed to fetch replies:', error)
throw error
@ -106,12 +109,22 @@ export function useSocial(relayUrls?: string[]) {
if (uncachedPubkeys.length === 0) return
try {
await client.connect()
const fetchedProfiles = await client.fetchProfiles(uncachedPubkeys)
await relayHub.connect()
const fetchedProfiles = await relayHub.queryEvents([
{
kinds: [0], // PROFILE_METADATA
authors: uncachedPubkeys
}
])
// Update cache
fetchedProfiles.forEach((profile, pubkey) => {
profiles.value.set(pubkey, profile)
// Update cache - convert events to profile map
fetchedProfiles.forEach((event) => {
try {
const profileData = JSON.parse(event.content)
profiles.value.set(event.pubkey, profileData)
} catch (e) {
console.warn('Failed to parse profile data for', event.pubkey)
}
})
} catch (error) {
console.error('Failed to fetch profiles:', error)